29730eb643
Kitty implements OSC 5522 paste-event listings by sending one DATA packet whose mime field decodes to the literal dot sentinel and whose payload carries the available types as a whitespace-separated list. The TUI previously only honored the ancillary per-type DATA-packet form, so plain-text Kitty pastes were rejected as unsupported. Decode the dot-listing payload during the listing phase and keep the per-type packet path as a fallback. Also emit the follow-up OSC 5522 read request using the Kitty protocol shape: the selected MIME list belongs in the request payload, not in mime metadata. Sending it as metadata makes Kitty parse an empty requested MIME list and returns no clipboard bytes, which surfaces as an empty paste. Add regression coverage for Kitty's text-only and multi-type listings, and assert the payload-form read requests for default and primary selection paste events. Fixes #2051
177 lines
6.9 KiB
TypeScript
177 lines
6.9 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { EnhancedPasteController } from "../../src/utils/enhanced-paste";
|
|
|
|
const ST = "\x1b\\";
|
|
const OSC = "\x1b]5522;";
|
|
|
|
function packet(metadata: string, payload?: string): string {
|
|
return `${OSC}${metadata}${payload === undefined ? "" : `;${payload}`}${ST}`;
|
|
}
|
|
|
|
describe("EnhancedPasteController", () => {
|
|
it("requests image data from an OSC 5522 paste event and preserves chunk boundaries", () => {
|
|
const writes: string[] = [];
|
|
const pastedImages: Array<{ data: string; mimeType: string }> = [];
|
|
const statuses: string[] = [];
|
|
const controller = new EnhancedPasteController({
|
|
write: data => writes.push(data),
|
|
pasteText: () => statuses.push("unexpected text paste"),
|
|
pasteImage: image => {
|
|
pastedImages.push({ data: image.data, mimeType: image.mimeType });
|
|
},
|
|
showStatus: message => statuses.push(message),
|
|
});
|
|
|
|
controller.enable();
|
|
expect(writes).toEqual(["\x1b[?5522h"]);
|
|
|
|
const imageMime = Buffer.from("image/png", "utf8").toString("base64");
|
|
const textMime = Buffer.from("text/plain", "utf8").toString("base64");
|
|
const password = Buffer.from("secret123", "utf8").toString("base64");
|
|
controller.handleInput(packet(`type=read:status=OK:pw=${password}`));
|
|
controller.handleInput(packet(`type=read:status=DATA:mime=${textMime}`));
|
|
controller.handleInput(packet(`type=read:status=DATA:mime=${imageMime}`));
|
|
controller.handleInput(packet("type=read:status=DONE"));
|
|
|
|
const pasteEventName = Buffer.from("Paste event", "utf8").toString("base64");
|
|
expect(writes.at(-1)).toBe(`${OSC}type=read:pw=${password}:name=${pasteEventName};${imageMime}${ST}`);
|
|
|
|
controller.handleInput(packet("type=read:status=OK"));
|
|
controller.handleInput(
|
|
packet(`type=read:status=DATA:mime=${imageMime}`, Buffer.from("image-", "utf8").toString("base64")),
|
|
);
|
|
controller.handleInput(
|
|
packet(`type=read:status=DATA:mime=${imageMime}`, Buffer.from("bytes", "utf8").toString("base64")),
|
|
);
|
|
controller.handleInput(packet("type=read:status=DONE"));
|
|
|
|
expect(pastedImages).toEqual([
|
|
{
|
|
data: Buffer.from("image-bytes", "utf8").toString("base64"),
|
|
mimeType: "image/png",
|
|
},
|
|
]);
|
|
expect(statuses).toEqual([]);
|
|
});
|
|
|
|
it("falls back to text/plain and carries primary-selection location into the read request", () => {
|
|
const writes: string[] = [];
|
|
const pastedText: string[] = [];
|
|
const controller = new EnhancedPasteController({
|
|
write: data => writes.push(data),
|
|
pasteText: text => pastedText.push(text),
|
|
pasteImage: () => {
|
|
throw new Error("unexpected image paste");
|
|
},
|
|
showStatus: message => pastedText.push(`status:${message}`),
|
|
});
|
|
|
|
const textMime = Buffer.from("text/plain", "utf8").toString("base64");
|
|
const password = Buffer.from("secret456", "utf8").toString("base64");
|
|
const pasteEventName = Buffer.from("Paste event", "utf8").toString("base64");
|
|
expect(controller.handleInput("plain text")).toBe(false);
|
|
controller.handleInput(packet(`type=read:status=OK:loc=primary:pw=${password}`));
|
|
controller.handleInput(packet(`type=read:status=DATA:mime=${textMime}`));
|
|
controller.handleInput(packet("type=read:status=DONE"));
|
|
|
|
expect(writes).toEqual([`${OSC}type=read:loc=primary:pw=${password}:name=${pasteEventName};${textMime}${ST}`]);
|
|
|
|
controller.handleInput(packet("type=read:status=OK"));
|
|
controller.handleInput(
|
|
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from("hello ", "utf8").toString("base64")),
|
|
);
|
|
controller.handleInput(
|
|
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from("world", "utf8").toString("base64")),
|
|
);
|
|
controller.handleInput(packet("type=read:status=DONE"));
|
|
|
|
expect(pastedText).toEqual(["hello world"]);
|
|
});
|
|
|
|
it("reports unsupported paste events instead of leaking OSC packets to the editor", () => {
|
|
const statuses: string[] = [];
|
|
const controller = new EnhancedPasteController({
|
|
write: () => {},
|
|
pasteText: () => {},
|
|
pasteImage: () => {},
|
|
showStatus: message => statuses.push(message),
|
|
});
|
|
|
|
const htmlMime = Buffer.from("text/html", "utf8").toString("base64");
|
|
expect(controller.handleInput(packet("type=read:status=OK"))).toBe(true);
|
|
expect(controller.handleInput(packet(`type=read:status=DATA:mime=${htmlMime}`))).toBe(true);
|
|
expect(controller.handleInput(packet("type=read:status=DONE"))).toBe(true);
|
|
|
|
expect(statuses).toEqual(["Clipboard paste has no supported text or image data"]);
|
|
});
|
|
|
|
it("decodes Kitty's dot-listing DATA payload to discover plain-text and request it", () => {
|
|
const writes: string[] = [];
|
|
const pastedText: string[] = [];
|
|
const controller = new EnhancedPasteController({
|
|
write: data => writes.push(data),
|
|
pasteText: text => pastedText.push(text),
|
|
pasteImage: () => {
|
|
throw new Error("unexpected image paste");
|
|
},
|
|
showStatus: message => pastedText.push(`status:${message}`),
|
|
});
|
|
|
|
const dot = Buffer.from(".", "utf8").toString("base64");
|
|
const textMime = Buffer.from("text/plain", "utf8").toString("base64");
|
|
const password = Buffer.from("secret-token-123", "utf8").toString("base64");
|
|
const pasteEventName = Buffer.from("Paste event", "utf8").toString("base64");
|
|
|
|
// Kitty bundles the available MIME types into a single DATA packet
|
|
// whose `mime` field is the literal `.` and whose payload carries a
|
|
// whitespace-separated, base64-encoded list (e.g. "text/plain\n").
|
|
controller.handleInput(packet(`type=read:status=OK:pw=${password}`));
|
|
controller.handleInput(
|
|
packet(
|
|
`type=read:status=DATA:mime=${dot}:pw=${password}`,
|
|
Buffer.from("text/plain\n", "utf8").toString("base64"),
|
|
),
|
|
);
|
|
controller.handleInput(packet(`type=read:status=DONE:pw=${password}`));
|
|
|
|
expect(writes.at(-1)).toBe(`${OSC}type=read:pw=${password}:name=${pasteEventName};${textMime}${ST}`);
|
|
|
|
controller.handleInput(packet("type=read:status=OK"));
|
|
controller.handleInput(
|
|
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from("hello", "utf8").toString("base64")),
|
|
);
|
|
controller.handleInput(
|
|
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from(" world", "utf8").toString("base64")),
|
|
);
|
|
controller.handleInput(packet("type=read:status=DONE"));
|
|
|
|
expect(pastedText).toEqual(["hello world"]);
|
|
});
|
|
|
|
it("prefers images when Kitty's dot-listing payload advertises multiple MIME types", () => {
|
|
const writes: string[] = [];
|
|
const controller = new EnhancedPasteController({
|
|
write: data => writes.push(data),
|
|
pasteText: () => {
|
|
throw new Error("unexpected text paste");
|
|
},
|
|
pasteImage: () => {},
|
|
showStatus: () => {},
|
|
});
|
|
|
|
const dot = Buffer.from(".", "utf8").toString("base64");
|
|
const imageMime = Buffer.from("image/png", "utf8").toString("base64");
|
|
|
|
controller.handleInput(packet("type=read:status=OK"));
|
|
controller.handleInput(
|
|
packet(
|
|
`type=read:status=DATA:mime=${dot}`,
|
|
Buffer.from("text/plain image/png text/html\n", "utf8").toString("base64"),
|
|
),
|
|
);
|
|
controller.handleInput(packet("type=read:status=DONE"));
|
|
|
|
expect(writes.at(-1)).toBe(`${OSC}type=read;${imageMime}${ST}`);
|
|
});
|
|
});
|