detached fe081c7

feat(daemon): size floating pane overlays

commit
fe081c762e4a25a67cbd49fc86ac57858a764da0
author
brice · · 2026-07-10 05:58 UTC
parent
1469f94
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +198 -4
M internal/usecase/daemon/client.go +3 -0
@@ -593,6 +593,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
593593 d.applyLayoutLocked(tb)
594594 tb.mu.Unlock()
595595 }
596+ // Only the shown tab's floating terminal tracks the client size. This is
597+ // outside tab.mu so its PTY resize cannot block tab state.
598+ d.resizeActiveFloating(sess.activeTab())
596599 markSnapshotDirty(sess)
597600 if ac != nil {
598601 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
@@ -593,6 +593,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
593 d.applyLayoutLocked(tb)593 d.applyLayoutLocked(tb)
594 tb.mu.Unlock()594 tb.mu.Unlock()
595 }595 }
596+ // Only the shown tab's floating terminal tracks the client size. This is
597+ // outside tab.mu so its PTY resize cannot block tab state.
598+ d.resizeActiveFloating(sess.activeTab())
596 markSnapshotDirty(sess)599 markSnapshotDirty(sess)
597 if ac != nil {600 if ac != nil {
598 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)601 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
M internal/usecase/daemon/floating.go +13 -4
@@ -175,6 +175,9 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
175175 d.launchFloating(sess, tb, cfg, generation, visible)
176176 return nil
177177 }
178+ if visible {
179+ d.resizeActiveFloating(tb)
180+ }
178181 if ac != nil {
179182 d.paint(sess, ac, true)
180183 }
@@ -198,6 +201,7 @@ type floatingLaunchSpec struct {
198201 sessionName string
199202 cwd string
200203 size domain.Size
204+ innerRect domain.Rect
201205 paneStableID string
202206 env []string
203207 command string
@@ -226,13 +230,15 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
226230 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
227231 sess.mu.Unlock()
228232 tb.mu.Lock()
229- size := floatingInnerSize(tb.size, cfg)
233+ inner := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg).Inner
234+ size := rectSize(inner)
235+ if !size.Valid() {
236+ inner = domain.Rect{Width: 1, Height: 1}
237+ size = rectSize(inner)
238+ }
230239 focused := tb.focusedPane()
231240 tabStableID, tabCtx := tb.stableID, tb.ctx
232241 tb.mu.Unlock()
233- if !size.Valid() {
234- size = domain.Size{Cols: 1, Rows: 1}
235- }
236242 if focused != nil && d.procCwd != nil && focused.pty != nil {
237243 if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" {
238244 cwd = live
@@ -259,6 +265,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
259265 sessionName: name,
260266 cwd: cwd,
261267 size: size,
268+ innerRect: inner,
262269 paneStableID: paneStableID,
263270 env: d.childEnv(name, tabStableID, paneStableID, term),
264271 command: command,
@@ -278,6 +285,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
278285 return
279286 }
280287 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
288+ p.rect = spec.innerRect
281289 p.title.displayFallback = spec.fallback
282290 p.ctx, p.cancel = context.WithCancel(spec.parentCtx)
283291 // The reader may run as soon as install returns; make its exit policy
@@ -312,6 +320,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
312320 }
313321 d.startPaneGoroutines(sess, tb, p)
314322 if visible {
323+ d.resizeActiveFloating(tb)
315324 sess.mu.Lock()
316325 ac := sess.client
317326 sess.mu.Unlock()
@@ -175,6 +175,9 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
175 d.launchFloating(sess, tb, cfg, generation, visible)175 d.launchFloating(sess, tb, cfg, generation, visible)
176 return nil176 return nil
177 }177 }
178+ if visible {
179+ d.resizeActiveFloating(tb)
180+ }
178 if ac != nil {181 if ac != nil {
179 d.paint(sess, ac, true)182 d.paint(sess, ac, true)
180 }183 }
@@ -198,6 +201,7 @@ type floatingLaunchSpec struct {
198 sessionName string201 sessionName string
199 cwd string202 cwd string
200 size domain.Size203 size domain.Size
204+ innerRect domain.Rect
201 paneStableID string205 paneStableID string
202 env []string206 env []string
203 command string207 command string
@@ -226,13 +230,15 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
226 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx230 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
227 sess.mu.Unlock()231 sess.mu.Unlock()
228 tb.mu.Lock()232 tb.mu.Lock()
229- size := floatingInnerSize(tb.size, cfg)233+ inner := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg).Inner
234+ size := rectSize(inner)
235+ if !size.Valid() {
236+ inner = domain.Rect{Width: 1, Height: 1}
237+ size = rectSize(inner)
238+ }
230 focused := tb.focusedPane()239 focused := tb.focusedPane()
231 tabStableID, tabCtx := tb.stableID, tb.ctx240 tabStableID, tabCtx := tb.stableID, tb.ctx
232 tb.mu.Unlock()241 tb.mu.Unlock()
233- if !size.Valid() {
234- size = domain.Size{Cols: 1, Rows: 1}
235- }
236 if focused != nil && d.procCwd != nil && focused.pty != nil {242 if focused != nil && d.procCwd != nil && focused.pty != nil {
237 if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" {243 if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" {
238 cwd = live244 cwd = live
@@ -259,6 +265,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
259 sessionName: name,265 sessionName: name,
260 cwd: cwd,266 cwd: cwd,
261 size: size,267 size: size,
268+ innerRect: inner,
262 paneStableID: paneStableID,269 paneStableID: paneStableID,
263 env: d.childEnv(name, tabStableID, paneStableID, term),270 env: d.childEnv(name, tabStableID, paneStableID, term),
264 command: command,271 command: command,
@@ -278,6 +285,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
278 return285 return
279 }286 }
280 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)287 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
288+ p.rect = spec.innerRect
281 p.title.displayFallback = spec.fallback289 p.title.displayFallback = spec.fallback
282 p.ctx, p.cancel = context.WithCancel(spec.parentCtx)290 p.ctx, p.cancel = context.WithCancel(spec.parentCtx)
283 // The reader may run as soon as install returns; make its exit policy291 // The reader may run as soon as install returns; make its exit policy
@@ -312,6 +320,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
312 }320 }
313 d.startPaneGoroutines(sess, tb, p)321 d.startPaneGoroutines(sess, tb, p)
314 if visible {322 if visible {
323+ d.resizeActiveFloating(tb)
315 sess.mu.Lock()324 sess.mu.Lock()
316 ac := sess.client325 ac := sess.client
317 sess.mu.Unlock()326 sess.mu.Unlock()
A internal/usecase/daemon/floating_render.go +85 -0
@@ -0,0 +1,85 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// floatingGeometry describes a popup's outer frame and terminal content area.
6+// Bounds and Inner are both relative to the supplied content rectangle.
7+type floatingGeometry struct {
8+ Bounds domain.Rect
9+ Inner domain.Rect
10+}
11+
12+// calculateFloatingGeometry centers a percentage-sized popup in content. Its
13+// terminal area deliberately uses floatingInnerSize so launch and rendering
14+// always share the same border and percentage rules.
15+func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry {
16+ if content.Width <= 0 || content.Height <= 0 {
17+ return floatingGeometry{}
18+ }
19+ bounds := domain.Rect{
20+ Width: floatingBoundsAxis(content.Width, cfg.Width),
21+ Height: floatingBoundsAxis(content.Height, cfg.Height),
22+ }
23+ bounds.X = content.X + (content.Width-bounds.Width)/2
24+ bounds.Y = content.Y + (content.Height-bounds.Height)/2
25+ innerSize := floatingInnerSize(domain.Size{Cols: bounds.Width, Rows: bounds.Height}, domain.FloatingConfig{Width: 100, Height: 100})
26+ inner := domain.Rect{X: bounds.X, Y: bounds.Y, Width: innerSize.Cols, Height: innerSize.Rows}
27+ if bounds.Width >= 3 {
28+ inner.X++
29+ }
30+ if bounds.Height >= 3 {
31+ inner.Y++
32+ }
33+ return floatingGeometry{Bounds: bounds, Inner: inner}
34+}
35+
36+func floatingBoundsAxis(available, percent int) int {
37+ percent = min(max(percent, 1), 100)
38+ return min(max(available*percent/100, 1), available)
39+}
40+
41+// resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
42+// is intentionally outside pane.mu; failed resizes do not publish any state.
43+func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool {
44+ if p == nil || inner.Width <= 0 || inner.Height <= 0 {
45+ return false
46+ }
47+ p.resizeMu.Lock()
48+ defer p.resizeMu.Unlock()
49+
50+ p.mu.Lock()
51+ old := p.rect
52+ pty := p.pty
53+ p.mu.Unlock()
54+ if old == inner {
55+ return true
56+ }
57+ if pty != nil {
58+ if err := pty.Resize(rectSize(inner)); err != nil {
59+ d.log.Warn("floating pty resize failed", "err", err)
60+ return false
61+ }
62+ }
63+ p.mu.Lock()
64+ p.screen.Resize(inner.Width, inner.Height)
65+ p.rect = inner
66+ p.mu.Unlock()
67+ return true
68+}
69+
70+// resizeActiveFloating updates only a visible floating pane. Hidden panes
71+// retain their remembered geometry until they are shown again.
72+func (d *Daemon) resizeActiveFloating(tb *tab) bool {
73+ if d == nil || tb == nil {
74+ return false
75+ }
76+ tb.mu.Lock()
77+ visible := tb.floating.state == floatingVisible
78+ p := tb.floating.pane
79+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
80+ tb.mu.Unlock()
81+ if !visible || p == nil {
82+ return false
83+ }
84+ return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner)
85+}
@@ -0,0 +1,85 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// floatingGeometry describes a popup's outer frame and terminal content area.
6+// Bounds and Inner are both relative to the supplied content rectangle.
7+type floatingGeometry struct {
8+ Bounds domain.Rect
9+ Inner domain.Rect
10+}
11+
12+// calculateFloatingGeometry centers a percentage-sized popup in content. Its
13+// terminal area deliberately uses floatingInnerSize so launch and rendering
14+// always share the same border and percentage rules.
15+func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry {
16+ if content.Width <= 0 || content.Height <= 0 {
17+ return floatingGeometry{}
18+ }
19+ bounds := domain.Rect{
20+ Width: floatingBoundsAxis(content.Width, cfg.Width),
21+ Height: floatingBoundsAxis(content.Height, cfg.Height),
22+ }
23+ bounds.X = content.X + (content.Width-bounds.Width)/2
24+ bounds.Y = content.Y + (content.Height-bounds.Height)/2
25+ innerSize := floatingInnerSize(domain.Size{Cols: bounds.Width, Rows: bounds.Height}, domain.FloatingConfig{Width: 100, Height: 100})
26+ inner := domain.Rect{X: bounds.X, Y: bounds.Y, Width: innerSize.Cols, Height: innerSize.Rows}
27+ if bounds.Width >= 3 {
28+ inner.X++
29+ }
30+ if bounds.Height >= 3 {
31+ inner.Y++
32+ }
33+ return floatingGeometry{Bounds: bounds, Inner: inner}
34+}
35+
36+func floatingBoundsAxis(available, percent int) int {
37+ percent = min(max(percent, 1), 100)
38+ return min(max(available*percent/100, 1), available)
39+}
40+
41+// resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
42+// is intentionally outside pane.mu; failed resizes do not publish any state.
43+func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool {
44+ if p == nil || inner.Width <= 0 || inner.Height <= 0 {
45+ return false
46+ }
47+ p.resizeMu.Lock()
48+ defer p.resizeMu.Unlock()
49+
50+ p.mu.Lock()
51+ old := p.rect
52+ pty := p.pty
53+ p.mu.Unlock()
54+ if old == inner {
55+ return true
56+ }
57+ if pty != nil {
58+ if err := pty.Resize(rectSize(inner)); err != nil {
59+ d.log.Warn("floating pty resize failed", "err", err)
60+ return false
61+ }
62+ }
63+ p.mu.Lock()
64+ p.screen.Resize(inner.Width, inner.Height)
65+ p.rect = inner
66+ p.mu.Unlock()
67+ return true
68+}
69+
70+// resizeActiveFloating updates only a visible floating pane. Hidden panes
71+// retain their remembered geometry until they are shown again.
72+func (d *Daemon) resizeActiveFloating(tb *tab) bool {
73+ if d == nil || tb == nil {
74+ return false
75+ }
76+ tb.mu.Lock()
77+ visible := tb.floating.state == floatingVisible
78+ p := tb.floating.pane
79+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
80+ tb.mu.Unlock()
81+ if !visible || p == nil {
82+ return false
83+ }
84+ return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner)
85+}
A internal/usecase/daemon/floating_render_test.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+)
14+
15+func TestCalculateFloatingGeometry(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ content domain.Rect
19+ cfg domain.FloatingConfig
20+ want floatingGeometry
21+ }{
22+ {"eighty percent centered", domain.Rect{X: 3, Y: 1, Width: 101, Height: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 13, Y: 6, Width: 80, Height: 40}, Inner: domain.Rect{X: 14, Y: 7, Width: 78, Height: 38}}},
23+ {"one percent clamps to one", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}}},
24+ {"full size", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Y: 1, Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 2, Width: 98, Height: 18}}},
25+ {"tiny axes omit borders", domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}},
26+ {"percent clamps", domain.Rect{Width: 10, Height: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ require.Equal(t, tt.want, calculateFloatingGeometry(tt.content, tt.cfg))
31+ })
32+ }
33+}
34+
35+func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
36+ t.Run("failure preserves state", func(t *testing.T) {
37+ pty := &resizePTY{err: errors.New("nope")}
38+ p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})
39+ p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
40+ d := newTestDaemon(t, nil, stubClock{})
41+ require.False(t, d.resizeFloatingPane(p, domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}))
42+ require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
43+ require.Equal(t, 5, p.screen.Frame.Width)
44+ require.Equal(t, 4, p.screen.Frame.Height)
45+ })
46+
47+ t.Run("competing resizes serialize", func(t *testing.T) {
48+ pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
49+ p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})
50+ d := newTestDaemon(t, nil, stubClock{})
51+ first := domain.Rect{Width: 4, Height: 3}
52+ second := domain.Rect{Width: 8, Height: 6}
53+ done1 := make(chan bool, 1)
54+ done2 := make(chan bool, 1)
55+ go func() { done1 <- d.resizeFloatingPane(p, first) }()
56+ <-pty.entered
57+ go func() { done2 <- d.resizeFloatingPane(p, second) }()
58+ require.Never(t, func() bool { return pty.calls() > 1 }, 30*time.Millisecond, time.Millisecond)
59+ close(pty.release)
60+ require.True(t, <-done1)
61+ require.True(t, <-done2)
62+ require.Equal(t, second, p.rect)
63+ require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
64+ })
65+}
66+
67+type resizePTY struct {
68+ mu sync.Mutex
69+ resizes []domain.Size
70+ err error
71+ entered chan struct{}
72+ release chan struct{}
73+}
74+
75+func (p *resizePTY) Resize(sz domain.Size) error {
76+ p.mu.Lock()
77+ p.resizes = append(p.resizes, sz)
78+ n := len(p.resizes)
79+ p.mu.Unlock()
80+ if n == 1 && p.entered != nil {
81+ close(p.entered)
82+ <-p.release
83+ }
84+ return p.err
85+}
86+func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) }
87+func (p *resizePTY) sizes() []domain.Size {
88+ p.mu.Lock()
89+ defer p.mu.Unlock()
90+ return append([]domain.Size(nil), p.resizes...)
91+}
92+func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF }
93+func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil }
94+func (*resizePTY) Close() error { return nil }
95+func (*resizePTY) Pid() int { return 0 }
96+func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil }
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+)
14+
15+func TestCalculateFloatingGeometry(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ content domain.Rect
19+ cfg domain.FloatingConfig
20+ want floatingGeometry
21+ }{
22+ {"eighty percent centered", domain.Rect{X: 3, Y: 1, Width: 101, Height: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 13, Y: 6, Width: 80, Height: 40}, Inner: domain.Rect{X: 14, Y: 7, Width: 78, Height: 38}}},
23+ {"one percent clamps to one", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}}},
24+ {"full size", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Y: 1, Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 2, Width: 98, Height: 18}}},
25+ {"tiny axes omit borders", domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}},
26+ {"percent clamps", domain.Rect{Width: 10, Height: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ require.Equal(t, tt.want, calculateFloatingGeometry(tt.content, tt.cfg))
31+ })
32+ }
33+}
34+
35+func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
36+ t.Run("failure preserves state", func(t *testing.T) {
37+ pty := &resizePTY{err: errors.New("nope")}
38+ p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})
39+ p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
40+ d := newTestDaemon(t, nil, stubClock{})
41+ require.False(t, d.resizeFloatingPane(p, domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}))
42+ require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
43+ require.Equal(t, 5, p.screen.Frame.Width)
44+ require.Equal(t, 4, p.screen.Frame.Height)
45+ })
46+
47+ t.Run("competing resizes serialize", func(t *testing.T) {
48+ pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
49+ p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})
50+ d := newTestDaemon(t, nil, stubClock{})
51+ first := domain.Rect{Width: 4, Height: 3}
52+ second := domain.Rect{Width: 8, Height: 6}
53+ done1 := make(chan bool, 1)
54+ done2 := make(chan bool, 1)
55+ go func() { done1 <- d.resizeFloatingPane(p, first) }()
56+ <-pty.entered
57+ go func() { done2 <- d.resizeFloatingPane(p, second) }()
58+ require.Never(t, func() bool { return pty.calls() > 1 }, 30*time.Millisecond, time.Millisecond)
59+ close(pty.release)
60+ require.True(t, <-done1)
61+ require.True(t, <-done2)
62+ require.Equal(t, second, p.rect)
63+ require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
64+ })
65+}
66+
67+type resizePTY struct {
68+ mu sync.Mutex
69+ resizes []domain.Size
70+ err error
71+ entered chan struct{}
72+ release chan struct{}
73+}
74+
75+func (p *resizePTY) Resize(sz domain.Size) error {
76+ p.mu.Lock()
77+ p.resizes = append(p.resizes, sz)
78+ n := len(p.resizes)
79+ p.mu.Unlock()
80+ if n == 1 && p.entered != nil {
81+ close(p.entered)
82+ <-p.release
83+ }
84+ return p.err
85+}
86+func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) }
87+func (p *resizePTY) sizes() []domain.Size {
88+ p.mu.Lock()
89+ defer p.mu.Unlock()
90+ return append([]domain.Size(nil), p.resizes...)
91+}
92+func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF }
93+func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil }
94+func (*resizePTY) Close() error { return nil }
95+func (*resizePTY) Pid() int { return 0 }
96+func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil }
M internal/usecase/daemon/pane.go +1 -0
@@ -20,6 +20,7 @@ type pane struct {
2020 stableID string
2121 pty ports.PTY
2222 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
2324 screen *vt.Screen
2425 scrollback *scopy.Scrollback
2526 dirty chan struct{}
@@ -20,6 +20,7 @@ type pane struct {
20 stableID string20 stableID string
21 pty ports.PTY21 pty ports.PTY
22 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title22 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
23 screen *vt.Screen24 screen *vt.Screen
24 scrollback *scopy.Scrollback25 scrollback *scopy.Scrollback
25 dirty chan struct{}26 dirty chan struct{}

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