detached dc9a821

fix(daemon): reuse bounded render snapshots

commit
dc9a821d361977e86dd8bb068d4c505d9f26704a
author
brice · · 2026-07-12 05:52 UTC
parent
f9d041e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +45 -29
M internal/usecase/daemon/client.go +6 -4
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
1616 "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
1718 "github.com/bnema/vev/internal/usecase/mouse"
1819 themeui "github.com/bnema/vev/internal/usecase/theme"
1920 )
@@ -35,10 +36,11 @@ type attachedClient struct {
3536 // exact coordinator incarnation. paint rechecks it under sendMu.
3637 coordinatorReadyEpoch atomic.Uint64
3738 echoAck atomic.Uint64
38- pipelineCache composeCacheInput // only touched while sendMu is held
39- resizePaint pendingByteTimer // guarded by sendMu
40- resizePaintGeneration uint64 // guarded by sendMu
41- resizePaintPending bool // guarded by sendMu
39+ pipelineCache composeCacheInput // only touched while sendMu is held
40+ captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
41+ resizePaint pendingByteTimer // guarded by sendMu
42+ resizePaintGeneration uint64 // guarded by sendMu
43+ resizePaintPending bool // guarded by sendMu
4244 size domain.Size
4345 keys *keys.Router
4446 sess Guarded[*session]
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/usecase/keys"16 "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
17 "github.com/bnema/vev/internal/usecase/mouse"18 "github.com/bnema/vev/internal/usecase/mouse"
18 themeui "github.com/bnema/vev/internal/usecase/theme"19 themeui "github.com/bnema/vev/internal/usecase/theme"
19 )20 )
@@ -35,10 +36,11 @@ type attachedClient struct {
35 // exact coordinator incarnation. paint rechecks it under sendMu.36 // exact coordinator incarnation. paint rechecks it under sendMu.
36 coordinatorReadyEpoch atomic.Uint6437 coordinatorReadyEpoch atomic.Uint64
37 echoAck atomic.Uint6438 echoAck atomic.Uint64
38- pipelineCache composeCacheInput // only touched while sendMu is held39+ pipelineCache composeCacheInput // only touched while sendMu is held
39- resizePaint pendingByteTimer // guarded by sendMu40+ captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
40- resizePaintGeneration uint64 // guarded by sendMu41+ resizePaint pendingByteTimer // guarded by sendMu
41- resizePaintPending bool // guarded by sendMu42+ resizePaintGeneration uint64 // guarded by sendMu
43+ resizePaintPending bool // guarded by sendMu
42 size domain.Size44 size domain.Size
43 keys *keys.Router45 keys *keys.Router
44 sess Guarded[*session]46 sess Guarded[*session]
M internal/usecase/daemon/copymode.go +0 -5
@@ -402,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool {
402402 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
403403 }
404404
405-func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
406- styleSet := resolveThemeStyles(styles)
407- return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
408-}
409-
410405 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
411406 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
412407 return frame, nil
@@ -402,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool {
402 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')402 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
403 }403 }
404 404
405-func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
406- styleSet := resolveThemeStyles(styles)
407- return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
408-}
409-
410 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {405 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
411 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {406 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
412 return frame, nil407 return frame, nil
M internal/usecase/daemon/mouse_panes.go +0 -4
@@ -64,10 +64,6 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
6464 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
6565 }
6666
67-func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool {
68- return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root)
69-}
70-
7167 func layoutFingerprint(root *layout.Node) string {
7268 var b strings.Builder
7369 writeLayoutFingerprint(&b, root)
@@ -64,10 +64,6 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
64 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}64 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
65 }65 }
66 66
67-func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool {
68- return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root)
69-}
70-
71 func layoutFingerprint(root *layout.Node) string {67 func layoutFingerprint(root *layout.Node) string {
72 var b strings.Builder68 var b strings.Builder
73 writeLayoutFingerprint(&b, root)69 writeLayoutFingerprint(&b, root)
M internal/usecase/daemon/prompt.go +0 -6
@@ -6,7 +6,6 @@ import (
66 "github.com/bnema/vev/internal/domain"
77 promptui "github.com/bnema/vev/internal/usecase/prompt"
88 "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
109 )
1110
1211 func promptModalFor(title string) ui.Modal {
@@ -95,8 +94,3 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
9594 d.invalidateRender(sess, ac, true, "prompt.go")
9695 }
9796 }
98-
99-func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
100- styleSet := resolveThemeStyles(styles)
101- return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render)
102-}
@@ -6,7 +6,6 @@ import (
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 promptui "github.com/bnema/vev/internal/usecase/prompt"7 promptui "github.com/bnema/vev/internal/usecase/prompt"
8 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
10 )9 )
11 10
12 func promptModalFor(title string) ui.Modal {11 func promptModalFor(title string) ui.Modal {
@@ -95,8 +94,3 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
95 d.invalidateRender(sess, ac, true, "prompt.go")94 d.invalidateRender(sess, ac, true, "prompt.go")
96 }95 }
97 }96 }
98-
99-func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
100- styleSet := resolveThemeStyles(styles)
101- return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render)
102-}
M internal/usecase/daemon/render_capture.go +18 -5
@@ -74,7 +74,6 @@ type capturedOverlayRenderState struct {
7474 copyMode *scopy.Mode
7575 copySnapshot *scopy.Snapshot
7676 copyPaneID layout.PaneID
77- copyTarget domain.Rect
7877 copyFeedback string
7978 paletteGuidance string
8079 copySearch, picker, palette, prompt capturedModal
@@ -110,11 +109,17 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageC
110109
111110 width := min(max(visible.Width, 0), p.screen.Frame.Width)
112111 height := min(max(visible.Height, 0), p.screen.Frame.Height)
112+ needsFrame := len(damage.Damage) > 0
113113 if out.frame.Width != width || out.frame.Height != height {
114114 out.frame = renderer.NewFrame(width, height)
115+ needsFrame = true
115116 }
116- for y := range height {
117- copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
117+ // A retained attachment snapshot is already an immutable copy of this
118+ // pane. With no VT damage, retain it rather than copying inactive panes.
119+ if needsFrame {
120+ for y := range height {
121+ copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
122+ }
118123 }
119124
120125 if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
@@ -195,7 +200,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
195200 visible = domain.Rect{}
196201 }
197202 p.mu.Lock()
198- captured := capturePaneRenderStateLocked(p, visible, mode)
203+ if ac.captureFrames == nil {
204+ ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
205+ }
206+ captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
199207 captured.placement = placement
200208 captured.focused = placement.ID == layoutSnap.focus
201209 if captured.focused {
@@ -207,13 +215,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
207215 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
208216 }
209217 captured.damage = translated
218+ ac.captureFrames[captured.id] = captured
210219 state.panes = append(state.panes, captured)
211220 }
212221 if tb.floating.state == floatingVisible && tb.floating.pane != nil {
213222 p := tb.floating.pane
214223 p.mu.Lock()
215224 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
216- captured := capturePaneRenderStateLocked(p, geometry.Inner, mode)
225+ if ac.captureFrames == nil {
226+ ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
227+ }
228+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
229+ ac.captureFrames[captured.id] = captured
217230 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
218231 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
219232 p.mu.Unlock()
@@ -74,7 +74,6 @@ type capturedOverlayRenderState struct {
74 copyMode *scopy.Mode74 copyMode *scopy.Mode
75 copySnapshot *scopy.Snapshot75 copySnapshot *scopy.Snapshot
76 copyPaneID layout.PaneID76 copyPaneID layout.PaneID
77- copyTarget domain.Rect
78 copyFeedback string77 copyFeedback string
79 paletteGuidance string78 paletteGuidance string
80 copySearch, picker, palette, prompt capturedModal79 copySearch, picker, palette, prompt capturedModal
@@ -110,11 +109,17 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageC
110 109
111 width := min(max(visible.Width, 0), p.screen.Frame.Width)110 width := min(max(visible.Width, 0), p.screen.Frame.Width)
112 height := min(max(visible.Height, 0), p.screen.Frame.Height)111 height := min(max(visible.Height, 0), p.screen.Frame.Height)
112+ needsFrame := len(damage.Damage) > 0
113 if out.frame.Width != width || out.frame.Height != height {113 if out.frame.Width != width || out.frame.Height != height {
114 out.frame = renderer.NewFrame(width, height)114 out.frame = renderer.NewFrame(width, height)
115+ needsFrame = true
115 }116 }
116- for y := range height {117+ // A retained attachment snapshot is already an immutable copy of this
117- copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])118+ // pane. With no VT damage, retain it rather than copying inactive panes.
119+ if needsFrame {
120+ for y := range height {
121+ copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
122+ }
118 }123 }
119 124
120 if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {125 if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
@@ -195,7 +200,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
195 visible = domain.Rect{}200 visible = domain.Rect{}
196 }201 }
197 p.mu.Lock()202 p.mu.Lock()
198- captured := capturePaneRenderStateLocked(p, visible, mode)203+ if ac.captureFrames == nil {
204+ ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
205+ }
206+ captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
199 captured.placement = placement207 captured.placement = placement
200 captured.focused = placement.ID == layoutSnap.focus208 captured.focused = placement.ID == layoutSnap.focus
201 if captured.focused {209 if captured.focused {
@@ -207,13 +215,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
207 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)215 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
208 }216 }
209 captured.damage = translated217 captured.damage = translated
218+ ac.captureFrames[captured.id] = captured
210 state.panes = append(state.panes, captured)219 state.panes = append(state.panes, captured)
211 }220 }
212 if tb.floating.state == floatingVisible && tb.floating.pane != nil {221 if tb.floating.state == floatingVisible && tb.floating.pane != nil {
213 p := tb.floating.pane222 p := tb.floating.pane
214 p.mu.Lock()223 p.mu.Lock()
215 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))224 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
216- captured := capturePaneRenderStateLocked(p, geometry.Inner, mode)225+ if ac.captureFrames == nil {
226+ ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
227+ }
228+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
229+ ac.captureFrames[captured.id] = captured
217 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}230 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
218 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)231 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
219 p.mu.Unlock()232 p.mu.Unlock()
M internal/usecase/daemon/render_pipeline.go +21 -5
@@ -25,6 +25,7 @@ type composeCacheInput struct {
2525 floatingGeometry floatingGeometry
2626 floatingTitleGeneration uint64
2727 bars barCache
28+ theme themeui.Theme
2829 }
2930
3031 type composedRenderFrame struct {
@@ -42,7 +43,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
4243 if width <= 0 || rows < 0 {
4344 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
4445 }
45- frame := renderer.NewFrame(width, rows+2)
46+ // The attachment-owned cache is safe to reuse while sendMu serializes this
47+ // transaction. It contains only prior composed cells, never VT-owned cells.
48+ // Reusing it avoids cloning a client-sized frame on every incremental paint.
49+ canReuseFrame := in.frame.Width == width && in.frame.Height == rows+2 && in.layoutFingerprint == state.layout.fingerprint
50+ frame := in.frame
51+ if !canReuseFrame {
52+ frame = renderer.NewFrame(width, rows+2)
53+ }
4654 styles := newThemeStyles(state.theme)
4755 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
4856 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
@@ -51,7 +59,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
5159 drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))
5260 }
5361
54- full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint
62+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme
5563 titles := make(map[layout.PaneID]uint64, len(state.panes))
5664 var damage []renderer.Damage
5765 for _, pane := range state.panes {
@@ -63,7 +71,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
6371 }
6472 titles[pane.id] = pane.titleGeneration
6573 }
66- if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
74+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
6775 blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)
6876 }
6977 for _, d := range pane.damage {
@@ -86,15 +94,23 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
8694 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
8795 }
8896 }
89- baseFrame := frame.Clone()
97+ var baseFrame renderer.Frame
98+ if state.overlays.active() {
99+ // Overlay composition mutates the visible frame but must never publish it
100+ // as the unadorned cache.
101+ baseFrame = frame.Clone()
102+ }
90103 frame, damage = composeCapturedOverlays(state, frame, damage, content)
104+ if !state.overlays.active() {
105+ baseFrame = frame
106+ }
91107 if full || state.overlays.active() {
92108 damage = []renderer.Damage{renderer.FullRedraw()}
93109 }
94110 cursorInputs := state.cursor
95111 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
96112 cursor := desiredCapturedCursor(cursorInputs)
97- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration}
113+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration}
98114 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
99115 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
100116 }
@@ -25,6 +25,7 @@ type composeCacheInput struct {
25 floatingGeometry floatingGeometry25 floatingGeometry floatingGeometry
26 floatingTitleGeneration uint6426 floatingTitleGeneration uint64
27 bars barCache27 bars barCache
28+ theme themeui.Theme
28 }29 }
29 30
30 type composedRenderFrame struct {31 type composedRenderFrame struct {
@@ -42,7 +43,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
42 if width <= 0 || rows < 0 {43 if width <= 0 || rows < 0 {
43 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}44 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
44 }45 }
45- frame := renderer.NewFrame(width, rows+2)46+ // The attachment-owned cache is safe to reuse while sendMu serializes this
47+ // transaction. It contains only prior composed cells, never VT-owned cells.
48+ // Reusing it avoids cloning a client-sized frame on every incremental paint.
49+ canReuseFrame := in.frame.Width == width && in.frame.Height == rows+2 && in.layoutFingerprint == state.layout.fingerprint
50+ frame := in.frame
51+ if !canReuseFrame {
52+ frame = renderer.NewFrame(width, rows+2)
53+ }
46 styles := newThemeStyles(state.theme)54 styles := newThemeStyles(state.theme)
47 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)55 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
48 drawStatusBarState(frame.Row(rows+1), state.bars, styles)56 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
@@ -51,7 +59,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
51 drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))59 drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))
52 }60 }
53 61
54- full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint62+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme
55 titles := make(map[layout.PaneID]uint64, len(state.panes))63 titles := make(map[layout.PaneID]uint64, len(state.panes))
56 var damage []renderer.Damage64 var damage []renderer.Damage
57 for _, pane := range state.panes {65 for _, pane := range state.panes {
@@ -63,7 +71,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
63 }71 }
64 titles[pane.id] = pane.titleGeneration72 titles[pane.id] = pane.titleGeneration
65 }73 }
66- if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {74+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
67 blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)75 blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)
68 }76 }
69 for _, d := range pane.damage {77 for _, d := range pane.damage {
@@ -86,15 +94,23 @@ func composeFrame(state capturedRenderState, in composeCacheInput) composedRende
86 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})94 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
87 }95 }
88 }96 }
89- baseFrame := frame.Clone()97+ var baseFrame renderer.Frame
98+ if state.overlays.active() {
99+ // Overlay composition mutates the visible frame but must never publish it
100+ // as the unadorned cache.
101+ baseFrame = frame.Clone()
102+ }
90 frame, damage = composeCapturedOverlays(state, frame, damage, content)103 frame, damage = composeCapturedOverlays(state, frame, damage, content)
104+ if !state.overlays.active() {
105+ baseFrame = frame
106+ }
91 if full || state.overlays.active() {107 if full || state.overlays.active() {
92 damage = []renderer.Damage{renderer.FullRedraw()}108 damage = []renderer.Damage{renderer.FullRedraw()}
93 }109 }
94 cursorInputs := state.cursor110 cursorInputs := state.cursor
95 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()111 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
96 cursor := desiredCapturedCursor(cursorInputs)112 cursor := desiredCapturedCursor(cursorInputs)
97- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration}113+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration}
98 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))114 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
99 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}115 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
100 }116 }

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