fix(daemon): resize floating pane on show
- commit
- b897e1c4b9e62745192813fd620c7e53724bd8c8
- author
- brice · · 2026-07-10 06:23 UTC
- parent
- 9f11203
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +49 -3
M internal/usecase/daemon/floating.go +3 -1
| @@ -169,7 +169,9 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||
| 169 | 169 | cfg := d.currentFloatingConfig() | |
| 170 | 170 | tb.mu.Lock() | |
| 171 | 171 | start, generation := tb.toggleFloatingLocked() | |
| 172 | - | visible := tb.floating.desiredVisible | |
| 172 | + | // desiredVisible is meaningful only while a launch is warming. Installed | |
| 173 | + | // slots derive their resize and paint work from the resulting slot state. | |
| 174 | + | visible := tb.floating.state == floatingVisible | |
| 173 | 175 | tb.mu.Unlock() | |
| 174 | 176 | if start { | |
| 175 | 177 | d.launchFloating(sess, tb, cfg, generation, visible) | |
| @@ -169,7 +169,9 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||||
| 169 | cfg := d.currentFloatingConfig() | 169 | cfg := d.currentFloatingConfig() | ||
| 170 | tb.mu.Lock() | 170 | tb.mu.Lock() | ||
| 171 | start, generation := tb.toggleFloatingLocked() | 171 | start, generation := tb.toggleFloatingLocked() | ||
| 172 | - | visible := tb.floating.desiredVisible | 172 | + | // desiredVisible is meaningful only while a launch is warming. Installed |
| 173 | + | // slots derive their resize and paint work from the resulting slot state. | |||
| 174 | + | visible := tb.floating.state == floatingVisible | |||
| 173 | tb.mu.Unlock() | 175 | tb.mu.Unlock() | ||
| 174 | if start { | 176 | if start { | ||
| 175 | d.launchFloating(sess, tb, cfg, generation, visible) | 177 | d.launchFloating(sess, tb, cfg, generation, visible) | ||
M internal/usecase/daemon/floating_render_test.go +45 -1
| @@ -121,6 +121,45 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |||
| 121 | 121 | } | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |
| 125 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 126 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}}) | |
| 127 | + | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | |
| 128 | + | sess := &session{tabs: []*tab{tb}} | |
| 129 | + | ||
| 130 | + | initial := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()).Inner | |
| 131 | + | current := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()).Inner | |
| 132 | + | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | |
| 133 | + | floating := newPane("floating", pty, rectSize(initial)) | |
| 134 | + | floating.rect = initial | |
| 135 | + | ||
| 136 | + | // Prewarming installs a hidden pane at the initial geometry. | |
| 137 | + | tb.mu.Lock() | |
| 138 | + | generation := tb.beginFloatingWarmLocked(false) | |
| 139 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |
| 140 | + | require.Equal(t, floatingHidden, tb.floating.state) | |
| 141 | + | require.False(t, tb.floating.desiredVisible) | |
| 142 | + | tb.mu.Unlock() | |
| 143 | + | // A client resize changes tab geometry but must leave the hidden PTY alone. | |
| 144 | + | d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42}) | |
| 145 | + | require.Empty(t, pty.sizes()) | |
| 146 | + | ||
| 147 | + | // Showing attempts the current size before paint. A failed resize leaves the | |
| 148 | + | // old screen and rect untouched, so a later hide/show can retry. | |
| 149 | + | require.NoError(t, d.toggleFloating(sess, nil)) | |
| 150 | + | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | |
| 151 | + | require.Equal(t, initial, floating.rect) | |
| 152 | + | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |
| 153 | + | require.Equal(t, initial.Height, floating.screen.Frame.Height) | |
| 154 | + | ||
| 155 | + | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |
| 156 | + | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | |
| 157 | + | require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes()) | |
| 158 | + | require.Equal(t, current, floating.rect) | |
| 159 | + | require.Equal(t, current.Width, floating.screen.Frame.Width) | |
| 160 | + | require.Equal(t, current.Height, floating.screen.Frame.Height) | |
| 161 | + | } | |
| 162 | + | ||
| 124 | 163 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |
| 125 | 164 | t.Run("failure preserves state", func(t *testing.T) { | |
| 126 | 165 | pty := &resizePTY{err: errors.New("nope")} | |
| @@ -157,6 +196,7 @@ type resizePTY struct { | |||
| 157 | 196 | mu sync.Mutex | |
| 158 | 197 | resizes []domain.Size | |
| 159 | 198 | err error | |
| 199 | + | errs []error | |
| 160 | 200 | entered chan struct{} | |
| 161 | 201 | release chan struct{} | |
| 162 | 202 | } | |
| @@ -165,12 +205,16 @@ func (p *resizePTY) Resize(sz domain.Size) error { | |||
| 165 | 205 | p.mu.Lock() | |
| 166 | 206 | p.resizes = append(p.resizes, sz) | |
| 167 | 207 | n := len(p.resizes) | |
| 208 | + | err := p.err | |
| 209 | + | if n <= len(p.errs) { | |
| 210 | + | err = p.errs[n-1] | |
| 211 | + | } | |
| 168 | 212 | p.mu.Unlock() | |
| 169 | 213 | if n == 1 && p.entered != nil { | |
| 170 | 214 | close(p.entered) | |
| 171 | 215 | <-p.release | |
| 172 | 216 | } | |
| 173 | - | return p.err | |
| 217 | + | return err | |
| 174 | 218 | } | |
| 175 | 219 | func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) } | |
| 176 | 220 | func (p *resizePTY) sizes() []domain.Size { | |
| @@ -121,6 +121,45 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |||||
| 121 | } | 121 | } | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | + | func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 125 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 126 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}}) | |||
| 127 | + | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | |||
| 128 | + | sess := &session{tabs: []*tab{tb}} | |||
| 129 | + | ||||
| 130 | + | initial := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()).Inner | |||
| 131 | + | current := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()).Inner | |||
| 132 | + | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | |||
| 133 | + | floating := newPane("floating", pty, rectSize(initial)) | |||
| 134 | + | floating.rect = initial | |||
| 135 | + | ||||
| 136 | + | // Prewarming installs a hidden pane at the initial geometry. | |||
| 137 | + | tb.mu.Lock() | |||
| 138 | + | generation := tb.beginFloatingWarmLocked(false) | |||
| 139 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 140 | + | require.Equal(t, floatingHidden, tb.floating.state) | |||
| 141 | + | require.False(t, tb.floating.desiredVisible) | |||
| 142 | + | tb.mu.Unlock() | |||
| 143 | + | // A client resize changes tab geometry but must leave the hidden PTY alone. | |||
| 144 | + | d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42}) | |||
| 145 | + | require.Empty(t, pty.sizes()) | |||
| 146 | + | ||||
| 147 | + | // Showing attempts the current size before paint. A failed resize leaves the | |||
| 148 | + | // old screen and rect untouched, so a later hide/show can retry. | |||
| 149 | + | require.NoError(t, d.toggleFloating(sess, nil)) | |||
| 150 | + | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | |||
| 151 | + | require.Equal(t, initial, floating.rect) | |||
| 152 | + | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |||
| 153 | + | require.Equal(t, initial.Height, floating.screen.Frame.Height) | |||
| 154 | + | ||||
| 155 | + | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |||
| 156 | + | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | |||
| 157 | + | require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes()) | |||
| 158 | + | require.Equal(t, current, floating.rect) | |||
| 159 | + | require.Equal(t, current.Width, floating.screen.Frame.Width) | |||
| 160 | + | require.Equal(t, current.Height, floating.screen.Frame.Height) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 124 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | 163 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | ||
| 125 | t.Run("failure preserves state", func(t *testing.T) { | 164 | t.Run("failure preserves state", func(t *testing.T) { | ||
| 126 | pty := &resizePTY{err: errors.New("nope")} | 165 | pty := &resizePTY{err: errors.New("nope")} | ||
| @@ -157,6 +196,7 @@ type resizePTY struct { | |||||
| 157 | mu sync.Mutex | 196 | mu sync.Mutex | ||
| 158 | resizes []domain.Size | 197 | resizes []domain.Size | ||
| 159 | err error | 198 | err error | ||
| 199 | + | errs []error | |||
| 160 | entered chan struct{} | 200 | entered chan struct{} | ||
| 161 | release chan struct{} | 201 | release chan struct{} | ||
| 162 | } | 202 | } | ||
| @@ -165,12 +205,16 @@ func (p *resizePTY) Resize(sz domain.Size) error { | |||||
| 165 | p.mu.Lock() | 205 | p.mu.Lock() | ||
| 166 | p.resizes = append(p.resizes, sz) | 206 | p.resizes = append(p.resizes, sz) | ||
| 167 | n := len(p.resizes) | 207 | n := len(p.resizes) | ||
| 208 | + | err := p.err | |||
| 209 | + | if n <= len(p.errs) { | |||
| 210 | + | err = p.errs[n-1] | |||
| 211 | + | } | |||
| 168 | p.mu.Unlock() | 212 | p.mu.Unlock() | ||
| 169 | if n == 1 && p.entered != nil { | 213 | if n == 1 && p.entered != nil { | ||
| 170 | close(p.entered) | 214 | close(p.entered) | ||
| 171 | <-p.release | 215 | <-p.release | ||
| 172 | } | 216 | } | ||
| 173 | - | return p.err | 217 | + | return err |
| 174 | } | 218 | } | ||
| 175 | func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) } | 219 | func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) } | ||
| 176 | func (p *resizePTY) sizes() []domain.Size { | 220 | func (p *resizePTY) sizes() []domain.Size { | ||
M internal/usecase/daemon/floating_test.go +1 -1
| @@ -584,7 +584,7 @@ func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | |||
| 584 | 584 | sess := &session{tabs: []*tab{tb}, ctx: t.Context()} | |
| 585 | 585 | tb.mu.Lock() | |
| 586 | 586 | g := tb.beginFloatingWarmLocked(false) | |
| 587 | - | floating := &pane{} | |
| 587 | + | floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8}) | |
| 588 | 588 | require.True(t, tb.installFloatingLocked(floating, g)) | |
| 589 | 589 | tb.mu.Unlock() | |
| 590 | 590 | require.NoError(t, d.toggleFloating(sess, nil)) | |
| @@ -584,7 +584,7 @@ func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | |||||
| 584 | sess := &session{tabs: []*tab{tb}, ctx: t.Context()} | 584 | sess := &session{tabs: []*tab{tb}, ctx: t.Context()} | ||
| 585 | tb.mu.Lock() | 585 | tb.mu.Lock() | ||
| 586 | g := tb.beginFloatingWarmLocked(false) | 586 | g := tb.beginFloatingWarmLocked(false) | ||
| 587 | - | floating := &pane{} | 587 | + | floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8}) |
| 588 | require.True(t, tb.installFloatingLocked(floating, g)) | 588 | require.True(t, tb.installFloatingLocked(floating, g)) | ||
| 589 | tb.mu.Unlock() | 589 | tb.mu.Unlock() | ||
| 590 | require.NoError(t, d.toggleFloating(sess, nil)) | 590 | require.NoError(t, d.toggleFloating(sess, nil)) | ||