Intercept clipboard paste events containing images in the terminal, upload them into the Docker container via bollard's tar upload API, and inject the resulting file path into terminal stdin so Claude Code can reference the image. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
262 lines
8.4 KiB
TypeScript
262 lines
8.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Terminal } from "@xterm/xterm";
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import { WebglAddon } from "@xterm/addon-webgl";
|
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import { useTerminal } from "../../hooks/useTerminal";
|
|
import { UrlDetector } from "../../lib/urlDetector";
|
|
import UrlToast from "./UrlToast";
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
active: boolean;
|
|
}
|
|
|
|
export default function TerminalView({ sessionId, active }: Props) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<Terminal | null>(null);
|
|
const fitRef = useRef<FitAddon | null>(null);
|
|
const webglRef = useRef<WebglAddon | null>(null);
|
|
const detectorRef = useRef<UrlDetector | null>(null);
|
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
|
|
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 14,
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
|
theme: {
|
|
background: "#0d1117",
|
|
foreground: "#e6edf3",
|
|
cursor: "#58a6ff",
|
|
selectionBackground: "#264f78",
|
|
black: "#484f58",
|
|
red: "#ff7b72",
|
|
green: "#3fb950",
|
|
yellow: "#d29922",
|
|
blue: "#58a6ff",
|
|
magenta: "#bc8cff",
|
|
cyan: "#39d353",
|
|
white: "#b1bac4",
|
|
brightBlack: "#6e7681",
|
|
brightRed: "#ffa198",
|
|
brightGreen: "#56d364",
|
|
brightYellow: "#e3b341",
|
|
brightBlue: "#79c0ff",
|
|
brightMagenta: "#d2a8ff",
|
|
brightCyan: "#56d364",
|
|
brightWhite: "#f0f6fc",
|
|
},
|
|
});
|
|
|
|
const fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
|
|
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
|
// that matches URLs even if they lack trailing path segments (the default regex
|
|
// misses OAuth URLs that end mid-line).
|
|
const urlRegex = /https?:\/\/[^\s'"\x07]+/;
|
|
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
|
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
|
|
}, { urlRegex });
|
|
term.loadAddon(webLinksAddon);
|
|
|
|
term.open(containerRef.current);
|
|
|
|
// WebGL addon is loaded/disposed dynamically in the active effect
|
|
// to avoid exhausting the browser's limited WebGL context pool.
|
|
|
|
fitAddon.fit();
|
|
termRef.current = term;
|
|
fitRef.current = fitAddon;
|
|
|
|
// Send initial size
|
|
resize(sessionId, term.cols, term.rows);
|
|
|
|
// Handle user input -> backend
|
|
const inputDisposable = term.onData((data) => {
|
|
sendInput(sessionId, data);
|
|
});
|
|
|
|
// Handle image paste: intercept paste events with image data,
|
|
// upload to the container, and inject the file path into terminal input.
|
|
const handlePaste = (e: ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
for (const item of Array.from(items)) {
|
|
if (item.type.startsWith("image/")) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const blob = item.getAsFile();
|
|
if (!blob) return;
|
|
|
|
blob.arrayBuffer().then(async (buf) => {
|
|
try {
|
|
setImagePasteMsg("Uploading image...");
|
|
const data = new Uint8Array(buf);
|
|
const filePath = await pasteImage(sessionId, data);
|
|
// Inject the file path into terminal stdin
|
|
sendInput(sessionId, filePath);
|
|
setImagePasteMsg(`Image saved to ${filePath}`);
|
|
} catch (err) {
|
|
console.error("Image paste failed:", err);
|
|
setImagePasteMsg("Image paste failed");
|
|
}
|
|
});
|
|
return; // Only handle the first image
|
|
}
|
|
}
|
|
};
|
|
|
|
containerRef.current.addEventListener("paste", handlePaste, { capture: true });
|
|
|
|
// Handle backend output -> terminal
|
|
let aborted = false;
|
|
|
|
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
|
detectorRef.current = detector;
|
|
|
|
const outputPromise = onOutput(sessionId, (data) => {
|
|
if (aborted) return;
|
|
term.write(data);
|
|
detector.feed(data);
|
|
}).then((unlisten) => {
|
|
if (aborted) unlisten();
|
|
return unlisten;
|
|
});
|
|
|
|
const exitPromise = onExit(sessionId, () => {
|
|
if (aborted) return;
|
|
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
|
|
}).then((unlisten) => {
|
|
if (aborted) unlisten();
|
|
return unlisten;
|
|
});
|
|
|
|
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls).
|
|
// Skip resize work for hidden terminals — containerRef will have 0 dimensions.
|
|
let resizeRafId: number | null = null;
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (resizeRafId !== null) return;
|
|
const el = containerRef.current;
|
|
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
|
resizeRafId = requestAnimationFrame(() => {
|
|
resizeRafId = null;
|
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
|
fitAddon.fit();
|
|
resize(sessionId, term.cols, term.rows);
|
|
});
|
|
});
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
return () => {
|
|
aborted = true;
|
|
detector.dispose();
|
|
detectorRef.current = null;
|
|
inputDisposable.dispose();
|
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
|
outputPromise.then((fn) => fn?.());
|
|
exitPromise.then((fn) => fn?.());
|
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
|
resizeObserver.disconnect();
|
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
|
webglRef.current = null;
|
|
term.dispose();
|
|
};
|
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
|
// Only the active terminal holds a WebGL context to avoid exhausting
|
|
// the browser's limited pool (~8-16 contexts).
|
|
useEffect(() => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
|
|
if (active) {
|
|
// Attach WebGL renderer
|
|
if (!webglRef.current) {
|
|
try {
|
|
const addon = new WebglAddon();
|
|
addon.onContextLoss(() => {
|
|
try { addon.dispose(); } catch { /* ignore */ }
|
|
webglRef.current = null;
|
|
});
|
|
term.loadAddon(addon);
|
|
webglRef.current = addon;
|
|
} catch {
|
|
// WebGL not available, canvas renderer is fine
|
|
}
|
|
}
|
|
fitRef.current?.fit();
|
|
term.focus();
|
|
} else {
|
|
// Release WebGL context for inactive terminals
|
|
if (webglRef.current) {
|
|
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
|
webglRef.current = null;
|
|
}
|
|
}
|
|
}, [active]);
|
|
|
|
// Auto-dismiss toast after 30 seconds
|
|
useEffect(() => {
|
|
if (!detectedUrl) return;
|
|
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
|
|
return () => clearTimeout(timer);
|
|
}, [detectedUrl]);
|
|
|
|
// Auto-dismiss image paste message after 3 seconds
|
|
useEffect(() => {
|
|
if (!imagePasteMsg) return;
|
|
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
|
|
return () => clearTimeout(timer);
|
|
}, [imagePasteMsg]);
|
|
|
|
const handleOpenUrl = useCallback(() => {
|
|
if (detectedUrl) {
|
|
openUrl(detectedUrl).catch((e) =>
|
|
console.error("Failed to open URL:", e),
|
|
);
|
|
setDetectedUrl(null);
|
|
}
|
|
}, [detectedUrl]);
|
|
|
|
return (
|
|
<div
|
|
ref={terminalContainerRef}
|
|
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
|
>
|
|
{detectedUrl && (
|
|
<UrlToast
|
|
url={detectedUrl}
|
|
onOpen={handleOpenUrl}
|
|
onDismiss={() => setDetectedUrl(null)}
|
|
/>
|
|
)}
|
|
{imagePasteMsg && (
|
|
<div
|
|
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
|
|
onClick={() => setImagePasteMsg(null)}
|
|
>
|
|
{imagePasteMsg}
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full h-full"
|
|
style={{ padding: "8px" }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|