detached 9519803

Merge remote-tracking branch 'origin/main' into feat/pane-resize

# Conflicts:
#	internal/usecase/daemon/pane_actions.go
commit
951980399ad0f4e27ca919af55d278b75b042c1b
author
brice · · 2026-07-25 09:17 UTC
merge of
3ead26d + c41c8a7 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1184 -57
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
@@ -273,7 +273,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
273273 }
274274 return nil
275275 case daemonActionFocusPane:
276- return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
276+ _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
277+ return err
277278 case daemonActionNextTab:
278279 return a.switchRelative(target.session, 1)
279280 case daemonActionPreviousTab:
@@ -361,17 +362,8 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att
361362 return
362363 }
363364 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
364- tb := request.target.tab
365- tb.mu.Lock()
366- newFocus := tb.tree.Focus
367- pl, hasPlacement := focusedPlacementLocked(tb)
368- tb.mu.Unlock()
369- if newFocus != request.target.pane.id {
370- d.exitCopyMode(ac)
371- if hasPlacement && pl.InStack {
372- d.refreshPaneTitleOnFocus(request.target.session, newFocus)
373- }
374- }
365+ d.finishPaneFocusForClient(request.target.session, ac, request.target.tab, request.target.pane.id, producer)
366+ return
375367 }
376368 d.invalidateRender(request.target.session, ac, true, producer)
377369 }
@@ -273,7 +273,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
273 }273 }
274 return nil274 return nil
275 case daemonActionFocusPane:275 case daemonActionFocusPane:
276- return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)276+ _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
277+ return err
277 case daemonActionNextTab:278 case daemonActionNextTab:
278 return a.switchRelative(target.session, 1)279 return a.switchRelative(target.session, 1)
279 case daemonActionPreviousTab:280 case daemonActionPreviousTab:
@@ -361,17 +362,8 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att
361 return362 return
362 }363 }
363 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {364 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
364- tb := request.target.tab365+ d.finishPaneFocusForClient(request.target.session, ac, request.target.tab, request.target.pane.id, producer)
365- tb.mu.Lock()366+ return
366- newFocus := tb.tree.Focus
367- pl, hasPlacement := focusedPlacementLocked(tb)
368- tb.mu.Unlock()
369- if newFocus != request.target.pane.id {
370- d.exitCopyMode(ac)
371- if hasPlacement && pl.InStack {
372- d.refreshPaneTitleOnFocus(request.target.session, newFocus)
373- }
374- }
375 }367 }
376 d.invalidateRender(request.target.session, ac, true, producer)368 d.invalidateRender(request.target.session, ac, true, producer)
377 }369 }
M internal/usecase/daemon/daemon.go +1 -0
@@ -166,6 +166,7 @@ type Daemon struct {
166166 floatingConfig atomic.Pointer[domain.FloatingConfig]
167167 copyConfig atomic.Pointer[domain.CopyConfig]
168168 paletteConfig atomic.Pointer[domain.PaletteConfig]
169+ navConfig atomic.Pointer[domain.NavConfig]
169170 tabsConfig atomic.Pointer[domain.TabsConfig]
170171 themeConfig atomic.Pointer[themeConfigSnapshot]
171172 barScripts *barScriptState
@@ -166,6 +166,7 @@ type Daemon struct {
166 floatingConfig atomic.Pointer[domain.FloatingConfig]166 floatingConfig atomic.Pointer[domain.FloatingConfig]
167 copyConfig atomic.Pointer[domain.CopyConfig]167 copyConfig atomic.Pointer[domain.CopyConfig]
168 paletteConfig atomic.Pointer[domain.PaletteConfig]168 paletteConfig atomic.Pointer[domain.PaletteConfig]
169+ navConfig atomic.Pointer[domain.NavConfig]
169 tabsConfig atomic.Pointer[domain.TabsConfig]170 tabsConfig atomic.Pointer[domain.TabsConfig]
170 themeConfig atomic.Pointer[themeConfigSnapshot]171 themeConfig atomic.Pointer[themeConfigSnapshot]
171 barScripts *barScriptState172 barScripts *barScriptState
A internal/usecase/daemon/navigation_overflow.go +246 -0
@@ -0,0 +1,246 @@
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+ targetTree := candidate.target.tree.Clone()
232+ if err := targetTree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
233+ candidate.target.mu.Unlock()
234+ candidate.source.mu.Unlock()
235+ sess.mu.Unlock()
236+ return false
237+ }
238+ candidate.target.tree = targetTree
239+ candidate.target.bumpLayoutGenerationLocked()
240+ sess.active = targetIndex
241+ candidate.target.mu.Unlock()
242+ candidate.source.mu.Unlock()
243+ sess.mu.Unlock()
244+ d.applyTabLayout(sess, candidate.target)
245+ return true
246+}
@@ -0,0 +1,246 @@
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+ targetTree := candidate.target.tree.Clone()
232+ if err := targetTree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
233+ candidate.target.mu.Unlock()
234+ candidate.source.mu.Unlock()
235+ sess.mu.Unlock()
236+ return false
237+ }
238+ candidate.target.tree = targetTree
239+ candidate.target.bumpLayoutGenerationLocked()
240+ sess.active = targetIndex
241+ candidate.target.mu.Unlock()
242+ candidate.source.mu.Unlock()
243+ sess.mu.Unlock()
244+ d.applyTabLayout(sess, candidate.target)
245+ return true
246+}
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
@@ -260,35 +260,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
260260 if target.pane != nil {
261261 oldFocus = target.pane.id
262262 }
263- err := d.focusDirAt(sess, target.tab, target.pane, dir)
264- if err == nil && ac != nil {
265- target.tab.mu.Lock()
266- newFocus := target.tab.tree.Focus
267- pl, hasPlacement := focusedPlacementLocked(target.tab)
268- target.tab.mu.Unlock()
269- if newFocus != oldFocus {
270- d.exitCopyMode(ac)
271- if hasPlacement && pl.InStack {
272- d.refreshPaneTitleOnFocus(sess, newFocus)
273- }
263+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
264+ if err == nil {
265+ if ac != nil {
266+ d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
274267 }
275- d.invalidateRender(sess, ac, true, "pane_actions.go")
268+ return nil
276269 }
277- return err
270+ if !errors.Is(err, errNoNeighbor) || target.tab == nil {
271+ return err
272+ }
273+ if !overflowSourceEligible(sess, target.tab) {
274+ return errNoNeighbor
275+ }
276+
277+ cfg := d.currentNavConfig()
278+ if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
279+ if ac == nil {
280+ return errNoNeighbor
281+ }
282+ return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
283+ }
284+
285+ sess.mu.Lock()
286+ position, count := sess.active, len(sess.tabs)
287+ sess.mu.Unlock()
288+ step := resolveOverflow(dir, cfg, position, count)
289+ if step.kind != overflowTabs {
290+ return err
291+ }
292+ candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
293+ if !ok || !d.commitTabOverflow(sess, candidate) {
294+ return errNoNeighbor
295+ }
296+ d.activateTab(sess, candidate.target)
297+ if ac != nil {
298+ d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
299+ }
300+ return nil
278301 }
279302
280-func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {
303+// finishPaneFocusForClient applies the attachment lifecycle shared by every
304+// successful directional focus move. Keep copy-mode exit before the optional
305+// stack title refresh, and both before render invalidation.
306+func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
307+ tb.mu.Lock()
308+ newFocus := tb.tree.Focus
309+ pl, hasPlacement := focusedPlacementLocked(tb)
310+ tb.mu.Unlock()
311+ if newFocus != oldFocus {
312+ d.exitCopyMode(ac)
313+ if hasPlacement && pl.InStack {
314+ d.refreshPaneTitleOnFocus(sess, newFocus)
315+ }
316+ }
317+ d.invalidateRender(sess, ac, true, producer)
318+}
319+
320+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) {
281321 if tb == nil || target == nil {
282- return layout.ErrNotFound
322+ return domain.Rect{}, layout.ErrNotFound
283323 }
284324 tb.mu.Lock()
285325 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
286326 tb.mu.Unlock()
287- return layout.ErrNotFound
327+ return domain.Rect{}, layout.ErrNotFound
288328 }
289329 candidate := tb.tree.Clone()
290330 candidate.Focus = target.id
291- err := candidate.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
331+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
332+ span, _ := candidate.FocusSpan(area)
333+ err := candidate.FocusDir(dir, area)
292334 committed := err == nil && candidate.Focus != tb.tree.Focus
293335 if committed {
294336 tb.tree = candidate
@@ -299,7 +341,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
299341 d.applyTabLayout(sess, tb)
300342 }
301343 if errors.Is(err, layout.ErrNoPane) {
302- return errNoNeighbor
344+ return span, errNoNeighbor
303345 }
304- return err
346+ return span, err
305347 }
@@ -260,35 +260,77 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
260 if target.pane != nil {260 if target.pane != nil {
261 oldFocus = target.pane.id261 oldFocus = target.pane.id
262 }262 }
263- err := d.focusDirAt(sess, target.tab, target.pane, dir)263+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
264- if err == nil && ac != nil {264+ if err == nil {
265- target.tab.mu.Lock()265+ if ac != nil {
266- newFocus := target.tab.tree.Focus266+ d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
267- pl, hasPlacement := focusedPlacementLocked(target.tab)
268- target.tab.mu.Unlock()
269- if newFocus != oldFocus {
270- d.exitCopyMode(ac)
271- if hasPlacement && pl.InStack {
272- d.refreshPaneTitleOnFocus(sess, newFocus)
273- }
274 }267 }
275- d.invalidateRender(sess, ac, true, "pane_actions.go")268+ return nil
276 }269 }
277- return err270+ if !errors.Is(err, errNoNeighbor) || target.tab == nil {
271+ return err
272+ }
273+ if !overflowSourceEligible(sess, target.tab) {
274+ return errNoNeighbor
275+ }
276+
277+ cfg := d.currentNavConfig()
278+ if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
279+ if ac == nil {
280+ return errNoNeighbor
281+ }
282+ return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
283+ }
284+
285+ sess.mu.Lock()
286+ position, count := sess.active, len(sess.tabs)
287+ sess.mu.Unlock()
288+ step := resolveOverflow(dir, cfg, position, count)
289+ if step.kind != overflowTabs {
290+ return err
291+ }
292+ candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
293+ if !ok || !d.commitTabOverflow(sess, candidate) {
294+ return errNoNeighbor
295+ }
296+ d.activateTab(sess, candidate.target)
297+ if ac != nil {
298+ d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
299+ }
300+ return nil
278 }301 }
279 302
280-func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error {303+// finishPaneFocusForClient applies the attachment lifecycle shared by every
304+// successful directional focus move. Keep copy-mode exit before the optional
305+// stack title refresh, and both before render invalidation.
306+func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
307+ tb.mu.Lock()
308+ newFocus := tb.tree.Focus
309+ pl, hasPlacement := focusedPlacementLocked(tb)
310+ tb.mu.Unlock()
311+ if newFocus != oldFocus {
312+ d.exitCopyMode(ac)
313+ if hasPlacement && pl.InStack {
314+ d.refreshPaneTitleOnFocus(sess, newFocus)
315+ }
316+ }
317+ d.invalidateRender(sess, ac, true, producer)
318+}
319+
320+func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (domain.Rect, error) {
281 if tb == nil || target == nil {321 if tb == nil || target == nil {
282- return layout.ErrNotFound322+ return domain.Rect{}, layout.ErrNotFound
283 }323 }
284 tb.mu.Lock()324 tb.mu.Lock()
285 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {325 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
286 tb.mu.Unlock()326 tb.mu.Unlock()
287- return layout.ErrNotFound327+ return domain.Rect{}, layout.ErrNotFound
288 }328 }
289 candidate := tb.tree.Clone()329 candidate := tb.tree.Clone()
290 candidate.Focus = target.id330 candidate.Focus = target.id
291- err := candidate.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})331+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
332+ span, _ := candidate.FocusSpan(area)
333+ err := candidate.FocusDir(dir, area)
292 committed := err == nil && candidate.Focus != tb.tree.Focus334 committed := err == nil && candidate.Focus != tb.tree.Focus
293 if committed {335 if committed {
294 tb.tree = candidate336 tb.tree = candidate
@@ -299,7 +341,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
299 d.applyTabLayout(sess, tb)341 d.applyTabLayout(sess, tb)
300 }342 }
301 if errors.Is(err, layout.ErrNoPane) {343 if errors.Is(err, layout.ErrNoPane) {
302- return errNoNeighbor344+ return span, errNoNeighbor
303 }345 }
304- return err346+ return span, err
305 }347 }
M internal/usecase/daemon/pane_actions_test.go +32 -1
@@ -154,7 +154,8 @@ func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) {
154154 target := tb.panes["pane-1"]
155155 tb.mu.Unlock()
156156
157- require.NoError(t, d.focusDirAt(sess, tb, target, layout.Right))
157+ _, err := d.focusDirAt(sess, tb, target, layout.Right)
158+ require.NoError(t, err)
158159 require.Empty(t, pty.requested(), "an equality-mismatched candidate was not committed and must not apply")
159160 }
160161
@@ -581,6 +582,36 @@ func TestResizeReflowsAllPanes(t *testing.T) {
581582 require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)
582583 }
583584
585+func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
586+ tests := []struct {
587+ name string
588+ direction layout.Direction
589+ wantErr error
590+ wantFocus layout.PaneID
591+ }{
592+ {name: "moves right", direction: layout.Right, wantFocus: "pane-2"},
593+ {name: "maps missing left neighbor", direction: layout.Left, wantErr: errNoNeighbor, wantFocus: "pane-1"},
594+ }
595+
596+ for _, tt := range tests {
597+ t.Run(tt.name, func(t *testing.T) {
598+ d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
599+ tb := sess.activeTab()
600+ target := tb.panes["pane-1"]
601+ 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"}
602+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
603+ tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
604+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
605+
606+ span, err := d.focusDirAt(sess, tb, target, tt.direction)
607+
608+ require.ErrorIs(t, err, tt.wantErr)
609+ require.Equal(t, domain.Rect{Width: 20, Height: 10}, span)
610+ require.Equal(t, tt.wantFocus, tb.tree.Focus)
611+ })
612+ }
613+}
614+
584615 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
585616 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
586617 tb := sess.activeTab()
@@ -154,7 +154,8 @@ func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) {
154 target := tb.panes["pane-1"]154 target := tb.panes["pane-1"]
155 tb.mu.Unlock()155 tb.mu.Unlock()
156 156
157- require.NoError(t, d.focusDirAt(sess, tb, target, layout.Right))157+ _, err := d.focusDirAt(sess, tb, target, layout.Right)
158+ require.NoError(t, err)
158 require.Empty(t, pty.requested(), "an equality-mismatched candidate was not committed and must not apply")159 require.Empty(t, pty.requested(), "an equality-mismatched candidate was not committed and must not apply")
159 }160 }
160 161
@@ -581,6 +582,36 @@ func TestResizeReflowsAllPanes(t *testing.T) {
581 require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)582 require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)
582 }583 }
583 584
585+func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
586+ tests := []struct {
587+ name string
588+ direction layout.Direction
589+ wantErr error
590+ wantFocus layout.PaneID
591+ }{
592+ {name: "moves right", direction: layout.Right, wantFocus: "pane-2"},
593+ {name: "maps missing left neighbor", direction: layout.Left, wantErr: errNoNeighbor, wantFocus: "pane-1"},
594+ }
595+
596+ for _, tt := range tests {
597+ t.Run(tt.name, func(t *testing.T) {
598+ d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
599+ tb := sess.activeTab()
600+ target := tb.panes["pane-1"]
601+ 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"}
602+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
603+ tb.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
604+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
605+
606+ span, err := d.focusDirAt(sess, tb, target, tt.direction)
607+
608+ require.ErrorIs(t, err, tt.wantErr)
609+ require.Equal(t, domain.Rect{Width: 20, Height: 10}, span)
610+ require.Equal(t, tt.wantFocus, tb.tree.Focus)
611+ })
612+ }
613+}
614+
584 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {615 func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
585 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})616 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
586 tb := sess.activeTab()617 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
@@ -608,6 +608,63 @@ func leafIDs(n *Node) []PaneID {
608608 return ids
609609 }
610610
611+// FocusSpan returns the solved focus rectangle of the focused pane.
612+func (t *Tree) FocusSpan(area domain.Rect) (domain.Rect, error) {
613+ if t == nil || t.Root == nil {
614+ return domain.Rect{}, ErrNotFound
615+ }
616+ placements, ok := Solve(t.Root, area)
617+ if !ok {
618+ return domain.Rect{}, ErrTooSmall
619+ }
620+ for _, placement := range placements {
621+ if placement.ID == t.Focus {
622+ return focusRect(placement), nil
623+ }
624+ }
625+ return domain.Rect{}, ErrNotFound
626+}
627+
628+// EntryPane returns the pane on the facing edge that best preserves span on
629+// the perpendicular axis. It does not mutate the tree.
630+func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) {
631+ if t == nil || t.Root == nil {
632+ return "", ErrNotFound
633+ }
634+ placements, ok := Solve(t.Root, area)
635+ if !ok {
636+ return "", ErrTooSmall
637+ }
638+
639+ outside := span
640+ switch dir {
641+ case Left:
642+ outside.X = area.X + area.Width
643+ case Right:
644+ outside.X = area.X - span.Width
645+ case Up:
646+ outside.Y = area.Y + area.Height
647+ case Down:
648+ outside.Y = area.Y - span.Height
649+ default:
650+ return "", ErrNoPane
651+ }
652+
653+ return selectFacingCandidate(placements, outside, dir, facingGapFirst)
654+}
655+
656+// FocusEnter focuses the best pane on the facing edge and expands it when it
657+// is a collapsed stack member.
658+func (t *Tree) FocusEnter(dir Direction, span, area domain.Rect) error {
659+ id, err := t.EntryPane(dir, span, area)
660+ if err != nil {
661+ return err
662+ }
663+ t.Focus = id
664+ setExpanded(t.Root, id)
665+ return nil
666+}
667+
611668 // FocusDir moves focus in dir. Vertical movement within a stack walks stack
612669 // members before falling back to geometric focus.
613670 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
@@ -632,36 +689,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
632689 return ErrNotFound
633690 }
634691 current := focusRect(placements[currentIdx])
635- type candidate struct {
636- id PaneID
637- overlap int
638- gap int
692+ candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst)
693+ if err != nil {
694+ return err
639695 }
640- var candidates []candidate
641- for _, p := range placements {
642- if p.ID == t.Focus {
643- continue
644- }
645- r := focusRect(p)
646- if ok, gap, overlap := sideGapOverlap(current, r, dir); ok {
647- candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap})
696+ t.Focus = candidate
697+ setExpanded(t.Root, t.Focus)
698+ return nil
699+}
700+
701+type facingMetricPriority uint8
702+
703+const (
704+ facingGapFirst facingMetricPriority = iota
705+ facingOverlapFirst
706+)
707+
708+type facingCandidate struct {
709+ id PaneID
710+ gap int
711+ overlap int
712+}
713+
714+func selectFacingCandidate(placements []Placement, from domain.Rect, dir Direction, priority facingMetricPriority) (PaneID, error) {
715+ candidates := make([]facingCandidate, 0, len(placements))
716+ for _, placement := range placements {
717+ if facing, gap, perpendicularOverlap := sideGapOverlap(from, focusRect(placement), dir); facing {
718+ candidates = append(candidates, facingCandidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
648719 }
649720 }
650721 if len(candidates) == 0 {
651- return ErrNoPane
722+ return "", ErrNoPane
652723 }
653724 sort.Slice(candidates, func(i, j int) bool {
654- if candidates[i].overlap != candidates[j].overlap {
655- return candidates[i].overlap > candidates[j].overlap
656- }
657- if candidates[i].gap != candidates[j].gap {
658- return candidates[i].gap < candidates[j].gap
725+ if priority == facingGapFirst {
726+ if candidates[i].gap != candidates[j].gap {
727+ return candidates[i].gap < candidates[j].gap
728+ }
729+ if candidates[i].overlap != candidates[j].overlap {
730+ return candidates[i].overlap > candidates[j].overlap
731+ }
732+ } else {
733+ if candidates[i].overlap != candidates[j].overlap {
734+ return candidates[i].overlap > candidates[j].overlap
735+ }
736+ if candidates[i].gap != candidates[j].gap {
737+ return candidates[i].gap < candidates[j].gap
738+ }
659739 }
660740 return candidates[i].id < candidates[j].id
661741 })
662- t.Focus = candidates[0].id
663- setExpanded(t.Root, t.Focus)
664- return nil
742+ return candidates[0].id, nil
665743 }
666744
667745 func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {
@@ -608,6 +608,63 @@ func leafIDs(n *Node) []PaneID {
608 return ids608 return ids
609 }609 }
610 610
611+// FocusSpan returns the solved focus rectangle of the focused pane.
612+func (t *Tree) FocusSpan(area domain.Rect) (domain.Rect, error) {
613+ if t == nil || t.Root == nil {
614+ return domain.Rect{}, ErrNotFound
615+ }
616+ placements, ok := Solve(t.Root, area)
617+ if !ok {
618+ return domain.Rect{}, ErrTooSmall
619+ }
620+ for _, placement := range placements {
621+ if placement.ID == t.Focus {
622+ return focusRect(placement), nil
623+ }
624+ }
625+ return domain.Rect{}, ErrNotFound
626+}
627+
628+// EntryPane returns the pane on the facing edge that best preserves span on
629+// the perpendicular axis. It does not mutate the tree.
630+func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) {
631+ if t == nil || t.Root == nil {
632+ return "", ErrNotFound
633+ }
634+ placements, ok := Solve(t.Root, area)
635+ if !ok {
636+ return "", ErrTooSmall
637+ }
638+
639+ outside := span
640+ switch dir {
641+ case Left:
642+ outside.X = area.X + area.Width
643+ case Right:
644+ outside.X = area.X - span.Width
645+ case Up:
646+ outside.Y = area.Y + area.Height
647+ case Down:
648+ outside.Y = area.Y - span.Height
649+ default:
650+ return "", ErrNoPane
651+ }
652+
653+ return selectFacingCandidate(placements, outside, dir, facingGapFirst)
654+}
655+
656+// FocusEnter focuses the best pane on the facing edge and expands it when it
657+// is a collapsed stack member.
658+func (t *Tree) FocusEnter(dir Direction, span, area domain.Rect) error {
659+ id, err := t.EntryPane(dir, span, area)
660+ if err != nil {
661+ return err
662+ }
663+ t.Focus = id
664+ setExpanded(t.Root, id)
665+ return nil
666+}
667+
611 // FocusDir moves focus in dir. Vertical movement within a stack walks stack668 // FocusDir moves focus in dir. Vertical movement within a stack walks stack
612 // members before falling back to geometric focus.669 // members before falling back to geometric focus.
613 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {670 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
@@ -632,36 +689,57 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
632 return ErrNotFound689 return ErrNotFound
633 }690 }
634 current := focusRect(placements[currentIdx])691 current := focusRect(placements[currentIdx])
635- type candidate struct {692+ candidate, err := selectFacingCandidate(placements, current, dir, facingOverlapFirst)
636- id PaneID693+ if err != nil {
637- overlap int694+ return err
638- gap int
639 }695 }
640- var candidates []candidate696+ t.Focus = candidate
641- for _, p := range placements {697+ setExpanded(t.Root, t.Focus)
642- if p.ID == t.Focus {698+ return nil
643- continue699+}
644- }700+
645- r := focusRect(p)701+type facingMetricPriority uint8
646- if ok, gap, overlap := sideGapOverlap(current, r, dir); ok {702+
647- candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap})703+const (
704+ facingGapFirst facingMetricPriority = iota
705+ facingOverlapFirst
706+)
707+
708+type facingCandidate struct {
709+ id PaneID
710+ gap int
711+ overlap int
712+}
713+
714+func selectFacingCandidate(placements []Placement, from domain.Rect, dir Direction, priority facingMetricPriority) (PaneID, error) {
715+ candidates := make([]facingCandidate, 0, len(placements))
716+ for _, placement := range placements {
717+ if facing, gap, perpendicularOverlap := sideGapOverlap(from, focusRect(placement), dir); facing {
718+ candidates = append(candidates, facingCandidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
648 }719 }
649 }720 }
650 if len(candidates) == 0 {721 if len(candidates) == 0 {
651- return ErrNoPane722+ return "", ErrNoPane
652 }723 }
653 sort.Slice(candidates, func(i, j int) bool {724 sort.Slice(candidates, func(i, j int) bool {
654- if candidates[i].overlap != candidates[j].overlap {725+ if priority == facingGapFirst {
655- return candidates[i].overlap > candidates[j].overlap726+ if candidates[i].gap != candidates[j].gap {
656- }727+ return candidates[i].gap < candidates[j].gap
657- if candidates[i].gap != candidates[j].gap {728+ }
658- return candidates[i].gap < candidates[j].gap729+ if candidates[i].overlap != candidates[j].overlap {
730+ return candidates[i].overlap > candidates[j].overlap
731+ }
732+ } else {
733+ if candidates[i].overlap != candidates[j].overlap {
734+ return candidates[i].overlap > candidates[j].overlap
735+ }
736+ if candidates[i].gap != candidates[j].gap {
737+ return candidates[i].gap < candidates[j].gap
738+ }
659 }739 }
660 return candidates[i].id < candidates[j].id740 return candidates[i].id < candidates[j].id
661 })741 })
662- t.Focus = candidates[0].id742+ return candidates[0].id, nil
663- setExpanded(t.Root, t.Focus)
664- return nil
665 }743 }
666 744
667 func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {745 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