detached 777c969

feat(daemon): overflow vertical focus across sessions

commit
777c969057d0f6175af5f443b523c750ac52f626
author
brice · · 2026-07-25 05:20 UTC
parent
ca3316c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +176 -1
M internal/usecase/daemon/navigation_overflow.go +51 -0
@@ -1,8 +1,11 @@
11 package daemon
22
33 import (
4+ "sort"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/internal/usecase/picker"
69 )
710
811 type overflowKind uint8
@@ -47,6 +50,54 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count
4750 return step
4851 }
4952
53+type sessionOverflowSnapshot struct {
54+ session *session
55+ id domain.SessionID
56+ name string
57+}
58+
59+// prepareSessionOverflow snapshots only the live registry entries, then sorts
60+// immutable names exactly like the picker. No daemon or session lock survives
61+// the snapshot, sorting, or the later switchToTarget call.
62+func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, cfg domain.NavConfig) (picker.Target, bool) {
63+ if current == nil || !cfg.OverflowSessions || (dir != layout.Up && dir != layout.Down) {
64+ return picker.Target{}, false
65+ }
66+ current.mu.Lock()
67+ currentID := current.id
68+ current.mu.Unlock()
69+
70+ d.mu.Lock()
71+ live := make([]*session, 0, len(d.sessions))
72+ for _, sess := range d.sessions {
73+ live = append(live, sess)
74+ }
75+ d.mu.Unlock()
76+
77+ snapshots := make([]sessionOverflowSnapshot, 0, len(live))
78+ for _, sess := range live {
79+ sess.mu.Lock()
80+ snapshot := sessionOverflowSnapshot{session: sess, id: sess.id, name: sess.name}
81+ sess.mu.Unlock()
82+ snapshots = append(snapshots, snapshot)
83+ }
84+ sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].name < snapshots[j].name })
85+
86+ position := -1
87+ for i, snapshot := range snapshots {
88+ if snapshot.session == current && snapshot.id == currentID {
89+ position = i
90+ break
91+ }
92+ }
93+ step := resolveOverflow(dir, cfg, position, len(snapshots))
94+ if step.kind != overflowSessions {
95+ return picker.Target{}, false
96+ }
97+ target := snapshots[position+step.delta]
98+ return picker.Target{Session: target.id, TabIndex: -1}, true
99+}
100+
50101 type tabOverflowCandidate struct {
51102 source *tab
52103 target *tab
@@ -1,8 +1,11 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sort"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/layout"7 "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/internal/usecase/picker"
6 )9 )
7 10
8 type overflowKind uint811 type overflowKind uint8
@@ -47,6 +50,54 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count
47 return step50 return step
48 }51 }
49 52
53+type sessionOverflowSnapshot struct {
54+ session *session
55+ id domain.SessionID
56+ name string
57+}
58+
59+// prepareSessionOverflow snapshots only the live registry entries, then sorts
60+// immutable names exactly like the picker. No daemon or session lock survives
61+// the snapshot, sorting, or the later switchToTarget call.
62+func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, cfg domain.NavConfig) (picker.Target, bool) {
63+ if current == nil || !cfg.OverflowSessions || (dir != layout.Up && dir != layout.Down) {
64+ return picker.Target{}, false
65+ }
66+ current.mu.Lock()
67+ currentID := current.id
68+ current.mu.Unlock()
69+
70+ d.mu.Lock()
71+ live := make([]*session, 0, len(d.sessions))
72+ for _, sess := range d.sessions {
73+ live = append(live, sess)
74+ }
75+ d.mu.Unlock()
76+
77+ snapshots := make([]sessionOverflowSnapshot, 0, len(live))
78+ for _, sess := range live {
79+ sess.mu.Lock()
80+ snapshot := sessionOverflowSnapshot{session: sess, id: sess.id, name: sess.name}
81+ sess.mu.Unlock()
82+ snapshots = append(snapshots, snapshot)
83+ }
84+ sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].name < snapshots[j].name })
85+
86+ position := -1
87+ for i, snapshot := range snapshots {
88+ if snapshot.session == current && snapshot.id == currentID {
89+ position = i
90+ break
91+ }
92+ }
93+ step := resolveOverflow(dir, cfg, position, len(snapshots))
94+ if step.kind != overflowSessions {
95+ return picker.Target{}, false
96+ }
97+ target := snapshots[position+step.delta]
98+ return picker.Target{Session: target.id, TabIndex: -1}, true
99+}
100+
50 type tabOverflowCandidate struct {101 type tabOverflowCandidate struct {
51 source *tab102 source *tab
52 target *tab103 target *tab
M internal/usecase/daemon/navigation_overflow_test.go +116 -0
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "sync"
45 "testing"
56
67 "github.com/stretchr/testify/require"
@@ -76,6 +77,121 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
7677 }
7778 }
7879
80+func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *testing.T) {
81+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
82+ alpha.mu.Lock()
83+ alpha.name = "alpha"
84+ alpha.mu.Unlock()
85+
86+ newSession := func(id, name string, active int, focus layout.PaneID) *session {
87+ tabs := []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10}), newTab(nil, domain.Size{Cols: 41, Rows: 10})}
88+ target := tabs[active]
89+ target.mu.Lock()
90+ 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}
91+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
92+ target.mu.Unlock()
93+ return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}
94+ }
95+ charlie := newSession("live-charlie", "charlie", 1, "pane-2")
96+ echo := newSession("live-echo", "echo", 1, "pane-2")
97+
98+ // Deliberately register the live sessions out of alphabetical order. The
99+ // stopped name sorts between alpha and charlie but must never be resumed.
100+ d.mu.Lock()
101+ delete(d.sessions, alpha.id)
102+ d.sessions[echo.id] = echo
103+ d.sessions[alpha.id] = alpha
104+ d.sessions[charlie.id] = charlie
105+ d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"}
106+ d.mu.Unlock()
107+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
108+ handler := daemonKeyHandler{d: d, ac: ac}
109+
110+ moves := []struct {
111+ name string
112+ action keys.Action
113+ want *session
114+ }{
115+ {name: "down to charlie", action: keys.ActionFocusPaneDown, want: charlie},
116+ {name: "down to echo", action: keys.ActionFocusPaneDown, want: echo},
117+ {name: "down wall", action: keys.ActionFocusPaneDown, want: echo},
118+ {name: "up to charlie", action: keys.ActionFocusPaneUp, want: charlie},
119+ {name: "up to alpha", action: keys.ActionFocusPaneUp, want: alpha},
120+ {name: "up wall", action: keys.ActionFocusPaneUp, want: alpha},
121+ }
122+ for _, move := range moves {
123+ t.Run(move.name, func(t *testing.T) {
124+ handler.Action(move.action)
125+ require.Same(t, move.want, ac.currentSession())
126+ })
127+ }
128+
129+ for _, target := range []*session{charlie, echo} {
130+ require.Equal(t, 1, activeTabIndex(target), "switch preserves the target active tab")
131+ target.tabs[1].mu.Lock()
132+ require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
133+ target.tabs[1].mu.Unlock()
134+ }
135+ d.mu.Lock()
136+ _, stopped := d.stopped["bravo"]
137+ var bravoLive bool
138+ for _, candidate := range d.sessions {
139+ candidate.mu.Lock()
140+ bravoLive = bravoLive || candidate.name == "bravo"
141+ candidate.mu.Unlock()
142+ }
143+ d.mu.Unlock()
144+ alpha.mu.Lock()
145+ attached := alpha.client
146+ alpha.mu.Unlock()
147+ require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
148+ require.False(t, bravoLive, "the stopped session is absent from the live registry")
149+ require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning")
150+}
151+
152+func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
153+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
154+ alpha.mu.Lock()
155+ alpha.name = "alpha"
156+ alpha.mu.Unlock()
157+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
158+ echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
159+ d.mu.Lock()
160+ d.sessions[echo.id] = echo
161+ d.sessions[charlie.id] = charlie
162+ d.mu.Unlock()
163+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
164+
165+ start := make(chan struct{})
166+ renameErrors := make(chan error, 40)
167+ var wg sync.WaitGroup
168+ wg.Go(func() {
169+ <-start
170+ for i := range 40 {
171+ name := "charlie"
172+ if i%2 == 0 {
173+ name = "delta"
174+ }
175+ if err := d.renameSession(charlie, name); err != nil {
176+ renameErrors <- err
177+ }
178+ }
179+ })
180+
181+ close(start)
182+ handler := daemonKeyHandler{d: d, ac: ac}
183+ for range 20 {
184+ handler.Action(keys.ActionFocusPaneDown)
185+ handler.Action(keys.ActionFocusPaneUp)
186+ }
187+ wg.Wait()
188+ close(renameErrors)
189+ for err := range renameErrors {
190+ require.NoError(t, err)
191+ }
192+ require.NotNil(t, ac.currentSession())
193+}
194+
79195 func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
80196 tests := []struct {
81197 name string
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sync"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
@@ -76,6 +77,121 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
76 }77 }
77 }78 }
78 79
80+func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *testing.T) {
81+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
82+ alpha.mu.Lock()
83+ alpha.name = "alpha"
84+ alpha.mu.Unlock()
85+
86+ newSession := func(id, name string, active int, focus layout.PaneID) *session {
87+ tabs := []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10}), newTab(nil, domain.Size{Cols: 41, Rows: 10})}
88+ target := tabs[active]
89+ target.mu.Lock()
90+ 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}
91+ target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
92+ target.mu.Unlock()
93+ return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}
94+ }
95+ charlie := newSession("live-charlie", "charlie", 1, "pane-2")
96+ echo := newSession("live-echo", "echo", 1, "pane-2")
97+
98+ // Deliberately register the live sessions out of alphabetical order. The
99+ // stopped name sorts between alpha and charlie but must never be resumed.
100+ d.mu.Lock()
101+ delete(d.sessions, alpha.id)
102+ d.sessions[echo.id] = echo
103+ d.sessions[alpha.id] = alpha
104+ d.sessions[charlie.id] = charlie
105+ d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"}
106+ d.mu.Unlock()
107+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
108+ handler := daemonKeyHandler{d: d, ac: ac}
109+
110+ moves := []struct {
111+ name string
112+ action keys.Action
113+ want *session
114+ }{
115+ {name: "down to charlie", action: keys.ActionFocusPaneDown, want: charlie},
116+ {name: "down to echo", action: keys.ActionFocusPaneDown, want: echo},
117+ {name: "down wall", action: keys.ActionFocusPaneDown, want: echo},
118+ {name: "up to charlie", action: keys.ActionFocusPaneUp, want: charlie},
119+ {name: "up to alpha", action: keys.ActionFocusPaneUp, want: alpha},
120+ {name: "up wall", action: keys.ActionFocusPaneUp, want: alpha},
121+ }
122+ for _, move := range moves {
123+ t.Run(move.name, func(t *testing.T) {
124+ handler.Action(move.action)
125+ require.Same(t, move.want, ac.currentSession())
126+ })
127+ }
128+
129+ for _, target := range []*session{charlie, echo} {
130+ require.Equal(t, 1, activeTabIndex(target), "switch preserves the target active tab")
131+ target.tabs[1].mu.Lock()
132+ require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
133+ target.tabs[1].mu.Unlock()
134+ }
135+ d.mu.Lock()
136+ _, stopped := d.stopped["bravo"]
137+ var bravoLive bool
138+ for _, candidate := range d.sessions {
139+ candidate.mu.Lock()
140+ bravoLive = bravoLive || candidate.name == "bravo"
141+ candidate.mu.Unlock()
142+ }
143+ d.mu.Unlock()
144+ alpha.mu.Lock()
145+ attached := alpha.client
146+ alpha.mu.Unlock()
147+ require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
148+ require.False(t, bravoLive, "the stopped session is absent from the live registry")
149+ require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning")
150+}
151+
152+func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
153+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
154+ alpha.mu.Lock()
155+ alpha.name = "alpha"
156+ alpha.mu.Unlock()
157+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
158+ echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
159+ d.mu.Lock()
160+ d.sessions[echo.id] = echo
161+ d.sessions[charlie.id] = charlie
162+ d.mu.Unlock()
163+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
164+
165+ start := make(chan struct{})
166+ renameErrors := make(chan error, 40)
167+ var wg sync.WaitGroup
168+ wg.Go(func() {
169+ <-start
170+ for i := range 40 {
171+ name := "charlie"
172+ if i%2 == 0 {
173+ name = "delta"
174+ }
175+ if err := d.renameSession(charlie, name); err != nil {
176+ renameErrors <- err
177+ }
178+ }
179+ })
180+
181+ close(start)
182+ handler := daemonKeyHandler{d: d, ac: ac}
183+ for range 20 {
184+ handler.Action(keys.ActionFocusPaneDown)
185+ handler.Action(keys.ActionFocusPaneUp)
186+ }
187+ wg.Wait()
188+ close(renameErrors)
189+ for err := range renameErrors {
190+ require.NoError(t, err)
191+ }
192+ require.NotNil(t, ac.currentSession())
193+}
194+
79 func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {195 func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
80 tests := []struct {196 tests := []struct {
81 name string197 name string
M internal/usecase/daemon/pane_actions.go +9 -1
@@ -320,10 +320,18 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
320320 return err
321321 }
322322
323+ cfg := d.currentNavConfig()
324+ if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
325+ if ac == nil {
326+ return errNoNeighbor
327+ }
328+ return d.switchToTarget(sess, ac, sessionTarget)
329+ }
330+
323331 sess.mu.Lock()
324332 position, count := sess.active, len(sess.tabs)
325333 sess.mu.Unlock()
326- step := resolveOverflow(dir, d.currentNavConfig(), position, count)
334+ step := resolveOverflow(dir, cfg, position, count)
327335 if step.kind != overflowTabs {
328336 return err
329337 }
@@ -320,10 +320,18 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
320 return err320 return err
321 }321 }
322 322
323+ cfg := d.currentNavConfig()
324+ if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
325+ if ac == nil {
326+ return errNoNeighbor
327+ }
328+ return d.switchToTarget(sess, ac, sessionTarget)
329+ }
330+
323 sess.mu.Lock()331 sess.mu.Lock()
324 position, count := sess.active, len(sess.tabs)332 position, count := sess.active, len(sess.tabs)
325 sess.mu.Unlock()333 sess.mu.Unlock()
326- step := resolveOverflow(dir, d.currentNavConfig(), position, count)334+ step := resolveOverflow(dir, cfg, position, count)
327 if step.kind != overflowTabs {335 if step.kind != overflowTabs {
328 return err336 return err
329 }337 }

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