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, incarnation: newTestLifecycle(t)}, 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.inactive["bravo"] = inactiveSession{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.inactive["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, sends := newManualSessionWithPTYs(t, nil)
282	// This race test generates many full views and semantic route updates.
283	// Keep its test client reading rather than filling the bounded transport.
284	stop, drained := make(chan struct{}), make(chan struct{})
285	go func() {
286		defer close(drained)
287		for {
288			select {
289			case <-sends:
290			case <-stop:
291				return
292			}
293		}
294	}()
295	defer func() { close(stop); <-drained }()
296	alpha.mu.Lock()
297	alpha.name = "alpha"
298	alpha.mu.Unlock()
299	charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie", incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
300	echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo", incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
301	d.mu.Lock()
302	d.sessions[echo.id] = echo
303	d.sessions[charlie.id] = charlie
304	d.mu.Unlock()
305	d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
306
307	start := make(chan struct{})
308	renameErrors := make(chan error, 40)
309	var wg sync.WaitGroup
310	wg.Go(func() {
311		<-start
312		for i := range 40 {
313			name := "charlie"
314			if i%2 == 0 {
315				name = "delta"
316			}
317			if err := d.renameSession(charlie, name); err != nil {
318				renameErrors <- err
319			}
320		}
321	})
322
323	close(start)
324	handler := daemonKeyHandler{d: d, ac: ac}
325	for range 20 {
326		handler.Action(keys.ActionFocusPaneDown, nil)
327		handler.Action(keys.ActionFocusPaneUp, nil)
328	}
329	wg.Wait()
330	close(renameErrors)
331	for err := range renameErrors {
332		require.NoError(t, err)
333	}
334
335	current := ac.currentSession()
336	owners := make([]*session, 0, 1)
337	d.mu.Lock()
338	for _, candidate := range d.sessions {
339		if candidate == nil {
340			continue
341		}
342		candidate.mu.Lock()
343		if attachmentRegisteredLocked(candidate, ac) {
344			owners = append(owners, candidate)
345		}
346		candidate.mu.Unlock()
347	}
348	d.mu.Unlock()
349	require.Len(t, owners, 1)
350	require.Same(t, current, owners[0])
351}
352
353func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) {
354	tests := []struct {
355		name  string
356		setup func(d *Daemon, sess *session)
357		dir   keys.Action
358	}{
359		{name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight},
360		{name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft},
361		{name: "failed target entry", setup: func(d *Daemon, sess *session) {
362			d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
363			sess.tabs[1].mu.Lock()
364			sess.tabs[1].tree = nil
365			sess.tabs[1].mu.Unlock()
366		}, dir: keys.ActionFocusPaneRight},
367		{name: "visible floating pane", setup: func(d *Daemon, sess *session) {
368			d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
369			installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
370		}, dir: keys.ActionFocusPaneRight},
371	}
372
373	for _, tt := range tests {
374		t.Run(tt.name, func(t *testing.T) {
375			d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
376			tt.setup(d, sess)
377
378			daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil)
379
380			require.Equal(t, 0, testAttachmentTabIndex(sess))
381		})
382	}
383}

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