detached 540608f

feat(daemon): route floating terminal input

commit
540608fb41a70018ef8ab4b4ddeb9bdb5ff1d983
author
brice · · 2026-07-10 07:19 UTC
parent
eaec9db
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +191 -2
M internal/usecase/daemon/clipboard.go +1 -1
@@ -91,7 +91,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
9191 return
9292 }
9393 tb.mu.Lock()
94- p := tb.focusedPane()
94+ p := tb.terminalTargetLocked()
9595 tb.mu.Unlock()
9696 if p == nil {
9797 return
@@ -91,7 +91,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
91 return91 return
92 }92 }
93 tb.mu.Lock()93 tb.mu.Lock()
94- p := tb.focusedPane()94+ p := tb.terminalTargetLocked()
95 tb.mu.Unlock()95 tb.mu.Unlock()
96 if p == nil {96 if p == nil {
97 return97 return
M internal/usecase/daemon/clipboard_test.go +19 -0
@@ -13,11 +13,30 @@ import (
1313 "github.com/stretchr/testify/mock"
1414 "github.com/stretchr/testify/require"
1515
16+ "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/ports"
1718 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1819 scopy "github.com/bnema/vev/internal/usecase/copy"
1920 )
2021
22+func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) {
23+ normalWrites := make(chan []byte, 1)
24+ floatingWrites := make(chan []byte, 1)
25+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
26+ floatingPTY, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
27+ defer releaseNormal()
28+ defer releaseFloating()
29+ d, sess, _, _ := newManualSessionWithPTYs(t, normal)
30+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
31+ floating.screen.Write([]byte("\x1b[?2004h"))
32+ installTestFloating(sess.activeTab(), floating, true)
33+
34+ d.injectClipboardPath(sess, "/tmp/image.png")
35+
36+ requirePTYWrite(t, floatingWrites, []byte("\x1b[200~/tmp/image.png\x1b[201~"))
37+ requireNoPTYWrite(t, normalWrites)
38+}
39+
2140 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
2241 writes := make(chan []byte, 1)
2342 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -13,11 +13,30 @@ import (
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16+ "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
19 )20 )
20 21
22+func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) {
23+ normalWrites := make(chan []byte, 1)
24+ floatingWrites := make(chan []byte, 1)
25+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
26+ floatingPTY, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
27+ defer releaseNormal()
28+ defer releaseFloating()
29+ d, sess, _, _ := newManualSessionWithPTYs(t, normal)
30+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
31+ floating.screen.Write([]byte("\x1b[?2004h"))
32+ installTestFloating(sess.activeTab(), floating, true)
33+
34+ d.injectClipboardPath(sess, "/tmp/image.png")
35+
36+ requirePTYWrite(t, floatingWrites, []byte("\x1b[200~/tmp/image.png\x1b[201~"))
37+ requireNoPTYWrite(t, normalWrites)
38+}
39+
21 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {40 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
22 writes := make(chan []byte, 1)41 writes := make(chan []byte, 1)
23 p, releasePTY := newBlockingPTYWithWrites(t, writes)42 p, releasePTY := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/floating.go +14 -0
@@ -125,6 +125,20 @@ func (tb *tab) terminalTargetLocked() *pane {
125125 return tb.focusedPane()
126126 }
127127
128+// visibleFloatingSnapshotLocked captures the visible popup and its current
129+// geometry under the tab lock, so input cannot mix a pane from one state with
130+// coordinates from another. The caller must hold tb.mu.
131+func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, floatingGeometry, bool) {
132+ if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil {
133+ return nil, floatingGeometry{}, false
134+ }
135+ geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
136+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
137+ return nil, floatingGeometry{}, false
138+ }
139+ return tb.floating.pane, geometry, true
140+}
141+
128142 // ensureFloatingWarm starts the background prewarm exactly once for a tab.
129143 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
130144 if d == nil || sess == nil || tb == nil || d.ptys == nil {
@@ -125,6 +125,20 @@ func (tb *tab) terminalTargetLocked() *pane {
125 return tb.focusedPane()125 return tb.focusedPane()
126 }126 }
127 127
128+// visibleFloatingSnapshotLocked captures the visible popup and its current
129+// geometry under the tab lock, so input cannot mix a pane from one state with
130+// coordinates from another. The caller must hold tb.mu.
131+func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, floatingGeometry, bool) {
132+ if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil {
133+ return nil, floatingGeometry{}, false
134+ }
135+ geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
136+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
137+ return nil, floatingGeometry{}, false
138+ }
139+ return tb.floating.pane, geometry, true
140+}
141+
128 // ensureFloatingWarm starts the background prewarm exactly once for a tab.142 // ensureFloatingWarm starts the background prewarm exactly once for a tab.
129 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {143 func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
130 if d == nil || sess == nil || tb == nil || d.ptys == nil {144 if d == nil || sess == nil || tb == nil || d.ptys == nil {
M internal/usecase/daemon/input.go +22 -1
@@ -120,6 +120,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
120120
121121 tb.mu.Lock()
122122 contentRow := ev.Row - 1
123+ floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
124+ if floatingVisible {
125+ if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
126+ tb.mu.Unlock()
127+ return
128+ }
129+ tb.mu.Unlock()
130+ ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
131+ d.handleTerminalMouse(sess, ac, floating, ev, true, true)
132+ return
133+ }
123134 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
124135 multi := len(tb.panes) > 1
125136 focusedID := layout.PaneID("")
@@ -177,6 +188,14 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
177188 return
178189 }
179190 }
191+ d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
192+}
193+
194+func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
195+ if p == nil {
196+ return
197+ }
198+ rt := ac.overlays
180199 p.mu.Lock()
181200 childRows := p.screen.Frame.Height
182201 mouseMode, mouseSGR := p.screen.MouseMode()
@@ -285,7 +304,9 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
285304 if _, err := p.pty.Write(data); err != nil {
286305 name := ""
287306 if sess != nil {
307+ sess.mu.Lock()
288308 name = sess.name
309+ sess.mu.Unlock()
289310 }
290311 d.log.Error("pty write failed", "err", err, "session", name)
291312 }
@@ -348,7 +369,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
348369 return
349370 }
350371 tb.mu.Lock()
351- p := tb.focusedPane()
372+ p := tb.terminalTargetLocked()
352373 tb.mu.Unlock()
353374 h.d.writeToPane(sess, p, data)
354375 }
@@ -120,6 +120,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
120 120
121 tb.mu.Lock()121 tb.mu.Lock()
122 contentRow := ev.Row - 1122 contentRow := ev.Row - 1
123+ floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
124+ if floatingVisible {
125+ if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
126+ tb.mu.Unlock()
127+ return
128+ }
129+ tb.mu.Unlock()
130+ ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
131+ d.handleTerminalMouse(sess, ac, floating, ev, true, true)
132+ return
133+ }
123 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)134 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
124 multi := len(tb.panes) > 1135 multi := len(tb.panes) > 1
125 focusedID := layout.PaneID("")136 focusedID := layout.PaneID("")
@@ -177,6 +188,14 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
177 return188 return
178 }189 }
179 }190 }
191+ d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
192+}
193+
194+func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
195+ if p == nil {
196+ return
197+ }
198+ rt := ac.overlays
180 p.mu.Lock()199 p.mu.Lock()
181 childRows := p.screen.Frame.Height200 childRows := p.screen.Frame.Height
182 mouseMode, mouseSGR := p.screen.MouseMode()201 mouseMode, mouseSGR := p.screen.MouseMode()
@@ -285,7 +304,9 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
285 if _, err := p.pty.Write(data); err != nil {304 if _, err := p.pty.Write(data); err != nil {
286 name := ""305 name := ""
287 if sess != nil {306 if sess != nil {
307+ sess.mu.Lock()
288 name = sess.name308 name = sess.name
309+ sess.mu.Unlock()
289 }310 }
290 d.log.Error("pty write failed", "err", err, "session", name)311 d.log.Error("pty write failed", "err", err, "session", name)
291 }312 }
@@ -348,7 +369,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
348 return369 return
349 }370 }
350 tb.mu.Lock()371 tb.mu.Lock()
351- p := tb.focusedPane()372+ p := tb.terminalTargetLocked()
352 tb.mu.Unlock()373 tb.mu.Unlock()
353 h.d.writeToPane(sess, p, data)374 h.d.writeToPane(sess, p, data)
354 }375 }
M internal/usecase/daemon/input_test.go +135 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "encoding/base64"
5+ "fmt"
56 "strings"
67 "sync"
78 "testing"
@@ -14,6 +15,7 @@ import (
1415 "github.com/bnema/vev/internal/ports"
1516 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1617 scopy "github.com/bnema/vev/internal/usecase/copy"
18+ "github.com/bnema/vev/internal/usecase/keys"
1719 "github.com/bnema/vev/internal/usecase/layout"
1820 )
1921
@@ -79,6 +81,129 @@ func TestAltCForwardsToPTY(t *testing.T) {
7981 releasePTY()
8082 }
8183
84+func installTestFloating(tb *tab, p *pane, visible bool) {
85+ tb.mu.Lock()
86+ defer tb.mu.Unlock()
87+ tb.floating.pane = p
88+ tb.floating.generation++
89+ if visible {
90+ tb.floating.state = floatingVisible
91+ } else {
92+ tb.floating.state = floatingHidden
93+ }
94+}
95+
96+func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) {
97+ for _, tc := range []struct {
98+ name string
99+ visible bool
100+ input []byte
101+ }{
102+ {name: "visible ordinary bytes", visible: true, input: []byte("hello")},
103+ {name: "visible escape", visible: true, input: []byte("\x1b")},
104+ {name: "hidden ordinary bytes", visible: false, input: []byte("hello")},
105+ {name: "hidden escape", visible: false, input: []byte("\x1b")},
106+ } {
107+ t.Run(tc.name, func(t *testing.T) {
108+ normalWrites := make(chan []byte, 1)
109+ floatingWrites := make(chan []byte, 1)
110+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
111+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
112+ defer releaseNormal()
113+ defer releaseFloating()
114+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
115+ installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible)
116+
117+ daemonKeyHandler{d: d, ac: ac}.Forward(tc.input)
118+ if tc.visible {
119+ requirePTYWrite(t, floatingWrites, tc.input)
120+ requireNoPTYWrite(t, normalWrites)
121+ } else {
122+ requirePTYWrite(t, normalWrites, tc.input)
123+ requireNoPTYWrite(t, floatingWrites)
124+ }
125+ })
126+ }
127+}
128+
129+func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) {
130+ normalWrites := make(chan []byte, 1)
131+ floatingWrites := make(chan []byte, 1)
132+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
133+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
134+ defer releaseNormal()
135+ defer releaseFloating()
136+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
137+ installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
138+
139+ paste := []byte("\x1b[200~paste\x1bf\x1b[201~")
140+ d.handleInput(sess, ac, paste)
141+ requirePTYWrite(t, floatingWrites, paste)
142+ d.handleInput(sess, ac, []byte("\x1b "))
143+ require.True(t, ac.overlays.paletteActive(), "global palette action must still intercept")
144+ requireNoPTYWrite(t, normalWrites)
145+}
146+
147+func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
148+ writes := make(chan []byte, 1)
149+ floatingWrites := make(chan []byte, 1)
150+ normal, releaseNormal := newBlockingPTYWithWrites(t, writes)
151+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
152+ defer releaseNormal()
153+ defer releaseFloating()
154+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
155+ tb := sess.activeTab()
156+ second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
157+ tb.mu.Lock()
158+ tb.size = domain.Size{Cols: 81, Rows: 5}
159+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
160+ tb.panes["pane-2"] = second
161+ tb.mu.Unlock()
162+ installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
163+
164+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight)
165+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
166+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x"))
167+ requirePTYWrite(t, floatingWrites, []byte("x"))
168+}
169+
170+func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
171+ normal := portsmocks.NewMockPTY(t)
172+ floatingPTY := portsmocks.NewMockPTY(t)
173+ floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
174+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
175+ tb := sess.activeTab()
176+ tb.mu.Lock()
177+ tb.size = domain.Size{Cols: 100, Rows: 20}
178+ tb.mu.Unlock()
179+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
180+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
181+ installTestFloating(tb, floating, true)
182+ geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig())
183+ raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2))
184+
185+ d.handleInput(sess, ac, raw)
186+}
187+
188+func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
189+ normal := portsmocks.NewMockPTY(t)
190+ floatingPTY := portsmocks.NewMockPTY(t)
191+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
192+ tb := sess.activeTab()
193+ tb.mu.Lock()
194+ tb.size = domain.Size{Cols: 100, Rows: 20}
195+ tb.mu.Unlock()
196+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
197+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
198+ installTestFloating(tb, floating, true)
199+ geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig())
200+
201+ border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2))
202+ outside := []byte("\x1b[<0;1;2M")
203+ d.handleInput(sess, ac, border)
204+ d.handleInput(sess, ac, outside)
205+}
206+
82207 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83208 writes := make(chan []byte, 1)
84209 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -196,6 +321,16 @@ func TestOverlayInputPrecedence(t *testing.T) {
196321 }
197322 }
198323
324+func requirePTYWrite(t *testing.T, writes chan []byte, want []byte) {
325+ t.Helper()
326+ select {
327+ case got := <-writes:
328+ require.Equal(t, want, got)
329+ case <-time.After(time.Second):
330+ t.Fatalf("PTY did not receive %q", want)
331+ }
332+}
333+
199334 func requireNoPTYWrite(t *testing.T, writes chan []byte) {
200335 t.Helper()
201336 select {
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5+ "fmt"
5 "strings"6 "strings"
6 "sync"7 "sync"
7 "testing"8 "testing"
@@ -14,6 +15,7 @@ import (
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 scopy "github.com/bnema/vev/internal/usecase/copy"17 scopy "github.com/bnema/vev/internal/usecase/copy"
18+ "github.com/bnema/vev/internal/usecase/keys"
17 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
18 )20 )
19 21
@@ -79,6 +81,129 @@ func TestAltCForwardsToPTY(t *testing.T) {
79 releasePTY()81 releasePTY()
80 }82 }
81 83
84+func installTestFloating(tb *tab, p *pane, visible bool) {
85+ tb.mu.Lock()
86+ defer tb.mu.Unlock()
87+ tb.floating.pane = p
88+ tb.floating.generation++
89+ if visible {
90+ tb.floating.state = floatingVisible
91+ } else {
92+ tb.floating.state = floatingHidden
93+ }
94+}
95+
96+func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) {
97+ for _, tc := range []struct {
98+ name string
99+ visible bool
100+ input []byte
101+ }{
102+ {name: "visible ordinary bytes", visible: true, input: []byte("hello")},
103+ {name: "visible escape", visible: true, input: []byte("\x1b")},
104+ {name: "hidden ordinary bytes", visible: false, input: []byte("hello")},
105+ {name: "hidden escape", visible: false, input: []byte("\x1b")},
106+ } {
107+ t.Run(tc.name, func(t *testing.T) {
108+ normalWrites := make(chan []byte, 1)
109+ floatingWrites := make(chan []byte, 1)
110+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
111+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
112+ defer releaseNormal()
113+ defer releaseFloating()
114+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
115+ installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible)
116+
117+ daemonKeyHandler{d: d, ac: ac}.Forward(tc.input)
118+ if tc.visible {
119+ requirePTYWrite(t, floatingWrites, tc.input)
120+ requireNoPTYWrite(t, normalWrites)
121+ } else {
122+ requirePTYWrite(t, normalWrites, tc.input)
123+ requireNoPTYWrite(t, floatingWrites)
124+ }
125+ })
126+ }
127+}
128+
129+func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) {
130+ normalWrites := make(chan []byte, 1)
131+ floatingWrites := make(chan []byte, 1)
132+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
133+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
134+ defer releaseNormal()
135+ defer releaseFloating()
136+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
137+ installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
138+
139+ paste := []byte("\x1b[200~paste\x1bf\x1b[201~")
140+ d.handleInput(sess, ac, paste)
141+ requirePTYWrite(t, floatingWrites, paste)
142+ d.handleInput(sess, ac, []byte("\x1b "))
143+ require.True(t, ac.overlays.paletteActive(), "global palette action must still intercept")
144+ requireNoPTYWrite(t, normalWrites)
145+}
146+
147+func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
148+ writes := make(chan []byte, 1)
149+ floatingWrites := make(chan []byte, 1)
150+ normal, releaseNormal := newBlockingPTYWithWrites(t, writes)
151+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
152+ defer releaseNormal()
153+ defer releaseFloating()
154+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
155+ tb := sess.activeTab()
156+ second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
157+ tb.mu.Lock()
158+ tb.size = domain.Size{Cols: 81, Rows: 5}
159+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
160+ tb.panes["pane-2"] = second
161+ tb.mu.Unlock()
162+ installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
163+
164+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight)
165+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
166+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x"))
167+ requirePTYWrite(t, floatingWrites, []byte("x"))
168+}
169+
170+func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
171+ normal := portsmocks.NewMockPTY(t)
172+ floatingPTY := portsmocks.NewMockPTY(t)
173+ floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
174+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
175+ tb := sess.activeTab()
176+ tb.mu.Lock()
177+ tb.size = domain.Size{Cols: 100, Rows: 20}
178+ tb.mu.Unlock()
179+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
180+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
181+ installTestFloating(tb, floating, true)
182+ geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig())
183+ raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2))
184+
185+ d.handleInput(sess, ac, raw)
186+}
187+
188+func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
189+ normal := portsmocks.NewMockPTY(t)
190+ floatingPTY := portsmocks.NewMockPTY(t)
191+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
192+ tb := sess.activeTab()
193+ tb.mu.Lock()
194+ tb.size = domain.Size{Cols: 100, Rows: 20}
195+ tb.mu.Unlock()
196+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
197+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
198+ installTestFloating(tb, floating, true)
199+ geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig())
200+
201+ border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2))
202+ outside := []byte("\x1b[<0;1;2M")
203+ d.handleInput(sess, ac, border)
204+ d.handleInput(sess, ac, outside)
205+}
206+
82 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {207 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83 writes := make(chan []byte, 1)208 writes := make(chan []byte, 1)
84 p, releasePTY := newBlockingPTYWithWrites(t, writes)209 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -196,6 +321,16 @@ func TestOverlayInputPrecedence(t *testing.T) {
196 }321 }
197 }322 }
198 323
324+func requirePTYWrite(t *testing.T, writes chan []byte, want []byte) {
325+ t.Helper()
326+ select {
327+ case got := <-writes:
328+ require.Equal(t, want, got)
329+ case <-time.After(time.Second):
330+ t.Fatalf("PTY did not receive %q", want)
331+ }
332+}
333+
199 func requireNoPTYWrite(t *testing.T, writes chan []byte) {334 func requireNoPTYWrite(t *testing.T, writes chan []byte) {
200 t.Helper()335 t.Helper()
201 select {336 select {

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