detached 66775da

test(daemon): restore render pipeline cache coverage

commit
66775dabba8ecda3d404496e78777c80df763fa2
author
brice · · 2026-07-14 20:13 UTC
parent
30f99e6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +178 -3
M internal/usecase/daemon/floating_render_test.go +24 -0
@@ -332,6 +332,30 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
332332 }
333333 }
334334
335+func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
336+ base := renderer.NewFrame(80, 24)
337+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
338+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
339+ input := floatingComposeInput{
340+ baseFrame: base,
341+ floating: capturedFloatingRenderState{
342+ visible: true,
343+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
344+ geometry: geometry, title: "float", generation: 1, titleGeneration: 1,
345+ },
346+ content: content,
347+ cache: composeCacheInput{
348+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1,
349+ },
350+ }
351+
352+ allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
353+ // The production entry point deliberately clones Frame.Cells and its row
354+ // offsets to keep the cached base immutable; those are its two unavoidable
355+ // allocations. A higher count would reintroduce avoidable cache churn.
356+ require.LessOrEqual(t, allocs, float64(2))
357+}
358+
335359 func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
336360 base := renderer.NewFrame(80, 24)
337361 content := domain.Rect{Y: 1, Width: 80, Height: 22}
@@ -332,6 +332,30 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
332 }332 }
333 }333 }
334 334
335+func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
336+ base := renderer.NewFrame(80, 24)
337+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
338+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
339+ input := floatingComposeInput{
340+ baseFrame: base,
341+ floating: capturedFloatingRenderState{
342+ visible: true,
343+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
344+ geometry: geometry, title: "float", generation: 1, titleGeneration: 1,
345+ },
346+ content: content,
347+ cache: composeCacheInput{
348+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1,
349+ },
350+ }
351+
352+ allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
353+ // The production entry point deliberately clones Frame.Cells and its row
354+ // offsets to keep the cached base immutable; those are its two unavoidable
355+ // allocations. A higher count would reintroduce avoidable cache churn.
356+ require.LessOrEqual(t, allocs, float64(2))
357+}
358+
335 func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {359 func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
336 base := renderer.NewFrame(80, 24)360 base := renderer.NewFrame(80, 24)
337 content := domain.Rect{Y: 1, Width: 80, Height: 22}361 content := domain.Rect{Y: 1, Width: 80, Height: 22}
M internal/usecase/daemon/render_damage_receipt_test.go +31 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
89 "github.com/bnema/vev/internal/usecase/picker"
910 "github.com/bnema/vev/pkg/renderer"
1011 "github.com/stretchr/testify/require"
@@ -45,6 +46,36 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
4546
4647 // These are deliberately end-to-end pipeline tests: the pending damage comes
4748 // from a real pane VT Write, not a hand-built FullRedraw.
49+func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *testing.T) {
50+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
51+ tb := sess.tabs[0]
52+ visible := tb.focusedPane()
53+ collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23})
54+ tb.panes[collapsed.id] = collapsed
55+ tb.tree = &layout.Tree{
56+ Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(visible.id), layout.NewLeaf(collapsed.id)}, Expanded: visible.id},
57+ Focus: visible.id,
58+ }
59+ visible.screen.ClearDamage()
60+ collapsed.screen.ClearDamage()
61+ collapsed.screen.Write([]byte("hidden damage"))
62+
63+ ac.sendMu.Lock()
64+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}})
65+ require.True(t, ok)
66+ collapsedReceipt := false
67+ for _, receipt := range state.receipts {
68+ collapsedReceipt = collapsedReceipt || receipt.pane == collapsed
69+ }
70+ require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane")
71+ require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage")
72+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
73+ require.True(t, d.emitFrame(sess, ac, state, composed))
74+
75+ <-sends
76+ require.Empty(t, collapsed.screen.Damage(), "successful emission must acknowledge the collapsed-pane receipt")
77+}
78+
4879 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
4980 for _, tt := range []struct {
5081 name string
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
8 "github.com/bnema/vev/internal/usecase/picker"9 "github.com/bnema/vev/internal/usecase/picker"
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
@@ -45,6 +46,36 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
45 46
46 // These are deliberately end-to-end pipeline tests: the pending damage comes47 // These are deliberately end-to-end pipeline tests: the pending damage comes
47 // from a real pane VT Write, not a hand-built FullRedraw.48 // from a real pane VT Write, not a hand-built FullRedraw.
49+func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *testing.T) {
50+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
51+ tb := sess.tabs[0]
52+ visible := tb.focusedPane()
53+ collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23})
54+ tb.panes[collapsed.id] = collapsed
55+ tb.tree = &layout.Tree{
56+ Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(visible.id), layout.NewLeaf(collapsed.id)}, Expanded: visible.id},
57+ Focus: visible.id,
58+ }
59+ visible.screen.ClearDamage()
60+ collapsed.screen.ClearDamage()
61+ collapsed.screen.Write([]byte("hidden damage"))
62+
63+ ac.sendMu.Lock()
64+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}})
65+ require.True(t, ok)
66+ collapsedReceipt := false
67+ for _, receipt := range state.receipts {
68+ collapsedReceipt = collapsedReceipt || receipt.pane == collapsed
69+ }
70+ require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane")
71+ require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage")
72+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
73+ require.True(t, d.emitFrame(sess, ac, state, composed))
74+
75+ <-sends
76+ require.Empty(t, collapsed.screen.Damage(), "successful emission must acknowledge the collapsed-pane receipt")
77+}
78+
48 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {79 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
49 for _, tt := range []struct {80 for _, tt := range []struct {
50 name string81 name string
M internal/usecase/daemon/render_pipeline_cache_test.go +123 -3
@@ -3,11 +3,13 @@ package daemon
33 import (
44 "errors"
55 "io"
6+ "maps"
67 "testing"
78
89 "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/layout"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
1113 "github.com/bnema/vev/pkg/renderer"
1214 "github.com/stretchr/testify/require"
1315 )
@@ -86,6 +88,126 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
8688 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
8789 }
8890
91+func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) {
92+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
93+ initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
94+ committed := composeFrame(initial, composeCacheInput{})
95+ require.Equal(t, '│', committed.frame.At(20, 1).Rune)
96+ focusedStyle := committed.frame.At(0, 1).Style
97+ dimmedStyle := committed.frame.At(21, 1).Style
98+
99+ undamaged := initial
100+ undamaged.reset = false
101+ undamaged.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'Z', Style: renderer.DefaultStyle()})
102+ undamaged.panes[0].damage = nil
103+ undamaged.panes[1].frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
104+ undamaged.panes[1].damage = []renderer.Damage{{Kind: renderer.DamageText, X: 21, Width: 1, Height: 1}}
105+ out := composeFrame(undamaged, committed.cache, composeCacheInput{})
106+ require.Equal(t, 'L', out.frame.At(0, 1).Rune, "an undamaged pane must retain the committed blit")
107+ require.Equal(t, 'x', out.frame.At(21, 1).Rune)
108+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 21, Y: 1, Width: 1, Height: 1})
109+
110+ focusChanged := cachedSplitState("horizontal-right", "right", layout.Horizontal, theme)
111+ focusChanged.reset = false
112+ focusChanged.panes[0].damage, focusChanged.panes[1].damage = nil, nil
113+ out = composeFrame(focusChanged, out.cache, committed.cache)
114+ require.Equal(t, dimmedStyle, out.frame.At(0, 1).Style, "the old focus must be re-blitted dimmed")
115+ require.Equal(t, focusedStyle, out.frame.At(21, 1).Style, "the new focus must be re-blitted undimmed")
116+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
117+
118+ vertical := cachedSplitState("vertical-right", "right", layout.Vertical, theme)
119+ vertical.reset = false
120+ vertical.panes[0].damage, vertical.panes[1].damage = nil, nil
121+ out = composeFrame(vertical, out.cache, committed.cache)
122+ require.NotEqual(t, '│', out.frame.At(20, 1).Rune, "layout invalidation must clear the old vertical divider")
123+ require.Equal(t, '─', out.frame.At(20, 3).Rune)
124+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
125+}
126+
127+func TestComposeFrameCacheRefreshesStackTitlesAndBars(t *testing.T) {
128+ initial := cachedStackTitleState("one", 1, true)
129+ committed := composeFrame(initial, composeCacheInput{})
130+
131+ renamed := initial
132+ renamed.reset = false
133+ renamed.panes[0].title, renamed.panes[0].titleGeneration = "renamed", 2
134+ out := composeFrame(renamed, committed.cache, composeCacheInput{})
135+ require.Equal(t, "renamed", rowText(out.frame.Row(1))[:7])
136+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
137+
138+ unchanged := composeFrame(renamed, out.cache, committed.cache)
139+ require.NotContains(t, unchanged.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
140+
141+ bars := cacheBarState("one", true)
142+ committed = composeFrame(bars, composeCacheInput{})
143+ bars.reset = false
144+ bars.panes[0].damage = []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}
145+ out = composeFrame(bars, committed.cache, composeCacheInput{})
146+ for _, damage := range out.damage {
147+ require.NotEqual(t, 0, damage.Y, "unchanged top bar must remain cached")
148+ require.NotEqual(t, 2, damage.Y, "unchanged bottom bar must remain cached")
149+ }
150+ bars.panes[0].damage = nil
151+ bars.bars.bottomRight = "two"
152+ out = composeFrame(bars, out.cache, committed.cache)
153+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 2, Width: 6, Height: 1})
154+ require.NotContains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 6, Height: 1})
155+}
156+
157+func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
158+ state := cachedStackTitleState("title", 1, true)
159+ committed, scratch := composeCacheInput{}, composeCacheInput{}
160+ compose := func() {
161+ out := composeFrame(state, committed, scratch)
162+ scratch, committed = committed, out.cache
163+ state.reset = false
164+ }
165+ compose()
166+ compose()
167+ compose()
168+
169+ allocs := testing.AllocsPerRun(100, compose)
170+ require.Zero(t, allocs, "warm production title composition must reuse the committed and scratch cache backing storage")
171+}
172+
173+func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {
174+ left, right := layout.PaneID("left"), layout.PaneID("right")
175+ placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}}
176+ leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')
177+ if direction == layout.Vertical {
178+ placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}}
179+ leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')
180+ }
181+ return capturedRenderState{
182+ reset: true,
183+ layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},
184+ 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()}}},
185+ theme: theme,
186+ }
187+}
188+
189+func cachedStackTitleState(title string, generation uint64, reset bool) capturedRenderState {
190+ first, second := layout.PaneID("first"), layout.PaneID("second")
191+ placements := []layout.Placement{{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true}, {ID: second, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Collapsed: true}}
192+ return capturedRenderState{
193+ reset: reset,
194+ layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
195+ panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, title: "second", titleGeneration: 1, placement: placements[1], focused: true}},
196+ }
197+}
198+
199+func cacheBarState(bottom string, reset bool) capturedRenderState {
200+ pane := cachePaneFrame(6, 1, 'P')
201+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
202+ 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}}
203+}
204+
205+func cachePaneFrame(width, height int, r rune) renderer.Frame {
206+ frame := renderer.NewFrame(width, height)
207+ frame.Set(0, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
208+ return frame
209+}
210+
89211 func cacheState(title string, generation uint64) capturedRenderState {
90212 pane := renderer.NewFrame(6, 1)
91213 for x, r := range title[:min(len(title), 6)] {
@@ -106,9 +228,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput {
106228 out := in
107229 out.frame = in.frame.Clone()
108230 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
109- for id, generation := range in.titleGenerations {
110- out.titleGenerations[id] = generation
111- }
231+ maps.Copy(out.titleGenerations, in.titleGenerations)
112232 out.damage = append([]renderer.Damage(nil), in.damage...)
113233 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
114234 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
@@ -3,11 +3,13 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 "io"5 "io"
6+ "maps"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
12 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
13 )15 )
@@ -86,6 +88,126 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
86 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)88 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
87 }89 }
88 90
91+func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) {
92+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
93+ initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
94+ committed := composeFrame(initial, composeCacheInput{})
95+ require.Equal(t, '│', committed.frame.At(20, 1).Rune)
96+ focusedStyle := committed.frame.At(0, 1).Style
97+ dimmedStyle := committed.frame.At(21, 1).Style
98+
99+ undamaged := initial
100+ undamaged.reset = false
101+ undamaged.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'Z', Style: renderer.DefaultStyle()})
102+ undamaged.panes[0].damage = nil
103+ undamaged.panes[1].frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
104+ undamaged.panes[1].damage = []renderer.Damage{{Kind: renderer.DamageText, X: 21, Width: 1, Height: 1}}
105+ out := composeFrame(undamaged, committed.cache, composeCacheInput{})
106+ require.Equal(t, 'L', out.frame.At(0, 1).Rune, "an undamaged pane must retain the committed blit")
107+ require.Equal(t, 'x', out.frame.At(21, 1).Rune)
108+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 21, Y: 1, Width: 1, Height: 1})
109+
110+ focusChanged := cachedSplitState("horizontal-right", "right", layout.Horizontal, theme)
111+ focusChanged.reset = false
112+ focusChanged.panes[0].damage, focusChanged.panes[1].damage = nil, nil
113+ out = composeFrame(focusChanged, out.cache, committed.cache)
114+ require.Equal(t, dimmedStyle, out.frame.At(0, 1).Style, "the old focus must be re-blitted dimmed")
115+ require.Equal(t, focusedStyle, out.frame.At(21, 1).Style, "the new focus must be re-blitted undimmed")
116+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
117+
118+ vertical := cachedSplitState("vertical-right", "right", layout.Vertical, theme)
119+ vertical.reset = false
120+ vertical.panes[0].damage, vertical.panes[1].damage = nil, nil
121+ out = composeFrame(vertical, out.cache, committed.cache)
122+ require.NotEqual(t, '│', out.frame.At(20, 1).Rune, "layout invalidation must clear the old vertical divider")
123+ require.Equal(t, '─', out.frame.At(20, 3).Rune)
124+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
125+}
126+
127+func TestComposeFrameCacheRefreshesStackTitlesAndBars(t *testing.T) {
128+ initial := cachedStackTitleState("one", 1, true)
129+ committed := composeFrame(initial, composeCacheInput{})
130+
131+ renamed := initial
132+ renamed.reset = false
133+ renamed.panes[0].title, renamed.panes[0].titleGeneration = "renamed", 2
134+ out := composeFrame(renamed, committed.cache, composeCacheInput{})
135+ require.Equal(t, "renamed", rowText(out.frame.Row(1))[:7])
136+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
137+
138+ unchanged := composeFrame(renamed, out.cache, committed.cache)
139+ require.NotContains(t, unchanged.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
140+
141+ bars := cacheBarState("one", true)
142+ committed = composeFrame(bars, composeCacheInput{})
143+ bars.reset = false
144+ bars.panes[0].damage = []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}
145+ out = composeFrame(bars, committed.cache, composeCacheInput{})
146+ for _, damage := range out.damage {
147+ require.NotEqual(t, 0, damage.Y, "unchanged top bar must remain cached")
148+ require.NotEqual(t, 2, damage.Y, "unchanged bottom bar must remain cached")
149+ }
150+ bars.panes[0].damage = nil
151+ bars.bars.bottomRight = "two"
152+ out = composeFrame(bars, out.cache, committed.cache)
153+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 2, Width: 6, Height: 1})
154+ require.NotContains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 6, Height: 1})
155+}
156+
157+func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
158+ state := cachedStackTitleState("title", 1, true)
159+ committed, scratch := composeCacheInput{}, composeCacheInput{}
160+ compose := func() {
161+ out := composeFrame(state, committed, scratch)
162+ scratch, committed = committed, out.cache
163+ state.reset = false
164+ }
165+ compose()
166+ compose()
167+ compose()
168+
169+ allocs := testing.AllocsPerRun(100, compose)
170+ require.Zero(t, allocs, "warm production title composition must reuse the committed and scratch cache backing storage")
171+}
172+
173+func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {
174+ left, right := layout.PaneID("left"), layout.PaneID("right")
175+ placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}}
176+ leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')
177+ if direction == layout.Vertical {
178+ placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}}
179+ leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')
180+ }
181+ return capturedRenderState{
182+ reset: true,
183+ layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},
184+ 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()}}},
185+ theme: theme,
186+ }
187+}
188+
189+func cachedStackTitleState(title string, generation uint64, reset bool) capturedRenderState {
190+ first, second := layout.PaneID("first"), layout.PaneID("second")
191+ placements := []layout.Placement{{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true}, {ID: second, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Collapsed: true}}
192+ return capturedRenderState{
193+ reset: reset,
194+ layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
195+ panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, title: "second", titleGeneration: 1, placement: placements[1], focused: true}},
196+ }
197+}
198+
199+func cacheBarState(bottom string, reset bool) capturedRenderState {
200+ pane := cachePaneFrame(6, 1, 'P')
201+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
202+ 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}}
203+}
204+
205+func cachePaneFrame(width, height int, r rune) renderer.Frame {
206+ frame := renderer.NewFrame(width, height)
207+ frame.Set(0, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
208+ return frame
209+}
210+
89 func cacheState(title string, generation uint64) capturedRenderState {211 func cacheState(title string, generation uint64) capturedRenderState {
90 pane := renderer.NewFrame(6, 1)212 pane := renderer.NewFrame(6, 1)
91 for x, r := range title[:min(len(title), 6)] {213 for x, r := range title[:min(len(title), 6)] {
@@ -106,9 +228,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput {
106 out := in228 out := in
107 out.frame = in.frame.Clone()229 out.frame = in.frame.Clone()
108 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))230 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
109- for id, generation := range in.titleGenerations {231+ maps.Copy(out.titleGenerations, in.titleGenerations)
110- out.titleGenerations[id] = generation
111- }
112 out.damage = append([]renderer.Damage(nil), in.damage...)232 out.damage = append([]renderer.Damage(nil), in.damage...)
113 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)233 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
114 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)234 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)

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