detached 773d08a

refactor(renderer): harden floating composition

commit
773d08a7b5b2aaf4ed1a99a610cd5fd08bb4487e
author
brice · · 2026-07-10 06:34 UTC
parent
b897e1c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +138 -81
M internal/usecase/daemon/floating.go +0 -23
@@ -125,29 +125,6 @@ func (tb *tab) terminalTargetLocked() *pane {
125125 return tb.focusedPane()
126126 }
127127
128-// floatingInnerSize returns the terminal area inside a percentage-sized popup.
129-// For each axis, a popup with at least three cells spends one cell on each
130-// border; smaller popups omit that axis's border. A valid tab always yields a
131-// valid PTY size.
132-func floatingInnerSize(tabSize domain.Size, cfg domain.FloatingConfig) domain.Size {
133- if !tabSize.Valid() {
134- return domain.Size{}
135- }
136- return domain.Size{
137- Cols: floatingInnerAxis(tabSize.Cols, cfg.Width),
138- Rows: floatingInnerAxis(tabSize.Rows, cfg.Height),
139- }
140-}
141-
142-func floatingInnerAxis(available, percent int) int {
143- percent = min(max(percent, 1), 100)
144- bounds := min(max(available*percent/100, 1), available)
145- if bounds >= 3 {
146- return bounds - 2
147- }
148- return bounds
149-}
150-
151128 // ensureFloatingWarm starts the background prewarm exactly once for a tab.
152129 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
153130 if d == nil || sess == nil || tb == nil || d.ptys == nil {
@@ -125,29 +125,6 @@ func (tb *tab) terminalTargetLocked() *pane {
125 return tb.focusedPane()125 return tb.focusedPane()
126 }126 }
127 127
128-// floatingInnerSize returns the terminal area inside a percentage-sized popup.
129-// For each axis, a popup with at least three cells spends one cell on each
130-// border; smaller popups omit that axis's border. A valid tab always yields a
131-// valid PTY size.
132-func floatingInnerSize(tabSize domain.Size, cfg domain.FloatingConfig) domain.Size {
133- if !tabSize.Valid() {
134- return domain.Size{}
135- }
136- return domain.Size{
137- Cols: floatingInnerAxis(tabSize.Cols, cfg.Width),
138- Rows: floatingInnerAxis(tabSize.Rows, cfg.Height),
139- }
140-}
141-
142-func floatingInnerAxis(available, percent int) int {
143- percent = min(max(percent, 1), 100)
144- bounds := min(max(available*percent/100, 1), available)
145- if bounds >= 3 {
146- return bounds - 2
147- }
148- return bounds
149-}
150-
151 // ensureFloatingWarm starts the background prewarm exactly once for a tab.128 // ensureFloatingWarm starts the background prewarm exactly once for a tab.
152 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {129 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
153 if d == nil || sess == nil || tb == nil || d.ptys == nil {130 if d == nil || sess == nil || tb == nil || d.ptys == nil {
M internal/usecase/daemon/floating_render.go +37 -21
@@ -14,33 +14,49 @@ type floatingGeometry struct {
1414 Inner domain.Rect
1515 }
1616
17-// calculateFloatingGeometry centers a percentage-sized popup in content. Its
18-// terminal area deliberately uses floatingInnerSize so launch and rendering
19-// always share the same border and percentage rules.
17+// floatingAxisGeometry is the canonical percentage and border calculation for
18+// one popup axis. Popups smaller than three cells spend no cells on borders.
19+type floatingAxisGeometry struct {
20+ BoundsSize int
21+ InnerSize int
22+ BorderOffset int
23+}
24+
25+func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry {
26+ percent = min(max(percent, 1), 100)
27+ bounds := min(max(available*percent/100, 1), available)
28+ axis := floatingAxisGeometry{BoundsSize: bounds, InnerSize: bounds}
29+ if bounds >= 3 {
30+ axis.InnerSize -= 2
31+ axis.BorderOffset = 1
32+ }
33+ return axis
34+}
35+
36+// calculateFloatingGeometry centers a percentage-sized popup in content.
37+// Launch sizing derives from Inner too, so rendering and PTY geometry share
38+// the same per-axis percentage and tiny-border rules.
2039 func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry {
2140 if content.Width <= 0 || content.Height <= 0 {
2241 return floatingGeometry{}
2342 }
43+ x := calculateFloatingAxisGeometry(content.Width, cfg.Width)
44+ y := calculateFloatingAxisGeometry(content.Height, cfg.Height)
2445 bounds := domain.Rect{
25- Width: floatingBoundsAxis(content.Width, cfg.Width),
26- Height: floatingBoundsAxis(content.Height, cfg.Height),
46+ X: content.X + (content.Width-x.BoundsSize)/2,
47+ Y: content.Y + (content.Height-y.BoundsSize)/2,
48+ Width: x.BoundsSize,
49+ Height: y.BoundsSize,
50+ }
51+ return floatingGeometry{
52+ Bounds: bounds,
53+ Inner: domain.Rect{
54+ X: bounds.X + x.BorderOffset,
55+ Y: bounds.Y + y.BorderOffset,
56+ Width: x.InnerSize,
57+ Height: y.InnerSize,
58+ },
2759 }
28- bounds.X = content.X + (content.Width-bounds.Width)/2
29- bounds.Y = content.Y + (content.Height-bounds.Height)/2
30- innerSize := floatingInnerSize(domain.Size{Cols: bounds.Width, Rows: bounds.Height}, domain.FloatingConfig{Width: 100, Height: 100})
31- inner := domain.Rect{X: bounds.X, Y: bounds.Y, Width: innerSize.Cols, Height: innerSize.Rows}
32- if bounds.Width >= 3 {
33- inner.X++
34- }
35- if bounds.Height >= 3 {
36- inner.Y++
37- }
38- return floatingGeometry{Bounds: bounds, Inner: inner}
39-}
40-
41-func floatingBoundsAxis(available, percent int) int {
42- percent = min(max(percent, 1), 100)
43- return min(max(available*percent/100, 1), available)
4460 }
4561
4662 // composeFloatingFrame applies the popup to a copy of the normal composed
@@ -14,33 +14,49 @@ type floatingGeometry struct {
14 Inner domain.Rect14 Inner domain.Rect
15 }15 }
16 16
17-// calculateFloatingGeometry centers a percentage-sized popup in content. Its17+// floatingAxisGeometry is the canonical percentage and border calculation for
18-// terminal area deliberately uses floatingInnerSize so launch and rendering18+// one popup axis. Popups smaller than three cells spend no cells on borders.
19-// always share the same border and percentage rules.19+type floatingAxisGeometry struct {
20+ BoundsSize int
21+ InnerSize int
22+ BorderOffset int
23+}
24+
25+func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry {
26+ percent = min(max(percent, 1), 100)
27+ bounds := min(max(available*percent/100, 1), available)
28+ axis := floatingAxisGeometry{BoundsSize: bounds, InnerSize: bounds}
29+ if bounds >= 3 {
30+ axis.InnerSize -= 2
31+ axis.BorderOffset = 1
32+ }
33+ return axis
34+}
35+
36+// calculateFloatingGeometry centers a percentage-sized popup in content.
37+// Launch sizing derives from Inner too, so rendering and PTY geometry share
38+// the same per-axis percentage and tiny-border rules.
20 func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry {39 func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry {
21 if content.Width <= 0 || content.Height <= 0 {40 if content.Width <= 0 || content.Height <= 0 {
22 return floatingGeometry{}41 return floatingGeometry{}
23 }42 }
43+ x := calculateFloatingAxisGeometry(content.Width, cfg.Width)
44+ y := calculateFloatingAxisGeometry(content.Height, cfg.Height)
24 bounds := domain.Rect{45 bounds := domain.Rect{
25- Width: floatingBoundsAxis(content.Width, cfg.Width),46+ X: content.X + (content.Width-x.BoundsSize)/2,
26- Height: floatingBoundsAxis(content.Height, cfg.Height),47+ Y: content.Y + (content.Height-y.BoundsSize)/2,
48+ Width: x.BoundsSize,
49+ Height: y.BoundsSize,
50+ }
51+ return floatingGeometry{
52+ Bounds: bounds,
53+ Inner: domain.Rect{
54+ X: bounds.X + x.BorderOffset,
55+ Y: bounds.Y + y.BorderOffset,
56+ Width: x.InnerSize,
57+ Height: y.InnerSize,
58+ },
27 }59 }
28- bounds.X = content.X + (content.Width-bounds.Width)/2
29- bounds.Y = content.Y + (content.Height-bounds.Height)/2
30- innerSize := floatingInnerSize(domain.Size{Cols: bounds.Width, Rows: bounds.Height}, domain.FloatingConfig{Width: 100, Height: 100})
31- inner := domain.Rect{X: bounds.X, Y: bounds.Y, Width: innerSize.Cols, Height: innerSize.Rows}
32- if bounds.Width >= 3 {
33- inner.X++
34- }
35- if bounds.Height >= 3 {
36- inner.Y++
37- }
38- return floatingGeometry{Bounds: bounds, Inner: inner}
39-}
40-
41-func floatingBoundsAxis(available, percent int) int {
42- percent = min(max(percent, 1), 100)
43- return min(max(available*percent/100, 1), available)
44 }60 }
45 61
46 // composeFloatingFrame applies the popup to a copy of the normal composed62 // composeFloatingFrame applies the popup to a copy of the normal composed
M internal/usecase/daemon/floating_render_test.go +87 -0
@@ -35,6 +35,25 @@ func TestCalculateFloatingGeometry(t *testing.T) {
3535 }
3636 }
3737
38+func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ available, percent int
42+ want floatingAxisGeometry
43+ }{
44+ {name: "one percent", available: 100, percent: 1, want: floatingAxisGeometry{BoundsSize: 1, InnerSize: 1}},
45+ {name: "full size reserves borders", available: 100, percent: 100, want: floatingAxisGeometry{BoundsSize: 100, InnerSize: 98, BorderOffset: 1}},
46+ {name: "two cells omits borders", available: 2, percent: 100, want: floatingAxisGeometry{BoundsSize: 2, InnerSize: 2}},
47+ {name: "three cells leaves one inner cell", available: 3, percent: 100, want: floatingAxisGeometry{BoundsSize: 3, InnerSize: 1, BorderOffset: 1}},
48+ {name: "percentage clamps", available: 10, percent: 101, want: floatingAxisGeometry{BoundsSize: 10, InnerSize: 8, BorderOffset: 1}},
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ require.Equal(t, tt.want, calculateFloatingAxisGeometry(tt.available, tt.percent))
53+ })
54+ }
55+}
56+
3857 func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
3958 p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
4059 p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
@@ -121,6 +140,74 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
121140 }
122141 }
123142
143+func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) {
144+ normalPTY, releaseNormal := newBlockingPTY(t)
145+ defer releaseNormal()
146+ d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY)
147+ previewTransport, previewSends := newCapturingTransport(t)
148+ preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
149+ preview.initOverlays()
150+ preview.setSession(sess)
151+
152+ tb := sess.activeTab()
153+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8})
154+ floating.screen.Write([]byte("hidden output"))
155+ tb.mu.Lock()
156+ generation := tb.beginFloatingWarmLocked(false)
157+ require.True(t, tb.installFloatingLocked(floating, generation))
158+ tb.previewClient = preview
159+ tb.mu.Unlock()
160+
161+ d.render(sess, tb, floating)
162+
163+ floating.mu.Lock()
164+ require.Empty(t, floating.screen.Damage())
165+ floating.mu.Unlock()
166+ select {
167+ case frame := <-previewSends:
168+ t.Fatalf("hidden floating output painted picker preview: %#v", frame)
169+ default:
170+ }
171+ select {
172+ case frame := <-activeSends:
173+ t.Fatalf("hidden floating output painted active client: %#v", frame)
174+ default:
175+ }
176+}
177+
178+func BenchmarkComposeFloatingFrameCached(b *testing.B) {
179+ p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
180+ p.title.displayFallback = "float"
181+ base := renderer.NewFrame(80, 24)
182+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
183+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
184+ cache := &composedFrameCache{}
185+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
186+ p.screen.ClearDamage()
187+
188+ b.ReportAllocs()
189+ b.ResetTimer()
190+ for b.Loop() {
191+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
192+ }
193+}
194+
195+func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) {
196+ p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
197+ p.title.displayFallback = "float"
198+ base := renderer.NewFrame(80, 24)
199+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
200+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
201+ cache := &composedFrameCache{}
202+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
203+ p.screen.ClearDamage()
204+
205+ allocs := testing.AllocsPerRun(100, func() {
206+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
207+ })
208+ require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes")
209+}
210+
124211 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
125212 d := newTestDaemon(t, nil, stubClock{})
126213 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})
@@ -35,6 +35,25 @@ func TestCalculateFloatingGeometry(t *testing.T) {
35 }35 }
36 }36 }
37 37
38+func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ available, percent int
42+ want floatingAxisGeometry
43+ }{
44+ {name: "one percent", available: 100, percent: 1, want: floatingAxisGeometry{BoundsSize: 1, InnerSize: 1}},
45+ {name: "full size reserves borders", available: 100, percent: 100, want: floatingAxisGeometry{BoundsSize: 100, InnerSize: 98, BorderOffset: 1}},
46+ {name: "two cells omits borders", available: 2, percent: 100, want: floatingAxisGeometry{BoundsSize: 2, InnerSize: 2}},
47+ {name: "three cells leaves one inner cell", available: 3, percent: 100, want: floatingAxisGeometry{BoundsSize: 3, InnerSize: 1, BorderOffset: 1}},
48+ {name: "percentage clamps", available: 10, percent: 101, want: floatingAxisGeometry{BoundsSize: 10, InnerSize: 8, BorderOffset: 1}},
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ require.Equal(t, tt.want, calculateFloatingAxisGeometry(tt.available, tt.percent))
53+ })
54+ }
55+}
56+
38 func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {57 func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
39 p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})58 p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
40 p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})59 p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
@@ -121,6 +140,74 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
121 }140 }
122 }141 }
123 142
143+func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) {
144+ normalPTY, releaseNormal := newBlockingPTY(t)
145+ defer releaseNormal()
146+ d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY)
147+ previewTransport, previewSends := newCapturingTransport(t)
148+ preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
149+ preview.initOverlays()
150+ preview.setSession(sess)
151+
152+ tb := sess.activeTab()
153+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8})
154+ floating.screen.Write([]byte("hidden output"))
155+ tb.mu.Lock()
156+ generation := tb.beginFloatingWarmLocked(false)
157+ require.True(t, tb.installFloatingLocked(floating, generation))
158+ tb.previewClient = preview
159+ tb.mu.Unlock()
160+
161+ d.render(sess, tb, floating)
162+
163+ floating.mu.Lock()
164+ require.Empty(t, floating.screen.Damage())
165+ floating.mu.Unlock()
166+ select {
167+ case frame := <-previewSends:
168+ t.Fatalf("hidden floating output painted picker preview: %#v", frame)
169+ default:
170+ }
171+ select {
172+ case frame := <-activeSends:
173+ t.Fatalf("hidden floating output painted active client: %#v", frame)
174+ default:
175+ }
176+}
177+
178+func BenchmarkComposeFloatingFrameCached(b *testing.B) {
179+ p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
180+ p.title.displayFallback = "float"
181+ base := renderer.NewFrame(80, 24)
182+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
183+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
184+ cache := &composedFrameCache{}
185+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
186+ p.screen.ClearDamage()
187+
188+ b.ReportAllocs()
189+ b.ResetTimer()
190+ for b.Loop() {
191+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
192+ }
193+}
194+
195+func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) {
196+ p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
197+ p.title.displayFallback = "float"
198+ base := renderer.NewFrame(80, 24)
199+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
200+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
201+ cache := &composedFrameCache{}
202+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
203+ p.screen.ClearDamage()
204+
205+ allocs := testing.AllocsPerRun(100, func() {
206+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
207+ })
208+ require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes")
209+}
210+
124 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {211 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
125 d := newTestDaemon(t, nil, stubClock{})212 d := newTestDaemon(t, nil, stubClock{})
126 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})213 d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})
M internal/usecase/daemon/floating_test.go +0 -22
@@ -592,25 +592,3 @@ func TestFloatingToggleUsesVisibleTarget(t *testing.T) {
592592 require.Same(t, floating, tb.terminalTargetLocked())
593593 tb.mu.Unlock()
594594 }
595-
596-func TestFloatingInnerSize(t *testing.T) {
597- tests := []struct {
598- name string
599- tab domain.Size
600- cfg domain.FloatingConfig
601- want domain.Size
602- }{
603- {name: "one percent", tab: domain.Size{Cols: 100, Rows: 100}, cfg: domain.FloatingConfig{Width: 1, Height: 1}, want: domain.Size{Cols: 1, Rows: 1}},
604- {name: "full size reserves borders", tab: domain.Size{Cols: 100, Rows: 80}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 98, Rows: 78}},
605- {name: "tiny tab omits borders", tab: domain.Size{Cols: 2, Rows: 1}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 2, Rows: 1}},
606- {name: "three cells leaves one inner cell", tab: domain.Size{Cols: 3, Rows: 3}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 1, Rows: 1}},
607- {name: "invalid tab has no pty size", tab: domain.Size{}, cfg: domain.FloatingConfig{Width: 80, Height: 80}, want: domain.Size{}},
608- }
609- for _, tt := range tests {
610- t.Run(tt.name, func(t *testing.T) {
611- if got := floatingInnerSize(tt.tab, tt.cfg); got != tt.want {
612- t.Fatalf("floatingInnerSize(%+v, %+v) = %+v, want %+v", tt.tab, tt.cfg, got, tt.want)
613- }
614- })
615- }
616-}
@@ -592,25 +592,3 @@ func TestFloatingToggleUsesVisibleTarget(t *testing.T) {
592 require.Same(t, floating, tb.terminalTargetLocked())592 require.Same(t, floating, tb.terminalTargetLocked())
593 tb.mu.Unlock()593 tb.mu.Unlock()
594 }594 }
595-
596-func TestFloatingInnerSize(t *testing.T) {
597- tests := []struct {
598- name string
599- tab domain.Size
600- cfg domain.FloatingConfig
601- want domain.Size
602- }{
603- {name: "one percent", tab: domain.Size{Cols: 100, Rows: 100}, cfg: domain.FloatingConfig{Width: 1, Height: 1}, want: domain.Size{Cols: 1, Rows: 1}},
604- {name: "full size reserves borders", tab: domain.Size{Cols: 100, Rows: 80}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 98, Rows: 78}},
605- {name: "tiny tab omits borders", tab: domain.Size{Cols: 2, Rows: 1}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 2, Rows: 1}},
606- {name: "three cells leaves one inner cell", tab: domain.Size{Cols: 3, Rows: 3}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 1, Rows: 1}},
607- {name: "invalid tab has no pty size", tab: domain.Size{}, cfg: domain.FloatingConfig{Width: 80, Height: 80}, want: domain.Size{}},
608- }
609- for _, tt := range tests {
610- t.Run(tt.name, func(t *testing.T) {
611- if got := floatingInnerSize(tt.tab, tt.cfg); got != tt.want {
612- t.Fatalf("floatingInnerSize(%+v, %+v) = %+v, want %+v", tt.tab, tt.cfg, got, tt.want)
613- }
614- })
615- }
616-}
M internal/usecase/daemon/render.go +14 -15
@@ -219,9 +219,6 @@ func syncUpdateEndIn(data []byte) bool {
219219 // render paints the current client, or (when detached) just clears accumulated
220220 // damage so it never grows unbounded while headless.
221221 func (d *Daemon) render(sess *session, tb *tab, p *pane) {
222- tb.mu.Lock()
223- previewer := tb.previewClient
224- tb.mu.Unlock()
225222 if p == nil {
226223 return
227224 }
@@ -232,6 +229,20 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
232229 }
233230 p.mu.Unlock()
234231
232+ // A retained floating pane continues to drain its PTY while hidden. Its
233+ // scheduler must not route through picker preview (or the active client),
234+ // but it must consume damage so a long-hidden command cannot retain it.
235+ tb.mu.Lock()
236+ hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible
237+ previewer := tb.previewClient
238+ tb.mu.Unlock()
239+ if hiddenFloating {
240+ p.mu.Lock()
241+ p.screen.ClearDamage()
242+ p.mu.Unlock()
243+ return
244+ }
245+
235246 sess.mu.Lock()
236247 ac := sess.client
237248 active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
@@ -257,18 +268,6 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
257268 p.mu.Unlock()
258269 return
259270 }
260- // A retained floating pane continues to drain its PTY while hidden. Do
261- // not let its scheduler repaint the normal tab, but consume its damage so
262- // a long-hidden command cannot retain an unbounded dirty region.
263- tb.mu.Lock()
264- hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible
265- tb.mu.Unlock()
266- if hiddenFloating {
267- p.mu.Lock()
268- p.screen.ClearDamage()
269- p.mu.Unlock()
270- return
271- }
272271 d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
273272 d.paint(sess, ac, false)
274273 }
@@ -219,9 +219,6 @@ func syncUpdateEndIn(data []byte) bool {
219 // render paints the current client, or (when detached) just clears accumulated219 // render paints the current client, or (when detached) just clears accumulated
220 // damage so it never grows unbounded while headless.220 // damage so it never grows unbounded while headless.
221 func (d *Daemon) render(sess *session, tb *tab, p *pane) {221 func (d *Daemon) render(sess *session, tb *tab, p *pane) {
222- tb.mu.Lock()
223- previewer := tb.previewClient
224- tb.mu.Unlock()
225 if p == nil {222 if p == nil {
226 return223 return
227 }224 }
@@ -232,6 +229,20 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
232 }229 }
233 p.mu.Unlock()230 p.mu.Unlock()
234 231
232+ // A retained floating pane continues to drain its PTY while hidden. Its
233+ // scheduler must not route through picker preview (or the active client),
234+ // but it must consume damage so a long-hidden command cannot retain it.
235+ tb.mu.Lock()
236+ hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible
237+ previewer := tb.previewClient
238+ tb.mu.Unlock()
239+ if hiddenFloating {
240+ p.mu.Lock()
241+ p.screen.ClearDamage()
242+ p.mu.Unlock()
243+ return
244+ }
245+
235 sess.mu.Lock()246 sess.mu.Lock()
236 ac := sess.client247 ac := sess.client
237 active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb248 active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
@@ -257,18 +268,6 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
257 p.mu.Unlock()268 p.mu.Unlock()
258 return269 return
259 }270 }
260- // A retained floating pane continues to drain its PTY while hidden. Do
261- // not let its scheduler repaint the normal tab, but consume its damage so
262- // a long-hidden command cannot retain an unbounded dirty region.
263- tb.mu.Lock()
264- hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible
265- tb.mu.Unlock()
266- if hiddenFloating {
267- p.mu.Lock()
268- p.screen.ClearDamage()
269- p.mu.Unlock()
270- return
271- }
272 d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)271 d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
273 d.paint(sess, ac, false)272 d.paint(sess, ac, false)
274 }273 }

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