fix(daemon): stop discarding keybinding handler errors
focusDir now returns the errNoNeighbor sentinel instead of nil for the "no pane in that direction" case, letting reportError's benign filter keep edge-focus silent while genuine layout-tree errors propagate. Action's four focus cases, ActionJumpAttention, and ActionToggleFloatingPane now route their errors through d.reportError instead of discarding them. jumpAttention returns an error so a real switchToTarget failure (a target existed but the commit failed) surfaces as a notice, while the no-target path stays a silent nil.
- commit
- 650884a14d52daab25241a295bfb9aaf9ae8c14f
- author
- brice · · 2026-07-20 18:19 UTC
- parent
- 9453c6d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +97 -16
M internal/usecase/daemon/attention.go +9 -5
| @@ -42,23 +42,27 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||
| 42 | 42 | d.pokeAttentionTicker() | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | - | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) { | |
| 45 | + | // jumpAttention switches to the oldest pending attention target, local tab | |
| 46 | + | // first and then another session's. Returning nil never implies a switch | |
| 47 | + | // happened — it also covers "no target exists", which is routine and not an | |
| 48 | + | // error. Only a failure to reach a target that does exist is a genuine error. | |
| 49 | + | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |
| 46 | 50 | if sess == nil || ac == nil { | |
| 47 | - | return | |
| 51 | + | return nil | |
| 48 | 52 | } | |
| 49 | 53 | if idx, ok := oldestAttentionTab(sess); ok { | |
| 50 | 54 | if sess.switchTab(idx) { | |
| 51 | 55 | d.activateTab(sess, sess.activeTab()) | |
| 52 | 56 | d.invalidateRender(sess, ac, true, "attention.go") | |
| 53 | 57 | } | |
| 54 | - | return | |
| 58 | + | return nil | |
| 55 | 59 | } | |
| 56 | 60 | ||
| 57 | 61 | target, ok := d.oldestOtherSessionAttention(sess) | |
| 58 | 62 | if !ok { | |
| 59 | - | return | |
| 63 | + | return nil | |
| 60 | 64 | } | |
| 61 | - | _ = d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | |
| 65 | + | return d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | |
| 62 | 66 | } | |
| 63 | 67 | ||
| 64 | 68 | func oldestAttentionTab(sess *session) (int, bool) { | |
| @@ -42,23 +42,27 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||||
| 42 | d.pokeAttentionTicker() | 42 | d.pokeAttentionTicker() | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | - | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) { | 45 | + | // jumpAttention switches to the oldest pending attention target, local tab |
| 46 | + | // first and then another session's. Returning nil never implies a switch | |||
| 47 | + | // happened — it also covers "no target exists", which is routine and not an | |||
| 48 | + | // error. Only a failure to reach a target that does exist is a genuine error. | |||
| 49 | + | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |||
| 46 | if sess == nil || ac == nil { | 50 | if sess == nil || ac == nil { | ||
| 47 | - | return | 51 | + | return nil |
| 48 | } | 52 | } | ||
| 49 | if idx, ok := oldestAttentionTab(sess); ok { | 53 | if idx, ok := oldestAttentionTab(sess); ok { | ||
| 50 | if sess.switchTab(idx) { | 54 | if sess.switchTab(idx) { | ||
| 51 | d.activateTab(sess, sess.activeTab()) | 55 | d.activateTab(sess, sess.activeTab()) | ||
| 52 | d.invalidateRender(sess, ac, true, "attention.go") | 56 | d.invalidateRender(sess, ac, true, "attention.go") | ||
| 53 | } | 57 | } | ||
| 54 | - | return | 58 | + | return nil |
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
| 57 | target, ok := d.oldestOtherSessionAttention(sess) | 61 | target, ok := d.oldestOtherSessionAttention(sess) | ||
| 58 | if !ok { | 62 | if !ok { | ||
| 59 | - | return | 63 | + | return nil |
| 60 | } | 64 | } | ||
| 61 | - | _ = d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) | 65 | + | return d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}) |
| 62 | } | 66 | } | ||
| 63 | 67 | ||||
| 64 | func oldestAttentionTab(sess *session) (int, bool) { | 68 | func oldestAttentionTab(sess *session) (int, bool) { | ||
M internal/usecase/daemon/attention_test.go +36 -1
| @@ -526,7 +526,7 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) { | |||
| 526 | 526 | defer releases[1]() | |
| 527 | 527 | sess.active = 0 | |
| 528 | 528 | ||
| 529 | - | d.jumpAttention(sess, ac) | |
| 529 | + | require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure") | |
| 530 | 530 | ||
| 531 | 531 | require.Same(t, sess, ac.currentSession()) | |
| 532 | 532 | require.Equal(t, 0, activeTabIndex(sess)) | |
| @@ -537,6 +537,41 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) { | |||
| 537 | 537 | } | |
| 538 | 538 | } | |
| 539 | 539 | ||
| 540 | + | // TestJumpAttentionSwitchFailureReportsNotice drives jumpAttention's | |
| 541 | + | // cross-session path once a target has already been chosen, then makes the | |
| 542 | + | // commit itself fail (the origin session's client no longer matches ac — | |
| 543 | + | // simulating a client that detached between target selection and commit). | |
| 544 | + | // Unlike the no-target case, a target existed and the switch to it failed: | |
| 545 | + | // that is a genuine error, not a benign no-op, and must reach the user. | |
| 546 | + | func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) { | |
| 547 | + | p1, release1 := newBlockingPTY(t) | |
| 548 | + | p2, release2 := newBlockingPTY(t) | |
| 549 | + | defer release1() | |
| 550 | + | defer release2() | |
| 551 | + | d, sess1, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 552 | + | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 553 | + | defer cancel2() | |
| 554 | + | tab2 := newTestTabWithContext(p2, sctx2, cancel2) | |
| 555 | + | tab2.attention = true | |
| 556 | + | tab2.attentionAt = time.Unix(9, 0) | |
| 557 | + | sess2 := &session{id: "other", name: "other", ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}} | |
| 558 | + | d.sessions[sess2.id] = sess2 | |
| 559 | + | ||
| 560 | + | sess1.mu.Lock() | |
| 561 | + | sess1.client = nil | |
| 562 | + | sess1.mu.Unlock() | |
| 563 | + | ||
| 564 | + | err := d.jumpAttention(sess1, ac) | |
| 565 | + | ||
| 566 | + | require.Error(t, err) | |
| 567 | + | require.Same(t, sess1, ac.currentSession(), "a failed switch must leave the client on its origin session") | |
| 568 | + | ||
| 569 | + | d.reportError(sess1, err) | |
| 570 | + | history := d.notices.history() | |
| 571 | + | require.Len(t, history, 1) | |
| 572 | + | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |
| 573 | + | } | |
| 574 | + | ||
| 540 | 575 | func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) { | |
| 541 | 576 | d, sess, _, sends, releases := newManualTabSession(t, 2) | |
| 542 | 577 | defer releases[0]() | |
| @@ -526,7 +526,7 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) { | |||||
| 526 | defer releases[1]() | 526 | defer releases[1]() | ||
| 527 | sess.active = 0 | 527 | sess.active = 0 | ||
| 528 | 528 | ||||
| 529 | - | d.jumpAttention(sess, ac) | 529 | + | require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure") |
| 530 | 530 | ||||
| 531 | require.Same(t, sess, ac.currentSession()) | 531 | require.Same(t, sess, ac.currentSession()) | ||
| 532 | require.Equal(t, 0, activeTabIndex(sess)) | 532 | require.Equal(t, 0, activeTabIndex(sess)) | ||
| @@ -537,6 +537,41 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) { | |||||
| 537 | } | 537 | } | ||
| 538 | } | 538 | } | ||
| 539 | 539 | ||||
| 540 | + | // TestJumpAttentionSwitchFailureReportsNotice drives jumpAttention's | |||
| 541 | + | // cross-session path once a target has already been chosen, then makes the | |||
| 542 | + | // commit itself fail (the origin session's client no longer matches ac — | |||
| 543 | + | // simulating a client that detached between target selection and commit). | |||
| 544 | + | // Unlike the no-target case, a target existed and the switch to it failed: | |||
| 545 | + | // that is a genuine error, not a benign no-op, and must reach the user. | |||
| 546 | + | func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) { | |||
| 547 | + | p1, release1 := newBlockingPTY(t) | |||
| 548 | + | p2, release2 := newBlockingPTY(t) | |||
| 549 | + | defer release1() | |||
| 550 | + | defer release2() | |||
| 551 | + | d, sess1, ac, _ := newManualSessionWithPTYs(t, p1) | |||
| 552 | + | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |||
| 553 | + | defer cancel2() | |||
| 554 | + | tab2 := newTestTabWithContext(p2, sctx2, cancel2) | |||
| 555 | + | tab2.attention = true | |||
| 556 | + | tab2.attentionAt = time.Unix(9, 0) | |||
| 557 | + | sess2 := &session{id: "other", name: "other", ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}} | |||
| 558 | + | d.sessions[sess2.id] = sess2 | |||
| 559 | + | ||||
| 560 | + | sess1.mu.Lock() | |||
| 561 | + | sess1.client = nil | |||
| 562 | + | sess1.mu.Unlock() | |||
| 563 | + | ||||
| 564 | + | err := d.jumpAttention(sess1, ac) | |||
| 565 | + | ||||
| 566 | + | require.Error(t, err) | |||
| 567 | + | require.Same(t, sess1, ac.currentSession(), "a failed switch must leave the client on its origin session") | |||
| 568 | + | ||||
| 569 | + | d.reportError(sess1, err) | |||
| 570 | + | history := d.notices.history() | |||
| 571 | + | require.Len(t, history, 1) | |||
| 572 | + | require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code) | |||
| 573 | + | } | |||
| 574 | + | ||||
| 540 | func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) { | 575 | func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) { | ||
| 541 | d, sess, _, sends, releases := newManualTabSession(t, 2) | 576 | d, sess, _, sends, releases := newManualTabSession(t, 2) | ||
| 542 | defer releases[0]() | 577 | defer releases[0]() | ||
M internal/usecase/daemon/input.go +16 -5
| @@ -286,19 +286,30 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 286 | 286 | case keys.ActionOpenPalette: | |
| 287 | 287 | h.d.enterPalette(sess, h.ac) | |
| 288 | 288 | case keys.ActionJumpAttention: | |
| 289 | - | h.d.jumpAttention(sess, h.ac) | |
| 289 | + | if err := h.d.jumpAttention(sess, h.ac); err != nil { | |
| 290 | + | h.d.reportError(sess, err) | |
| 291 | + | } | |
| 290 | 292 | case keys.ActionToggleFloatingPane: | |
| 291 | 293 | if err := h.d.toggleFloating(sess, h.ac); err != nil { | |
| 292 | 294 | h.d.log.Warn("toggle floating pane failed", "err", err) | |
| 295 | + | h.d.reportError(sess, err) | |
| 293 | 296 | } | |
| 294 | 297 | case keys.ActionFocusPaneLeft: | |
| 295 | - | _ = h.d.focusDir(sess, h.ac, layout.Left) | |
| 298 | + | if err := h.d.focusDir(sess, h.ac, layout.Left); err != nil { | |
| 299 | + | h.d.reportError(sess, err) | |
| 300 | + | } | |
| 296 | 301 | case keys.ActionFocusPaneRight: | |
| 297 | - | _ = h.d.focusDir(sess, h.ac, layout.Right) | |
| 302 | + | if err := h.d.focusDir(sess, h.ac, layout.Right); err != nil { | |
| 303 | + | h.d.reportError(sess, err) | |
| 304 | + | } | |
| 298 | 305 | case keys.ActionFocusPaneUp: | |
| 299 | - | _ = h.d.focusDir(sess, h.ac, layout.Up) | |
| 306 | + | if err := h.d.focusDir(sess, h.ac, layout.Up); err != nil { | |
| 307 | + | h.d.reportError(sess, err) | |
| 308 | + | } | |
| 300 | 309 | case keys.ActionFocusPaneDown: | |
| 301 | - | _ = h.d.focusDir(sess, h.ac, layout.Down) | |
| 310 | + | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { | |
| 311 | + | h.d.reportError(sess, err) | |
| 312 | + | } | |
| 302 | 313 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 303 | 314 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 304 | 315 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
| @@ -286,19 +286,30 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 286 | case keys.ActionOpenPalette: | 286 | case keys.ActionOpenPalette: | ||
| 287 | h.d.enterPalette(sess, h.ac) | 287 | h.d.enterPalette(sess, h.ac) | ||
| 288 | case keys.ActionJumpAttention: | 288 | case keys.ActionJumpAttention: | ||
| 289 | - | h.d.jumpAttention(sess, h.ac) | 289 | + | if err := h.d.jumpAttention(sess, h.ac); err != nil { |
| 290 | + | h.d.reportError(sess, err) | |||
| 291 | + | } | |||
| 290 | case keys.ActionToggleFloatingPane: | 292 | case keys.ActionToggleFloatingPane: | ||
| 291 | if err := h.d.toggleFloating(sess, h.ac); err != nil { | 293 | if err := h.d.toggleFloating(sess, h.ac); err != nil { | ||
| 292 | h.d.log.Warn("toggle floating pane failed", "err", err) | 294 | h.d.log.Warn("toggle floating pane failed", "err", err) | ||
| 295 | + | h.d.reportError(sess, err) | |||
| 293 | } | 296 | } | ||
| 294 | case keys.ActionFocusPaneLeft: | 297 | case keys.ActionFocusPaneLeft: | ||
| 295 | - | _ = h.d.focusDir(sess, h.ac, layout.Left) | 298 | + | if err := h.d.focusDir(sess, h.ac, layout.Left); err != nil { |
| 299 | + | h.d.reportError(sess, err) | |||
| 300 | + | } | |||
| 296 | case keys.ActionFocusPaneRight: | 301 | case keys.ActionFocusPaneRight: | ||
| 297 | - | _ = h.d.focusDir(sess, h.ac, layout.Right) | 302 | + | if err := h.d.focusDir(sess, h.ac, layout.Right); err != nil { |
| 303 | + | h.d.reportError(sess, err) | |||
| 304 | + | } | |||
| 298 | case keys.ActionFocusPaneUp: | 305 | case keys.ActionFocusPaneUp: | ||
| 299 | - | _ = h.d.focusDir(sess, h.ac, layout.Up) | 306 | + | if err := h.d.focusDir(sess, h.ac, layout.Up); err != nil { |
| 307 | + | h.d.reportError(sess, err) | |||
| 308 | + | } | |||
| 300 | case keys.ActionFocusPaneDown: | 309 | case keys.ActionFocusPaneDown: | ||
| 301 | - | _ = h.d.focusDir(sess, h.ac, layout.Down) | 310 | + | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { |
| 311 | + | h.d.reportError(sess, err) | |||
| 312 | + | } | |||
| 302 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | 313 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | ||
| 303 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | 314 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | ||
| 304 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 315 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
M internal/usecase/daemon/input_test.go +29 -0
| @@ -329,6 +329,35 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |||
| 329 | 329 | requirePTYWrite(t, floatingWrites, []byte("x")) | |
| 330 | 330 | } | |
| 331 | 331 | ||
| 332 | + | // TestActionFocusPaneAtEdgeStaysSilent drives a directional focus move with | |
| 333 | + | // no pane on that side (the session has a single, unsplit pane). This is | |
| 334 | + | // routine navigation, not a failure, and must never produce a notice. | |
| 335 | + | func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) { | |
| 336 | + | d, _, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 337 | + | ||
| 338 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft) | |
| 339 | + | ||
| 340 | + | require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent") | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // TestActionFocusPaneGenuineErrorReportsNoticeInternal drives a directional | |
| 344 | + | // focus move against a tab with no layout tree at all — a genuine structural | |
| 345 | + | // error distinct from "no pane in that direction" — and asserts it reaches | |
| 346 | + | // the user as a notice instead of being silently discarded. | |
| 347 | + | func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) { | |
| 348 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 349 | + | tb := sess.activeTab() | |
| 350 | + | tb.mu.Lock() | |
| 351 | + | tb.tree = nil | |
| 352 | + | tb.mu.Unlock() | |
| 353 | + | ||
| 354 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft) | |
| 355 | + | ||
| 356 | + | history := d.notices.history() | |
| 357 | + | require.Len(t, history, 1, "a genuine layout-tree error must surface exactly one notice") | |
| 358 | + | require.Equal(t, domain.NoticeInternal, history[0].Code) | |
| 359 | + | } | |
| 360 | + | ||
| 332 | 361 | func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) { | |
| 333 | 362 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 334 | 363 | first := sess.activeTab() | |
| @@ -329,6 +329,35 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |||||
| 329 | requirePTYWrite(t, floatingWrites, []byte("x")) | 329 | requirePTYWrite(t, floatingWrites, []byte("x")) | ||
| 330 | } | 330 | } | ||
| 331 | 331 | ||||
| 332 | + | // TestActionFocusPaneAtEdgeStaysSilent drives a directional focus move with | |||
| 333 | + | // no pane on that side (the session has a single, unsplit pane). This is | |||
| 334 | + | // routine navigation, not a failure, and must never produce a notice. | |||
| 335 | + | func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) { | |||
| 336 | + | d, _, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 337 | + | ||||
| 338 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft) | |||
| 339 | + | ||||
| 340 | + | require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent") | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // TestActionFocusPaneGenuineErrorReportsNoticeInternal drives a directional | |||
| 344 | + | // focus move against a tab with no layout tree at all — a genuine structural | |||
| 345 | + | // error distinct from "no pane in that direction" — and asserts it reaches | |||
| 346 | + | // the user as a notice instead of being silently discarded. | |||
| 347 | + | func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) { | |||
| 348 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 349 | + | tb := sess.activeTab() | |||
| 350 | + | tb.mu.Lock() | |||
| 351 | + | tb.tree = nil | |||
| 352 | + | tb.mu.Unlock() | |||
| 353 | + | ||||
| 354 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft) | |||
| 355 | + | ||||
| 356 | + | history := d.notices.history() | |||
| 357 | + | require.Len(t, history, 1, "a genuine layout-tree error must surface exactly one notice") | |||
| 358 | + | require.Equal(t, domain.NoticeInternal, history[0].Code) | |||
| 359 | + | } | |||
| 360 | + | ||||
| 332 | func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) { | 361 | func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) { | ||
| 333 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | 362 | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 334 | first := sess.activeTab() | 363 | first := sess.activeTab() | ||
M internal/usecase/daemon/palette_test.go +6 -4
| @@ -938,10 +938,12 @@ func TestPaletteExecMethods(t *testing.T) { | |||
| 938 | 938 | require.NoError(t, exec.StackPane()) | |
| 939 | 939 | require.NoError(t, exec.ToggleStack()) | |
| 940 | 940 | require.NoError(t, exec.ClosePane()) | |
| 941 | - | require.NoError(t, exec.FocusPaneLeft()) | |
| 942 | - | require.NoError(t, exec.FocusPaneRight()) | |
| 943 | - | require.NoError(t, exec.FocusPaneUp()) | |
| 944 | - | require.NoError(t, exec.FocusPaneDown()) | |
| 941 | + | // A single pane remains: every direction is a benign no-neighbor move, | |
| 942 | + | // not an error the palette should ever surface (Task 10). | |
| 943 | + | require.ErrorIs(t, exec.FocusPaneLeft(), errNoNeighbor) | |
| 944 | + | require.ErrorIs(t, exec.FocusPaneRight(), errNoNeighbor) | |
| 945 | + | require.ErrorIs(t, exec.FocusPaneUp(), errNoNeighbor) | |
| 946 | + | require.ErrorIs(t, exec.FocusPaneDown(), errNoNeighbor) | |
| 945 | 947 | require.NoError(t, exec.CloseTab()) | |
| 946 | 948 | require.Len(t, sess.tabs, 2) | |
| 947 | 949 | // Drain paints generated by methods above. | |
| @@ -938,10 +938,12 @@ func TestPaletteExecMethods(t *testing.T) { | |||||
| 938 | require.NoError(t, exec.StackPane()) | 938 | require.NoError(t, exec.StackPane()) | ||
| 939 | require.NoError(t, exec.ToggleStack()) | 939 | require.NoError(t, exec.ToggleStack()) | ||
| 940 | require.NoError(t, exec.ClosePane()) | 940 | require.NoError(t, exec.ClosePane()) | ||
| 941 | - | require.NoError(t, exec.FocusPaneLeft()) | 941 | + | // A single pane remains: every direction is a benign no-neighbor move, |
| 942 | - | require.NoError(t, exec.FocusPaneRight()) | 942 | + | // not an error the palette should ever surface (Task 10). |
| 943 | - | require.NoError(t, exec.FocusPaneUp()) | 943 | + | require.ErrorIs(t, exec.FocusPaneLeft(), errNoNeighbor) |
| 944 | - | require.NoError(t, exec.FocusPaneDown()) | 944 | + | require.ErrorIs(t, exec.FocusPaneRight(), errNoNeighbor) |
| 945 | + | require.ErrorIs(t, exec.FocusPaneUp(), errNoNeighbor) | |||
| 946 | + | require.ErrorIs(t, exec.FocusPaneDown(), errNoNeighbor) | |||
| 945 | require.NoError(t, exec.CloseTab()) | 947 | require.NoError(t, exec.CloseTab()) | ||
| 946 | require.Len(t, sess.tabs, 2) | 948 | require.Len(t, sess.tabs, 2) | ||
| 947 | // Drain paints generated by methods above. | 949 | // Drain paints generated by methods above. | ||
M internal/usecase/daemon/pane_actions.go +1 -1
| @@ -317,7 +317,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 317 | 317 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 318 | 318 | } | |
| 319 | 319 | if errors.Is(err, layout.ErrNoPane) { | |
| 320 | - | return nil | |
| 320 | + | return errNoNeighbor | |
| 321 | 321 | } | |
| 322 | 322 | return err | |
| 323 | 323 | } | |
| @@ -317,7 +317,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||||
| 317 | d.invalidateRender(sess, ac, true, "pane_actions.go") | 317 | d.invalidateRender(sess, ac, true, "pane_actions.go") | ||
| 318 | } | 318 | } | ||
| 319 | if errors.Is(err, layout.ErrNoPane) { | 319 | if errors.Is(err, layout.ErrNoPane) { | ||
| 320 | - | return nil | 320 | + | return errNoNeighbor |
| 321 | } | 321 | } | ||
| 322 | return err | 322 | return err | ||
| 323 | } | 323 | } | ||