detached c41c8a7

Merge pull request #131 from bnema/feat/navigation-overflow

feat(navigation): overflow focus across tabs and sessions
commit
c41c8a7acc0ccb3797cf5ebb08b2a2bead5bfae2
author
bnema · · 2026-07-25 08:17 UTC
committer
GitHub · · 2026-07-25 08:17 UTC
merge of
c240dab + af99e02 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +1180 -56
M docs/configuration.md +10 -0
@@ -19,6 +19,10 @@ floating.command =
1919 floating.width = 80%
2020 floating.height = 80%
2121
22+# Let directional keyboard focus continue past a pane edge. Both default off.
23+nav.overflow-tabs = off
24+nav.overflow-sessions = off
25+
2226 # Show the focused pane's terminal title in tab labels; off keeps the process name only.
2327 tabs.terminal-title = on
2428
@@ -84,6 +88,12 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b
8488
8589 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
8690
91+## Navigation overflow
92+
93+Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.
94+
95+Overflow applies only to keyboard focus actions; mouse navigation does not overflow, and floating panes never overflow. Hot reload applies either setting to subsequent navigation without restarting the daemon.
96+
8797 ## Copy mode
8898
8999 Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy.
@@ -19,6 +19,10 @@ floating.command =
19 floating.width = 80%19 floating.width = 80%
20 floating.height = 80%20 floating.height = 80%
21 21
22+# Let directional keyboard focus continue past a pane edge. Both default off.
23+nav.overflow-tabs = off
24+nav.overflow-sessions = off
25+
22 # Show the focused pane's terminal title in tab labels; off keeps the process name only.26 # Show the focused pane's terminal title in tab labels; off keeps the process name only.
23 tabs.terminal-title = on27 tabs.terminal-title = on
24 28
@@ -84,6 +88,12 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b
84 88
85 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.89 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
86 90
91+## Navigation overflow
92+
93+Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.
94+
95+Overflow applies only to keyboard focus actions; mouse navigation does not overflow, and floating panes never overflow. Hot reload applies either setting to subsequent navigation without restarting the daemon.
96+
87 ## Copy mode97 ## Copy mode
88 98
89 Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy.99 Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy.
M internal/adapters/config/config.go +17 -0
@@ -33,6 +33,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3333 seenFloatingKeys := make(map[string]bool)
3434 seenCopyKeys := make(map[string]bool)
3535 seenPaletteKeys := make(map[string]bool)
36+ seenNavKeys := make(map[string]bool)
3637 seenTabsKeys := make(map[string]bool)
3738
3839 scanner := bufio.NewScanner(r)
@@ -118,6 +119,22 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
118119 continue
119120 }
120121 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
122+ case key == "nav.overflow-tabs":
123+ warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
124+ on, ok := parseOnOff(value)
125+ if !ok {
126+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)})
127+ continue
128+ }
129+ cfg.Nav.OverflowTabs = on
130+ case key == "nav.overflow-sessions":
131+ warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
132+ on, ok := parseOnOff(value)
133+ if !ok {
134+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)})
135+ continue
136+ }
137+ cfg.Nav.OverflowSessions = on
121138 case key == "tabs.terminal-title":
122139 warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
123140 on, ok := parseOnOff(value)
@@ -33,6 +33,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
33 seenFloatingKeys := make(map[string]bool)33 seenFloatingKeys := make(map[string]bool)
34 seenCopyKeys := make(map[string]bool)34 seenCopyKeys := make(map[string]bool)
35 seenPaletteKeys := make(map[string]bool)35 seenPaletteKeys := make(map[string]bool)
36+ seenNavKeys := make(map[string]bool)
36 seenTabsKeys := make(map[string]bool)37 seenTabsKeys := make(map[string]bool)
37 38
38 scanner := bufio.NewScanner(r)39 scanner := bufio.NewScanner(r)
@@ -118,6 +119,22 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
118 continue119 continue
119 }120 }
120 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}121 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
122+ case key == "nav.overflow-tabs":
123+ warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
124+ on, ok := parseOnOff(value)
125+ if !ok {
126+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)})
127+ continue
128+ }
129+ cfg.Nav.OverflowTabs = on
130+ case key == "nav.overflow-sessions":
131+ warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
132+ on, ok := parseOnOff(value)
133+ if !ok {
134+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)})
135+ continue
136+ }
137+ cfg.Nav.OverflowSessions = on
121 case key == "tabs.terminal-title":138 case key == "tabs.terminal-title":
122 warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)139 warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
123 on, ok := parseOnOff(value)140 on, ok := parseOnOff(value)
M internal/adapters/config/config_test.go +77 -0
@@ -476,6 +476,83 @@ func TestParseTabsTerminalTitle(t *testing.T) {
476476 }
477477 }
478478
479+func TestParseNavigationOverflow(t *testing.T) {
480+ t.Parallel()
481+
482+ tests := []struct {
483+ name string
484+ input string
485+ want domain.NavConfig
486+ wantWarnings []domain.Warning
487+ }{
488+ {
489+ name: "absent defaults both settings off",
490+ },
491+ {
492+ name: "tabs on enables only tab overflow",
493+ input: "nav.overflow-tabs = on\n",
494+ want: domain.NavConfig{OverflowTabs: true},
495+ },
496+ {
497+ name: "sessions on enables only session overflow",
498+ input: "nav.overflow-sessions = on\n",
499+ want: domain.NavConfig{OverflowSessions: true},
500+ },
501+ {
502+ name: "off disables both settings",
503+ input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
504+ },
505+ {
506+ name: "settings are independent",
507+ input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
508+ want: domain.NavConfig{OverflowTabs: true},
509+ },
510+ {
511+ name: "invalid values warn and preserve defaults",
512+ input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
513+ wantWarnings: []domain.Warning{
514+ {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
515+ {Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
516+ },
517+ },
518+ {
519+ name: "duplicate keys warn and last valid value wins",
520+ input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
521+ want: domain.NavConfig{OverflowTabs: true},
522+ wantWarnings: []domain.Warning{
523+ {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
524+ {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
525+ },
526+ },
527+ {
528+ name: "invalid duplicate still warns and keeps previous value",
529+ input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
530+ want: domain.NavConfig{OverflowTabs: true},
531+ wantWarnings: []domain.Warning{
532+ {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
533+ {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
534+ },
535+ },
536+ }
537+
538+ for _, tt := range tests {
539+ t.Run(tt.name, func(t *testing.T) {
540+ t.Parallel()
541+
542+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
543+ require.NoError(t, err)
544+ require.Equal(t, tt.want, cfg.Nav)
545+ require.Equal(t, tt.wantWarnings, warnings)
546+ })
547+ }
548+}
549+
550+func TestDefaultsDisableNavigationOverflow(t *testing.T) {
551+ t.Parallel()
552+
553+ require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
554+}
555+
479556 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
480557 t.Parallel()
481558
@@ -476,6 +476,83 @@ func TestParseTabsTerminalTitle(t *testing.T) {
476 }476 }
477 }477 }
478 478
479+func TestParseNavigationOverflow(t *testing.T) {
480+ t.Parallel()
481+
482+ tests := []struct {
483+ name string
484+ input string
485+ want domain.NavConfig
486+ wantWarnings []domain.Warning
487+ }{
488+ {
489+ name: "absent defaults both settings off",
490+ },
491+ {
492+ name: "tabs on enables only tab overflow",
493+ input: "nav.overflow-tabs = on\n",
494+ want: domain.NavConfig{OverflowTabs: true},
495+ },
496+ {
497+ name: "sessions on enables only session overflow",
498+ input: "nav.overflow-sessions = on\n",
499+ want: domain.NavConfig{OverflowSessions: true},
500+ },
501+ {
502+ name: "off disables both settings",
503+ input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
504+ },
505+ {
506+ name: "settings are independent",
507+ input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
508+ want: domain.NavConfig{OverflowTabs: true},
509+ },
510+ {
511+ name: "invalid values warn and preserve defaults",
512+ input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
513+ wantWarnings: []domain.Warning{
514+ {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
515+ {Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
516+ },
517+ },
518+ {
519+ name: "duplicate keys warn and last valid value wins",
520+ input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
521+ want: domain.NavConfig{OverflowTabs: true},
522+ wantWarnings: []domain.Warning{
523+ {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
524+ {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
525+ },
526+ },
527+ {
528+ name: "invalid duplicate still warns and keeps previous value",
529+ input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
530+ want: domain.NavConfig{OverflowTabs: true},
531+ wantWarnings: []domain.Warning{
532+ {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
533+ {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
534+ },
535+ },
536+ }
537+
538+ for _, tt := range tests {
539+ t.Run(tt.name, func(t *testing.T) {
540+ t.Parallel()
541+
542+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
543+ require.NoError(t, err)
544+ require.Equal(t, tt.want, cfg.Nav)
545+ require.Equal(t, tt.wantWarnings, warnings)
546+ })
547+ }
548+}
549+
550+func TestDefaultsDisableNavigationOverflow(t *testing.T) {
551+ t.Parallel()
552+
553+ require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
554+}
555+
479 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {556 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
480 t.Parallel()557 t.Parallel()
481 558
M internal/adapters/term/resize_test.go +3 -0
@@ -58,9 +58,11 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
5858 quit := make(chan struct{})
5959
6060 var calls int
61+ firstGetSize := make(chan struct{})
6162 getSize := func() (domain.Size, error) {
6263 calls++
6364 if calls == 1 {
65+ close(firstGetSize)
6466 return domain.Size{}, errors.New("boom")
6567 }
6668 return domain.Size{Cols: 10, Rows: 5}, nil
@@ -69,6 +71,7 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
6971 go resizeLoop(sig, out, quit, getSize)
7072
7173 sig <- syscall.SIGWINCH // errors; no emission
74+ <-firstGetSize // wait until the failed signal has been handled
7275 sig <- syscall.SIGWINCH // succeeds
7376
7477 got := <-out
@@ -58,9 +58,11 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
58 quit := make(chan struct{})58 quit := make(chan struct{})
59 59
60 var calls int60 var calls int
61+ firstGetSize := make(chan struct{})
61 getSize := func() (domain.Size, error) {62 getSize := func() (domain.Size, error) {
62 calls++63 calls++
63 if calls == 1 {64 if calls == 1 {
65+ close(firstGetSize)
64 return domain.Size{}, errors.New("boom")66 return domain.Size{}, errors.New("boom")
65 }67 }
66 return domain.Size{Cols: 10, Rows: 5}, nil68 return domain.Size{Cols: 10, Rows: 5}, nil
@@ -69,6 +71,7 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
69 go resizeLoop(sig, out, quit, getSize)71 go resizeLoop(sig, out, quit, getSize)
70 72
71 sig <- syscall.SIGWINCH // errors; no emission73 sig <- syscall.SIGWINCH // errors; no emission
74+ <-firstGetSize // wait until the failed signal has been handled
72 sig <- syscall.SIGWINCH // succeeds75 sig <- syscall.SIGWINCH // succeeds
73 76
74 got := <-out77 got := <-out
M internal/domain/config.go +7 -0
@@ -69,6 +69,12 @@ type FloatingConfig struct {
6969 Height int
7070 }
7171
72+// NavConfig contains directional navigation overflow settings.
73+type NavConfig struct {
74+ OverflowTabs bool
75+ OverflowSessions bool
76+}
77+
7278 // TabsConfig contains tab label display settings.
7379 type TabsConfig struct {
7480 // TerminalTitle includes the focused pane's OSC terminal title in tab
@@ -90,6 +96,7 @@ type Config struct {
9096 Copy CopyConfig
9197 Palette PaletteConfig
9298 Floating FloatingConfig
99+ Nav NavConfig
93100 Tabs TabsConfig
94101 }
95102
@@ -69,6 +69,12 @@ type FloatingConfig struct {
69 Height int69 Height int
70 }70 }
71 71
72+// NavConfig contains directional navigation overflow settings.
73+type NavConfig struct {
74+ OverflowTabs bool
75+ OverflowSessions bool
76+}
77+
72 // TabsConfig contains tab label display settings.78 // TabsConfig contains tab label display settings.
73 type TabsConfig struct {79 type TabsConfig struct {
74 // TerminalTitle includes the focused pane's OSC terminal title in tab80 // TerminalTitle includes the focused pane's OSC terminal title in tab
@@ -90,6 +96,7 @@ type Config struct {
90 Copy CopyConfig96 Copy CopyConfig
91 Palette PaletteConfig97 Palette PaletteConfig
92 Floating FloatingConfig98 Floating FloatingConfig
99+ Nav NavConfig
93 Tabs TabsConfig100 Tabs TabsConfig
94 }101 }
95 102
M internal/usecase/daemon/config.go +9 -0
@@ -56,6 +56,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
5656 d.copyConfig.Store(&copyConfig)
5757 palette := cfg.Palette
5858 d.paletteConfig.Store(&palette)
59+ nav := cfg.Nav
60+ d.navConfig.Store(&nav)
5961 tabs := cfg.Tabs
6062 d.tabsConfig.Store(&tabs)
6163 d.storeThemeConfig(cfg)
@@ -262,6 +264,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
262264 return domain.Defaults().Palette
263265 }
264266
267+func (d *Daemon) currentNavConfig() domain.NavConfig {
268+ if cfg := d.navConfig.Load(); cfg != nil {
269+ return *cfg
270+ }
271+ return domain.Defaults().Nav
272+}
273+
265274 func (d *Daemon) currentTabsConfig() domain.TabsConfig {
266275 if cfg := d.tabsConfig.Load(); cfg != nil {
267276 return *cfg
@@ -56,6 +56,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
56 d.copyConfig.Store(&copyConfig)56 d.copyConfig.Store(&copyConfig)
57 palette := cfg.Palette57 palette := cfg.Palette
58 d.paletteConfig.Store(&palette)58 d.paletteConfig.Store(&palette)
59+ nav := cfg.Nav
60+ d.navConfig.Store(&nav)
59 tabs := cfg.Tabs61 tabs := cfg.Tabs
60 d.tabsConfig.Store(&tabs)62 d.tabsConfig.Store(&tabs)
61 d.storeThemeConfig(cfg)63 d.storeThemeConfig(cfg)
@@ -262,6 +264,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
262 return domain.Defaults().Palette264 return domain.Defaults().Palette
263 }265 }
264 266
267+func (d *Daemon) currentNavConfig() domain.NavConfig {
268+ if cfg := d.navConfig.Load(); cfg != nil {
269+ return *cfg
270+ }
271+ return domain.Defaults().Nav
272+}
273+
265 func (d *Daemon) currentTabsConfig() domain.TabsConfig {274 func (d *Daemon) currentTabsConfig() domain.TabsConfig {
266 if cfg := d.tabsConfig.Load(); cfg != nil {275 if cfg := d.tabsConfig.Load(); cfg != nil {
267 return *cfg276 return *cfg
M internal/usecase/daemon/config_test.go +18 -0
@@ -365,6 +365,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
365365 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
366366 }
367367
368+func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) {
369+ d := newTestDaemon(t, nil, stubClock{})
370+ require.Equal(t, domain.NavConfig{}, d.currentNavConfig())
371+
372+ firstConfig := domain.Defaults()
373+ firstConfig.Nav = domain.NavConfig{OverflowTabs: true}
374+ d.ApplyConfig(firstConfig)
375+ first := d.currentNavConfig()
376+
377+ secondConfig := domain.Defaults()
378+ secondConfig.Nav = domain.NavConfig{OverflowSessions: true}
379+ d.ApplyConfig(secondConfig)
380+ second := d.currentNavConfig()
381+
382+ require.Equal(t, domain.NavConfig{OverflowTabs: true}, first)
383+ require.Equal(t, domain.NavConfig{OverflowSessions: true}, second)
384+}
385+
368386 func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
369387 d := newTestDaemon(t, nil, stubClock{})
370388 require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
@@ -365,6 +365,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
365 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)365 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
366 }366 }
367 367
368+func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) {
369+ d := newTestDaemon(t, nil, stubClock{})
370+ require.Equal(t, domain.NavConfig{}, d.currentNavConfig())
371+
372+ firstConfig := domain.Defaults()
373+ firstConfig.Nav = domain.NavConfig{OverflowTabs: true}
374+ d.ApplyConfig(firstConfig)
375+ first := d.currentNavConfig()
376+
377+ secondConfig := domain.Defaults()
378+ secondConfig.Nav = domain.NavConfig{OverflowSessions: true}
379+ d.ApplyConfig(secondConfig)
380+ second := d.currentNavConfig()
381+
382+ require.Equal(t, domain.NavConfig{OverflowTabs: true}, first)
383+ require.Equal(t, domain.NavConfig{OverflowSessions: true}, second)
384+}
385+
368 func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {386 func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
369 d := newTestDaemon(t, nil, stubClock{})387 d := newTestDaemon(t, nil, stubClock{})
370 require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())388 require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
M internal/usecase/daemon/control.go +4 -12
@@ -265,7 +265,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
265265 }
266266 return nil
267267 case daemonActionFocusPane:
268- return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
268+ _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
269+ return err
269270 case daemonActionNextTab:
270271 return a.switchRelative(target.session, 1)
271272 case daemonActionPreviousTab:
@@ -349,17 +350,8 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att
349350 return
350351 }
351352 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
352- tb := request.target.tab
353- tb.mu.Lock()
354- newFocus := tb.tree.Focus
355- pl, hasPlacement := focusedPlacementLocked(tb)
356- tb.mu.Unlock()
357- if newFocus != request.target.pane.id {
358- d.exitCopyMode(ac)
359- if hasPlacement && pl.InStack {
360- d.refreshPaneTitleOnFocus(request.target.session, newFocus)
361- }
362- }
353+ d.finishPaneFocusForClient(request.target.session, ac, request.target.tab, request.target.pane.id, producer)
354+ return
363355 }
364356 d.invalidateRender(request.target.session, ac, true, producer)
365357 }
@@ -265,7 +265,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
265 }265 }
266 return nil266 return nil
267 case daemonActionFocusPane:267 case daemonActionFocusPane:
268- return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)268+ _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
269+ return err
269 case daemonActionNextTab:270 case daemonActionNextTab:
270 return a.switchRelative(target.session, 1)271 return a.switchRelative(target.session, 1)
271 case daemonActionPreviousTab:272 case daemonActionPreviousTab:
@@ -349,17 +350,8 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att
349 return350 return
350 }351 }
351 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {352 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
352- tb := request.target.tab353+ d.finishPaneFocusForClient(request.target.session, ac, request.target.tab, request.target.pane.id, producer)
353- tb.mu.Lock()354+ return
354- newFocus := tb.tree.Focus
355- pl, hasPlacement := focusedPlacementLocked(tb)
356- tb.mu.Unlock()
357- if newFocus != request.target.pane.id {
358- d.exitCopyMode(ac)
359- if hasPlacement && pl.InStack {
360- d.refreshPaneTitleOnFocus(request.target.session, newFocus)
361- }
362- }
363 }355 }
364 d.invalidateRender(request.target.session, ac, true, producer)356 d.invalidateRender(request.target.session, ac, true, producer)
365 }357 }
M internal/usecase/daemon/daemon.go +1 -0
@@ -162,6 +162,7 @@ type Daemon struct {
162162 floatingConfig atomic.Pointer[domain.FloatingConfig]
163163 copyConfig atomic.Pointer[domain.CopyConfig]
164164 paletteConfig atomic.Pointer[domain.PaletteConfig]
165+ navConfig atomic.Pointer[domain.NavConfig]
165166 tabsConfig atomic.Pointer[domain.TabsConfig]
166167 themeConfig atomic.Pointer[themeConfigSnapshot]
167168 barScripts *barScriptState
@@ -162,6 +162,7 @@ type Daemon struct {
162 floatingConfig atomic.Pointer[domain.FloatingConfig]162 floatingConfig atomic.Pointer[domain.FloatingConfig]
163 copyConfig atomic.Pointer[domain.CopyConfig]163 copyConfig atomic.Pointer[domain.CopyConfig]
164 paletteConfig atomic.Pointer[domain.PaletteConfig]164 paletteConfig atomic.Pointer[domain.PaletteConfig]
165+ navConfig atomic.Pointer[domain.NavConfig]
165 tabsConfig atomic.Pointer[domain.TabsConfig]166 tabsConfig atomic.Pointer[domain.TabsConfig]
166 themeConfig atomic.Pointer[themeConfigSnapshot]167 themeConfig atomic.Pointer[themeConfigSnapshot]
167 barScripts *barScriptState168 barScripts *barScriptState
A internal/usecase/daemon/navigation_overflow.go +244 -0
@@ -0,0 +1,244 @@
1+package daemon
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+)
10+
11+type overflowKind uint8
12+
13+const (
14+ overflowNone overflowKind = iota
15+ overflowTabs
16+ overflowSessions
17+)
18+
19+type overflowStep struct {
20+ kind overflowKind
21+ delta int
22+}
23+
24+// resolveOverflow is the pure escalation policy for directional navigation.
25+// The caller supplies its position on the selected axis, so disabled axes and
26+// either hard wall all resolve to the zero step.
27+func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep {
28+ var step overflowStep
29+ switch dir {
30+ case layout.Left:
31+ if cfg.OverflowTabs {
32+ step = overflowStep{kind: overflowTabs, delta: -1}
33+ }
34+ case layout.Right:
35+ if cfg.OverflowTabs {
36+ step = overflowStep{kind: overflowTabs, delta: 1}
37+ }
38+ case layout.Up:
39+ if cfg.OverflowSessions {
40+ step = overflowStep{kind: overflowSessions, delta: -1}
41+ }
42+ case layout.Down:
43+ if cfg.OverflowSessions {
44+ step = overflowStep{kind: overflowSessions, delta: 1}
45+ }
46+ }
47+ if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count {
48+ return overflowStep{}
49+ }
50+ return step
51+}
52+
53+// overflowSourceEligible validates that directional overflow still originates
54+// from the active tiled tab. The session-to-tab lock order matches tab overflow
55+// commit and leaves no lock held across a session handoff.
56+func overflowSourceEligible(sess *session, expectedSource *tab) bool {
57+ if sess == nil || expectedSource == nil {
58+ return false
59+ }
60+ sess.mu.Lock()
61+ defer sess.mu.Unlock()
62+ if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
63+ return false
64+ }
65+ expectedSource.mu.Lock()
66+ defer expectedSource.mu.Unlock()
67+ return expectedSource.floating.state != floatingVisible
68+}
69+
70+type sessionOverflowSnapshot struct {
71+ session *session
72+ id domain.SessionID
73+ name string
74+}
75+
76+// prepareSessionOverflow snapshots only the live registry entries, then sorts
77+// immutable names exactly like the picker. No daemon or session lock survives
78+// the snapshot, sorting, or the later switchToTarget call.
79+func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, cfg domain.NavConfig) (picker.Target, bool) {
80+ if current == nil || !cfg.OverflowSessions || (dir != layout.Up && dir != layout.Down) {
81+ return picker.Target{}, false
82+ }
83+ current.mu.Lock()
84+ currentID := current.id
85+ current.mu.Unlock()
86+
87+ d.mu.Lock()
88+ live := make([]*session, 0, len(d.sessions))
89+ for _, sess := range d.sessions {
90+ live = append(live, sess)
91+ }
92+ d.mu.Unlock()
93+
94+ snapshots := make([]sessionOverflowSnapshot, 0, len(live))
95+ for _, sess := range live {
96+ sess.mu.Lock()
97+ snapshot := sessionOverflowSnapshot{session: sess, id: sess.id, name: sess.name}
98+ sess.mu.Unlock()
99+ snapshots = append(snapshots, snapshot)
100+ }
101+ sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].name < snapshots[j].name })
102+
103+ position := -1
104+ for i, snapshot := range snapshots {
105+ if snapshot.session == current && snapshot.id == currentID {
106+ position = i
107+ break
108+ }
109+ }
110+ step := resolveOverflow(dir, cfg, position, len(snapshots))
111+ if step.kind != overflowSessions {
112+ return picker.Target{}, false
113+ }
114+ target := snapshots[position+step.delta]
115+ return picker.Target{Session: target.id, TabIndex: -1}, true
116+}
117+
118+// commitSessionOverflow carries its source constraint into the handoff so tab
119+// identity and floating visibility are revalidated while source ownership is
120+// transferred under the daemon -> routing -> session -> tab lock order.
121+func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {
122+ if from == nil || ac == nil || expectedSource == nil {
123+ return errNoNeighbor
124+ }
125+ return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{expectedSource: expectedSource})
126+}
127+
128+type tabOverflowCandidate struct {
129+ source *tab
130+ target *tab
131+ sourceIndex int
132+ delta int
133+ direction layout.Direction
134+ span domain.Rect
135+ entry layout.PaneID
136+ entryPane *pane
137+ targetOldFocus layout.PaneID
138+}
139+
140+// prepareTabOverflow captures tab identities under the session lock and uses
141+// EntryPane's pure lookup before any active-tab state is changed.
142+func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
143+ if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
144+ return tabOverflowCandidate{}, false
145+ }
146+ sess.mu.Lock()
147+ if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
148+ sess.mu.Unlock()
149+ return tabOverflowCandidate{}, false
150+ }
151+ sourceIndex := sess.active
152+ targetIndex := sourceIndex + delta
153+ if targetIndex < 0 || targetIndex >= len(sess.tabs) {
154+ sess.mu.Unlock()
155+ return tabOverflowCandidate{}, false
156+ }
157+ source := sess.tabs[sourceIndex]
158+ target := sess.tabs[targetIndex]
159+ sess.mu.Unlock()
160+
161+ source.mu.Lock()
162+ floatingVisible := source.floating.state == floatingVisible
163+ source.mu.Unlock()
164+ if floatingVisible {
165+ return tabOverflowCandidate{}, false
166+ }
167+
168+ target.mu.Lock()
169+ if target.tree == nil {
170+ target.mu.Unlock()
171+ return tabOverflowCandidate{}, false
172+ }
173+ area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}
174+ entry, err := target.tree.EntryPane(dir, span, area)
175+ entryPane := target.panes[entry]
176+ oldFocus := target.tree.Focus
177+ valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)
178+ target.mu.Unlock()
179+ if !valid {
180+ return tabOverflowCandidate{}, false
181+ }
182+ return tabOverflowCandidate{
183+ source: source,
184+ target: target,
185+ sourceIndex: sourceIndex,
186+ delta: delta,
187+ direction: dir,
188+ span: span,
189+ entry: entry,
190+ entryPane: entryPane,
191+ targetOldFocus: oldFocus,
192+ }, true
193+}
194+
195+// commitTabOverflow atomically revalidates the source and target tab entries.
196+// The target layout and selected pane are revalidated while locked before the
197+// focus and active-tab mutations become visible together.
198+func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
199+ if sess == nil || candidate.source == nil || candidate.target == nil {
200+ return false
201+ }
202+ sess.mu.Lock()
203+ targetIndex := candidate.sourceIndex + candidate.delta
204+ if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
205+ sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
206+ sess.mu.Unlock()
207+ return false
208+ }
209+
210+ candidate.source.mu.Lock()
211+ if candidate.source.floating.state == floatingVisible {
212+ candidate.source.mu.Unlock()
213+ sess.mu.Unlock()
214+ return false
215+ }
216+ candidate.target.mu.Lock()
217+ if candidate.target.tree == nil {
218+ candidate.target.mu.Unlock()
219+ candidate.source.mu.Unlock()
220+ sess.mu.Unlock()
221+ return false
222+ }
223+ area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}
224+ entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)
225+ if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {
226+ candidate.target.mu.Unlock()
227+ candidate.source.mu.Unlock()
228+ sess.mu.Unlock()
229+ return false
230+ }
231+ if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
232+ candidate.target.mu.Unlock()
233+ candidate.source.mu.Unlock()
234+ sess.mu.Unlock()
235+ return false
236+ }
237+ d.applyLayoutLocked(candidate.target)
238+ sess.active = targetIndex
239+ candidate.target.mu.Unlock()
240+ candidate.source.mu.Unlock()
241+ sess.mu.Unlock()
242+ markSnapshotDirty(sess)
243+ return true
244+}
@@ -0,0 +1,244 @@
1+package daemon
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+)
10+
11+type overflowKind uint8
12+
13+const (
14+ overflowNone overflowKind = iota
15+ overflowTabs
16+ overflowSessions
17+)
18+
19+type overflowStep struct {
20+ kind overflowKind
21+ delta int
22+}
23+
24+// resolveOverflow is the pure escalation policy for directional navigation.
25+// The caller supplies its position on the selected axis, so disabled axes and
26+// either hard wall all resolve to the zero step.
27+func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep {
28+ var step overflowStep
29+ switch dir {
30+ case layout.Left:
31+ if cfg.OverflowTabs {
32+ step = overflowStep{kind: overflowTabs, delta: -1}
33+ }
34+ case layout.Right:
35+ if cfg.OverflowTabs {
36+ step = overflowStep{kind: overflowTabs, delta: 1}
37+ }
38+ case layout.Up:
39+ if cfg.OverflowSessions {
40+ step = overflowStep{kind: overflowSessions, delta: -1}
41+ }
42+ case layout.Down:
43+ if cfg.OverflowSessions {
44+ step = overflowStep{kind: overflowSessions, delta: 1}
45+ }
46+ }
47+ if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count {
48+ return overflowStep{}
49+ }
50+ return step
51+}
52+
53+// overflowSourceEligible validates that directional overflow still originates
54+// from the active tiled tab. The session-to-tab lock order matches tab overflow
55+// commit and leaves no lock held across a session handoff.
56+func overflowSourceEligible(sess *session, expectedSource *tab) bool {
57+ if sess == nil || expectedSource == nil {
58+ return false
59+ }
60+ sess.mu.Lock()
61+ defer sess.mu.Unlock()
62+ if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
63+ return false
64+ }
65+ expectedSource.mu.Lock()
66+ defer expectedSource.mu.Unlock()
67+ return expectedSource.floating.state != floatingVisible
68+}
69+
70+type sessionOverflowSnapshot struct {
71+ session *session
72+ id domain.SessionID
73+ name string
74+}
75+
76+// prepareSessionOverflow snapshots only the live registry entries, then sorts
77+// immutable names exactly like the picker. No daemon or session lock survives
78+// the snapshot, sorting, or the later switchToTarget call.
79+func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, cfg domain.NavConfig) (picker.Target, bool) {
80+ if current == nil || !cfg.OverflowSessions || (dir != layout.Up && dir != layout.Down) {
81+ return picker.Target{}, false
82+ }
83+ current.mu.Lock()
84+ currentID := current.id
85+ current.mu.Unlock()
86+
87+ d.mu.Lock()
88+ live := make([]*session, 0, len(d.sessions))
89+ for _, sess := range d.sessions {
90+ live = append(live, sess)
91+ }
92+ d.mu.Unlock()
93+
94+ snapshots := make([]sessionOverflowSnapshot, 0, len(live))
95+ for _, sess := range live {
96+ sess.mu.Lock()
97+ snapshot := sessionOverflowSnapshot{session: sess, id: sess.id, name: sess.name}
98+ sess.mu.Unlock()
99+ snapshots = append(snapshots, snapshot)
100+ }
101+ sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].name < snapshots[j].name })
102+
103+ position := -1
104+ for i, snapshot := range snapshots {
105+ if snapshot.session == current && snapshot.id == currentID {
106+ position = i
107+ break
108+ }
109+ }
110+ step := resolveOverflow(dir, cfg, position, len(snapshots))
111+ if step.kind != overflowSessions {
112+ return picker.Target{}, false
113+ }
114+ target := snapshots[position+step.delta]
115+ return picker.Target{Session: target.id, TabIndex: -1}, true
116+}
117+
118+// commitSessionOverflow carries its source constraint into the handoff so tab
119+// identity and floating visibility are revalidated while source ownership is
120+// transferred under the daemon -> routing -> session -> tab lock order.
121+func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {
122+ if from == nil || ac == nil || expectedSource == nil {
123+ return errNoNeighbor
124+ }
125+ return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{expectedSource: expectedSource})
126+}
127+
128+type tabOverflowCandidate struct {
129+ source *tab
130+ target *tab
131+ sourceIndex int
132+ delta int
133+ direction layout.Direction
134+ span domain.Rect
135+ entry layout.PaneID
136+ entryPane *pane
137+ targetOldFocus layout.PaneID
138+}
139+
140+// prepareTabOverflow captures tab identities under the session lock and uses
141+// EntryPane's pure lookup before any active-tab state is changed.
142+func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
143+ if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
144+ return tabOverflowCandidate{}, false
145+ }
146+ sess.mu.Lock()
147+ if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
148+ sess.mu.Unlock()
149+ return tabOverflowCandidate{}, false
150+ }
151+ sourceIndex := sess.active
152+ targetIndex := sourceIndex + delta
153+ if targetIndex < 0 || targetIndex >= len(sess.tabs) {
154+ sess.mu.Unlock()
155+ return tabOverflowCandidate{}, false
156+ }
157+ source := sess.tabs[sourceIndex]
158+ target := sess.tabs[targetIndex]
159+ sess.mu.Unlock()
160+
161+ source.mu.Lock()
162+ floatingVisible := source.floating.state == floatingVisible
163+ source.mu.Unlock()
164+ if floatingVisible {
165+ return tabOverflowCandidate{}, false
166+ }
167+
168+ target.mu.Lock()
169+ if target.tree == nil {
170+ target.mu.Unlock()
171+ return tabOverflowCandidate{}, false
172+ }
173+ area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}
174+ entry, err := target.tree.EntryPane(dir, span, area)
175+ entryPane := target.panes[entry]
176+ oldFocus := target.tree.Focus
177+ valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)
178+ target.mu.Unlock()
179+ if !valid {
180+ return tabOverflowCandidate{}, false
181+ }
182+ return tabOverflowCandidate{
183+ source: source,
184+ target: target,
185+ sourceIndex: sourceIndex,
186+ delta: delta,
187+ direction: dir,
188+ span: span,
189+ entry: entry,
190+ entryPane: entryPane,
191+ targetOldFocus: oldFocus,
192+ }, true
193+}
194+
195+// commitTabOverflow atomically revalidates the source and target tab entries.
196+// The target layout and selected pane are revalidated while locked before the
197+// focus and active-tab mutations become visible together.
198+func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
199+ if sess == nil || candidate.source == nil || candidate.target == nil {
200+ return false
201+ }
202+ sess.mu.Lock()
203+ targetIndex := candidate.sourceIndex + candidate.delta
204+ if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
205+ sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
206+ sess.mu.Unlock()
207+ return false
208+ }
209+
210+ candidate.source.mu.Lock()
211+ if candidate.source.floating.state == floatingVisible {
212+ candidate.source.mu.Unlock()
213+ sess.mu.Unlock()
214+ return false
215+ }
216+ candidate.target.mu.Lock()
217+ if candidate.target.tree == nil {
218+ candidate.target.mu.Unlock()
219+ candidate.source.mu.Unlock()
220+ sess.mu.Unlock()
221+ return false
222+ }
223+ area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}
224+ entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)
225+ if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {
226+ candidate.target.mu.Unlock()
227+ candidate.source.mu.Unlock()
228+ sess.mu.Unlock()
229+ return false
230+ }
231+ if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
232+ candidate.target.mu.Unlock()
233+ candidate.source.mu.Unlock()
234+ sess.mu.Unlock()
235+ return false
236+ }
237+ d.applyLayoutLocked(candidate.target)
238+ sess.active = targetIndex
239+ candidate.target.mu.Unlock()
240+ candidate.source.mu.Unlock()
241+ sess.mu.Unlock()
242+ markSnapshotDirty(sess)
243+ return true
244+}
A internal/usecase/daemon/navigation_overflow_test.go +383 -0
@@ -0,0 +1,383 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/keys"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/internal/usecase/picker"
13+)
14+
15+func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ dir layout.Direction
19+ cfg domain.NavConfig
20+ position int
21+ count int
22+ want overflowStep
23+ }{
24+ {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2},
25+ {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2},
26+ {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}},
27+ {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}},
28+ {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
29+ {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2},
30+ {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}},
31+ {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}},
32+ {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
33+ {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1},
34+ {name: "negative position is out of range", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: -1, count: 2},
35+ {name: "position at count is out of range", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 2, count: 2},
36+ }
37+
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count))
41+ })
42+ }
43+}
44+
45+func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
46+ tests := []struct {
47+ name string
48+ start int
49+ action keys.Action
50+ wantActive int
51+ wantFocus layout.PaneID
52+ }{
53+ {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"},
54+ {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"},
55+ }
56+
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
60+ sess.mu.Lock()
61+ sess.active = tt.start
62+ target := sess.tabs[tt.wantActive]
63+ source := sess.tabs[tt.start]
64+ sess.mu.Unlock()
65+ target.mu.Lock()
66+ target.size = domain.Size{Cols: 41, Rows: 10}
67+ target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
68+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
69+ target.mu.Unlock()
70+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
71+
72+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
73+
74+ require.Equal(t, tt.wantActive, activeTabIndex(sess))
75+ target.mu.Lock()
76+ require.Equal(t, tt.wantFocus, target.tree.Focus)
77+ target.mu.Unlock()
78+ require.NotSame(t, source, sess.activeTab())
79+ })
80+ }
81+}
82+
83+func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *testing.T) {
84+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
85+ alpha.mu.Lock()
86+ alpha.name = "alpha"
87+ alpha.mu.Unlock()
88+
89+ newSession := func(id, name string, active int, focus layout.PaneID) *session {
90+ tabs := []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10}), newTab(nil, domain.Size{Cols: 41, Rows: 10})}
91+ target := tabs[active]
92+ target.mu.Lock()
93+ target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}
94+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
95+ target.mu.Unlock()
96+ return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}
97+ }
98+ charlie := newSession("live-charlie", "charlie", 1, "pane-2")
99+ echo := newSession("live-echo", "echo", 1, "pane-2")
100+
101+ // Deliberately register the live sessions out of alphabetical order. The
102+ // stopped name sorts between alpha and charlie but must never be resumed.
103+ d.mu.Lock()
104+ delete(d.sessions, alpha.id)
105+ d.sessions[echo.id] = echo
106+ d.sessions[alpha.id] = alpha
107+ d.sessions[charlie.id] = charlie
108+ d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"}
109+ d.mu.Unlock()
110+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
111+ handler := daemonKeyHandler{d: d, ac: ac}
112+
113+ moves := []struct {
114+ name string
115+ action keys.Action
116+ want *session
117+ }{
118+ {name: "down to charlie", action: keys.ActionFocusPaneDown, want: charlie},
119+ {name: "down to echo", action: keys.ActionFocusPaneDown, want: echo},
120+ {name: "down wall", action: keys.ActionFocusPaneDown, want: echo},
121+ {name: "up to charlie", action: keys.ActionFocusPaneUp, want: charlie},
122+ {name: "up to alpha", action: keys.ActionFocusPaneUp, want: alpha},
123+ {name: "up wall", action: keys.ActionFocusPaneUp, want: alpha},
124+ }
125+ for _, move := range moves {
126+ t.Run(move.name, func(t *testing.T) {
127+ handler.Action(move.action)
128+ require.Same(t, move.want, ac.currentSession())
129+ })
130+ }
131+
132+ for _, target := range []*session{charlie, echo} {
133+ require.Equal(t, 1, activeTabIndex(target), "switch preserves the target active tab")
134+ target.tabs[1].mu.Lock()
135+ require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
136+ target.tabs[1].mu.Unlock()
137+ }
138+ d.mu.Lock()
139+ _, stopped := d.stopped["bravo"]
140+ var bravoLive bool
141+ for _, candidate := range d.sessions {
142+ candidate.mu.Lock()
143+ bravoLive = bravoLive || candidate.name == "bravo"
144+ candidate.mu.Unlock()
145+ }
146+ d.mu.Unlock()
147+ alpha.mu.Lock()
148+ attached := alpha.client
149+ alpha.mu.Unlock()
150+ require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
151+ require.False(t, bravoLive, "the stopped session is absent from the live registry")
152+ require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning")
153+}
154+
155+func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
156+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
157+ alpha.mu.Lock()
158+ alpha.name = "alpha"
159+ alpha.mu.Unlock()
160+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
161+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
162+ d.mu.Lock()
163+ d.sessions[charlie.id] = charlie
164+ d.mu.Unlock()
165+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
166+
167+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown)
168+
169+ require.Same(t, alpha, ac.currentSession())
170+ alpha.mu.Lock()
171+ require.Same(t, ac, alpha.client)
172+ alpha.mu.Unlock()
173+ charlie.mu.Lock()
174+ require.Nil(t, charlie.client)
175+ charlie.mu.Unlock()
176+}
177+
178+func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
179+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
180+ alpha.mu.Lock()
181+ alpha.name = "alpha"
182+ alpha.mu.Unlock()
183+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
184+ d.mu.Lock()
185+ d.sessions[charlie.id] = charlie
186+ d.mu.Unlock()
187+
188+ target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
189+ require.True(t, ok)
190+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
191+
192+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
193+ require.Same(t, alpha, ac.currentSession())
194+ alpha.mu.Lock()
195+ require.Same(t, ac, alpha.client)
196+ alpha.mu.Unlock()
197+ charlie.mu.Lock()
198+ require.Nil(t, charlie.client)
199+ charlie.mu.Unlock()
200+}
201+
202+func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
203+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
204+ alpha.mu.Lock()
205+ alpha.name = "alpha"
206+ expectedSource := alpha.tabs[0]
207+ alpha.active = 1
208+ alpha.mu.Unlock()
209+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
210+ d.mu.Lock()
211+ d.sessions[charlie.id] = charlie
212+ d.mu.Unlock()
213+
214+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
215+ require.Same(t, alpha, ac.currentSession())
216+ alpha.mu.Lock()
217+ require.Same(t, ac, alpha.client)
218+ alpha.mu.Unlock()
219+ charlie.mu.Lock()
220+ require.Nil(t, charlie.client)
221+ charlie.mu.Unlock()
222+}
223+
224+func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {
225+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
226+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
227+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
228+ d.mu.Lock()
229+ d.sessions[charlie.id] = charlie
230+ d.mu.Unlock()
231+
232+ require.NoError(t, d.switchToTarget(alpha, ac, picker.Target{Session: charlie.id, TabIndex: -1}))
233+ require.Same(t, charlie, ac.currentSession())
234+}
235+
236+func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
237+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
238+ alpha.mu.Lock()
239+ alpha.name = "alpha"
240+ alpha.mu.Unlock()
241+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
242+ d.mu.Lock()
243+ d.sessions[charlie.id] = charlie
244+ d.mu.Unlock()
245+
246+ target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
247+ require.True(t, ok)
248+ replacement := &attachedClient{}
249+ alpha.mu.Lock()
250+ alpha.client = replacement
251+ alpha.mu.Unlock()
252+
253+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
254+ require.Same(t, alpha, ac.currentSession())
255+ alpha.mu.Lock()
256+ require.Same(t, replacement, alpha.client)
257+ alpha.mu.Unlock()
258+ charlie.mu.Lock()
259+ require.Nil(t, charlie.client)
260+ charlie.mu.Unlock()
261+}
262+
263+func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
264+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
265+ alpha.mu.Lock()
266+ alpha.name = "alpha"
267+ alpha.mu.Unlock()
268+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
269+ echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
270+ d.mu.Lock()
271+ d.sessions[echo.id] = echo
272+ d.sessions[charlie.id] = charlie
273+ d.mu.Unlock()
274+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
275+
276+ start := make(chan struct{})
277+ renameErrors := make(chan error, 40)
278+ var wg sync.WaitGroup
279+ wg.Go(func() {
280+ <-start
281+ for i := range 40 {
282+ name := "charlie"
283+ if i%2 == 0 {
284+ name = "delta"
285+ }
286+ if err := d.renameSession(charlie, name); err != nil {
287+ renameErrors <- err
288+ }
289+ }
290+ })
291+
292+ close(start)
293+ handler := daemonKeyHandler{d: d, ac: ac}
294+ for range 20 {
295+ handler.Action(keys.ActionFocusPaneDown)
296+ handler.Action(keys.ActionFocusPaneUp)
297+ }
298+ wg.Wait()
299+ close(renameErrors)
300+ for err := range renameErrors {
301+ require.NoError(t, err)
302+ }
303+
304+ current := ac.currentSession()
305+ owners := make([]*session, 0, 1)
306+ d.mu.Lock()
307+ for _, candidate := range d.sessions {
308+ candidate.mu.Lock()
309+ if candidate.client == ac {
310+ owners = append(owners, candidate)
311+ }
312+ candidate.mu.Unlock()
313+ }
314+ d.mu.Unlock()
315+ require.Len(t, owners, 1)
316+ require.Same(t, current, owners[0])
317+}
318+
319+func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
320+ tests := []struct {
321+ name string
322+ setup func(d *Daemon, sess *session)
323+ dir keys.Action
324+ }{
325+ {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight},
326+ {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft},
327+ {name: "failed target entry", setup: func(d *Daemon, sess *session) {
328+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
329+ sess.tabs[1].mu.Lock()
330+ sess.tabs[1].tree = nil
331+ sess.tabs[1].mu.Unlock()
332+ }, dir: keys.ActionFocusPaneRight},
333+ {name: "visible floating pane", setup: func(d *Daemon, sess *session) {
334+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
335+ installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
336+ }, dir: keys.ActionFocusPaneRight},
337+ }
338+
339+ for _, tt := range tests {
340+ t.Run(tt.name, func(t *testing.T) {
341+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
342+ tt.setup(d, sess)
343+
344+ daemonKeyHandler{d: d, ac: ac}.Action(tt.dir)
345+
346+ require.Equal(t, 0, activeTabIndex(sess))
347+ })
348+ }
349+}
350+
351+func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
352+ tests := []struct {
353+ name string
354+ mutate func(sess *session, candidate tabOverflowCandidate)
355+ }{
356+ {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) {
357+ sess.mu.Lock()
358+ sess.active = 1
359+ sess.mu.Unlock()
360+ }},
361+ {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) {
362+ sess.mu.Lock()
363+ sess.tabs[1] = newTab(nil, candidate.target.size)
364+ sess.mu.Unlock()
365+ }},
366+ {name: "source gains visible floating pane", mutate: func(_ *session, candidate tabOverflowCandidate) {
367+ installTestFloating(candidate.source, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
368+ }},
369+ }
370+
371+ for _, tt := range tests {
372+ t.Run(tt.name, func(t *testing.T) {
373+ d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
374+ candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1)
375+ require.True(t, ok)
376+ tt.mutate(sess, candidate)
377+ activeBeforeCommit := activeTabIndex(sess)
378+
379+ require.False(t, d.commitTabOverflow(sess, candidate))
380+ require.Equal(t, activeBeforeCommit, activeTabIndex(sess))
381+ })
382+ }
383+}
@@ -0,0 +1,383 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/keys"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/internal/usecase/picker"
13+)
14+
15+func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ dir layout.Direction
19+ cfg domain.NavConfig
20+ position int
21+ count int
22+ want overflowStep
23+ }{
24+ {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2},
25+ {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2},
26+ {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}},
27+ {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}},
28+ {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
29+ {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2},
30+ {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}},
31+ {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}},
32+ {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
33+ {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1},
34+ {name: "negative position is out of range", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: -1, count: 2},
35+ {name: "position at count is out of range", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 2, count: 2},
36+ }
37+
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count))
41+ })
42+ }
43+}
44+
45+func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
46+ tests := []struct {
47+ name string
48+ start int
49+ action keys.Action
50+ wantActive int
51+ wantFocus layout.PaneID
52+ }{
53+ {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"},
54+ {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"},
55+ }
56+
57+ for _, tt := range tests {
58+ t.Run(tt.name, func(t *testing.T) {
59+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
60+ sess.mu.Lock()
61+ sess.active = tt.start
62+ target := sess.tabs[tt.wantActive]
63+ source := sess.tabs[tt.start]
64+ sess.mu.Unlock()
65+ target.mu.Lock()
66+ target.size = domain.Size{Cols: 41, Rows: 10}
67+ target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
68+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
69+ target.mu.Unlock()
70+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
71+
72+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
73+
74+ require.Equal(t, tt.wantActive, activeTabIndex(sess))
75+ target.mu.Lock()
76+ require.Equal(t, tt.wantFocus, target.tree.Focus)
77+ target.mu.Unlock()
78+ require.NotSame(t, source, sess.activeTab())
79+ })
80+ }
81+}
82+
83+func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *testing.T) {
84+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
85+ alpha.mu.Lock()
86+ alpha.name = "alpha"
87+ alpha.mu.Unlock()
88+
89+ newSession := func(id, name string, active int, focus layout.PaneID) *session {
90+ tabs := []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10}), newTab(nil, domain.Size{Cols: 41, Rows: 10})}
91+ target := tabs[active]
92+ target.mu.Lock()
93+ target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}
94+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
95+ target.mu.Unlock()
96+ return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}
97+ }
98+ charlie := newSession("live-charlie", "charlie", 1, "pane-2")
99+ echo := newSession("live-echo", "echo", 1, "pane-2")
100+
101+ // Deliberately register the live sessions out of alphabetical order. The
102+ // stopped name sorts between alpha and charlie but must never be resumed.
103+ d.mu.Lock()
104+ delete(d.sessions, alpha.id)
105+ d.sessions[echo.id] = echo
106+ d.sessions[alpha.id] = alpha
107+ d.sessions[charlie.id] = charlie
108+ d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"}
109+ d.mu.Unlock()
110+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
111+ handler := daemonKeyHandler{d: d, ac: ac}
112+
113+ moves := []struct {
114+ name string
115+ action keys.Action
116+ want *session
117+ }{
118+ {name: "down to charlie", action: keys.ActionFocusPaneDown, want: charlie},
119+ {name: "down to echo", action: keys.ActionFocusPaneDown, want: echo},
120+ {name: "down wall", action: keys.ActionFocusPaneDown, want: echo},
121+ {name: "up to charlie", action: keys.ActionFocusPaneUp, want: charlie},
122+ {name: "up to alpha", action: keys.ActionFocusPaneUp, want: alpha},
123+ {name: "up wall", action: keys.ActionFocusPaneUp, want: alpha},
124+ }
125+ for _, move := range moves {
126+ t.Run(move.name, func(t *testing.T) {
127+ handler.Action(move.action)
128+ require.Same(t, move.want, ac.currentSession())
129+ })
130+ }
131+
132+ for _, target := range []*session{charlie, echo} {
133+ require.Equal(t, 1, activeTabIndex(target), "switch preserves the target active tab")
134+ target.tabs[1].mu.Lock()
135+ require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
136+ target.tabs[1].mu.Unlock()
137+ }
138+ d.mu.Lock()
139+ _, stopped := d.stopped["bravo"]
140+ var bravoLive bool
141+ for _, candidate := range d.sessions {
142+ candidate.mu.Lock()
143+ bravoLive = bravoLive || candidate.name == "bravo"
144+ candidate.mu.Unlock()
145+ }
146+ d.mu.Unlock()
147+ alpha.mu.Lock()
148+ attached := alpha.client
149+ alpha.mu.Unlock()
150+ require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
151+ require.False(t, bravoLive, "the stopped session is absent from the live registry")
152+ require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning")
153+}
154+
155+func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
156+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
157+ alpha.mu.Lock()
158+ alpha.name = "alpha"
159+ alpha.mu.Unlock()
160+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
161+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
162+ d.mu.Lock()
163+ d.sessions[charlie.id] = charlie
164+ d.mu.Unlock()
165+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
166+
167+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown)
168+
169+ require.Same(t, alpha, ac.currentSession())
170+ alpha.mu.Lock()
171+ require.Same(t, ac, alpha.client)
172+ alpha.mu.Unlock()
173+ charlie.mu.Lock()
174+ require.Nil(t, charlie.client)
175+ charlie.mu.Unlock()
176+}
177+
178+func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
179+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
180+ alpha.mu.Lock()
181+ alpha.name = "alpha"
182+ alpha.mu.Unlock()
183+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
184+ d.mu.Lock()
185+ d.sessions[charlie.id] = charlie
186+ d.mu.Unlock()
187+
188+ target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
189+ require.True(t, ok)
190+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
191+
192+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
193+ require.Same(t, alpha, ac.currentSession())
194+ alpha.mu.Lock()
195+ require.Same(t, ac, alpha.client)
196+ alpha.mu.Unlock()
197+ charlie.mu.Lock()
198+ require.Nil(t, charlie.client)
199+ charlie.mu.Unlock()
200+}
201+
202+func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
203+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
204+ alpha.mu.Lock()
205+ alpha.name = "alpha"
206+ expectedSource := alpha.tabs[0]
207+ alpha.active = 1
208+ alpha.mu.Unlock()
209+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
210+ d.mu.Lock()
211+ d.sessions[charlie.id] = charlie
212+ d.mu.Unlock()
213+
214+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
215+ require.Same(t, alpha, ac.currentSession())
216+ alpha.mu.Lock()
217+ require.Same(t, ac, alpha.client)
218+ alpha.mu.Unlock()
219+ charlie.mu.Lock()
220+ require.Nil(t, charlie.client)
221+ charlie.mu.Unlock()
222+}
223+
224+func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {
225+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
226+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
227+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
228+ d.mu.Lock()
229+ d.sessions[charlie.id] = charlie
230+ d.mu.Unlock()
231+
232+ require.NoError(t, d.switchToTarget(alpha, ac, picker.Target{Session: charlie.id, TabIndex: -1}))
233+ require.Same(t, charlie, ac.currentSession())
234+}
235+
236+func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
237+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
238+ alpha.mu.Lock()
239+ alpha.name = "alpha"
240+ alpha.mu.Unlock()
241+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
242+ d.mu.Lock()
243+ d.sessions[charlie.id] = charlie
244+ d.mu.Unlock()
245+
246+ target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
247+ require.True(t, ok)
248+ replacement := &attachedClient{}
249+ alpha.mu.Lock()
250+ alpha.client = replacement
251+ alpha.mu.Unlock()
252+
253+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
254+ require.Same(t, alpha, ac.currentSession())
255+ alpha.mu.Lock()
256+ require.Same(t, replacement, alpha.client)
257+ alpha.mu.Unlock()
258+ charlie.mu.Lock()
259+ require.Nil(t, charlie.client)
260+ charlie.mu.Unlock()
261+}
262+
263+func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
264+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
265+ alpha.mu.Lock()
266+ alpha.name = "alpha"
267+ alpha.mu.Unlock()
268+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
269+ echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
270+ d.mu.Lock()
271+ d.sessions[echo.id] = echo
272+ d.sessions[charlie.id] = charlie
273+ d.mu.Unlock()
274+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
275+
276+ start := make(chan struct{})
277+ renameErrors := make(chan error, 40)
278+ var wg sync.WaitGroup
279+ wg.Go(func() {
280+ <-start
281+ for i := range 40 {
282+ name := "charlie"
283+ if i%2 == 0 {
284+ name = "delta"
285+ }
286+ if err := d.renameSession(charlie, name); err != nil {
287+ renameErrors <- err
288+ }
289+ }
290+ })
291+
292+ close(start)
293+ handler := daemonKeyHandler{d: d, ac: ac}
294+ for range 20 {
295+ handler.Action(keys.ActionFocusPaneDown)
296+ handler.Action(keys.ActionFocusPaneUp)
297+ }
298+ wg.Wait()
299+ close(renameErrors)
300+ for err := range renameErrors {
301+ require.NoError(t, err)
302+ }
303+
304+ current := ac.currentSession()
305+ owners := make([]*session, 0, 1)
306+ d.mu.Lock()
307+ for _, candidate := range d.sessions {
308+ candidate.mu.Lock()
309+ if candidate.client == ac {
310+ owners = append(owners, candidate)
311+ }
312+ candidate.mu.Unlock()
313+ }
314+ d.mu.Unlock()
315+ require.Len(t, owners, 1)
316+ require.Same(t, current, owners[0])
317+}
318+
319+func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
320+ tests := []struct {
321+ name string
322+ setup func(d *Daemon, sess *session)
323+ dir keys.Action
324+ }{
325+ {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight},
326+ {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft},
327+ {name: "failed target entry", setup: func(d *Daemon, sess *session) {
328+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
329+ sess.tabs[1].mu.Lock()
330+ sess.tabs[1].tree = nil
331+ sess.tabs[1].mu.Unlock()
332+ }, dir: keys.ActionFocusPaneRight},
333+ {name: "visible floating pane", setup: func(d *Daemon, sess *session) {
334+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
335+ installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
336+ }, dir: keys.ActionFocusPaneRight},
337+ }
338+
339+ for _, tt := range tests {
340+ t.Run(tt.name, func(t *testing.T) {
341+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
342+ tt.setup(d, sess)
343+
344+ daemonKeyHandler{d: d, ac: ac}.Action(tt.dir)
345+
346+ require.Equal(t, 0, activeTabIndex(sess))
347+ })
348+ }
349+}
350+
351+func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
352+ tests := []struct {
353+ name string
354+ mutate func(sess *session, candidate tabOverflowCandidate)
355+ }{
356+ {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) {
357+ sess.mu.Lock()
358+ sess.active = 1
359+ sess.mu.Unlock()
360+ }},
361+ {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) {
362+ sess.mu.Lock()
363+ sess.tabs[1] = newTab(nil, candidate.target.size)
364+ sess.mu.Unlock()
365+ }},
366+ {name: "source gains visible floating pane", mutate: func(_ *session, candidate tabOverflowCandidate) {
367+ installTestFloating(candidate.source, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
368+ }},
369+ }
370+
371+ for _, tt := range tests {
372+ t.Run(tt.name, func(t *testing.T) {
373+ d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
374+ candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1)
375+ require.True(t, ok)
376+ tt.mutate(sess, candidate)
377+ activeBeforeCommit := activeTabIndex(sess)
378+
379+ require.False(t, d.commitTabOverflow(sess, candidate))
380+ require.Equal(t, activeBeforeCommit, activeTabIndex(sess))
381+ })
382+ }
383+}
M internal/usecase/daemon/pane_actions.go +61 -19
@@ -309,35 +309,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
309309 if target.pane != nil {
310310 oldFocus = target.pane.id
311311 }
312- err := d.focusDirAt(sess, target.tab, target.pane, dir)
313- if err == nil && ac != nil {
314- target.tab.mu.Lock()
315- newFocus := target.tab.tree.Focus
316- pl, hasPlacement := focusedPlacementLocked(target.tab)
317- target.tab.mu.Unlock()
318- if newFocus != oldFocus {
319- d.exitCopyMode(ac)
320- if hasPlacement && pl.InStack {
321- d.refreshPaneTitleOnFocus(sess, newFocus)
322- }
312+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
313+ if err == nil {
314+ if ac != nil {
315+ d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
323316 }
324- d.invalidateRender(sess, ac, true, "pane_actions.go")
317+ return nil
325318 }
326- return err
319+ if !errors.Is(err, errNoNeighbor) || target.tab == nil {
320+ return err
321+ }
322+ if !overflowSourceEligible(sess, target.tab) {
323+ return errNoNeighbor
324+ }
325+
326+ cfg := d.currentNavConfig()
327+ if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
328+ if ac == nil {
329+ return errNoNeighbor
330+ }
331+ return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
332+ }
333+
334+ sess.mu.Lock()
335+ position, count := sess.active, len(sess.tabs)
336+ sess.mu.Unlock()
337+ step := resolveOverflow(dir, cfg, position, count)
338+ if step.kind != overflowTabs {
339+ return err
340+ }
341+ candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
342+ if !ok || !d.commitTabOverflow(sess, candidate) {
343+ return errNoNeighbor
344+ }
345+ d.activateTab(sess, candidate.target)
346+ if ac != nil {
347+ d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
348+ }
349+ return nil
327350 }
328351
329-func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
352+// finishPaneFocusForClient applies the attachment lifecycle shared by every
353+// successful directional focus move. Keep copy-mode exit before the optional
354+// stack title refresh, and both before render invalidation.
355+func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
356+ tb.mu.Lock()
357+ newFocus := tb.tree.Focus
358+ pl, hasPlacement := focusedPlacementLocked(tb)
359+ tb.mu.Unlock()
360+ if newFocus != oldFocus {
361+ d.exitCopyMode(ac)
362+ if hasPlacement && pl.InStack {
363+ d.refreshPaneTitleOnFocus(sess, newFocus)
364+ }
365+ }
366+ d.invalidateRender(sess, ac, true, producer)
367+}
368+
369+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) {
330370 if tb == nil || target == nil {
331- return layout.ErrNotFound
371+ return domain.Rect{}, layout.ErrNotFound
332372 }
333373 tb.mu.Lock()
334374 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
335375 tb.mu.Unlock()
336- return layout.ErrNotFound
376+ return domain.Rect{}, layout.ErrNotFound
337377 }
338378 oldFocus := target.id
339379 tb.tree.Focus = oldFocus
340- err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
380+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
381+ span, _ := tb.tree.FocusSpan(area)
382+ err := tb.tree.FocusDir(dir, area)
341383 newFocus := tb.tree.Focus
342384 if err == nil {
343385 d.applyLayoutLocked(tb)
@@ -347,7 +389,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
347389 markSnapshotDirty(sess)
348390 }
349391 if errors.Is(err, layout.ErrNoPane) {
350- return errNoNeighbor
392+ return span, errNoNeighbor
351393 }
352- return err
394+ return span, err
353395 }
@@ -309,35 +309,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
309 if target.pane != nil {309 if target.pane != nil {
310 oldFocus = target.pane.id310 oldFocus = target.pane.id
311 }311 }
312- err := d.focusDirAt(sess, target.tab, target.pane, dir)312+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
313- if err == nil && ac != nil {313+ if err == nil {
314- target.tab.mu.Lock()314+ if ac != nil {
315- newFocus := target.tab.tree.Focus315+ d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
316- pl, hasPlacement := focusedPlacementLocked(target.tab)
317- target.tab.mu.Unlock()
318- if newFocus != oldFocus {
319- d.exitCopyMode(ac)
320- if hasPlacement && pl.InStack {
321- d.refreshPaneTitleOnFocus(sess, newFocus)
322- }
323 }316 }
324- d.invalidateRender(sess, ac, true, "pane_actions.go")317+ return nil
325 }318 }
326- return err319+ if !errors.Is(err, errNoNeighbor) || target.tab == nil {
320+ return err
321+ }
322+ if !overflowSourceEligible(sess, target.tab) {
323+ return errNoNeighbor
324+ }
325+
326+ cfg := d.currentNavConfig()
327+ if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
328+ if ac == nil {
329+ return errNoNeighbor
330+ }
331+ return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
332+ }
333+
334+ sess.mu.Lock()
335+ position, count := sess.active, len(sess.tabs)
336+ sess.mu.Unlock()
337+ step := resolveOverflow(dir, cfg, position, count)
338+ if step.kind != overflowTabs {
339+ return err
340+ }
341+ candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
342+ if !ok || !d.commitTabOverflow(sess, candidate) {
343+ return errNoNeighbor
344+ }
345+ d.activateTab(sess, candidate.target)
346+ if ac != nil {
347+ d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
348+ }
349+ return nil
327 }350 }
328 351
329-func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {352+// finishPaneFocusForClient applies the attachment lifecycle shared by every
353+// successful directional focus move. Keep copy-mode exit before the optional
354+// stack title refresh, and both before render invalidation.
355+func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
356+ tb.mu.Lock()
357+ newFocus := tb.tree.Focus
358+ pl, hasPlacement := focusedPlacementLocked(tb)
359+ tb.mu.Unlock()
360+ if newFocus != oldFocus {
361+ d.exitCopyMode(ac)
362+ if hasPlacement && pl.InStack {
363+ d.refreshPaneTitleOnFocus(sess, newFocus)
364+ }
365+ }
366+ d.invalidateRender(sess, ac, true, producer)
367+}
368+
369+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) {
330 if tb == nil || target == nil {370 if tb == nil || target == nil {
331- return layout.ErrNotFound371+ return domain.Rect{}, layout.ErrNotFound
332 }372 }
333 tb.mu.Lock()373 tb.mu.Lock()
334 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {374 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
335 tb.mu.Unlock()375 tb.mu.Unlock()
336- return layout.ErrNotFound376+ return domain.Rect{}, layout.ErrNotFound
337 }377 }
338 oldFocus := target.id378 oldFocus := target.id
339 tb.tree.Focus = oldFocus379 tb.tree.Focus = oldFocus
340- err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})380+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
381+ span, _ := tb.tree.FocusSpan(area)
382+ err := tb.tree.FocusDir(dir, area)
341 newFocus := tb.tree.Focus383 newFocus := tb.tree.Focus
342 if err == nil {384 if err == nil {
343 d.applyLayoutLocked(tb)385 d.applyLayoutLocked(tb)
@@ -347,7 +389,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
347 markSnapshotDirty(sess)389 markSnapshotDirty(sess)
348 }390 }
349 if errors.Is(err, layout.ErrNoPane) {391 if errors.Is(err, layout.ErrNoPane) {
350- return errNoNeighbor392+ return span, errNoNeighbor
351 }393 }
352- return err394+ return span, err
353 }395 }
M internal/usecase/daemon/pane_actions_test.go +30 -0
@@ -152,6 +152,36 @@ func TestResizeReflowsAllPanes(t *testing.T) {
152152 require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)
153153 }
154154
155+func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
156+ tests := []struct {
157+ name string
158+ direction layout.Direction
159+ wantErr error
160+ wantFocus layout.PaneID
161+ }{
162+ {name: "moves right", direction: layout.Right, wantFocus: "pane-2"},
163+ {name: "maps missing left neighbor", direction: layout.Left, wantErr: errNoNeighbor, wantFocus: "pane-1"},
164+ }
165+
166+ for _, tt := range tests {
167+ t.Run(tt.name, func(t *testing.T) {
168+ d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
169+ tb := sess.activeTab()
170+ target := tb.panes["pane-1"]
171+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
172+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
173+ tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
174+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
175+
176+ span, err := d.focusDirAt(sess, tb, target, tt.direction)
177+
178+ require.ErrorIs(t, err, tt.wantErr)
179+ require.Equal(t, domain.Rect{Width: 20, Height: 10}, span)
180+ require.Equal(t, tt.wantFocus, tb.tree.Focus)
181+ })
182+ }
183+}
184+
155185 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
156186 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
157187 tb := sess.activeTab()
@@ -152,6 +152,36 @@ func TestResizeReflowsAllPanes(t *testing.T) {
152 require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)152 require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)
153 }153 }
154 154
155+func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
156+ tests := []struct {
157+ name string
158+ direction layout.Direction
159+ wantErr error
160+ wantFocus layout.PaneID
161+ }{
162+ {name: "moves right", direction: layout.Right, wantFocus: "pane-2"},
163+ {name: "maps missing left neighbor", direction: layout.Left, wantErr: errNoNeighbor, wantFocus: "pane-1"},
164+ }
165+
166+ for _, tt := range tests {
167+ t.Run(tt.name, func(t *testing.T) {
168+ d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
169+ tb := sess.activeTab()
170+ target := tb.panes["pane-1"]
171+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
172+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
173+ tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
174+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
175+
176+ span, err := d.focusDirAt(sess, tb, target, tt.direction)
177+
178+ require.ErrorIs(t, err, tt.wantErr)
179+ require.Equal(t, domain.Rect{Width: 20, Height: 10}, span)
180+ require.Equal(t, tt.wantFocus, tb.tree.Focus)
181+ })
182+ }
183+}
184+
155 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {185 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
156 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})186 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
157 tb := sess.activeTab()187 tb := sess.activeTab()
M internal/usecase/daemon/picker.go +39 -4
@@ -341,10 +341,23 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
341341 return d.sessions[id]
342342 }
343343
344+// sessionHandoffGuard optionally constrains an active-session handoff to the
345+// tab that initiated navigation. Its zero value preserves ordinary picker and
346+// command switching behavior.
347+type sessionHandoffGuard struct {
348+ expectedSource *tab
349+}
350+
344351 // switchToTarget resolves named lifecycle targets and commits their transition
345352 // while d.mu is held. A named palette result is allowed to cross an
346353 // active/stopped transition, but it never follows a same-name replacement.
347354 func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
355+ return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
356+}
357+
358+// switchToTargetGuarded is the navigation-only variant whose guard is checked
359+// as part of the source ownership transfer.
360+func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
348361 d.mu.Lock()
349362 var (
350363 targetSess *session
@@ -363,15 +376,18 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
363376 // locked name/lifecycle resolver chose this active incarnation.
364377 resolvedTarget := target
365378 resolvedTarget.Session = active.id
366- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget)
379+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard)
367380 }
368381 }
369382 } else if active := d.sessions[target.Session]; active != nil {
370- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target)
383+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard)
371384 }
372385 d.mu.Unlock()
373386
374387 if !switched {
388+ if guard.expectedSource != nil {
389+ return errNoNeighbor
390+ }
375391 d.invalidateRender(from, ac, true, "picker.go")
376392 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
377393 }
@@ -413,7 +429,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
413429
414430 // switchToActiveTargetLocked commits an active target handoff. Caller holds
415431 // d.mu and releases it before completing render cleanup or first paint.
416-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
432+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
417433 // The caller already holds d.mu. Keep handoff ownership atomic with global
418434 // routing so it cannot select an attachment midway between sessions.
419435 d.notices.routingMu.Lock()
@@ -443,10 +459,29 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
443459 targetSess.mu.Unlock()
444460 return nil, nil, nil, false
445461 }
462+ var guardedSource *tab
463+ if guard.expectedSource != nil {
464+ if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
465+ from.mu.Unlock()
466+ targetSess.mu.Unlock()
467+ return nil, nil, nil, false
468+ }
469+ guardedSource = guard.expectedSource
470+ guardedSource.mu.Lock()
471+ if guardedSource.floating.state == floatingVisible {
472+ guardedSource.mu.Unlock()
473+ from.mu.Unlock()
474+ targetSess.mu.Unlock()
475+ return nil, nil, nil, false
476+ }
477+ }
446478 term := from.terminal
447479 env := copyEnvironment(from.env)
448480 from.client = nil
449481 ac.setSession(nil)
482+ if guardedSource != nil {
483+ guardedSource.mu.Unlock()
484+ }
450485 from.mu.Unlock()
451486
452487 old := targetSess.client
@@ -513,7 +548,7 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
513548 // targets must use switchToTarget so resolution and commit share d.mu.
514549 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
515550 d.mu.Lock()
516- _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target)
551+ _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{})
517552 d.mu.Unlock()
518553 if !switched {
519554 return nil
@@ -341,10 +341,23 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
341 return d.sessions[id]341 return d.sessions[id]
342 }342 }
343 343
344+// sessionHandoffGuard optionally constrains an active-session handoff to the
345+// tab that initiated navigation. Its zero value preserves ordinary picker and
346+// command switching behavior.
347+type sessionHandoffGuard struct {
348+ expectedSource *tab
349+}
350+
344 // switchToTarget resolves named lifecycle targets and commits their transition351 // switchToTarget resolves named lifecycle targets and commits their transition
345 // while d.mu is held. A named palette result is allowed to cross an352 // while d.mu is held. A named palette result is allowed to cross an
346 // active/stopped transition, but it never follows a same-name replacement.353 // active/stopped transition, but it never follows a same-name replacement.
347 func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {354 func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
355+ return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
356+}
357+
358+// switchToTargetGuarded is the navigation-only variant whose guard is checked
359+// as part of the source ownership transfer.
360+func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
348 d.mu.Lock()361 d.mu.Lock()
349 var (362 var (
350 targetSess *session363 targetSess *session
@@ -363,15 +376,18 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
363 // locked name/lifecycle resolver chose this active incarnation.376 // locked name/lifecycle resolver chose this active incarnation.
364 resolvedTarget := target377 resolvedTarget := target
365 resolvedTarget.Session = active.id378 resolvedTarget.Session = active.id
366- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget)379+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard)
367 }380 }
368 }381 }
369 } else if active := d.sessions[target.Session]; active != nil {382 } else if active := d.sessions[target.Session]; active != nil {
370- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target)383+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard)
371 }384 }
372 d.mu.Unlock()385 d.mu.Unlock()
373 386
374 if !switched {387 if !switched {
388+ if guard.expectedSource != nil {
389+ return errNoNeighbor
390+ }
375 d.invalidateRender(from, ac, true, "picker.go")391 d.invalidateRender(from, ac, true, "picker.go")
376 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)392 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
377 }393 }
@@ -413,7 +429,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
413 429
414 // switchToActiveTargetLocked commits an active target handoff. Caller holds430 // switchToActiveTargetLocked commits an active target handoff. Caller holds
415 // d.mu and releases it before completing render cleanup or first paint.431 // d.mu and releases it before completing render cleanup or first paint.
416-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {432+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
417 // The caller already holds d.mu. Keep handoff ownership atomic with global433 // The caller already holds d.mu. Keep handoff ownership atomic with global
418 // routing so it cannot select an attachment midway between sessions.434 // routing so it cannot select an attachment midway between sessions.
419 d.notices.routingMu.Lock()435 d.notices.routingMu.Lock()
@@ -443,10 +459,29 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
443 targetSess.mu.Unlock()459 targetSess.mu.Unlock()
444 return nil, nil, nil, false460 return nil, nil, nil, false
445 }461 }
462+ var guardedSource *tab
463+ if guard.expectedSource != nil {
464+ if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
465+ from.mu.Unlock()
466+ targetSess.mu.Unlock()
467+ return nil, nil, nil, false
468+ }
469+ guardedSource = guard.expectedSource
470+ guardedSource.mu.Lock()
471+ if guardedSource.floating.state == floatingVisible {
472+ guardedSource.mu.Unlock()
473+ from.mu.Unlock()
474+ targetSess.mu.Unlock()
475+ return nil, nil, nil, false
476+ }
477+ }
446 term := from.terminal478 term := from.terminal
447 env := copyEnvironment(from.env)479 env := copyEnvironment(from.env)
448 from.client = nil480 from.client = nil
449 ac.setSession(nil)481 ac.setSession(nil)
482+ if guardedSource != nil {
483+ guardedSource.mu.Unlock()
484+ }
450 from.mu.Unlock()485 from.mu.Unlock()
451 486
452 old := targetSess.client487 old := targetSess.client
@@ -513,7 +548,7 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
513 // targets must use switchToTarget so resolution and commit share d.mu.548 // targets must use switchToTarget so resolution and commit share d.mu.
514 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {549 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
515 d.mu.Lock()550 d.mu.Lock()
516- _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target)551+ _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{})
517 d.mu.Unlock()552 d.mu.Unlock()
518 if !switched {553 if !switched {
519 return nil554 return nil
A internal/usecase/layout/cross_focus_test.go +178 -0
@@ -0,0 +1,178 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestFocusSpan(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{X: 5, Y: 7, Width: 41, Height: 5}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ area domain.Rect
18+ want domain.Rect
19+ wantErr error
20+ }{
21+ {
22+ name: "nil root",
23+ tree: &Tree{},
24+ area: area,
25+ wantErr: ErrNotFound,
26+ },
27+ {
28+ name: "layout too small",
29+ tree: NewTree("a"),
30+ area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows},
31+ wantErr: ErrTooSmall,
32+ },
33+ {
34+ name: "focused pane unknown",
35+ tree: &Tree{Root: NewLeaf("a"), Focus: "missing"},
36+ area: area,
37+ wantErr: ErrNotFound,
38+ },
39+ {
40+ name: "returns collapsed stack title bar",
41+ tree: &Tree{
42+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"},
43+ Focus: "b",
44+ },
45+ area: area,
46+ want: domain.Rect{X: 5, Y: 11, Width: 41, Height: 1},
47+ },
48+ }
49+
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ got, err := tt.tree.FocusSpan(tt.area)
53+ require.ErrorIs(t, err, tt.wantErr)
54+ require.Equal(t, tt.want, got)
55+ })
56+ }
57+}
58+
59+func TestEntryPane(t *testing.T) {
60+ t.Parallel()
61+
62+ area := domain.Rect{Width: 41, Height: 5}
63+ tests := []struct {
64+ name string
65+ tree *Tree
66+ dir Direction
67+ span domain.Rect
68+ area domain.Rect
69+ want PaneID
70+ wantErr error
71+ }{
72+ {
73+ name: "nil root",
74+ tree: &Tree{},
75+ dir: Right,
76+ area: area,
77+ wantErr: ErrNotFound,
78+ },
79+ {
80+ name: "layout too small",
81+ tree: NewTree("a"),
82+ dir: Right,
83+ area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows},
84+ wantErr: ErrTooSmall,
85+ },
86+ {
87+ name: "unknown direction has no pane",
88+ tree: NewTree("a"),
89+ dir: Direction(99),
90+ area: area,
91+ wantErr: ErrNoPane,
92+ },
93+ {
94+ name: "right enters left edge",
95+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "right"},
96+ dir: Right,
97+ span: domain.Rect{Y: 1, Width: 20, Height: 2},
98+ area: area,
99+ want: "left",
100+ },
101+ {
102+ name: "left enters right edge",
103+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "left"},
104+ dir: Left,
105+ span: domain.Rect{Y: 1, Width: 20, Height: 2},
106+ area: area,
107+ want: "right",
108+ },
109+ {
110+ name: "down enters top edge",
111+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "bottom"},
112+ dir: Down,
113+ span: domain.Rect{X: 5, Width: 10, Height: 2},
114+ area: area,
115+ want: "top",
116+ },
117+ {
118+ name: "up enters bottom edge",
119+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "top"},
120+ dir: Up,
121+ span: domain.Rect{X: 5, Width: 10, Height: 2},
122+ area: area,
123+ want: "bottom",
124+ },
125+ {
126+ name: "minimum edge gap precedes overlap",
127+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
128+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("edge-top"), NewLeaf("edge-bottom")}},
129+ NewLeaf("far"),
130+ }}},
131+ dir: Right,
132+ span: domain.Rect{Y: 1, Width: 20, Height: 4},
133+ area: domain.Rect{Width: 62, Height: 5},
134+ want: "edge-bottom",
135+ },
136+ {
137+ name: "maximum perpendicular overlap breaks equal gap",
138+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}},
139+ dir: Right,
140+ span: domain.Rect{Y: 3, Width: 20, Height: 2},
141+ area: domain.Rect{Width: 20, Height: 5},
142+ want: "bottom",
143+ },
144+ {
145+ name: "pane id deterministically breaks full tie",
146+ tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("z"), NewLeaf("a")}, Expanded: "z"}},
147+ dir: Right,
148+ span: domain.Rect{Y: 10, Width: 20, Height: 1},
149+ area: domain.Rect{Width: 20, Height: 4},
150+ want: "a",
151+ },
152+ }
153+
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ before := tt.tree.Clone()
157+ got, err := tt.tree.EntryPane(tt.dir, tt.span, tt.area)
158+ require.ErrorIs(t, err, tt.wantErr)
159+ require.Equal(t, tt.want, got)
160+ require.Equal(t, before, tt.tree, "EntryPane must be pure")
161+ })
162+ }
163+}
164+
165+func TestFocusEnterExpandsCollapsedStackMember(t *testing.T) {
166+ t.Parallel()
167+
168+ area := domain.Rect{Width: 20, Height: 4}
169+ tr := &Tree{
170+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("expanded"), NewLeaf("collapsed")}, Expanded: "expanded"},
171+ Focus: "expanded",
172+ }
173+
174+ err := tr.FocusEnter(Right, domain.Rect{Y: 3, Width: 20, Height: 1}, area)
175+ require.NoError(t, err)
176+ require.Equal(t, PaneID("collapsed"), tr.Focus)
177+ require.Equal(t, PaneID("collapsed"), tr.Root.Expanded)
178+}
@@ -0,0 +1,178 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestFocusSpan(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{X: 5, Y: 7, Width: 41, Height: 5}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ area domain.Rect
18+ want domain.Rect
19+ wantErr error
20+ }{
21+ {
22+ name: "nil root",
23+ tree: &Tree{},
24+ area: area,
25+ wantErr: ErrNotFound,
26+ },
27+ {
28+ name: "layout too small",
29+ tree: NewTree("a"),
30+ area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows},
31+ wantErr: ErrTooSmall,
32+ },
33+ {
34+ name: "focused pane unknown",
35+ tree: &Tree{Root: NewLeaf("a"), Focus: "missing"},
36+ area: area,
37+ wantErr: ErrNotFound,
38+ },
39+ {
40+ name: "returns collapsed stack title bar",
41+ tree: &Tree{
42+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"},
43+ Focus: "b",
44+ },
45+ area: area,
46+ want: domain.Rect{X: 5, Y: 11, Width: 41, Height: 1},
47+ },
48+ }
49+
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ got, err := tt.tree.FocusSpan(tt.area)
53+ require.ErrorIs(t, err, tt.wantErr)
54+ require.Equal(t, tt.want, got)
55+ })
56+ }
57+}
58+
59+func TestEntryPane(t *testing.T) {
60+ t.Parallel()
61+
62+ area := domain.Rect{Width: 41, Height: 5}
63+ tests := []struct {
64+ name string
65+ tree *Tree
66+ dir Direction
67+ span domain.Rect
68+ area domain.Rect
69+ want PaneID
70+ wantErr error
71+ }{
72+ {
73+ name: "nil root",
74+ tree: &Tree{},
75+ dir: Right,
76+ area: area,
77+ wantErr: ErrNotFound,
78+ },
79+ {
80+ name: "layout too small",
81+ tree: NewTree("a"),
82+ dir: Right,
83+ area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows},
84+ wantErr: ErrTooSmall,
85+ },
86+ {
87+ name: "unknown direction has no pane",
88+ tree: NewTree("a"),
89+ dir: Direction(99),
90+ area: area,
91+ wantErr: ErrNoPane,
92+ },
93+ {
94+ name: "right enters left edge",
95+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "right"},
96+ dir: Right,
97+ span: domain.Rect{Y: 1, Width: 20, Height: 2},
98+ area: area,
99+ want: "left",
100+ },
101+ {
102+ name: "left enters right edge",
103+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "left"},
104+ dir: Left,
105+ span: domain.Rect{Y: 1, Width: 20, Height: 2},
106+ area: area,
107+ want: "right",
108+ },
109+ {
110+ name: "down enters top edge",
111+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "bottom"},
112+ dir: Down,
113+ span: domain.Rect{X: 5, Width: 10, Height: 2},
114+ area: area,
115+ want: "top",
116+ },
117+ {
118+ name: "up enters bottom edge",
119+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "top"},
120+ dir: Up,
121+ span: domain.Rect{X: 5, Width: 10, Height: 2},
122+ area: area,
123+ want: "bottom",
124+ },
125+ {
126+ name: "minimum edge gap precedes overlap",
127+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
128+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("edge-top"), NewLeaf("edge-bottom")}},
129+ NewLeaf("far"),
130+ }}},
131+ dir: Right,
132+ span: domain.Rect{Y: 1, Width: 20, Height: 4},
133+ area: domain.Rect{Width: 62, Height: 5},
134+ want: "edge-bottom",
135+ },
136+ {
137+ name: "maximum perpendicular overlap breaks equal gap",
138+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}},
139+ dir: Right,
140+ span: domain.Rect{Y: 3, Width: 20, Height: 2},
141+ area: domain.Rect{Width: 20, Height: 5},
142+ want: "bottom",
143+ },
144+ {
145+ name: "pane id deterministically breaks full tie",
146+ tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("z"), NewLeaf("a")}, Expanded: "z"}},
147+ dir: Right,
148+ span: domain.Rect{Y: 10, Width: 20, Height: 1},
149+ area: domain.Rect{Width: 20, Height: 4},
150+ want: "a",
151+ },
152+ }
153+
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ before := tt.tree.Clone()
157+ got, err := tt.tree.EntryPane(tt.dir, tt.span, tt.area)
158+ require.ErrorIs(t, err, tt.wantErr)
159+ require.Equal(t, tt.want, got)
160+ require.Equal(t, before, tt.tree, "EntryPane must be pure")
161+ })
162+ }
163+}
164+
165+func TestFocusEnterExpandsCollapsedStackMember(t *testing.T) {
166+ t.Parallel()
167+
168+ area := domain.Rect{Width: 20, Height: 4}
169+ tr := &Tree{
170+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("expanded"), NewLeaf("collapsed")}, Expanded: "expanded"},
171+ Focus: "expanded",
172+ }
173+
174+ err := tr.FocusEnter(Right, domain.Rect{Y: 3, Width: 20, Height: 1}, area)
175+ require.NoError(t, err)
176+ require.Equal(t, PaneID("collapsed"), tr.Focus)
177+ require.Equal(t, PaneID("collapsed"), tr.Root.Expanded)
178+}
M internal/usecase/layout/ops.go +99 -21
@@ -372,6 +372,63 @@ func leafIDs(n *Node) []PaneID {
372372 return ids
373373 }
374374
375+// FocusSpan returns the solved focus rectangle of the focused pane.
376+func (t *Tree) FocusSpan(area domain.Rect) (domain.Rect, error) {
377+ if t == nil || t.Root == nil {
378+ return domain.Rect{}, ErrNotFound
379+ }
380+ placements, ok := Solve(t.Root, area)
381+ if !ok {
382+ return domain.Rect{}, ErrTooSmall
383+ }
384+ for _, placement := range placements {
385+ if placement.ID == t.Focus {
386+ return focusRect(placement), nil
387+ }
388+ }
389+ return domain.Rect{}, ErrNotFound
390+}
391+
392+// EntryPane returns the pane on the facing edge that best preserves span on
393+// the perpendicular axis. It does not mutate the tree.
394+func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) {
395+ if t == nil || t.Root == nil {
396+ return "", ErrNotFound
397+ }
398+ placements, ok := Solve(t.Root, area)
399+ if !ok {
400+ return "", ErrTooSmall
401+ }
402+
403+ outside := span
404+ switch dir {
405+ case Left:
406+ outside.X = area.X + area.Width
407+ case Right:
408+ outside.X = area.X - span.Width
409+ case Up:
410+ outside.Y = area.Y + area.Height
411+ case Down:
412+ outside.Y = area.Y - span.Height
413+ default:
414+ return "", ErrNoPane
415+ }
416+
417+ return selectFacingCandidate(placements, outside, dir, facingGapFirst)
418+}
419+
420+// FocusEnter focuses the best pane on the facing edge and expands it when it
421+// is a collapsed stack member.
422+func (t *Tree) FocusEnter(dir Direction, span, area domain.Rect) error {
423+ id, err := t.EntryPane(dir, span, area)
424+ if err != nil {
425+ return err
426+ }
427+ t.Focus = id
428+ setExpanded(t.Root, id)
429+ return nil
430+}
431+
375432 // FocusDir moves focus in dir. Vertical movement within a stack walks stack
376433 // members before falling back to geometric focus.
377434 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
@@ -396,36 +453,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
396453 return ErrNotFound
397454 }
398455 current := focusRect(placements[currentIdx])
399- type candidate struct {
400- id PaneID
401- overlap int
402- gap int
456+ candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst)
457+ if err != nil {
458+ return err
403459 }
404- var candidates []candidate
405- for _, p := range placements {
406- if p.ID == t.Focus {
407- continue
408- }
409- r := focusRect(p)
410- if ok, gap, overlap := sideGapOverlap(current, r, dir); ok {
411- candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap})
460+ t.Focus = candidate
461+ setExpanded(t.Root, t.Focus)
462+ return nil
463+}
464+
465+type facingMetricPriority uint8
466+
467+const (
468+ facingGapFirst facingMetricPriority = iota
469+ facingOverlapFirst
470+)
471+
472+type facingCandidate struct {
473+ id PaneID
474+ gap int
475+ overlap int
476+}
477+
478+func selectFacingCandidate(placements []Placement, from domain.Rect, dir Direction, priority facingMetricPriority) (PaneID, error) {
479+ candidates := make([]facingCandidate, 0, len(placements))
480+ for _, placement := range placements {
481+ if facing, gap, perpendicularOverlap := sideGapOverlap(from, focusRect(placement), dir); facing {
482+ candidates = append(candidates, facingCandidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
412483 }
413484 }
414485 if len(candidates) == 0 {
415- return ErrNoPane
486+ return "", ErrNoPane
416487 }
417488 sort.Slice(candidates, func(i, j int) bool {
418- if candidates[i].overlap != candidates[j].overlap {
419- return candidates[i].overlap > candidates[j].overlap
420- }
421- if candidates[i].gap != candidates[j].gap {
422- return candidates[i].gap < candidates[j].gap
489+ if priority == facingGapFirst {
490+ if candidates[i].gap != candidates[j].gap {
491+ return candidates[i].gap < candidates[j].gap
492+ }
493+ if candidates[i].overlap != candidates[j].overlap {
494+ return candidates[i].overlap > candidates[j].overlap
495+ }
496+ } else {
497+ if candidates[i].overlap != candidates[j].overlap {
498+ return candidates[i].overlap > candidates[j].overlap
499+ }
500+ if candidates[i].gap != candidates[j].gap {
501+ return candidates[i].gap < candidates[j].gap
502+ }
423503 }
424504 return candidates[i].id < candidates[j].id
425505 })
426- t.Focus = candidates[0].id
427- setExpanded(t.Root, t.Focus)
428- return nil
506+ return candidates[0].id, nil
429507 }
430508
431509 func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {
@@ -372,6 +372,63 @@ func leafIDs(n *Node) []PaneID {
372 return ids372 return ids
373 }373 }
374 374
375+// FocusSpan returns the solved focus rectangle of the focused pane.
376+func (t *Tree) FocusSpan(area domain.Rect) (domain.Rect, error) {
377+ if t == nil || t.Root == nil {
378+ return domain.Rect{}, ErrNotFound
379+ }
380+ placements, ok := Solve(t.Root, area)
381+ if !ok {
382+ return domain.Rect{}, ErrTooSmall
383+ }
384+ for _, placement := range placements {
385+ if placement.ID == t.Focus {
386+ return focusRect(placement), nil
387+ }
388+ }
389+ return domain.Rect{}, ErrNotFound
390+}
391+
392+// EntryPane returns the pane on the facing edge that best preserves span on
393+// the perpendicular axis. It does not mutate the tree.
394+func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) {
395+ if t == nil || t.Root == nil {
396+ return "", ErrNotFound
397+ }
398+ placements, ok := Solve(t.Root, area)
399+ if !ok {
400+ return "", ErrTooSmall
401+ }
402+
403+ outside := span
404+ switch dir {
405+ case Left:
406+ outside.X = area.X + area.Width
407+ case Right:
408+ outside.X = area.X - span.Width
409+ case Up:
410+ outside.Y = area.Y + area.Height
411+ case Down:
412+ outside.Y = area.Y - span.Height
413+ default:
414+ return "", ErrNoPane
415+ }
416+
417+ return selectFacingCandidate(placements, outside, dir, facingGapFirst)
418+}
419+
420+// FocusEnter focuses the best pane on the facing edge and expands it when it
421+// is a collapsed stack member.
422+func (t *Tree) FocusEnter(dir Direction, span, area domain.Rect) error {
423+ id, err := t.EntryPane(dir, span, area)
424+ if err != nil {
425+ return err
426+ }
427+ t.Focus = id
428+ setExpanded(t.Root, id)
429+ return nil
430+}
431+
375 // FocusDir moves focus in dir. Vertical movement within a stack walks stack432 // FocusDir moves focus in dir. Vertical movement within a stack walks stack
376 // members before falling back to geometric focus.433 // members before falling back to geometric focus.
377 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {434 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
@@ -396,36 +453,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
396 return ErrNotFound453 return ErrNotFound
397 }454 }
398 current := focusRect(placements[currentIdx])455 current := focusRect(placements[currentIdx])
399- type candidate struct {456+ candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst)
400- id PaneID457+ if err != nil {
401- overlap int458+ return err
402- gap int
403 }459 }
404- var candidates []candidate460+ t.Focus = candidate
405- for _, p := range placements {461+ setExpanded(t.Root, t.Focus)
406- if p.ID == t.Focus {462+ return nil
407- continue463+}
408- }464+
409- r := focusRect(p)465+type facingMetricPriority uint8
410- if ok, gap, overlap := sideGapOverlap(current, r, dir); ok {466+
411- candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap})467+const (
468+ facingGapFirst facingMetricPriority = iota
469+ facingOverlapFirst
470+)
471+
472+type facingCandidate struct {
473+ id PaneID
474+ gap int
475+ overlap int
476+}
477+
478+func selectFacingCandidate(placements []Placement, from domain.Rect, dir Direction, priority facingMetricPriority) (PaneID, error) {
479+ candidates := make([]facingCandidate, 0, len(placements))
480+ for _, placement := range placements {
481+ if facing, gap, perpendicularOverlap := sideGapOverlap(from, focusRect(placement), dir); facing {
482+ candidates = append(candidates, facingCandidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
412 }483 }
413 }484 }
414 if len(candidates) == 0 {485 if len(candidates) == 0 {
415- return ErrNoPane486+ return "", ErrNoPane
416 }487 }
417 sort.Slice(candidates, func(i, j int) bool {488 sort.Slice(candidates, func(i, j int) bool {
418- if candidates[i].overlap != candidates[j].overlap {489+ if priority == facingGapFirst {
419- return candidates[i].overlap > candidates[j].overlap490+ if candidates[i].gap != candidates[j].gap {
420- }491+ return candidates[i].gap < candidates[j].gap
421- if candidates[i].gap != candidates[j].gap {492+ }
422- return candidates[i].gap < candidates[j].gap493+ if candidates[i].overlap != candidates[j].overlap {
494+ return candidates[i].overlap > candidates[j].overlap
495+ }
496+ } else {
497+ if candidates[i].overlap != candidates[j].overlap {
498+ return candidates[i].overlap > candidates[j].overlap
499+ }
500+ if candidates[i].gap != candidates[j].gap {
501+ return candidates[i].gap < candidates[j].gap
502+ }
423 }503 }
424 return candidates[i].id < candidates[j].id504 return candidates[i].id < candidates[j].id
425 })505 })
426- t.Focus = candidates[0].id506+ return candidates[0].id, nil
427- setExpanded(t.Root, t.Focus)
428- return nil
429 }507 }
430 508
431 func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {509 func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {

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 12

Tags 11

↑↓ moveenter openesc close