move_acceptance_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/layout"
11)
12
13func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
14	names := make([]string, 0, len(updates))
15	for _, update := range updates {
16		names = append(names, update.Name)
17	}
18	return names
19}
20
21type moveCatalogueRecorder struct {
22	daemon        *Daemon
23	source        *session
24	destination   *session
25	mu            sync.Mutex
26	updates       []domain.CatalogueMetadataUpdate
27	lockViolation bool
28}
29
30func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
31func (c *moveCatalogueRecorder) Record(string) (domain.CatalogueRecord, bool, error) {
32	return domain.CatalogueRecord{}, false, nil
33}
34func (c *moveCatalogueRecorder) Create(domain.CatalogueRecord) error          { return nil }
35func (c *moveCatalogueRecorder) Replace(string, domain.CatalogueRecord) error { return nil }
36func (c *moveCatalogueRecorder) Rename(string, domain.CatalogueRecord) error  { return nil }
37func (c *moveCatalogueRecorder) Delete(string) error                          { return nil }
38func (c *moveCatalogueRecorder) Sync() error                                  { return nil }
39func (c *moveCatalogueRecorder) Close() error                                 { return nil }
40func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
41	violated := false
42	if c.daemon.mu.TryLock() {
43		c.daemon.mu.Unlock()
44	} else {
45		violated = true
46	}
47	if c.source.mu.TryLock() {
48		c.source.mu.Unlock()
49	} else {
50		violated = true
51	}
52	if c.destination.mu.TryLock() {
53		c.destination.mu.Unlock()
54	} else {
55		violated = true
56	}
57	c.mu.Lock()
58	c.updates = append(c.updates, update)
59	c.lockViolation = c.lockViolation || violated
60	c.mu.Unlock()
61	return nil
62}
63
64func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T) {
65	movedPTY, releaseMoved := newBlockingPTY(t)
66	remainingPTY, releaseRemaining := newBlockingPTY(t)
67	destinationPTY, releaseDestination := newBlockingPTY(t)
68	activePTY, releaseActive := newBlockingPTY(t)
69	defer releaseMoved()
70	defer releaseRemaining()
71	defer releaseDestination()
72	defer releaseActive()
73
74	d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
75	sourceTab := source.tabs[0]
76	sourceTab.stableID = "source-tab"
77	moved := sourceTab.focusedPane()
78	remaining := newPaneWithStableID("pane-2", "remaining-pane", remainingPTY, domain.Size{Cols: 80, Rows: 23})
79	sourceTab.mu.Lock()
80	sourceTab.tree = &layout.Tree{
81		Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
82			layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
83		}},
84		Focus: "pane-1",
85	}
86	sourceTab.panes[remaining.id] = remaining
87	sourceTab.mu.Unlock()
88	publishPaneOwner(remaining, source, sourceTab, 0)
89
90	destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
91	destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})
92	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}}
93	publishTiledPaneOwners(destination, destinationActive)
94	publishTiledPaneOwners(destination, destinationTab)
95	d.mu.Lock()
96	d.sessions[destination.id] = destination
97	d.mu.Unlock()
98	source.ephemeral = false
99	destination.ephemeral = false
100	source.snapEligible.Store(true)
101	destination.snapEligible.Store(true)
102	catalogue := &moveCatalogueRecorder{daemon: d, source: source, destination: destination}
103	d.catalogue = catalogue
104	d.persistEnabled = true
105	source.snapshotMu.Lock()
106	beforeSourceSnapshot := source.snapshotGeneration
107	source.snapshotMu.Unlock()
108	destination.snapshotMu.Lock()
109	beforeDestinationSnapshot := destination.snapshotGeneration
110	destination.snapshotMu.Unlock()
111
112	sourceTab.mu.Lock()
113	beforeSourceGeneration := sourceTab.layoutGeneration
114	sourceTab.mu.Unlock()
115	destinationTab.mu.Lock()
116	beforeDestinationGeneration := destinationTab.layoutGeneration
117	destinationTab.mu.Unlock()
118	moved.mu.Lock()
119	beforeOwnerGeneration := moved.ownerGeneration
120	moved.mu.Unlock()
121
122	require.NoError(t, d.movePane(movePaneRequest{
123		Source:           moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
124		SourceTabID:      domain.TabStableID(sourceTab.stableID),
125		SourcePaneID:     domain.PaneStableID(moved.stableID),
126		Destination:      moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
127		DestinationTabID: domain.TabStableID(destinationTab.stableID),
128	}))
129
130	source.mu.Lock()
131	sourceTabs := append([]*tab(nil), source.tabs...)
132	source.mu.Unlock()
133	require.Len(t, sourceTabs, 1)
134	require.Same(t, sourceTab, sourceTabs[0])
135	sourceTab.mu.Lock()
136	remainingPane := sourceTab.panes[remaining.id]
137	sourceFocus := sourceTab.tree.Focus
138	sourceGeneration := sourceTab.layoutGeneration
139	sourceTab.mu.Unlock()
140	require.Same(t, remaining, remainingPane)
141	require.Equal(t, layout.PaneID("pane-2"), sourceFocus, "source focus must use the valid fallback leaf")
142	destination.mu.Lock()
143	destinationActiveTab := destination.tabs[testAttachmentTabIndexLocked(destination)]
144	destination.mu.Unlock()
145	require.Same(t, destinationActive, destinationActiveTab, "destination active tab must remain unchanged")
146	destinationTab.mu.Lock()
147	destinationMovedPane := destinationTab.panes[moved.id]
148	destinationFocus := destinationTab.tree.Focus
149	destinationGeneration := destinationTab.layoutGeneration
150	destinationTab.mu.Unlock()
151	require.Same(t, moved, destinationMovedPane)
152	require.Equal(t, destinationFocus, moved.id)
153	moved.mu.Lock()
154	ownerGeneration := moved.ownerGeneration
155	moved.mu.Unlock()
156	require.Equal(t, beforeOwnerGeneration+1, ownerGeneration, "move publishes exactly one new owner generation")
157	require.Equal(t, beforeSourceGeneration+1, sourceGeneration)
158	require.Equal(t, beforeDestinationGeneration+1, destinationGeneration)
159	source.snapshotMu.Lock()
160	require.GreaterOrEqual(t, source.snapshotGeneration, beforeSourceSnapshot+1)
161	require.True(t, source.snapDirty.Load())
162	source.snapshotMu.Unlock()
163	destination.snapshotMu.Lock()
164	require.GreaterOrEqual(t, destination.snapshotGeneration, beforeDestinationSnapshot+1)
165	require.True(t, destination.snapDirty.Load())
166	destination.snapshotMu.Unlock()
167	catalogue.mu.Lock()
168	updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...)
169	lockViolation := catalogue.lockViolation
170	catalogue.mu.Unlock()
171	require.False(t, lockViolation, "catalogue publication ran under an architecture lock")
172	require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates))
173}
174
175func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
176	movedPTY, releaseMoved := newBlockingPTY(t)
177	destinationPTY, releaseDestination := newBlockingPTY(t)
178	defer releaseMoved()
179	defer releaseDestination()
180
181	d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
182	sourceTab := source.tabs[0]
183	sourceTab.stableID = "source-tab"
184	moved := sourceTab.focusedPane()
185	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
186	destinationTab := destination.tabs[0]
187	publishTiledPaneOwners(destination, destinationTab)
188	d.mu.Lock()
189	d.sessions[destination.id] = destination
190	d.mu.Unlock()
191
192	sourceTab.mu.Lock()
193	beforeSource := sourceTab.tree.Clone()
194	sourceTab.mu.Unlock()
195	destinationTab.mu.Lock()
196	beforeDestination := destinationTab.tree.Clone()
197	destinationTab.mu.Unlock()
198	beforeOwner := moved.ownerSnapshot()
199	staleIncarnation := source.incarnation
200	staleIncarnation[0]++
201	err := d.movePane(movePaneRequest{
202		Source:           moveSessionLocator{ID: source.id, Incarnation: staleIncarnation},
203		SourceTabID:      domain.TabStableID(sourceTab.stableID),
204		SourcePaneID:     domain.PaneStableID(moved.stableID),
205		Destination:      moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
206		DestinationTabID: domain.TabStableID(destinationTab.stableID),
207	})
208	require.ErrorIs(t, err, errMovePaneInvalid)
209	sourceTab.mu.Lock()
210	afterSource := sourceTab.tree.Clone()
211	sourceMovedPane := sourceTab.panes[moved.id]
212	sourceTab.mu.Unlock()
213	destinationTab.mu.Lock()
214	afterDestination := destinationTab.tree.Clone()
215	destinationMovedPane := destinationTab.panes[moved.id]
216	destinationTab.mu.Unlock()
217	require.Equal(t, beforeSource, afterSource)
218	require.Equal(t, beforeDestination, afterDestination)
219	require.Same(t, beforeOwner, moved.ownerSnapshot())
220	require.Same(t, moved, sourceMovedPane)
221	require.NotSame(t, moved, destinationMovedPane)
222}
223
224func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
225	movedPTY, releaseMoved := newBlockingPTY(t)
226	destinationPTY, releaseDestination := newBlockingPTY(t)
227	defer releaseMoved()
228	defer releaseDestination()
229
230	d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
231	sourceTab := source.tabs[0]
232	sourceTab.stableID = "source-tab"
233	moved := sourceTab.focusedPane()
234	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
235	destinationTab := destination.tabs[0]
236	publishTiledPaneOwners(destination, destinationTab)
237	d.mu.Lock()
238	d.sessions[destination.id] = destination
239	d.mu.Unlock()
240
241	destinationTab.mu.Lock()
242	beforeDestination := destinationTab.tree.Clone()
243	destinationTab.mu.Unlock()
244	beforeOwner := moved.ownerSnapshot()
245	d.afterMovePaneSourceSnapshot = func() {
246		sourceTab.mu.Lock()
247		sourceTab.layoutGeneration++
248		sourceTab.mu.Unlock()
249	}
250	defer func() { d.afterMovePaneSourceSnapshot = nil }()
251
252	err := d.movePane(movePaneRequest{
253		Source:           moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
254		SourceTabID:      domain.TabStableID(sourceTab.stableID),
255		SourcePaneID:     domain.PaneStableID(moved.stableID),
256		Destination:      moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
257		DestinationTabID: domain.TabStableID(destinationTab.stableID),
258	})
259	require.Error(t, err)
260	destinationTab.mu.Lock()
261	afterDestination := destinationTab.tree.Clone()
262	destinationTab.mu.Unlock()
263	sourceTab.mu.Lock()
264	sourceMovedPane := sourceTab.panes[moved.id]
265	sourceTab.mu.Unlock()
266	require.Equal(t, beforeDestination, afterDestination)
267	require.Same(t, beforeOwner, moved.ownerSnapshot())
268	require.Same(t, moved, sourceMovedPane)
269}

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