detached 38162d7

fix(palette): commit recent jumps before closing

commit
38162d75c060cf8c948e936abf27fd375fc6acae
author
brice · · 2026-07-11 12:19 UTC
parent
59a790d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
114114
115115 paletteRecentMu sync.Mutex
116116 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()
117120
118121 ptys ports.PTYFactory
119122 clock ports.Clock
@@ -114,6 +114,9 @@ type Daemon struct {
114 114
115 paletteRecentMu sync.Mutex115 paletteRecentMu sync.Mutex
116 paletteRecent []string116 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.PTYFactory121 ptys ports.PTYFactory
119 clock ports.Clock122 clock ports.Clock
M internal/usecase/daemon/palette.go +36 -11
@@ -134,16 +134,29 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
134134 down: func() { ac.overlays.palette.Down(); changed = true },
135135 cancel: func() { cancel = true },
136136 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 {
140140 ac.overlays.paletteFeedback = "invalid command arguments"
141141 ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback)
142142 changed = true
143143 return
144144 }
145- args = action.Args
146145 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+ }
147160 recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
148161 generation = ac.overlays.paletteGeneration
149162 execute = true
@@ -179,18 +192,25 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
179192 // by a newly-ranked session, so MRU changes cannot shift a requested rank.
180193 if cmd.Code == "JRS" {
181194 rank, err := command.ParsePositiveDecimal(args)
182- if err != nil || rank > len(recent) || d.sessionByID(recent[rank-1].id) == nil {
195+ if err != nil {
183196 d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable")
184197 d.paint(sess, ac, true)
185198 return
186199 }
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)
190206 return
191207 }
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+ }
194214 return
195215 }
196216 if !d.closeExecutedPalette(ac, generation, rawQuery) {
@@ -381,7 +401,12 @@ func (e paletteExec) JumpRecentSession(rank int) error {
381401 if e.d.sessionByID(target.id) == nil {
382402 return command.ErrInvalidArguments
383403 }
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+ }
385410 return nil
386411 }
387412
@@ -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 = true142 changed = true
143 return143 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.paletteGeneration161 generation = ac.overlays.paletteGeneration
149 execute = true162 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 return198 return
186 }199 }
187- // Publication remains generation-safe; close only if this is still the200+ // 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 return206 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 return214 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.ErrInvalidArguments402 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 nil410 return nil
386 }411 }
387 412
M internal/usecase/daemon/palette_test.go +96 -0
@@ -70,6 +70,102 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
7070 ac.overlays.paletteMu.Unlock()
7171 }
7272
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+
73169 func TestPaletteFLTExecutesFloatingToggle(t *testing.T) {
74170 p, release := newBlockingPTY(t)
75171 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 {
353353 return d.sessions[id]
354354 }
355355
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 {
357357 if target.Stopped {
358- d.resumeStoppedAndSwitch(sess, ac, target)
359- return
358+ return d.resumeStoppedAndSwitch(sess, ac, target)
360359 }
361360 targetSess := d.sessionByID(target.Session)
362361 if targetSess == nil {
363362 d.paint(sess, ac, true)
364- return
363+ return false
365364 }
366365 if targetSess == sess {
367366 if sess.switchTab(target.TabIndex) {
368367 d.activateTab(sess, sess.activeTab())
369368 }
370369 d.paint(sess, ac, true)
371- return
370+ return true
372371 }
373372 old := d.stealClientForTarget(sess, ac, targetSess, target)
374373 if ac.currentSession() != targetSess {
375374 d.paint(sess, ac, true)
376- return
375+ return false
377376 }
378377 if old != nil && old != ac {
379378
@@ -383,6 +382,7 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
383382 d.notifyDetachedAsync(old, ports.ReasonDetach)
384383 }
385384 d.firstPaint(targetSess, ac, ac.size)
385+ return true
386386 }
387387
388388 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
418418 return old
419419 }
420420
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 {
422422 d.mu.Lock()
423423 stopped, ok := d.stopped[target.Name]
424424 if !ok || stopped.purging {
425425 d.mu.Unlock()
426426 d.paint(from, ac, true)
427- return
427+ return false
428428 }
429429 from.mu.Lock()
430430 if from.client != ac {
431431 from.mu.Unlock()
432432 d.mu.Unlock()
433- return
433+ return false
434434 }
435435 term := from.terminal
436436 cwd := d.dirOrHome(stopped.cwd)
@@ -440,7 +440,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
440440 d.mu.Unlock()
441441 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
442442 d.paint(from, ac, true)
443- return
443+ return false
444444 }
445445 from.client = nil
446446 ac.setSession(nil)
@@ -454,6 +454,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
454454 ac.recordPreviousSession(from)
455455 d.mu.Unlock()
456456 d.firstPaint(targetSess, ac, ac.size)
457+ return true
457458 }
458459
459460 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- return363+ 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- return370+ 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- return375+ 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 old418 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- return427+ 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- return433+ return false
434 }434 }
435 term := from.terminal435 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- return443+ return false
444 }444 }
445 from.client = nil445 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close