render_pipeline_cache_test.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"io"
6	"maps"
7	"testing"
8
9	renderer "github.com/bnema/vev-vt"
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/domain/layout"
12	"github.com/bnema/vev/internal/protocol"
13	"github.com/bnema/vev/internal/protocol/wire"
14	themeui "github.com/bnema/vev/internal/usecase/theme"
15	"github.com/bnema/vev/internal/usecase/ui"
16	"github.com/stretchr/testify/require"
17)
18
19type cacheFailTransport struct{}
20
21func (cacheFailTransport) Send(wire.Envelope) error     { return errors.New("send failed") }
22func (cacheFailTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
23func (cacheFailTransport) Close() error                 { return nil }
24
25func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
26	for _, failure := range []struct {
27		name  string
28		apply func(*composedRenderFrame, *attachedClient)
29	}{
30		{
31			name: "prepare",
32			apply: func(composed *composedRenderFrame, _ *attachedClient) {
33				// Frame validation fails in output.prepare after composition.
34				composed.frame = renderer.Frame{Width: 1}
35			},
36		},
37		{
38			name:  "send",
39			apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
40		},
41	} {
42		t.Run(failure.name, func(t *testing.T) {
43			d, sess, ac, sends := newManualSessionWithPTYs(t)
44			healthy := ac.transport()
45			committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
46			ac.render.cache = committed.cache
47			before := cloneComposeCache(ac.render.cache)
48			pending := composeFrame(cacheState("next", 2), ac.render.cache, ac.render.spare)
49			failure.apply(&pending, ac)
50
51			state := cacheState("next", 2)
52			state.attachment = ac
53			state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
54			state.view.revision = ac.viewSnapshot().revision
55			ac.sendMu.Lock()
56			require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
57			require.Equal(t, before, cloneComposeCache(ac.render.cache), "failed emission must not publish any composed cache backing storage")
58
59			// A send error detaches the failed link. Re-own this test attachment with
60			// its original healthy transport, then retry the pending state.
61			if failure.name == "send" {
62				sess.mu.Lock()
63				sess.registerAttachmentLocked(ac)
64				sess.mu.Unlock()
65				ac.setSession(sess)
66				ac.replaceTransport(healthy)
67			}
68			pending = composeFrame(cacheState("next", 2), ac.render.cache, ac.render.spare)
69			state = cacheState("next", 2)
70			state.attachment = ac
71			state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
72			state.view.revision = ac.viewSnapshot().revision
73			ac.sendMu.Lock()
74			require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
75			require.Equal(t, pending.cache, ac.render.cache)
76			frame := <-sends
77			output := unmarshalTestOutput(t, frame.Payload)
78			require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")
79		})
80	}
81}
82
83func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
84	visible := cacheState("base", 1)
85	committed := composeFrame(visible, composeCacheInput{})
86
87	hidden := visible
88	hidden.floating = capturedFloatingRenderState{}
89	hidden.panes[0].damage = nil
90	result := composeFrame(hidden, committed.cache, committed.cache)
91
92	require.Equal(t, 's', result.frame.Row(1)[2].Rune)
93	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
94}
95
96func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) {
97	theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
98	initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
99	committed := composeFrame(initial, composeCacheInput{})
100	require.Equal(t, '│', committed.frame.At(20, 1).Rune)
101	focusedStyle := committed.frame.At(0, 1).Style
102	dimmedStyle := committed.frame.At(21, 1).Style
103	require.Equal(t, renderer.RGB{R: 96, G: 96, B: 96}, dimmedStyle.ForegroundRGB, "inactive panes should use stronger foreground dimming")
104
105	undamaged := initial
106	undamaged.reset = false
107	undamaged.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'Z', Style: renderer.DefaultStyle()})
108	undamaged.panes[0].damage = nil
109	undamaged.panes[1].frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
110	undamaged.panes[1].damage = []renderer.Damage{{Kind: renderer.DamageText, X: 21, Width: 1, Height: 1}}
111	out := composeFrame(undamaged, committed.cache, composeCacheInput{})
112	require.Equal(t, 'L', out.frame.At(0, 1).Rune, "an undamaged pane must retain the committed blit")
113	require.Equal(t, 'x', out.frame.At(21, 1).Rune)
114	require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 21, Y: 1, Width: 1, Height: 1})
115
116	focusChanged := cachedSplitState("horizontal-right", "right", layout.Horizontal, theme)
117	focusChanged.reset = false
118	focusChanged.panes[0].damage, focusChanged.panes[1].damage = nil, nil
119	out = composeFrame(focusChanged, out.cache, committed.cache)
120	require.Equal(t, dimmedStyle, out.frame.At(0, 1).Style, "the old focus must be re-blitted dimmed")
121	require.Equal(t, focusedStyle, out.frame.At(21, 1).Style, "the new focus must be re-blitted undimmed")
122	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
123
124	vertical := cachedSplitState("vertical-right", "right", layout.Vertical, theme)
125	vertical.reset = false
126	vertical.panes[0].damage, vertical.panes[1].damage = nil, nil
127	out = composeFrame(vertical, out.cache, committed.cache)
128	require.NotEqual(t, '│', out.frame.At(20, 1).Rune, "layout invalidation must clear the old vertical divider")
129	require.Equal(t, '─', out.frame.At(20, 3).Rune)
130	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
131}
132
133func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) {
134	root := &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
135		{Kind: layout.Leaf, Leaf: "left", Weight: 1},
136		{Kind: layout.Leaf, Leaf: "right", Weight: 1},
137	}}
138	stateFor := func(reset bool, damage []renderer.Damage) capturedRenderState {
139		placements, ok := layout.Solve(root, domain.Rect{Width: 61, Height: 5})
140		require.True(t, ok)
141		panes := make([]capturedPaneRenderState, 0, len(placements))
142		for _, placement := range placements {
143			frame := renderer.NewFrame(placement.Content.Width, placement.Content.Height)
144			for y := 0; y < frame.Height; y++ {
145				for x := 0; x < frame.Width; x++ {
146					frame.Set(x, y, renderer.Cell{Rune: rune(placement.ID[0]), Style: renderer.DefaultStyle()})
147				}
148			}
149			panes = append(panes, capturedPaneRenderState{
150				id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "left", damage: damage,
151			})
152		}
153		return capturedRenderState{
154			reset:  reset,
155			layout: capturedTabLayout{area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true},
156			panes:  panes, styles: resolveStyles(nil),
157		}
158	}
159
160	initial := stateFor(true, []renderer.Damage{renderer.FullRedraw()})
161	committed := composeFrame(initial, composeCacheInput{})
162	root.Children[0].Weight = 2
163	next := stateFor(false, nil)
164	require.NotEqual(t, initial.layout.fingerprint, next.layout.fingerprint)
165	require.Equal(t, 41, next.layout.placements[1].Content.X)
166	out := composeFrame(next, committed.cache, composeCacheInput{})
167	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
168	require.Equal(t, 'l', out.frame.At(31, 1).Rune, "the enlarged pane must replace its old divider/cache footprint")
169	require.Equal(t, 'r', out.frame.At(41, 1).Rune, "weight-derived pane geometry must be reblitted at its new position")
170}
171
172func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {
173	theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}
174	neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}}
175	expected := themeui.NewDimmer(theme).Dim(neutralBorder)
176
177	t.Run("divider", func(t *testing.T) {
178		state := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
179		state.styles.NeutralBorder = neutralBorder
180
181		out := composeFrame(state, composeCacheInput{})
182		require.Equal(t, expected, out.frame.At(20, 1).Style)
183	})
184
185	t.Run("unfocused title", func(t *testing.T) {
186		state := cachedStackTitleState("collapsed", 1, true)
187		state.theme = theme
188		state.styles = themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
189		state.styles.NeutralBorder = neutralBorder
190
191		out := composeFrame(state, composeCacheInput{})
192		require.Equal(t, expected, out.frame.At(0, 1).Style)
193	})
194}
195
196func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
197	state := cachedStackTitleState("collapsed", 1, true)
198	state.theme = themeui.Theme{
199		Known:      true,
200		HasFG:      true,
201		HasBG:      true,
202		Foreground: renderer.RGB{R: 220, G: 210, B: 200},
203		Background: renderer.RGB{R: 20, G: 30, B: 40},
204	}
205	state.styles = themeui.Resolve(state.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
206	before := cloneCapturedStackState(state)
207
208	out := composeFrame(state, composeCacheInput{})
209
210	collapsedTitle := out.frame.At(0, 1)
211	require.Equal(t, 'c', collapsedTitle.Rune)
212	require.True(t, collapsedTitle.Style.HasForegroundRGB, "collapsed title bar must use dimmed chrome")
213	require.Equal(t, 'E', out.frame.At(0, 2).Rune, "expanded pane must draw no title row; its content starts where the title bar used to be")
214	require.Equal(t, before, state, "composition must not mutate the captured source frame")
215}
216
217func TestComposeFrameCacheRefreshesStackTitlesAndBars(t *testing.T) {
218	initial := cachedStackTitleState("one", 1, true)
219	committed := composeFrame(initial, composeCacheInput{})
220
221	renamed := initial
222	renamed.reset = false
223	renamed.panes[0].title, renamed.panes[0].titleGeneration = "renamed", 2
224	out := composeFrame(renamed, committed.cache, composeCacheInput{})
225	require.Equal(t, "renamed", rowText(out.frame.Row(1))[:7])
226	require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
227
228	unchanged := composeFrame(renamed, out.cache, committed.cache)
229	require.NotContains(t, unchanged.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
230
231	bars := cacheBarState("one", true)
232	committed = composeFrame(bars, composeCacheInput{})
233	bars.reset = false
234	bars.panes[0].damage = []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}
235	out = composeFrame(bars, committed.cache, composeCacheInput{})
236	for _, damage := range out.damage {
237		require.NotEqual(t, 0, damage.Y, "unchanged top bar must remain cached")
238		require.NotEqual(t, 2, damage.Y, "unchanged bottom bar must remain cached")
239	}
240	bars.panes[0].damage = nil
241	bars.bars.bottomRight = "two"
242	out = composeFrame(bars, out.cache, committed.cache)
243	require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 2, Width: 6, Height: 1})
244	require.NotContains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 6, Height: 1})
245}
246
247func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
248	state := cachedStackTitleState("title", 1, true)
249	committed, scratch := composeCacheInput{}, composeCacheInput{}
250	compose := func() {
251		out := composeFrame(state, committed, scratch)
252		scratch, committed = committed, out.cache
253		state.reset = false
254	}
255	compose()
256	compose()
257	compose()
258
259	allocs := testing.AllocsPerRun(100, compose)
260	require.Zero(t, allocs, "warm production title composition must reuse the committed and scratch cache backing storage")
261}
262
263func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {
264	left, right := layout.PaneID("left"), layout.PaneID("right")
265	area := domain.Rect{Width: 41, Height: 5}
266	root := &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}
267	placements, dividers, ok := layout.SolveWithDividers(root, area)
268	if !ok {
269		panic("cached split fixture has invalid geometry")
270	}
271	leftFrame := cachePaneFrame(placements[0].Content.Width, placements[0].Content.Height, 'L')
272	rightFrame := cachePaneFrame(placements[1].Content.Width, placements[1].Content.Height, 'R')
273	return capturedRenderState{
274		reset:  true,
275		layout: capturedTabLayout{area: area, focus: focus, placements: placements, dividers: dividers, fingerprint: fingerprint, valid: true},
276		panes:  []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},
277		theme:  theme,
278		styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
279	}
280}
281
282func cachedStackTitleState(title string, generation uint64, reset bool) capturedRenderState {
283	first, second := layout.PaneID("first"), layout.PaneID("second")
284	placements := []layout.Placement{
285		{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true},
286		{ID: second, Content: domain.Rect{Y: 1, Width: 20, Height: 4}, InStack: true},
287	}
288	return capturedRenderState{
289		reset:  reset,
290		layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
291		panes:  []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
292		styles: resolveStyles(nil),
293	}
294}
295
296func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
297	out := in
298	out.layout.placements = append([]layout.Placement(nil), in.layout.placements...)
299	out.panes = append([]capturedPaneRenderState(nil), in.panes...)
300	for i := range out.panes {
301		out.panes[i].frame = in.panes[i].frame.Clone()
302		out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...)
303		out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...)
304	}
305	return out
306}
307
308func cacheBarState(bottom string, reset bool) capturedRenderState {
309	pane := cachePaneFrame(6, 1, 'P')
310	placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
311	return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}, styles: resolveStyles(nil)}
312}
313
314func cachePaneFrame(width, height int, r rune) renderer.Frame {
315	frame := renderer.NewFrame(width, height)
316	frame.Set(0, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
317	return frame
318}
319
320func cacheState(title string, generation uint64) capturedRenderState {
321	pane := renderer.NewFrame(6, 1)
322	for x, r := range title[:min(len(title), 6)] {
323		pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
324	}
325	floating := renderer.NewFrame(2, 1)
326	floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
327	return capturedRenderState{
328		route:         publicationTestContext().Route,
329		view:          attachmentView{tabID: "tab-1"},
330		focusedPaneID: "pane-1",
331		reset:         false,
332		layout:        capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
333		panes:         []capturedPaneRenderState{{id: "pane", stableID: "pane-1", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
334		floating:      capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
335		bars:          barState{topRight: title, bottomRight: title},
336		styles:        resolveStyles(nil),
337	}
338}
339
340func cloneComposeCache(in composeCacheInput) composeCacheInput {
341	out := in
342	out.frame = in.frame.Clone()
343	out.copyViewport.frame = in.copyViewport.frame.Clone()
344	out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
345	maps.Copy(out.titleGenerations, in.titleGenerations)
346	out.damage = append([]renderer.Damage(nil), in.damage...)
347	out.toastFootprints = append([]domain.Rect(nil), in.toastFootprints...)
348	out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
349	out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
350	return out
351}

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