detached 6f656a7

test(floating): cover responsive resize and input

commit
6f656a7f6534fff62c7d49aea1604b54034c9f35
author
brice · · 2026-07-27 16:27 UTC
committer
brice · · 2026-07-27 17:06 UTC
parent
d1d65ad
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +248 -11
M internal/usecase/daemon/floating_render_test.go +5 -5
@@ -187,7 +187,7 @@ func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testi
187187 require.Equal(t, geometry, gotGeometry)
188188 }
189189
190-func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(t *testing.T) {
190+func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) {
191191 cfg := domain.FloatingConfig{Width: 100, Height: 100}
192192 initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
193193 requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)
@@ -203,11 +203,11 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(t *test
203203 failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
204204 require.True(t, ok)
205205 require.Empty(t, failed)
206- require.Equal(t, []domain.Size{{Cols: 1, Rows: 1}}, pty.sizes())
206+ require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize")
207207 require.Equal(t, requested, floating.popupGeometry)
208- require.Equal(t, domain.Rect{Width: 1, Height: 1}, floating.rect)
209- require.Equal(t, 1, floating.screen.Frame.Width)
210- require.Equal(t, 1, floating.screen.Frame.Height)
208+ require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size")
209+ require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width)
210+ require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height)
211211 }
212212
213213 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
@@ -187,7 +187,7 @@ func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testi
187 require.Equal(t, geometry, gotGeometry)187 require.Equal(t, geometry, gotGeometry)
188 }188 }
189 189
190-func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(t *testing.T) {190+func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) {
191 cfg := domain.FloatingConfig{Width: 100, Height: 100}191 cfg := domain.FloatingConfig{Width: 100, Height: 100}
192 initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)192 initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
193 requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)193 requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)
@@ -203,11 +203,11 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(t *test
203 failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)203 failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
204 require.True(t, ok)204 require.True(t, ok)
205 require.Empty(t, failed)205 require.Empty(t, failed)
206- require.Equal(t, []domain.Size{{Cols: 1, Rows: 1}}, pty.sizes())206+ require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize")
207 require.Equal(t, requested, floating.popupGeometry)207 require.Equal(t, requested, floating.popupGeometry)
208- require.Equal(t, domain.Rect{Width: 1, Height: 1}, floating.rect)208+ require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size")
209- require.Equal(t, 1, floating.screen.Frame.Width)209+ require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width)
210- require.Equal(t, 1, floating.screen.Frame.Height)210+ require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height)
211 }211 }
212 212
213 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {213 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
M internal/usecase/daemon/floating_test.go +65 -0
@@ -184,6 +184,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
184184 d.sessWg.Wait()
185185 }
186186
187+func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
188+ initial := domain.Size{Cols: 80, Rows: 25}
189+ latest := domain.Size{Cols: 79, Rows: 25}
190+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
191+ latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
192+
193+ readerRelease := make(chan struct{})
194+ readerStarted := make(chan struct{})
195+ resized := make(chan struct{})
196+ floatingPTY := portsmocks.NewMockPTY(t)
197+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
198+ close(readerStarted)
199+ <-readerRelease
200+ return 0, io.EOF
201+ }).Once()
202+ floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()
203+ floatingPTY.EXPECT().Close().RunAndReturn(func() error {
204+ select {
205+ case <-readerRelease:
206+ default:
207+ close(readerRelease)
208+ }
209+ return nil
210+ }).Once()
211+ factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
212+ d := newTestDaemon(t, factory, stubClock{})
213+ d.ApplyConfig(domain.Config{Floating: cfg})
214+ tabCtx, cancelTab := context.WithCancel(t.Context())
215+ t.Cleanup(cancelTab)
216+ tb := newTab(&transactionalResizePTY{}, tabSize(initial))
217+ tb.ctx, tb.cancel = tabCtx, cancelTab
218+ sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()}
219+
220+ d.startFloating(sess, tb, true)
221+ openCall := <-opened
222+ require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
223+ require.True(t, d.requestTransactionalResize(sess, nil, latest, true))
224+ allowOpen()
225+ select {
226+ case <-resized:
227+ case <-time.After(time.Second):
228+ t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
229+ }
230+ select {
231+ case <-readerStarted:
232+ case <-time.After(time.Second):
233+ t.Fatal("floating reader did not start")
234+ }
235+
236+ require.Eventually(t, func() bool {
237+ tb.mu.Lock()
238+ p := tb.floating.pane
239+ visible := tb.floating.state == floatingVisible
240+ tb.mu.Unlock()
241+ if !visible || p == nil {
242+ return false
243+ }
244+ p.mu.Lock()
245+ defer p.mu.Unlock()
246+ return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
247+ }, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
248+ d.teardownFloating(tb, nil)
249+ d.sessWg.Wait()
250+}
251+
187252 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
188253 floatingPTY := portsmocks.NewMockPTY(t)
189254 readerStarted := make(chan struct{})
@@ -184,6 +184,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
184 d.sessWg.Wait()184 d.sessWg.Wait()
185 }185 }
186 186
187+func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
188+ initial := domain.Size{Cols: 80, Rows: 25}
189+ latest := domain.Size{Cols: 79, Rows: 25}
190+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
191+ latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
192+
193+ readerRelease := make(chan struct{})
194+ readerStarted := make(chan struct{})
195+ resized := make(chan struct{})
196+ floatingPTY := portsmocks.NewMockPTY(t)
197+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
198+ close(readerStarted)
199+ <-readerRelease
200+ return 0, io.EOF
201+ }).Once()
202+ floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()
203+ floatingPTY.EXPECT().Close().RunAndReturn(func() error {
204+ select {
205+ case <-readerRelease:
206+ default:
207+ close(readerRelease)
208+ }
209+ return nil
210+ }).Once()
211+ factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
212+ d := newTestDaemon(t, factory, stubClock{})
213+ d.ApplyConfig(domain.Config{Floating: cfg})
214+ tabCtx, cancelTab := context.WithCancel(t.Context())
215+ t.Cleanup(cancelTab)
216+ tb := newTab(&transactionalResizePTY{}, tabSize(initial))
217+ tb.ctx, tb.cancel = tabCtx, cancelTab
218+ sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()}
219+
220+ d.startFloating(sess, tb, true)
221+ openCall := <-opened
222+ require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
223+ require.True(t, d.requestTransactionalResize(sess, nil, latest, true))
224+ allowOpen()
225+ select {
226+ case <-resized:
227+ case <-time.After(time.Second):
228+ t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
229+ }
230+ select {
231+ case <-readerStarted:
232+ case <-time.After(time.Second):
233+ t.Fatal("floating reader did not start")
234+ }
235+
236+ require.Eventually(t, func() bool {
237+ tb.mu.Lock()
238+ p := tb.floating.pane
239+ visible := tb.floating.state == floatingVisible
240+ tb.mu.Unlock()
241+ if !visible || p == nil {
242+ return false
243+ }
244+ p.mu.Lock()
245+ defer p.mu.Unlock()
246+ return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
247+ }, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
248+ d.teardownFloating(tb, nil)
249+ d.sessWg.Wait()
250+}
251+
187 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {252 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
188 floatingPTY := portsmocks.NewMockPTY(t)253 floatingPTY := portsmocks.NewMockPTY(t)
189 readerStarted := make(chan struct{})254 readerStarted := make(chan struct{})
M internal/usecase/daemon/input.go +2 -2
@@ -60,7 +60,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
6060 }
6161
6262 tb.mu.Lock()
63- contentRow := ev.Row - 1
63+ contentRow := ev.Row - clientTopBarRows
6464 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
6565 if floatingVisible {
6666 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
@@ -169,7 +169,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
169169 if translated {
170170 d.writeToPane(sess, p, ev.Raw)
171171 } else {
172- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))
172+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
173173 }
174174 return
175175 }
@@ -60,7 +60,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
60 }60 }
61 61
62 tb.mu.Lock()62 tb.mu.Lock()
63- contentRow := ev.Row - 163+ contentRow := ev.Row - clientTopBarRows
64 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())64 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
65 if floatingVisible {65 if floatingVisible {
66 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {66 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
@@ -169,7 +169,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
169 if translated {169 if translated {
170 d.writeToPane(sess, p, ev.Raw)170 d.writeToPane(sess, p, ev.Raw)
171 } else {171 } else {
172- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))172+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
173 }173 }
174 return174 return
175 }175 }
M internal/usecase/daemon/input_test.go +75 -2
@@ -21,6 +21,7 @@ import (
2121 "github.com/bnema/vev/internal/usecase/layout"
2222 "github.com/bnema/vev/internal/usecase/mouse"
2323 "github.com/bnema/vev/internal/usecase/picker"
24+ "github.com/bnema/vev/internal/usecase/ui"
2425 "github.com/bnema/vev/pkg/vt"
2526 )
2627
@@ -602,7 +603,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
602603 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
603604 installTestFloating(tb, floating, true)
604605 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
605- raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2))
606+ raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
606607
607608 d.handleInput(sess, ac, raw)
608609 }
@@ -620,12 +621,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
620621 installTestFloating(tb, floating, true)
621622 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
622623
623- border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2))
624+ border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1)
624625 outside := []byte("\x1b[<0;1;2M")
625626 d.handleInput(sess, ac, border)
626627 d.handleInput(sess, ac, outside)
627628 }
628629
630+func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) {
631+ normal := portsmocks.NewMockPTY(t)
632+ floatingPTY := portsmocks.NewMockPTY(t)
633+ floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
634+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
635+ tb := sess.activeTab()
636+ complete := domain.Size{Cols: 79, Rows: 25}
637+ content := tabSize(complete)
638+ geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
639+ require.Equal(t, ui.PresentationDrawer, geometry.Mode)
640+ tb.mu.Lock()
641+ tb.size = content
642+ tb.mu.Unlock()
643+ floating := newPane("floating", floatingPTY, rectSize(geometry.Inner))
644+ floating.popupGeometry = geometry
645+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
646+ installTestFloating(tb, floating, true)
647+
648+ firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
649+ d.handleInput(sess, ac, firstInner)
650+ for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
651+ d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
652+ }
653+}
654+
655+func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) {
656+ p, release := newBlockingPTY(t)
657+ defer release()
658+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
659+ tb := sess.activeTab()
660+ complete := domain.Size{Cols: 79, Rows: 25}
661+ content := tabSize(complete)
662+ geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
663+ tb.mu.Lock()
664+ tb.size = content
665+ tb.mu.Unlock()
666+ floating := newPane("floating", nil, rectSize(geometry.Inner))
667+ floating.popupGeometry = geometry
668+ floating.screen.Write([]byte("drawer row zero\r\nsecond row"))
669+ installTestFloating(tb, floating, true)
670+
671+ for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
672+ d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
673+ ac.overlays.copyMu.Lock()
674+ require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow)
675+ ac.overlays.copyMu.Unlock()
676+ }
677+
678+ firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
679+ d.handleInput(sess, ac, firstInner)
680+ ac.overlays.copyMu.Lock()
681+ pointer := ac.overlays.copyPointer
682+ ac.overlays.copyMu.Unlock()
683+ require.True(t, pointer.valid)
684+ require.Same(t, floating, pointer.pane)
685+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press)
686+ require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content)
687+
688+ // Motion onto the protected top row clamps against the press-owned drawer
689+ // geometry instead of retargeting the underlying tiled terminal.
690+ d.handleInput(sess, ac, []byte("\x1b[<32;79;1M"))
691+ ac.overlays.copyMu.Lock()
692+ require.NotNil(t, ac.overlays.copyMode)
693+ require.Same(t, floating, ac.overlays.copyPane)
694+ require.Same(t, floating, ac.overlays.copyPointer.pane)
695+ require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry)
696+ selection := ac.overlays.copyMode.Selection()
697+ ac.overlays.copyMu.Unlock()
698+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
699+ require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row")
700+}
701+
629702 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
630703 writes := make(chan []byte, 1)
631704 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -21,6 +21,7 @@ import (
21 "github.com/bnema/vev/internal/usecase/layout"21 "github.com/bnema/vev/internal/usecase/layout"
22 "github.com/bnema/vev/internal/usecase/mouse"22 "github.com/bnema/vev/internal/usecase/mouse"
23 "github.com/bnema/vev/internal/usecase/picker"23 "github.com/bnema/vev/internal/usecase/picker"
24+ "github.com/bnema/vev/internal/usecase/ui"
24 "github.com/bnema/vev/pkg/vt"25 "github.com/bnema/vev/pkg/vt"
25 )26 )
26 27
@@ -602,7 +603,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
602 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))603 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
603 installTestFloating(tb, floating, true)604 installTestFloating(tb, floating, true)
604 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())605 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
605- raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2))606+ raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
606 607
607 d.handleInput(sess, ac, raw)608 d.handleInput(sess, ac, raw)
608 }609 }
@@ -620,12 +621,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
620 installTestFloating(tb, floating, true)621 installTestFloating(tb, floating, true)
621 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())622 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
622 623
623- border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2))624+ border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1)
624 outside := []byte("\x1b[<0;1;2M")625 outside := []byte("\x1b[<0;1;2M")
625 d.handleInput(sess, ac, border)626 d.handleInput(sess, ac, border)
626 d.handleInput(sess, ac, outside)627 d.handleInput(sess, ac, outside)
627 }628 }
628 629
630+func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) {
631+ normal := portsmocks.NewMockPTY(t)
632+ floatingPTY := portsmocks.NewMockPTY(t)
633+ floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
634+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
635+ tb := sess.activeTab()
636+ complete := domain.Size{Cols: 79, Rows: 25}
637+ content := tabSize(complete)
638+ geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
639+ require.Equal(t, ui.PresentationDrawer, geometry.Mode)
640+ tb.mu.Lock()
641+ tb.size = content
642+ tb.mu.Unlock()
643+ floating := newPane("floating", floatingPTY, rectSize(geometry.Inner))
644+ floating.popupGeometry = geometry
645+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
646+ installTestFloating(tb, floating, true)
647+
648+ firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
649+ d.handleInput(sess, ac, firstInner)
650+ for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
651+ d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
652+ }
653+}
654+
655+func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) {
656+ p, release := newBlockingPTY(t)
657+ defer release()
658+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
659+ tb := sess.activeTab()
660+ complete := domain.Size{Cols: 79, Rows: 25}
661+ content := tabSize(complete)
662+ geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
663+ tb.mu.Lock()
664+ tb.size = content
665+ tb.mu.Unlock()
666+ floating := newPane("floating", nil, rectSize(geometry.Inner))
667+ floating.popupGeometry = geometry
668+ floating.screen.Write([]byte("drawer row zero\r\nsecond row"))
669+ installTestFloating(tb, floating, true)
670+
671+ for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
672+ d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
673+ ac.overlays.copyMu.Lock()
674+ require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow)
675+ ac.overlays.copyMu.Unlock()
676+ }
677+
678+ firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
679+ d.handleInput(sess, ac, firstInner)
680+ ac.overlays.copyMu.Lock()
681+ pointer := ac.overlays.copyPointer
682+ ac.overlays.copyMu.Unlock()
683+ require.True(t, pointer.valid)
684+ require.Same(t, floating, pointer.pane)
685+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press)
686+ require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content)
687+
688+ // Motion onto the protected top row clamps against the press-owned drawer
689+ // geometry instead of retargeting the underlying tiled terminal.
690+ d.handleInput(sess, ac, []byte("\x1b[<32;79;1M"))
691+ ac.overlays.copyMu.Lock()
692+ require.NotNil(t, ac.overlays.copyMode)
693+ require.Same(t, floating, ac.overlays.copyPane)
694+ require.Same(t, floating, ac.overlays.copyPointer.pane)
695+ require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry)
696+ selection := ac.overlays.copyMode.Selection()
697+ ac.overlays.copyMu.Unlock()
698+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
699+ require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row")
700+}
701+
629 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {702 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
630 writes := make(chan []byte, 1)703 writes := make(chan []byte, 1)
631 p, releasePTY := newBlockingPTYWithWrites(t, writes)704 p, releasePTY := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/mouse_panes.go +1 -1
@@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
187187 func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {
188188 ev.Col -= colOffset
189189 ev.Row -= contentYOffset
190- ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1)
190+ ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows)
191191 return ev
192192 }
193193
@@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
187 func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {187 func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {
188 ev.Col -= colOffset188 ev.Col -= colOffset
189 ev.Row -= contentYOffset189 ev.Row -= contentYOffset
190- ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1)190+ ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows)
191 return ev191 return ev
192 }192 }
193 193
M internal/usecase/daemon/transactional_resize.go +21 -0
@@ -369,6 +369,27 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
369369 if !geometry.committable() {
370370 return nil, true
371371 }
372+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
373+ // A drawer can validly reserve only its separator row. Publish that
374+ // presentation without inventing a physical PTY size; the last usable
375+ // rectangle and screen remain committed until a later resize has content.
376+ tb.mu.Lock()
377+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
378+ tb.floating.pane == p && tb.size == size
379+ if current != nil && !current() {
380+ currentSlot = false
381+ }
382+ if currentSlot {
383+ p.mu.Lock()
384+ p.popupGeometry = geometry
385+ p.mu.Unlock()
386+ }
387+ tb.mu.Unlock()
388+ if current != nil && !current() {
389+ return nil, false
390+ }
391+ return nil, true
392+ }
372393
373394 // This keeps successful PTY resizes gated until the
374395 // floating slot and tab size are revalidated. A newer client resize may
@@ -369,6 +369,27 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
369 if !geometry.committable() {369 if !geometry.committable() {
370 return nil, true370 return nil, true
371 }371 }
372+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
373+ // A drawer can validly reserve only its separator row. Publish that
374+ // presentation without inventing a physical PTY size; the last usable
375+ // rectangle and screen remain committed until a later resize has content.
376+ tb.mu.Lock()
377+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
378+ tb.floating.pane == p && tb.size == size
379+ if current != nil && !current() {
380+ currentSlot = false
381+ }
382+ if currentSlot {
383+ p.mu.Lock()
384+ p.popupGeometry = geometry
385+ p.mu.Unlock()
386+ }
387+ tb.mu.Unlock()
388+ if current != nil && !current() {
389+ return nil, false
390+ }
391+ return nil, true
392+ }
372 393
373 // This keeps successful PTY resizes gated until the394 // This keeps successful PTY resizes gated until the
374 // floating slot and tab size are revalidated. A newer client resize may395 // floating slot and tab size are revalidated. A newer client resize may
M internal/usecase/daemon/transactional_resize_test.go +79 -1
@@ -484,11 +484,65 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
484484 tb.mu.Unlock()
485485 }
486486
487+func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
488+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
489+ for _, tc := range []struct {
490+ name string
491+ from domain.Size
492+ to domain.Size
493+ wantMode ui.PresentationMode
494+ }{
495+ {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
496+ {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
497+ } {
498+ t.Run(tc.name, func(t *testing.T) {
499+ d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
500+ d.ApplyConfig(domain.Config{Floating: cfg})
501+ require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
502+
503+ tb := sess.activeTab()
504+ fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
505+ toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
506+ entered, release := make(chan struct{}), make(chan struct{})
507+ var once sync.Once
508+ popupPTY := &transactionalResizePTY{onResize: func() {
509+ once.Do(func() { close(entered) })
510+ <-release
511+ }}
512+ popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
513+ popup.rect = fromGeometry.Inner
514+ popup.popupGeometry = fromGeometry
515+ installTestFloating(tb, popup, true)
516+
517+ done := make(chan bool, 1)
518+ go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }()
519+ <-entered
520+ popup.mu.Lock()
521+ require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
522+ require.Equal(t, fromGeometry.Inner, popup.rect)
523+ require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
524+ popup.mu.Unlock()
525+ close(release)
526+ require.True(t, <-done)
527+
528+ require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
529+ popup.mu.Lock()
530+ require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
531+ require.Equal(t, toGeometry, popup.popupGeometry)
532+ require.Equal(t, toGeometry.Inner, popup.rect)
533+ require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
534+ popup.mu.Unlock()
535+ })
536+ }
537+}
538+
487539 func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
488540 cfg := domain.FloatingConfig{Width: 50, Height: 50}
489541 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
490- requestedSize := domain.Size{Cols: 100, Rows: 40}
542+ requestedSize := domain.Size{Cols: 79, Rows: 40}
491543 requested := calculateContentFloatingGeometry(requestedSize, cfg)
544+ require.Equal(t, ui.PresentationFloating, committed.Mode)
545+ require.Equal(t, ui.PresentationDrawer, requested.Mode)
492546 resizeErr := errors.New("floating resize failed")
493547 pty := &transactionalResizePTY{errs: []error{resizeErr}}
494548 popup := newPane("floating", pty, rectSize(committed.Inner))
@@ -563,6 +617,30 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *
563617 popup.mu.Unlock()
564618 }
565619
620+func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
621+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
622+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
623+ pty := &transactionalResizePTY{}
624+ popup := newPane("floating", pty, rectSize(committed.Inner))
625+ popup.rect = committed.Inner
626+ popup.popupGeometry = committed
627+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
628+ installTestFloating(tb, popup, true)
629+ d := newTestDaemon(t, nil, stubClock{})
630+ d.ApplyConfig(domain.Config{Floating: cfg})
631+
632+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false })
633+
634+ require.False(t, ok)
635+ require.Empty(t, failed)
636+ require.Empty(t, pty.requested())
637+ popup.mu.Lock()
638+ require.Equal(t, committed, popup.popupGeometry)
639+ require.Equal(t, committed.Inner, popup.rect)
640+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
641+ popup.mu.Unlock()
642+}
643+
566644 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
567645 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
568646 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
@@ -484,11 +484,65 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
484 tb.mu.Unlock()484 tb.mu.Unlock()
485 }485 }
486 486
487+func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
488+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
489+ for _, tc := range []struct {
490+ name string
491+ from domain.Size
492+ to domain.Size
493+ wantMode ui.PresentationMode
494+ }{
495+ {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
496+ {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
497+ } {
498+ t.Run(tc.name, func(t *testing.T) {
499+ d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
500+ d.ApplyConfig(domain.Config{Floating: cfg})
501+ require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
502+
503+ tb := sess.activeTab()
504+ fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
505+ toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
506+ entered, release := make(chan struct{}), make(chan struct{})
507+ var once sync.Once
508+ popupPTY := &transactionalResizePTY{onResize: func() {
509+ once.Do(func() { close(entered) })
510+ <-release
511+ }}
512+ popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
513+ popup.rect = fromGeometry.Inner
514+ popup.popupGeometry = fromGeometry
515+ installTestFloating(tb, popup, true)
516+
517+ done := make(chan bool, 1)
518+ go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }()
519+ <-entered
520+ popup.mu.Lock()
521+ require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
522+ require.Equal(t, fromGeometry.Inner, popup.rect)
523+ require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
524+ popup.mu.Unlock()
525+ close(release)
526+ require.True(t, <-done)
527+
528+ require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
529+ popup.mu.Lock()
530+ require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
531+ require.Equal(t, toGeometry, popup.popupGeometry)
532+ require.Equal(t, toGeometry.Inner, popup.rect)
533+ require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
534+ popup.mu.Unlock()
535+ })
536+ }
537+}
538+
487 func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {539 func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
488 cfg := domain.FloatingConfig{Width: 50, Height: 50}540 cfg := domain.FloatingConfig{Width: 50, Height: 50}
489 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)541 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
490- requestedSize := domain.Size{Cols: 100, Rows: 40}542+ requestedSize := domain.Size{Cols: 79, Rows: 40}
491 requested := calculateContentFloatingGeometry(requestedSize, cfg)543 requested := calculateContentFloatingGeometry(requestedSize, cfg)
544+ require.Equal(t, ui.PresentationFloating, committed.Mode)
545+ require.Equal(t, ui.PresentationDrawer, requested.Mode)
492 resizeErr := errors.New("floating resize failed")546 resizeErr := errors.New("floating resize failed")
493 pty := &transactionalResizePTY{errs: []error{resizeErr}}547 pty := &transactionalResizePTY{errs: []error{resizeErr}}
494 popup := newPane("floating", pty, rectSize(committed.Inner))548 popup := newPane("floating", pty, rectSize(committed.Inner))
@@ -563,6 +617,30 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *
563 popup.mu.Unlock()617 popup.mu.Unlock()
564 }618 }
565 619
620+func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
621+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
622+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
623+ pty := &transactionalResizePTY{}
624+ popup := newPane("floating", pty, rectSize(committed.Inner))
625+ popup.rect = committed.Inner
626+ popup.popupGeometry = committed
627+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
628+ installTestFloating(tb, popup, true)
629+ d := newTestDaemon(t, nil, stubClock{})
630+ d.ApplyConfig(domain.Config{Floating: cfg})
631+
632+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false })
633+
634+ require.False(t, ok)
635+ require.Empty(t, failed)
636+ require.Empty(t, pty.requested())
637+ popup.mu.Lock()
638+ require.Equal(t, committed, popup.popupGeometry)
639+ require.Equal(t, committed.Inner, popup.rect)
640+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
641+ popup.mu.Unlock()
642+}
643+
566 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {644 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
567 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}645 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
568 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})646 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})

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 2

Tags 13

↑↓ moveenter openesc close