detached 588d04f

fix(render): scope captured frames to pane identity

commit
588d04f3da3e1c82ad9c320344804740ef689d9d
author
brice · · 2026-07-13 06:18 UTC
committer
brice · · 2026-07-13 06:18 UTC
parent
a4dfd3e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +62 -23
M internal/usecase/daemon/client.go +16 -15
@@ -14,7 +14,6 @@ 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"
1817 "github.com/bnema/vev/internal/usecase/mouse"
1918 themeui "github.com/bnema/vev/internal/usecase/theme"
2019 )
@@ -41,17 +40,19 @@ type attachedClient struct {
4140 // sendMu and must never share mutable backing storage.
4241 pipelineCache composeCacheInput
4342 pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46- size domain.Size
47- keys *keys.Router
48- sess Guarded[*session]
49- mouseScan mouse.Scanner
50- themeMu sync.Mutex
51- theme themeui.Theme
52- clientTheme themeui.Theme
53- lastCursor cursorOut
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
43+ renderScratch renderCaptureScratch // only touched while sendMu is held
44+ // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
45+ // snapshots cannot leak when an attachment switches tabs or sessions.
46+ captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
47+ size domain.Size
48+ keys *keys.Router
49+ sess Guarded[*session]
50+ mouseScan mouse.Scanner
51+ themeMu sync.Mutex
52+ theme themeui.Theme
53+ clientTheme themeui.Theme
54+ lastCursor cursorOut
55+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
5556 // previousSession is guarded independently. It is retained through temporary
5657 // setSession(nil) hand-offs and cleared only on terminal teardown.
5758 previousSession Guarded[*session]
@@ -383,9 +384,9 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache
383384 }
384385 current.sendMu.Lock()
385386 current.output.rebase()
386- // Capture snapshots are attachment-owned but pane IDs are only session-
387- // local. Discard them while the attachment is exclusively held so a pane
388- // in target cannot reuse pixels from an identically named source pane.
387+ // Capture snapshots are attachment-owned. Discard them while the attachment
388+ // is exclusively held so the transfer releases panes no longer reachable
389+ // from the target session.
389390 current.captureFrames = nil
390391 current.sendMu.Unlock()
391392 d.attachCoordinator(target, old, current, true)
@@ -14,7 +14,6 @@ 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"
18 "github.com/bnema/vev/internal/usecase/mouse"17 "github.com/bnema/vev/internal/usecase/mouse"
19 themeui "github.com/bnema/vev/internal/usecase/theme"18 themeui "github.com/bnema/vev/internal/usecase/theme"
20 )19 )
@@ -41,17 +40,19 @@ type attachedClient struct {
41 // sendMu and must never share mutable backing storage.40 // sendMu and must never share mutable backing storage.
42 pipelineCache composeCacheInput41 pipelineCache composeCacheInput
43 pipelineScratch composeCacheInput42 pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held43+ renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held44+ // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
46- size domain.Size45+ // snapshots cannot leak when an attachment switches tabs or sessions.
47- keys *keys.Router46+ captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
48- sess Guarded[*session]47+ size domain.Size
49- mouseScan mouse.Scanner48+ keys *keys.Router
50- themeMu sync.Mutex49+ sess Guarded[*session]
51- theme themeui.Theme50+ mouseScan mouse.Scanner
52- clientTheme themeui.Theme51+ themeMu sync.Mutex
53- lastCursor cursorOut52+ theme themeui.Theme
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held53+ clientTheme themeui.Theme
54+ lastCursor cursorOut
55+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
55 // previousSession is guarded independently. It is retained through temporary56 // previousSession is guarded independently. It is retained through temporary
56 // setSession(nil) hand-offs and cleared only on terminal teardown.57 // setSession(nil) hand-offs and cleared only on terminal teardown.
57 previousSession Guarded[*session]58 previousSession Guarded[*session]
@@ -383,9 +384,9 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache
383 }384 }
384 current.sendMu.Lock()385 current.sendMu.Lock()
385 current.output.rebase()386 current.output.rebase()
386- // Capture snapshots are attachment-owned but pane IDs are only session-387+ // Capture snapshots are attachment-owned. Discard them while the attachment
387- // local. Discard them while the attachment is exclusively held so a pane388+ // is exclusively held so the transfer releases panes no longer reachable
388- // in target cannot reuse pixels from an identically named source pane.389+ // from the target session.
389 current.captureFrames = nil390 current.captureFrames = nil
390 current.sendMu.Unlock()391 current.sendMu.Unlock()
391 d.attachCoordinator(target, old, current, true)392 d.attachCoordinator(target, old, current, true)
M internal/usecase/daemon/input_test.go +34 -0
@@ -87,6 +87,40 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
8787 d.sessWg.Wait()
8888 }
8989
90+func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
91+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
92+ source := sess.tabs[0].focusedPane()
93+ target := sess.tabs[1].focusedPane()
94+ require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1")
95+
96+ source.mu.Lock()
97+ source.screen.Write([]byte("source"))
98+ source.mu.Unlock()
99+ d.paint(sess, ac, true)
100+ first := <-sends
101+ firstOutput, err := ports.UnmarshalOutput(first.Payload)
102+ require.NoError(t, err)
103+ terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows)
104+ terminal.Write(firstOutput.Data)
105+
106+ // A clean pane relies on the attachment capture cache for its retained
107+ // snapshot. Both tabs deliberately use the tab-local default pane ID.
108+ target.mu.Lock()
109+ target.screen.ClearDamage()
110+ target.mu.Unlock()
111+ // Use the real key action: it requests the mandatory complete repaint for
112+ // the first target-tab frame.
113+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
114+ require.Equal(t, 1, activeTabIndex(sess))
115+
116+ second := <-sends
117+ secondOutput, err := ports.UnmarshalOutput(second.Payload)
118+ require.NoError(t, err)
119+ require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first")
120+ terminal.Write(secondOutput.Data)
121+ require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
122+}
123+
90124 func TestAltCForwardsToPTY(t *testing.T) {
91125 writes := make(chan []byte, 2)
92126 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -87,6 +87,40 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
87 d.sessWg.Wait()87 d.sessWg.Wait()
88 }88 }
89 89
90+func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
91+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
92+ source := sess.tabs[0].focusedPane()
93+ target := sess.tabs[1].focusedPane()
94+ require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1")
95+
96+ source.mu.Lock()
97+ source.screen.Write([]byte("source"))
98+ source.mu.Unlock()
99+ d.paint(sess, ac, true)
100+ first := <-sends
101+ firstOutput, err := ports.UnmarshalOutput(first.Payload)
102+ require.NoError(t, err)
103+ terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows)
104+ terminal.Write(firstOutput.Data)
105+
106+ // A clean pane relies on the attachment capture cache for its retained
107+ // snapshot. Both tabs deliberately use the tab-local default pane ID.
108+ target.mu.Lock()
109+ target.screen.ClearDamage()
110+ target.mu.Unlock()
111+ // Use the real key action: it requests the mandatory complete repaint for
112+ // the first target-tab frame.
113+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
114+ require.Equal(t, 1, activeTabIndex(sess))
115+
116+ second := <-sends
117+ secondOutput, err := ports.UnmarshalOutput(second.Payload)
118+ require.NoError(t, err)
119+ require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first")
120+ terminal.Write(secondOutput.Data)
121+ require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
122+}
123+
90 func TestAltCForwardsToPTY(t *testing.T) {124 func TestAltCForwardsToPTY(t *testing.T) {
91 writes := make(chan []byte, 2)125 writes := make(chan []byte, 2)
92 p, releasePTY := newBlockingPTYWithWrites(t, writes)126 p, releasePTY := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/render_capture.go +12 -8
@@ -132,7 +132,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
132132 width := min(max(visible.Width, 0), p.screen.Frame.Width)
133133 height := min(max(visible.Height, 0), p.screen.Frame.Height)
134134 needsFrame := len(damage.Damage) > 0
135- if out.frame.Width != width || out.frame.Height != height {
135+ frameChanged := out.frame.Width != width || out.frame.Height != height
136+ if frameChanged {
136137 out.frame = renderer.NewFrame(width, height)
137138 needsFrame = true
138139 }
@@ -144,7 +145,10 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
144145 }
145146 }
146147
147- if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
148+ if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
149+ // A cache entry with a new frame has no terminal-shadow equivalent.
150+ // Redraw it even when the pane has no VT damage, such as an untouched
151+ // pane first made visible after a tab or session switch.
148152 out.damage = []renderer.Damage{renderer.FullRedraw()}
149153 } else {
150154 out.damage = append(out.damage[:0], damage.Damage...)
@@ -232,9 +236,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
232236 }
233237 p.mu.Lock()
234238 if ac.captureFrames == nil {
235- ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
239+ ac.captureFrames = make(map[*pane]capturedPaneRenderState)
236240 }
237- captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
241+ captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p])
238242 if mode == damageCaptureConsume {
239243 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240244 }
@@ -248,7 +252,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
248252 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
249253 }
250254 captured.rawDamage, captured.damage = captured.damage, translated
251- ac.captureFrames[captured.id] = captured
255+ ac.captureFrames[p] = captured
252256 state.panes = append(state.panes, captured)
253257 }
254258 scratch.panes = state.panes
@@ -257,9 +261,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
257261 p.mu.Lock()
258262 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
259263 if ac.captureFrames == nil {
260- ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
264+ ac.captureFrames = make(map[*pane]capturedPaneRenderState)
261265 }
262- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
266+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p])
263267 if mode == damageCaptureConsume {
264268 seen := false
265269 for _, receipt := range state.receipts {
@@ -272,7 +276,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
272276 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273277 }
274278 }
275- ac.captureFrames[captured.id] = captured
279+ ac.captureFrames[p] = captured
276280 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
277281 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
278282 p.mu.Unlock()
@@ -132,7 +132,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
132 width := min(max(visible.Width, 0), p.screen.Frame.Width)132 width := min(max(visible.Width, 0), p.screen.Frame.Width)
133 height := min(max(visible.Height, 0), p.screen.Frame.Height)133 height := min(max(visible.Height, 0), p.screen.Frame.Height)
134 needsFrame := len(damage.Damage) > 0134 needsFrame := len(damage.Damage) > 0
135- if out.frame.Width != width || out.frame.Height != height {135+ frameChanged := out.frame.Width != width || out.frame.Height != height
136+ if frameChanged {
136 out.frame = renderer.NewFrame(width, height)137 out.frame = renderer.NewFrame(width, height)
137 needsFrame = true138 needsFrame = true
138 }139 }
@@ -144,7 +145,10 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
144 }145 }
145 }146 }
146 147
147- if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {148+ if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
149+ // A cache entry with a new frame has no terminal-shadow equivalent.
150+ // Redraw it even when the pane has no VT damage, such as an untouched
151+ // pane first made visible after a tab or session switch.
148 out.damage = []renderer.Damage{renderer.FullRedraw()}152 out.damage = []renderer.Damage{renderer.FullRedraw()}
149 } else {153 } else {
150 out.damage = append(out.damage[:0], damage.Damage...)154 out.damage = append(out.damage[:0], damage.Damage...)
@@ -232,9 +236,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
232 }236 }
233 p.mu.Lock()237 p.mu.Lock()
234 if ac.captureFrames == nil {238 if ac.captureFrames == nil {
235- ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)239+ ac.captureFrames = make(map[*pane]capturedPaneRenderState)
236 }240 }
237- captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])241+ captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p])
238 if mode == damageCaptureConsume {242 if mode == damageCaptureConsume {
239 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})243 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240 }244 }
@@ -248,7 +252,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
248 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)252 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
249 }253 }
250 captured.rawDamage, captured.damage = captured.damage, translated254 captured.rawDamage, captured.damage = captured.damage, translated
251- ac.captureFrames[captured.id] = captured255+ ac.captureFrames[p] = captured
252 state.panes = append(state.panes, captured)256 state.panes = append(state.panes, captured)
253 }257 }
254 scratch.panes = state.panes258 scratch.panes = state.panes
@@ -257,9 +261,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
257 p.mu.Lock()261 p.mu.Lock()
258 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))262 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
259 if ac.captureFrames == nil {263 if ac.captureFrames == nil {
260- ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)264+ ac.captureFrames = make(map[*pane]capturedPaneRenderState)
261 }265 }
262- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])266+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p])
263 if mode == damageCaptureConsume {267 if mode == damageCaptureConsume {
264 seen := false268 seen := false
265 for _, receipt := range state.receipts {269 for _, receipt := range state.receipts {
@@ -272,7 +276,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
272 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})276 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273 }277 }
274 }278 }
275- ac.captureFrames[captured.id] = captured279+ ac.captureFrames[p] = captured
276 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}280 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
277 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)281 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
278 p.mu.Unlock()282 p.mu.Unlock()

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