detached fb90c2a

Merge pull request #12 from bnema/fix/remote-clipboard-paste

fix(vt,daemon): pass through app OSC 52 clipboard to attached client
commit
fb90c2a5ccea15545382d3bf5756c77dc39d6505
author
bnema · · 2026-07-04 16:29 UTC
committer
GitHub · · 2026-07-04 16:29 UTC
merge of
bef6cff + fb1b268 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +435 -13
M internal/usecase/copy/mode.go +12 -0
@@ -239,6 +239,18 @@ func OSC52(text string) [][]byte {
239239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
240240 }
241241
242+// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
243+// clipboard set request that a pane app already emitted as base64. It
244+// returns nil if b64 is not valid base64 or its decoded length exceeds
245+// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
246+func OSC52FromBase64(b64 string) []byte {
247+ decoded, err := base64.StdEncoding.DecodeString(b64)
248+ if err != nil || len(decoded) > OSC52MaxPayloadBytes {
249+ return nil
250+ }
251+ return []byte("\x1b]52;c;" + b64 + "\x07")
252+}
253+
242254 func strconvItoa(n int) string {
243255 if n == 0 {
244256 return "0"
@@ -239,6 +239,18 @@ func OSC52(text string) [][]byte {
239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
240 }240 }
241 241
242+// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
243+// clipboard set request that a pane app already emitted as base64. It
244+// returns nil if b64 is not valid base64 or its decoded length exceeds
245+// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
246+func OSC52FromBase64(b64 string) []byte {
247+ decoded, err := base64.StdEncoding.DecodeString(b64)
248+ if err != nil || len(decoded) > OSC52MaxPayloadBytes {
249+ return nil
250+ }
251+ return []byte("\x1b]52;c;" + b64 + "\x07")
252+}
253+
242 func strconvItoa(n int) string {254 func strconvItoa(n int) string {
243 if n == 0 {255 if n == 0 {
244 return "0"256 return "0"
M internal/usecase/copy/mode_test.go +25 -0
@@ -1,6 +1,7 @@
11 package copy
22
33 import (
4+ "bytes"
45 "encoding/base64"
56 "strings"
67 "testing"
@@ -366,6 +367,30 @@ func TestOSC52OversizedPayloadIsDeferred(t *testing.T) {
366367 }
367368 }
368369
370+func TestOSC52FromBase64(t *testing.T) {
371+ valid := base64.StdEncoding.EncodeToString([]byte("hello"))
372+ oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
373+
374+ tests := []struct {
375+ name string
376+ in string
377+ want []byte
378+ }{
379+ {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")},
380+ {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")},
381+ {name: "invalid base64", in: "not-valid-base64!!", want: nil},
382+ {name: "oversized decoded payload", in: oversized, want: nil},
383+ }
384+ for _, tt := range tests {
385+ t.Run(tt.name, func(t *testing.T) {
386+ got := OSC52FromBase64(tt.in)
387+ if !bytes.Equal(got, tt.want) {
388+ t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want)
389+ }
390+ })
391+ }
392+}
393+
369394 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
370395 t.Helper()
371396 s := string(chunk)
@@ -1,6 +1,7 @@
1 package copy1 package copy
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/base64"5 "encoding/base64"
5 "strings"6 "strings"
6 "testing"7 "testing"
@@ -366,6 +367,30 @@ func TestOSC52OversizedPayloadIsDeferred(t *testing.T) {
366 }367 }
367 }368 }
368 369
370+func TestOSC52FromBase64(t *testing.T) {
371+ valid := base64.StdEncoding.EncodeToString([]byte("hello"))
372+ oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
373+
374+ tests := []struct {
375+ name string
376+ in string
377+ want []byte
378+ }{
379+ {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")},
380+ {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")},
381+ {name: "invalid base64", in: "not-valid-base64!!", want: nil},
382+ {name: "oversized decoded payload", in: oversized, want: nil},
383+ }
384+ for _, tt := range tests {
385+ t.Run(tt.name, func(t *testing.T) {
386+ got := OSC52FromBase64(tt.in)
387+ if !bytes.Equal(got, tt.want) {
388+ t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want)
389+ }
390+ })
391+ }
392+}
393+
369 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {394 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
370 t.Helper()395 t.Helper()
371 s := string(chunk)396 s := string(chunk)
A internal/usecase/daemon/clipboard.go +84 -0
@@ -0,0 +1,84 @@
1+package daemon
2+
3+import (
4+ scopy "github.com/bnema/vev/internal/usecase/copy"
5+)
6+
7+type clipboardForward struct {
8+ ac *attachedClient
9+ seq []byte
10+}
11+
12+// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
13+// request (already captured off the pane's screen while ptyReader held pane.mu,
14+// then handed here after that lock is released) to the session's attached
15+// client, if any. Invalid base64 or an oversized decoded payload is dropped
16+// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
17+// caller must not hold pane.mu, tab.mu, or session.mu.
18+//
19+// Requests are queued on the session and drained by one worker so clipboard
20+// writes keep arrival order without making the PTY reader wait on client I/O.
21+func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
22+ seq := scopy.OSC52FromBase64(b64)
23+ if seq == nil {
24+ return
25+ }
26+
27+ sess.mu.Lock()
28+ if sess.ctx != nil {
29+ select {
30+ case <-sess.ctx.Done():
31+ sess.mu.Unlock()
32+ return
33+ default:
34+ }
35+ }
36+ ac := sess.client
37+ if ac == nil {
38+ sess.mu.Unlock()
39+ return
40+ }
41+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})
42+ if !sess.clipboardWorkerRunning {
43+ sess.clipboardWorkerRunning = true
44+ go d.clipboardWorker(sess)
45+ }
46+ sess.mu.Unlock()
47+}
48+
49+func (d *Daemon) clipboardWorker(sess *session) {
50+ for {
51+ item, ok := nextClipboardForward(sess)
52+ if !ok {
53+ return
54+ }
55+ failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)
56+ if err != nil {
57+ d.detachOnSendError(sess, item.ac, failed)
58+ }
59+ }
60+}
61+
62+func nextClipboardForward(sess *session) (clipboardForward, bool) {
63+ sess.mu.Lock()
64+ defer sess.mu.Unlock()
65+ if sess.ctx != nil {
66+ select {
67+ case <-sess.ctx.Done():
68+ sess.clipboardQueue = nil
69+ sess.clipboardWorkerRunning = false
70+ return clipboardForward{}, false
71+ default:
72+ }
73+ }
74+ if len(sess.clipboardQueue) == 0 {
75+ sess.clipboardWorkerRunning = false
76+ return clipboardForward{}, false
77+ }
78+ item := sess.clipboardQueue[0]
79+ copy(sess.clipboardQueue, sess.clipboardQueue[1:])
80+ var zero clipboardForward
81+ sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero
82+ sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1]
83+ return item, true
84+}
@@ -0,0 +1,84 @@
1+package daemon
2+
3+import (
4+ scopy "github.com/bnema/vev/internal/usecase/copy"
5+)
6+
7+type clipboardForward struct {
8+ ac *attachedClient
9+ seq []byte
10+}
11+
12+// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
13+// request (already captured off the pane's screen while ptyReader held pane.mu,
14+// then handed here after that lock is released) to the session's attached
15+// client, if any. Invalid base64 or an oversized decoded payload is dropped
16+// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
17+// caller must not hold pane.mu, tab.mu, or session.mu.
18+//
19+// Requests are queued on the session and drained by one worker so clipboard
20+// writes keep arrival order without making the PTY reader wait on client I/O.
21+func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
22+ seq := scopy.OSC52FromBase64(b64)
23+ if seq == nil {
24+ return
25+ }
26+
27+ sess.mu.Lock()
28+ if sess.ctx != nil {
29+ select {
30+ case <-sess.ctx.Done():
31+ sess.mu.Unlock()
32+ return
33+ default:
34+ }
35+ }
36+ ac := sess.client
37+ if ac == nil {
38+ sess.mu.Unlock()
39+ return
40+ }
41+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})
42+ if !sess.clipboardWorkerRunning {
43+ sess.clipboardWorkerRunning = true
44+ go d.clipboardWorker(sess)
45+ }
46+ sess.mu.Unlock()
47+}
48+
49+func (d *Daemon) clipboardWorker(sess *session) {
50+ for {
51+ item, ok := nextClipboardForward(sess)
52+ if !ok {
53+ return
54+ }
55+ failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)
56+ if err != nil {
57+ d.detachOnSendError(sess, item.ac, failed)
58+ }
59+ }
60+}
61+
62+func nextClipboardForward(sess *session) (clipboardForward, bool) {
63+ sess.mu.Lock()
64+ defer sess.mu.Unlock()
65+ if sess.ctx != nil {
66+ select {
67+ case <-sess.ctx.Done():
68+ sess.clipboardQueue = nil
69+ sess.clipboardWorkerRunning = false
70+ return clipboardForward{}, false
71+ default:
72+ }
73+ }
74+ if len(sess.clipboardQueue) == 0 {
75+ sess.clipboardWorkerRunning = false
76+ return clipboardForward{}, false
77+ }
78+ item := sess.clipboardQueue[0]
79+ copy(sess.clipboardQueue, sess.clipboardQueue[1:])
80+ var zero clipboardForward
81+ sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero
82+ sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1]
83+ return item, true
84+}
A internal/usecase/daemon/clipboard_test.go +194 -0
@@ -0,0 +1,194 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "io"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ scopy "github.com/bnema/vev/internal/usecase/copy"
17+ "github.com/bnema/vev/pkg/renderer"
18+)
19+
20+// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
21+// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
22+// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
23+func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
24+ t.Helper()
25+ p := portsmocks.NewMockPTY(t)
26+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
27+ if len(chunks) == 0 {
28+ return 0, io.EOF
29+ }
30+ n := copy(buf, chunks[0])
31+ if n == len(chunks[0]) {
32+ chunks = chunks[1:]
33+ } else {
34+ chunks[0] = chunks[0][n:]
35+ }
36+ return n, nil
37+ })
38+ p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
39+ p.EXPECT().Close().Return(nil).Maybe()
40+ return p
41+}
42+
43+func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
44+ d := newTestDaemon(t, nil, stubClock{})
45+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
46+
47+ tr, sends := newCapturingTransport(t)
48+ sctx, cancel := context.WithCancel(context.Background())
49+ defer cancel()
50+ win := newTestTabWithContext(p, sctx, cancel)
51+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
52+ sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
53+ ac.setSession(sess)
54+ d.sessions[sess.id] = sess
55+
56+ d.sessWg.Add(1)
57+ d.ptyReader(sess, win, win.focusedPane())
58+
59+ f := awaitFrame(t, sends, ports.MsgOutput)
60+ out, err := ports.UnmarshalOutput(f.Payload)
61+ require.NoError(t, err)
62+ require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
63+}
64+
65+func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
66+ d := newTestDaemon(t, nil, stubClock{})
67+ big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
68+ b64 := base64.StdEncoding.EncodeToString([]byte(big))
69+ p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
70+
71+ tr, sends := newCapturingTransport(t)
72+ sctx, cancel := context.WithCancel(context.Background())
73+ defer cancel()
74+ win := newTestTabWithContext(p, sctx, cancel)
75+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
76+ sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
77+ ac.setSession(sess)
78+ d.sessions[sess.id] = sess
79+
80+ d.sessWg.Add(1)
81+ d.ptyReader(sess, win, win.focusedPane())
82+
83+ select {
84+ case f := <-sends:
85+ require.NotEqual(t, ports.MsgOutput, f.Type)
86+ default:
87+ }
88+}
89+
90+func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
91+ d := newTestDaemon(t, nil, stubClock{})
92+ p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
93+
94+ tr, sends := newCapturingTransport(t)
95+ sctx, cancel := context.WithCancel(context.Background())
96+ defer cancel()
97+ win := newTestTabWithContext(p, sctx, cancel)
98+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
99+ sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
100+ ac.setSession(sess)
101+ d.sessions[sess.id] = sess
102+
103+ d.sessWg.Add(1)
104+ d.ptyReader(sess, win, win.focusedPane())
105+
106+ select {
107+ case f := <-sends:
108+ require.NotEqual(t, ports.MsgOutput, f.Type)
109+ default:
110+ }
111+}
112+
113+func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
114+ d := newTestDaemon(t, nil, stubClock{})
115+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
116+
117+ sctx, cancel := context.WithCancel(context.Background())
118+ defer cancel()
119+ win := newTestTabWithContext(p, sctx, cancel)
120+ sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
121+ d.sessions[sess.id] = sess
122+
123+ d.sessWg.Add(1)
124+ require.NotPanics(t, func() {
125+ d.ptyReader(sess, win, win.focusedPane())
126+ })
127+}
128+
129+func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
130+ d := newTestDaemon(t, nil, stubClock{})
131+ tr := newBlockingClipboardTransport()
132+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
133+ sctx, cancel := context.WithCancel(context.Background())
134+ defer cancel()
135+ sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
136+ ac.setSession(sess)
137+
138+ first := base64.StdEncoding.EncodeToString([]byte("first"))
139+ second := base64.StdEncoding.EncodeToString([]byte("second"))
140+ d.forwardClipboardAsync(sess, first)
141+ require.Equal(t, "first", <-tr.started)
142+
143+ d.forwardClipboardAsync(sess, second)
144+ select {
145+ case got := <-tr.started:
146+ t.Fatalf("second clipboard send started before first completed: %q", got)
147+ case <-time.After(50 * time.Millisecond):
148+ }
149+
150+ close(tr.releaseFirst)
151+ require.Equal(t, "first", <-tr.sent)
152+ require.Equal(t, "second", <-tr.started)
153+ require.Equal(t, "second", <-tr.sent)
154+}
155+
156+type blockingClipboardTransport struct {
157+ started chan string
158+ sent chan string
159+ releaseFirst chan struct{}
160+}
161+
162+func newBlockingClipboardTransport() *blockingClipboardTransport {
163+ return &blockingClipboardTransport{
164+ started: make(chan string, 2),
165+ sent: make(chan string, 2),
166+ releaseFirst: make(chan struct{}),
167+ }
168+}
169+
170+func (tr *blockingClipboardTransport) Send(f ports.Frame) error {
171+ out, err := ports.UnmarshalOutput(f.Payload)
172+ if err != nil {
173+ return err
174+ }
175+ data := string(out.Data)
176+ var label string
177+ switch {
178+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))):
179+ label = "first"
180+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))):
181+ label = "second"
182+ default:
183+ label = data
184+ }
185+ tr.started <- label
186+ if label == "first" {
187+ <-tr.releaseFirst
188+ }
189+ tr.sent <- label
190+ return nil
191+}
192+
193+func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
194+func (tr *blockingClipboardTransport) Close() error { return nil }
@@ -0,0 +1,194 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "io"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ scopy "github.com/bnema/vev/internal/usecase/copy"
17+ "github.com/bnema/vev/pkg/renderer"
18+)
19+
20+// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
21+// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
22+// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
23+func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
24+ t.Helper()
25+ p := portsmocks.NewMockPTY(t)
26+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
27+ if len(chunks) == 0 {
28+ return 0, io.EOF
29+ }
30+ n := copy(buf, chunks[0])
31+ if n == len(chunks[0]) {
32+ chunks = chunks[1:]
33+ } else {
34+ chunks[0] = chunks[0][n:]
35+ }
36+ return n, nil
37+ })
38+ p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
39+ p.EXPECT().Close().Return(nil).Maybe()
40+ return p
41+}
42+
43+func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
44+ d := newTestDaemon(t, nil, stubClock{})
45+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
46+
47+ tr, sends := newCapturingTransport(t)
48+ sctx, cancel := context.WithCancel(context.Background())
49+ defer cancel()
50+ win := newTestTabWithContext(p, sctx, cancel)
51+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
52+ sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
53+ ac.setSession(sess)
54+ d.sessions[sess.id] = sess
55+
56+ d.sessWg.Add(1)
57+ d.ptyReader(sess, win, win.focusedPane())
58+
59+ f := awaitFrame(t, sends, ports.MsgOutput)
60+ out, err := ports.UnmarshalOutput(f.Payload)
61+ require.NoError(t, err)
62+ require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
63+}
64+
65+func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
66+ d := newTestDaemon(t, nil, stubClock{})
67+ big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
68+ b64 := base64.StdEncoding.EncodeToString([]byte(big))
69+ p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
70+
71+ tr, sends := newCapturingTransport(t)
72+ sctx, cancel := context.WithCancel(context.Background())
73+ defer cancel()
74+ win := newTestTabWithContext(p, sctx, cancel)
75+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
76+ sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
77+ ac.setSession(sess)
78+ d.sessions[sess.id] = sess
79+
80+ d.sessWg.Add(1)
81+ d.ptyReader(sess, win, win.focusedPane())
82+
83+ select {
84+ case f := <-sends:
85+ require.NotEqual(t, ports.MsgOutput, f.Type)
86+ default:
87+ }
88+}
89+
90+func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
91+ d := newTestDaemon(t, nil, stubClock{})
92+ p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
93+
94+ tr, sends := newCapturingTransport(t)
95+ sctx, cancel := context.WithCancel(context.Background())
96+ defer cancel()
97+ win := newTestTabWithContext(p, sctx, cancel)
98+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
99+ sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
100+ ac.setSession(sess)
101+ d.sessions[sess.id] = sess
102+
103+ d.sessWg.Add(1)
104+ d.ptyReader(sess, win, win.focusedPane())
105+
106+ select {
107+ case f := <-sends:
108+ require.NotEqual(t, ports.MsgOutput, f.Type)
109+ default:
110+ }
111+}
112+
113+func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
114+ d := newTestDaemon(t, nil, stubClock{})
115+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
116+
117+ sctx, cancel := context.WithCancel(context.Background())
118+ defer cancel()
119+ win := newTestTabWithContext(p, sctx, cancel)
120+ sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
121+ d.sessions[sess.id] = sess
122+
123+ d.sessWg.Add(1)
124+ require.NotPanics(t, func() {
125+ d.ptyReader(sess, win, win.focusedPane())
126+ })
127+}
128+
129+func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
130+ d := newTestDaemon(t, nil, stubClock{})
131+ tr := newBlockingClipboardTransport()
132+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
133+ sctx, cancel := context.WithCancel(context.Background())
134+ defer cancel()
135+ sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
136+ ac.setSession(sess)
137+
138+ first := base64.StdEncoding.EncodeToString([]byte("first"))
139+ second := base64.StdEncoding.EncodeToString([]byte("second"))
140+ d.forwardClipboardAsync(sess, first)
141+ require.Equal(t, "first", <-tr.started)
142+
143+ d.forwardClipboardAsync(sess, second)
144+ select {
145+ case got := <-tr.started:
146+ t.Fatalf("second clipboard send started before first completed: %q", got)
147+ case <-time.After(50 * time.Millisecond):
148+ }
149+
150+ close(tr.releaseFirst)
151+ require.Equal(t, "first", <-tr.sent)
152+ require.Equal(t, "second", <-tr.started)
153+ require.Equal(t, "second", <-tr.sent)
154+}
155+
156+type blockingClipboardTransport struct {
157+ started chan string
158+ sent chan string
159+ releaseFirst chan struct{}
160+}
161+
162+func newBlockingClipboardTransport() *blockingClipboardTransport {
163+ return &blockingClipboardTransport{
164+ started: make(chan string, 2),
165+ sent: make(chan string, 2),
166+ releaseFirst: make(chan struct{}),
167+ }
168+}
169+
170+func (tr *blockingClipboardTransport) Send(f ports.Frame) error {
171+ out, err := ports.UnmarshalOutput(f.Payload)
172+ if err != nil {
173+ return err
174+ }
175+ data := string(out.Data)
176+ var label string
177+ switch {
178+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))):
179+ label = "first"
180+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))):
181+ label = "second"
182+ default:
183+ label = data
184+ }
185+ tr.started <- label
186+ if label == "first" {
187+ <-tr.releaseFirst
188+ }
189+ tr.sent <- label
190+ return nil
191+}
192+
193+func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
194+func (tr *blockingClipboardTransport) Close() error { return nil }
M internal/usecase/daemon/render.go +8 -0
@@ -54,11 +54,13 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
5454 }
5555 buf := make([]byte, ptyReadBufSize)
5656 var resp []byte
57+ var clipboards []string
5758 attentionCh := make(chan struct{}, 1)
5859 p.mu.Lock()
5960 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
6061 p.screen.OnBell = func() { signal(attentionCh) }
6162 p.screen.OnNotify = func(string, string) { signal(attentionCh) }
63+ p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
6264 p.mu.Unlock()
6365 for {
6466 n, err := p.pty.Read(buf)
@@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
7476 d.noteAttention(sess, tb)
7577 default:
7678 }
79+ if len(clipboards) > 0 {
80+ for _, b64 := range clipboards {
81+ d.forwardClipboardAsync(sess, b64)
82+ }
83+ clipboards = clipboards[:0]
84+ }
7785 if len(resp) > 0 {
7886 if _, writeErr := p.pty.Write(resp); writeErr != nil {
7987 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
@@ -54,11 +54,13 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
54 }54 }
55 buf := make([]byte, ptyReadBufSize)55 buf := make([]byte, ptyReadBufSize)
56 var resp []byte56 var resp []byte
57+ var clipboards []string
57 attentionCh := make(chan struct{}, 1)58 attentionCh := make(chan struct{}, 1)
58 p.mu.Lock()59 p.mu.Lock()
59 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }60 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
60 p.screen.OnBell = func() { signal(attentionCh) }61 p.screen.OnBell = func() { signal(attentionCh) }
61 p.screen.OnNotify = func(string, string) { signal(attentionCh) }62 p.screen.OnNotify = func(string, string) { signal(attentionCh) }
63+ p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
62 p.mu.Unlock()64 p.mu.Unlock()
63 for {65 for {
64 n, err := p.pty.Read(buf)66 n, err := p.pty.Read(buf)
@@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
74 d.noteAttention(sess, tb)76 d.noteAttention(sess, tb)
75 default:77 default:
76 }78 }
79+ if len(clipboards) > 0 {
80+ for _, b64 := range clipboards {
81+ d.forwardClipboardAsync(sess, b64)
82+ }
83+ clipboards = clipboards[:0]
84+ }
77 if len(resp) > 0 {85 if len(resp) > 0 {
78 if _, writeErr := p.pty.Write(resp); writeErr != nil {86 if _, writeErr := p.pty.Write(resp); writeErr != nil {
79 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)87 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
M internal/usecase/daemon/session.go +8 -6
@@ -47,12 +47,14 @@ type session struct {
4747 ctx context.Context
4848 cancel context.CancelFunc
4949
50- mu sync.Mutex // guards tabs, active, and client
51- tabs []*tab
52- active int
53- client *attachedClient
54- cwd string
55- createdAt int64
50+ mu sync.Mutex // guards tabs, active, client, and clipboard queue state
51+ tabs []*tab
52+ active int
53+ client *attachedClient
54+ clipboardQueue []clipboardForward
55+ clipboardWorkerRunning bool
56+ cwd string
57+ createdAt int64
5658 }
5759
5860 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -47,12 +47,14 @@ type session struct {
47 ctx context.Context47 ctx context.Context
48 cancel context.CancelFunc48 cancel context.CancelFunc
49 49
50- mu sync.Mutex // guards tabs, active, and client50+ mu sync.Mutex // guards tabs, active, client, and clipboard queue state
51- tabs []*tab51+ tabs []*tab
52- active int52+ active int
53- client *attachedClient53+ client *attachedClient
54- cwd string54+ clipboardQueue []clipboardForward
55- createdAt int6455+ clipboardWorkerRunning bool
56+ cwd string
57+ createdAt int64
56 }58 }
57 59
58 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.60 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
M pkg/vt/screen.go +40 -7
@@ -25,7 +25,11 @@ type screenState struct {
2525 savedCursor cursorState
2626 }
2727
28-const maxEscapeBufferLen = 4096
28+// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
29+// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
30+// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
31+// so keep this value in sync if that payload cap changes.
32+const maxEscapeBufferLen = 128 * 1024
2933
3034 type Screen struct {
3135 Frame renderer.Frame
@@ -45,8 +49,14 @@ type Screen struct {
4549 OnBell func()
4650 // OnNotify is called synchronously from Write for explicit terminal
4751 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
48- // All other OSC payloads remain discarded. Nil disables it.
52+ // Other non-clipboard OSC payloads remain discarded. Nil disables it.
4953 OnNotify func(title, body string)
54+ // OnClipboard is called synchronously from Write for a complete OSC 52
55+ // clipboard set request from the child. The OSC 52 selection field is
56+ // accepted but ignored; the callback receives only the raw base64 payload.
57+ // Clipboard queries (data == "?") and malformed payloads are ignored and
58+ // never invoke it. Nil disables it.
59+ OnClipboard func(b64 string)
5060
5161 defaultFG renderer.RGB
5262 defaultBG renderer.RGB
@@ -625,9 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
625635 }
626636
627637 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
628-// terminator). Only notification sequences are acted on; titles, clipboard
629-// and every other OSC are still discarded.
638+// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard
639+// set requests (OSC 52) are acted on; titles and every other OSC are still
640+// discarded.
630641 func (s *Screen) handleOSC(payload []byte) {
642+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
643+ s.handleOSC52(string(payload[len("52;"):]))
644+ return
645+ }
631646 if s.OnNotify == nil {
632647 return
633648 }
@@ -654,6 +669,24 @@ func (s *Screen) handleOSC(payload []byte) {
654669 }
655670 }
656671
672+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
673+// clipboard payload (selection may be empty; split on the first ";"). A
674+// clipboard query (data == "?") is always ignored — vev never answers
675+// clipboard queries. A payload with no second ";" is malformed and ignored.
676+func (s *Screen) handleOSC52(rest string) {
677+ idx := strings.IndexByte(rest, ';')
678+ if idx < 0 {
679+ return
680+ }
681+ data := rest[idx+1:]
682+ if data == "?" {
683+ return
684+ }
685+ if s.OnClipboard != nil {
686+ s.OnClipboard(data)
687+ }
688+}
689+
657690 func consumeSTString(data []byte) (consumed int, partial bool) {
658691 for i := 2; i < len(data); i++ {
659692 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -1022,11 +1055,11 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
10221055 next := renderer.NewFrame(newW, newH)
10231056 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
10241057 if evict != nil {
1025- for y := 0; y < shift; y++ {
1058+ for y := range shift {
10261059 evict(old.Row(y))
10271060 }
10281061 }
1029- for dy := 0; dy < newH; dy++ {
1062+ for dy := range newH {
10301063 sy := dy + shift
10311064 if sy >= old.Height {
10321065 break
@@ -1126,7 +1159,7 @@ func (s *Screen) clearScreenMode(mode int) {
11261159 s.clampCursor()
11271160 switch mode {
11281161 case 1:
1129- for y := 0; y <= s.Row && y < s.Frame.Height; y++ {
1162+ for y := range min(s.Row+1, s.Frame.Height) {
11301163 end := s.Frame.Width
11311164 if y == s.Row {
11321165 end = min(s.Col+1, s.Frame.Width)
@@ -25,7 +25,11 @@ type screenState struct {
25 savedCursor cursorState25 savedCursor cursorState
26 }26 }
27 27
28-const maxEscapeBufferLen = 409628+// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
29+// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
30+// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
31+// so keep this value in sync if that payload cap changes.
32+const maxEscapeBufferLen = 128 * 1024
29 33
30 type Screen struct {34 type Screen struct {
31 Frame renderer.Frame35 Frame renderer.Frame
@@ -45,8 +49,14 @@ type Screen struct {
45 OnBell func()49 OnBell func()
46 // OnNotify is called synchronously from Write for explicit terminal50 // OnNotify is called synchronously from Write for explicit terminal
47 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).51 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
48- // All other OSC payloads remain discarded. Nil disables it.52+ // Other non-clipboard OSC payloads remain discarded. Nil disables it.
49 OnNotify func(title, body string)53 OnNotify func(title, body string)
54+ // OnClipboard is called synchronously from Write for a complete OSC 52
55+ // clipboard set request from the child. The OSC 52 selection field is
56+ // accepted but ignored; the callback receives only the raw base64 payload.
57+ // Clipboard queries (data == "?") and malformed payloads are ignored and
58+ // never invoke it. Nil disables it.
59+ OnClipboard func(b64 string)
50 60
51 defaultFG renderer.RGB61 defaultFG renderer.RGB
52 defaultBG renderer.RGB62 defaultBG renderer.RGB
@@ -625,9 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
625 }635 }
626 636
627 // handleOSC inspects a complete OSC payload (between "ESC ]" and its637 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
628-// terminator). Only notification sequences are acted on; titles, clipboard638+// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard
629-// and every other OSC are still discarded.639+// set requests (OSC 52) are acted on; titles and every other OSC are still
640+// discarded.
630 func (s *Screen) handleOSC(payload []byte) {641 func (s *Screen) handleOSC(payload []byte) {
642+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
643+ s.handleOSC52(string(payload[len("52;"):]))
644+ return
645+ }
631 if s.OnNotify == nil {646 if s.OnNotify == nil {
632 return647 return
633 }648 }
@@ -654,6 +669,24 @@ func (s *Screen) handleOSC(payload []byte) {
654 }669 }
655 }670 }
656 671
672+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
673+// clipboard payload (selection may be empty; split on the first ";"). A
674+// clipboard query (data == "?") is always ignored — vev never answers
675+// clipboard queries. A payload with no second ";" is malformed and ignored.
676+func (s *Screen) handleOSC52(rest string) {
677+ idx := strings.IndexByte(rest, ';')
678+ if idx < 0 {
679+ return
680+ }
681+ data := rest[idx+1:]
682+ if data == "?" {
683+ return
684+ }
685+ if s.OnClipboard != nil {
686+ s.OnClipboard(data)
687+ }
688+}
689+
657 func consumeSTString(data []byte) (consumed int, partial bool) {690 func consumeSTString(data []byte) (consumed int, partial bool) {
658 for i := 2; i < len(data); i++ {691 for i := 2; i < len(data); i++ {
659 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {692 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -1022,11 +1055,11 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
1022 next := renderer.NewFrame(newW, newH)1055 next := renderer.NewFrame(newW, newH)
1023 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))1056 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
1024 if evict != nil {1057 if evict != nil {
1025- for y := 0; y < shift; y++ {1058+ for y := range shift {
1026 evict(old.Row(y))1059 evict(old.Row(y))
1027 }1060 }
1028 }1061 }
1029- for dy := 0; dy < newH; dy++ {1062+ for dy := range newH {
1030 sy := dy + shift1063 sy := dy + shift
1031 if sy >= old.Height {1064 if sy >= old.Height {
1032 break1065 break
@@ -1126,7 +1159,7 @@ func (s *Screen) clearScreenMode(mode int) {
1126 s.clampCursor()1159 s.clampCursor()
1127 switch mode {1160 switch mode {
1128 case 1:1161 case 1:
1129- for y := 0; y <= s.Row && y < s.Frame.Height; y++ {1162+ for y := range min(s.Row+1, s.Frame.Height) {
1130 end := s.Frame.Width1163 end := s.Frame.Width
1131 if y == s.Row {1164 if y == s.Row {
1132 end = min(s.Col+1, s.Frame.Width)1165 end = min(s.Col+1, s.Frame.Width)
M pkg/vt/screen_test.go +64 -0
@@ -157,6 +157,70 @@ func TestOnNotifySplitAcrossWrites(t *testing.T) {
157157 require.Equal(t, 1, calls)
158158 }
159159
160+func TestOnClipboardBELTerminated(t *testing.T) {
161+ s := NewScreen(10, 2)
162+ var got string
163+ calls := 0
164+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
165+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
166+ require.Equal(t, 1, calls)
167+ require.Equal(t, "aGVsbG8=", got)
168+}
169+
170+func TestOnClipboardSTTerminated(t *testing.T) {
171+ s := NewScreen(10, 2)
172+ var got string
173+ calls := 0
174+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
175+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
176+ require.Equal(t, 1, calls)
177+ require.Equal(t, "aGVsbG8=", got)
178+}
179+
180+func TestOnClipboardQueryIgnored(t *testing.T) {
181+ s := NewScreen(10, 2)
182+ calls := 0
183+ s.OnClipboard = func(string) { calls++ }
184+ s.Write([]byte("\x1b]52;c;?\x07"))
185+ require.Equal(t, 0, calls)
186+}
187+
188+func TestOnClipboardEmptySelection(t *testing.T) {
189+ s := NewScreen(10, 2)
190+ var got string
191+ calls := 0
192+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
193+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
194+ require.Equal(t, 1, calls)
195+ require.Equal(t, "aGVsbG8=", got)
196+}
197+
198+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
199+ s := NewScreen(10, 2)
200+ require.NotPanics(t, func() {
201+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
202+ })
203+}
204+
205+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ var got string
208+ calls := 0
209+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
210+ s.Write([]byte("\x1b]52;c;aGVs"))
211+ s.Write([]byte("bG8=\x07"))
212+ require.Equal(t, 1, calls)
213+ require.Equal(t, "aGVsbG8=", got)
214+}
215+
216+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
217+ s := NewScreen(10, 2)
218+ calls := 0
219+ s.OnClipboard = func(string) { calls++ }
220+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
221+ require.Equal(t, 0, calls)
222+}
223+
160224 // ---------------------------------------------------------------------------
161225 // Printable ASCII / UTF-8
162226 // ---------------------------------------------------------------------------
@@ -157,6 +157,70 @@ func TestOnNotifySplitAcrossWrites(t *testing.T) {
157 require.Equal(t, 1, calls)157 require.Equal(t, 1, calls)
158 }158 }
159 159
160+func TestOnClipboardBELTerminated(t *testing.T) {
161+ s := NewScreen(10, 2)
162+ var got string
163+ calls := 0
164+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
165+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
166+ require.Equal(t, 1, calls)
167+ require.Equal(t, "aGVsbG8=", got)
168+}
169+
170+func TestOnClipboardSTTerminated(t *testing.T) {
171+ s := NewScreen(10, 2)
172+ var got string
173+ calls := 0
174+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
175+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
176+ require.Equal(t, 1, calls)
177+ require.Equal(t, "aGVsbG8=", got)
178+}
179+
180+func TestOnClipboardQueryIgnored(t *testing.T) {
181+ s := NewScreen(10, 2)
182+ calls := 0
183+ s.OnClipboard = func(string) { calls++ }
184+ s.Write([]byte("\x1b]52;c;?\x07"))
185+ require.Equal(t, 0, calls)
186+}
187+
188+func TestOnClipboardEmptySelection(t *testing.T) {
189+ s := NewScreen(10, 2)
190+ var got string
191+ calls := 0
192+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
193+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
194+ require.Equal(t, 1, calls)
195+ require.Equal(t, "aGVsbG8=", got)
196+}
197+
198+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
199+ s := NewScreen(10, 2)
200+ require.NotPanics(t, func() {
201+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
202+ })
203+}
204+
205+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ var got string
208+ calls := 0
209+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
210+ s.Write([]byte("\x1b]52;c;aGVs"))
211+ s.Write([]byte("bG8=\x07"))
212+ require.Equal(t, 1, calls)
213+ require.Equal(t, "aGVsbG8=", got)
214+}
215+
216+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
217+ s := NewScreen(10, 2)
218+ calls := 0
219+ s.OnClipboard = func(string) { calls++ }
220+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
221+ require.Equal(t, 0, calls)
222+}
223+
160 // ---------------------------------------------------------------------------224 // ---------------------------------------------------------------------------
161 // Printable ASCII / UTF-8225 // Printable ASCII / UTF-8
162 // ---------------------------------------------------------------------------226 // ---------------------------------------------------------------------------

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