detached 29a2879

refactor(daemon): remove legacy resize dispatch owners

commit
29a2879de9f59c30a525844725554955923db7b8
author
brice · · 2026-07-12 08:31 UTC
parent
cf19ce4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +82 -197
M internal/usecase/daemon/client.go +19 -32
@@ -39,22 +39,19 @@ type attachedClient struct {
3939 // pipelineCache is the last successfully emitted composition. pipelineScratch
4040 // is its attachment-owned alternate buffer; both are only touched under
4141 // sendMu and must never share mutable backing storage.
42- pipelineCache composeCacheInput
43- pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46- resizePaint pendingByteTimer // guarded by sendMu
47- resizePaintGeneration uint64 // guarded by sendMu
48- resizePaintPending bool // guarded by sendMu
49- size domain.Size
50- keys *keys.Router
51- sess Guarded[*session]
52- mouseScan mouse.Scanner
53- themeMu sync.Mutex
54- theme themeui.Theme
55- clientTheme themeui.Theme
56- lastCursor cursorOut
57- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
42+ pipelineCache composeCacheInput
43+ 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
5855 // previousSession is guarded independently. It is retained through temporary
5956 // setSession(nil) hand-offs and cleared only on terminal teardown.
6057 previousSession Guarded[*session]
@@ -71,6 +68,8 @@ type cursorOut struct {
7168 hasStyle bool
7269 }
7370
71+// pendingByteTimer is retained for overlay input timers. Resize timing belongs
72+// exclusively to renderCoordinator.
7473 type pendingByteTimer struct {
7574 timer ports.Timer
7675 done chan struct{}
@@ -100,16 +99,6 @@ func (p *pendingByteTimer) stop() {
10099 }
101100 }
102101
103-// cancelResizePaint invalidates callbacks before detaching or replacing an
104-// attachment. It deliberately takes sendMu before any session/daemon lock.
105-func (ac *attachedClient) cancelResizePaint() {
106- ac.sendMu.Lock()
107- ac.resizePaintGeneration++
108- ac.resizePaintPending = false
109- ac.resizePaint.stop()
110- ac.sendMu.Unlock()
111-}
112-
113102 func (ac *attachedClient) initOverlays() {
114103 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
115104 }
@@ -413,7 +402,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
413402 // paint occurs under sendMu, closing a park/resume race after this
414403 // unlocked coordinator validation.
415404 if w.attachment != nil && rc.wakeCurrent(w) {
416- d.paintCoordinatorWake(sess, w)
405+ d.paint(sess, w.attachment, w.reset, w.attachmentEpoch)
417406 }
418407 },
419408 ackReady: func() bool {
@@ -459,8 +448,6 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
459448 if old == nil {
460449 return
461450 }
462- old.cancelResizePaint()
463-
464451 d.unregisterPreview(old)
465452 old.clearPreviousSession()
466453 old.setSession(nil)
@@ -487,8 +474,10 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
487474 d.resizeForFirstPaint(sess, ac, clientSize)
488475 }
489476 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
490- d.invalidateRenderNow(sess, ac, true, "client.go")
477+ // Activation can synchronously resize a retained floating pane through the
478+ // same transaction. Fold its invalidation into this mandatory Welcome frame.
491479 d.activateTab(sess, tb)
480+ d.invalidateRenderNow(sess, ac, true, "client.go")
492481 }
493482
494483 // runConnLoop is the per-connection input router: it pumps client messages
@@ -563,7 +552,6 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
563552 if !sess.detachIfCurrent(ac) {
564553 return // already displaced by a newer client; nothing to do
565554 }
566- ac.cancelResizePaint()
567555 if rc := sess.renderCoordinator(); rc != nil {
568556 rc.noteDetach(ac)
569557 }
@@ -637,7 +625,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
637625 return
638626 }
639627 if sess.detachIfCurrent(ac) {
640- ac.cancelResizePaint()
641628 if rc := sess.renderCoordinator(); rc != nil {
642629 rc.noteDetach(ac)
643630 }
@@ -39,22 +39,19 @@ type attachedClient struct {
39 // pipelineCache is the last successfully emitted composition. pipelineScratch39 // pipelineCache is the last successfully emitted composition. pipelineScratch
40 // is its attachment-owned alternate buffer; both are only touched under40 // is its attachment-owned alternate buffer; both are only touched under
41 // sendMu and must never share mutable backing storage.41 // sendMu and must never share mutable backing storage.
42- pipelineCache composeCacheInput42+ pipelineCache composeCacheInput
43- pipelineScratch composeCacheInput43+ pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held44+ renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held45+ captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46- resizePaint pendingByteTimer // guarded by sendMu46+ size domain.Size
47- resizePaintGeneration uint64 // guarded by sendMu47+ keys *keys.Router
48- resizePaintPending bool // guarded by sendMu48+ sess Guarded[*session]
49- size domain.Size49+ mouseScan mouse.Scanner
50- keys *keys.Router50+ themeMu sync.Mutex
51- sess Guarded[*session]51+ theme themeui.Theme
52- mouseScan mouse.Scanner52+ clientTheme themeui.Theme
53- themeMu sync.Mutex53+ lastCursor cursorOut
54- theme themeui.Theme54+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
55- clientTheme themeui.Theme
56- lastCursor cursorOut
57- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
58 // previousSession is guarded independently. It is retained through temporary55 // previousSession is guarded independently. It is retained through temporary
59 // setSession(nil) hand-offs and cleared only on terminal teardown.56 // setSession(nil) hand-offs and cleared only on terminal teardown.
60 previousSession Guarded[*session]57 previousSession Guarded[*session]
@@ -71,6 +68,8 @@ type cursorOut struct {
71 hasStyle bool68 hasStyle bool
72 }69 }
73 70
71+// pendingByteTimer is retained for overlay input timers. Resize timing belongs
72+// exclusively to renderCoordinator.
74 type pendingByteTimer struct {73 type pendingByteTimer struct {
75 timer ports.Timer74 timer ports.Timer
76 done chan struct{}75 done chan struct{}
@@ -100,16 +99,6 @@ func (p *pendingByteTimer) stop() {
100 }99 }
101 }100 }
102 101
103-// cancelResizePaint invalidates callbacks before detaching or replacing an
104-// attachment. It deliberately takes sendMu before any session/daemon lock.
105-func (ac *attachedClient) cancelResizePaint() {
106- ac.sendMu.Lock()
107- ac.resizePaintGeneration++
108- ac.resizePaintPending = false
109- ac.resizePaint.stop()
110- ac.sendMu.Unlock()
111-}
112-
113 func (ac *attachedClient) initOverlays() {102 func (ac *attachedClient) initOverlays() {
114 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })103 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
115 }104 }
@@ -413,7 +402,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
413 // paint occurs under sendMu, closing a park/resume race after this402 // paint occurs under sendMu, closing a park/resume race after this
414 // unlocked coordinator validation.403 // unlocked coordinator validation.
415 if w.attachment != nil && rc.wakeCurrent(w) {404 if w.attachment != nil && rc.wakeCurrent(w) {
416- d.paintCoordinatorWake(sess, w)405+ d.paint(sess, w.attachment, w.reset, w.attachmentEpoch)
417 }406 }
418 },407 },
419 ackReady: func() bool {408 ackReady: func() bool {
@@ -459,8 +448,6 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
459 if old == nil {448 if old == nil {
460 return449 return
461 }450 }
462- old.cancelResizePaint()
463-
464 d.unregisterPreview(old)451 d.unregisterPreview(old)
465 old.clearPreviousSession()452 old.clearPreviousSession()
466 old.setSession(nil)453 old.setSession(nil)
@@ -487,8 +474,10 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
487 d.resizeForFirstPaint(sess, ac, clientSize)474 d.resizeForFirstPaint(sess, ac, clientSize)
488 }475 }
489 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)476 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
490- d.invalidateRenderNow(sess, ac, true, "client.go")477+ // Activation can synchronously resize a retained floating pane through the
478+ // same transaction. Fold its invalidation into this mandatory Welcome frame.
491 d.activateTab(sess, tb)479 d.activateTab(sess, tb)
480+ d.invalidateRenderNow(sess, ac, true, "client.go")
492 }481 }
493 482
494 // runConnLoop is the per-connection input router: it pumps client messages483 // runConnLoop is the per-connection input router: it pumps client messages
@@ -563,7 +552,6 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
563 if !sess.detachIfCurrent(ac) {552 if !sess.detachIfCurrent(ac) {
564 return // already displaced by a newer client; nothing to do553 return // already displaced by a newer client; nothing to do
565 }554 }
566- ac.cancelResizePaint()
567 if rc := sess.renderCoordinator(); rc != nil {555 if rc := sess.renderCoordinator(); rc != nil {
568 rc.noteDetach(ac)556 rc.noteDetach(ac)
569 }557 }
@@ -637,7 +625,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
637 return625 return
638 }626 }
639 if sess.detachIfCurrent(ac) {627 if sess.detachIfCurrent(ac) {
640- ac.cancelResizePaint()
641 if rc := sess.renderCoordinator(); rc != nil {628 if rc := sess.renderCoordinator(); rc != nil {
642 rc.noteDetach(ac)629 rc.noteDetach(ac)
643 }630 }
M internal/usecase/daemon/floating.go +15 -9
@@ -169,7 +169,13 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
169169 return
170170 }
171171 d.ensureFloatingWarm(sess, tb)
172- d.resizeInstalledFloating(tb)
172+ tb.mu.Lock()
173+ hasFloating := tb.floating.pane != nil
174+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175+ tb.mu.Unlock()
176+ if hasFloating {
177+ d.requestTransactionalResize(sess, ac, size, true)
178+ }
173179 }
174180
175181 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -197,10 +203,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
197203 return nil
198204 }
199205 if visible {
200- d.resizeActiveFloating(tb)
201- }
202- if ac != nil {
203- d.invalidateRender(sess, ac, true, "floating.go")
206+ tb.mu.Lock()
207+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
208+ tb.mu.Unlock()
209+ d.requestTransactionalResize(sess, ac, size, true)
204210 }
205211 return nil
206212 }
@@ -343,13 +349,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
343349 d.reapplyThemeSession(sess)
344350 d.startPaneGoroutines(sess, tb, p)
345351 if visible {
346- d.resizeActiveFloating(tb)
347352 sess.mu.Lock()
348353 ac := sess.client
349354 sess.mu.Unlock()
350- if ac != nil {
351- d.invalidateRender(sess, ac, true, "floating.go")
352- }
355+ tb.mu.Lock()
356+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
357+ tb.mu.Unlock()
358+ d.requestTransactionalResize(sess, ac, size, true)
353359 }
354360 }
355361
@@ -169,7 +169,13 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
169 return169 return
170 }170 }
171 d.ensureFloatingWarm(sess, tb)171 d.ensureFloatingWarm(sess, tb)
172- d.resizeInstalledFloating(tb)172+ tb.mu.Lock()
173+ hasFloating := tb.floating.pane != nil
174+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175+ tb.mu.Unlock()
176+ if hasFloating {
177+ d.requestTransactionalResize(sess, ac, size, true)
178+ }
173 }179 }
174 180
175 // toggleFloating changes only this tab's slot. Opening an uninitialized slot181 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -197,10 +203,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
197 return nil203 return nil
198 }204 }
199 if visible {205 if visible {
200- d.resizeActiveFloating(tb)206+ tb.mu.Lock()
201- }207+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
202- if ac != nil {208+ tb.mu.Unlock()
203- d.invalidateRender(sess, ac, true, "floating.go")209+ d.requestTransactionalResize(sess, ac, size, true)
204 }210 }
205 return nil211 return nil
206 }212 }
@@ -343,13 +349,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
343 d.reapplyThemeSession(sess)349 d.reapplyThemeSession(sess)
344 d.startPaneGoroutines(sess, tb, p)350 d.startPaneGoroutines(sess, tb, p)
345 if visible {351 if visible {
346- d.resizeActiveFloating(tb)
347 sess.mu.Lock()352 sess.mu.Lock()
348 ac := sess.client353 ac := sess.client
349 sess.mu.Unlock()354 sess.mu.Unlock()
350- if ac != nil {355+ tb.mu.Lock()
351- d.invalidateRender(sess, ac, true, "floating.go")356+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
352- }357+ tb.mu.Unlock()
358+ d.requestTransactionalResize(sess, ac, size, true)
353 }359 }
354 }360 }
355 361
M internal/usecase/daemon/floating_render.go +0 -67
@@ -133,70 +133,3 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string,
133133 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
134134 }
135135 }
136-
137-// resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
138-// is intentionally outside pane.mu; failed resizes do not publish any state.
139-func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool {
140- inner := geometry.Inner
141- if p == nil || !geometry.valid() {
142- return false
143- }
144- p.resizeMu.Lock()
145- defer p.resizeMu.Unlock()
146-
147- p.mu.Lock()
148- old := p.rect
149- pty := p.pty
150- p.mu.Unlock()
151- if old.Width == inner.Width && old.Height == inner.Height {
152- // Position and outer bounds can change while the terminal dimensions do
153- // not. Commit that geometry without needlessly resetting the PTY/screen.
154- p.mu.Lock()
155- p.rect = inner
156- p.popupGeometry = geometry
157- p.mu.Unlock()
158- return true
159- }
160- if pty != nil {
161- if err := pty.Resize(rectSize(inner)); err != nil {
162- d.log.Warn("floating pty resize failed", "err", err)
163- return false
164- }
165- }
166- p.mu.Lock()
167- p.screen.Resize(inner.Width, inner.Height)
168- p.rect = inner
169- p.popupGeometry = geometry
170- p.mu.Unlock()
171- return true
172-}
173-
174-// resizeInstalledFloating updates an installed floating pane, including a
175-// retained hidden pane when its tab becomes active.
176-func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
177- if d == nil || tb == nil {
178- return false
179- }
180- tb.mu.Lock()
181- p := tb.floating.pane
182- content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}
183- tb.mu.Unlock()
184- if p == nil {
185- return false
186- }
187- return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig()))
188-}
189-
190-// resizeActiveFloating updates a visible floating pane during a client resize.
191-func (d *Daemon) resizeActiveFloating(tb *tab) bool {
192- if d == nil || tb == nil {
193- return false
194- }
195- tb.mu.Lock()
196- visible := tb.floating.state == floatingVisible
197- tb.mu.Unlock()
198- if !visible {
199- return false
200- }
201- return d.resizeInstalledFloating(tb)
202-}
@@ -133,70 +133,3 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string,
133 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)133 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
134 }134 }
135 }135 }
136-
137-// resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
138-// is intentionally outside pane.mu; failed resizes do not publish any state.
139-func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool {
140- inner := geometry.Inner
141- if p == nil || !geometry.valid() {
142- return false
143- }
144- p.resizeMu.Lock()
145- defer p.resizeMu.Unlock()
146-
147- p.mu.Lock()
148- old := p.rect
149- pty := p.pty
150- p.mu.Unlock()
151- if old.Width == inner.Width && old.Height == inner.Height {
152- // Position and outer bounds can change while the terminal dimensions do
153- // not. Commit that geometry without needlessly resetting the PTY/screen.
154- p.mu.Lock()
155- p.rect = inner
156- p.popupGeometry = geometry
157- p.mu.Unlock()
158- return true
159- }
160- if pty != nil {
161- if err := pty.Resize(rectSize(inner)); err != nil {
162- d.log.Warn("floating pty resize failed", "err", err)
163- return false
164- }
165- }
166- p.mu.Lock()
167- p.screen.Resize(inner.Width, inner.Height)
168- p.rect = inner
169- p.popupGeometry = geometry
170- p.mu.Unlock()
171- return true
172-}
173-
174-// resizeInstalledFloating updates an installed floating pane, including a
175-// retained hidden pane when its tab becomes active.
176-func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
177- if d == nil || tb == nil {
178- return false
179- }
180- tb.mu.Lock()
181- p := tb.floating.pane
182- content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}
183- tb.mu.Unlock()
184- if p == nil {
185- return false
186- }
187- return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig()))
188-}
189-
190-// resizeActiveFloating updates a visible floating pane during a client resize.
191-func (d *Daemon) resizeActiveFloating(tb *tab) bool {
192- if d == nil || tb == nil {
193- return false
194- }
195- tb.mu.Lock()
196- visible := tb.floating.state == floatingVisible
197- tb.mu.Unlock()
198- if !visible {
199- return false
200- }
201- return d.resizeInstalledFloating(tb)
202-}
M internal/usecase/daemon/floating_render_test.go +24 -5
@@ -14,6 +14,25 @@ import (
1414 "github.com/bnema/vev/pkg/renderer"
1515 )
1616
17+// applyFloatingResizePlanForTest exercises the transactional apply primitive.
18+func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool {
19+ if p == nil || !geometry.valid() {
20+ return false
21+ }
22+ plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}}
23+ d.applyResize(&plan)
24+ member := plan.members[0]
25+ if !member.ok {
26+ return false
27+ }
28+ p.mu.Lock()
29+ p.rect = geometry.Inner
30+ p.popupGeometry = geometry
31+ p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
32+ p.mu.Unlock()
33+ return true
34+}
35+
1736 func TestFloatingGeometryTranslate(t *testing.T) {
1837 geometry := floatingGeometry{
1938 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
@@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T)
200219 p.rect, p.popupGeometry = initial.Inner, initial
201220 d := newTestDaemon(t, nil, stubClock{})
202221
203- require.True(t, d.resizeFloatingPane(p, tt.next))
222+ require.True(t, applyFloatingResizePlanForTest(d, p, tt.next))
204223 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
205224 require.Equal(t, tt.next.Inner, p.rect)
206225 require.Equal(t, tt.next, p.popupGeometry)
@@ -362,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
362381 d := newTestDaemon(t, nil, stubClock{})
363382 d.ApplyConfig(domain.Config{Floating: cfg})
364383
365- require.False(t, d.resizeFloatingPane(p, newGeometry))
384+ require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
366385 tb.mu.Lock()
367386 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
368387 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
@@ -388,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
388407 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
389408 d := newTestDaemon(t, nil, stubClock{})
390409 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
391- require.False(t, d.resizeFloatingPane(p, requested))
410+ require.False(t, applyFloatingResizePlanForTest(d, p, requested))
392411 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
393412 require.Equal(t, floatingGeometry{}, p.popupGeometry)
394413 require.Equal(t, 5, p.screen.Frame.Width)
@@ -404,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
404423 done1 := make(chan bool, 1)
405424 done2 := make(chan bool, 1)
406425 secondStarted := make(chan struct{})
407- go func() { done1 <- d.resizeFloatingPane(p, first) }()
426+ go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
408427 <-pty.entered
409428 go func() {
410429 close(secondStarted)
411- done2 <- d.resizeFloatingPane(p, second)
430+ done2 <- applyFloatingResizePlanForTest(d, p, second)
412431 }()
413432 <-secondStarted
414433 close(pty.release)
@@ -14,6 +14,25 @@ import (
14 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
15 )15 )
16 16
17+// applyFloatingResizePlanForTest exercises the transactional apply primitive.
18+func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool {
19+ if p == nil || !geometry.valid() {
20+ return false
21+ }
22+ plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}}
23+ d.applyResize(&plan)
24+ member := plan.members[0]
25+ if !member.ok {
26+ return false
27+ }
28+ p.mu.Lock()
29+ p.rect = geometry.Inner
30+ p.popupGeometry = geometry
31+ p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
32+ p.mu.Unlock()
33+ return true
34+}
35+
17 func TestFloatingGeometryTranslate(t *testing.T) {36 func TestFloatingGeometryTranslate(t *testing.T) {
18 geometry := floatingGeometry{37 geometry := floatingGeometry{
19 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},38 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
@@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T)
200 p.rect, p.popupGeometry = initial.Inner, initial219 p.rect, p.popupGeometry = initial.Inner, initial
201 d := newTestDaemon(t, nil, stubClock{})220 d := newTestDaemon(t, nil, stubClock{})
202 221
203- require.True(t, d.resizeFloatingPane(p, tt.next))222+ require.True(t, applyFloatingResizePlanForTest(d, p, tt.next))
204 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")223 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
205 require.Equal(t, tt.next.Inner, p.rect)224 require.Equal(t, tt.next.Inner, p.rect)
206 require.Equal(t, tt.next, p.popupGeometry)225 require.Equal(t, tt.next, p.popupGeometry)
@@ -362,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
362 d := newTestDaemon(t, nil, stubClock{})381 d := newTestDaemon(t, nil, stubClock{})
363 d.ApplyConfig(domain.Config{Floating: cfg})382 d.ApplyConfig(domain.Config{Floating: cfg})
364 383
365- require.False(t, d.resizeFloatingPane(p, newGeometry))384+ require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
366 tb.mu.Lock()385 tb.mu.Lock()
367 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)386 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
368 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))387 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
@@ -388,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
388 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}407 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
389 d := newTestDaemon(t, nil, stubClock{})408 d := newTestDaemon(t, nil, stubClock{})
390 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}409 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
391- require.False(t, d.resizeFloatingPane(p, requested))410+ require.False(t, applyFloatingResizePlanForTest(d, p, requested))
392 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)411 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
393 require.Equal(t, floatingGeometry{}, p.popupGeometry)412 require.Equal(t, floatingGeometry{}, p.popupGeometry)
394 require.Equal(t, 5, p.screen.Frame.Width)413 require.Equal(t, 5, p.screen.Frame.Width)
@@ -404,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
404 done1 := make(chan bool, 1)423 done1 := make(chan bool, 1)
405 done2 := make(chan bool, 1)424 done2 := make(chan bool, 1)
406 secondStarted := make(chan struct{})425 secondStarted := make(chan struct{})
407- go func() { done1 <- d.resizeFloatingPane(p, first) }()426+ go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
408 <-pty.entered427 <-pty.entered
409 go func() {428 go func() {
410 close(secondStarted)429 close(secondStarted)
411- done2 <- d.resizeFloatingPane(p, second)430+ done2 <- applyFloatingResizePlanForTest(d, p, second)
412 }()431 }()
413 <-secondStarted432 <-secondStarted
414 close(pty.release)433 close(pty.release)
M internal/usecase/daemon/nil_pty_test.go +1 -1
@@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
2424 sess.tabs = []*tab{first, second}
2525
2626 require.NotPanics(t, func() {
27- d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
27+ d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
2828 })
2929 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
3030 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
@@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
24 sess.tabs = []*tab{first, second}24 sess.tabs = []*tab{first, second}
25 25
26 require.NotPanics(t, func() {26 require.NotPanics(t, func() {
27- d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})27+ d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
28 })28 })
29 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)29 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
30 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)30 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
M internal/usecase/daemon/pane_actions.go +3 -3
@@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
111111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
112112 if !ok {
113113 for _, p := range tb.panes {
114- d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
114+ d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
115115 }
116116 return
117117 }
@@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
120120 continue
121121 }
122122 if p := tb.panes[pl.ID]; p != nil {
123- d.resizePane(p, pl.Content)
123+ d.applyPaneResize(p, pl.Content)
124124 }
125125 }
126126 }
127127
128-func (d *Daemon) resizePane(p *pane, r domain.Rect) {
128+func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) {
129129 if p == nil {
130130 return
131131 }
@@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
112 if !ok {112 if !ok {
113 for _, p := range tb.panes {113 for _, p := range tb.panes {
114- d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})114+ d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
115 }115 }
116 return116 return
117 }117 }
@@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
120 continue120 continue
121 }121 }
122 if p := tb.panes[pl.ID]; p != nil {122 if p := tb.panes[pl.ID]; p != nil {
123- d.resizePane(p, pl.Content)123+ d.applyPaneResize(p, pl.Content)
124 }124 }
125 }125 }
126 }126 }
127 127
128-func (d *Daemon) resizePane(p *pane, r domain.Rect) {128+func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) {
129 if p == nil {129 if p == nil {
130 return130 return
131 }131 }
M internal/usecase/daemon/picker.go +0 -6
@@ -410,12 +410,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
410410 d.touchMRU(targetSess)
411411 ac.recordPreviousSession(from)
412412 d.mu.Unlock()
413- if old != nil && old != ac {
414- // Displacement is a detach lifecycle transition too. Cancel the
415- // attachment-owned PR #71 timer only after releasing daemon/session
416- // locks, preserving sendMu > daemon/session lock ordering.
417- old.cancelResizePaint()
418- }
419413 return old
420414 }
421415
@@ -410,12 +410,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
410 d.touchMRU(targetSess)410 d.touchMRU(targetSess)
411 ac.recordPreviousSession(from)411 ac.recordPreviousSession(from)
412 d.mu.Unlock()412 d.mu.Unlock()
413- if old != nil && old != ac {
414- // Displacement is a detach lifecycle transition too. Cancel the
415- // attachment-owned PR #71 timer only after releasing daemon/session
416- // locks, preserving sendMu > daemon/session lock ordering.
417- old.cancelResizePaint()
418- }
419 return old413 return old
420 }414 }
421 415
M internal/usecase/daemon/picker_test.go +4 -8
@@ -281,16 +281,12 @@ func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
281281
282282 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
283283 timer := <-clock.timers
284- ac2.sendMu.Lock()
285- before := ac2.resizePaintGeneration
286- require.True(t, ac2.resizePaintPending)
287- ac2.sendMu.Unlock()
284+ before := sess2.renderCoordinator().resizeSnapshot().epoch
288285
289286 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
290- ac2.sendMu.Lock()
291- require.False(t, ac2.resizePaintPending)
292- require.Equal(t, before+1, ac2.resizePaintGeneration)
293- ac2.sendMu.Unlock()
287+ // The target coordinator invalidates its scheduled epoch during handoff;
288+ // no attachment-local timer survives the transfer.
289+ require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)
294290 timer.ch <- time.Time{}
295291 }
296292
@@ -281,16 +281,12 @@ func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
281 281
282 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})282 d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
283 timer := <-clock.timers283 timer := <-clock.timers
284- ac2.sendMu.Lock()284+ before := sess2.renderCoordinator().resizeSnapshot().epoch
285- before := ac2.resizePaintGeneration
286- require.True(t, ac2.resizePaintPending)
287- ac2.sendMu.Unlock()
288 285
289 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))286 require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
290- ac2.sendMu.Lock()287+ // The target coordinator invalidates its scheduled epoch during handoff;
291- require.False(t, ac2.resizePaintPending)288+ // no attachment-local timer survives the transfer.
292- require.Equal(t, before+1, ac2.resizePaintGeneration)289+ require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)
293- ac2.sendMu.Unlock()
294 timer.ch <- time.Time{}290 timer.ch <- time.Time{}
295 }291 }
296 292
M internal/usecase/daemon/render.go +4 -45
@@ -5,7 +5,6 @@ import (
55 "bytes"
66
77 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
98 "github.com/bnema/vev/internal/usecase/layout"
109 themeui "github.com/bnema/vev/internal/usecase/theme"
1110 "github.com/bnema/vev/pkg/renderer"
@@ -199,42 +198,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
199198 // paint draws the composed client frame (active tab plus status bar) and
200199 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
201200 // such as switch/create/close/rename/resize so the repaint is complete.
202-func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
203- ac.resizePaint.stop()
204- ac.resizePaintGeneration++
205- generation := ac.resizePaintGeneration
206- ac.resizePaintPending = true
207- ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
208- d.invalidateForResizeGeneration(sess, ac, generation)
209- })
210-}
211-
212-// invalidateForResizeGeneration preserves PR #71's attachment-owned timer,
213-// generation rejection, and cancellation while transferring only its eventual
214-// render request to the coordinator.
215-func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) {
216- ac.sendMu.Lock()
217- if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation {
218- ac.sendMu.Unlock()
219- return
201+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...uint64) {
202+ attachmentEpoch := uint64(0)
203+ if len(epochs) != 0 {
204+ attachmentEpoch = epochs[0]
220205 }
221- ac.resizePaint.stop()
222- ac.resizePaintPending = false
223- ac.sendMu.Unlock()
224- d.invalidateRender(sess, ac, true, "render.go")
225-}
226-
227-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
228- d.paintForResizeGeneration(sess, ac, reset, 0, 0)
229-}
230-
231-// paintCoordinatorWake composes a coordinator wake only for its captured
232-// attachment incarnation. The epoch is checked after sendMu is acquired.
233-func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) {
234- d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch)
235-}
236-
237-func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) {
238206 tb := sess.activeTab()
239207 if tb == nil {
240208 return
@@ -258,19 +226,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
258226 ac.sendMu.Unlock()
259227 return
260228 }
261- if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
262- ac.sendMu.Unlock()
263- return
264- }
265229 // Composition owns attachment sendMu; initialize its lazy overlay state
266230 // under that same ownership so concurrent fallback paints cannot observe a
267231 // partially published runtime.
268232 ac.initOverlays()
269- if ac.resizePaintPending {
270- ac.resizePaint.stop()
271- ac.resizePaintPending = false
272- reset = true
273- }
274233 overlays := ac.overlays.SnapshotForRender()
275234 repaintAttachedClients := false
276235 defer func() {
@@ -5,7 +5,6 @@ import (
5 "bytes"5 "bytes"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
10 themeui "github.com/bnema/vev/internal/usecase/theme"9 themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
@@ -199,42 +198,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
199 // paint draws the composed client frame (active tab plus status bar) and198 // paint draws the composed client frame (active tab plus status bar) and
200 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations199 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
201 // such as switch/create/close/rename/resize so the repaint is complete.200 // such as switch/create/close/rename/resize so the repaint is complete.
202-func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {201+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...uint64) {
203- ac.resizePaint.stop()202+ attachmentEpoch := uint64(0)
204- ac.resizePaintGeneration++203+ if len(epochs) != 0 {
205- generation := ac.resizePaintGeneration204+ attachmentEpoch = epochs[0]
206- ac.resizePaintPending = true
207- ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
208- d.invalidateForResizeGeneration(sess, ac, generation)
209- })
210-}
211-
212-// invalidateForResizeGeneration preserves PR #71's attachment-owned timer,
213-// generation rejection, and cancellation while transferring only its eventual
214-// render request to the coordinator.
215-func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) {
216- ac.sendMu.Lock()
217- if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation {
218- ac.sendMu.Unlock()
219- return
220 }205 }
221- ac.resizePaint.stop()
222- ac.resizePaintPending = false
223- ac.sendMu.Unlock()
224- d.invalidateRender(sess, ac, true, "render.go")
225-}
226-
227-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
228- d.paintForResizeGeneration(sess, ac, reset, 0, 0)
229-}
230-
231-// paintCoordinatorWake composes a coordinator wake only for its captured
232-// attachment incarnation. The epoch is checked after sendMu is acquired.
233-func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) {
234- d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch)
235-}
236-
237-func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) {
238 tb := sess.activeTab()206 tb := sess.activeTab()
239 if tb == nil {207 if tb == nil {
240 return208 return
@@ -258,19 +226,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
258 ac.sendMu.Unlock()226 ac.sendMu.Unlock()
259 return227 return
260 }228 }
261- if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
262- ac.sendMu.Unlock()
263- return
264- }
265 // Composition owns attachment sendMu; initialize its lazy overlay state229 // Composition owns attachment sendMu; initialize its lazy overlay state
266 // under that same ownership so concurrent fallback paints cannot observe a230 // under that same ownership so concurrent fallback paints cannot observe a
267 // partially published runtime.231 // partially published runtime.
268 ac.initOverlays()232 ac.initOverlays()
269- if ac.resizePaintPending {
270- ac.resizePaint.stop()
271- ac.resizePaintPending = false
272- reset = true
273- }
274 overlays := ac.overlays.SnapshotForRender()233 overlays := ac.overlays.SnapshotForRender()
275 repaintAttachedClients := false234 repaintAttachedClients := false
276 defer func() {235 defer func() {
M internal/usecase/daemon/render_coordinator.go +2 -2
@@ -845,7 +845,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
845845 }
846846 }
847847 if ac != nil {
848- d.paint(sess, ac, reset)
848+ d.paint(sess, ac, reset, 0)
849849 }
850850 }
851851
@@ -861,7 +861,7 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
861861 }
862862 }
863863 if ac != nil {
864- d.paint(sess, ac, reset)
864+ d.paint(sess, ac, reset, 0)
865865 }
866866 }
867867
@@ -845,7 +845,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
845 }845 }
846 }846 }
847 if ac != nil {847 if ac != nil {
848- d.paint(sess, ac, reset)848+ d.paint(sess, ac, reset, 0)
849 }849 }
850 }850 }
851 851
@@ -861,7 +861,7 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
861 }861 }
862 }862 }
863 if ac != nil {863 if ac != nil {
864- d.paint(sess, ac, reset)864+ d.paint(sess, ac, reset, 0)
865 }865 }
866 }866 }
867 867
M internal/usecase/daemon/render_coordinator_test.go +3 -14
@@ -989,13 +989,8 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
989989 require.Same(t, owner, snap.source)
990990 require.Equal(t, uint64(3), snap.epoch)
991991
992- // Metadata ownership is independent of the retained PR #71
993- // attachment dispatch state.
994- owner.sendMu.Lock()
995- generation, pending := owner.resizePaintGeneration, owner.resizePaintPending
996- owner.sendMu.Unlock()
997- require.Zero(t, generation, "recording metadata must not touch the PR #71 generation")
998- require.False(t, pending, "recording metadata must not arm the PR #71 dispatch")
992+ // Resize state is exclusively coordinator-owned.
993+ require.Zero(t, snap.committed)
999994 },
1000995 },
1001996 {
@@ -1478,12 +1473,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
14781473 timer := awaitCoordinatorScheduledTimer(t, clk)
14791474 require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
14801475 require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
1481- ac.sendMu.Lock()
1482- generation, pending := ac.resizePaintGeneration, ac.resizePaintPending
1483- ac.sendMu.Unlock()
1484- require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment")
1485- require.True(t, pending, "PR #71 pending dispatch must remain armed")
1486-
14871476 snap := sess.renderCoordinator().resizeSnapshot()
14881477 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
14891478 "the coordinator must record the latest requested resize before delegating")
@@ -1525,7 +1514,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
15251514
15261515 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
15271516 timer := awaitCoordinatorScheduledTimer(t, clk)
1528- ac.cancelResizePaint()
1517+ sess.renderCoordinator().noteDetach(ac)
15291518
15301519 timer.ch <- time.Time{}
15311520 requireNoInvalidation(t, invs)
@@ -989,13 +989,8 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
989 require.Same(t, owner, snap.source)989 require.Same(t, owner, snap.source)
990 require.Equal(t, uint64(3), snap.epoch)990 require.Equal(t, uint64(3), snap.epoch)
991 991
992- // Metadata ownership is independent of the retained PR #71992+ // Resize state is exclusively coordinator-owned.
993- // attachment dispatch state.993+ require.Zero(t, snap.committed)
994- owner.sendMu.Lock()
995- generation, pending := owner.resizePaintGeneration, owner.resizePaintPending
996- owner.sendMu.Unlock()
997- require.Zero(t, generation, "recording metadata must not touch the PR #71 generation")
998- require.False(t, pending, "recording metadata must not arm the PR #71 dispatch")
999 },994 },
1000 },995 },
1001 {996 {
@@ -1478,12 +1473,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1478 timer := awaitCoordinatorScheduledTimer(t, clk)1473 timer := awaitCoordinatorScheduledTimer(t, clk)
1479 require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)1474 require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
1480 require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)1475 require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
1481- ac.sendMu.Lock()
1482- generation, pending := ac.resizePaintGeneration, ac.resizePaintPending
1483- ac.sendMu.Unlock()
1484- require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment")
1485- require.True(t, pending, "PR #71 pending dispatch must remain armed")
1486-
1487 snap := sess.renderCoordinator().resizeSnapshot()1476 snap := sess.renderCoordinator().resizeSnapshot()
1488 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,1477 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
1489 "the coordinator must record the latest requested resize before delegating")1478 "the coordinator must record the latest requested resize before delegating")
@@ -1525,7 +1514,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1525 1514
1526 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})1515 d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
1527 timer := awaitCoordinatorScheduledTimer(t, clk)1516 timer := awaitCoordinatorScheduledTimer(t, clk)
1528- ac.cancelResizePaint()1517+ sess.renderCoordinator().noteDetach(ac)
1529 1518
1530 timer.ch <- time.Time{}1519 timer.ch <- time.Time{}
1531 requireNoInvalidation(t, invs)1520 requireNoInvalidation(t, invs)
M internal/usecase/daemon/resume.go +0 -4
@@ -48,10 +48,6 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
4848 if !d.prepareParkAttachment(sess, ac) {
4949 return false
5050 }
51- // A parked attachment has no valid visible destination. Cancel its
52- // resize-idle callback before registering the grace timer; resume performs
53- // a fresh full paint.
54- ac.cancelResizePaint()
5551 d.mu.Lock()
5652 if d.closing {
5753 d.mu.Unlock()
@@ -48,10 +48,6 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
48 if !d.prepareParkAttachment(sess, ac) {48 if !d.prepareParkAttachment(sess, ac) {
49 return false49 return false
50 }50 }
51- // A parked attachment has no valid visible destination. Cancel its
52- // resize-idle callback before registering the grace timer; resume performs
53- // a fresh full paint.
54- ac.cancelResizePaint()
55 d.mu.Lock()51 d.mu.Lock()
56 if d.closing {52 if d.closing {
57 d.mu.Unlock()53 d.mu.Unlock()
M internal/usecase/daemon/session.go +0 -1
@@ -724,7 +724,6 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
724724 rc.noteSessionTeardown()
725725 }
726726 if ac != nil {
727- ac.cancelResizePaint()
728727 d.unregisterPreview(ac)
729728 ac.clearPreviousSession()
730729 ac.setSession(nil)
@@ -724,7 +724,6 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
724 rc.noteSessionTeardown()724 rc.noteSessionTeardown()
725 }725 }
726 if ac != nil {726 if ac != nil {
727- ac.cancelResizePaint()
728 d.unregisterPreview(ac)727 d.unregisterPreview(ac)
729 ac.clearPreviousSession()728 ac.clearPreviousSession()
730 ac.setSession(nil)729 ac.setSession(nil)
M internal/usecase/daemon/transactional_resize.go +7 -0
@@ -90,6 +90,13 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
9090 tb.mu.Unlock()
9191 }
9292 for _, m := range plan.members {
93+ // A failed floating resize must retain the committed popup geometry: it
94+ // controls both its frame and mouse/copy hit testing. Normal layout still
95+ // publishes its solved rectangle, while retaining its old screen until a
96+ // successful PTY resize.
97+ if m.isFloating && !m.ok {
98+ continue
99+ }
93100 m.pane.mu.Lock()
94101 m.pane.rect = m.rect
95102 if m.isFloating {
@@ -90,6 +90,13 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
90 tb.mu.Unlock()90 tb.mu.Unlock()
91 }91 }
92 for _, m := range plan.members {92 for _, m := range plan.members {
93+ // A failed floating resize must retain the committed popup geometry: it
94+ // controls both its frame and mouse/copy hit testing. Normal layout still
95+ // publishes its solved rectangle, while retaining its old screen until a
96+ // successful PTY resize.
97+ if m.isFloating && !m.ok {
98+ continue
99+ }
93 m.pane.mu.Lock()100 m.pane.mu.Lock()
94 m.pane.rect = m.rect101 m.pane.rect = m.rect
95 if m.isFloating {102 if m.isFloating {

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