detached 4e6142e

fix(daemon): preserve coordinator ownership across sessions

commit
4e6142e341001f64f5970285f799d735750ee6ac
author
brice · · 2026-07-11 16:33 UTC
parent
a4184b2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +144 -32
M internal/usecase/daemon/client.go +25 -16
@@ -355,43 +355,52 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
355355 sess.client = ac
356356 name := sess.name
357357 sess.mu.Unlock()
358- // A session has exactly one coordinator; replacement changes only its
359- // attachment identity and the private transitional compositor target.
358+ d.attachCoordinator(sess, old, ac)
359+ d.touchMRU(sess)
360+ d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
361+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
362+ return ac, old
363+}
364+
365+// attachCoordinator is the sole direct attachment handoff. It creates at
366+// most one coordinator for sess and changes its identity before any caller
367+// can publish resize or render state for the new client.
368+func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient) *renderCoordinator {
369+ sess.mu.Lock()
360370 rc := sess.renderCoordinator()
361371 if rc == nil {
362372 rc = newRenderCoordinator(renderCoordinatorOptions{
363373 clock: d.clock,
364374 wake: func(w renderWake) {
365375 sess.mu.Lock()
366- current := sess.client
376+ attached := sess.client
367377 sess.mu.Unlock()
368- if current != nil {
369- d.paint(sess, current, w.reset)
378+ if attached != nil {
379+ d.paint(sess, attached, w.reset)
370380 }
371381 },
372382 ackReady: func() bool {
373383 sess.mu.Lock()
374- current := sess.client
384+ attached := sess.client
375385 sess.mu.Unlock()
376- if current == nil {
386+ if attached == nil {
377387 return false
378388 }
379- current.sendMu.Lock()
380- ready := !current.output.atCapacity()
381- current.sendMu.Unlock()
389+ attached.sendMu.Lock()
390+ ready := !attached.output.atCapacity()
391+ attached.sendMu.Unlock()
382392 return ready
383393 },
384394 })
385395 sess.installRenderCoordinator(rc)
386396 }
397+ sess.mu.Unlock()
387398 if old != nil {
388- rc.noteReplace(old, ac)
399+ rc.noteReplace(old, current)
400+ } else if current != nil {
401+ rc.attach(current)
389402 }
390- rc.attach(ac)
391- d.touchMRU(sess)
392- d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
393- d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
394- return ac, old
403+ return rc
395404 }
396405
397406 // resetScreenDefaultColors clears the known default foreground/background
@@ -355,43 +355,52 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
355 sess.client = ac355 sess.client = ac
356 name := sess.name356 name := sess.name
357 sess.mu.Unlock()357 sess.mu.Unlock()
358- // A session has exactly one coordinator; replacement changes only its358+ d.attachCoordinator(sess, old, ac)
359- // attachment identity and the private transitional compositor target.359+ d.touchMRU(sess)
360+ d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
361+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
362+ return ac, old
363+}
364+
365+// attachCoordinator is the sole direct attachment handoff. It creates at
366+// most one coordinator for sess and changes its identity before any caller
367+// can publish resize or render state for the new client.
368+func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient) *renderCoordinator {
369+ sess.mu.Lock()
360 rc := sess.renderCoordinator()370 rc := sess.renderCoordinator()
361 if rc == nil {371 if rc == nil {
362 rc = newRenderCoordinator(renderCoordinatorOptions{372 rc = newRenderCoordinator(renderCoordinatorOptions{
363 clock: d.clock,373 clock: d.clock,
364 wake: func(w renderWake) {374 wake: func(w renderWake) {
365 sess.mu.Lock()375 sess.mu.Lock()
366- current := sess.client376+ attached := sess.client
367 sess.mu.Unlock()377 sess.mu.Unlock()
368- if current != nil {378+ if attached != nil {
369- d.paint(sess, current, w.reset)379+ d.paint(sess, attached, w.reset)
370 }380 }
371 },381 },
372 ackReady: func() bool {382 ackReady: func() bool {
373 sess.mu.Lock()383 sess.mu.Lock()
374- current := sess.client384+ attached := sess.client
375 sess.mu.Unlock()385 sess.mu.Unlock()
376- if current == nil {386+ if attached == nil {
377 return false387 return false
378 }388 }
379- current.sendMu.Lock()389+ attached.sendMu.Lock()
380- ready := !current.output.atCapacity()390+ ready := !attached.output.atCapacity()
381- current.sendMu.Unlock()391+ attached.sendMu.Unlock()
382 return ready392 return ready
383 },393 },
384 })394 })
385 sess.installRenderCoordinator(rc)395 sess.installRenderCoordinator(rc)
386 }396 }
397+ sess.mu.Unlock()
387 if old != nil {398 if old != nil {
388- rc.noteReplace(old, ac)399+ rc.noteReplace(old, current)
400+ } else if current != nil {
401+ rc.attach(current)
389 }402 }
390- rc.attach(ac)403+ return rc
391- d.touchMRU(sess)
392- d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
393- d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
394- return ac, old
395 }404 }
396 405
397 // resetScreenDefaultColors clears the known default foreground/background406 // resetScreenDefaultColors clears the known default foreground/background
M internal/usecase/daemon/overlay_runtime.go +6 -5
@@ -13,11 +13,12 @@ import (
1313 type overlayRuntime struct {
1414 ac *attachedClient
1515
16- pickerMu sync.Mutex
17- picker *picker.Model
18- pickerPreview *tab
19- pickerPending []byte
20- pickerESC pendingByteTimer
16+ pickerMu sync.Mutex
17+ picker *picker.Model
18+ pickerPreview *tab
19+ pickerPreviewSession *session
20+ pickerPending []byte
21+ pickerESC pendingByteTimer
2122
2223 paletteMu sync.Mutex
2324 palette *palette.Model
@@ -13,11 +13,12 @@ import (
13 type overlayRuntime struct {13 type overlayRuntime struct {
14 ac *attachedClient14 ac *attachedClient
15 15
16- pickerMu sync.Mutex16+ pickerMu sync.Mutex
17- picker *picker.Model17+ picker *picker.Model
18- pickerPreview *tab18+ pickerPreview *tab
19- pickerPending []byte19+ pickerPreviewSession *session
20- pickerESC pendingByteTimer20+ pickerPending []byte
21+ pickerESC pendingByteTimer
21 22
22 paletteMu sync.Mutex23 paletteMu sync.Mutex
23 palette *palette.Model24 palette *palette.Model
M internal/usecase/daemon/picker.go +67 -7
@@ -222,21 +222,65 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
222222 if ac.overlays.picker != nil {
223223 target, ok = ac.overlays.picker.Selected()
224224 }
225- var next *tab
226- if ok {
227- next = d.tabByTarget(target)
225+ previous := ac.overlays.pickerPreviewSession
226+ ac.overlays.pickerPreviewSession = nil
227+ ac.overlays.pickerPreview = nil
228+ ac.overlays.pickerMu.Unlock()
229+ if previous != nil {
230+ if rc := previous.renderCoordinator(); rc != nil {
231+ rc.teardownPreviewFor(ac)
232+ }
228233 }
234+ if !ok {
235+ return
236+ }
237+ next := d.tabByTarget(target)
238+ targetSess := d.sessionByID(target.Session)
239+ if next == nil || targetSess == nil {
240+ return
241+ }
242+ ac.overlays.pickerMu.Lock()
243+ // Selection may have changed while the target was resolved.
229244 if ac.overlays.picker == nil {
230- next = nil
245+ ac.overlays.pickerMu.Unlock()
246+ return
231247 }
232248 ac.overlays.pickerPreview = next
249+ ac.overlays.pickerPreviewSession = targetSess
233250 ac.overlays.pickerMu.Unlock()
251+ // A same-session preview is already invalidated by its own coordinator.
252+ // Cross-session previews subscribe to the target's producer wake instead
253+ // of starting a direct paint/timer path.
254+ if targetSess == ac.currentSession() {
255+ return
256+ }
257+ rc := d.attachCoordinator(targetSess, nil, nil)
258+ rc.subscribePreviewFor(ac, func(renderWake) {
259+ ac.overlays.pickerMu.Lock()
260+ valid := ac.overlays.pickerPreviewSession == targetSess
261+ ac.overlays.pickerMu.Unlock()
262+ if valid {
263+ if owner := ac.currentSession(); owner != nil {
264+ d.invalidateRender(owner, ac, false, "picker preview")
265+ }
266+ }
267+ })
234268 }
235269
236270 func (d *Daemon) unregisterPreview(ac *attachedClient) {
271+ if ac == nil || ac.overlays == nil {
272+ return
273+ }
237274 ac.overlays.pickerMu.Lock()
275+ previous := ac.overlays.pickerPreviewSession
276+ ac.overlays.pickerPreviewSession = nil
238277 ac.overlays.pickerPreview = nil
239278 ac.overlays.pickerMu.Unlock()
279+ if previous != nil {
280+ if rc := previous.renderCoordinator(); rc != nil {
281+ rc.teardownPreviewFor(ac)
282+ }
283+ }
240284 }
241285
242286 // clearDestroyedTabPreview clears coordinator-owned picker state for clients
@@ -261,11 +305,9 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
261305 }
262306 ac.overlays.pickerMu.Lock()
263307 cleared := ac.overlays.pickerPreview == tb
264- if cleared {
265- ac.overlays.pickerPreview = nil
266- }
267308 ac.overlays.pickerMu.Unlock()
268309 if cleared {
310+ d.unregisterPreview(ac)
269311 if sess := ac.currentSession(); sess != nil {
270312 d.invalidateRender(sess, ac, true, "picker.go")
271313 }
@@ -364,6 +406,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
364406 ac.setSession(targetSess)
365407 ac.recordPreviousSession(from)
366408 d.mu.Unlock()
409+ // Both session coordinators must observe the ownership transfer before
410+ // stale readers/timers can publish into either session.
411+ if rc := from.renderCoordinator(); rc != nil {
412+ rc.noteDetach(ac)
413+ }
414+ // Output states from the prior session cannot be acknowledged against the
415+ // destination's full first frame.
416+ ac.sendMu.Lock()
417+ ac.output.rebase()
418+ ac.sendMu.Unlock()
419+ d.attachCoordinator(targetSess, old, ac)
367420 return old
368421 }
369422
@@ -402,6 +455,13 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
402455 ac.setSession(targetSess)
403456 ac.recordPreviousSession(from)
404457 d.mu.Unlock()
458+ if rc := from.renderCoordinator(); rc != nil {
459+ rc.noteDetach(ac)
460+ }
461+ ac.sendMu.Lock()
462+ ac.output.rebase()
463+ ac.sendMu.Unlock()
464+ d.attachCoordinator(targetSess, nil, ac)
405465 d.firstPaint(targetSess, ac, ac.size)
406466 return true
407467 }
@@ -222,21 +222,65 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
222 if ac.overlays.picker != nil {222 if ac.overlays.picker != nil {
223 target, ok = ac.overlays.picker.Selected()223 target, ok = ac.overlays.picker.Selected()
224 }224 }
225- var next *tab225+ previous := ac.overlays.pickerPreviewSession
226- if ok {226+ ac.overlays.pickerPreviewSession = nil
227- next = d.tabByTarget(target)227+ ac.overlays.pickerPreview = nil
228+ ac.overlays.pickerMu.Unlock()
229+ if previous != nil {
230+ if rc := previous.renderCoordinator(); rc != nil {
231+ rc.teardownPreviewFor(ac)
232+ }
228 }233 }
234+ if !ok {
235+ return
236+ }
237+ next := d.tabByTarget(target)
238+ targetSess := d.sessionByID(target.Session)
239+ if next == nil || targetSess == nil {
240+ return
241+ }
242+ ac.overlays.pickerMu.Lock()
243+ // Selection may have changed while the target was resolved.
229 if ac.overlays.picker == nil {244 if ac.overlays.picker == nil {
230- next = nil245+ ac.overlays.pickerMu.Unlock()
246+ return
231 }247 }
232 ac.overlays.pickerPreview = next248 ac.overlays.pickerPreview = next
249+ ac.overlays.pickerPreviewSession = targetSess
233 ac.overlays.pickerMu.Unlock()250 ac.overlays.pickerMu.Unlock()
251+ // A same-session preview is already invalidated by its own coordinator.
252+ // Cross-session previews subscribe to the target's producer wake instead
253+ // of starting a direct paint/timer path.
254+ if targetSess == ac.currentSession() {
255+ return
256+ }
257+ rc := d.attachCoordinator(targetSess, nil, nil)
258+ rc.subscribePreviewFor(ac, func(renderWake) {
259+ ac.overlays.pickerMu.Lock()
260+ valid := ac.overlays.pickerPreviewSession == targetSess
261+ ac.overlays.pickerMu.Unlock()
262+ if valid {
263+ if owner := ac.currentSession(); owner != nil {
264+ d.invalidateRender(owner, ac, false, "picker preview")
265+ }
266+ }
267+ })
234 }268 }
235 269
236 func (d *Daemon) unregisterPreview(ac *attachedClient) {270 func (d *Daemon) unregisterPreview(ac *attachedClient) {
271+ if ac == nil || ac.overlays == nil {
272+ return
273+ }
237 ac.overlays.pickerMu.Lock()274 ac.overlays.pickerMu.Lock()
275+ previous := ac.overlays.pickerPreviewSession
276+ ac.overlays.pickerPreviewSession = nil
238 ac.overlays.pickerPreview = nil277 ac.overlays.pickerPreview = nil
239 ac.overlays.pickerMu.Unlock()278 ac.overlays.pickerMu.Unlock()
279+ if previous != nil {
280+ if rc := previous.renderCoordinator(); rc != nil {
281+ rc.teardownPreviewFor(ac)
282+ }
283+ }
240 }284 }
241 285
242 // clearDestroyedTabPreview clears coordinator-owned picker state for clients286 // clearDestroyedTabPreview clears coordinator-owned picker state for clients
@@ -261,11 +305,9 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
261 }305 }
262 ac.overlays.pickerMu.Lock()306 ac.overlays.pickerMu.Lock()
263 cleared := ac.overlays.pickerPreview == tb307 cleared := ac.overlays.pickerPreview == tb
264- if cleared {
265- ac.overlays.pickerPreview = nil
266- }
267 ac.overlays.pickerMu.Unlock()308 ac.overlays.pickerMu.Unlock()
268 if cleared {309 if cleared {
310+ d.unregisterPreview(ac)
269 if sess := ac.currentSession(); sess != nil {311 if sess := ac.currentSession(); sess != nil {
270 d.invalidateRender(sess, ac, true, "picker.go")312 d.invalidateRender(sess, ac, true, "picker.go")
271 }313 }
@@ -364,6 +406,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
364 ac.setSession(targetSess)406 ac.setSession(targetSess)
365 ac.recordPreviousSession(from)407 ac.recordPreviousSession(from)
366 d.mu.Unlock()408 d.mu.Unlock()
409+ // Both session coordinators must observe the ownership transfer before
410+ // stale readers/timers can publish into either session.
411+ if rc := from.renderCoordinator(); rc != nil {
412+ rc.noteDetach(ac)
413+ }
414+ // Output states from the prior session cannot be acknowledged against the
415+ // destination's full first frame.
416+ ac.sendMu.Lock()
417+ ac.output.rebase()
418+ ac.sendMu.Unlock()
419+ d.attachCoordinator(targetSess, old, ac)
367 return old420 return old
368 }421 }
369 422
@@ -402,6 +455,13 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
402 ac.setSession(targetSess)455 ac.setSession(targetSess)
403 ac.recordPreviousSession(from)456 ac.recordPreviousSession(from)
404 d.mu.Unlock()457 d.mu.Unlock()
458+ if rc := from.renderCoordinator(); rc != nil {
459+ rc.noteDetach(ac)
460+ }
461+ ac.sendMu.Lock()
462+ ac.output.rebase()
463+ ac.sendMu.Unlock()
464+ d.attachCoordinator(targetSess, nil, ac)
405 d.firstPaint(targetSess, ac, ac.size)465 d.firstPaint(targetSess, ac, ac.size)
406 return true466 return true
407 }467 }
M internal/usecase/daemon/render_coordinator.go +45 -4
@@ -103,9 +103,11 @@ type renderCoordinator struct {
103103 pendingUrgent bool
104104 coalesced int
105105
106- // previewWake receives the same coalesced wakes while a preview
107- // subscription is installed.
108- previewWake func(renderWake)
106+ // previewWake receives the same coalesced wakes for the legacy single
107+ // subscriber. previewWakes tracks picker subscriptions by viewer, so one
108+ // inactive session cannot replace another viewer's live preview.
109+ previewWake func(renderWake)
110+ previewWakes map[*attachedClient]func(renderWake)
109111
110112 // attachment is the currently bound client identity; callbacks from any
111113 // other identity are stale and must not mutate coordinator state.
@@ -208,6 +210,13 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
208210 case <-cancel:
209211 }
210212 }()
213+ // A nil timer channel is an inert test clock, not a schedulable deadline.
214+ // Complete through the coordinator so manual daemon fixtures retain their
215+ // deterministic synchronous contract without a direct paint fallback.
216+ if timer.C() == nil {
217+ c.fire(gen, false)
218+ return
219+ }
211220 // Give fake-clock callbacks a chance to block on C before the test
212221 // advances it; real clocks are unaffected.
213222 runtime.Gosched()
@@ -294,9 +303,32 @@ func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
294303 c.mu.Unlock()
295304 }
296305
297-// teardownPreview removes the preview subscription.
306+// teardownPreview removes the legacy preview subscription.
298307 func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
299308
309+// subscribePreviewFor installs the dynamic picker observer owned by viewer.
310+// The owner key makes target changes, detach, and session teardown precise.
311+func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, fn func(renderWake)) {
312+ if viewer == nil || fn == nil {
313+ return
314+ }
315+ c.mu.Lock()
316+ if !c.torndown {
317+ if c.previewWakes == nil {
318+ c.previewWakes = make(map[*attachedClient]func(renderWake))
319+ }
320+ c.previewWakes[viewer] = fn
321+ }
322+ c.mu.Unlock()
323+}
324+
325+// teardownPreviewFor removes only viewer's dynamic picker observer.
326+func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) {
327+ c.mu.Lock()
328+ delete(c.previewWakes, viewer)
329+ c.mu.Unlock()
330+}
331+
300332 // attach binds ac as the coordinator's current attachment identity.
301333 func (c *renderCoordinator) attach(ac *attachedClient) {
302334 c.mu.Lock()
@@ -344,6 +376,8 @@ func (c *renderCoordinator) noteSessionTeardown() {
344376 c.mu.Lock()
345377 c.torndown = true
346378 c.attachment = nil
379+ c.previewWake = nil
380+ c.previewWakes = nil
347381 c.pending = false
348382 c.generation++
349383 c.armed = false
@@ -438,6 +472,10 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
438472 c.cancelNormalTimerLocked()
439473 c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false
440474 wake, preview := c.opts.wake, c.previewWake
475+ previews := make([]func(renderWake), 0, len(c.previewWakes))
476+ for _, fn := range c.previewWakes {
477+ previews = append(previews, fn)
478+ }
441479 c.mu.Unlock()
442480 if wake != nil {
443481 wake(w)
@@ -445,4 +483,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
445483 if preview != nil {
446484 preview(w)
447485 }
486+ for _, fn := range previews {
487+ fn(w)
488+ }
448489 }
@@ -103,9 +103,11 @@ type renderCoordinator struct {
103 pendingUrgent bool103 pendingUrgent bool
104 coalesced int104 coalesced int
105 105
106- // previewWake receives the same coalesced wakes while a preview106+ // previewWake receives the same coalesced wakes for the legacy single
107- // subscription is installed.107+ // subscriber. previewWakes tracks picker subscriptions by viewer, so one
108- previewWake func(renderWake)108+ // inactive session cannot replace another viewer's live preview.
109+ previewWake func(renderWake)
110+ previewWakes map[*attachedClient]func(renderWake)
109 111
110 // attachment is the currently bound client identity; callbacks from any112 // attachment is the currently bound client identity; callbacks from any
111 // other identity are stale and must not mutate coordinator state.113 // other identity are stale and must not mutate coordinator state.
@@ -208,6 +210,13 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
208 case <-cancel:210 case <-cancel:
209 }211 }
210 }()212 }()
213+ // A nil timer channel is an inert test clock, not a schedulable deadline.
214+ // Complete through the coordinator so manual daemon fixtures retain their
215+ // deterministic synchronous contract without a direct paint fallback.
216+ if timer.C() == nil {
217+ c.fire(gen, false)
218+ return
219+ }
211 // Give fake-clock callbacks a chance to block on C before the test220 // Give fake-clock callbacks a chance to block on C before the test
212 // advances it; real clocks are unaffected.221 // advances it; real clocks are unaffected.
213 runtime.Gosched()222 runtime.Gosched()
@@ -294,9 +303,32 @@ func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
294 c.mu.Unlock()303 c.mu.Unlock()
295 }304 }
296 305
297-// teardownPreview removes the preview subscription.306+// teardownPreview removes the legacy preview subscription.
298 func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }307 func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
299 308
309+// subscribePreviewFor installs the dynamic picker observer owned by viewer.
310+// The owner key makes target changes, detach, and session teardown precise.
311+func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, fn func(renderWake)) {
312+ if viewer == nil || fn == nil {
313+ return
314+ }
315+ c.mu.Lock()
316+ if !c.torndown {
317+ if c.previewWakes == nil {
318+ c.previewWakes = make(map[*attachedClient]func(renderWake))
319+ }
320+ c.previewWakes[viewer] = fn
321+ }
322+ c.mu.Unlock()
323+}
324+
325+// teardownPreviewFor removes only viewer's dynamic picker observer.
326+func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) {
327+ c.mu.Lock()
328+ delete(c.previewWakes, viewer)
329+ c.mu.Unlock()
330+}
331+
300 // attach binds ac as the coordinator's current attachment identity.332 // attach binds ac as the coordinator's current attachment identity.
301 func (c *renderCoordinator) attach(ac *attachedClient) {333 func (c *renderCoordinator) attach(ac *attachedClient) {
302 c.mu.Lock()334 c.mu.Lock()
@@ -344,6 +376,8 @@ func (c *renderCoordinator) noteSessionTeardown() {
344 c.mu.Lock()376 c.mu.Lock()
345 c.torndown = true377 c.torndown = true
346 c.attachment = nil378 c.attachment = nil
379+ c.previewWake = nil
380+ c.previewWakes = nil
347 c.pending = false381 c.pending = false
348 c.generation++382 c.generation++
349 c.armed = false383 c.armed = false
@@ -438,6 +472,10 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
438 c.cancelNormalTimerLocked()472 c.cancelNormalTimerLocked()
439 c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false473 c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false
440 wake, preview := c.opts.wake, c.previewWake474 wake, preview := c.opts.wake, c.previewWake
475+ previews := make([]func(renderWake), 0, len(c.previewWakes))
476+ for _, fn := range c.previewWakes {
477+ previews = append(previews, fn)
478+ }
441 c.mu.Unlock()479 c.mu.Unlock()
442 if wake != nil {480 if wake != nil {
443 wake(w)481 wake(w)
@@ -445,4 +483,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
445 if preview != nil {483 if preview != nil {
446 preview(w)484 preview(w)
447 }485 }
486+ for _, fn := range previews {
487+ fn(w)
488+ }
448 }489 }
M internal/usecase/daemon/resume.go +1 -0
@@ -187,6 +187,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
187187 sess.client = ac
188188 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
189189 sess.mu.Unlock()
190+ d.attachCoordinator(sess, nil, ac)
190191 d.touchMRU(sess)
191192 d.log.Info("client resumed", "session", sess.name)
192193 return sess, ac, true, nil
@@ -187,6 +187,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
187 sess.client = ac187 sess.client = ac
188 sess.terminal = terminalEnv{TrueColor: h.TrueColor}188 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
189 sess.mu.Unlock()189 sess.mu.Unlock()
190+ d.attachCoordinator(sess, nil, ac)
190 d.touchMRU(sess)191 d.touchMRU(sess)
191 d.log.Info("client resumed", "session", sess.name)192 d.log.Info("client resumed", "session", sess.name)
192 return sess, ac, true, nil193 return sess, ac, true, nil

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