detached ca3316c

feat(daemon): overflow horizontal focus across tabs

commit
ca3316c9431170425a5f0e407840859476f91503
author
brice · · 2026-07-25 05:15 UTC
parent
15f2a25
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +317 -4
A internal/usecase/daemon/navigation_overflow.go +159 -0
@@ -0,0 +1,159 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+type overflowKind uint8
9+
10+const (
11+ overflowNone overflowKind = iota
12+ overflowTabs
13+ overflowSessions
14+)
15+
16+type overflowStep struct {
17+ kind overflowKind
18+ delta int
19+}
20+
21+// resolveOverflow is the pure escalation policy for directional navigation.
22+// The caller supplies its position on the selected axis, so disabled axes and
23+// either hard wall all resolve to the zero step.
24+func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep {
25+ var step overflowStep
26+ switch dir {
27+ case layout.Left:
28+ if cfg.OverflowTabs {
29+ step = overflowStep{kind: overflowTabs, delta: -1}
30+ }
31+ case layout.Right:
32+ if cfg.OverflowTabs {
33+ step = overflowStep{kind: overflowTabs, delta: 1}
34+ }
35+ case layout.Up:
36+ if cfg.OverflowSessions {
37+ step = overflowStep{kind: overflowSessions, delta: -1}
38+ }
39+ case layout.Down:
40+ if cfg.OverflowSessions {
41+ step = overflowStep{kind: overflowSessions, delta: 1}
42+ }
43+ }
44+ if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count {
45+ return overflowStep{}
46+ }
47+ return step
48+}
49+
50+type tabOverflowCandidate struct {
51+ source *tab
52+ target *tab
53+ sourceIndex int
54+ delta int
55+ direction layout.Direction
56+ span domain.Rect
57+ entry layout.PaneID
58+ entryPane *pane
59+ targetOldFocus layout.PaneID
60+}
61+
62+// prepareTabOverflow captures tab identities under the session lock and uses
63+// EntryPane's pure lookup before any active-tab state is changed.
64+func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
65+ if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
66+ return tabOverflowCandidate{}, false
67+ }
68+ sess.mu.Lock()
69+ if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
70+ sess.mu.Unlock()
71+ return tabOverflowCandidate{}, false
72+ }
73+ sourceIndex := sess.active
74+ targetIndex := sourceIndex + delta
75+ if targetIndex < 0 || targetIndex >= len(sess.tabs) {
76+ sess.mu.Unlock()
77+ return tabOverflowCandidate{}, false
78+ }
79+ source := sess.tabs[sourceIndex]
80+ target := sess.tabs[targetIndex]
81+ sess.mu.Unlock()
82+
83+ source.mu.Lock()
84+ floatingVisible := source.floating.state == floatingVisible
85+ source.mu.Unlock()
86+ if floatingVisible {
87+ return tabOverflowCandidate{}, false
88+ }
89+
90+ target.mu.Lock()
91+ area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}
92+ entry, err := target.tree.EntryPane(dir, span, area)
93+ entryPane := target.panes[entry]
94+ oldFocus := layout.PaneID("")
95+ if target.tree != nil {
96+ oldFocus = target.tree.Focus
97+ }
98+ valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)
99+ target.mu.Unlock()
100+ if !valid {
101+ return tabOverflowCandidate{}, false
102+ }
103+ return tabOverflowCandidate{
104+ source: source,
105+ target: target,
106+ sourceIndex: sourceIndex,
107+ delta: delta,
108+ direction: dir,
109+ span: span,
110+ entry: entry,
111+ entryPane: entryPane,
112+ targetOldFocus: oldFocus,
113+ }, true
114+}
115+
116+// commitTabOverflow atomically revalidates the source and target tab entries.
117+// The target layout and selected pane are revalidated while locked before the
118+// focus and active-tab mutations become visible together.
119+func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
120+ if sess == nil || candidate.source == nil || candidate.target == nil {
121+ return false
122+ }
123+ sess.mu.Lock()
124+ targetIndex := candidate.sourceIndex + candidate.delta
125+ if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
126+ sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
127+ sess.mu.Unlock()
128+ return false
129+ }
130+
131+ candidate.source.mu.Lock()
132+ if candidate.source.floating.state == floatingVisible {
133+ candidate.source.mu.Unlock()
134+ sess.mu.Unlock()
135+ return false
136+ }
137+ candidate.target.mu.Lock()
138+ area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}
139+ entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)
140+ if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {
141+ candidate.target.mu.Unlock()
142+ candidate.source.mu.Unlock()
143+ sess.mu.Unlock()
144+ return false
145+ }
146+ if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
147+ candidate.target.mu.Unlock()
148+ candidate.source.mu.Unlock()
149+ sess.mu.Unlock()
150+ return false
151+ }
152+ d.applyLayoutLocked(candidate.target)
153+ sess.active = targetIndex
154+ candidate.target.mu.Unlock()
155+ candidate.source.mu.Unlock()
156+ sess.mu.Unlock()
157+ markSnapshotDirty(sess)
158+ return true
159+}
@@ -0,0 +1,159 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+type overflowKind uint8
9+
10+const (
11+ overflowNone overflowKind = iota
12+ overflowTabs
13+ overflowSessions
14+)
15+
16+type overflowStep struct {
17+ kind overflowKind
18+ delta int
19+}
20+
21+// resolveOverflow is the pure escalation policy for directional navigation.
22+// The caller supplies its position on the selected axis, so disabled axes and
23+// either hard wall all resolve to the zero step.
24+func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep {
25+ var step overflowStep
26+ switch dir {
27+ case layout.Left:
28+ if cfg.OverflowTabs {
29+ step = overflowStep{kind: overflowTabs, delta: -1}
30+ }
31+ case layout.Right:
32+ if cfg.OverflowTabs {
33+ step = overflowStep{kind: overflowTabs, delta: 1}
34+ }
35+ case layout.Up:
36+ if cfg.OverflowSessions {
37+ step = overflowStep{kind: overflowSessions, delta: -1}
38+ }
39+ case layout.Down:
40+ if cfg.OverflowSessions {
41+ step = overflowStep{kind: overflowSessions, delta: 1}
42+ }
43+ }
44+ if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count {
45+ return overflowStep{}
46+ }
47+ return step
48+}
49+
50+type tabOverflowCandidate struct {
51+ source *tab
52+ target *tab
53+ sourceIndex int
54+ delta int
55+ direction layout.Direction
56+ span domain.Rect
57+ entry layout.PaneID
58+ entryPane *pane
59+ targetOldFocus layout.PaneID
60+}
61+
62+// prepareTabOverflow captures tab identities under the session lock and uses
63+// EntryPane's pure lookup before any active-tab state is changed.
64+func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
65+ if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
66+ return tabOverflowCandidate{}, false
67+ }
68+ sess.mu.Lock()
69+ if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
70+ sess.mu.Unlock()
71+ return tabOverflowCandidate{}, false
72+ }
73+ sourceIndex := sess.active
74+ targetIndex := sourceIndex + delta
75+ if targetIndex < 0 || targetIndex >= len(sess.tabs) {
76+ sess.mu.Unlock()
77+ return tabOverflowCandidate{}, false
78+ }
79+ source := sess.tabs[sourceIndex]
80+ target := sess.tabs[targetIndex]
81+ sess.mu.Unlock()
82+
83+ source.mu.Lock()
84+ floatingVisible := source.floating.state == floatingVisible
85+ source.mu.Unlock()
86+ if floatingVisible {
87+ return tabOverflowCandidate{}, false
88+ }
89+
90+ target.mu.Lock()
91+ area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows}
92+ entry, err := target.tree.EntryPane(dir, span, area)
93+ entryPane := target.panes[entry]
94+ oldFocus := layout.PaneID("")
95+ if target.tree != nil {
96+ oldFocus = target.tree.Focus
97+ }
98+ valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry)
99+ target.mu.Unlock()
100+ if !valid {
101+ return tabOverflowCandidate{}, false
102+ }
103+ return tabOverflowCandidate{
104+ source: source,
105+ target: target,
106+ sourceIndex: sourceIndex,
107+ delta: delta,
108+ direction: dir,
109+ span: span,
110+ entry: entry,
111+ entryPane: entryPane,
112+ targetOldFocus: oldFocus,
113+ }, true
114+}
115+
116+// commitTabOverflow atomically revalidates the source and target tab entries.
117+// The target layout and selected pane are revalidated while locked before the
118+// focus and active-tab mutations become visible together.
119+func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
120+ if sess == nil || candidate.source == nil || candidate.target == nil {
121+ return false
122+ }
123+ sess.mu.Lock()
124+ targetIndex := candidate.sourceIndex + candidate.delta
125+ if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
126+ sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
127+ sess.mu.Unlock()
128+ return false
129+ }
130+
131+ candidate.source.mu.Lock()
132+ if candidate.source.floating.state == floatingVisible {
133+ candidate.source.mu.Unlock()
134+ sess.mu.Unlock()
135+ return false
136+ }
137+ candidate.target.mu.Lock()
138+ area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows}
139+ entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area)
140+ if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) {
141+ candidate.target.mu.Unlock()
142+ candidate.source.mu.Unlock()
143+ sess.mu.Unlock()
144+ return false
145+ }
146+ if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
147+ candidate.target.mu.Unlock()
148+ candidate.source.mu.Unlock()
149+ sess.mu.Unlock()
150+ return false
151+ }
152+ d.applyLayoutLocked(candidate.target)
153+ sess.active = targetIndex
154+ candidate.target.mu.Unlock()
155+ candidate.source.mu.Unlock()
156+ sess.mu.Unlock()
157+ markSnapshotDirty(sess)
158+ return true
159+}
A internal/usecase/daemon/navigation_overflow_test.go +132 -0
@@ -0,0 +1,132 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/keys"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ dir layout.Direction
17+ cfg domain.NavConfig
18+ position int
19+ count int
20+ want overflowStep
21+ }{
22+ {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2},
23+ {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2},
24+ {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}},
25+ {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}},
26+ {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
27+ {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2},
28+ {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}},
29+ {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}},
30+ {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
31+ {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1},
32+ }
33+
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count))
37+ })
38+ }
39+}
40+
41+func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
42+ tests := []struct {
43+ name string
44+ start int
45+ action keys.Action
46+ wantActive int
47+ wantFocus layout.PaneID
48+ }{
49+ {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"},
50+ {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"},
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
56+ sess.mu.Lock()
57+ sess.active = tt.start
58+ target := sess.tabs[tt.wantActive]
59+ source := sess.tabs[tt.start]
60+ sess.mu.Unlock()
61+ target.mu.Lock()
62+ target.size = domain.Size{Cols: 41, Rows: 10}
63+ 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"}
64+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
65+ target.mu.Unlock()
66+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
67+
68+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
69+
70+ require.Equal(t, tt.wantActive, activeTabIndex(sess))
71+ target.mu.Lock()
72+ require.Equal(t, tt.wantFocus, target.tree.Focus)
73+ target.mu.Unlock()
74+ require.NotSame(t, source, sess.activeTab())
75+ })
76+ }
77+}
78+
79+func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
80+ tests := []struct {
81+ name string
82+ setup func(d *Daemon, sess *session)
83+ dir keys.Action
84+ }{
85+ {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight},
86+ {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft},
87+ {name: "failed target entry", setup: func(d *Daemon, sess *session) {
88+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
89+ sess.tabs[1].mu.Lock()
90+ sess.tabs[1].tree = nil
91+ sess.tabs[1].mu.Unlock()
92+ }, dir: keys.ActionFocusPaneRight},
93+ {name: "visible floating pane", setup: func(d *Daemon, sess *session) {
94+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
95+ installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
96+ }, dir: keys.ActionFocusPaneRight},
97+ }
98+
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
102+ tt.setup(d, sess)
103+
104+ daemonKeyHandler{d: d, ac: ac}.Action(tt.dir)
105+
106+ require.Equal(t, 0, activeTabIndex(sess))
107+ })
108+ }
109+}
110+
111+func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
112+ tests := []struct {
113+ name string
114+ mutate func(sess *session, candidate tabOverflowCandidate)
115+ }{
116+ {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { sess.active = 1 }},
117+ {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { sess.tabs[1] = newTab(nil, candidate.target.size) }},
118+ }
119+
120+ for _, tt := range tests {
121+ t.Run(tt.name, func(t *testing.T) {
122+ d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
123+ candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1)
124+ require.True(t, ok)
125+ sess.mu.Lock()
126+ tt.mutate(sess, candidate)
127+ sess.mu.Unlock()
128+
129+ require.False(t, d.commitTabOverflow(sess, candidate))
130+ })
131+ }
132+}
@@ -0,0 +1,132 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/keys"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ dir layout.Direction
17+ cfg domain.NavConfig
18+ position int
19+ count int
20+ want overflowStep
21+ }{
22+ {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2},
23+ {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2},
24+ {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}},
25+ {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}},
26+ {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
27+ {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2},
28+ {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}},
29+ {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}},
30+ {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2},
31+ {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1},
32+ }
33+
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count))
37+ })
38+ }
39+}
40+
41+func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
42+ tests := []struct {
43+ name string
44+ start int
45+ action keys.Action
46+ wantActive int
47+ wantFocus layout.PaneID
48+ }{
49+ {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"},
50+ {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"},
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
56+ sess.mu.Lock()
57+ sess.active = tt.start
58+ target := sess.tabs[tt.wantActive]
59+ source := sess.tabs[tt.start]
60+ sess.mu.Unlock()
61+ target.mu.Lock()
62+ target.size = domain.Size{Cols: 41, Rows: 10}
63+ 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"}
64+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
65+ target.mu.Unlock()
66+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
67+
68+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
69+
70+ require.Equal(t, tt.wantActive, activeTabIndex(sess))
71+ target.mu.Lock()
72+ require.Equal(t, tt.wantFocus, target.tree.Focus)
73+ target.mu.Unlock()
74+ require.NotSame(t, source, sess.activeTab())
75+ })
76+ }
77+}
78+
79+func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
80+ tests := []struct {
81+ name string
82+ setup func(d *Daemon, sess *session)
83+ dir keys.Action
84+ }{
85+ {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight},
86+ {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft},
87+ {name: "failed target entry", setup: func(d *Daemon, sess *session) {
88+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
89+ sess.tabs[1].mu.Lock()
90+ sess.tabs[1].tree = nil
91+ sess.tabs[1].mu.Unlock()
92+ }, dir: keys.ActionFocusPaneRight},
93+ {name: "visible floating pane", setup: func(d *Daemon, sess *session) {
94+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
95+ installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
96+ }, dir: keys.ActionFocusPaneRight},
97+ }
98+
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
102+ tt.setup(d, sess)
103+
104+ daemonKeyHandler{d: d, ac: ac}.Action(tt.dir)
105+
106+ require.Equal(t, 0, activeTabIndex(sess))
107+ })
108+ }
109+}
110+
111+func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) {
112+ tests := []struct {
113+ name string
114+ mutate func(sess *session, candidate tabOverflowCandidate)
115+ }{
116+ {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { sess.active = 1 }},
117+ {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { sess.tabs[1] = newTab(nil, candidate.target.size) }},
118+ }
119+
120+ for _, tt := range tests {
121+ t.Run(tt.name, func(t *testing.T) {
122+ d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
123+ candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1)
124+ require.True(t, ok)
125+ sess.mu.Lock()
126+ tt.mutate(sess, candidate)
127+ sess.mu.Unlock()
128+
129+ require.False(t, d.commitTabOverflow(sess, candidate))
130+ })
131+ }
132+}
M internal/usecase/daemon/pane_actions.go +26 -4
@@ -309,11 +309,33 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
309309 if target.pane != nil {
310310 oldFocus = target.pane.id
311311 }
312- _, err := d.focusDirAt(sess, target.tab, target.pane, dir)
313- if err == nil && ac != nil {
314- d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
312+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
313+ if err == nil {
314+ if ac != nil {
315+ d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
316+ }
317+ return nil
315318 }
316- return err
319+ if !errors.Is(err, errNoNeighbor) || target.tab == nil {
320+ return err
321+ }
322+
323+ sess.mu.Lock()
324+ position, count := sess.active, len(sess.tabs)
325+ sess.mu.Unlock()
326+ step := resolveOverflow(dir, d.currentNavConfig(), position, count)
327+ if step.kind != overflowTabs {
328+ return err
329+ }
330+ candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
331+ if !ok || !d.commitTabOverflow(sess, candidate) {
332+ return errNoNeighbor
333+ }
334+ d.activateTab(sess, candidate.target)
335+ if ac != nil {
336+ d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
337+ }
338+ return nil
317339 }
318340
319341 // finishPaneFocusForClient applies the attachment lifecycle shared by every
@@ -309,11 +309,33 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
309 if target.pane != nil {309 if target.pane != nil {
310 oldFocus = target.pane.id310 oldFocus = target.pane.id
311 }311 }
312- _, err := d.focusDirAt(sess, target.tab, target.pane, dir)312+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
313- if err == nil && ac != nil {313+ if err == nil {
314- d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")314+ if ac != nil {
315+ d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
316+ }
317+ return nil
315 }318 }
316- return err319+ if !errors.Is(err, errNoNeighbor) || target.tab == nil {
320+ return err
321+ }
322+
323+ sess.mu.Lock()
324+ position, count := sess.active, len(sess.tabs)
325+ sess.mu.Unlock()
326+ step := resolveOverflow(dir, d.currentNavConfig(), position, count)
327+ if step.kind != overflowTabs {
328+ return err
329+ }
330+ candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
331+ if !ok || !d.commitTabOverflow(sess, candidate) {
332+ return errNoNeighbor
333+ }
334+ d.activateTab(sess, candidate.target)
335+ if ac != nil {
336+ d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
337+ }
338+ return nil
317 }339 }
318 340
319 // finishPaneFocusForClient applies the attachment lifecycle shared by every341 // finishPaneFocusForClient applies the attachment lifecycle shared by every

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