v0.2.0 80c61e3

navigation_overflow_test.go RawHistoryFolder

1package daemon
2
3import (
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
15func 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
45func TestPrepareTabOverflowRejectsMissingExpectedSource(t *testing.T) {
46	d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil)
47	target := sess.tabs[1]
48	target.mu.Lock()
49	target.size = domain.Size{Cols: 41, Rows: 10}
50	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"}
51	target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
52	target.mu.Unlock()
53
54	missing := &tab{stableID: "missing-source"}
55	_, ok := d.prepareTabOverflowForAttachment(sess, nil, missing, layout.Right, domain.Rect{Width: 20, Height: 10}, 1)
56	require.False(t, ok)
57}
58
59func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
60	tests := []struct {
61		name       string
62		start      int
63		action     keys.Action
64		wantActive int
65		wantFocus  layout.PaneID
66	}{
67		{name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"},
68		{name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"},
69	}
70
71	for _, tt := range tests {
72		t.Run(tt.name, func(t *testing.T) {
73			d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
74			sess.mu.Lock()
75			selectTestAttachmentTabLocked(sess, tt.start)
76			target := sess.tabs[tt.wantActive]
77			source := sess.tabs[tt.start]
78			sess.mu.Unlock()
79			target.mu.Lock()
80			target.size = domain.Size{Cols: 41, Rows: 10}
81			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"}
82			target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
83			target.mu.Unlock()
84			d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
85
86			daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
87
88			require.Equal(t, tt.wantActive, testAttachmentTabIndex(sess))
89			target.mu.Lock()
90			require.Equal(t, tt.wantFocus, target.tree.Focus)
91			target.mu.Unlock()
92			require.NotSame(t, source, testAttachmentTab(sess))
93		})
94	}
95}
96
97func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *testing.T) {
98	d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
99	alpha.mu.Lock()
100	alpha.name = "alpha"
101	alpha.mu.Unlock()
102
103	newSession := func(id, name string, active int, focus layout.PaneID) *session {
104		tabs := []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10}), newTab(nil, domain.Size{Cols: 41, Rows: 10})}
105		target := tabs[active]
106		target.mu.Lock()
107		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}
108		target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
109		target.mu.Unlock()
110		return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs}
111	}
112	charlie := newSession("live-charlie", "charlie", 1, "pane-2")
113	echo := newSession("live-echo", "echo", 1, "pane-2")
114
115	// Deliberately register the live sessions out of alphabetical order. The
116	// stopped name sorts between alpha and charlie but must never be resumed.
117	d.mu.Lock()
118	delete(d.sessions, alpha.id)
119	d.sessions[echo.id] = echo
120	d.sessions[alpha.id] = alpha
121	d.sessions[charlie.id] = charlie
122	d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"}
123	d.mu.Unlock()
124	d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
125	handler := daemonKeyHandler{d: d, ac: ac}
126
127	moves := []struct {
128		name   string
129		action keys.Action
130		want   *session
131	}{
132		{name: "down to charlie", action: keys.ActionFocusPaneDown, want: charlie},
133		{name: "down to echo", action: keys.ActionFocusPaneDown, want: echo},
134		{name: "down wall", action: keys.ActionFocusPaneDown, want: echo},
135		{name: "up to charlie", action: keys.ActionFocusPaneUp, want: charlie},
136		{name: "up to alpha", action: keys.ActionFocusPaneUp, want: alpha},
137		{name: "up wall", action: keys.ActionFocusPaneUp, want: alpha},
138	}
139	for _, move := range moves {
140		t.Run(move.name, func(t *testing.T) {
141			handler.Action(move.action, nil)
142			require.Same(t, move.want, ac.currentSession())
143		})
144	}
145
146	for _, target := range []*session{charlie, echo} {
147		require.Equal(t, 0, testAttachmentTabIndex(target), "new attachment starts at the deterministic first tab")
148		target.tabs[1].mu.Lock()
149		require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
150		target.tabs[1].mu.Unlock()
151	}
152	d.mu.Lock()
153	_, stopped := d.stopped["bravo"]
154	var bravoLive bool
155	for _, candidate := range d.sessions {
156		if candidate == nil {
157			continue
158		}
159		candidate.mu.Lock()
160		bravoLive = bravoLive || candidate.name == "bravo"
161		candidate.mu.Unlock()
162	}
163	d.mu.Unlock()
164	alpha.mu.Lock()
165	attached := alpha.snapshotAttachmentsLocked()
166	alpha.mu.Unlock()
167	require.True(t, stopped, "vertical overflow leaves the stopped session stopped")
168	require.False(t, bravoLive, "the stopped session is absent from the live registry")
169	require.Contains(t, attached, ac, "the source session owns the genuinely attached client after returning")
170}
171
172func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
173	d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
174	alpha.mu.Lock()
175	alpha.name = "alpha"
176	alpha.mu.Unlock()
177	installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
178	charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
179	d.mu.Lock()
180	d.sessions[charlie.id] = charlie
181	d.mu.Unlock()
182	d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
183
184	daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown, nil)
185
186	require.Same(t, alpha, ac.currentSession())
187	alpha.mu.Lock()
188	require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
189	alpha.mu.Unlock()
190	charlie.mu.Lock()
191	require.Empty(t, charlie.snapshotAttachmentsLocked())
192	charlie.mu.Unlock()
193}
194
195func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
196	d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
197	alpha.mu.Lock()
198	alpha.name = "alpha"
199	alpha.mu.Unlock()
200	charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
201	d.mu.Lock()
202	d.sessions[charlie.id] = charlie
203	d.mu.Unlock()
204
205	target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
206	require.True(t, ok)
207	installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
208
209	require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
210	require.Same(t, alpha, ac.currentSession())
211	alpha.mu.Lock()
212	require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
213	alpha.mu.Unlock()
214	charlie.mu.Lock()
215	require.Empty(t, charlie.snapshotAttachmentsLocked())
216	charlie.mu.Unlock()
217}
218
219func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
220	d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
221	alpha.mu.Lock()
222	alpha.name = "alpha"
223	expectedSource := alpha.tabs[0]
224	selectTestAttachmentTabLocked(alpha, 1)
225	alpha.mu.Unlock()
226	charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
227	d.mu.Lock()
228	d.sessions[charlie.id] = charlie
229	d.mu.Unlock()
230
231	require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
232	require.Same(t, alpha, ac.currentSession())
233	alpha.mu.Lock()
234	require.Contains(t, alpha.snapshotAttachmentsLocked(), ac)
235	alpha.mu.Unlock()
236	charlie.mu.Lock()
237	require.Empty(t, charlie.snapshotAttachmentsLocked())
238	charlie.mu.Unlock()
239}
240
241func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {
242	d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
243	installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
244	charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
245	d.mu.Lock()
246	d.sessions[charlie.id] = charlie
247	d.mu.Unlock()
248
249	require.NoError(t, d.switchToTarget(alpha, ac, picker.Target{Session: charlie.id, TabIndex: -1}))
250	require.Same(t, charlie, ac.currentSession())
251}
252
253func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
254	d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
255	alpha.mu.Lock()
256	alpha.name = "alpha"
257	alpha.mu.Unlock()
258	charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
259	d.mu.Lock()
260	d.sessions[charlie.id] = charlie
261	d.mu.Unlock()
262
263	target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
264	require.True(t, ok)
265	replacement := &attachedClient{}
266	alpha.mu.Lock()
267	alpha.registerAttachmentLocked(replacement)
268	alpha.mu.Unlock()
269
270	require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
271	require.Same(t, alpha, ac.currentSession())
272	alpha.mu.Lock()
273	require.Contains(t, alpha.snapshotAttachmentsLocked(), replacement)
274	alpha.mu.Unlock()
275	charlie.mu.Lock()
276	require.Empty(t, charlie.snapshotAttachmentsLocked())
277	charlie.mu.Unlock()
278}
279
280func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
281	d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
282	alpha.mu.Lock()
283	alpha.name = "alpha"
284	alpha.mu.Unlock()
285	charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
286	echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
287	d.mu.Lock()
288	d.sessions[echo.id] = echo
289	d.sessions[charlie.id] = charlie
290	d.mu.Unlock()
291	d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
292
293	start := make(chan struct{})
294	renameErrors := make(chan error, 40)
295	var wg sync.WaitGroup
296	wg.Go(func() {
297		<-start
298		for i := range 40 {
299			name := "charlie"
300			if i%2 == 0 {
301				name = "delta"
302			}
303			if err := d.renameSession(charlie, name); err != nil {
304				renameErrors <- err
305			}
306		}
307	})
308
309	close(start)
310	handler := daemonKeyHandler{d: d, ac: ac}
311	for range 20 {
312		handler.Action(keys.ActionFocusPaneDown, nil)
313		handler.Action(keys.ActionFocusPaneUp, nil)
314	}
315	wg.Wait()
316	close(renameErrors)
317	for err := range renameErrors {
318		require.NoError(t, err)
319	}
320
321	current := ac.currentSession()
322	owners := make([]*session, 0, 1)
323	d.mu.Lock()
324	for _, candidate := range d.sessions {
325		if candidate == nil {
326			continue
327		}
328		candidate.mu.Lock()
329		if attachmentRegisteredLocked(candidate, ac) {
330			owners = append(owners, candidate)
331		}
332		candidate.mu.Unlock()
333	}
334	d.mu.Unlock()
335	require.Len(t, owners, 1)
336	require.Same(t, current, owners[0])
337}
338
339func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
340	tests := []struct {
341		name  string
342		setup func(d *Daemon, sess *session)
343		dir   keys.Action
344	}{
345		{name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight},
346		{name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft},
347		{name: "failed target entry", setup: func(d *Daemon, sess *session) {
348			d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
349			sess.tabs[1].mu.Lock()
350			sess.tabs[1].tree = nil
351			sess.tabs[1].mu.Unlock()
352		}, dir: keys.ActionFocusPaneRight},
353		{name: "visible floating pane", setup: func(d *Daemon, sess *session) {
354			d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
355			installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
356		}, dir: keys.ActionFocusPaneRight},
357	}
358
359	for _, tt := range tests {
360		t.Run(tt.name, func(t *testing.T) {
361			d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
362			tt.setup(d, sess)
363
364			daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil)
365
366			require.Equal(t, 0, testAttachmentTabIndex(sess))
367		})
368	}
369}

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