detached ddea50c

fix(daemon): track sync lifecycle across visibility

commit
ddea50cbf6f6aeb94422e38c0e18ce71dc030f31
author
brice · · 2026-07-11 19:12 UTC
parent
eafd950
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +118 -33
M internal/usecase/daemon/render.go +37 -29
@@ -30,45 +30,48 @@ 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 {
33+// paneRenderable reports dynamic composition visibility. Callers must not
34+// hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
35+func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
3836 if sess == nil || tb == nil || p == nil {
3937 return false
4038 }
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.
4539 preview := d.tabIsPickerPreview(tb)
46-
4740 sess.mu.Lock()
4841 active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
4942 attached := sess.client != nil
5043 sess.mu.Unlock()
51-
5244 tb.mu.Lock()
53- renderable := false
45+ defer tb.mu.Unlock()
5446 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- }
47+ return active && attached && tb.floating.state == floatingVisible
48+ }
49+ if !((active && attached) || preview) {
50+ return false
51+ }
52+ placements, ok := solvedPlacementsLocked(tb)
53+ if !ok {
54+ return false
55+ }
56+ for _, placement := range placements {
57+ if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
58+ return true
6459 }
6560 }
66- if !renderable {
61+ return false
62+}
63+
64+// clearNonRenderablePaneDamage is the single transitional S1 owner for VT
65+// damage that cannot reach a composition target. S2 replaces this visibility
66+// decision with pipeline ownership; do not paint or mutate renderer shadows
67+// here.
68+func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool {
69+ renderable := d.paneRenderable(sess, tb, p)
70+ if !renderable && p != nil {
6771 p.mu.Lock()
6872 p.screen.ClearDamage()
6973 p.mu.Unlock()
7074 }
71- tb.mu.Unlock()
7275 return renderable
7376 }
7477
@@ -157,29 +160,34 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
157160 }
158161 resp = resp[:0]
159162 }
160- if rc := sess.renderCoordinator(); renderable && rc != nil {
163+ if rc := sess.renderCoordinator(); rc != nil {
161164 if wasSyncing != isSyncing {
162165 gen := syncGen
163166 if isSyncing {
164- rc.noteSyncBegin(p, gen, func() {
167+ rc.noteSyncBeginWithRenderability(p, gen, func() bool {
168+ return d.paneRenderable(sess, tb, p)
169+ }, func() {
165170 p.mu.Lock()
166171 if p.syncGen == gen && p.screen.SyncUpdateActive() {
167172 p.screen.ForceSyncEnd()
168173 }
169174 p.mu.Unlock()
170175 })
171- // The accumulated synchronized batch is the pending render.
172- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
176+ // The accumulated synchronized batch is the pending render only
177+ // while this pane can currently reach a composition target.
178+ if renderable {
179+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
180+ }
173181 } else {
174182 rc.noteSyncEnd(p, gen)
175183 }
176184 }
177- if completeSyncRead {
185+ if completeSyncRead && renderable {
178186 // The entire batch completed in this read. Its final screen state
179187 // is ready now, so bypass the bulk debounce without arming a
180188 // watchdog for a batch that no longer exists.
181189 rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
182- } else if !isSyncing && wasSyncing == isSyncing {
190+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
183191 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
184192 }
185193 }
@@ -30,45 +30,48 @@ func signal(ch chan struct{}) {
30 }30 }
31 }31 }
32 32
33-// clearNonRenderablePaneDamage is the single transitional S1 owner for VT33+// paneRenderable reports dynamic composition visibility. Callers must not
34-// damage that cannot reach a composition target. S2 replaces this visibility34+// hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
35-// decision with pipeline ownership; do not paint or mutate renderer shadows35+func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
36-// here.
37-func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool {
38 if sess == nil || tb == nil || p == nil {36 if sess == nil || tb == nil || p == nil {
39 return false37 return false
40 }38 }
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)39 preview := d.tabIsPickerPreview(tb)
46-
47 sess.mu.Lock()40 sess.mu.Lock()
48 active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb41 active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
49 attached := sess.client != nil42 attached := sess.client != nil
50 sess.mu.Unlock()43 sess.mu.Unlock()
51-
52 tb.mu.Lock()44 tb.mu.Lock()
53- renderable := false45+ defer tb.mu.Unlock()
54 if tb.floating.pane == p {46 if tb.floating.pane == p {
55- renderable = active && attached && tb.floating.state == floatingVisible47+ return active && attached && tb.floating.state == floatingVisible
56- } else if (active && attached) || preview {48+ }
57- if placements, ok := solvedPlacementsLocked(tb); ok {49+ if !((active && attached) || preview) {
58- for _, placement := range placements {50+ return false
59- if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {51+ }
60- renderable = true52+ placements, ok := solvedPlacementsLocked(tb)
61- break53+ if !ok {
62- }54+ return false
63- }55+ }
56+ for _, placement := range placements {
57+ if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
58+ return true
64 }59 }
65 }60 }
66- if !renderable {61+ return false
62+}
63+
64+// clearNonRenderablePaneDamage is the single transitional S1 owner for VT
65+// damage that cannot reach a composition target. S2 replaces this visibility
66+// decision with pipeline ownership; do not paint or mutate renderer shadows
67+// here.
68+func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool {
69+ renderable := d.paneRenderable(sess, tb, p)
70+ if !renderable && p != nil {
67 p.mu.Lock()71 p.mu.Lock()
68 p.screen.ClearDamage()72 p.screen.ClearDamage()
69 p.mu.Unlock()73 p.mu.Unlock()
70 }74 }
71- tb.mu.Unlock()
72 return renderable75 return renderable
73 }76 }
74 77
@@ -157,29 +160,34 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
157 }160 }
158 resp = resp[:0]161 resp = resp[:0]
159 }162 }
160- if rc := sess.renderCoordinator(); renderable && rc != nil {163+ if rc := sess.renderCoordinator(); rc != nil {
161 if wasSyncing != isSyncing {164 if wasSyncing != isSyncing {
162 gen := syncGen165 gen := syncGen
163 if isSyncing {166 if isSyncing {
164- rc.noteSyncBegin(p, gen, func() {167+ rc.noteSyncBeginWithRenderability(p, gen, func() bool {
168+ return d.paneRenderable(sess, tb, p)
169+ }, func() {
165 p.mu.Lock()170 p.mu.Lock()
166 if p.syncGen == gen && p.screen.SyncUpdateActive() {171 if p.syncGen == gen && p.screen.SyncUpdateActive() {
167 p.screen.ForceSyncEnd()172 p.screen.ForceSyncEnd()
168 }173 }
169 p.mu.Unlock()174 p.mu.Unlock()
170 })175 })
171- // The accumulated synchronized batch is the pending render.176+ // The accumulated synchronized batch is the pending render only
172- rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})177+ // while this pane can currently reach a composition target.
178+ if renderable {
179+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
180+ }
173 } else {181 } else {
174 rc.noteSyncEnd(p, gen)182 rc.noteSyncEnd(p, gen)
175 }183 }
176 }184 }
177- if completeSyncRead {185+ if completeSyncRead && renderable {
178 // The entire batch completed in this read. Its final screen state186 // The entire batch completed in this read. Its final screen state
179 // is ready now, so bypass the bulk debounce without arming a187 // is ready now, so bypass the bulk debounce without arming a
180 // watchdog for a batch that no longer exists.188 // watchdog for a batch that no longer exists.
181 rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})189 rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
182- } else if !isSyncing && wasSyncing == isSyncing {190+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
183 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})191 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
184 }192 }
185 }193 }
M internal/usecase/daemon/render_coordinator.go +81 -4
@@ -76,7 +76,11 @@ type renderCoordinatorOptions struct {
7676 // ackReady reports whether the attachment may compose another output
7777 // state (the outputStateStream window has capacity).
7878 ackReady func() bool
79- // syncActive reports whether a synchronized-output batch is open.
79+ // syncRenderable reports whether a pane's synchronized batch currently
80+ // contributes to the attached composition. It must not acquire c.mu.
81+ syncRenderable func(*pane) bool
82+ // syncActive is retained for existing test harness construction; per-pane
83+ // lifecycle and syncRenderable are the coordinator gate.
8084 syncActive func() bool
8185 // onInvalidate observes every published invalidation (test-visible hook).
8286 onInvalidate func(renderInvalidation)
@@ -170,6 +174,9 @@ func (c *renderCoordinator) cancelNormalTimerLocked() {
170174
171175 type syncBatch struct {
172176 generation uint64
177+ // renderable is evaluated outside c.mu: tab/session visibility locks may be
178+ // acquired by this predicate.
179+ renderable func() bool
173180 force func()
174181 timer ports.Timer
175182 cancel chan struct{}
@@ -288,6 +295,16 @@ func (c *renderCoordinator) notifyAck() {
288295 // noteSyncBegin records a synchronized-output batch for its stable pane and
289296 // arms that pane's watchdog. Overlapping pane batches are independent.
290297 func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) {
298+ var renderable func() bool
299+ if c.opts.syncRenderable != nil {
300+ renderable = func() bool { return c.opts.syncRenderable(p) }
301+ }
302+ c.noteSyncBeginWithRenderability(p, gen, renderable, force...)
303+}
304+
305+// noteSyncBeginWithRenderability records lifecycle unconditionally while the
306+// supplied predicate decides dynamically whether this batch gates composition.
307+func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) {
291308 c.mu.Lock()
292309 if c.torndown {
293310 c.mu.Unlock()
@@ -299,7 +316,7 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func())
299316 if old := c.syncBatches[p]; old != nil {
300317 cancelSyncBatchLocked(old)
301318 }
302- batch := &syncBatch{generation: gen}
319+ batch := &syncBatch{generation: gen, renderable: renderable}
303320 if len(force) != 0 {
304321 batch.force = force[0]
305322 }
@@ -345,15 +362,27 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func())
345362 // noteSyncEnd records completion for exactly one pane batch. It only flushes
346363 // after the aggregate gate opens.
347364 func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) {
365+ // Visibility may acquire session/tab locks, so sample it before c.mu and
366+ // then verify the batch identity under c.mu.
348367 c.mu.Lock()
349368 batch := c.syncBatches[p]
350369 if batch == nil || batch.generation != gen {
351370 c.mu.Unlock()
352371 return
353372 }
373+ renderable := batch.renderable
374+ c.mu.Unlock()
375+ wasRenderable := renderable == nil || renderable()
376+
377+ c.mu.Lock()
378+ batch = c.syncBatches[p]
379+ if batch == nil || batch.generation != gen {
380+ c.mu.Unlock()
381+ return
382+ }
354383 delete(c.syncBatches, p)
355384 cancelSyncBatchLocked(batch)
356- if len(c.syncBatches) == 0 && c.pending {
385+ if wasRenderable && len(c.syncBatches) == 0 && c.pending {
357386 c.pendingUrgent = true
358387 }
359388 c.mu.Unlock()
@@ -552,6 +581,9 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
552581 }
553582 ackReady := c.opts.ackReady
554583 c.mu.Unlock()
584+ if !c.syncGateOpen() {
585+ return
586+ }
555587
556588 ready := ackReady == nil || ackReady()
557589
@@ -562,6 +594,15 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
562594 c.mu.Unlock()
563595 return
564596 }
597+ c.mu.Unlock()
598+ if !c.syncGateOpen() {
599+ return
600+ }
601+ c.mu.Lock()
602+ if !c.fireValidLocked(gen, watchdog) {
603+ c.mu.Unlock()
604+ return
605+ }
565606 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
566607 if !ready {
567608 if deadline {
@@ -596,7 +637,43 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
596637 }
597638
598639 func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool {
599- return !c.torndown && c.pending && (watchdog || gen == c.generation) && len(c.syncBatches) == 0
640+ return !c.torndown && c.pending && (watchdog || gen == c.generation)
641+}
642+
643+// syncGateOpen evaluates pane visibility without c.mu, then reacquires and
644+// validates the complete batch identity set before accepting that result.
645+func (c *renderCoordinator) syncGateOpen() bool {
646+ for {
647+ c.mu.Lock()
648+ batches := make(map[*pane]*syncBatch, len(c.syncBatches))
649+ for p, batch := range c.syncBatches {
650+ batches[p] = batch
651+ }
652+ c.mu.Unlock()
653+
654+ gated := false
655+ for _, batch := range batches {
656+ if batch.renderable == nil || batch.renderable() {
657+ gated = true
658+ break
659+ }
660+ }
661+
662+ c.mu.Lock()
663+ unchanged := len(batches) == len(c.syncBatches)
664+ if unchanged {
665+ for p, batch := range batches {
666+ if c.syncBatches[p] != batch || c.syncBatches[p].generation != batch.generation {
667+ unchanged = false
668+ break
669+ }
670+ }
671+ }
672+ c.mu.Unlock()
673+ if unchanged {
674+ return !gated
675+ }
676+ }
600677 }
601678
602679 // takePendingPreviewsLocked snapshots and consumes the preview delivery for
@@ -76,7 +76,11 @@ type renderCoordinatorOptions struct {
76 // ackReady reports whether the attachment may compose another output76 // ackReady reports whether the attachment may compose another output
77 // state (the outputStateStream window has capacity).77 // state (the outputStateStream window has capacity).
78 ackReady func() bool78 ackReady func() bool
79- // syncActive reports whether a synchronized-output batch is open.79+ // syncRenderable reports whether a pane's synchronized batch currently
80+ // contributes to the attached composition. It must not acquire c.mu.
81+ syncRenderable func(*pane) bool
82+ // syncActive is retained for existing test harness construction; per-pane
83+ // lifecycle and syncRenderable are the coordinator gate.
80 syncActive func() bool84 syncActive func() bool
81 // onInvalidate observes every published invalidation (test-visible hook).85 // onInvalidate observes every published invalidation (test-visible hook).
82 onInvalidate func(renderInvalidation)86 onInvalidate func(renderInvalidation)
@@ -170,6 +174,9 @@ func (c *renderCoordinator) cancelNormalTimerLocked() {
170 174
171 type syncBatch struct {175 type syncBatch struct {
172 generation uint64176 generation uint64
177+ // renderable is evaluated outside c.mu: tab/session visibility locks may be
178+ // acquired by this predicate.
179+ renderable func() bool
173 force func()180 force func()
174 timer ports.Timer181 timer ports.Timer
175 cancel chan struct{}182 cancel chan struct{}
@@ -288,6 +295,16 @@ func (c *renderCoordinator) notifyAck() {
288 // noteSyncBegin records a synchronized-output batch for its stable pane and295 // noteSyncBegin records a synchronized-output batch for its stable pane and
289 // arms that pane's watchdog. Overlapping pane batches are independent.296 // arms that pane's watchdog. Overlapping pane batches are independent.
290 func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) {297 func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) {
298+ var renderable func() bool
299+ if c.opts.syncRenderable != nil {
300+ renderable = func() bool { return c.opts.syncRenderable(p) }
301+ }
302+ c.noteSyncBeginWithRenderability(p, gen, renderable, force...)
303+}
304+
305+// noteSyncBeginWithRenderability records lifecycle unconditionally while the
306+// supplied predicate decides dynamically whether this batch gates composition.
307+func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) {
291 c.mu.Lock()308 c.mu.Lock()
292 if c.torndown {309 if c.torndown {
293 c.mu.Unlock()310 c.mu.Unlock()
@@ -299,7 +316,7 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func())
299 if old := c.syncBatches[p]; old != nil {316 if old := c.syncBatches[p]; old != nil {
300 cancelSyncBatchLocked(old)317 cancelSyncBatchLocked(old)
301 }318 }
302- batch := &syncBatch{generation: gen}319+ batch := &syncBatch{generation: gen, renderable: renderable}
303 if len(force) != 0 {320 if len(force) != 0 {
304 batch.force = force[0]321 batch.force = force[0]
305 }322 }
@@ -345,15 +362,27 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func())
345 // noteSyncEnd records completion for exactly one pane batch. It only flushes362 // noteSyncEnd records completion for exactly one pane batch. It only flushes
346 // after the aggregate gate opens.363 // after the aggregate gate opens.
347 func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) {364 func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) {
365+ // Visibility may acquire session/tab locks, so sample it before c.mu and
366+ // then verify the batch identity under c.mu.
348 c.mu.Lock()367 c.mu.Lock()
349 batch := c.syncBatches[p]368 batch := c.syncBatches[p]
350 if batch == nil || batch.generation != gen {369 if batch == nil || batch.generation != gen {
351 c.mu.Unlock()370 c.mu.Unlock()
352 return371 return
353 }372 }
373+ renderable := batch.renderable
374+ c.mu.Unlock()
375+ wasRenderable := renderable == nil || renderable()
376+
377+ c.mu.Lock()
378+ batch = c.syncBatches[p]
379+ if batch == nil || batch.generation != gen {
380+ c.mu.Unlock()
381+ return
382+ }
354 delete(c.syncBatches, p)383 delete(c.syncBatches, p)
355 cancelSyncBatchLocked(batch)384 cancelSyncBatchLocked(batch)
356- if len(c.syncBatches) == 0 && c.pending {385+ if wasRenderable && len(c.syncBatches) == 0 && c.pending {
357 c.pendingUrgent = true386 c.pendingUrgent = true
358 }387 }
359 c.mu.Unlock()388 c.mu.Unlock()
@@ -552,6 +581,9 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
552 }581 }
553 ackReady := c.opts.ackReady582 ackReady := c.opts.ackReady
554 c.mu.Unlock()583 c.mu.Unlock()
584+ if !c.syncGateOpen() {
585+ return
586+ }
555 587
556 ready := ackReady == nil || ackReady()588 ready := ackReady == nil || ackReady()
557 589
@@ -562,6 +594,15 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
562 c.mu.Unlock()594 c.mu.Unlock()
563 return595 return
564 }596 }
597+ c.mu.Unlock()
598+ if !c.syncGateOpen() {
599+ return
600+ }
601+ c.mu.Lock()
602+ if !c.fireValidLocked(gen, watchdog) {
603+ c.mu.Unlock()
604+ return
605+ }
565 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}606 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
566 if !ready {607 if !ready {
567 if deadline {608 if deadline {
@@ -596,7 +637,43 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
596 }637 }
597 638
598 func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool {639 func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool {
599- return !c.torndown && c.pending && (watchdog || gen == c.generation) && len(c.syncBatches) == 0640+ return !c.torndown && c.pending && (watchdog || gen == c.generation)
641+}
642+
643+// syncGateOpen evaluates pane visibility without c.mu, then reacquires and
644+// validates the complete batch identity set before accepting that result.
645+func (c *renderCoordinator) syncGateOpen() bool {
646+ for {
647+ c.mu.Lock()
648+ batches := make(map[*pane]*syncBatch, len(c.syncBatches))
649+ for p, batch := range c.syncBatches {
650+ batches[p] = batch
651+ }
652+ c.mu.Unlock()
653+
654+ gated := false
655+ for _, batch := range batches {
656+ if batch.renderable == nil || batch.renderable() {
657+ gated = true
658+ break
659+ }
660+ }
661+
662+ c.mu.Lock()
663+ unchanged := len(batches) == len(c.syncBatches)
664+ if unchanged {
665+ for p, batch := range batches {
666+ if c.syncBatches[p] != batch || c.syncBatches[p].generation != batch.generation {
667+ unchanged = false
668+ break
669+ }
670+ }
671+ }
672+ c.mu.Unlock()
673+ if unchanged {
674+ return !gated
675+ }
676+ }
600 }677 }
601 678
602 // takePendingPreviewsLocked snapshots and consumes the preview delivery for679 // takePendingPreviewsLocked snapshots and consumes the preview delivery for

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