detached d277208

fix(daemon): preserve focus for stable control targets

commit
d277208c8130c189aeedfe81f463341dae5fb9d1
author
brice · · 2026-07-23 22:17 UTC
parent
b253fab
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +62 -60
M internal/usecase/daemon/control.go +4 -57
@@ -66,7 +66,7 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports
6666 return ports.CommandResult{OK: true, Output: result.Output}
6767 }
6868 switch {
69- case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired):
69+ case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):
7070 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
7171 case errors.Is(err, errSessionNameInUse):
7272 return commandFailure(ports.ErrNameTaken, err.Error())
@@ -135,7 +135,7 @@ func (s *session) containsStableIDs(tabID, paneID string) bool {
135135 return false
136136 }
137137
138-func resolveControlTarget(sess *session, kind command.TargetKind, tabID, paneID string) (*tab, *pane, uint16, string) {
138+func resolveControlTarget(sess *session, kind command.TargetKind, _, _ string) (*tab, *pane, uint16, string) {
139139 sess.mu.Lock()
140140 tabs := append([]*tab(nil), sess.tabs...)
141141 active := sess.active
@@ -144,31 +144,11 @@ func resolveControlTarget(sess *session, kind command.TargetKind, tabID, paneID
144144 return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
145145 }
146146 tb := tabs[active]
147- if tabID != "" {
148- tb = nil
149- for _, candidate := range tabs {
150- candidate.mu.Lock()
151- match := candidate.stableID == tabID
152- candidate.mu.Unlock()
153- if match {
154- tb = candidate
155- break
156- }
157- }
158- if tb == nil {
159- return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
160- }
161- }
162147 if kind != command.TargetPane {
163148 return tb, nil, 0, ""
164149 }
165150 tb.mu.Lock()
166- var target *pane
167- if paneID != "" {
168- target = paneByStableIDLocked(tb, paneID)
169- } else {
170- target = tb.focusedPane()
171- }
151+ target := tb.focusedPane()
172152 tb.mu.Unlock()
173153 if target == nil {
174154 return nil, nil, ports.ErrNoSuchTarget, "no such target pane"
@@ -200,27 +180,6 @@ func (e controlExec) client() *attachedClient {
200180 return e.sess.client
201181 }
202182
203-// selectTarget translates an explicit stable target to the canonical active
204-// tab/focus state expected by the existing daemon action seam.
205-func (e controlExec) selectTarget() {
206- if e.tab == nil {
207- return
208- }
209- e.sess.mu.Lock()
210- for i, tb := range e.sess.tabs {
211- if tb == e.tab {
212- e.sess.active = i
213- break
214- }
215- }
216- e.sess.mu.Unlock()
217- if e.pane != nil {
218- e.tab.mu.Lock()
219- e.tab.tree.Focus = e.pane.id
220- e.tab.mu.Unlock()
221- }
222-}
223-
224183 func (e controlExec) CreateTab() error { return e.d.createTab(e.sess, e.sess.fullViewportSize()) }
225184 func (e controlExec) CreateSessionNamed(name string) error {
226185 if err := domain.ValidateSessionName(name); err != nil {
@@ -242,49 +201,38 @@ func (e controlExec) CreateSessionNamed(name string) error {
242201 return err
243202 }
244203 func (e controlExec) CloseTab() error {
245- e.selectTarget()
246204 e.d.closeTab(e.sess, e.tab, true)
247205 return nil
248206 }
249207 func (e controlExec) ClosePane() error {
250- e.selectTarget()
251208 return e.d.closeFocusedPane(e.sess, e.client())
252209 }
253210 func (e controlExec) SplitRight() error {
254- e.selectTarget()
255211 return e.d.splitPane(e.sess, e.client(), layout.Right)
256212 }
257213 func (e controlExec) SplitLeft() error {
258- e.selectTarget()
259214 return e.d.splitPane(e.sess, e.client(), layout.Left)
260215 }
261216 func (e controlExec) SplitUp() error {
262- e.selectTarget()
263217 return e.d.splitPane(e.sess, e.client(), layout.Up)
264218 }
265219 func (e controlExec) SplitDown() error {
266- e.selectTarget()
267220 return e.d.splitPane(e.sess, e.client(), layout.Down)
268221 }
269-func (e controlExec) StackPane() error { e.selectTarget(); return e.d.stackPane(e.sess, e.client()) }
222+func (e controlExec) StackPane() error { return e.d.stackPane(e.sess, e.client()) }
270223 func (e controlExec) ToggleStack() error {
271- e.selectTarget()
272224 return e.d.toggleStack(e.sess, e.client())
273225 }
274226 func (e controlExec) FocusPaneLeft() error {
275- e.selectTarget()
276227 return e.d.focusDir(e.sess, e.client(), layout.Left)
277228 }
278229 func (e controlExec) FocusPaneRight() error {
279- e.selectTarget()
280230 return e.d.focusDir(e.sess, e.client(), layout.Right)
281231 }
282232 func (e controlExec) FocusPaneUp() error {
283- e.selectTarget()
284233 return e.d.focusDir(e.sess, e.client(), layout.Up)
285234 }
286235 func (e controlExec) FocusPaneDown() error {
287- e.selectTarget()
288236 return e.d.focusDir(e.sess, e.client(), layout.Down)
289237 }
290238 func (e controlExec) NextTab() error { return e.switchRelative(1) }
@@ -297,7 +245,6 @@ func (e controlExec) switchRelative(delta int) error {
297245 }
298246 func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) }
299247 func (e controlExec) RenameTabTo(name string) error {
300- e.selectTarget()
301248 return e.d.renameTab(e.sess, e.tab, name)
302249 }
303250 func (e controlExec) Toast(severity, message string) error {
@@ -66,7 +66,7 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports
66 return ports.CommandResult{OK: true, Output: result.Output}66 return ports.CommandResult{OK: true, Output: result.Output}
67 }67 }
68 switch {68 switch {
69- case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired):69+ case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):
70 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)70 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
71 case errors.Is(err, errSessionNameInUse):71 case errors.Is(err, errSessionNameInUse):
72 return commandFailure(ports.ErrNameTaken, err.Error())72 return commandFailure(ports.ErrNameTaken, err.Error())
@@ -135,7 +135,7 @@ func (s *session) containsStableIDs(tabID, paneID string) bool {
135 return false135 return false
136 }136 }
137 137
138-func resolveControlTarget(sess *session, kind command.TargetKind, tabID, paneID string) (*tab, *pane, uint16, string) {138+func resolveControlTarget(sess *session, kind command.TargetKind, _, _ string) (*tab, *pane, uint16, string) {
139 sess.mu.Lock()139 sess.mu.Lock()
140 tabs := append([]*tab(nil), sess.tabs...)140 tabs := append([]*tab(nil), sess.tabs...)
141 active := sess.active141 active := sess.active
@@ -144,31 +144,11 @@ func resolveControlTarget(sess *session, kind command.TargetKind, tabID, paneID
144 return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"144 return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
145 }145 }
146 tb := tabs[active]146 tb := tabs[active]
147- if tabID != "" {
148- tb = nil
149- for _, candidate := range tabs {
150- candidate.mu.Lock()
151- match := candidate.stableID == tabID
152- candidate.mu.Unlock()
153- if match {
154- tb = candidate
155- break
156- }
157- }
158- if tb == nil {
159- return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
160- }
161- }
162 if kind != command.TargetPane {147 if kind != command.TargetPane {
163 return tb, nil, 0, ""148 return tb, nil, 0, ""
164 }149 }
165 tb.mu.Lock()150 tb.mu.Lock()
166- var target *pane151+ target := tb.focusedPane()
167- if paneID != "" {
168- target = paneByStableIDLocked(tb, paneID)
169- } else {
170- target = tb.focusedPane()
171- }
172 tb.mu.Unlock()152 tb.mu.Unlock()
173 if target == nil {153 if target == nil {
174 return nil, nil, ports.ErrNoSuchTarget, "no such target pane"154 return nil, nil, ports.ErrNoSuchTarget, "no such target pane"
@@ -200,27 +180,6 @@ func (e controlExec) client() *attachedClient {
200 return e.sess.client180 return e.sess.client
201 }181 }
202 182
203-// selectTarget translates an explicit stable target to the canonical active
204-// tab/focus state expected by the existing daemon action seam.
205-func (e controlExec) selectTarget() {
206- if e.tab == nil {
207- return
208- }
209- e.sess.mu.Lock()
210- for i, tb := range e.sess.tabs {
211- if tb == e.tab {
212- e.sess.active = i
213- break
214- }
215- }
216- e.sess.mu.Unlock()
217- if e.pane != nil {
218- e.tab.mu.Lock()
219- e.tab.tree.Focus = e.pane.id
220- e.tab.mu.Unlock()
221- }
222-}
223-
224 func (e controlExec) CreateTab() error { return e.d.createTab(e.sess, e.sess.fullViewportSize()) }183 func (e controlExec) CreateTab() error { return e.d.createTab(e.sess, e.sess.fullViewportSize()) }
225 func (e controlExec) CreateSessionNamed(name string) error {184 func (e controlExec) CreateSessionNamed(name string) error {
226 if err := domain.ValidateSessionName(name); err != nil {185 if err := domain.ValidateSessionName(name); err != nil {
@@ -242,49 +201,38 @@ func (e controlExec) CreateSessionNamed(name string) error {
242 return err201 return err
243 }202 }
244 func (e controlExec) CloseTab() error {203 func (e controlExec) CloseTab() error {
245- e.selectTarget()
246 e.d.closeTab(e.sess, e.tab, true)204 e.d.closeTab(e.sess, e.tab, true)
247 return nil205 return nil
248 }206 }
249 func (e controlExec) ClosePane() error {207 func (e controlExec) ClosePane() error {
250- e.selectTarget()
251 return e.d.closeFocusedPane(e.sess, e.client())208 return e.d.closeFocusedPane(e.sess, e.client())
252 }209 }
253 func (e controlExec) SplitRight() error {210 func (e controlExec) SplitRight() error {
254- e.selectTarget()
255 return e.d.splitPane(e.sess, e.client(), layout.Right)211 return e.d.splitPane(e.sess, e.client(), layout.Right)
256 }212 }
257 func (e controlExec) SplitLeft() error {213 func (e controlExec) SplitLeft() error {
258- e.selectTarget()
259 return e.d.splitPane(e.sess, e.client(), layout.Left)214 return e.d.splitPane(e.sess, e.client(), layout.Left)
260 }215 }
261 func (e controlExec) SplitUp() error {216 func (e controlExec) SplitUp() error {
262- e.selectTarget()
263 return e.d.splitPane(e.sess, e.client(), layout.Up)217 return e.d.splitPane(e.sess, e.client(), layout.Up)
264 }218 }
265 func (e controlExec) SplitDown() error {219 func (e controlExec) SplitDown() error {
266- e.selectTarget()
267 return e.d.splitPane(e.sess, e.client(), layout.Down)220 return e.d.splitPane(e.sess, e.client(), layout.Down)
268 }221 }
269-func (e controlExec) StackPane() error { e.selectTarget(); return e.d.stackPane(e.sess, e.client()) }222+func (e controlExec) StackPane() error { return e.d.stackPane(e.sess, e.client()) }
270 func (e controlExec) ToggleStack() error {223 func (e controlExec) ToggleStack() error {
271- e.selectTarget()
272 return e.d.toggleStack(e.sess, e.client())224 return e.d.toggleStack(e.sess, e.client())
273 }225 }
274 func (e controlExec) FocusPaneLeft() error {226 func (e controlExec) FocusPaneLeft() error {
275- e.selectTarget()
276 return e.d.focusDir(e.sess, e.client(), layout.Left)227 return e.d.focusDir(e.sess, e.client(), layout.Left)
277 }228 }
278 func (e controlExec) FocusPaneRight() error {229 func (e controlExec) FocusPaneRight() error {
279- e.selectTarget()
280 return e.d.focusDir(e.sess, e.client(), layout.Right)230 return e.d.focusDir(e.sess, e.client(), layout.Right)
281 }231 }
282 func (e controlExec) FocusPaneUp() error {232 func (e controlExec) FocusPaneUp() error {
283- e.selectTarget()
284 return e.d.focusDir(e.sess, e.client(), layout.Up)233 return e.d.focusDir(e.sess, e.client(), layout.Up)
285 }234 }
286 func (e controlExec) FocusPaneDown() error {235 func (e controlExec) FocusPaneDown() error {
287- e.selectTarget()
288 return e.d.focusDir(e.sess, e.client(), layout.Down)236 return e.d.focusDir(e.sess, e.client(), layout.Down)
289 }237 }
290 func (e controlExec) NextTab() error { return e.switchRelative(1) }238 func (e controlExec) NextTab() error { return e.switchRelative(1) }
@@ -297,7 +245,6 @@ func (e controlExec) switchRelative(delta int) error {
297 }245 }
298 func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) }246 func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) }
299 func (e controlExec) RenameTabTo(name string) error {247 func (e controlExec) RenameTabTo(name string) error {
300- e.selectTarget()
301 return e.d.renameTab(e.sess, e.tab, name)248 return e.d.renameTab(e.sess, e.tab, name)
302 }249 }
303 func (e controlExec) Toast(severity, message string) error {250 func (e controlExec) Toast(severity, message string) error {
M internal/usecase/daemon/control_test.go +58 -3
@@ -90,9 +90,10 @@ func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
9090 require.Equal(t, uint64(1), sess.mruAt.Load())
9191 }
9292
93-func TestHandleCommandTargetsTabByStableIDs(t *testing.T) {
93+func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
9494 d := newTestDaemon(t, nil, stubClock{})
9595 sess := addControlSession(d, "work", "t_first", "p_first")
96+ first := sess.tabs[0]
9697 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
9798 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
9899 sess.mu.Lock()
@@ -106,14 +107,68 @@ func TestHandleCommandTargetsTabByStableIDs(t *testing.T) {
106107 })
107108
108109 require.True(t, result.OK, result.Text)
110+ first.mu.Lock()
111+ require.Equal(t, "targeted", first.name)
112+ first.mu.Unlock()
109113 second.mu.Lock()
110- require.Equal(t, "targeted", second.name)
114+ require.Empty(t, second.name)
111115 second.mu.Unlock()
112116 sess.mu.Lock()
113- require.Equal(t, 1, sess.active)
117+ require.Zero(t, sess.active)
114118 sess.mu.Unlock()
115119 }
116120
121+func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) {
122+ factory := &controlPTYFactory{}
123+ d := newTestDaemon(t, factory, stubClock{})
124+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
125+ sess := addControlSession(d, "work", "t_active", "p_active")
126+ active := sess.tabs[0]
127+ originFocus := active.tree.Focus
128+ origin := active.focusedPane()
129+ second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
130+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
131+ sess.mu.Lock()
132+ sess.tabs = append(sess.tabs, second)
133+ sess.active = 0
134+ sess.mu.Unlock()
135+
136+ result := sendCommand(t, d, ports.CommandRequest{
137+ Slug: "split-right", TargetSession: "work",
138+ TargetTab: "t_origin", TargetPane: "p_origin",
139+ })
140+
141+ require.True(t, result.OK, result.Text)
142+ sess.mu.Lock()
143+ activeIndex := sess.active
144+ sess.mu.Unlock()
145+ require.Zero(t, activeIndex)
146+ active.mu.Lock()
147+ activePaneCount := len(active.panes)
148+ activeFocus := active.tree.Focus
149+ originRetained := active.panes[origin.id] == origin
150+ active.mu.Unlock()
151+ require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab")
152+ require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane")
153+ require.True(t, originRetained)
154+ second.mu.Lock()
155+ secondPaneCount := len(second.panes)
156+ second.mu.Unlock()
157+ require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
158+}
159+
160+func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
161+ d := newTestDaemon(t, nil, stubClock{})
162+ addControlSession(d, "work", "t_work", "p_work")
163+
164+ result := sendCommand(t, d, ports.CommandRequest{
165+ Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
166+ })
167+
168+ require.False(t, result.OK)
169+ require.Equal(t, ports.ErrInvalidCommandArgs, result.Code)
170+}
171+
117172 func TestHandleCommandHeadlessMutations(t *testing.T) {
118173 tests := []struct {
119174 name string
@@ -90,9 +90,10 @@ func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
90 require.Equal(t, uint64(1), sess.mruAt.Load())90 require.Equal(t, uint64(1), sess.mruAt.Load())
91 }91 }
92 92
93-func TestHandleCommandTargetsTabByStableIDs(t *testing.T) {93+func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
94 d := newTestDaemon(t, nil, stubClock{})94 d := newTestDaemon(t, nil, stubClock{})
95 sess := addControlSession(d, "work", "t_first", "p_first")95 sess := addControlSession(d, "work", "t_first", "p_first")
96+ first := sess.tabs[0]
96 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})97 second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
97 second.ctx, second.cancel = context.WithCancel(d.serveCtx)98 second.ctx, second.cancel = context.WithCancel(d.serveCtx)
98 sess.mu.Lock()99 sess.mu.Lock()
@@ -106,14 +107,68 @@ func TestHandleCommandTargetsTabByStableIDs(t *testing.T) {
106 })107 })
107 108
108 require.True(t, result.OK, result.Text)109 require.True(t, result.OK, result.Text)
110+ first.mu.Lock()
111+ require.Equal(t, "targeted", first.name)
112+ first.mu.Unlock()
109 second.mu.Lock()113 second.mu.Lock()
110- require.Equal(t, "targeted", second.name)114+ require.Empty(t, second.name)
111 second.mu.Unlock()115 second.mu.Unlock()
112 sess.mu.Lock()116 sess.mu.Lock()
113- require.Equal(t, 1, sess.active)117+ require.Zero(t, sess.active)
114 sess.mu.Unlock()118 sess.mu.Unlock()
115 }119 }
116 120
121+func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) {
122+ factory := &controlPTYFactory{}
123+ d := newTestDaemon(t, factory, stubClock{})
124+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
125+ sess := addControlSession(d, "work", "t_active", "p_active")
126+ active := sess.tabs[0]
127+ originFocus := active.tree.Focus
128+ origin := active.focusedPane()
129+ second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
130+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
131+ sess.mu.Lock()
132+ sess.tabs = append(sess.tabs, second)
133+ sess.active = 0
134+ sess.mu.Unlock()
135+
136+ result := sendCommand(t, d, ports.CommandRequest{
137+ Slug: "split-right", TargetSession: "work",
138+ TargetTab: "t_origin", TargetPane: "p_origin",
139+ })
140+
141+ require.True(t, result.OK, result.Text)
142+ sess.mu.Lock()
143+ activeIndex := sess.active
144+ sess.mu.Unlock()
145+ require.Zero(t, activeIndex)
146+ active.mu.Lock()
147+ activePaneCount := len(active.panes)
148+ activeFocus := active.tree.Focus
149+ originRetained := active.panes[origin.id] == origin
150+ active.mu.Unlock()
151+ require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab")
152+ require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane")
153+ require.True(t, originRetained)
154+ second.mu.Lock()
155+ secondPaneCount := len(second.panes)
156+ second.mu.Unlock()
157+ require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
158+}
159+
160+func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
161+ d := newTestDaemon(t, nil, stubClock{})
162+ addControlSession(d, "work", "t_work", "p_work")
163+
164+ result := sendCommand(t, d, ports.CommandRequest{
165+ Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
166+ })
167+
168+ require.False(t, result.OK)
169+ require.Equal(t, ports.ErrInvalidCommandArgs, result.Code)
170+}
171+
117 func TestHandleCommandHeadlessMutations(t *testing.T) {172 func TestHandleCommandHeadlessMutations(t *testing.T) {
118 tests := []struct {173 tests := []struct {
119 name string174 name string

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