detached bc8142c

fix(daemon): preserve committed floating geometry

commit
bc8142c6ed80d339953843499e756a6d10b31195
author
brice · · 2026-07-10 10:58 UTC
parent
c22bfd8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +122 -35
M internal/usecase/daemon/floating.go +14 -9
@@ -132,11 +132,15 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane,
132132 if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil {
133133 return nil, floatingGeometry{}, false
134134 }
135- geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
135+ p := tb.floating.pane
136+ desired := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
137+ p.mu.Lock()
138+ geometry := p.committedFloatingGeometryLocked(desired)
139+ p.mu.Unlock()
136140 if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
137141 return nil, floatingGeometry{}, false
138142 }
139- return tb.floating.pane, geometry, true
143+ return p, geometry, true
140144 }
141145
142146 // ensureFloatingWarm starts the background prewarm exactly once for a tab.
@@ -217,7 +221,7 @@ type floatingLaunchSpec struct {
217221 sessionName string
218222 cwd string
219223 size domain.Size
220- innerRect domain.Rect
224+ geometry floatingGeometry
221225 paneStableID string
222226 env []string
223227 command string
@@ -246,11 +250,11 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
246250 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
247251 sess.mu.Unlock()
248252 tb.mu.Lock()
249- inner := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg).Inner
250- size := rectSize(inner)
253+ geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
254+ size := rectSize(geometry.Inner)
251255 if !size.Valid() {
252- inner = domain.Rect{Width: 1, Height: 1}
253- size = rectSize(inner)
256+ geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}}
257+ size = rectSize(geometry.Inner)
254258 }
255259 focused := tb.focusedPane()
256260 tabStableID, tabCtx := tb.stableID, tb.ctx
@@ -281,7 +285,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
281285 sessionName: name,
282286 cwd: cwd,
283287 size: size,
284- innerRect: inner,
288+ geometry: geometry,
285289 paneStableID: paneStableID,
286290 env: d.childEnv(name, tabStableID, paneStableID, term),
287291 command: command,
@@ -301,7 +305,8 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
301305 return
302306 }
303307 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
304- p.rect = spec.innerRect
308+ p.rect = spec.geometry.Inner
309+ p.popupGeometry = spec.geometry
305310 p.title.displayFallback = spec.fallback
306311 p.ctx, p.cancel = context.WithCancel(spec.parentCtx)
307312 // The reader may run as soon as install returns; make its exit policy
@@ -132,11 +132,15 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane,
132 if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil {132 if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil {
133 return nil, floatingGeometry{}, false133 return nil, floatingGeometry{}, false
134 }134 }
135- geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)135+ p := tb.floating.pane
136+ desired := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
137+ p.mu.Lock()
138+ geometry := p.committedFloatingGeometryLocked(desired)
139+ p.mu.Unlock()
136 if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {140 if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
137 return nil, floatingGeometry{}, false141 return nil, floatingGeometry{}, false
138 }142 }
139- return tb.floating.pane, geometry, true143+ return p, geometry, true
140 }144 }
141 145
142 // ensureFloatingWarm starts the background prewarm exactly once for a tab.146 // ensureFloatingWarm starts the background prewarm exactly once for a tab.
@@ -217,7 +221,7 @@ type floatingLaunchSpec struct {
217 sessionName string221 sessionName string
218 cwd string222 cwd string
219 size domain.Size223 size domain.Size
220- innerRect domain.Rect224+ geometry floatingGeometry
221 paneStableID string225 paneStableID string
222 env []string226 env []string
223 command string227 command string
@@ -246,11 +250,11 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
246 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx250 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
247 sess.mu.Unlock()251 sess.mu.Unlock()
248 tb.mu.Lock()252 tb.mu.Lock()
249- inner := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg).Inner253+ geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
250- size := rectSize(inner)254+ size := rectSize(geometry.Inner)
251 if !size.Valid() {255 if !size.Valid() {
252- inner = domain.Rect{Width: 1, Height: 1}256+ geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}}
253- size = rectSize(inner)257+ size = rectSize(geometry.Inner)
254 }258 }
255 focused := tb.focusedPane()259 focused := tb.focusedPane()
256 tabStableID, tabCtx := tb.stableID, tb.ctx260 tabStableID, tabCtx := tb.stableID, tb.ctx
@@ -281,7 +285,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
281 sessionName: name,285 sessionName: name,
282 cwd: cwd,286 cwd: cwd,
283 size: size,287 size: size,
284- innerRect: inner,288+ geometry: geometry,
285 paneStableID: paneStableID,289 paneStableID: paneStableID,
286 env: d.childEnv(name, tabStableID, paneStableID, term),290 env: d.childEnv(name, tabStableID, paneStableID, term),
287 command: command,291 command: command,
@@ -301,7 +305,8 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
301 return305 return
302 }306 }
303 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)307 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
304- p.rect = spec.innerRect308+ p.rect = spec.geometry.Inner
309+ p.popupGeometry = spec.geometry
305 p.title.displayFallback = spec.fallback310 p.title.displayFallback = spec.fallback
306 p.ctx, p.cancel = context.WithCancel(spec.parentCtx)311 p.ctx, p.cancel = context.WithCancel(spec.parentCtx)
307 // The reader may run as soon as install returns; make its exit policy312 // The reader may run as soon as install returns; make its exit policy
M internal/usecase/daemon/floating_render.go +21 -4
@@ -14,6 +14,20 @@ type floatingGeometry struct {
1414 Inner domain.Rect
1515 }
1616
17+func (g floatingGeometry) valid() bool {
18+ return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
19+}
20+
21+// committedFloatingGeometryLocked returns the geometry whose PTY resize was
22+// last committed. Newly constructed test panes fall back to the requested
23+// geometry until their first resize. The caller holds p.mu.
24+func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {
25+ if p.popupGeometry.valid() {
26+ return p.popupGeometry
27+ }
28+ return fallback
29+}
30+
1731 // floatingAxisGeometry is the canonical percentage and border calculation for
1832 // one popup axis. Popups smaller than three cells spend no cells on borders.
1933 type floatingAxisGeometry struct {
@@ -76,7 +90,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
7690 }
7791 }
7892 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
79- geometry := calculateFloatingGeometry(content, cfg)
93+ desiredGeometry := calculateFloatingGeometry(content, cfg)
8094
8195 popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation
8296
@@ -84,6 +98,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
8498 // every read of them, including the blit, in the same critical section.
8599 // In particular, Frame is not safe to retain as an alias after unlocking.
86100 p.mu.Lock()
101+ geometry := p.committedFloatingGeometryLocked(desiredGeometry)
87102 title := p.displayTitleLocked()
88103 titleGeneration := p.title.generation
89104 screenDamage := p.screen.Damage()
@@ -140,8 +155,9 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string,
140155
141156 // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
142157 // is intentionally outside pane.mu; failed resizes do not publish any state.
143-func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool {
144- if p == nil || inner.Width <= 0 || inner.Height <= 0 {
158+func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool {
159+ inner := geometry.Inner
160+ if p == nil || !geometry.valid() {
145161 return false
146162 }
147163 p.resizeMu.Lock()
@@ -163,6 +179,7 @@ func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool {
163179 p.mu.Lock()
164180 p.screen.Resize(inner.Width, inner.Height)
165181 p.rect = inner
182+ p.popupGeometry = geometry
166183 p.mu.Unlock()
167184 return true
168185 }
@@ -180,7 +197,7 @@ func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
180197 if p == nil {
181198 return false
182199 }
183- return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner)
200+ return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()))
184201 }
185202
186203 // resizeActiveFloating updates a visible floating pane during a client resize.
@@ -14,6 +14,20 @@ type floatingGeometry struct {
14 Inner domain.Rect14 Inner domain.Rect
15 }15 }
16 16
17+func (g floatingGeometry) valid() bool {
18+ return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
19+}
20+
21+// committedFloatingGeometryLocked returns the geometry whose PTY resize was
22+// last committed. Newly constructed test panes fall back to the requested
23+// geometry until their first resize. The caller holds p.mu.
24+func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {
25+ if p.popupGeometry.valid() {
26+ return p.popupGeometry
27+ }
28+ return fallback
29+}
30+
17 // floatingAxisGeometry is the canonical percentage and border calculation for31 // floatingAxisGeometry is the canonical percentage and border calculation for
18 // one popup axis. Popups smaller than three cells spend no cells on borders.32 // one popup axis. Popups smaller than three cells spend no cells on borders.
19 type floatingAxisGeometry struct {33 type floatingAxisGeometry struct {
@@ -76,7 +90,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
76 }90 }
77 }91 }
78 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)92 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
79- geometry := calculateFloatingGeometry(content, cfg)93+ desiredGeometry := calculateFloatingGeometry(content, cfg)
80 94
81 popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation95 popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation
82 96
@@ -84,6 +98,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
84 // every read of them, including the blit, in the same critical section.98 // every read of them, including the blit, in the same critical section.
85 // In particular, Frame is not safe to retain as an alias after unlocking.99 // In particular, Frame is not safe to retain as an alias after unlocking.
86 p.mu.Lock()100 p.mu.Lock()
101+ geometry := p.committedFloatingGeometryLocked(desiredGeometry)
87 title := p.displayTitleLocked()102 title := p.displayTitleLocked()
88 titleGeneration := p.title.generation103 titleGeneration := p.title.generation
89 screenDamage := p.screen.Damage()104 screenDamage := p.screen.Damage()
@@ -140,8 +155,9 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string,
140 155
141 // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize156 // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
142 // is intentionally outside pane.mu; failed resizes do not publish any state.157 // is intentionally outside pane.mu; failed resizes do not publish any state.
143-func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool {158+func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool {
144- if p == nil || inner.Width <= 0 || inner.Height <= 0 {159+ inner := geometry.Inner
160+ if p == nil || !geometry.valid() {
145 return false161 return false
146 }162 }
147 p.resizeMu.Lock()163 p.resizeMu.Lock()
@@ -163,6 +179,7 @@ func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool {
163 p.mu.Lock()179 p.mu.Lock()
164 p.screen.Resize(inner.Width, inner.Height)180 p.screen.Resize(inner.Width, inner.Height)
165 p.rect = inner181 p.rect = inner
182+ p.popupGeometry = geometry
166 p.mu.Unlock()183 p.mu.Unlock()
167 return true184 return true
168 }185 }
@@ -180,7 +197,7 @@ func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
180 if p == nil {197 if p == nil {
181 return false198 return false
182 }199 }
183- return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner)200+ return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()))
184 }201 }
185 202
186 // resizeActiveFloating updates a visible floating pane during a client resize.203 // resizeActiveFloating updates a visible floating pane during a client resize.
M internal/usecase/daemon/floating_render_test.go +70 -16
@@ -6,7 +6,6 @@ import (
66 "strings"
77 "sync"
88 "testing"
9- "time"
109
1110 "github.com/stretchr/testify/require"
1211
@@ -214,11 +213,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
214213 tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
215214 sess := &session{tabs: []*tab{tb}}
216215
217- initial := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()).Inner
218- current := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()).Inner
216+ initialGeometry := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig())
217+ currentGeometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig())
218+ initial := initialGeometry.Inner
219+ current := currentGeometry.Inner
219220 pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}}
220221 floating := newPane("floating", pty, rectSize(initial))
221222 floating.rect = initial
223+ floating.popupGeometry = initialGeometry
222224
223225 // Prewarming installs a hidden pane at the initial geometry.
224226 tb.mu.Lock()
@@ -238,6 +240,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
238240 require.Equal(t, initial, floating.rect)
239241 require.Equal(t, initial.Width, floating.screen.Frame.Width)
240242 require.Equal(t, initial.Height, floating.screen.Frame.Height)
243+ require.Equal(t, initialGeometry, floating.popupGeometry)
241244
242245 require.NoError(t, d.toggleFloating(sess, nil)) // hide
243246 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
@@ -245,6 +248,39 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
245248 require.Equal(t, current, floating.rect)
246249 require.Equal(t, current.Width, floating.screen.Frame.Width)
247250 require.Equal(t, current.Height, floating.screen.Frame.Height)
251+ require.Equal(t, currentGeometry, floating.popupGeometry)
252+}
253+
254+func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
255+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
256+ oldContent := domain.Rect{Y: 1, Width: 80, Height: 24}
257+ newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
258+ oldGeometry := calculateFloatingGeometry(oldContent, cfg)
259+ newGeometry := calculateFloatingGeometry(newContent, cfg)
260+ pty := &resizePTY{err: errors.New("resize failed")}
261+ p := newPane("floating", pty, rectSize(oldGeometry.Inner))
262+ p.rect = oldGeometry.Inner
263+ p.popupGeometry = oldGeometry
264+ tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height})
265+ installTestFloating(tb, p, true)
266+ d := newTestDaemon(t, nil, stubClock{})
267+ d.ApplyConfig(domain.Config{Floating: cfg})
268+
269+ require.False(t, d.resizeFloatingPane(p, newGeometry))
270+ tb.mu.Lock()
271+ _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
272+ copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, cfg)
273+ tb.mu.Unlock()
274+ require.True(t, visible)
275+ require.Equal(t, oldGeometry, inputGeometry)
276+ require.Equal(t, oldGeometry.Inner, copyRect)
277+
278+ base := renderer.NewFrame(newContent.Width, newContent.Height+2)
279+ frame, _ := composeFloatingFrame(base, nil, p, 1, newContent, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true)
280+ require.Equal(t, '┌', frame.At(oldGeometry.Bounds.X, oldGeometry.Bounds.Y).Rune)
281+ if newGeometry.Bounds.X != oldGeometry.Bounds.X || newGeometry.Bounds.Y != oldGeometry.Bounds.Y {
282+ require.NotEqual(t, '┌', frame.At(newGeometry.Bounds.X, newGeometry.Bounds.Y).Rune)
283+ }
248284 }
249285
250286 func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
@@ -253,7 +289,8 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
253289 p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})
254290 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
255291 d := newTestDaemon(t, nil, stubClock{})
256- require.False(t, d.resizeFloatingPane(p, domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}))
292+ requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
293+ require.False(t, d.resizeFloatingPane(p, requested))
257294 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
258295 require.Equal(t, 5, p.screen.Frame.Width)
259296 require.Equal(t, 4, p.screen.Frame.Height)
@@ -263,34 +300,44 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
263300 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
264301 p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})
265302 d := newTestDaemon(t, nil, stubClock{})
266- first := domain.Rect{Width: 4, Height: 3}
267- second := domain.Rect{Width: 8, Height: 6}
303+ first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}}
304+ second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}}
268305 done1 := make(chan bool, 1)
269306 done2 := make(chan bool, 1)
307+ secondStarted := make(chan struct{})
270308 go func() { done1 <- d.resizeFloatingPane(p, first) }()
271309 <-pty.entered
272- go func() { done2 <- d.resizeFloatingPane(p, second) }()
273- require.Never(t, func() bool { return pty.calls() > 1 }, 30*time.Millisecond, time.Millisecond)
310+ go func() {
311+ close(secondStarted)
312+ done2 <- d.resizeFloatingPane(p, second)
313+ }()
314+ <-secondStarted
274315 close(pty.release)
275316 require.True(t, <-done1)
276317 require.True(t, <-done2)
277- require.Equal(t, second, p.rect)
318+ require.Equal(t, second.Inner, p.rect)
319+ require.Equal(t, second, p.popupGeometry)
320+ require.Equal(t, 1, pty.maxConcurrentCalls())
278321 require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
279322 })
280323 }
281324
282325 type resizePTY struct {
283- mu sync.Mutex
284- resizes []domain.Size
285- err error
286- errs []error
287- entered chan struct{}
288- release chan struct{}
326+ mu sync.Mutex
327+ resizes []domain.Size
328+ err error
329+ errs []error
330+ entered chan struct{}
331+ release chan struct{}
332+ activeCalls int
333+ maxConcurrent int
289334 }
290335
291336 func (p *resizePTY) Resize(sz domain.Size) error {
292337 p.mu.Lock()
293338 p.resizes = append(p.resizes, sz)
339+ p.activeCalls++
340+ p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)
294341 n := len(p.resizes)
295342 err := p.err
296343 if n <= len(p.errs) {
@@ -301,9 +348,16 @@ func (p *resizePTY) Resize(sz domain.Size) error {
301348 close(p.entered)
302349 <-p.release
303350 }
351+ p.mu.Lock()
352+ p.activeCalls--
353+ p.mu.Unlock()
304354 return err
305355 }
306-func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) }
356+func (p *resizePTY) maxConcurrentCalls() int {
357+ p.mu.Lock()
358+ defer p.mu.Unlock()
359+ return p.maxConcurrent
360+}
307361 func (p *resizePTY) sizes() []domain.Size {
308362 p.mu.Lock()
309363 defer p.mu.Unlock()
@@ -6,7 +6,6 @@ import (
6 "strings"6 "strings"
7 "sync"7 "sync"
8 "testing"8 "testing"
9- "time"
10 9
11 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
12 11
@@ -214,11 +213,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
214 tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})213 tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
215 sess := &session{tabs: []*tab{tb}}214 sess := &session{tabs: []*tab{tb}}
216 215
217- initial := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()).Inner216+ initialGeometry := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig())
218- current := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()).Inner217+ currentGeometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig())
218+ initial := initialGeometry.Inner
219+ current := currentGeometry.Inner
219 pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}}220 pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}}
220 floating := newPane("floating", pty, rectSize(initial))221 floating := newPane("floating", pty, rectSize(initial))
221 floating.rect = initial222 floating.rect = initial
223+ floating.popupGeometry = initialGeometry
222 224
223 // Prewarming installs a hidden pane at the initial geometry.225 // Prewarming installs a hidden pane at the initial geometry.
224 tb.mu.Lock()226 tb.mu.Lock()
@@ -238,6 +240,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
238 require.Equal(t, initial, floating.rect)240 require.Equal(t, initial, floating.rect)
239 require.Equal(t, initial.Width, floating.screen.Frame.Width)241 require.Equal(t, initial.Width, floating.screen.Frame.Width)
240 require.Equal(t, initial.Height, floating.screen.Frame.Height)242 require.Equal(t, initial.Height, floating.screen.Frame.Height)
243+ require.Equal(t, initialGeometry, floating.popupGeometry)
241 244
242 require.NoError(t, d.toggleFloating(sess, nil)) // hide245 require.NoError(t, d.toggleFloating(sess, nil)) // hide
243 require.NoError(t, d.toggleFloating(sess, nil)) // retry show246 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
@@ -245,6 +248,39 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
245 require.Equal(t, current, floating.rect)248 require.Equal(t, current, floating.rect)
246 require.Equal(t, current.Width, floating.screen.Frame.Width)249 require.Equal(t, current.Width, floating.screen.Frame.Width)
247 require.Equal(t, current.Height, floating.screen.Frame.Height)250 require.Equal(t, current.Height, floating.screen.Frame.Height)
251+ require.Equal(t, currentGeometry, floating.popupGeometry)
252+}
253+
254+func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
255+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
256+ oldContent := domain.Rect{Y: 1, Width: 80, Height: 24}
257+ newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
258+ oldGeometry := calculateFloatingGeometry(oldContent, cfg)
259+ newGeometry := calculateFloatingGeometry(newContent, cfg)
260+ pty := &resizePTY{err: errors.New("resize failed")}
261+ p := newPane("floating", pty, rectSize(oldGeometry.Inner))
262+ p.rect = oldGeometry.Inner
263+ p.popupGeometry = oldGeometry
264+ tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height})
265+ installTestFloating(tb, p, true)
266+ d := newTestDaemon(t, nil, stubClock{})
267+ d.ApplyConfig(domain.Config{Floating: cfg})
268+
269+ require.False(t, d.resizeFloatingPane(p, newGeometry))
270+ tb.mu.Lock()
271+ _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
272+ copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, cfg)
273+ tb.mu.Unlock()
274+ require.True(t, visible)
275+ require.Equal(t, oldGeometry, inputGeometry)
276+ require.Equal(t, oldGeometry.Inner, copyRect)
277+
278+ base := renderer.NewFrame(newContent.Width, newContent.Height+2)
279+ frame, _ := composeFloatingFrame(base, nil, p, 1, newContent, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true)
280+ require.Equal(t, '┌', frame.At(oldGeometry.Bounds.X, oldGeometry.Bounds.Y).Rune)
281+ if newGeometry.Bounds.X != oldGeometry.Bounds.X || newGeometry.Bounds.Y != oldGeometry.Bounds.Y {
282+ require.NotEqual(t, '┌', frame.At(newGeometry.Bounds.X, newGeometry.Bounds.Y).Rune)
283+ }
248 }284 }
249 285
250 func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {286 func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
@@ -253,7 +289,8 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
253 p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})289 p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})
254 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}290 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
255 d := newTestDaemon(t, nil, stubClock{})291 d := newTestDaemon(t, nil, stubClock{})
256- require.False(t, d.resizeFloatingPane(p, domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}))292+ requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
293+ require.False(t, d.resizeFloatingPane(p, requested))
257 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)294 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
258 require.Equal(t, 5, p.screen.Frame.Width)295 require.Equal(t, 5, p.screen.Frame.Width)
259 require.Equal(t, 4, p.screen.Frame.Height)296 require.Equal(t, 4, p.screen.Frame.Height)
@@ -263,34 +300,44 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
263 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}300 pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
264 p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})301 p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})
265 d := newTestDaemon(t, nil, stubClock{})302 d := newTestDaemon(t, nil, stubClock{})
266- first := domain.Rect{Width: 4, Height: 3}303+ first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}}
267- second := domain.Rect{Width: 8, Height: 6}304+ second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}}
268 done1 := make(chan bool, 1)305 done1 := make(chan bool, 1)
269 done2 := make(chan bool, 1)306 done2 := make(chan bool, 1)
307+ secondStarted := make(chan struct{})
270 go func() { done1 <- d.resizeFloatingPane(p, first) }()308 go func() { done1 <- d.resizeFloatingPane(p, first) }()
271 <-pty.entered309 <-pty.entered
272- go func() { done2 <- d.resizeFloatingPane(p, second) }()310+ go func() {
273- require.Never(t, func() bool { return pty.calls() > 1 }, 30*time.Millisecond, time.Millisecond)311+ close(secondStarted)
312+ done2 <- d.resizeFloatingPane(p, second)
313+ }()
314+ <-secondStarted
274 close(pty.release)315 close(pty.release)
275 require.True(t, <-done1)316 require.True(t, <-done1)
276 require.True(t, <-done2)317 require.True(t, <-done2)
277- require.Equal(t, second, p.rect)318+ require.Equal(t, second.Inner, p.rect)
319+ require.Equal(t, second, p.popupGeometry)
320+ require.Equal(t, 1, pty.maxConcurrentCalls())
278 require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())321 require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
279 })322 })
280 }323 }
281 324
282 type resizePTY struct {325 type resizePTY struct {
283- mu sync.Mutex326+ mu sync.Mutex
284- resizes []domain.Size327+ resizes []domain.Size
285- err error328+ err error
286- errs []error329+ errs []error
287- entered chan struct{}330+ entered chan struct{}
288- release chan struct{}331+ release chan struct{}
332+ activeCalls int
333+ maxConcurrent int
289 }334 }
290 335
291 func (p *resizePTY) Resize(sz domain.Size) error {336 func (p *resizePTY) Resize(sz domain.Size) error {
292 p.mu.Lock()337 p.mu.Lock()
293 p.resizes = append(p.resizes, sz)338 p.resizes = append(p.resizes, sz)
339+ p.activeCalls++
340+ p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)
294 n := len(p.resizes)341 n := len(p.resizes)
295 err := p.err342 err := p.err
296 if n <= len(p.errs) {343 if n <= len(p.errs) {
@@ -301,9 +348,16 @@ func (p *resizePTY) Resize(sz domain.Size) error {
301 close(p.entered)348 close(p.entered)
302 <-p.release349 <-p.release
303 }350 }
351+ p.mu.Lock()
352+ p.activeCalls--
353+ p.mu.Unlock()
304 return err354 return err
305 }355 }
306-func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) }356+func (p *resizePTY) maxConcurrentCalls() int {
357+ p.mu.Lock()
358+ defer p.mu.Unlock()
359+ return p.maxConcurrent
360+}
307 func (p *resizePTY) sizes() []domain.Size {361 func (p *resizePTY) sizes() []domain.Size {
308 p.mu.Lock()362 p.mu.Lock()
309 defer p.mu.Unlock()363 defer p.mu.Unlock()
M internal/usecase/daemon/pane.go +1 -0
@@ -27,6 +27,7 @@ type pane struct {
2727 flush chan struct{}
2828 syncGen uint64
2929 rect domain.Rect
30+ popupGeometry floatingGeometry // last geometry committed after a successful floating resize
3031 title paneTitleState
3132 ctx context.Context
3233 cancel context.CancelFunc
@@ -27,6 +27,7 @@ type pane struct {
27 flush chan struct{}27 flush chan struct{}
28 syncGen uint6428 syncGen uint64
29 rect domain.Rect29 rect domain.Rect
30+ popupGeometry floatingGeometry // last geometry committed after a successful floating resize
30 title paneTitleState31 title paneTitleState
31 ctx context.Context32 ctx context.Context
32 cancel context.CancelFunc33 cancel context.CancelFunc
M internal/usecase/daemon/render.go +16 -6
@@ -283,7 +283,11 @@ func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect,
283283 return domain.Rect{}
284284 }
285285 if hasFloating && p == floating {
286- return calculateFloatingGeometry(contentArea, cfg).Inner
286+ desired := calculateFloatingGeometry(contentArea, cfg)
287+ p.mu.Lock()
288+ geometry := p.committedFloatingGeometryLocked(desired)
289+ p.mu.Unlock()
290+ return geometry.Inner
287291 }
288292 for _, placement := range layoutSnap.placements {
289293 if placement.ID == p.id && !placement.Collapsed {
@@ -373,9 +377,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
373377 ac.sendMu.Unlock()
374378 return
375379 }
376- // Recheck the slot after refreshing its title outside the tab lock.
380+ // Recheck the slot after refreshing its title outside the tab lock. One
381+ // paint uses one immutable floating config snapshot across composition,
382+ // copy targeting, and cursor placement.
377383 floating = tb.floating.pane
378384 hasFloating = tb.floating.state == floatingVisible && floating != nil
385+ floatingCfg := d.currentFloatingConfig()
379386 overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
380387 if reset || overlayActive {
381388 ac.bars.Reset()
@@ -393,14 +400,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
393400 }
394401 contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
395402 if hasFloating {
396- frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, d.currentFloatingConfig(), layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
403+ frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, floatingCfg, layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
397404 }
398405 if overlays.copyActive {
399406 copyPane := overlays.copyPane
400407 if copyPane == nil {
401408 copyPane = p
402409 }
403- copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, d.currentFloatingConfig())
410+ copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg)
404411 frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)
405412 }
406413 // A palette above normal/copy content dims that composed content. When a
@@ -434,10 +441,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
434441 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
435442 if hasFloating {
436443 cursorPane = floating
437- geometry := calculateFloatingGeometry(contentArea, d.currentFloatingConfig())
438- cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0
439444 }
440445 cursorPane.mu.Lock()
446+ if hasFloating {
447+ desiredGeometry := calculateFloatingGeometry(contentArea, floatingCfg)
448+ geometry := floating.committedFloatingGeometryLocked(desiredGeometry)
449+ cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0
450+ }
441451 desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
442452 cursorPane.mu.Unlock()
443453 ac.sess.mu.Lock()
@@ -283,7 +283,11 @@ func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect,
283 return domain.Rect{}283 return domain.Rect{}
284 }284 }
285 if hasFloating && p == floating {285 if hasFloating && p == floating {
286- return calculateFloatingGeometry(contentArea, cfg).Inner286+ desired := calculateFloatingGeometry(contentArea, cfg)
287+ p.mu.Lock()
288+ geometry := p.committedFloatingGeometryLocked(desired)
289+ p.mu.Unlock()
290+ return geometry.Inner
287 }291 }
288 for _, placement := range layoutSnap.placements {292 for _, placement := range layoutSnap.placements {
289 if placement.ID == p.id && !placement.Collapsed {293 if placement.ID == p.id && !placement.Collapsed {
@@ -373,9 +377,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
373 ac.sendMu.Unlock()377 ac.sendMu.Unlock()
374 return378 return
375 }379 }
376- // Recheck the slot after refreshing its title outside the tab lock.380+ // Recheck the slot after refreshing its title outside the tab lock. One
381+ // paint uses one immutable floating config snapshot across composition,
382+ // copy targeting, and cursor placement.
377 floating = tb.floating.pane383 floating = tb.floating.pane
378 hasFloating = tb.floating.state == floatingVisible && floating != nil384 hasFloating = tb.floating.state == floatingVisible && floating != nil
385+ floatingCfg := d.currentFloatingConfig()
379 overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive386 overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
380 if reset || overlayActive {387 if reset || overlayActive {
381 ac.bars.Reset()388 ac.bars.Reset()
@@ -393,14 +400,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
393 }400 }
394 contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}401 contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
395 if hasFloating {402 if hasFloating {
396- frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, d.currentFloatingConfig(), layoutSnap, bars.theme, &ac.composed, reset || overlayActive)403+ frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, floatingCfg, layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
397 }404 }
398 if overlays.copyActive {405 if overlays.copyActive {
399 copyPane := overlays.copyPane406 copyPane := overlays.copyPane
400 if copyPane == nil {407 if copyPane == nil {
401 copyPane = p408 copyPane = p
402 }409 }
403- copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, d.currentFloatingConfig())410+ copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg)
404 frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)411 frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)
405 }412 }
406 // A palette above normal/copy content dims that composed content. When a413 // A palette above normal/copy content dims that composed content. When a
@@ -434,10 +441,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
434 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)441 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
435 if hasFloating {442 if hasFloating {
436 cursorPane = floating443 cursorPane = floating
437- geometry := calculateFloatingGeometry(contentArea, d.currentFloatingConfig())
438- cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0
439 }444 }
440 cursorPane.mu.Lock()445 cursorPane.mu.Lock()
446+ if hasFloating {
447+ desiredGeometry := calculateFloatingGeometry(contentArea, floatingCfg)
448+ geometry := floating.committedFloatingGeometryLocked(desiredGeometry)
449+ cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0
450+ }
441 desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)451 desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
442 cursorPane.mu.Unlock()452 cursorPane.mu.Unlock()
443 ac.sess.mu.Lock()453 ac.sess.mu.Lock()

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