detached 65ad319

fix(clipboard): address osc52 review findings

commit
65ad3195df9f82ba36643d4c100953e185b5ca20
author
brice · · 2026-07-04 16:14 UTC
parent
6340314
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +58 -23
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)
M internal/usecase/daemon/clipboard.go +16 -11
@@ -4,13 +4,16 @@ import (
44 scopy "github.com/bnema/vev/internal/usecase/copy"
55 )
66
7-// forwardClipboard re-emits an app-originated OSC 52 clipboard set request
8-// (already captured off the pane's screen while ptyReader held pane.mu, then
9-// handed here after that lock is released) to the session's attached client,
10-// if any. Invalid base64 or an oversized decoded payload is dropped silently,
11-// matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The caller must
12-// not hold pane.mu, tab.mu, or session.mu.
13-func (d *Daemon) forwardClipboard(sess *session, b64 string) {
7+// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
8+// request (already captured off the pane's screen while ptyReader held pane.mu,
9+// then handed here after that lock is released) to the session's attached
10+// client, if any. Invalid base64 or an oversized decoded payload is dropped
11+// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
12+// caller must not hold pane.mu, tab.mu, or session.mu.
13+//
14+// The bounded client send runs in its own goroutine so a slow or wedged client
15+// cannot make the PTY reader stop draining child output.
16+func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
1417 seq := scopy.OSC52FromBase64(b64)
1518 if seq == nil {
1619 return
@@ -23,8 +26,10 @@ func (d *Daemon) forwardClipboard(sess *session, b64 string) {
2326 return
2427 }
2528
26- failed, err := d.boundedSendOutputErrTransport(ac, seq)
27- if err != nil {
28- d.detachOnSendError(sess, ac, failed)
29- }
29+ go func() {
30+ failed, err := d.boundedSendOutputErrTransport(ac, seq)
31+ if err != nil {
32+ d.detachOnSendError(sess, ac, failed)
33+ }
34+ }()
3035 }
@@ -4,13 +4,16 @@ import (
4 scopy "github.com/bnema/vev/internal/usecase/copy"4 scopy "github.com/bnema/vev/internal/usecase/copy"
5 )5 )
6 6
7-// forwardClipboard re-emits an app-originated OSC 52 clipboard set request7+// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
8-// (already captured off the pane's screen while ptyReader held pane.mu, then8+// request (already captured off the pane's screen while ptyReader held pane.mu,
9-// handed here after that lock is released) to the session's attached client,9+// then handed here after that lock is released) to the session's attached
10-// if any. Invalid base64 or an oversized decoded payload is dropped silently,10+// client, if any. Invalid base64 or an oversized decoded payload is dropped
11-// matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The caller must11+// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
12-// not hold pane.mu, tab.mu, or session.mu.12+// caller must not hold pane.mu, tab.mu, or session.mu.
13-func (d *Daemon) forwardClipboard(sess *session, b64 string) {13+//
14+// The bounded client send runs in its own goroutine so a slow or wedged client
15+// cannot make the PTY reader stop draining child output.
16+func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
14 seq := scopy.OSC52FromBase64(b64)17 seq := scopy.OSC52FromBase64(b64)
15 if seq == nil {18 if seq == nil {
16 return19 return
@@ -23,8 +26,10 @@ func (d *Daemon) forwardClipboard(sess *session, b64 string) {
23 return26 return
24 }27 }
25 28
26- failed, err := d.boundedSendOutputErrTransport(ac, seq)29+ go func() {
27- if err != nil {30+ failed, err := d.boundedSendOutputErrTransport(ac, seq)
28- d.detachOnSendError(sess, ac, failed)31+ if err != nil {
29- }32+ d.detachOnSendError(sess, ac, failed)
33+ }
34+ }()
30 }35 }
M internal/usecase/daemon/clipboard_test.go +5 -1
@@ -27,7 +27,11 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
2727 return 0, io.EOF
2828 }
2929 n := copy(buf, chunks[0])
30- chunks = chunks[1:]
30+ if n == len(chunks[0]) {
31+ chunks = chunks[1:]
32+ } else {
33+ chunks[0] = chunks[0][n:]
34+ }
3135 return n, nil
3236 })
3337 p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
@@ -27,7 +27,11 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
27 return 0, io.EOF27 return 0, io.EOF
28 }28 }
29 n := copy(buf, chunks[0])29 n := copy(buf, chunks[0])
30- chunks = chunks[1:]30+ if n == len(chunks[0]) {
31+ chunks = chunks[1:]
32+ } else {
33+ chunks[0] = chunks[0][n:]
34+ }
31 return n, nil35 return n, nil
32 })36 })
33 p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()37 p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
M internal/usecase/daemon/render.go +1 -1
@@ -78,7 +78,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
7878 }
7979 if len(clipboards) > 0 {
8080 for _, b64 := range clipboards {
81- d.forwardClipboard(sess, b64)
81+ d.forwardClipboardAsync(sess, b64)
8282 }
8383 clipboards = clipboards[:0]
8484 }
@@ -78,7 +78,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
78 }78 }
79 if len(clipboards) > 0 {79 if len(clipboards) > 0 {
80 for _, b64 := range clipboards {80 for _, b64 := range clipboards {
81- d.forwardClipboard(sess, b64)81+ d.forwardClipboardAsync(sess, b64)
82 }82 }
83 clipboards = clipboards[:0]83 clipboards = clipboards[:0]
84 }84 }
M pkg/vt/screen.go +11 -10
@@ -45,12 +45,13 @@ type Screen struct {
4545 OnBell func()
4646 // OnNotify is called synchronously from Write for explicit terminal
4747 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
48- // All other OSC payloads remain discarded. Nil disables it.
48+ // Other non-clipboard OSC payloads remain discarded. Nil disables it.
4949 OnNotify func(title, body string)
5050 // OnClipboard is called synchronously from Write for a complete OSC 52
51- // clipboard set request from the child ("52;<selection>;<base64>"),
52- // receiving the raw base64 payload. Clipboard queries (data == "?") and
53- // malformed payloads are ignored and never invoke it. Nil disables it.
51+ // clipboard set request from the child. The OSC 52 selection field is
52+ // accepted but ignored; the callback receives only the raw base64 payload.
53+ // Clipboard queries (data == "?") and malformed payloads are ignored and
54+ // never invoke it. Nil disables it.
5455 OnClipboard func(b64 string)
5556
5657 defaultFG renderer.RGB
@@ -634,14 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
634635 // set requests (OSC 52) are acted on; titles and every other OSC are still
635636 // discarded.
636637 func (s *Screen) handleOSC(payload []byte) {
637- p := string(payload)
638- if strings.HasPrefix(p, "52;") {
639- s.handleOSC52(p[len("52;"):])
638+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
639+ s.handleOSC52(string(payload[len("52;"):]))
640640 return
641641 }
642642 if s.OnNotify == nil {
643643 return
644644 }
645+ p := string(payload)
645646 switch {
646647 case strings.HasPrefix(p, "9;"):
647648 if strings.HasPrefix(p, "9;4;") {
@@ -1050,11 +1051,11 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
10501051 next := renderer.NewFrame(newW, newH)
10511052 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
10521053 if evict != nil {
1053- for y := 0; y < shift; y++ {
1054+ for y := range shift {
10541055 evict(old.Row(y))
10551056 }
10561057 }
1057- for dy := 0; dy < newH; dy++ {
1058+ for dy := range newH {
10581059 sy := dy + shift
10591060 if sy >= old.Height {
10601061 break
@@ -1154,7 +1155,7 @@ func (s *Screen) clearScreenMode(mode int) {
11541155 s.clampCursor()
11551156 switch mode {
11561157 case 1:
1157- for y := 0; y <= s.Row && y < s.Frame.Height; y++ {
1158+ for y := range min(s.Row+1, s.Frame.Height) {
11581159 end := s.Frame.Width
11591160 if y == s.Row {
11601161 end = min(s.Col+1, s.Frame.Width)
@@ -45,12 +45,13 @@ type Screen struct {
45 OnBell func()45 OnBell func()
46 // OnNotify is called synchronously from Write for explicit terminal46 // OnNotify is called synchronously from Write for explicit terminal
47 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).47 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
48- // All other OSC payloads remain discarded. Nil disables it.48+ // Other non-clipboard OSC payloads remain discarded. Nil disables it.
49 OnNotify func(title, body string)49 OnNotify func(title, body string)
50 // OnClipboard is called synchronously from Write for a complete OSC 5250 // OnClipboard is called synchronously from Write for a complete OSC 52
51- // clipboard set request from the child ("52;<selection>;<base64>"),51+ // clipboard set request from the child. The OSC 52 selection field is
52- // receiving the raw base64 payload. Clipboard queries (data == "?") and52+ // accepted but ignored; the callback receives only the raw base64 payload.
53- // malformed payloads are ignored and never invoke it. Nil disables it.53+ // Clipboard queries (data == "?") and malformed payloads are ignored and
54+ // never invoke it. Nil disables it.
54 OnClipboard func(b64 string)55 OnClipboard func(b64 string)
55 56
56 defaultFG renderer.RGB57 defaultFG renderer.RGB
@@ -634,14 +635,14 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
634 // set requests (OSC 52) are acted on; titles and every other OSC are still635 // set requests (OSC 52) are acted on; titles and every other OSC are still
635 // discarded.636 // discarded.
636 func (s *Screen) handleOSC(payload []byte) {637 func (s *Screen) handleOSC(payload []byte) {
637- p := string(payload)638+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
638- if strings.HasPrefix(p, "52;") {639+ s.handleOSC52(string(payload[len("52;"):]))
639- s.handleOSC52(p[len("52;"):])
640 return640 return
641 }641 }
642 if s.OnNotify == nil {642 if s.OnNotify == nil {
643 return643 return
644 }644 }
645+ p := string(payload)
645 switch {646 switch {
646 case strings.HasPrefix(p, "9;"):647 case strings.HasPrefix(p, "9;"):
647 if strings.HasPrefix(p, "9;4;") {648 if strings.HasPrefix(p, "9;4;") {
@@ -1050,11 +1051,11 @@ func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]ren
1050 next := renderer.NewFrame(newW, newH)1051 next := renderer.NewFrame(newW, newH)
1051 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))1052 shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
1052 if evict != nil {1053 if evict != nil {
1053- for y := 0; y < shift; y++ {1054+ for y := range shift {
1054 evict(old.Row(y))1055 evict(old.Row(y))
1055 }1056 }
1056 }1057 }
1057- for dy := 0; dy < newH; dy++ {1058+ for dy := range newH {
1058 sy := dy + shift1059 sy := dy + shift
1059 if sy >= old.Height {1060 if sy >= old.Height {
1060 break1061 break
@@ -1154,7 +1155,7 @@ func (s *Screen) clearScreenMode(mode int) {
1154 s.clampCursor()1155 s.clampCursor()
1155 switch mode {1156 switch mode {
1156 case 1:1157 case 1:
1157- for y := 0; y <= s.Row && y < s.Frame.Height; y++ {1158+ for y := range min(s.Row+1, s.Frame.Height) {
1158 end := s.Frame.Width1159 end := s.Frame.Width
1159 if y == s.Row {1160 if y == s.Row {
1160 end = min(s.Col+1, s.Frame.Width)1161 end = min(s.Col+1, s.Frame.Width)

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