fix(palette): commit recent jumps before closing
- commit
- 38162d75c060cf8c948e936abf27fd375fc6acae
- author
- brice · · 2026-07-11 12:19 UTC
- parent
- 59a790d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +146 -21
M internal/usecase/daemon/daemon.go +3 -0
| @@ -114,6 +114,9 @@ type Daemon struct { | |||
| 114 | 114 | ||
| 115 | 115 | paletteRecentMu sync.Mutex | |
| 116 | 116 | paletteRecent []string | |
| 117 | + | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | |
| 118 | + | // interval between JRS validation and its committed hand-off. | |
| 119 | + | beforeRecentSessionHandoff func() | |
| 117 | 120 | ||
| 118 | 121 | ptys ports.PTYFactory | |
| 119 | 122 | clock ports.Clock | |
| @@ -114,6 +114,9 @@ type Daemon struct { | |||||
| 114 | 114 | ||||
| 115 | paletteRecentMu sync.Mutex | 115 | paletteRecentMu sync.Mutex | ||
| 116 | paletteRecent []string | 116 | paletteRecent []string | ||
| 117 | + | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | |||
| 118 | + | // interval between JRS validation and its committed hand-off. | |||
| 119 | + | beforeRecentSessionHandoff func() | |||
| 117 | 120 | ||||
| 118 | ptys ports.PTYFactory | 121 | ptys ports.PTYFactory | ||
| 119 | clock ports.Clock | 122 | clock ports.Clock | ||
M internal/usecase/daemon/palette.go +36 -11
| @@ -134,16 +134,29 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 134 | 134 | down: func() { ac.overlays.palette.Down(); changed = true }, | |
| 135 | 135 | cancel: func() { cancel = true }, | |
| 136 | 136 | enter: func() { | |
| 137 | - | cmd, _ = ac.overlays.palette.Selected() | |
| 138 | - | action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()) | |
| 139 | - | if !valid { | |
| 137 | + | var selected bool | |
| 138 | + | cmd, selected = ac.overlays.palette.Selected() | |
| 139 | + | if !selected { | |
| 140 | 140 | ac.overlays.paletteFeedback = "invalid command arguments" | |
| 141 | 141 | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | |
| 142 | 142 | changed = true | |
| 143 | 143 | return | |
| 144 | 144 | } | |
| 145 | - | args = action.Args | |
| 146 | 145 | rawQuery = ac.overlays.palette.Query() | |
| 146 | + | // JRS is contextual: it is executable only from its exact token so | |
| 147 | + | // fuzzy selection can never turn an unrelated query into a jump. | |
| 148 | + | // Static commands retain normal palette behavior and run the selected | |
| 149 | + | // fuzzy match. | |
| 150 | + | if cmd.Code == "JRS" { | |
| 151 | + | action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery) | |
| 152 | + | if !valid { | |
| 153 | + | ac.overlays.paletteFeedback = "invalid command arguments" | |
| 154 | + | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | |
| 155 | + | changed = true | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | args = action.Args | |
| 159 | + | } | |
| 147 | 160 | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |
| 148 | 161 | generation = ac.overlays.paletteGeneration | |
| 149 | 162 | execute = true | |
| @@ -179,18 +192,25 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 179 | 192 | // by a newly-ranked session, so MRU changes cannot shift a requested rank. | |
| 180 | 193 | if cmd.Code == "JRS" { | |
| 181 | 194 | rank, err := command.ParsePositiveDecimal(args) | |
| 182 | - | if err != nil || rank > len(recent) || d.sessionByID(recent[rank-1].id) == nil { | |
| 195 | + | if err != nil { | |
| 183 | 196 | d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable") | |
| 184 | 197 | d.paint(sess, ac, true) | |
| 185 | 198 | return | |
| 186 | 199 | } | |
| 187 | - | // Publication remains generation-safe; close only if this is still the | |
| 188 | - | // same exact palette interaction and raw argument token. | |
| 189 | - | if !d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 200 | + | // The captured ID is handed off atomically. A target can disappear after | |
| 201 | + | // the validation above, so closing is contingent on the committed switch. | |
| 202 | + | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | |
| 203 | + | if err := exec.JumpRecentSession(rank); err != nil { | |
| 204 | + | d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable") | |
| 205 | + | d.paint(sess, ac, true) | |
| 190 | 206 | return | |
| 191 | 207 | } | |
| 192 | - | d.switchToTarget(sess, ac, picker.Target{Session: recent[rank-1].id, TabIndex: -1}) | |
| 193 | - | d.recordPaletteUse(cmd.Code) | |
| 208 | + | // Publication remains generation-safe; do not let stale work close a | |
| 209 | + | // newer palette interaction. | |
| 210 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 211 | + | d.recordPaletteUse(cmd.Code) | |
| 212 | + | d.paint(ac.currentSession(), ac, true) | |
| 213 | + | } | |
| 194 | 214 | return | |
| 195 | 215 | } | |
| 196 | 216 | if !d.closeExecutedPalette(ac, generation, rawQuery) { | |
| @@ -381,7 +401,12 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 381 | 401 | if e.d.sessionByID(target.id) == nil { | |
| 382 | 402 | return command.ErrInvalidArguments | |
| 383 | 403 | } | |
| 384 | - | e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | |
| 404 | + | if e.d.beforeRecentSessionHandoff != nil { | |
| 405 | + | e.d.beforeRecentSessionHandoff() | |
| 406 | + | } | |
| 407 | + | if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) { | |
| 408 | + | return command.ErrInvalidArguments | |
| 409 | + | } | |
| 385 | 410 | return nil | |
| 386 | 411 | } | |
| 387 | 412 | ||
| @@ -134,16 +134,29 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 134 | down: func() { ac.overlays.palette.Down(); changed = true }, | 134 | down: func() { ac.overlays.palette.Down(); changed = true }, | ||
| 135 | cancel: func() { cancel = true }, | 135 | cancel: func() { cancel = true }, | ||
| 136 | enter: func() { | 136 | enter: func() { | ||
| 137 | - | cmd, _ = ac.overlays.palette.Selected() | 137 | + | var selected bool |
| 138 | - | action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()) | 138 | + | cmd, selected = ac.overlays.palette.Selected() |
| 139 | - | if !valid { | 139 | + | if !selected { |
| 140 | ac.overlays.paletteFeedback = "invalid command arguments" | 140 | ac.overlays.paletteFeedback = "invalid command arguments" | ||
| 141 | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | 141 | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | ||
| 142 | changed = true | 142 | changed = true | ||
| 143 | return | 143 | return | ||
| 144 | } | 144 | } | ||
| 145 | - | args = action.Args | |||
| 146 | rawQuery = ac.overlays.palette.Query() | 145 | rawQuery = ac.overlays.palette.Query() | ||
| 146 | + | // JRS is contextual: it is executable only from its exact token so | |||
| 147 | + | // fuzzy selection can never turn an unrelated query into a jump. | |||
| 148 | + | // Static commands retain normal palette behavior and run the selected | |||
| 149 | + | // fuzzy match. | |||
| 150 | + | if cmd.Code == "JRS" { | |||
| 151 | + | action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery) | |||
| 152 | + | if !valid { | |||
| 153 | + | ac.overlays.paletteFeedback = "invalid command arguments" | |||
| 154 | + | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | |||
| 155 | + | changed = true | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | args = action.Args | |||
| 159 | + | } | |||
| 147 | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | 160 | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | ||
| 148 | generation = ac.overlays.paletteGeneration | 161 | generation = ac.overlays.paletteGeneration | ||
| 149 | execute = true | 162 | execute = true | ||
| @@ -179,18 +192,25 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 179 | // by a newly-ranked session, so MRU changes cannot shift a requested rank. | 192 | // by a newly-ranked session, so MRU changes cannot shift a requested rank. | ||
| 180 | if cmd.Code == "JRS" { | 193 | if cmd.Code == "JRS" { | ||
| 181 | rank, err := command.ParsePositiveDecimal(args) | 194 | rank, err := command.ParsePositiveDecimal(args) | ||
| 182 | - | if err != nil || rank > len(recent) || d.sessionByID(recent[rank-1].id) == nil { | 195 | + | if err != nil { |
| 183 | d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable") | 196 | d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable") | ||
| 184 | d.paint(sess, ac, true) | 197 | d.paint(sess, ac, true) | ||
| 185 | return | 198 | return | ||
| 186 | } | 199 | } | ||
| 187 | - | // Publication remains generation-safe; close only if this is still the | 200 | + | // The captured ID is handed off atomically. A target can disappear after |
| 188 | - | // same exact palette interaction and raw argument token. | 201 | + | // the validation above, so closing is contingent on the committed switch. |
| 189 | - | if !d.closeExecutedPalette(ac, generation, rawQuery) { | 202 | + | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} |
| 203 | + | if err := exec.JumpRecentSession(rank); err != nil { | |||
| 204 | + | d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable") | |||
| 205 | + | d.paint(sess, ac, true) | |||
| 190 | return | 206 | return | ||
| 191 | } | 207 | } | ||
| 192 | - | d.switchToTarget(sess, ac, picker.Target{Session: recent[rank-1].id, TabIndex: -1}) | 208 | + | // Publication remains generation-safe; do not let stale work close a |
| 193 | - | d.recordPaletteUse(cmd.Code) | 209 | + | // newer palette interaction. |
| 210 | + | if d.closeExecutedPalette(ac, generation, rawQuery) { | |||
| 211 | + | d.recordPaletteUse(cmd.Code) | |||
| 212 | + | d.paint(ac.currentSession(), ac, true) | |||
| 213 | + | } | |||
| 194 | return | 214 | return | ||
| 195 | } | 215 | } | ||
| 196 | if !d.closeExecutedPalette(ac, generation, rawQuery) { | 216 | if !d.closeExecutedPalette(ac, generation, rawQuery) { | ||
| @@ -381,7 +401,12 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||||
| 381 | if e.d.sessionByID(target.id) == nil { | 401 | if e.d.sessionByID(target.id) == nil { | ||
| 382 | return command.ErrInvalidArguments | 402 | return command.ErrInvalidArguments | ||
| 383 | } | 403 | } | ||
| 384 | - | e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | 404 | + | if e.d.beforeRecentSessionHandoff != nil { |
| 405 | + | e.d.beforeRecentSessionHandoff() | |||
| 406 | + | } | |||
| 407 | + | if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) { | |||
| 408 | + | return command.ErrInvalidArguments | |||
| 409 | + | } | |||
| 385 | return nil | 410 | return nil | ||
| 386 | } | 411 | } | ||
| 387 | 412 | ||||
M internal/usecase/daemon/palette_test.go +96 -0
| @@ -70,6 +70,102 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | |||
| 70 | 70 | ac.overlays.paletteMu.Unlock() | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | + | func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | |
| 74 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 75 | + | defer releases[0]() | |
| 76 | + | defer releases[1]() | |
| 77 | + | ||
| 78 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 79 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 80 | + | d.handleInput(sess, ac, []byte("next\r")) | |
| 81 | + | ||
| 82 | + | require.False(t, ac.overlays.paletteActive()) | |
| 83 | + | require.Equal(t, 1, activeTabIndex(sess)) | |
| 84 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) { | |
| 88 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 89 | + | defer releaseAll(releases) | |
| 90 | + | captured := d.sessions[domain.SessionID("recent")] | |
| 91 | + | d.handleInput(current, ac, []byte("\x1b ")) | |
| 92 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 93 | + | // Reordering live MRU after opening must not shift rank 1 from its capture. | |
| 94 | + | d.sessions[domain.SessionID("older")].mruAt.Store(100) | |
| 95 | + | d.handleInput(current, ac, []byte("JRS 1\r")) | |
| 96 | + | ||
| 97 | + | require.Same(t, captured, ac.currentSession()) | |
| 98 | + | require.False(t, ac.overlays.paletteActive()) | |
| 99 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 100 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |
| 104 | + | p, release := newBlockingPTY(t) | |
| 105 | + | defer release() | |
| 106 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 107 | + | target := &session{id: "captured", name: "captured"} | |
| 108 | + | d.sessions[target.id] = target | |
| 109 | + | ||
| 110 | + | validated := make(chan struct{}) | |
| 111 | + | releaseHandoff := make(chan struct{}) | |
| 112 | + | d.beforeRecentSessionHandoff = func() { | |
| 113 | + | close(validated) | |
| 114 | + | <-releaseHandoff | |
| 115 | + | } | |
| 116 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 117 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 118 | + | go d.handleInput(sess, ac, []byte("JRS 1\r")) | |
| 119 | + | <-validated | |
| 120 | + | d.mu.Lock() | |
| 121 | + | delete(d.sessions, target.id) | |
| 122 | + | d.mu.Unlock() | |
| 123 | + | close(releaseHandoff) | |
| 124 | + | ||
| 125 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 126 | + | require.True(t, ac.overlays.paletteActive()) | |
| 127 | + | ac.overlays.paletteMu.Lock() | |
| 128 | + | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | |
| 129 | + | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) | |
| 130 | + | ac.overlays.paletteMu.Unlock() | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) { | |
| 134 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 135 | + | defer releaseAll(releases) | |
| 136 | + | ||
| 137 | + | d.handleInput(current, ac, []byte("\x1b ")) | |
| 138 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 139 | + | d.handleInput(current, ac, []byte("JRS 3\r")) | |
| 140 | + | ||
| 141 | + | require.Same(t, current, ac.currentSession()) | |
| 142 | + | require.True(t, ac.overlays.paletteActive()) | |
| 143 | + | ac.overlays.paletteMu.Lock() | |
| 144 | + | require.Equal(t, "JRS 3", ac.overlays.palette.Query()) | |
| 145 | + | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) | |
| 146 | + | ac.overlays.paletteMu.Unlock() | |
| 147 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) { | |
| 151 | + | p, release := newBlockingPTY(t) | |
| 152 | + | defer release() | |
| 153 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 154 | + | ||
| 155 | + | d.enterPalette(sess, ac) | |
| 156 | + | ac.overlays.paletteMu.Lock() | |
| 157 | + | staleGeneration := ac.overlays.paletteGeneration | |
| 158 | + | ac.overlays.paletteMu.Unlock() | |
| 159 | + | d.enterPalette(sess, ac) | |
| 160 | + | d.paletteFailure(ac, staleGeneration, "JRS 1", "stale failure") | |
| 161 | + | ||
| 162 | + | ac.overlays.paletteMu.Lock() | |
| 163 | + | require.Empty(t, ac.overlays.paletteFeedback) | |
| 164 | + | ac.overlays.paletteMu.Unlock() | |
| 165 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 166 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 167 | + | } | |
| 168 | + | ||
| 73 | 169 | func TestPaletteFLTExecutesFloatingToggle(t *testing.T) { | |
| 74 | 170 | p, release := newBlockingPTY(t) | |
| 75 | 171 | defer release() | |
| @@ -70,6 +70,102 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | |||||
| 70 | ac.overlays.paletteMu.Unlock() | 70 | ac.overlays.paletteMu.Unlock() | ||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | + | func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | |||
| 74 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 75 | + | defer releases[0]() | |||
| 76 | + | defer releases[1]() | |||
| 77 | + | ||||
| 78 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 79 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 80 | + | d.handleInput(sess, ac, []byte("next\r")) | |||
| 81 | + | ||||
| 82 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 83 | + | require.Equal(t, 1, activeTabIndex(sess)) | |||
| 84 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) { | |||
| 88 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 89 | + | defer releaseAll(releases) | |||
| 90 | + | captured := d.sessions[domain.SessionID("recent")] | |||
| 91 | + | d.handleInput(current, ac, []byte("\x1b ")) | |||
| 92 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 93 | + | // Reordering live MRU after opening must not shift rank 1 from its capture. | |||
| 94 | + | d.sessions[domain.SessionID("older")].mruAt.Store(100) | |||
| 95 | + | d.handleInput(current, ac, []byte("JRS 1\r")) | |||
| 96 | + | ||||
| 97 | + | require.Same(t, captured, ac.currentSession()) | |||
| 98 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 99 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 100 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||
| 104 | + | p, release := newBlockingPTY(t) | |||
| 105 | + | defer release() | |||
| 106 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 107 | + | target := &session{id: "captured", name: "captured"} | |||
| 108 | + | d.sessions[target.id] = target | |||
| 109 | + | ||||
| 110 | + | validated := make(chan struct{}) | |||
| 111 | + | releaseHandoff := make(chan struct{}) | |||
| 112 | + | d.beforeRecentSessionHandoff = func() { | |||
| 113 | + | close(validated) | |||
| 114 | + | <-releaseHandoff | |||
| 115 | + | } | |||
| 116 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 117 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 118 | + | go d.handleInput(sess, ac, []byte("JRS 1\r")) | |||
| 119 | + | <-validated | |||
| 120 | + | d.mu.Lock() | |||
| 121 | + | delete(d.sessions, target.id) | |||
| 122 | + | d.mu.Unlock() | |||
| 123 | + | close(releaseHandoff) | |||
| 124 | + | ||||
| 125 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 126 | + | require.True(t, ac.overlays.paletteActive()) | |||
| 127 | + | ac.overlays.paletteMu.Lock() | |||
| 128 | + | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | |||
| 129 | + | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) | |||
| 130 | + | ac.overlays.paletteMu.Unlock() | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) { | |||
| 134 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 135 | + | defer releaseAll(releases) | |||
| 136 | + | ||||
| 137 | + | d.handleInput(current, ac, []byte("\x1b ")) | |||
| 138 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 139 | + | d.handleInput(current, ac, []byte("JRS 3\r")) | |||
| 140 | + | ||||
| 141 | + | require.Same(t, current, ac.currentSession()) | |||
| 142 | + | require.True(t, ac.overlays.paletteActive()) | |||
| 143 | + | ac.overlays.paletteMu.Lock() | |||
| 144 | + | require.Equal(t, "JRS 3", ac.overlays.palette.Query()) | |||
| 145 | + | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) | |||
| 146 | + | ac.overlays.paletteMu.Unlock() | |||
| 147 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) { | |||
| 151 | + | p, release := newBlockingPTY(t) | |||
| 152 | + | defer release() | |||
| 153 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 154 | + | ||||
| 155 | + | d.enterPalette(sess, ac) | |||
| 156 | + | ac.overlays.paletteMu.Lock() | |||
| 157 | + | staleGeneration := ac.overlays.paletteGeneration | |||
| 158 | + | ac.overlays.paletteMu.Unlock() | |||
| 159 | + | d.enterPalette(sess, ac) | |||
| 160 | + | d.paletteFailure(ac, staleGeneration, "JRS 1", "stale failure") | |||
| 161 | + | ||||
| 162 | + | ac.overlays.paletteMu.Lock() | |||
| 163 | + | require.Empty(t, ac.overlays.paletteFeedback) | |||
| 164 | + | ac.overlays.paletteMu.Unlock() | |||
| 165 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 166 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 73 | func TestPaletteFLTExecutesFloatingToggle(t *testing.T) { | 169 | func TestPaletteFLTExecutesFloatingToggle(t *testing.T) { | ||
| 74 | p, release := newBlockingPTY(t) | 170 | p, release := newBlockingPTY(t) | ||
| 75 | defer release() | 171 | defer release() | ||
M internal/usecase/daemon/picker.go +11 -10
| @@ -353,27 +353,26 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 353 | 353 | return d.sessions[id] | |
| 354 | 354 | } | |
| 355 | 355 | ||
| 356 | - | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) { | |
| 356 | + | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool { | |
| 357 | 357 | if target.Stopped { | |
| 358 | - | d.resumeStoppedAndSwitch(sess, ac, target) | |
| 359 | - | return | |
| 358 | + | return d.resumeStoppedAndSwitch(sess, ac, target) | |
| 360 | 359 | } | |
| 361 | 360 | targetSess := d.sessionByID(target.Session) | |
| 362 | 361 | if targetSess == nil { | |
| 363 | 362 | d.paint(sess, ac, true) | |
| 364 | - | return | |
| 363 | + | return false | |
| 365 | 364 | } | |
| 366 | 365 | if targetSess == sess { | |
| 367 | 366 | if sess.switchTab(target.TabIndex) { | |
| 368 | 367 | d.activateTab(sess, sess.activeTab()) | |
| 369 | 368 | } | |
| 370 | 369 | d.paint(sess, ac, true) | |
| 371 | - | return | |
| 370 | + | return true | |
| 372 | 371 | } | |
| 373 | 372 | old := d.stealClientForTarget(sess, ac, targetSess, target) | |
| 374 | 373 | if ac.currentSession() != targetSess { | |
| 375 | 374 | d.paint(sess, ac, true) | |
| 376 | - | return | |
| 375 | + | return false | |
| 377 | 376 | } | |
| 378 | 377 | if old != nil && old != ac { | |
| 379 | 378 | ||
| @@ -383,6 +382,7 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||
| 383 | 382 | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 384 | 383 | } | |
| 385 | 384 | d.firstPaint(targetSess, ac, ac.size) | |
| 385 | + | return true | |
| 386 | 386 | } | |
| 387 | 387 | ||
| 388 | 388 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| @@ -418,19 +418,19 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 418 | 418 | return old | |
| 419 | 419 | } | |
| 420 | 420 | ||
| 421 | - | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) { | |
| 421 | + | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |
| 422 | 422 | d.mu.Lock() | |
| 423 | 423 | stopped, ok := d.stopped[target.Name] | |
| 424 | 424 | if !ok || stopped.purging { | |
| 425 | 425 | d.mu.Unlock() | |
| 426 | 426 | d.paint(from, ac, true) | |
| 427 | - | return | |
| 427 | + | return false | |
| 428 | 428 | } | |
| 429 | 429 | from.mu.Lock() | |
| 430 | 430 | if from.client != ac { | |
| 431 | 431 | from.mu.Unlock() | |
| 432 | 432 | d.mu.Unlock() | |
| 433 | - | return | |
| 433 | + | return false | |
| 434 | 434 | } | |
| 435 | 435 | term := from.terminal | |
| 436 | 436 | cwd := d.dirOrHome(stopped.cwd) | |
| @@ -440,7 +440,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 440 | 440 | d.mu.Unlock() | |
| 441 | 441 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 442 | 442 | d.paint(from, ac, true) | |
| 443 | - | return | |
| 443 | + | return false | |
| 444 | 444 | } | |
| 445 | 445 | from.client = nil | |
| 446 | 446 | ac.setSession(nil) | |
| @@ -454,6 +454,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 454 | 454 | ac.recordPreviousSession(from) | |
| 455 | 455 | d.mu.Unlock() | |
| 456 | 456 | d.firstPaint(targetSess, ac, ac.size) | |
| 457 | + | return true | |
| 457 | 458 | } | |
| 458 | 459 | ||
| 459 | 460 | func (d *Daemon) killPickerTarget(target picker.Target) { | |
| @@ -353,27 +353,26 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 353 | return d.sessions[id] | 353 | return d.sessions[id] | ||
| 354 | } | 354 | } | ||
| 355 | 355 | ||||
| 356 | - | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) { | 356 | + | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool { |
| 357 | if target.Stopped { | 357 | if target.Stopped { | ||
| 358 | - | d.resumeStoppedAndSwitch(sess, ac, target) | 358 | + | return d.resumeStoppedAndSwitch(sess, ac, target) |
| 359 | - | return | |||
| 360 | } | 359 | } | ||
| 361 | targetSess := d.sessionByID(target.Session) | 360 | targetSess := d.sessionByID(target.Session) | ||
| 362 | if targetSess == nil { | 361 | if targetSess == nil { | ||
| 363 | d.paint(sess, ac, true) | 362 | d.paint(sess, ac, true) | ||
| 364 | - | return | 363 | + | return false |
| 365 | } | 364 | } | ||
| 366 | if targetSess == sess { | 365 | if targetSess == sess { | ||
| 367 | if sess.switchTab(target.TabIndex) { | 366 | if sess.switchTab(target.TabIndex) { | ||
| 368 | d.activateTab(sess, sess.activeTab()) | 367 | d.activateTab(sess, sess.activeTab()) | ||
| 369 | } | 368 | } | ||
| 370 | d.paint(sess, ac, true) | 369 | d.paint(sess, ac, true) | ||
| 371 | - | return | 370 | + | return true |
| 372 | } | 371 | } | ||
| 373 | old := d.stealClientForTarget(sess, ac, targetSess, target) | 372 | old := d.stealClientForTarget(sess, ac, targetSess, target) | ||
| 374 | if ac.currentSession() != targetSess { | 373 | if ac.currentSession() != targetSess { | ||
| 375 | d.paint(sess, ac, true) | 374 | d.paint(sess, ac, true) | ||
| 376 | - | return | 375 | + | return false |
| 377 | } | 376 | } | ||
| 378 | if old != nil && old != ac { | 377 | if old != nil && old != ac { | ||
| 379 | 378 | ||||
| @@ -383,6 +382,7 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||||
| 383 | d.notifyDetachedAsync(old, ports.ReasonDetach) | 382 | d.notifyDetachedAsync(old, ports.ReasonDetach) | ||
| 384 | } | 383 | } | ||
| 385 | d.firstPaint(targetSess, ac, ac.size) | 384 | d.firstPaint(targetSess, ac, ac.size) | ||
| 385 | + | return true | |||
| 386 | } | 386 | } | ||
| 387 | 387 | ||||
| 388 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | 388 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | ||
| @@ -418,19 +418,19 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 418 | return old | 418 | return old | ||
| 419 | } | 419 | } | ||
| 420 | 420 | ||||
| 421 | - | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) { | 421 | + | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { |
| 422 | d.mu.Lock() | 422 | d.mu.Lock() | ||
| 423 | stopped, ok := d.stopped[target.Name] | 423 | stopped, ok := d.stopped[target.Name] | ||
| 424 | if !ok || stopped.purging { | 424 | if !ok || stopped.purging { | ||
| 425 | d.mu.Unlock() | 425 | d.mu.Unlock() | ||
| 426 | d.paint(from, ac, true) | 426 | d.paint(from, ac, true) | ||
| 427 | - | return | 427 | + | return false |
| 428 | } | 428 | } | ||
| 429 | from.mu.Lock() | 429 | from.mu.Lock() | ||
| 430 | if from.client != ac { | 430 | if from.client != ac { | ||
| 431 | from.mu.Unlock() | 431 | from.mu.Unlock() | ||
| 432 | d.mu.Unlock() | 432 | d.mu.Unlock() | ||
| 433 | - | return | 433 | + | return false |
| 434 | } | 434 | } | ||
| 435 | term := from.terminal | 435 | term := from.terminal | ||
| 436 | cwd := d.dirOrHome(stopped.cwd) | 436 | cwd := d.dirOrHome(stopped.cwd) | ||
| @@ -440,7 +440,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 440 | d.mu.Unlock() | 440 | d.mu.Unlock() | ||
| 441 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | 441 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | ||
| 442 | d.paint(from, ac, true) | 442 | d.paint(from, ac, true) | ||
| 443 | - | return | 443 | + | return false |
| 444 | } | 444 | } | ||
| 445 | from.client = nil | 445 | from.client = nil | ||
| 446 | ac.setSession(nil) | 446 | ac.setSession(nil) | ||
| @@ -454,6 +454,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 454 | ac.recordPreviousSession(from) | 454 | ac.recordPreviousSession(from) | ||
| 455 | d.mu.Unlock() | 455 | d.mu.Unlock() | ||
| 456 | d.firstPaint(targetSess, ac, ac.size) | 456 | d.firstPaint(targetSess, ac, ac.size) | ||
| 457 | + | return true | |||
| 457 | } | 458 | } | ||
| 458 | 459 | ||||
| 459 | func (d *Daemon) killPickerTarget(target picker.Target) { | 460 | func (d *Daemon) killPickerTarget(target picker.Target) { | ||