v0.2.0 80c61e3

render_pipeline_test.go RawHistoryFolder

1package daemon
2
3import (
4	"go/ast"
5	"go/parser"
6	"go/token"
7	"io"
8	"os"
9	"path/filepath"
10	"strings"
11	"testing"
12	"time"
13
14	vt "github.com/bnema/vev-vt"
15	renderer "github.com/bnema/vev-vt/ansi"
16	"github.com/bnema/vev/internal/domain"
17	"github.com/bnema/vev/internal/ports"
18	"github.com/bnema/vev/internal/testutil/replaytest"
19	scopy "github.com/bnema/vev/internal/usecase/copy"
20	"github.com/bnema/vev/internal/usecase/layout"
21	"github.com/bnema/vev/internal/usecase/notices"
22	"github.com/bnema/vev/internal/usecase/palette"
23	"github.com/bnema/vev/internal/usecase/picker"
24	promptui "github.com/bnema/vev/internal/usecase/prompt"
25	themeui "github.com/bnema/vev/internal/usecase/theme"
26	"github.com/bnema/vev/internal/usecase/ui"
27	"github.com/bnema/vev/internal/usecase/visualsearch"
28	"github.com/stretchr/testify/require"
29)
30
31// scriptedReplayTransport is intentionally a ports.Transport only: replay
32// assertions must not depend on any adapter's framing implementation.
33type scriptedReplayTransport struct {
34	t      *testing.T
35	frames []ports.Frame
36	next   int
37}
38
39func (s *scriptedReplayTransport) Send(got ports.Frame) error {
40	s.t.Helper()
41	if s.next >= len(s.frames) {
42		s.t.Errorf("unexpected frame %#v", got)
43		return nil
44	}
45	want := s.frames[s.next]
46	s.next++
47	require.Equal(s.t, want.Type, got.Type)
48	require.Equal(s.t, want.Payload, got.Payload)
49	return nil
50}
51func (*scriptedReplayTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
52func (*scriptedReplayTransport) Close() error               { return nil }
53
54func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
55	frames := replaytest.Transcript()
56	transport := &scriptedReplayTransport{t: t, frames: frames}
57	terminal := vt.NewScreen(8, 3)
58	for _, frame := range frames {
59		require.NoError(t, transport.Send(frame))
60		output, err := ports.UnmarshalOutput(frame.Payload)
61		require.NoError(t, err)
62		require.Equal(t, frame.Payload, mustMarshalOutput(output), "output payload must remain byte exact")
63		terminal.Write(output.Data)
64	}
65	require.Equal(t, len(frames), transport.next)
66	require.Equal(t, []string{"one     ", "TWO     ", "        "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
67}
68
69func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
70	p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
71	p.screen.ClearDamage()
72	p.screen.Write([]byte("old"))
73
74	p.mu.Lock()
75	captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1})
76	p.mu.Unlock()
77
78	require.Equal(t, 3, captured.frame.Width)
79	require.Equal(t, 1, captured.frame.Height)
80	require.NotEmpty(t, p.screen.Damage(), "capture is only a transactional snapshot")
81	p.mu.Lock()
82	p.screen.Write([]byte("new"))
83	p.mu.Unlock()
84	require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
85}
86
87func TestCapturePaneRenderStateIsAlwaysNonDestructive(t *testing.T) {
88	p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
89	p.screen.ClearDamage()
90	p.screen.Write([]byte("preview"))
91
92	p.mu.Lock()
93	_ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2})
94	p.mu.Unlock()
95
96	require.NotEmpty(t, p.screen.Damage())
97}
98
99func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) {
100	p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
101	p.screen.ClearDamage()
102	p.screen.Write([]byte("x"))
103	p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
104
105	p.mu.Lock()
106	captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2})
107	p.mu.Unlock()
108
109	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
110}
111
112func TestCapturePaneRenderStateCollapsedRetainsDamage(t *testing.T) {
113	p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
114	p.screen.ClearDamage()
115	p.screen.Write([]byte("hidden"))
116
117	p.mu.Lock()
118	captured := capturePaneRenderStateLocked(p, domain.Rect{})
119	p.mu.Unlock()
120
121	require.Zero(t, captured.frame.Width)
122	require.Zero(t, captured.frame.Height)
123	require.NotEmpty(t, p.screen.Damage(), "capture keeps hidden-pane damage pending until emission commits")
124}
125
126func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
127	pty, release := newBlockingPTY(t)
128	t.Cleanup(release)
129	d, sess, _, sends := newManualSessionWithPTYs(t, pty)
130	ac := sess.snapshotAttachments()[0]
131	// Resolve the initial attachment view before filling the output window; a
132	// first target repair is an epoch boundary by design.
133	require.NotNil(t, sess.tabForAttachment(ac))
134	ac.sendMu.Lock()
135	ac.output.next = ac.output.maxOutstanding
136	ac.output.acked = 0
137	ac.output.syncCapacityLocked()
138	ac.sendMu.Unlock()
139	p := sess.tabs[0].focusedPane()
140	p.mu.Lock()
141	p.screen.ClearDamage()
142	p.screen.Write([]byte("blocked"))
143	p.mu.Unlock()
144
145	d.paint(sess, ac, false, nil)
146
147	p.mu.Lock()
148	require.NotEmpty(t, p.screen.Damage())
149	p.mu.Unlock()
150	select {
151	case frame := <-sends:
152		t.Fatalf("ACK-blocked paint sent %#v", frame)
153	default:
154	}
155}
156
157func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
158	state := &capturedRenderState{
159		layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, valid: true},
160		panes: []capturedPaneRenderState{{
161			id: "p", frame: func() renderer.Frame {
162				f := renderer.NewFrame(4, 1)
163				for i, r := range "old " {
164					f.Set(i, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
165				}
166				return f
167			}(), placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, focused: true,
168			damage: []renderer.Damage{renderer.FullRedraw()},
169		}},
170		bars:   barState{},
171		cursor: capturedCursorInputs{visible: true, renderable: true, content: domain.Rect{Width: 4, Height: 1}},
172	}
173	cache := composeCacheInput{}
174	out := composeFrame(*state, cache)
175	state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
176	require.Equal(t, "old ", rowText(out.frame.Row(1)))
177	require.False(t, out.cursor.hidden)
178}
179
180func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
181	files, err := filepath.Glob("*.go")
182	require.NoError(t, err)
183	for _, name := range files {
184		parsed, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
185		require.NoError(t, err)
186		for _, declaration := range parsed.Decls {
187			switch declaration := declaration.(type) {
188			case *ast.FuncDecl:
189				forbidden := declaration.Name.Name == "composeFloatingFrame" || declaration.Name.Name == "copyTargetRectLocked" || strings.HasPrefix(declaration.Name.Name, "composeClientFrame") || strings.HasPrefix(declaration.Name.Name, "composeTabFrame")
190				require.False(t, forbidden, "%s declares forbidden legacy render helper %s", name, declaration.Name.Name)
191			case *ast.GenDecl:
192				for _, spec := range declaration.Specs {
193					typeSpec, ok := spec.(*ast.TypeSpec)
194					require.False(t, ok && typeSpec.Name.Name == "composedFrameCache", "%s declares forbidden legacy render cache", name)
195				}
196			}
197		}
198	}
199	data, err := os.ReadFile("render_pipeline.go")
200	require.NoError(t, err)
201	body := string(data)
202	start := strings.Index(body, "func composeFrame(")
203	require.NotEqual(t, -1, start)
204	end := strings.Index(body[start:], "\nfunc ")
205	if end > 0 {
206		body = body[start : start+end]
207	} else {
208		body = body[start:]
209	}
210	for _, forbidden := range []string{".Lock()", ".Unlock()", ".Send(", ".prepare("} {
211		require.NotContains(t, body, forbidden, "composeFrame must remain ownership- and transport-free")
212	}
213}
214
215func TestCursorCandidateDoesNotPublishDuringPreparation(t *testing.T) {
216	ac := &attachedClient{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}
217	desired := cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
218
219	candidate := ac.prepareCursorTail(desired, false)
220
221	require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, ac.lastCursor)
222	require.NotEmpty(t, candidate.data)
223	require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, candidate.next)
224}
225
226func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
227	d, sess, ac, sends := newManualSessionWithPTYs(t)
228	healthy := ac.transport()
229	beforeCursor := cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}
230	ac.lastCursor = beforeCursor
231	ac.replaceTransport(cacheFailTransport{})
232	state := cacheState("failed", 1)
233	state.attachment = ac
234	composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
235	composed.cursor = cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
236
237	ac.sendMu.Lock()
238	require.True(t, d.emitFrame(sess, ac, &state, composed))
239
240	require.Equal(t, beforeCursor, ac.lastCursor)
241	require.Zero(t, ac.output.next)
242	probe, err := ac.output.renderer.Prepare(composed.frame, nil, false)
243	require.NoError(t, err)
244	require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")
245
246	sess.mu.Lock()
247	sess.registerAttachmentLocked(ac)
248	sess.mu.Unlock()
249	ac.setSession(sess)
250	ac.replaceTransport(healthy)
251	state.attachment = ac
252	ac.sendMu.Lock()
253	require.True(t, d.emitFrame(sess, ac, &state, composed))
254	require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.lastCursor)
255	require.Equal(t, uint64(1), ac.output.next)
256	out, err := ports.UnmarshalOutput((<-sends).Payload)
257	require.NoError(t, err)
258	require.Zero(t, out.Base)
259	require.Equal(t, uint64(1), out.New)
260}
261
262func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) {
263	d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
264	state := cacheState("steady", 1)
265	state.attachment = ac
266	initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
267	ac.sendMu.Lock()
268	require.True(t, d.emitFrame(sess, ac, &state, initial))
269	<-sends
270
271	p := newPane("collapsed", nil, domain.Size{Cols: 1, Rows: 1})
272	p.mu.Lock()
273	p.screen.ClearDamage()
274	p.screen.Write([]byte("x"))
275	capture := p.screen.CaptureDamage()
276	p.mu.Unlock()
277
278	beforeNext := ac.output.next
279	beforeCursor := ac.lastCursor
280	state.receipts = []damageReceipt{{pane: p, generation: capture.Generation}}
281	noByte := composedRenderFrame{
282		frame:  initial.frame,
283		cursor: initial.cursor,
284		cache:  initial.cache,
285	}
286	noByte.cache.layoutFingerprint = "no-byte-committed"
287	ac.sendMu.Lock()
288	require.True(t, d.emitFrame(sess, ac, &state, noByte))
289
290	require.Equal(t, beforeNext, ac.output.next)
291	require.Equal(t, beforeCursor, ac.lastCursor)
292	require.Equal(t, "no-byte-committed", ac.pipelineCache.layoutFingerprint)
293	p.mu.Lock()
294	require.Empty(t, p.screen.Damage())
295	p.mu.Unlock()
296	select {
297	case frame := <-sends:
298		t.Fatalf("no-byte transaction sent state frame %#v", frame)
299	default:
300	}
301}
302
303func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
304	paneFrame := renderer.NewFrame(12, 5)
305	for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
306		for x, r := range text {
307			paneFrame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
308		}
309	}
310	floatingFrame := renderer.NewFrame(4, 1)
311	for x, r := range "FLOAT"[:4] {
312		floatingFrame.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
313	}
314	modalInner := renderer.NewFrame(6, 1)
315	for x, r := range "PROMPT" {
316		modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
317	}
318	state := capturedRenderState{
319		reset:    true,
320		layout:   capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
321		panes:    []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
322		floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
323		bars:     barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
324		overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},
325		cursor:   capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
326		styles:   resolveStyles(nil),
327	}
328	composed := composeFrame(state, composeCacheInput{})
329	require.Equal(t, []string{" tab       R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT      ", "            ", " sess      B"}, frameRows(composed.frame))
330	require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
331
332	stream := newOutputStateStream()
333	prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
334	require.NoError(t, err)
335	terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).prepareCursorTail(composed.cursor, true).data...)
336	require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m      R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m     B\x1b[0m\x1b[?25l", string(terminalBytes))
337	client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
338	client.Write(terminalBytes)
339	require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
340	prepared.draw.Commit()
341	stream.forceSnapshot = false
342	again, err := stream.renderer.Draw(composed.frame, nil)
343	require.NoError(t, err)
344	require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
345}
346
347func frameRows(frame renderer.Frame) []string {
348	rows := make([]string, frame.Height)
349	for y := range rows {
350		rows[y] = rowText(frame.Row(y))
351	}
352	return rows
353}
354
355func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
356	for _, tt := range []struct {
357		name      string
358		placement layout.Placement
359		initial   []string
360		scrolled  []string
361		damage    []renderer.Damage
362		wantBytes string
363	}{
364		{name: "safe full width", placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 3}}, initial: []string{"AAAA", "BBBB", "CCCC"}, scrolled: []string{"BBBB", "CCCC", "N   "}, damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}}, wantBytes: "\x1b[0m\x1b[2;4r\x1b[4;1H\n\x1b[r\x1b[4;1HN\x1b[K\x1b[0m"},
365		{name: "unsafe partial width", placement: layout.Placement{ID: "p", Content: domain.Rect{X: 2, Width: 2, Height: 3}}, initial: []string{"AA", "BB", "CC"}, scrolled: []string{"BB", "CC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageText, X: 2, Y: 0, Width: 2, Height: 3}}, wantBytes: "\x1b[2;3HBB\x1b[3;3HCC\x1b[4;3HN \x1b[0m"},
366	} {
367		t.Run(tt.name, func(t *testing.T) {
368			makeState := func(rows []string, damage []renderer.Damage, reset bool) capturedRenderState {
369				f := renderer.NewFrame(tt.placement.Content.Width, tt.placement.Content.Height)
370				for y, text := range rows {
371					for x, r := range text {
372						f.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
373					}
374				}
375				return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 3}, valid: true, focus: "p", placements: []layout.Placement{tt.placement}}, panes: []capturedPaneRenderState{{id: "p", frame: f, placement: tt.placement, focused: true, damage: damage}}, cursor: capturedCursorInputs{visible: false}}
376			}
377			stream := newOutputStateStream()
378			first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
379			firstBytes, err := stream.render(first.frame, first.damage, first.reset)
380			require.NoError(t, err)
381			client := vt.NewScreen(4, 5)
382			client.Write(firstBytes)
383
384			second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
385			secondBytes, err := stream.render(second.frame, second.damage, second.reset)
386			require.NoError(t, err)
387			require.Equal(t, tt.wantBytes, string(secondBytes))
388			client.Write(secondBytes)
389			require.Equal(t, frameRows(second.frame), frameRows(client.Frame))
390			again, err := stream.renderer.Draw(second.frame, nil)
391			require.NoError(t, err)
392			require.Empty(t, again, "renderer shadow must equal replayed terminal state")
393		})
394	}
395}
396
397func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
398	muted := renderer.Style{Foreground: 1, Background: 2}
399	active := renderer.Style{Foreground: 3, Background: 4}
400	interior := renderer.Style{Foreground: 5, Background: 6}
401	modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
402	presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8})
403
404	for _, tt := range []struct {
405		name    string
406		focused bool
407		border  renderer.Style
408	}{
409		{name: "unfocused", focused: false, border: muted},
410		{name: "focused", focused: true, border: active},
411	} {
412		t.Run(tt.name, func(t *testing.T) {
413			state := capturedRenderState{
414				styles:   themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
415				overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
416			}
417			frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil)
418			bounds := presentation.Bounds
419			require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
420			require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
421		})
422	}
423}
424
425func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
426	const (
427		width       = 100
428		contentRows = 6
429	)
430	styles := resolveStyles(nil)
431	theme := backdropTheme()
432	leftPlacement := layout.Placement{
433		ID:       "left",
434		TitleBar: domain.Rect{Width: 49, Height: 1},
435		Content:  domain.Rect{Y: 1, Width: 49, Height: 5},
436	}
437	rightPlacement := layout.Placement{
438		ID:       "right",
439		TitleBar: domain.Rect{X: 50, Width: 50, Height: 1},
440		Content:  domain.Rect{X: 50, Y: 1, Width: 50, Height: 5},
441	}
442	state := capturedRenderState{
443		reset: true,
444		layout: capturedTabLayout{
445			area:        domain.Rect{Width: width, Height: contentRows},
446			focus:       "left",
447			placements:  []layout.Placement{leftPlacement, rightPlacement},
448			dividers:    []layout.Divider{{Rect: domain.Rect{X: 49, Width: 1, Height: contentRows}, Dir: layout.Horizontal}},
449			fingerprint: "modal-backdrop",
450			valid:       true,
451		},
452		panes: []capturedPaneRenderState{
453			{id: "left", title: "left", frame: cachePaneFrame(49, 5, 'L'), placement: leftPlacement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}},
454			{id: "right", title: "right", frame: cachePaneFrame(50, 5, 'R'), placement: rightPlacement, damage: []renderer.Damage{renderer.FullRedraw()}},
455		},
456		overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
457		styles:   styles,
458		theme:    theme,
459	}
460	base := composeFrame(state, composeCacheInput{})
461	require.NotEmpty(t, base.cache.toastFootprints)
462
463	state.reset = false
464	state.panes[0].damage = nil
465	state.panes[1].damage = nil
466	state.overlays.promptActive = true
467	state.overlays.prompt = capturedModal{
468		active: true,
469		title:  "Prompt",
470		presentation: ui.Presentation{
471			Bounds:  domain.Rect{X: 80, Y: 5, Width: 10, Height: 2},
472			Inner:   domain.Rect{X: 81, Y: 6, Width: 8, Height: 1},
473			Borders: ui.BorderAll,
474		},
475		inner:   renderer.NewFrame(8, 1),
476		focused: true,
477	}
478	composed := composeFrame(state, base.cache)
479	dimmer := themeui.NewDimmer(theme)
480	toast := base.cache.toastFootprints[0]
481
482	for name, point := range map[string][2]int{
483		"top bar":      {0, 0},
484		"pane chrome":  {0, 1},
485		"pane content": {0, 2},
486		"divider":      {49, 2},
487		"bottom bar":   {0, contentRows + 1},
488		"toast":        {toast.X, toast.Y},
489	} {
490		t.Run(name, func(t *testing.T) {
491			x, y := point[0], point[1]
492			require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style)
493		})
494	}
495	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
496	require.False(t, composed.cache.valid)
497	require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned")
498}
499
500func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
501	const (
502		width       = 100
503		contentRows = 6
504	)
505	placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: width, Height: contentRows}}
506	theme := backdropTheme()
507	state := capturedRenderState{
508		reset:  true,
509		layout: capturedTabLayout{area: domain.Rect{Width: width, Height: contentRows}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "floating-toast-backdrop", valid: true},
510		panes: []capturedPaneRenderState{{
511			id: "pane", frame: cachePaneFrame(width, contentRows, 'P'), placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
512		}},
513		overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
514		styles:   resolveStyles(nil),
515		theme:    theme,
516	}
517	toastOnly := composeFrame(state, composeCacheInput{})
518	require.NotEmpty(t, toastOnly.cache.toastFootprints)
519	toast := toastOnly.cache.toastFootprints[0]
520
521	state.floating = capturedFloatingRenderState{
522		visible: true,
523		focused: true,
524		pane:    capturedPaneRenderState{id: "floating", frame: renderer.NewFrame(8, 1)},
525		geometry: floatingGeometry{
526			Mode:   ui.PresentationFloating,
527			Bounds: domain.Rect{X: 40, Y: 2, Width: 10, Height: 3},
528			Inner:  domain.Rect{X: 41, Y: 3, Width: 8, Height: 1},
529		},
530		generation: 1,
531	}
532	composed := composeFrame(state, composeCacheInput{})
533	dimmer := themeui.NewDimmer(theme)
534
535	for name, point := range map[string][2]int{
536		"top bar":    {0, 0},
537		"bottom bar": {0, contentRows + 1},
538		"toast":      {toast.X, toast.Y},
539	} {
540		t.Run(name, func(t *testing.T) {
541			x, y := point[0], point[1]
542			require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style)
543		})
544	}
545	require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache")
546}
547
548func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
549	theme := backdropTheme()
550	lowerStyle := renderer.Style{Foreground: 1, Background: 2}
551	higherStyle := renderer.Style{Foreground: 3, Background: 4}
552	modalAt := func(x int, r rune, style renderer.Style) capturedModal {
553		inner := renderer.NewFrame(1, 1)
554		inner.Set(0, 0, renderer.Cell{Rune: r, Style: style})
555		bounds := domain.Rect{X: x, Y: 1, Width: 1, Height: 1}
556		return capturedModal{active: true, presentation: ui.Presentation{Bounds: bounds, Inner: bounds}, inner: inner}
557	}
558
559	for _, tt := range []struct {
560		name   string
561		lower  string
562		higher string
563	}{
564		{name: "notices above copy search", lower: "copy search", higher: "notices"},
565		{name: "picker above notices", lower: "notices", higher: "picker"},
566		{name: "palette above picker", lower: "picker", higher: "palette"},
567		{name: "prompt above palette", lower: "palette", higher: "prompt"},
568	} {
569		t.Run(tt.name, func(t *testing.T) {
570			state := capturedRenderState{theme: theme, styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}}
571			setModal := func(name string, modal capturedModal) {
572				switch name {
573				case "copy search":
574					state.overlays.copySearch = modal
575				case "notices":
576					state.overlays.noticesOverlay = modal
577				case "picker":
578					state.overlays.picker = modal
579				case "palette":
580					state.overlays.palette = modal
581				case "prompt":
582					state.overlays.prompt = modal
583				}
584			}
585			setModal(tt.lower, modalAt(1, 'L', lowerStyle))
586			setModal(tt.higher, modalAt(3, 'H', higherStyle))
587
588			frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil)
589
590			require.Equal(t, 'L', frame.At(1, 1).Rune)
591			require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop")
592			require.Equal(t, 'H', frame.At(3, 1).Rune)
593			require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost")
594			require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
595		})
596	}
597}
598
599func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
600	state := capturedRenderState{
601		layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}},
602		styles: fallbackChromeStyles,
603	}
604	snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
605	snap := &overlayRenderSnapshot{
606		copySearchModel:      visualsearch.New(snapshot),
607		pickerActive:         true,
608		pickerModel:          picker.New(nil, picker.SelectionConfig{}),
609		noticesOverlayActive: true,
610		noticesOverlayModel:  notices.New(nil, time.Time{}),
611		paletteActive:        true,
612		paletteModel:         palette.New(nil),
613		promptActive:         true,
614		promptModel:          promptui.New(" Prompt ", ""),
615	}
616
617	captureOverlayLayers(&state, snap, domain.PaletteConfig{})
618
619	for name, modal := range map[string]capturedModal{
620		"copy search": state.overlays.copySearch,
621		"picker":      state.overlays.picker,
622		"notices":     state.overlays.noticesOverlay,
623		"palette":     state.overlays.palette,
624		"prompt":      state.overlays.prompt,
625	} {
626		t.Run(name, func(t *testing.T) {
627			require.True(t, modal.active)
628			require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode)
629			require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height})
630		})
631	}
632}
633
634func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) {
635	base := renderer.NewFrame(4, 3)
636	inner := renderer.NewFrame(4, 2)
637	for y, text := range []string{"ABCD", "EFGH"} {
638		for x, r := range text {
639			inner.Set(x, y, renderer.Cell{Rune: r})
640		}
641	}
642
643	copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner)
644
645	require.Equal(t, "BCD ", rowText(base.Row(2)))
646}
647
648func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) {
649	presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4})
650	require.Zero(t, presentation.Bounds.Height)
651	state := capturedRenderState{
652		styles: themeui.Styles{PickerBase: renderer.DefaultStyle()},
653		overlays: capturedOverlayRenderState{
654			copySearch: capturedModal{active: true, title: "Search", presentation: presentation},
655		},
656	}
657
658	_, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil)
659
660	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
661}
662
663func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
664	terminal := renderer.DefaultStyle()
665	muted := renderer.Style{Foreground: 1}
666	active := renderer.Style{Foreground: 2}
667	warn := renderer.Style{Foreground: 3}
668	styles := themeui.Styles{PickerBase: terminal, BorderMuted: muted, BorderActive: active, BorderWarn: warn}
669
670	got := noticeStylesFrom(styles)
671
672	require.True(t, got.Text.Equal(terminal), "toast content keeps the terminal background")
673	require.Equal(t, active, got.BoxError)
674	require.Equal(t, muted, got.BoxInfo)
675	// BoxWarn must use the dedicated BorderWarn role, not fall back to the
676	// same muted role BoxInfo uses - that was the pre-existing bug this
677	// role fixes (Warn and Info toasts were visually identical).
678	require.Equal(t, warn, got.BoxWarn)
679	require.NotEqual(t, got.BoxInfo, got.BoxWarn)
680}
681
682func TestEmitFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) {
683	d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
684	token := sess.attachmentToken(ac, ac.transport())
685	effect, admitted := ac.beginAttachmentEffect(token)
686	require.True(t, admitted)
687	ac.attachmentEffects.mu.Lock()
688	ac.attachmentEffects.capability.transport = transportSnapshot{}
689	ac.attachmentEffects.mu.Unlock()
690
691	state := cacheState("stale", 1)
692	state.attachment = ac
693	composed := composeFrame(state, composeCacheInput{})
694	ac.sendMu.Lock()
695	require.True(t, d.emitFrame(sess, ac, &state, composed, &runtimeMarkBatch{attachmentEffect: effect}))
696	d.attachmentCleanupWg.Wait()
697
698	require.Zero(t, ac.output.next, "rejected transport effect must not commit output state")
699	select {
700	case frame := <-sends:
701		t.Fatalf("rejected transport effect was sent: %v", frame.Type)
702	default:
703	}
704}

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