copymode_lifecycle_test.go RawHistoryFolder

1package daemon
2
3import (
4	"fmt"
5	"testing"
6	"time"
7
8	"github.com/stretchr/testify/require"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/domain/layout"
12	portsmocks "github.com/bnema/vev/internal/ports/mocks"
13	scopy "github.com/bnema/vev/internal/usecase/copy"
14)
15
16func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
17	pty, release := newBlockingPTY(t)
18	defer release()
19	d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
20	d.enterCopyMode(sess, ac)
21	require.True(t, ac.overlays.copyActive())
22	ac.overlays.copyMu.Lock()
23	seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
24	ac.overlays.copyMu.Unlock()
25
26	d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
27
28	require.False(t, ac.overlays.copyActive())
29	ac.overlays.copyMu.Lock()
30	require.False(t, ac.overlays.copyPointer.valid)
31	require.False(t, ac.overlays.copyClick.valid)
32	ac.overlays.copyMu.Unlock()
33}
34
35func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
36	d, sess, ac, _, releases := newManualTabSession(t, 2)
37	defer func() {
38		for _, release := range releases {
39			release()
40		}
41	}()
42	d.enterCopyMode(sess, ac)
43	require.True(t, ac.overlays.copyActive())
44	ac.overlays.copyMu.Lock()
45	seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument)
46	ac.overlays.copyMu.Unlock()
47
48	require.True(t, selectTestAttachmentTab(sess, 1))
49	d.activateTab(sess, testAttachmentTab(sess))
50
51	require.False(t, ac.overlays.copyActive())
52	ac.overlays.copyMu.Lock()
53	require.False(t, ac.overlays.copyPointer.valid)
54	require.False(t, ac.overlays.copyClick.valid)
55	ac.overlays.copyMu.Unlock()
56}
57
58func TestActivateTabAfterResizePathAndCopyModeLifecycle(t *testing.T) {
59	for _, tt := range []struct {
60		name        string
61		leaseBacked bool
62	}{
63		{name: "direct", leaseBacked: false},
64		{name: "lease-backed", leaseBacked: true},
65	} {
66		t.Run(tt.name, func(t *testing.T) {
67			d, sess, ac, _, releases := newManualTabSession(t, 2)
68			defer releaseAll(releases)
69			rc := newRenderCoordinator(renderCoordinatorOptions{clock: d.clock})
70			rc.attach(ac)
71			sess.installRenderCoordinator(rc)
72			defer func() { rc.beginSessionTeardown().finish() }()
73			path := make(chan bool, 1)
74			rc.opts.onActivateTabAfterResize = func(leaseBacked bool) { path <- leaseBacked }
75			var lease *attachmentLease
76			if tt.leaseBacked {
77				lease = rc.attachmentLease(ac)
78				require.NotNil(t, lease)
79			}
80
81			d.enterCopyMode(sess, ac)
82			require.True(t, ac.overlays.copyActive())
83			d.activateTabAfterResizeForLease(sess, testAttachmentTab(sess), true, ac, lease)
84
85			require.False(t, ac.overlays.copyActive())
86			require.Equal(t, tt.leaseBacked, <-path)
87		})
88	}
89}
90
91func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
92	d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
93	tb := testAttachmentTab(sess)
94	closingPTY := portsmocks.NewMockPTY(t)
95	closingPTY.EXPECT().Close().Return(nil).Once()
96	tb.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-2"}
97	closing := newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
98	tb.panes["pane-2"] = closing
99	ac := &attachedClient{output: newOutputStateStream()}
100	ac.initOverlays()
101	sess.registerAttachment(ac)
102	ac.setSession(sess)
103	closing.mu.Lock()
104	document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
105	closing.mu.Unlock()
106	ac.overlays.copyMu.Lock()
107	ac.overlays.copyPane = closing
108	ac.overlays.copyDocument = document
109	ac.overlays.copyMode = scopy.NewMode(document)
110	seedCopyInteractionLocked(ac.overlays, closing, document)
111	ac.overlays.copyMu.Unlock()
112
113	require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
114	require.False(t, ac.overlays.copyActive())
115	require.Nil(t, copyTargetPane(ac.overlays))
116	ac.overlays.copyMu.Lock()
117	require.False(t, ac.overlays.copyPointer.valid)
118	require.False(t, ac.overlays.copyClick.valid)
119	ac.overlays.copyMu.Unlock()
120}
121
122func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) {
123	d, sess, ac, _, releases := newManualTabSession(t, 2)
124	defer func() {
125		for _, release := range releases {
126			release()
127		}
128	}()
129	tb := testAttachmentTab(sess)
130	p := tb.focusedPane()
131	d.enterCopyMode(sess, ac)
132	require.True(t, ac.overlays.copyActive())
133
134	require.NoError(t, d.closePane(sess, tb, p.id, nil, false))
135	require.False(t, ac.overlays.copyActive())
136	require.Nil(t, copyTargetPane(ac.overlays))
137}
138
139func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
140	d, sess, ac, _, releases := newManualTabSession(t, 2)
141	defer func() {
142		for _, release := range releases {
143			release()
144		}
145	}()
146	tb := testAttachmentTab(sess)
147	p := tb.focusedPane()
148	p.mu.Lock()
149	document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
150	p.mu.Unlock()
151
152	// Pause after candidate publication, then make the captured target stale.
153	// A render snapshot while paused must not expose the candidate document.
154	reached := make(chan struct{})
155	resume := make(chan struct{})
156	done := make(chan struct{})
157	d.beforeCopyModeRevalidate = func() {
158		close(reached)
159		<-resume
160	}
161	resumed := false
162	defer func() {
163		if !resumed {
164			close(resume)
165		}
166		select {
167		case <-done:
168			d.beforeCopyModeRevalidate = nil
169		case <-time.After(time.Second):
170			t.Error("copy mode publication did not finish after revalidation gate was released")
171		}
172	}()
173
174	result := make(chan bool, 1)
175	go func() {
176		defer close(done)
177		result <- d.publishCopyMode(sess, ac, tb, p, document, nil, nil)
178	}()
179	select {
180	case <-reached:
181	case <-time.After(time.Second):
182		t.Fatal("copy mode publication did not reach revalidation gate")
183	}
184
185	renderSnapshot := ac.overlays.SnapshotForRender()
186	candidateWasRenderable := renderSnapshot.copyActive
187	renderSnapshot.Unlock()
188	require.True(t, selectTestAttachmentTab(sess, 1))
189	close(resume)
190	resumed = true
191
192	select {
193	case published := <-result:
194		require.False(t, published)
195	case <-time.After(time.Second):
196		t.Fatal("copy mode publication did not finish after revalidation gate was released")
197	}
198	require.False(t, candidateWasRenderable)
199	require.False(t, ac.overlays.copyActive())
200	require.Nil(t, copyTargetPane(ac.overlays))
201}
202
203func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.T) {
204	for _, tc := range []struct {
205		name       string
206		invalidate func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane)
207	}{
208		{
209			name: "release during publication",
210			invalidate: func(_ *testing.T, d *Daemon, sess *session, ac *attachedClient, _ *tab, _ *pane) {
211				d.handleInput(sess, ac, []byte("\x1b[<0;1;2m"))
212			},
213		},
214		{
215			name: "pane close during publication",
216			invalidate: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) {
217				require.NoError(t, d.closePane(sess, tb, p.id, ac, false))
218			},
219		},
220	} {
221		t.Run(tc.name, func(t *testing.T) {
222			d, sess, ac, _, releases := newManualTabSession(t, 2)
223			defer func() {
224				for _, release := range releases {
225					release()
226				}
227			}()
228			tb := testAttachmentTab(sess)
229			p := tb.focusedPane()
230
231			// A real press is required: the motion publication must inherit this
232			// pointer epoch, rather than publishing a fresh interaction.
233			d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
234			ac.overlays.copyMu.Lock()
235			require.True(t, ac.overlays.copyPointer.valid)
236			ac.overlays.copyMu.Unlock()
237
238			reached := make(chan struct{})
239			resume := make(chan struct{})
240			d.beforeCopyModeRevalidate = func() {
241				close(reached)
242				<-resume
243			}
244			resumed := false
245			defer func() {
246				if !resumed {
247					close(resume)
248				}
249				d.beforeCopyModeRevalidate = nil
250			}()
251			done := make(chan struct{})
252			go func() {
253				d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
254				close(done)
255			}()
256			select {
257			case <-reached:
258			case <-time.After(time.Second):
259				t.Fatal("copy mode publication did not reach revalidation gate")
260			}
261
262			tc.invalidate(t, d, sess, ac, tb, p)
263			close(resume)
264			resumed = true
265			select {
266			case <-done:
267			case <-time.After(time.Second):
268				t.Fatal("copy mode publication did not finish after revalidation gate was released")
269			}
270
271			ac.overlays.copyMu.Lock()
272			require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode")
273			require.Nil(t, ac.overlays.copyCandidate)
274			require.False(t, ac.overlays.copyPointer.valid)
275			require.False(t, ac.overlays.copyClick.valid)
276			ac.overlays.copyMu.Unlock()
277		})
278	}
279}
280
281func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *testing.T) {
282	d, sess, ac, _, releases := newManualTabSession(t, 2)
283	defer func() {
284		for _, release := range releases {
285			release()
286		}
287	}()
288	tb := testAttachmentTab(sess)
289	floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
290	installTestFloating(tb, floating, true)
291	tb.mu.Lock()
292	_, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
293	tb.mu.Unlock()
294	require.True(t, visible)
295	press := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)
296	motion := fmt.Appendf(nil, "\x1b[<32;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+3)
297
298	d.handleInput(sess, ac, press)
299	ac.overlays.copyMu.Lock()
300	require.True(t, ac.overlays.copyPointer.valid)
301	ac.overlays.copyMu.Unlock()
302
303	reached := make(chan struct{})
304	resume := make(chan struct{})
305	d.beforeCopyModeRevalidate = func() {
306		close(reached)
307		<-resume
308	}
309	resumed := false
310	defer func() {
311		if !resumed {
312			close(resume)
313		}
314		d.beforeCopyModeRevalidate = nil
315	}()
316	done := make(chan struct{})
317	go func() {
318		d.handleInput(sess, ac, motion)
319		close(done)
320	}()
321	select {
322	case <-reached:
323	case <-time.After(time.Second):
324		t.Fatal("copy mode publication did not reach revalidation gate")
325	}
326	d.teardownFloating(tb, ac)
327	close(resume)
328	resumed = true
329	select {
330	case <-done:
331	case <-time.After(time.Second):
332		t.Fatal("copy mode publication did not finish after revalidation gate was released")
333	}
334
335	ac.overlays.copyMu.Lock()
336	require.Nil(t, ac.overlays.copyMode)
337	require.Nil(t, ac.overlays.copyCandidate)
338	require.False(t, ac.overlays.copyPointer.valid)
339	require.False(t, ac.overlays.copyClick.valid)
340	ac.overlays.copyMu.Unlock()
341}
342
343func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
344	for _, tc := range []struct {
345		name       string
346		invalidate func(*session, *tab, *pane)
347	}{
348		{
349			name: "removed pane",
350			invalidate: func(_ *session, tb *tab, p *pane) {
351				tb.mu.Lock()
352				delete(tb.panes, p.id)
353				tb.mu.Unlock()
354			},
355		},
356		{
357			name: "inactive tab",
358			invalidate: func(sess *session, _ *tab, _ *pane) {
359				require.True(t, selectTestAttachmentTab(sess, 1))
360			},
361		},
362	} {
363		t.Run(tc.name, func(t *testing.T) {
364			d, sess, ac, _, releases := newManualTabSession(t, 2)
365			defer func() {
366				for _, release := range releases {
367					release()
368				}
369			}()
370			tb := testAttachmentTab(sess)
371			p := tb.focusedPane()
372			p.mu.Lock()
373			document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
374			p.mu.Unlock()
375			tc.invalidate(sess, tb, p)
376
377			require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil, nil))
378			require.False(t, ac.overlays.copyActive())
379			require.Nil(t, copyTargetPane(ac.overlays))
380		})
381	}
382}

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