fix(daemon): preserve focus for stable control targets
- commit
- d277208c8130c189aeedfe81f463341dae5fb9d1
- author
- brice · · 2026-07-23 22:17 UTC
- parent
- b253fab
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 66 | 66 | return ports.CommandResult{OK: true, Output: result.Output} | |
| 67 | 67 | } | |
| 68 | 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 | 70 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | |
| 71 | 71 | case errors.Is(err, errSessionNameInUse): | |
| 72 | 72 | return commandFailure(ports.ErrNameTaken, err.Error()) | |
| @@ -135,7 +135,7 @@ func (s *session) containsStableIDs(tabID, paneID string) bool { | |||
| 135 | 135 | 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 | 139 | sess.mu.Lock() | |
| 140 | 140 | tabs := append([]*tab(nil), sess.tabs...) | |
| 141 | 141 | active := sess.active | |
| @@ -144,31 +144,11 @@ func resolveControlTarget(sess *session, kind command.TargetKind, tabID, paneID | |||
| 144 | 144 | return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab" | |
| 145 | 145 | } | |
| 146 | 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 | 147 | if kind != command.TargetPane { | |
| 163 | 148 | return tb, nil, 0, "" | |
| 164 | 149 | } | |
| 165 | 150 | 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() | |
| 172 | 152 | tb.mu.Unlock() | |
| 173 | 153 | if target == nil { | |
| 174 | 154 | return nil, nil, ports.ErrNoSuchTarget, "no such target pane" | |
| @@ -200,27 +180,6 @@ func (e controlExec) client() *attachedClient { | |||
| 200 | 180 | 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 | 183 | func (e controlExec) CreateTab() error { return e.d.createTab(e.sess, e.sess.fullViewportSize()) } | |
| 225 | 184 | func (e controlExec) CreateSessionNamed(name string) error { | |
| 226 | 185 | if err := domain.ValidateSessionName(name); err != nil { | |
| @@ -242,49 +201,38 @@ func (e controlExec) CreateSessionNamed(name string) error { | |||
| 242 | 201 | return err | |
| 243 | 202 | } | |
| 244 | 203 | func (e controlExec) CloseTab() error { | |
| 245 | - | e.selectTarget() | |
| 246 | 204 | e.d.closeTab(e.sess, e.tab, true) | |
| 247 | 205 | return nil | |
| 248 | 206 | } | |
| 249 | 207 | func (e controlExec) ClosePane() error { | |
| 250 | - | e.selectTarget() | |
| 251 | 208 | return e.d.closeFocusedPane(e.sess, e.client()) | |
| 252 | 209 | } | |
| 253 | 210 | func (e controlExec) SplitRight() error { | |
| 254 | - | e.selectTarget() | |
| 255 | 211 | return e.d.splitPane(e.sess, e.client(), layout.Right) | |
| 256 | 212 | } | |
| 257 | 213 | func (e controlExec) SplitLeft() error { | |
| 258 | - | e.selectTarget() | |
| 259 | 214 | return e.d.splitPane(e.sess, e.client(), layout.Left) | |
| 260 | 215 | } | |
| 261 | 216 | func (e controlExec) SplitUp() error { | |
| 262 | - | e.selectTarget() | |
| 263 | 217 | return e.d.splitPane(e.sess, e.client(), layout.Up) | |
| 264 | 218 | } | |
| 265 | 219 | func (e controlExec) SplitDown() error { | |
| 266 | - | e.selectTarget() | |
| 267 | 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 | 223 | func (e controlExec) ToggleStack() error { | |
| 271 | - | e.selectTarget() | |
| 272 | 224 | return e.d.toggleStack(e.sess, e.client()) | |
| 273 | 225 | } | |
| 274 | 226 | func (e controlExec) FocusPaneLeft() error { | |
| 275 | - | e.selectTarget() | |
| 276 | 227 | return e.d.focusDir(e.sess, e.client(), layout.Left) | |
| 277 | 228 | } | |
| 278 | 229 | func (e controlExec) FocusPaneRight() error { | |
| 279 | - | e.selectTarget() | |
| 280 | 230 | return e.d.focusDir(e.sess, e.client(), layout.Right) | |
| 281 | 231 | } | |
| 282 | 232 | func (e controlExec) FocusPaneUp() error { | |
| 283 | - | e.selectTarget() | |
| 284 | 233 | return e.d.focusDir(e.sess, e.client(), layout.Up) | |
| 285 | 234 | } | |
| 286 | 235 | func (e controlExec) FocusPaneDown() error { | |
| 287 | - | e.selectTarget() | |
| 288 | 236 | return e.d.focusDir(e.sess, e.client(), layout.Down) | |
| 289 | 237 | } | |
| 290 | 238 | func (e controlExec) NextTab() error { return e.switchRelative(1) } | |
| @@ -297,7 +245,6 @@ func (e controlExec) switchRelative(delta int) error { | |||
| 297 | 245 | } | |
| 298 | 246 | func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) } | |
| 299 | 247 | func (e controlExec) RenameTabTo(name string) error { | |
| 300 | - | e.selectTarget() | |
| 301 | 248 | return e.d.renameTab(e.sess, e.tab, name) | |
| 302 | 249 | } | |
| 303 | 250 | 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 false | 135 | 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.active | 141 | 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 *pane | 151 | + | 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.client | 180 | 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 err | 201 | 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 nil | 205 | 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) { | |||
| 90 | 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 | 94 | d := newTestDaemon(t, nil, stubClock{}) | |
| 95 | 95 | sess := addControlSession(d, "work", "t_first", "p_first") | |
| 96 | + | first := sess.tabs[0] | |
| 96 | 97 | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 97 | 98 | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 98 | 99 | sess.mu.Lock() | |
| @@ -106,14 +107,68 @@ func TestHandleCommandTargetsTabByStableIDs(t *testing.T) { | |||
| 106 | 107 | }) | |
| 107 | 108 | ||
| 108 | 109 | require.True(t, result.OK, result.Text) | |
| 110 | + | first.mu.Lock() | |
| 111 | + | require.Equal(t, "targeted", first.name) | |
| 112 | + | first.mu.Unlock() | |
| 109 | 113 | second.mu.Lock() | |
| 110 | - | require.Equal(t, "targeted", second.name) | |
| 114 | + | require.Empty(t, second.name) | |
| 111 | 115 | second.mu.Unlock() | |
| 112 | 116 | sess.mu.Lock() | |
| 113 | - | require.Equal(t, 1, sess.active) | |
| 117 | + | require.Zero(t, sess.active) | |
| 114 | 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 | 172 | func TestHandleCommandHeadlessMutations(t *testing.T) { | |
| 118 | 173 | tests := []struct { | |
| 119 | 174 | 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 string | 174 | name string | ||