detached e47efb0

fix(copy): emit osc52 clipboard as single sequence

commit
e47efb0098ec6b034e529344c53027e7f124f379
author
brice · · 2026-07-02 16:00 UTC
parent
d9a1d18
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +25 -30
M internal/usecase/copy/mode.go +2 -12
@@ -169,20 +169,10 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
169169 }
170170 }
171171
172-// OSC52 encodes text for clipboard transfer. Large payloads are split into
173-// multiple complete OSC 52 sequences so each MsgOutput remains bounded.
172+// OSC52 encodes text for clipboard transfer as one complete OSC 52 sequence.
174173 func OSC52(text string) [][]byte {
175174 encoded := base64.StdEncoding.EncodeToString([]byte(text))
176- if encoded == "" {
177- return [][]byte{[]byte("\x1b]52;c;\x07")}
178- }
179- chunks := make([][]byte, 0, len(encoded)/OSC52ChunkSize+1)
180- for len(encoded) > 0 {
181- n := min(len(encoded), OSC52ChunkSize)
182- chunks = append(chunks, []byte("\x1b]52;c;"+encoded[:n]+"\x07"))
183- encoded = encoded[n:]
184- }
185- return chunks
175+ return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
186176 }
187177
188178 func strconvItoa(n int) string {
@@ -169,20 +169,10 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
169 }169 }
170 }170 }
171 171
172-// OSC52 encodes text for clipboard transfer. Large payloads are split into172+// OSC52 encodes text for clipboard transfer as one complete OSC 52 sequence.
173-// multiple complete OSC 52 sequences so each MsgOutput remains bounded.
174 func OSC52(text string) [][]byte {173 func OSC52(text string) [][]byte {
175 encoded := base64.StdEncoding.EncodeToString([]byte(text))174 encoded := base64.StdEncoding.EncodeToString([]byte(text))
176- if encoded == "" {175+ return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
177- return [][]byte{[]byte("\x1b]52;c;\x07")}
178- }
179- chunks := make([][]byte, 0, len(encoded)/OSC52ChunkSize+1)
180- for len(encoded) > 0 {
181- n := min(len(encoded), OSC52ChunkSize)
182- chunks = append(chunks, []byte("\x1b]52;c;"+encoded[:n]+"\x07"))
183- encoded = encoded[n:]
184- }
185- return chunks
186 }176 }
187 177
188 func strconvItoa(n int) string {178 func strconvItoa(n int) string {
M internal/usecase/copy/mode_test.go +23 -18
@@ -97,42 +97,47 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
9797 t.Fatalf("SelectedText() = %q, want %q", got, want)
9898 }
9999 frame := m.Render(s)
100- for y := 0; y < 2; y++ {
100+ for y := range 2 {
101101 if !frame.At(0, y).Style.Inverse {
102102 t.Fatalf("row %d first cell not inverse", y)
103103 }
104104 }
105105 }
106106
107-func TestOSC52Base64EncodingAndChunking(t *testing.T) {
107+func TestOSC52Base64Encoding(t *testing.T) {
108108 tests := []struct {
109109 name string
110110 text string
111111 }{
112112 {name: "small payload", text: "hello\nworld"},
113- {name: "chunked payload", text: strings.Repeat("x", OSC52ChunkSize)},
113+ {name: "large payload", text: strings.Repeat("x", OSC52ChunkSize)},
114114 }
115115 for _, tt := range tests {
116116 t.Run(tt.name, func(t *testing.T) {
117117 chunks := OSC52(tt.text)
118- joined := ""
118+ if len(chunks) != 1 {
119+ t.Fatalf("len(chunks) = %d, want 1", len(chunks))
120+ }
119121 for _, chunk := range chunks {
120- s := string(chunk)
121- if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") {
122- t.Fatalf("chunk %q missing OSC52 wrapper", s)
122+ decoded := decodeOSC52Chunk(t, chunk)
123+ if string(decoded) != tt.text {
124+ t.Fatalf("decoded = %q, want %q", string(decoded), tt.text)
123125 }
124- joined += strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07")
125- }
126- decoded, err := base64.StdEncoding.DecodeString(joined)
127- if err != nil {
128- t.Fatalf("DecodeString() error = %v", err)
129- }
130- if string(decoded) != tt.text {
131- t.Fatalf("decoded = %q, want %q", string(decoded), tt.text)
132- }
133- if tt.name == "chunked payload" && len(chunks) < 2 {
134- t.Fatalf("len(chunks) = %d, want chunking", len(chunks))
135126 }
136127 })
137128 }
138129 }
130+
131+func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
132+ t.Helper()
133+ s := string(chunk)
134+ if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") {
135+ t.Fatalf("chunk %q missing OSC52 wrapper", s)
136+ }
137+ encoded := strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07")
138+ decoded, err := base64.StdEncoding.DecodeString(encoded)
139+ if err != nil {
140+ t.Fatalf("DecodeString(%q) error = %v", encoded, err)
141+ }
142+ return decoded
143+}
@@ -97,42 +97,47 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
97 t.Fatalf("SelectedText() = %q, want %q", got, want)97 t.Fatalf("SelectedText() = %q, want %q", got, want)
98 }98 }
99 frame := m.Render(s)99 frame := m.Render(s)
100- for y := 0; y < 2; y++ {100+ for y := range 2 {
101 if !frame.At(0, y).Style.Inverse {101 if !frame.At(0, y).Style.Inverse {
102 t.Fatalf("row %d first cell not inverse", y)102 t.Fatalf("row %d first cell not inverse", y)
103 }103 }
104 }104 }
105 }105 }
106 106
107-func TestOSC52Base64EncodingAndChunking(t *testing.T) {107+func TestOSC52Base64Encoding(t *testing.T) {
108 tests := []struct {108 tests := []struct {
109 name string109 name string
110 text string110 text string
111 }{111 }{
112 {name: "small payload", text: "hello\nworld"},112 {name: "small payload", text: "hello\nworld"},
113- {name: "chunked payload", text: strings.Repeat("x", OSC52ChunkSize)},113+ {name: "large payload", text: strings.Repeat("x", OSC52ChunkSize)},
114 }114 }
115 for _, tt := range tests {115 for _, tt := range tests {
116 t.Run(tt.name, func(t *testing.T) {116 t.Run(tt.name, func(t *testing.T) {
117 chunks := OSC52(tt.text)117 chunks := OSC52(tt.text)
118- joined := ""118+ if len(chunks) != 1 {
119+ t.Fatalf("len(chunks) = %d, want 1", len(chunks))
120+ }
119 for _, chunk := range chunks {121 for _, chunk := range chunks {
120- s := string(chunk)122+ decoded := decodeOSC52Chunk(t, chunk)
121- if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") {123+ if string(decoded) != tt.text {
122- t.Fatalf("chunk %q missing OSC52 wrapper", s)124+ t.Fatalf("decoded = %q, want %q", string(decoded), tt.text)
123 }125 }
124- joined += strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07")
125- }
126- decoded, err := base64.StdEncoding.DecodeString(joined)
127- if err != nil {
128- t.Fatalf("DecodeString() error = %v", err)
129- }
130- if string(decoded) != tt.text {
131- t.Fatalf("decoded = %q, want %q", string(decoded), tt.text)
132- }
133- if tt.name == "chunked payload" && len(chunks) < 2 {
134- t.Fatalf("len(chunks) = %d, want chunking", len(chunks))
135 }126 }
136 })127 })
137 }128 }
138 }129 }
130+
131+func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
132+ t.Helper()
133+ s := string(chunk)
134+ if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") {
135+ t.Fatalf("chunk %q missing OSC52 wrapper", s)
136+ }
137+ encoded := strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07")
138+ decoded, err := base64.StdEncoding.DecodeString(encoded)
139+ if err != nil {
140+ t.Fatalf("DecodeString(%q) error = %v", encoded, err)
141+ }
142+ return decoded
143+}

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