v0.3.0 99dc266

move_picker_test.go RawHistoryFolder

1package daemon
2
3import (
4	"testing"
5	"time"
6
7	"github.com/stretchr/testify/require"
8
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/ports"
11	"github.com/bnema/vev/internal/usecase/picker"
12)
13
14func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
15	t.Helper()
16	return setupMovePickerSessionsWithClock(t, stubClock{}, extraDestinationTabs)
17}
18
19func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
20	t.Helper()
21	sourcePTY, releaseSource := newBlockingPTY(t)
22	d, source, ac, _ := newManualSessionWithPTYsClock(t, clock, sourcePTY)
23	source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1}
24	d.mu.Lock()
25	delete(d.sessions, domain.SessionID("manual"))
26	d.sessions[source.id] = source
27	d.mu.Unlock()
28	sourceTab := source.tabs[0]
29	sourceTab.stableID = "source-tab"
30	sourcePane := sourceTab.focusedPane()
31	sourcePane.stableID = "source-pane"
32
33	releases := []func(){releaseSource}
34	destPTY, releaseDest := newBlockingPTY(t)
35	releases = append(releases, releaseDest)
36	destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})
37	publishTiledPaneOwners(source, sourceTab)
38	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}}
39	publishTiledPaneOwners(destination, destinationTab)
40	for range extraDestinationTabs {
41		extraPTY, releaseExtra := newBlockingPTY(t)
42		releases = append(releases, releaseExtra)
43		extraTab := newTabWithStableID("extra-tab", "extra-pane", extraPTY, domain.Size{Cols: 80, Rows: 23})
44		destination.tabs = append(destination.tabs, extraTab)
45		publishTiledPaneOwners(destination, extraTab)
46	}
47	d.mu.Lock()
48	d.sessions[destination.id] = destination
49	d.mu.Unlock()
50	return d, source, ac, destination, destinationTab, releases
51}
52
53func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
54	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
55	defer releaseAll(releases)
56
57	d.handleInput(source, ac, []byte("\x1b "))
58	require.True(t, ac.overlays.paletteActive())
59	d.handleInput(source, ac, []byte("MFP\r"))
60	require.False(t, ac.overlays.paletteActive())
61	require.True(t, ac.overlays.pickerActive())
62
63	ac.overlays.pickerMu.Lock()
64	intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource
65	ac.overlays.pickerMu.Unlock()
66	require.Equal(t, pickerMovePane, intent)
67	require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)
68	require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)
69	require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)
70	require.Same(t, ac, captured.Attachment)
71}
72
73func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
74	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
75	defer releaseAll(releases)
76	sourceTab := source.tabs[0]
77	sourceTab.stableID = "active-tab"
78
79	d.handleInput(source, ac, []byte("\x1b "))
80	d.handleInput(source, ac, []byte("MAT\r"))
81	require.True(t, ac.overlays.pickerActive())
82
83	ac.overlays.pickerMu.Lock()
84	intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource
85	ac.overlays.pickerMu.Unlock()
86	require.Equal(t, pickerMoveTab, intent)
87	require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)
88	require.Equal(t, domain.PaneStableID(""), captured.PaneID)
89	require.Same(t, ac, captured.Attachment)
90}
91
92func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {
93	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
94	defer releaseAll(releases)
95	d.mu.Lock()
96	delete(d.sessions, domain.SessionID("destination"))
97	d.mu.Unlock()
98
99	d.handleInput(source, ac, []byte("\x1b "))
100	d.handleInput(source, ac, []byte("MFP\r"))
101
102	require.False(t, ac.overlays.pickerActive())
103	history := d.notices.history()
104	require.NotEmpty(t, history)
105	require.Equal(t, "No destination available.", history[0].Message)
106}
107
108func TestMovePickerEnterCommitsMovePane(t *testing.T) {
109	d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
110	defer releaseAll(releases)
111	source.mu.Lock()
112	clearAttachmentsForTestLocked(source)
113	source.mu.Unlock()
114	sourceTab := source.tabs[0]
115	moved := sourceTab.focusedPane()
116
117	require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
118		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
119		TabID:   "source-tab", PaneID: "source-pane",
120	}))
121	d.handlePickerInput(ac, []byte("\r"))
122
123	require.False(t, ac.overlays.pickerActive())
124	require.Nil(t, source.tabs)
125	require.Same(t, moved, destinationTab.panes[moved.id])
126	require.Same(t, destination, moved.ownerSnapshot().session)
127}
128
129func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) {
130	d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
131	defer releaseAll(releases)
132	source.mu.Lock()
133	clearAttachmentsForTestLocked(source)
134	source.mu.Unlock()
135	sourceTab := source.tabs[0]
136	moved := sourceTab.focusedPane()
137
138	require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
139		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
140		TabID:   "source-tab", PaneID: "source-pane",
141	}))
142	ac.overlays.pickerMu.Lock()
143	target, ok := ac.overlays.picker.Selected()
144	ac.overlays.pickerMu.Unlock()
145	require.True(t, ok)
146	require.NoError(t, d.commitMovePickerSelection(pickerMovePane, moveSourceLocator{
147		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
148		TabID:   "source-tab", PaneID: "source-pane",
149	}, target))
150
151	require.Nil(t, source.tabs)
152	require.Same(t, moved, destinationTab.panes[moved.id])
153	require.Same(t, destination, moved.ownerSnapshot().session)
154}
155
156func TestMovePickerEnterCommitsMoveTab(t *testing.T) {
157	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
158	defer releaseAll(releases)
159	source.mu.Lock()
160	clearAttachmentsForTestLocked(source)
161	source.mu.Unlock()
162	movedTab := source.tabs[0]
163	movedTab.stableID = "moved-tab"
164
165	require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{
166		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
167		TabID:   "moved-tab",
168	}))
169	d.handlePickerInput(ac, []byte("\r"))
170
171	require.False(t, ac.overlays.pickerActive())
172	require.Nil(t, source.tabs)
173	require.Len(t, destination.tabs, 2)
174	require.Same(t, movedTab, destination.tabs[1])
175}
176
177func TestMovePickerEscapePerformsNoMutation(t *testing.T) {
178	clk := &signalClock{timers: make(chan *signalTimer, 16)}
179	d, source, ac, _, _, releases := setupMovePickerSessionsWithClock(t, clk, 0)
180	defer releaseAll(releases)
181	before := len(source.tabs)
182
183	require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
184		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
185		TabID:   "source-tab", PaneID: "source-pane",
186	}))
187	for len(clk.timers) > 0 {
188		<-clk.timers
189	}
190	d.handlePickerInput(ac, []byte("\x1b"))
191	timer := <-clk.timers
192	timer.ch <- time.Now()
193	require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
194	require.Len(t, source.tabs, before)
195}
196
197func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) {
198	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
199	defer releaseAll(releases)
200
201	require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
202		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
203		TabID:   "source-tab", PaneID: "source-pane",
204	}))
205	d.mu.Lock()
206	delete(d.sessions, destination.id)
207	d.mu.Unlock()
208
209	d.handlePickerInput(ac, []byte("\r"))
210
211	require.False(t, ac.overlays.pickerActive())
212	history := d.notices.history()
213	require.NotEmpty(t, history)
214	require.Equal(t, "Destination is no longer available.", history[0].Message)
215	require.Len(t, source.tabs, 1)
216}
217
218func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) {
219	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
220	defer releaseAll(releases)
221	sourceTab := source.tabs[0]
222
223	require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
224		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
225		TabID:   "source-tab", PaneID: "source-pane",
226	}))
227	sourceTab.mu.Lock()
228	delete(sourceTab.panes, sourceTab.tree.Focus)
229	sourceTab.mu.Unlock()
230
231	d.handlePickerInput(ac, []byte("\r"))
232
233	require.True(t, ac.overlays.pickerActive())
234	history := d.notices.history()
235	require.NotEmpty(t, history)
236	require.Equal(t, "Pane no longer exists.", history[0].Message)
237}
238
239func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
240	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
241	defer releaseAll(releases)
242
243	require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{
244		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
245		TabID:   "source-tab",
246	}))
247	source.mu.Lock()
248	source.tabs = nil
249	source.mu.Unlock()
250
251	d.handlePickerInput(ac, []byte("\r"))
252
253	require.True(t, ac.overlays.pickerActive())
254	history := d.notices.history()
255	require.NotEmpty(t, history)
256	require.Equal(t, "Tab no longer exists.", history[0].Message)
257}
258
259func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
260	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
261	defer releaseAll(releases)
262	require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
263		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
264		TabID:   "source-tab", PaneID: "source-pane",
265	}))
266	d.mu.Lock()
267	delete(d.sessions, destination.id)
268	d.mu.Unlock()
269
270	rebuildReached := make(chan struct{})
271	allowClose := make(chan struct{})
272	refreshed := make(chan struct{})
273	ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
274		close(rebuildReached)
275		<-allowClose
276	}
277	go func() {
278		d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
279		close(refreshed)
280	}()
281	select {
282	case <-rebuildReached:
283	case <-time.After(time.Second):
284		t.Fatal("timed out waiting for move-picker refresh rebuild")
285	}
286
287	replacement := d.newPickerModel(source, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
288	d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{})
289	close(allowClose)
290	select {
291	case <-refreshed:
292	case <-time.After(time.Second):
293		t.Fatal("timed out waiting for move-picker refresh")
294	}
295	ac.overlays.pickerMu.Lock()
296	require.Same(t, replacement, ac.overlays.picker)
297	ac.overlays.pickerMu.Unlock()
298}
299
300func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {
301	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
302	defer releaseAll(releases)
303
304	d.handleInput(source, ac, []byte("\x1b "))
305	d.handleInput(source, ac, []byte("MFP\r"))
306	require.True(t, ac.overlays.pickerActive())
307	require.True(t, source.dispatchMu.TryLock(), "dispatchMu must not remain held while the move picker is open")
308	source.dispatchMu.Unlock()
309}

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