fix(copy): emit osc52 clipboard as single sequence
- commit
- e47efb0098ec6b034e529344c53027e7f124f379
- author
- brice · · 2026-07-02 16:00 UTC
- parent
- d9a1d18
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 169 | 169 | } | |
| 170 | 170 | } | |
| 171 | 171 | ||
| 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. | |
| 174 | 173 | func OSC52(text string) [][]byte { | |
| 175 | 174 | 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")} | |
| 186 | 176 | } | |
| 187 | 177 | ||
| 188 | 178 | 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 into | 172 | + | // 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) { | |||
| 97 | 97 | t.Fatalf("SelectedText() = %q, want %q", got, want) | |
| 98 | 98 | } | |
| 99 | 99 | frame := m.Render(s) | |
| 100 | - | for y := 0; y < 2; y++ { | |
| 100 | + | for y := range 2 { | |
| 101 | 101 | if !frame.At(0, y).Style.Inverse { | |
| 102 | 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 | 108 | tests := []struct { | |
| 109 | 109 | name string | |
| 110 | 110 | text string | |
| 111 | 111 | }{ | |
| 112 | 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 | 115 | for _, tt := range tests { | |
| 116 | 116 | t.Run(tt.name, func(t *testing.T) { | |
| 117 | 117 | chunks := OSC52(tt.text) | |
| 118 | - | joined := "" | |
| 118 | + | if len(chunks) != 1 { | |
| 119 | + | t.Fatalf("len(chunks) = %d, want 1", len(chunks)) | |
| 120 | + | } | |
| 119 | 121 | 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) | |
| 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 | + | } | |
| @@ -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 string | 109 | name string | ||
| 110 | text string | 110 | 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 | + | } | |||