detached cbdec07

fix(daemon): isolate committed render cache

commit
cbdec07343f8cd623f73ab7f46c42b5f11585653
author
brice · · 2026-07-12 06:49 UTC
parent
bac7c66
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +132 -13
M internal/usecase/daemon/client.go +5 -1
@@ -36,7 +36,11 @@ type attachedClient struct {
3636 // exact coordinator incarnation. paint rechecks it under sendMu.
3737 coordinatorReadyEpoch atomic.Uint64
3838 echoAck atomic.Uint64
39- pipelineCache composeCacheInput // only touched while sendMu is held
39+ // pipelineCache is the last successfully emitted composition. pipelineScratch
40+ // is its attachment-owned alternate buffer; both are only touched under
41+ // sendMu and must never share mutable backing storage.
42+ pipelineCache composeCacheInput
43+ pipelineScratch composeCacheInput
4044 renderScratch renderCaptureScratch // only touched while sendMu is held
4145 captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
4246 resizePaint pendingByteTimer // guarded by sendMu
@@ -36,7 +36,11 @@ type attachedClient struct {
36 // exact coordinator incarnation. paint rechecks it under sendMu.36 // exact coordinator incarnation. paint rechecks it under sendMu.
37 coordinatorReadyEpoch atomic.Uint6437 coordinatorReadyEpoch atomic.Uint64
38 echoAck atomic.Uint6438 echoAck atomic.Uint64
39- pipelineCache composeCacheInput // only touched while sendMu is held39+ // pipelineCache is the last successfully emitted composition. pipelineScratch
40+ // is its attachment-owned alternate buffer; both are only touched under
41+ // sendMu and must never share mutable backing storage.
42+ pipelineCache composeCacheInput
43+ pipelineScratch composeCacheInput
40 renderScratch renderCaptureScratch // only touched while sendMu is held44 renderScratch renderCaptureScratch // only touched while sendMu is held
41 captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held45 captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
42 resizePaint pendingByteTimer // guarded by sendMu46 resizePaint pendingByteTimer // guarded by sendMu
M internal/usecase/daemon/picker.go +1 -1
@@ -540,7 +540,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
540540 }
541541
542542 func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
543- composed := composeFrame(state, composeCacheInput{}).frame
543+ composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
544544 if composed.Height < 2 {
545545 return pickerPreviewFromFrame(composed)
546546 }
@@ -540,7 +540,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
540 }540 }
541 541
542 func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {542 func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
543- composed := composeFrame(state, composeCacheInput{}).frame543+ composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
544 if composed.Height < 2 {544 if composed.Height < 2 {
545 return pickerPreviewFromFrame(composed)545 return pickerPreviewFromFrame(composed)
546 }546 }
M internal/usecase/daemon/render.go +1 -1
@@ -328,7 +328,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
328328 state.preview = pickerPreviewFromCapturedRender(previewState)
329329 }
330330 captureOverlayLayers(state, overlays, paletteCfg)
331- composed := composeFrame(*state, ac.pipelineCache)
331+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
332332 d.emitFrame(sess, ac, state, composed)
333333 }
334334
@@ -328,7 +328,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
328 state.preview = pickerPreviewFromCapturedRender(previewState)328 state.preview = pickerPreviewFromCapturedRender(previewState)
329 }329 }
330 captureOverlayLayers(state, overlays, paletteCfg)330 captureOverlayLayers(state, overlays, paletteCfg)
331- composed := composeFrame(*state, ac.pipelineCache)331+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
332 d.emitFrame(sess, ac, state, composed)332 d.emitFrame(sess, ac, state, composed)
333 }333 }
334 334
M internal/usecase/daemon/render_pipeline.go +22 -10
@@ -38,20 +38,29 @@ type composedRenderFrame struct {
3838 }
3939
4040 // composeFrame is pure with respect to daemon ownership: it consumes only the
41-// capture and an attachment-local cache value, and returns a replacement cache.
42-func composeFrame(state capturedRenderState, in composeCacheInput) composedRenderFrame {
41+// capture, the last committed cache, and an independent attachment-owned
42+// scratch cache. It returns a replacement cache without mutating committed.
43+func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
44+ scratch := composeCacheInput{}
45+ if len(scratchIn) > 0 {
46+ scratch = scratchIn[0]
47+ }
4348 width, rows := state.layout.area.Width, state.layout.area.Height
4449 if width <= 0 || rows < 0 {
4550 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
4651 }
47- // The attachment-owned cache is safe to reuse while sendMu serializes this
48- // transaction. It contains only prior composed cells, never VT-owned cells.
49- // Reusing it avoids cloning a client-sized frame on every incremental paint.
50- canReuseFrame := in.frame.Width == width && in.frame.Height == rows+2 && in.layoutFingerprint == state.layout.fingerprint
51- frame := in.frame
52+ // Compose into the alternate attachment-owned buffer. The committed frame is
53+ // copied before incremental drawing so prepare/send failures cannot change it.
54+ canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2
55+ frame := scratch.frame
5256 if !canReuseFrame {
5357 frame = renderer.NewFrame(width, rows+2)
5458 }
59+ if in.frame.Width == width && in.frame.Height == rows+2 {
60+ for y := 0; y < frame.Height; y++ {
61+ copy(frame.Row(y), in.frame.Row(y))
62+ }
63+ }
5564 styles := newThemeStyles(state.theme)
5665 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
5766 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
@@ -61,11 +70,11 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
6170 }
6271
6372 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme
64- titles := in.titleGenerations
73+ titles := scratch.titleGenerations
6574 if titles == nil {
6675 titles = make(map[layout.PaneID]uint64, len(state.panes))
6776 }
68- damage := in.damage[:0]
77+ damage := scratch.damage[:0]
6978 for _, pane := range state.panes {
7079 pl := offsetPlacement(pane.placement, 0, 1)
7180 if pl.TitleBar.Height > 0 {
@@ -114,7 +123,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
114123 cursorInputs := state.cursor
115124 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
116125 cursor := desiredCapturedCursor(cursorInputs)
117- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: in.bars}
126+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
118127 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
119128 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
120129 }
@@ -292,6 +301,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
292301 }
293302 }
294303 if sendErr == nil {
304+ // Publish only after output preparation and transport emission both
305+ // succeed. Swap the old committed cache back as the next compose target.
306+ ac.pipelineScratch = ac.pipelineCache
295307 ac.pipelineCache = composed.cache
296308 }
297309 ac.sess.mu.Unlock()
@@ -38,20 +38,29 @@ type composedRenderFrame struct {
38 }38 }
39 39
40 // composeFrame is pure with respect to daemon ownership: it consumes only the40 // composeFrame is pure with respect to daemon ownership: it consumes only the
41-// capture and an attachment-local cache value, and returns a replacement cache.41+// capture, the last committed cache, and an independent attachment-owned
42-func composeFrame(state capturedRenderState, in composeCacheInput) composedRenderFrame {42+// scratch cache. It returns a replacement cache without mutating committed.
43+func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
44+ scratch := composeCacheInput{}
45+ if len(scratchIn) > 0 {
46+ scratch = scratchIn[0]
47+ }
43 width, rows := state.layout.area.Width, state.layout.area.Height48 width, rows := state.layout.area.Width, state.layout.area.Height
44 if width <= 0 || rows < 0 {49 if width <= 0 || rows < 0 {
45 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}50 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
46 }51 }
47- // The attachment-owned cache is safe to reuse while sendMu serializes this52+ // Compose into the alternate attachment-owned buffer. The committed frame is
48- // transaction. It contains only prior composed cells, never VT-owned cells.53+ // copied before incremental drawing so prepare/send failures cannot change it.
49- // Reusing it avoids cloning a client-sized frame on every incremental paint.54+ canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2
50- canReuseFrame := in.frame.Width == width && in.frame.Height == rows+2 && in.layoutFingerprint == state.layout.fingerprint55+ frame := scratch.frame
51- frame := in.frame
52 if !canReuseFrame {56 if !canReuseFrame {
53 frame = renderer.NewFrame(width, rows+2)57 frame = renderer.NewFrame(width, rows+2)
54 }58 }
59+ if in.frame.Width == width && in.frame.Height == rows+2 {
60+ for y := 0; y < frame.Height; y++ {
61+ copy(frame.Row(y), in.frame.Row(y))
62+ }
63+ }
55 styles := newThemeStyles(state.theme)64 styles := newThemeStyles(state.theme)
56 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)65 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
57 drawStatusBarState(frame.Row(rows+1), state.bars, styles)66 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
@@ -61,11 +70,11 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
61 }70 }
62 71
63 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme72 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme
64- titles := in.titleGenerations73+ titles := scratch.titleGenerations
65 if titles == nil {74 if titles == nil {
66 titles = make(map[layout.PaneID]uint64, len(state.panes))75 titles = make(map[layout.PaneID]uint64, len(state.panes))
67 }76 }
68- damage := in.damage[:0]77+ damage := scratch.damage[:0]
69 for _, pane := range state.panes {78 for _, pane := range state.panes {
70 pl := offsetPlacement(pane.placement, 0, 1)79 pl := offsetPlacement(pane.placement, 0, 1)
71 if pl.TitleBar.Height > 0 {80 if pl.TitleBar.Height > 0 {
@@ -114,7 +123,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
114 cursorInputs := state.cursor123 cursorInputs := state.cursor
115 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()124 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
116 cursor := desiredCapturedCursor(cursorInputs)125 cursor := desiredCapturedCursor(cursorInputs)
117- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: in.bars}126+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
118 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))127 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
119 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}128 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
120 }129 }
@@ -292,6 +301,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
292 }301 }
293 }302 }
294 if sendErr == nil {303 if sendErr == nil {
304+ // Publish only after output preparation and transport emission both
305+ // succeed. Swap the old committed cache back as the next compose target.
306+ ac.pipelineScratch = ac.pipelineCache
295 ac.pipelineCache = composed.cache307 ac.pipelineCache = composed.cache
296 }308 }
297 ac.sess.mu.Unlock()309 ac.sess.mu.Unlock()
A internal/usecase/daemon/render_pipeline_cache_test.go +103 -0
@@ -0,0 +1,103 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type cacheFailTransport struct{}
16+
17+func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
18+func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
19+func (cacheFailTransport) Close() error { return nil }
20+
21+func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
22+ for _, failure := range []struct {
23+ name string
24+ apply func(*composedRenderFrame, *attachedClient)
25+ }{
26+ {
27+ name: "prepare",
28+ apply: func(composed *composedRenderFrame, _ *attachedClient) {
29+ // Frame validation fails in output.prepare after composition.
30+ composed.frame = renderer.Frame{Width: 1}
31+ },
32+ },
33+ {
34+ name: "send",
35+ apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
36+ },
37+ } {
38+ t.Run(failure.name, func(t *testing.T) {
39+ d, sess, ac, sends := newManualSessionWithPTYs(t)
40+ healthy := ac.transport()
41+ committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
42+ ac.pipelineCache = committed.cache
43+ before := cloneComposeCache(ac.pipelineCache)
44+ pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
45+ failure.apply(&pending, ac)
46+
47+ state := cacheState("next", 2)
48+ state.attachment = ac
49+ ac.sendMu.Lock()
50+ require.True(t, d.emitFrame(sess, ac, &state, pending))
51+ require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")
52+
53+ // A send error detaches the failed link. Re-own this test attachment with
54+ // its original healthy transport, then retry the pending state.
55+ if failure.name == "send" {
56+ sess.mu.Lock()
57+ sess.client = ac
58+ sess.mu.Unlock()
59+ ac.setSession(sess)
60+ ac.replaceTransport(healthy)
61+ }
62+ pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
63+ state = cacheState("next", 2)
64+ state.attachment = ac
65+ ac.sendMu.Lock()
66+ require.True(t, d.emitFrame(sess, ac, &state, pending))
67+ require.Equal(t, pending.cache, ac.pipelineCache)
68+ frame := <-sends
69+ output, err := ports.UnmarshalOutput(frame.Payload)
70+ require.NoError(t, err)
71+ require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")
72+ })
73+ }
74+}
75+
76+func cacheState(title string, generation uint64) capturedRenderState {
77+ pane := renderer.NewFrame(6, 1)
78+ for x, r := range title[:min(len(title), 6)] {
79+ pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
80+ }
81+ floating := renderer.NewFrame(2, 1)
82+ floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
83+ return capturedRenderState{
84+ reset: false,
85+ layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
86+ panes: []capturedPaneRenderState{{id: "pane", 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()}}},
87+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{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},
88+ bars: barState{topRight: title, bottomRight: title},
89+ }
90+}
91+
92+func cloneComposeCache(in composeCacheInput) composeCacheInput {
93+ out := in
94+ out.frame = in.frame.Clone()
95+ out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
96+ for id, generation := range in.titleGenerations {
97+ out.titleGenerations[id] = generation
98+ }
99+ out.damage = append([]renderer.Damage(nil), in.damage...)
100+ out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
101+ out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
102+ return out
103+}
@@ -0,0 +1,103 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type cacheFailTransport struct{}
16+
17+func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
18+func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
19+func (cacheFailTransport) Close() error { return nil }
20+
21+func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
22+ for _, failure := range []struct {
23+ name string
24+ apply func(*composedRenderFrame, *attachedClient)
25+ }{
26+ {
27+ name: "prepare",
28+ apply: func(composed *composedRenderFrame, _ *attachedClient) {
29+ // Frame validation fails in output.prepare after composition.
30+ composed.frame = renderer.Frame{Width: 1}
31+ },
32+ },
33+ {
34+ name: "send",
35+ apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
36+ },
37+ } {
38+ t.Run(failure.name, func(t *testing.T) {
39+ d, sess, ac, sends := newManualSessionWithPTYs(t)
40+ healthy := ac.transport()
41+ committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
42+ ac.pipelineCache = committed.cache
43+ before := cloneComposeCache(ac.pipelineCache)
44+ pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
45+ failure.apply(&pending, ac)
46+
47+ state := cacheState("next", 2)
48+ state.attachment = ac
49+ ac.sendMu.Lock()
50+ require.True(t, d.emitFrame(sess, ac, &state, pending))
51+ require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")
52+
53+ // A send error detaches the failed link. Re-own this test attachment with
54+ // its original healthy transport, then retry the pending state.
55+ if failure.name == "send" {
56+ sess.mu.Lock()
57+ sess.client = ac
58+ sess.mu.Unlock()
59+ ac.setSession(sess)
60+ ac.replaceTransport(healthy)
61+ }
62+ pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
63+ state = cacheState("next", 2)
64+ state.attachment = ac
65+ ac.sendMu.Lock()
66+ require.True(t, d.emitFrame(sess, ac, &state, pending))
67+ require.Equal(t, pending.cache, ac.pipelineCache)
68+ frame := <-sends
69+ output, err := ports.UnmarshalOutput(frame.Payload)
70+ require.NoError(t, err)
71+ require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")
72+ })
73+ }
74+}
75+
76+func cacheState(title string, generation uint64) capturedRenderState {
77+ pane := renderer.NewFrame(6, 1)
78+ for x, r := range title[:min(len(title), 6)] {
79+ pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
80+ }
81+ floating := renderer.NewFrame(2, 1)
82+ floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
83+ return capturedRenderState{
84+ reset: false,
85+ layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
86+ panes: []capturedPaneRenderState{{id: "pane", 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()}}},
87+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{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},
88+ bars: barState{topRight: title, bottomRight: title},
89+ }
90+}
91+
92+func cloneComposeCache(in composeCacheInput) composeCacheInput {
93+ out := in
94+ out.frame = in.frame.Clone()
95+ out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
96+ for id, generation := range in.titleGenerations {
97+ out.titleGenerations[id] = generation
98+ }
99+ out.damage = append([]renderer.Damage(nil), in.damage...)
100+ out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
101+ out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
102+ return out
103+}

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