detached fd82476

refactor(daemon): route resize through coordinator transactions

commit
fd824762939a5400d96250b406e5c4585e98d1a4
author
brice · · 2026-07-12 08:02 UTC
parent
d74f5ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +261 -55
M internal/usecase/daemon/client.go +4 -48
@@ -621,57 +621,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
621621 }
622622
623623 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
624- d.resizeWithPaint(sess, ac, sz, true)
624+ d.requestTransactionalResize(sess, ac, sz, false)
625625 }
626626
627-// resizeForFirstPaint applies attach geometry without installing an idle timer:
628-// firstPaint immediately emits the required full frame itself.
627+// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
628+// firstPaint caller emits its full frame immediately after this commit.
629629 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
630- d.resizeWithPaint(sess, ac, sz, false)
631-}
632-
633-func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) {
634- if !sz.Valid() {
635- return
636- }
637- d.exitCopyMode(ac)
638- if ac != nil {
639- // Suspend emission until every pane has adopted this geometry and the
640- // bounded resize paint is armed. PTY readers remain independent.
641- ac.sendMu.Lock()
642- defer ac.sendMu.Unlock()
643- if ac.currentSession() != sess {
644- return
645- }
646- ac.size = sz
647- if rc := sess.renderCoordinator(); rc != nil {
648- rc.attach(ac)
649- rc.recordResizeRequest(sz, ac)
650- }
651- }
652- tbSize := tabSize(sz)
653-
654- sess.mu.Lock()
655- tabs := append([]*tab(nil), sess.tabs...)
656- sess.mu.Unlock()
657- if len(tabs) == 0 {
658- return
659- }
660-
661- for _, tb := range tabs {
662- tb.mu.Lock()
663- tb.size = tbSize
664- d.applyLayoutLocked(tb)
665- tb.mu.Unlock()
666- }
667- // Only the shown tab's floating terminal tracks the client size. This is
668- // outside tab.mu so its PTY resize cannot block tab state.
669- d.resizeActiveFloating(sess.activeTab())
670- markSnapshotDirty(sess)
671- if ac != nil && schedulePaint {
672- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
673- d.scheduleResizePaintLocked(sess, ac)
674- }
630+ d.requestTransactionalResize(sess, ac, sz, true)
675631 }
676632
677633 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -621,57 +621,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
621 }621 }
622 622
623 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {623 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
624- d.resizeWithPaint(sess, ac, sz, true)624+ d.requestTransactionalResize(sess, ac, sz, false)
625 }625 }
626 626
627-// resizeForFirstPaint applies attach geometry without installing an idle timer:627+// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
628-// firstPaint immediately emits the required full frame itself.628+// firstPaint caller emits its full frame immediately after this commit.
629 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {629 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
630- d.resizeWithPaint(sess, ac, sz, false)630+ d.requestTransactionalResize(sess, ac, sz, true)
631-}
632-
633-func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) {
634- if !sz.Valid() {
635- return
636- }
637- d.exitCopyMode(ac)
638- if ac != nil {
639- // Suspend emission until every pane has adopted this geometry and the
640- // bounded resize paint is armed. PTY readers remain independent.
641- ac.sendMu.Lock()
642- defer ac.sendMu.Unlock()
643- if ac.currentSession() != sess {
644- return
645- }
646- ac.size = sz
647- if rc := sess.renderCoordinator(); rc != nil {
648- rc.attach(ac)
649- rc.recordResizeRequest(sz, ac)
650- }
651- }
652- tbSize := tabSize(sz)
653-
654- sess.mu.Lock()
655- tabs := append([]*tab(nil), sess.tabs...)
656- sess.mu.Unlock()
657- if len(tabs) == 0 {
658- return
659- }
660-
661- for _, tb := range tabs {
662- tb.mu.Lock()
663- tb.size = tbSize
664- d.applyLayoutLocked(tb)
665- tb.mu.Unlock()
666- }
667- // Only the shown tab's floating terminal tracks the client size. This is
668- // outside tab.mu so its PTY resize cannot block tab state.
669- d.resizeActiveFloating(sess.activeTab())
670- markSnapshotDirty(sess)
671- if ac != nil && schedulePaint {
672- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
673- d.scheduleResizePaintLocked(sess, ac)
674- }
675 }631 }
676 632
677 // detachOnSendError drops a client whose transport failed, leaving the session633 // detachOnSendError drops a client whose transport failed, leaving the session
M internal/usecase/daemon/render_coordinator.go +104 -7
@@ -70,9 +70,10 @@ type renderWake struct {
7070 // metadata ownership only — S1/S2 dispatch stays with the retained PR #71
7171 // attachment timer path.
7272 type resizeRequestMetadata struct {
73- size domain.Size
74- source *attachedClient
75- epoch uint64
73+ size domain.Size
74+ source *attachedClient
75+ epoch uint64 // latest requested epoch
76+ committed uint64 // latest published epoch
7677 }
7778
7879 // renderCoordinatorOptions wires one coordinator instance.
@@ -158,8 +159,13 @@ type renderCoordinator struct {
158159 detached bool
159160 torndown bool
160161
161- resize resizeRequestMetadata
162- metrics renderCoordinatorBurstMetrics
162+ resize resizeRequestMetadata
163+ // resizeTimer is independent from render invalidation deadlines. It owns
164+ // resize burst coalescing and is cancelled by the same lifecycle changes.
165+ resizeTimer ports.Timer
166+ resizeCancel chan struct{}
167+ resizeGen uint64
168+ metrics renderCoordinatorBurstMetrics
163169 // generation invalidates callbacks from superseded deadline/watchdog timers.
164170 // Each worker also owns an explicit cancellation channel: fake timers may
165171 // expose a nil or never-firing C channel, so Stop alone cannot release a
@@ -609,7 +615,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
609615 // attachment.
610616 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
611617 c.mu.Lock()
612- var timer ports.Timer
618+ var timer, resizeTimer ports.Timer
613619 if c.attachment == ac {
614620 c.advanceAttachmentEpochLocked(ac)
615621 c.attachment = nil
@@ -624,9 +630,15 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
624630 c.generation++
625631 c.armed = false
626632 timer = c.detachNormalTimerLocked()
633+ if c.resizeCancel != nil {
634+ close(c.resizeCancel)
635+ c.resizeCancel = nil
636+ }
637+ resizeTimer, c.resizeTimer = c.resizeTimer, nil
627638 }
628639 c.mu.Unlock()
629640 stopTimer(timer)
641+ stopTimer(resizeTimer)
630642 }
631643
632644 // noteReplace hands the coordinator from old to replacement; callbacks
@@ -641,7 +653,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
641653 // which predated coordinator ownership. In that case nil has no pending
642654 // identity to invalidate, and the replacement becomes the first bound
643655 // attachment atomically with this lifecycle transition.
644- var timer ports.Timer
656+ var timer, resizeTimer ports.Timer
645657 if c.attachment == old || c.attachment == nil {
646658 c.advanceAttachmentEpochLocked(old, replacement)
647659 c.attachment = replacement
@@ -659,9 +671,15 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
659671 c.generation++
660672 c.armed = false
661673 timer = c.detachNormalTimerLocked()
674+ if c.resizeCancel != nil {
675+ close(c.resizeCancel)
676+ c.resizeCancel = nil
677+ }
678+ resizeTimer, c.resizeTimer = c.resizeTimer, nil
662679 }
663680 c.mu.Unlock()
664681 stopTimer(timer)
682+ stopTimer(resizeTimer)
665683 }
666684
667685 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -677,6 +695,12 @@ func (c *renderCoordinator) noteSessionTeardown() {
677695 c.previewWake = nil
678696 c.previewWakes = nil
679697 c.pending = false
698+ if c.resizeCancel != nil {
699+ close(c.resizeCancel)
700+ c.resizeCancel = nil
701+ }
702+ resizeTimer := c.resizeTimer
703+ c.resizeTimer = nil
680704 c.ackDeferred = false
681705 c.pendingPreview = false
682706 c.generation++
@@ -685,6 +709,7 @@ func (c *renderCoordinator) noteSessionTeardown() {
685709 timers := c.detachSyncTimersLocked()
686710 c.mu.Unlock()
687711 stopTimer(timer)
712+ stopTimer(resizeTimer)
688713 stopTimers(timers)
689714 }
690715
@@ -703,6 +728,78 @@ func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attach
703728 return c.resize.epoch
704729 }
705730
731+// scheduleResize records a latest-wins request and runs apply after the bounded
732+// bulk window. The callback never runs with c.mu held.
733+func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedClient, run func(uint64)) uint64 {
734+ epoch := c.recordResizeRequest(size, source)
735+ if epoch == 0 {
736+ return 0
737+ }
738+ c.mu.Lock()
739+ old := c.resizeTimer
740+ if c.resizeCancel != nil {
741+ close(c.resizeCancel)
742+ }
743+ c.resizeGen++
744+ gen := c.resizeGen
745+ cancel := make(chan struct{})
746+ c.resizeCancel = cancel
747+ clock := c.opts.clock
748+ c.resizeTimer = nil
749+ c.mu.Unlock()
750+ stopTimer(old)
751+ if clock == nil {
752+ run(epoch)
753+ return epoch
754+ }
755+ timer := clock.NewTimer(minOutputRenderDeadline)
756+ if timer.C() == nil {
757+ stopTimer(timer)
758+ run(epoch)
759+ return epoch
760+ }
761+ c.mu.Lock()
762+ if c.resizeGen != gen || c.torndown {
763+ c.mu.Unlock()
764+ stopTimer(timer)
765+ return epoch
766+ }
767+ c.resizeTimer = timer
768+ c.mu.Unlock()
769+ go func() {
770+ select {
771+ case <-timer.C():
772+ case <-cancel:
773+ return
774+ }
775+ c.mu.Lock()
776+ valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source
777+ c.resizeTimer = nil
778+ if c.resizeGen == gen {
779+ c.resizeCancel = nil
780+ }
781+ c.mu.Unlock()
782+ if valid {
783+ run(epoch)
784+ }
785+ }()
786+ return epoch
787+}
788+
789+// resizeCurrent verifies that an apply/commit attempt still owns the newest
790+// request. commit also advances the separately observable committed epoch.
791+func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool {
792+ c.mu.Lock()
793+ defer c.mu.Unlock()
794+ if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch {
795+ return false
796+ }
797+ if commit {
798+ c.resize.committed = epoch
799+ }
800+ return true
801+}
802+
706803 // resizeSnapshot returns a locked copy of the latest resize metadata.
707804 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
708805 c.mu.Lock()
@@ -70,9 +70,10 @@ type renderWake struct {
70 // metadata ownership only — S1/S2 dispatch stays with the retained PR #7170 // metadata ownership only — S1/S2 dispatch stays with the retained PR #71
71 // attachment timer path.71 // attachment timer path.
72 type resizeRequestMetadata struct {72 type resizeRequestMetadata struct {
73- size domain.Size73+ size domain.Size
74- source *attachedClient74+ source *attachedClient
75- epoch uint6475+ epoch uint64 // latest requested epoch
76+ committed uint64 // latest published epoch
76 }77 }
77 78
78 // renderCoordinatorOptions wires one coordinator instance.79 // renderCoordinatorOptions wires one coordinator instance.
@@ -158,8 +159,13 @@ type renderCoordinator struct {
158 detached bool159 detached bool
159 torndown bool160 torndown bool
160 161
161- resize resizeRequestMetadata162+ resize resizeRequestMetadata
162- metrics renderCoordinatorBurstMetrics163+ // resizeTimer is independent from render invalidation deadlines. It owns
164+ // resize burst coalescing and is cancelled by the same lifecycle changes.
165+ resizeTimer ports.Timer
166+ resizeCancel chan struct{}
167+ resizeGen uint64
168+ metrics renderCoordinatorBurstMetrics
163 // generation invalidates callbacks from superseded deadline/watchdog timers.169 // generation invalidates callbacks from superseded deadline/watchdog timers.
164 // Each worker also owns an explicit cancellation channel: fake timers may170 // Each worker also owns an explicit cancellation channel: fake timers may
165 // expose a nil or never-firing C channel, so Stop alone cannot release a171 // expose a nil or never-firing C channel, so Stop alone cannot release a
@@ -609,7 +615,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
609 // attachment.615 // attachment.
610 func (c *renderCoordinator) noteDetach(ac *attachedClient) {616 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
611 c.mu.Lock()617 c.mu.Lock()
612- var timer ports.Timer618+ var timer, resizeTimer ports.Timer
613 if c.attachment == ac {619 if c.attachment == ac {
614 c.advanceAttachmentEpochLocked(ac)620 c.advanceAttachmentEpochLocked(ac)
615 c.attachment = nil621 c.attachment = nil
@@ -624,9 +630,15 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
624 c.generation++630 c.generation++
625 c.armed = false631 c.armed = false
626 timer = c.detachNormalTimerLocked()632 timer = c.detachNormalTimerLocked()
633+ if c.resizeCancel != nil {
634+ close(c.resizeCancel)
635+ c.resizeCancel = nil
636+ }
637+ resizeTimer, c.resizeTimer = c.resizeTimer, nil
627 }638 }
628 c.mu.Unlock()639 c.mu.Unlock()
629 stopTimer(timer)640 stopTimer(timer)
641+ stopTimer(resizeTimer)
630 }642 }
631 643
632 // noteReplace hands the coordinator from old to replacement; callbacks644 // noteReplace hands the coordinator from old to replacement; callbacks
@@ -641,7 +653,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
641 // which predated coordinator ownership. In that case nil has no pending653 // which predated coordinator ownership. In that case nil has no pending
642 // identity to invalidate, and the replacement becomes the first bound654 // identity to invalidate, and the replacement becomes the first bound
643 // attachment atomically with this lifecycle transition.655 // attachment atomically with this lifecycle transition.
644- var timer ports.Timer656+ var timer, resizeTimer ports.Timer
645 if c.attachment == old || c.attachment == nil {657 if c.attachment == old || c.attachment == nil {
646 c.advanceAttachmentEpochLocked(old, replacement)658 c.advanceAttachmentEpochLocked(old, replacement)
647 c.attachment = replacement659 c.attachment = replacement
@@ -659,9 +671,15 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
659 c.generation++671 c.generation++
660 c.armed = false672 c.armed = false
661 timer = c.detachNormalTimerLocked()673 timer = c.detachNormalTimerLocked()
674+ if c.resizeCancel != nil {
675+ close(c.resizeCancel)
676+ c.resizeCancel = nil
677+ }
678+ resizeTimer, c.resizeTimer = c.resizeTimer, nil
662 }679 }
663 c.mu.Unlock()680 c.mu.Unlock()
664 stopTimer(timer)681 stopTimer(timer)
682+ stopTimer(resizeTimer)
665 }683 }
666 684
667 // notePark invalidates pending wakes when the attachment parks for resume.685 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -677,6 +695,12 @@ func (c *renderCoordinator) noteSessionTeardown() {
677 c.previewWake = nil695 c.previewWake = nil
678 c.previewWakes = nil696 c.previewWakes = nil
679 c.pending = false697 c.pending = false
698+ if c.resizeCancel != nil {
699+ close(c.resizeCancel)
700+ c.resizeCancel = nil
701+ }
702+ resizeTimer := c.resizeTimer
703+ c.resizeTimer = nil
680 c.ackDeferred = false704 c.ackDeferred = false
681 c.pendingPreview = false705 c.pendingPreview = false
682 c.generation++706 c.generation++
@@ -685,6 +709,7 @@ func (c *renderCoordinator) noteSessionTeardown() {
685 timers := c.detachSyncTimersLocked()709 timers := c.detachSyncTimersLocked()
686 c.mu.Unlock()710 c.mu.Unlock()
687 stopTimer(timer)711 stopTimer(timer)
712+ stopTimer(resizeTimer)
688 stopTimers(timers)713 stopTimers(timers)
689 }714 }
690 715
@@ -703,6 +728,78 @@ func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attach
703 return c.resize.epoch728 return c.resize.epoch
704 }729 }
705 730
731+// scheduleResize records a latest-wins request and runs apply after the bounded
732+// bulk window. The callback never runs with c.mu held.
733+func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedClient, run func(uint64)) uint64 {
734+ epoch := c.recordResizeRequest(size, source)
735+ if epoch == 0 {
736+ return 0
737+ }
738+ c.mu.Lock()
739+ old := c.resizeTimer
740+ if c.resizeCancel != nil {
741+ close(c.resizeCancel)
742+ }
743+ c.resizeGen++
744+ gen := c.resizeGen
745+ cancel := make(chan struct{})
746+ c.resizeCancel = cancel
747+ clock := c.opts.clock
748+ c.resizeTimer = nil
749+ c.mu.Unlock()
750+ stopTimer(old)
751+ if clock == nil {
752+ run(epoch)
753+ return epoch
754+ }
755+ timer := clock.NewTimer(minOutputRenderDeadline)
756+ if timer.C() == nil {
757+ stopTimer(timer)
758+ run(epoch)
759+ return epoch
760+ }
761+ c.mu.Lock()
762+ if c.resizeGen != gen || c.torndown {
763+ c.mu.Unlock()
764+ stopTimer(timer)
765+ return epoch
766+ }
767+ c.resizeTimer = timer
768+ c.mu.Unlock()
769+ go func() {
770+ select {
771+ case <-timer.C():
772+ case <-cancel:
773+ return
774+ }
775+ c.mu.Lock()
776+ valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source
777+ c.resizeTimer = nil
778+ if c.resizeGen == gen {
779+ c.resizeCancel = nil
780+ }
781+ c.mu.Unlock()
782+ if valid {
783+ run(epoch)
784+ }
785+ }()
786+ return epoch
787+}
788+
789+// resizeCurrent verifies that an apply/commit attempt still owns the newest
790+// request. commit also advances the separately observable committed epoch.
791+func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool {
792+ c.mu.Lock()
793+ defer c.mu.Unlock()
794+ if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch {
795+ return false
796+ }
797+ if commit {
798+ c.resize.committed = epoch
799+ }
800+ return true
801+}
802+
706 // resizeSnapshot returns a locked copy of the latest resize metadata.803 // resizeSnapshot returns a locked copy of the latest resize metadata.
707 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {804 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
708 c.mu.Lock()805 c.mu.Lock()
A internal/usecase/daemon/transactional_resize.go +153 -0
@@ -0,0 +1,153 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// resizeMember is an unpublished layout decision. Prepare only reads guarded
9+// state; apply performs the external PTY call; commit is the sole publisher.
10+type resizeMember struct {
11+ tab *tab
12+ pane *pane
13+ rect domain.Rect
14+ floating floatingGeometry
15+ isFloating bool
16+ ok bool
17+}
18+
19+type resizePlan struct {
20+ size domain.Size
21+ tabs []*tab
22+ members []resizeMember
23+}
24+
25+func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
26+ plan := resizePlan{size: size}
27+ sess.mu.Lock()
28+ plan.tabs = append(plan.tabs, sess.tabs...)
29+ active := sess.active
30+ sess.mu.Unlock()
31+ for i, tb := range plan.tabs {
32+ tb.mu.Lock()
33+ if tb.tree != nil && tb.tree.Root != nil {
34+ ts := tabSize(size)
35+ area := domain.Rect{Width: ts.Cols, Height: ts.Rows}
36+ if placements, ok := layout.Solve(tb.tree.Root, area); ok {
37+ for _, pl := range placements {
38+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
39+ if p := tb.panes[pl.ID]; p != nil {
40+ plan.members = append(plan.members, resizeMember{tab: tb, pane: p, rect: pl.Content})
41+ }
42+ }
43+ }
44+ }
45+ }
46+ if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
47+ g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
48+ if g.valid() {
49+ plan.members = append(plan.members, resizeMember{tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})
50+ }
51+ }
52+ tb.mu.Unlock()
53+ }
54+ return plan
55+}
56+
57+// applyResize deliberately holds only a pane's resizeMu around its PTY call;
58+// no daemon, session, tab, or pane lock crosses the external boundary.
59+func (d *Daemon) applyResize(plan *resizePlan) {
60+ for i := range plan.members {
61+ m := &plan.members[i]
62+ m.pane.resizeMu.Lock()
63+ m.pane.mu.Lock()
64+ old, pty := m.pane.rect, m.pane.pty
65+ m.pane.mu.Unlock()
66+ if old.Width == m.rect.Width && old.Height == m.rect.Height {
67+ m.ok = true
68+ } else if pty == nil {
69+ m.ok = true
70+ } else if err := pty.Resize(rectSize(m.rect)); err != nil {
71+ d.log.Warn("pty resize failed", "err", err)
72+ } else {
73+ m.ok = true
74+ }
75+ m.pane.resizeMu.Unlock()
76+ }
77+}
78+
79+func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {
80+ // Publish tab layout and every rectangle together only after the coordinator
81+ // has rejected obsolete work. A later request may have applied already, but
82+ // it can never publish through this epoch.
83+ for _, tb := range plan.tabs {
84+ tb.mu.Lock()
85+ tb.size = tabSize(plan.size)
86+ tb.mu.Unlock()
87+ }
88+ for _, m := range plan.members {
89+ m.pane.mu.Lock()
90+ m.pane.rect = m.rect
91+ if m.isFloating {
92+ m.pane.popupGeometry = m.floating
93+ }
94+ if m.ok {
95+ m.pane.screen.Resize(m.rect.Width, m.rect.Height)
96+ }
97+ m.pane.mu.Unlock()
98+ }
99+ if ac != nil {
100+ ac.sendMu.Lock()
101+ ac.size = plan.size
102+ ac.sendMu.Unlock()
103+ }
104+ markSnapshotDirty(sess)
105+}
106+
107+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {
108+ rc := sess.renderCoordinator()
109+ if rc == nil {
110+ return
111+ }
112+ snap := rc.resizeSnapshot()
113+ if !rc.resizeCurrent(epoch, ac, false) {
114+ return
115+ }
116+ d.exitCopyMode(ac)
117+ plan := d.prepareResize(sess, snap.size)
118+ if !rc.resizeCurrent(epoch, ac, false) {
119+ return
120+ }
121+ d.applyResize(&plan)
122+ if !rc.resizeCurrent(epoch, ac, true) {
123+ return
124+ }
125+ d.commitResize(sess, ac, plan)
126+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
127+ // A successful transaction publishes exactly one full S2 frame. The
128+ // coordinator is the only emission route and stale epochs never reach it.
129+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
130+}
131+
132+func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {
133+ if sess == nil || !size.Valid() {
134+ return
135+ }
136+ if ac == nil {
137+ // Headless geometry has no coordinator/transport to coalesce, but keeps
138+ // the same prepare/apply/commit ordering.
139+ plan := d.prepareResize(sess, size)
140+ d.applyResize(&plan)
141+ d.commitResize(sess, nil, plan)
142+ return
143+ }
144+ rc := d.attachCoordinator(sess, nil, ac, true)
145+ if immediate {
146+ epoch := rc.recordResizeRequest(size, ac)
147+ if epoch != 0 {
148+ d.runResizeTransaction(sess, ac, epoch)
149+ }
150+ return
151+ }
152+ rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) })
153+}
@@ -0,0 +1,153 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// resizeMember is an unpublished layout decision. Prepare only reads guarded
9+// state; apply performs the external PTY call; commit is the sole publisher.
10+type resizeMember struct {
11+ tab *tab
12+ pane *pane
13+ rect domain.Rect
14+ floating floatingGeometry
15+ isFloating bool
16+ ok bool
17+}
18+
19+type resizePlan struct {
20+ size domain.Size
21+ tabs []*tab
22+ members []resizeMember
23+}
24+
25+func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
26+ plan := resizePlan{size: size}
27+ sess.mu.Lock()
28+ plan.tabs = append(plan.tabs, sess.tabs...)
29+ active := sess.active
30+ sess.mu.Unlock()
31+ for i, tb := range plan.tabs {
32+ tb.mu.Lock()
33+ if tb.tree != nil && tb.tree.Root != nil {
34+ ts := tabSize(size)
35+ area := domain.Rect{Width: ts.Cols, Height: ts.Rows}
36+ if placements, ok := layout.Solve(tb.tree.Root, area); ok {
37+ for _, pl := range placements {
38+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
39+ if p := tb.panes[pl.ID]; p != nil {
40+ plan.members = append(plan.members, resizeMember{tab: tb, pane: p, rect: pl.Content})
41+ }
42+ }
43+ }
44+ }
45+ }
46+ if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
47+ g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
48+ if g.valid() {
49+ plan.members = append(plan.members, resizeMember{tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})
50+ }
51+ }
52+ tb.mu.Unlock()
53+ }
54+ return plan
55+}
56+
57+// applyResize deliberately holds only a pane's resizeMu around its PTY call;
58+// no daemon, session, tab, or pane lock crosses the external boundary.
59+func (d *Daemon) applyResize(plan *resizePlan) {
60+ for i := range plan.members {
61+ m := &plan.members[i]
62+ m.pane.resizeMu.Lock()
63+ m.pane.mu.Lock()
64+ old, pty := m.pane.rect, m.pane.pty
65+ m.pane.mu.Unlock()
66+ if old.Width == m.rect.Width && old.Height == m.rect.Height {
67+ m.ok = true
68+ } else if pty == nil {
69+ m.ok = true
70+ } else if err := pty.Resize(rectSize(m.rect)); err != nil {
71+ d.log.Warn("pty resize failed", "err", err)
72+ } else {
73+ m.ok = true
74+ }
75+ m.pane.resizeMu.Unlock()
76+ }
77+}
78+
79+func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {
80+ // Publish tab layout and every rectangle together only after the coordinator
81+ // has rejected obsolete work. A later request may have applied already, but
82+ // it can never publish through this epoch.
83+ for _, tb := range plan.tabs {
84+ tb.mu.Lock()
85+ tb.size = tabSize(plan.size)
86+ tb.mu.Unlock()
87+ }
88+ for _, m := range plan.members {
89+ m.pane.mu.Lock()
90+ m.pane.rect = m.rect
91+ if m.isFloating {
92+ m.pane.popupGeometry = m.floating
93+ }
94+ if m.ok {
95+ m.pane.screen.Resize(m.rect.Width, m.rect.Height)
96+ }
97+ m.pane.mu.Unlock()
98+ }
99+ if ac != nil {
100+ ac.sendMu.Lock()
101+ ac.size = plan.size
102+ ac.sendMu.Unlock()
103+ }
104+ markSnapshotDirty(sess)
105+}
106+
107+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {
108+ rc := sess.renderCoordinator()
109+ if rc == nil {
110+ return
111+ }
112+ snap := rc.resizeSnapshot()
113+ if !rc.resizeCurrent(epoch, ac, false) {
114+ return
115+ }
116+ d.exitCopyMode(ac)
117+ plan := d.prepareResize(sess, snap.size)
118+ if !rc.resizeCurrent(epoch, ac, false) {
119+ return
120+ }
121+ d.applyResize(&plan)
122+ if !rc.resizeCurrent(epoch, ac, true) {
123+ return
124+ }
125+ d.commitResize(sess, ac, plan)
126+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
127+ // A successful transaction publishes exactly one full S2 frame. The
128+ // coordinator is the only emission route and stale epochs never reach it.
129+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
130+}
131+
132+func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {
133+ if sess == nil || !size.Valid() {
134+ return
135+ }
136+ if ac == nil {
137+ // Headless geometry has no coordinator/transport to coalesce, but keeps
138+ // the same prepare/apply/commit ordering.
139+ plan := d.prepareResize(sess, size)
140+ d.applyResize(&plan)
141+ d.commitResize(sess, nil, plan)
142+ return
143+ }
144+ rc := d.attachCoordinator(sess, nil, ac, true)
145+ if immediate {
146+ epoch := rc.recordResizeRequest(size, ac)
147+ if epoch != 0 {
148+ d.runResizeTransaction(sess, ac, epoch)
149+ }
150+ return
151+ }
152+ rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) })
153+}

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