fix: implement OSC 52 clipboard handler in DirectTerminal
The XDA handler told tmux we support clipboard, but the actual OSC 52 handler that receives clipboard data and writes it to navigator.clipboard was never implemented. This adds the handler with secure-context detection and a textarea-based fallback, plus cleanup on unmount. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4cda43795b
commit
fbc3755833
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Pure-logic tests for OSC 52 clipboard parsing.
|
||||
*
|
||||
* The actual handler lives inside DirectTerminal's useEffect (registered via
|
||||
* xterm's `terminal.parser.registerOscHandler`), so we extract the parsing
|
||||
* logic here to validate it independently of the DOM/xterm runtime.
|
||||
*/
|
||||
|
||||
/** Mirrors the parsing logic in DirectTerminal's OSC 52 handler. */
|
||||
function parseOsc52(data: string): { handled: boolean; text?: string } {
|
||||
const semicolonIndex = data.indexOf(";");
|
||||
if (semicolonIndex === -1) return { handled: false };
|
||||
|
||||
const base64Content = data.substring(semicolonIndex + 1);
|
||||
if (base64Content === "?") {
|
||||
// Query request — acknowledge but no text
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const text = atob(base64Content);
|
||||
return { handled: true, text };
|
||||
} catch {
|
||||
return { handled: true }; // malformed base64 — swallow
|
||||
}
|
||||
}
|
||||
|
||||
describe("OSC 52 parsing", () => {
|
||||
it("decodes clipboard selection (c)", () => {
|
||||
// "c" = clipboard, base64("hello") = "aGVsbG8="
|
||||
const result = parseOsc52("c;aGVsbG8=");
|
||||
expect(result).toEqual({ handled: true, text: "hello" });
|
||||
});
|
||||
|
||||
it("decodes primary selection (p)", () => {
|
||||
const result = parseOsc52("p;aGVsbG8=");
|
||||
expect(result).toEqual({ handled: true, text: "hello" });
|
||||
});
|
||||
|
||||
it("decodes multi-target selection (pc)", () => {
|
||||
// tmux sometimes sends multiple targets like "pc"
|
||||
const result = parseOsc52("pc;aGVsbG8=");
|
||||
expect(result).toEqual({ handled: true, text: "hello" });
|
||||
});
|
||||
|
||||
it("handles query request (? payload)", () => {
|
||||
const result = parseOsc52("c;?");
|
||||
expect(result).toEqual({ handled: true });
|
||||
});
|
||||
|
||||
it("returns unhandled when no semicolon present", () => {
|
||||
const result = parseOsc52("garbage");
|
||||
expect(result).toEqual({ handled: false });
|
||||
});
|
||||
|
||||
it("handles invalid base64 gracefully", () => {
|
||||
const result = parseOsc52("c;!!!not-base64!!!");
|
||||
expect(result).toEqual({ handled: true }); // swallowed
|
||||
});
|
||||
|
||||
it("decodes unicode text", () => {
|
||||
// btoa can't encode non-latin1 directly, but tmux sends UTF-8 bytes
|
||||
// as base64. In a browser, atob returns a byte string which is valid
|
||||
// for ASCII-subset UTF-8.
|
||||
const base64 = btoa("line1\nline2\ttab");
|
||||
const result = parseOsc52(`c;${base64}`);
|
||||
expect(result).toEqual({ handled: true, text: "line1\nline2\ttab" });
|
||||
});
|
||||
|
||||
it("decodes empty string payload", () => {
|
||||
// base64("") = ""
|
||||
const result = parseOsc52("c;");
|
||||
expect(result).toEqual({ handled: true, text: "" });
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,22 @@ interface DirectTerminalProps {
|
|||
height?: string;
|
||||
}
|
||||
|
||||
/** Copy text to clipboard using a hidden textarea (fallback for non-secure contexts). */
|
||||
function fallbackCopy(text: string): void {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch {
|
||||
console.warn("[DirectTerminal] Fallback clipboard copy failed");
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct xterm.js terminal with native WebSocket connection.
|
||||
* Implements Extended Device Attributes (XDA) handler to enable
|
||||
|
|
@ -135,7 +151,7 @@ export function DirectTerminal({
|
|||
|
||||
// **CRITICAL FIX**: Register XDA (Extended Device Attributes) handler
|
||||
// This makes tmux recognize our terminal and enable clipboard support
|
||||
terminal.parser.registerCsiHandler(
|
||||
const xdaDisposable = terminal.parser.registerCsiHandler(
|
||||
{ prefix: ">", final: "q" }, // CSI > q is XTVERSION / XDA
|
||||
() => {
|
||||
// Respond with XTerm identification that tmux recognizes
|
||||
|
|
@ -148,6 +164,34 @@ export function DirectTerminal({
|
|||
},
|
||||
);
|
||||
|
||||
// Register OSC 52 handler for clipboard integration
|
||||
// tmux sends clipboard data as: OSC 52 ; c;<base64-data> ST
|
||||
const osc52Disposable = terminal.parser.registerOscHandler(52, (data: string) => {
|
||||
const semicolonIndex = data.indexOf(";");
|
||||
if (semicolonIndex === -1) return false;
|
||||
|
||||
const base64Content = data.substring(semicolonIndex + 1);
|
||||
if (base64Content === "?") {
|
||||
// Query request — not supported, ignore
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = atob(base64Content);
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).catch((err) => {
|
||||
console.warn("[DirectTerminal] Clipboard write failed:", err);
|
||||
fallbackCopy(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[DirectTerminal] Failed to decode OSC 52 data:", err);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Open terminal in DOM
|
||||
terminal.open(terminalRef.current);
|
||||
terminalInstance.current = terminal;
|
||||
|
|
@ -229,6 +273,8 @@ export function DirectTerminal({
|
|||
cleanup = () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
disposable.dispose();
|
||||
xdaDisposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
websocket.close();
|
||||
terminal.dispose();
|
||||
};
|
||||
|
|
@ -366,7 +412,7 @@ export function DirectTerminal({
|
|||
<span className={cn("text-[10px] font-medium uppercase tracking-[0.06em]", statusTextColor)}>
|
||||
{statusText}
|
||||
</span>
|
||||
{/* XDA clipboard badge */}
|
||||
{/* OSC 52 clipboard badge */}
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
|
||||
style={{
|
||||
|
|
@ -374,7 +420,7 @@ export function DirectTerminal({
|
|||
background: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
|
||||
}}
|
||||
>
|
||||
XDA
|
||||
OSC 52
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue