detached 0a4727b

fix(daemon): clear non-renderable pane damage

commit
0a4727b69953de163cbc5e800b036ecc38fd0e3a
author
brice · · 2026-07-11 18:38 UTC
committer
brice · · 2026-07-11 18:39 UTC
parent
a930cfb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +77 -7
M internal/usecase/daemon/render.go +74 -1
@@ -30,6 +30,78 @@ func signal(ch chan struct{}) {
3030 }
3131 }
3232
33+// clearNonRenderablePaneDamage is the single transitional S1 owner for VT
34+// damage that cannot reach a composition target. S2 replaces this visibility
35+// decision with pipeline ownership; do not paint or mutate renderer shadows
36+// here.
37+func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool {
38+ if sess == nil || tb == nil || p == nil {
39+ return false
40+ }
41+
42+ // A picker may render an otherwise headless/inactive target tab into its
43+ // attached owner's preview. Keep that source damage until the target
44+ // coordinator has supplied the preview wake.
45+ preview := d.tabIsPickerPreview(tb)
46+
47+ sess.mu.Lock()
48+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
49+ attached := sess.client != nil
50+ sess.mu.Unlock()
51+
52+ tb.mu.Lock()
53+ renderable := false
54+ if tb.floating.pane == p {
55+ renderable = active && attached && tb.floating.state == floatingVisible
56+ } else if (active && attached) || preview {
57+ if placements, ok := solvedPlacementsLocked(tb); ok {
58+ for _, placement := range placements {
59+ if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
60+ renderable = true
61+ break
62+ }
63+ }
64+ }
65+ }
66+ if !renderable {
67+ p.mu.Lock()
68+ p.screen.ClearDamage()
69+ p.mu.Unlock()
70+ }
71+ tb.mu.Unlock()
72+ return renderable
73+}
74+
75+// tabIsPickerPreview reports whether any attached client currently composes
76+// tb as a picker preview. It snapshots daemon ownership before taking overlay
77+// locks, preserving the Daemon -> session -> tab -> pane lock order.
78+func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
79+ if d == nil || tb == nil {
80+ return false
81+ }
82+ d.mu.Lock()
83+ sessions := make([]*session, 0, len(d.sessions))
84+ for _, sess := range d.sessions {
85+ sessions = append(sessions, sess)
86+ }
87+ d.mu.Unlock()
88+ for _, sess := range sessions {
89+ sess.mu.Lock()
90+ ac := sess.client
91+ sess.mu.Unlock()
92+ if ac == nil || ac.overlays == nil {
93+ continue
94+ }
95+ ac.overlays.pickerMu.Lock()
96+ preview := ac.overlays.pickerPreview == tb
97+ ac.overlays.pickerMu.Unlock()
98+ if preview {
99+ return true
100+ }
101+ }
102+ return false
103+}
104+
33105 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
34106 defer d.sessWg.Done()
35107 if p == nil {
@@ -66,6 +138,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
66138 }
67139 }
68140 p.mu.Unlock()
141+ renderable := d.clearNonRenderablePaneDamage(sess, tb, p)
69142 markSnapshotDirty(sess)
70143 select {
71144 case <-attentionCh:
@@ -84,7 +157,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
84157 }
85158 resp = resp[:0]
86159 }
87- if rc := sess.renderCoordinator(); rc != nil {
160+ if rc := sess.renderCoordinator(); renderable && rc != nil {
88161 if wasSyncing != isSyncing {
89162 gen := syncGen
90163 if isSyncing {
@@ -30,6 +30,78 @@ func signal(ch chan struct{}) {
30 }30 }
31 }31 }
32 32
33+// clearNonRenderablePaneDamage is the single transitional S1 owner for VT
34+// damage that cannot reach a composition target. S2 replaces this visibility
35+// decision with pipeline ownership; do not paint or mutate renderer shadows
36+// here.
37+func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool {
38+ if sess == nil || tb == nil || p == nil {
39+ return false
40+ }
41+
42+ // A picker may render an otherwise headless/inactive target tab into its
43+ // attached owner's preview. Keep that source damage until the target
44+ // coordinator has supplied the preview wake.
45+ preview := d.tabIsPickerPreview(tb)
46+
47+ sess.mu.Lock()
48+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
49+ attached := sess.client != nil
50+ sess.mu.Unlock()
51+
52+ tb.mu.Lock()
53+ renderable := false
54+ if tb.floating.pane == p {
55+ renderable = active && attached && tb.floating.state == floatingVisible
56+ } else if (active && attached) || preview {
57+ if placements, ok := solvedPlacementsLocked(tb); ok {
58+ for _, placement := range placements {
59+ if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
60+ renderable = true
61+ break
62+ }
63+ }
64+ }
65+ }
66+ if !renderable {
67+ p.mu.Lock()
68+ p.screen.ClearDamage()
69+ p.mu.Unlock()
70+ }
71+ tb.mu.Unlock()
72+ return renderable
73+}
74+
75+// tabIsPickerPreview reports whether any attached client currently composes
76+// tb as a picker preview. It snapshots daemon ownership before taking overlay
77+// locks, preserving the Daemon -> session -> tab -> pane lock order.
78+func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
79+ if d == nil || tb == nil {
80+ return false
81+ }
82+ d.mu.Lock()
83+ sessions := make([]*session, 0, len(d.sessions))
84+ for _, sess := range d.sessions {
85+ sessions = append(sessions, sess)
86+ }
87+ d.mu.Unlock()
88+ for _, sess := range sessions {
89+ sess.mu.Lock()
90+ ac := sess.client
91+ sess.mu.Unlock()
92+ if ac == nil || ac.overlays == nil {
93+ continue
94+ }
95+ ac.overlays.pickerMu.Lock()
96+ preview := ac.overlays.pickerPreview == tb
97+ ac.overlays.pickerMu.Unlock()
98+ if preview {
99+ return true
100+ }
101+ }
102+ return false
103+}
104+
33 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {105 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
34 defer d.sessWg.Done()106 defer d.sessWg.Done()
35 if p == nil {107 if p == nil {
@@ -66,6 +138,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
66 }138 }
67 }139 }
68 p.mu.Unlock()140 p.mu.Unlock()
141+ renderable := d.clearNonRenderablePaneDamage(sess, tb, p)
69 markSnapshotDirty(sess)142 markSnapshotDirty(sess)
70 select {143 select {
71 case <-attentionCh:144 case <-attentionCh:
@@ -84,7 +157,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
84 }157 }
85 resp = resp[:0]158 resp = resp[:0]
86 }159 }
87- if rc := sess.renderCoordinator(); rc != nil {160+ if rc := sess.renderCoordinator(); renderable && rc != nil {
88 if wasSyncing != isSyncing {161 if wasSyncing != isSyncing {
89 gen := syncGen162 gen := syncGen
90 if isSyncing {163 if isSyncing {
M internal/usecase/daemon/render_test.go +3 -6
@@ -133,12 +133,9 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
133133 require.NotSame(t, other, tb)
134134 }},
135135 {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
136- collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23})
137- tb.panes[collapsed.id] = collapsed
138- tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id), layout.NewLeaf(collapsed.id)}, Expanded: p.id}, Focus: p.id}
139- // Exercise the non-expanded pane rather than the original visible one.
140- p.id = collapsed.id
141- tb.panes[collapsed.id] = p
136+ // The expanded leaf is deliberately absent from panes: p is the
137+ // inactive stack leaf whose content is not composited.
138+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("visible"), layout.NewLeaf(p.id)}, Expanded: "visible"}, Focus: "visible"}
142139 }},
143140 {name: "hidden floating", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
144141 tb.floating = floatingSlot{state: floatingHidden, pane: p, generation: 1}
@@ -133,12 +133,9 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
133 require.NotSame(t, other, tb)133 require.NotSame(t, other, tb)
134 }},134 }},
135 {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {135 {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
136- collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23})136+ // The expanded leaf is deliberately absent from panes: p is the
137- tb.panes[collapsed.id] = collapsed137+ // inactive stack leaf whose content is not composited.
138- tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id), layout.NewLeaf(collapsed.id)}, Expanded: p.id}, Focus: p.id}138+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("visible"), layout.NewLeaf(p.id)}, Expanded: "visible"}, Focus: "visible"}
139- // Exercise the non-expanded pane rather than the original visible one.
140- p.id = collapsed.id
141- tb.panes[collapsed.id] = p
142 }},139 }},
143 {name: "hidden floating", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {140 {name: "hidden floating", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
144 tb.floating = floatingSlot{state: floatingHidden, pane: p, generation: 1}141 tb.floating = floatingSlot{state: floatingHidden, pane: p, generation: 1}

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