move_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"os"
6	"testing"
7
8	"github.com/stretchr/testify/require"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/domain/layout"
12)
13
14func TestMovePaneMutationSameSession(t *testing.T) {
15	p1, release1 := newBlockingPTY(t)
16	p2, release2 := newBlockingPTY(t)
17	defer release1()
18	defer release2()
19	d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
20	sourceTab, destinationTab := sess.tabs[0], sess.tabs[1]
21	sourceTab.stableID = "source-tab"
22	destinationTab.stableID = "destination-tab"
23	moved := sourceTab.focusedPane()
24	movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
25	moved.ctx, moved.cancel = movedCtx, movedCancel
26
27	err := d.movePane(movePaneRequest{
28		Source:           moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation},
29		SourceTabID:      domain.TabStableID(sourceTab.stableID),
30		SourcePaneID:     domain.PaneStableID(moved.stableID),
31		Destination:      moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation},
32		DestinationTabID: domain.TabStableID(destinationTab.stableID),
33	})
34	require.NoError(t, err)
35	require.Same(t, moved, destinationTab.panes[moved.id])
36	require.Equal(t, layout.PaneID("pane-2"), moved.id)
37	require.Equal(t, layout.PaneID("pane-2"), destinationTab.tree.Focus)
38	require.NotContains(t, sourceTab.panes, layout.PaneID("pane-1"))
39	require.Len(t, sess.tabs, 1)
40	require.Same(t, destinationTab, sess.tabs[0])
41	require.NoError(t, moved.ctx.Err(), "a same-session move must not retire the moved pane process")
42}
43
44func TestMovePaneMutationCrossSession(t *testing.T) {
45	p1, release1 := newBlockingPTY(t)
46	p2, release2 := newBlockingPTY(t)
47	defer release1()
48	defer release2()
49	d, source, _, _ := newManualSessionWithPTYs(t, p1)
50	source.mu.Lock()
51	clearAttachmentsForTestLocked(source)
52	source.mu.Unlock()
53	sourceTab := source.tabs[0]
54	sourceTab.stableID = "source-tab"
55	moved := sourceTab.focusedPane()
56	movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
57	moved.ctx, moved.cancel = movedCtx, movedCancel
58
59	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
60	destinationTab := destination.tabs[0]
61	destinationTab.stableID = "destination-tab"
62	publishTiledPaneOwners(destination, destinationTab)
63	d.mu.Lock()
64	d.sessions[destination.id] = destination
65	d.mu.Unlock()
66
67	err := d.movePane(movePaneRequest{
68		Source:           moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
69		SourceTabID:      domain.TabStableID(sourceTab.stableID),
70		SourcePaneID:     domain.PaneStableID(moved.stableID),
71		Destination:      moveSessionLocator{ID: destination.id},
72		DestinationTabID: domain.TabStableID(destinationTab.stableID),
73	})
74	require.NoError(t, err)
75	require.Nil(t, source.tabs)
76	require.Same(t, moved, destinationTab.panes[moved.id])
77	require.Equal(t, destinationTab, destination.tabs[0])
78	require.Same(t, destination, moved.ownerSnapshot().session)
79	require.Same(t, destinationTab, moved.ownerSnapshot().tab)
80	require.NoError(t, moved.ctx.Err(), "retiring the source must not cancel the transferred pane process")
81}
82
83func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing.T) {
84	movedPTY := newQuietPTY()
85	d, source, active, _ := newManualSessionWithPTYs(t, movedPTY)
86	clip, err := os.CreateTemp(t.TempDir(), "move-clip-")
87	require.NoError(t, err)
88	require.NoError(t, clip.Close())
89	source.mu.Lock()
90	clearAttachmentsForTestLocked(source)
91	source.clipFiles = []string{clip.Name()}
92	source.mu.Unlock()
93	active.setSession(nil)
94	moved := source.tabs[0].focusedPane()
95	movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
96	moved.ctx, moved.cancel = movedCtx, movedCancel
97	movedTab := source.tabs[0]
98	movedTab.stableID = "source-tab"
99
100	destinationPTY := newQuietPTY()
101	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
102	publishTiledPaneOwners(destination, destination.tabs[0])
103	d.mu.Lock()
104	d.sessions[destination.id] = destination
105	d.mu.Unlock()
106
107	err = d.movePane(movePaneRequest{
108		Source:           moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
109		SourceTabID:      domain.TabStableID(movedTab.stableID),
110		SourcePaneID:     domain.PaneStableID(moved.stableID),
111		Destination:      moveSessionLocator{ID: destination.id},
112		DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
113	})
114	require.NoError(t, err)
115	select {
116	case <-movedPTY.done:
117		t.Fatal("retiring the source closed the transferred PTY")
118	default:
119	}
120	select {
121	case <-moved.ctx.Done():
122		t.Fatal("retiring the source cancelled the transferred pane process")
123	default:
124	}
125	select {
126	case <-source.ctx.Done():
127	default:
128		t.Fatal("empty source lifecycle was not cancelled")
129	}
130	_, err = os.Stat(clip.Name())
131	require.ErrorIs(t, err, os.ErrNotExist, "retired source clipboard resources must be removed")
132}
133
134func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
135	movedPTY := newQuietPTY()
136	d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
137	source.mu.Lock()
138	clearAttachmentsForTestLocked(source)
139	source.mu.Unlock()
140	moved := source.tabs[0].focusedPane()
141	movedTab := source.tabs[0]
142	movedTab.stableID = "source-tab"
143
144	transport := &closeTrackingTransport{}
145	parked := &attachedClient{tr: transport, output: newOutputStateStream(), geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, resumeCapable: true}
146	parked.initOverlays()
147	require.True(t, d.parkAttachment(source, parked))
148	destinationPTY := newQuietPTY()
149	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
150	publishTiledPaneOwners(destination, destination.tabs[0])
151	d.mu.Lock()
152	d.sessions[destination.id] = destination
153	d.mu.Unlock()
154
155	require.NoError(t, d.movePane(movePaneRequest{
156		Source:           moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
157		SourceTabID:      domain.TabStableID(movedTab.stableID),
158		SourcePaneID:     domain.PaneStableID(moved.stableID),
159		Destination:      moveSessionLocator{ID: destination.id},
160		DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
161	}))
162	d.mu.Lock()
163	require.Empty(t, d.resume.parked, "retired source parked clients must leave the daemon registry")
164	d.mu.Unlock()
165	require.True(t, transport.Closed(), "retired source parked transport was not closed")
166}
167
168func TestPrepareMovePaneCandidateIsPure(t *testing.T) {
169	t.Parallel()
170
171	newPaneRef := func(id layout.PaneID, stableID string) *pane {
172		return &pane{id: id, stableID: stableID}
173	}
174	horizontal := func(ids ...layout.PaneID) *layout.Node {
175		children := make([]*layout.Node, 0, len(ids))
176		for _, id := range ids {
177			children = append(children, layout.NewLeaf(id))
178		}
179		return &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: children}
180	}
181	stack := func(ids ...layout.PaneID) *layout.Node {
182		children := make([]*layout.Node, 0, len(ids))
183		for _, id := range ids {
184			children = append(children, layout.NewLeaf(id))
185		}
186		return &layout.Node{Kind: layout.Stack, Children: children, Expanded: ids[0]}
187	}
188
189	tests := []struct {
190		name                string
191		sourceTree          *layout.Tree
192		sourceFloating      floatingState
193		destinationTree     *layout.Tree
194		destinationSize     domain.Size
195		destinationNext     int
196		wantErr             error
197		wantRemoveTab       bool
198		wantSourceFocus     layout.PaneID
199		wantDestination     layout.PaneID
200		wantDestinationNext int
201		wantDestinationIDs  []layout.PaneID
202	}{
203		{
204			name:                "split source closes on clone and destination inserts right of focus",
205			sourceTree:          &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
206			destinationTree:     &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-1"},
207			destinationSize:     domain.Size{Cols: 100, Rows: 4},
208			destinationNext:     2,
209			wantSourceFocus:     "pane-1",
210			wantDestination:     "pane-3",
211			wantDestinationNext: 4,
212			wantDestinationIDs:  []layout.PaneID{"pane-1", "pane-3", "pane-2"},
213		},
214		{
215			name:                "stack source remains valid after close",
216			sourceTree:          &layout.Tree{Root: stack("pane-2", "pane-1"), Focus: "pane-2"},
217			destinationTree:     layout.NewTree("pane-1"),
218			destinationSize:     domain.Size{Cols: 41, Rows: 4},
219			destinationNext:     2,
220			wantSourceFocus:     "pane-1",
221			wantDestination:     "pane-3",
222			wantDestinationNext: 4,
223			wantDestinationIDs:  []layout.PaneID{"pane-1", "pane-3"},
224		},
225		{
226			name:                "final tiled pane requests source tab removal",
227			sourceTree:          layout.NewTree("pane-2"),
228			destinationTree:     layout.NewTree("pane-1"),
229			destinationSize:     domain.Size{Cols: 41, Rows: 2},
230			destinationNext:     2,
231			wantRemoveTab:       true,
232			wantDestination:     "pane-3",
233			wantDestinationNext: 4,
234			wantDestinationIDs:  []layout.PaneID{"pane-1", "pane-3"},
235		},
236		{
237			name:            "final tiled pane rejects warming floating sibling",
238			sourceTree:      layout.NewTree("pane-2"),
239			sourceFloating:  floatingWarming,
240			destinationTree: layout.NewTree("pane-1"),
241			destinationSize: domain.Size{Cols: 41, Rows: 2},
242			destinationNext: 2,
243			wantErr:         errMoveFloatingWarming,
244		},
245		{
246			name:            "final tiled pane rejects hidden floating sibling",
247			sourceTree:      layout.NewTree("pane-2"),
248			sourceFloating:  floatingHidden,
249			destinationTree: layout.NewTree("pane-1"),
250			destinationSize: domain.Size{Cols: 41, Rows: 2},
251			destinationNext: 2,
252			wantErr:         errMovePaneFloatingSibling,
253		},
254		{
255			name:            "final tiled pane rejects visible floating sibling",
256			sourceTree:      layout.NewTree("pane-2"),
257			sourceFloating:  floatingVisible,
258			destinationTree: layout.NewTree("pane-1"),
259			destinationSize: domain.Size{Cols: 41, Rows: 2},
260			destinationNext: 2,
261			wantErr:         errMovePaneFloatingSibling,
262		},
263		{
264			name:            "destination too small rejects candidate",
265			sourceTree:      &layout.Tree{Root: horizontal("pane-1", "pane-2"), Focus: "pane-2"},
266			destinationTree: layout.NewTree("pane-1"),
267			destinationSize: domain.Size{Cols: 40, Rows: 2},
268			destinationNext: 2,
269			wantErr:         layout.ErrTooSmall,
270		},
271	}
272
273	for _, tt := range tests {
274		t.Run(tt.name, func(t *testing.T) {
275			moved := newPaneRef("pane-2", "stable-moved")
276			sourcePanes := map[layout.PaneID]*pane{"pane-2": moved}
277			for _, id := range layout.LeafIDs(tt.sourceTree.Root) {
278				if id != moved.id {
279					sourcePanes[id] = newPaneRef(id, "source-"+string(id))
280				}
281			}
282			destinationPanes := make(map[layout.PaneID]*pane)
283			for _, id := range layout.LeafIDs(tt.destinationTree.Root) {
284				destinationPanes[id] = newPaneRef(id, "destination-"+string(id))
285			}
286			source := &tab{
287				tree:       tt.sourceTree,
288				panes:      sourcePanes,
289				nextPaneID: 17,
290				size:       domain.Size{Cols: 80, Rows: 4},
291				floating:   floatingSlot{state: tt.sourceFloating, generation: 9},
292			}
293			destination := &tab{
294				tree:       tt.destinationTree,
295				panes:      destinationPanes,
296				nextPaneID: tt.destinationNext,
297				size:       tt.destinationSize,
298			}
299			sourceBefore := source.tree.Clone()
300			destinationBefore := destination.tree.Clone()
301			sourceNextBefore, destinationNextBefore := source.nextPaneID, destination.nextPaneID
302			movedIDBefore, movedStableIDBefore := moved.id, moved.stableID
303
304			candidate, err := prepareMovePaneCandidate(source, destination, moved)
305
306			require.ErrorIs(t, err, tt.wantErr)
307			require.Equal(t, sourceBefore, source.tree, "preparation must not mutate the source tree")
308			require.Equal(t, destinationBefore, destination.tree, "preparation must not mutate the destination tree")
309			require.Equal(t, sourceNextBefore, source.nextPaneID)
310			require.Equal(t, destinationNextBefore, destination.nextPaneID, "preparation must not advance the destination counter")
311			require.Equal(t, movedIDBefore, moved.id, "preparation must not publish the new local ID")
312			require.Equal(t, movedStableIDBefore, moved.stableID)
313			require.Same(t, moved, source.panes[movedIDBefore])
314			for id, p := range destinationPanes {
315				require.Same(t, p, destination.panes[id])
316			}
317			if tt.wantErr != nil {
318				require.Nil(t, candidate)
319				return
320			}
321
322			require.Same(t, moved, candidate.pane)
323			require.Equal(t, movedIDBefore, candidate.sourceID)
324			require.Equal(t, tt.wantDestination, candidate.destinationID)
325			require.NotEqual(t, candidate.sourceID, candidate.destinationID)
326			require.Equal(t, tt.wantRemoveTab, candidate.removeSourceTab)
327			require.Equal(t, tt.wantDestinationNext, candidate.destinationNextPaneID, "candidate counter should follow the allocated ID")
328			require.Equal(t, tt.wantDestinationIDs, layout.LeafIDs(candidate.destinationTree.Root))
329			require.Equal(t, tt.wantDestination, candidate.destinationTree.Focus)
330			require.NotEmpty(t, candidate.destinationPlacements)
331			if tt.wantRemoveTab {
332				require.Nil(t, candidate.sourceTree)
333				require.Nil(t, candidate.sourcePlacements)
334			} else {
335				require.Equal(t, tt.wantSourceFocus, candidate.sourceTree.Focus)
336				require.True(t, layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus))
337				require.NotEmpty(t, candidate.sourcePlacements)
338			}
339			require.Equal(t, "stable-moved", candidate.pane.stableID)
340		})
341	}
342}

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