Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 5m40s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Opening a page is a container probe, a browser launch, a page load and often a viewer start — several seconds during which the only feedback was the click itself. Worse from a terminal, where the result appears in a pane the user is not looking at. So: the backend emits progress on the existing `container-progress` channel at each step, the Browser tab renders that line whenever it is set — the progress belongs to the project, not to whoever pressed the button, which is what lets a terminal-initiated open report anywhere at all — and the terminal's "In container" now selects the project's Browser tab before starting, so the line has somewhere to appear. Selecting a sub-tab from outside needed a route: `ProjectHome` keeps it in local state, so `openProjectHomeTab` parks a request in the store and the pane consumes it once. Consumed once, so it cannot fight the user's own clicking afterwards. Preview releases now prune themselves to the newest KEEP_PREVIEWS (2), in a job that runs only if all three platforms published — a half-finished run must not evict a good older build. The cleanup workflow's manual sweep stays as the backstop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
717 lines
27 KiB
TypeScript
717 lines
27 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 { useAppState } from "../../store/appState";
|
||
import {
|
||
awsSsoRefresh,
|
||
openPageInContainerBrowser,
|
||
uploadHostFileToTerminal,
|
||
} from "../../lib/tauri-commands";
|
||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||
import { UrlDetector } from "../../lib/urlDetector";
|
||
import {
|
||
RelayRateLimiter,
|
||
URL_RELAY_OSC,
|
||
parseUrlRelayOsc,
|
||
sanitizeRelayUrl,
|
||
} from "../../lib/urlRelay";
|
||
import UrlToast from "./UrlToast";
|
||
import { trimSelection } from "./trimSelection";
|
||
import TerminalContextMenu from "./TerminalContextMenu";
|
||
|
||
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 setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||
|
||
const ssoBufferRef = useRef("");
|
||
const ssoTriggeredRef = useRef(false);
|
||
const projectId = useAppState(
|
||
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
||
);
|
||
|
||
// One toast slot, two producers: the heuristic long-URL detector and the
|
||
// container's explicit "open this in the host browser" relay (OSC 7777).
|
||
// Sharing the slot keeps them from stacking on top of each other.
|
||
//
|
||
// Both producers read the container's PTY output, so both are untrusted, and
|
||
// both must go through `sanitizeRelayUrl` before anything is stored here —
|
||
// see `promptUrl` below, which is the only writer.
|
||
//
|
||
// `seq` exists because the slot is shared and long-lived: a second prompt
|
||
// replacing a first would otherwise mutate the toast in place, swapping the
|
||
// text under a user who is mid-read and mid-click. Keying the toast on it
|
||
// remounts the component, so a new URL is unmistakably a new prompt.
|
||
const [urlPrompt, setUrlPrompt] = useState<{
|
||
url: string;
|
||
label: string;
|
||
seq: number;
|
||
} | null>(null);
|
||
const promptSeqRef = useRef(0);
|
||
const relayLimiterRef = useRef(new RelayRateLimiter());
|
||
|
||
/**
|
||
* The only writer of the prompt slot. Re-validates whatever the caller
|
||
* found: the OSC relay branch has already been through `parseUrlRelayOsc`,
|
||
* but the heuristic detector branch has been through nothing at all, and a
|
||
* raw regex match is exactly the input `sanitizeRelayUrl` exists to refuse.
|
||
*/
|
||
const promptUrl = useCallback((raw: string, label: string) => {
|
||
const url = sanitizeRelayUrl(raw);
|
||
if (!url) {
|
||
console.warn("Refusing to prompt for a URL that failed validation");
|
||
return;
|
||
}
|
||
promptSeqRef.current += 1;
|
||
setUrlPrompt({ url, label, seq: promptSeqRef.current });
|
||
}, []);
|
||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||
const isAtBottomRef = useRef(true);
|
||
// Tracks user intent to follow output — only set to false by explicit user
|
||
// actions (mouse wheel up), not by xterm scroll events during writes.
|
||
const autoFollowRef = useRef(true);
|
||
const lastUserScrollTimeRef = useRef(0);
|
||
|
||
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
||
// and the unmount-cleanup effect further down).
|
||
const activeRef = useRef(active);
|
||
activeRef.current = active;
|
||
|
||
// File drag-and-drop: dropped files are copied into the container and their
|
||
// in-container paths typed into the prompt so Claude Code can read them.
|
||
// Tauri intercepts OS file drops at the webview level, so we use
|
||
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
|
||
// The listener is window-wide, so we route purely by a hit-test against this
|
||
// terminal's bounds: the pane the drop lands on handles it. Inactive panes are
|
||
// `display:none` (zero-size rect) so they never match — this works for the
|
||
// current tabbed layout and would also do the right thing with split panes.
|
||
useEffect(() => {
|
||
let unlisten: (() => void) | undefined;
|
||
let cancelled = false;
|
||
|
||
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
// A hidden (display:none) pane has a zero-size rect — never a drop target.
|
||
if (!rect || rect.width === 0 || rect.height === 0) return false;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const x = pos.x / dpr;
|
||
const y = pos.y / dpr;
|
||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||
};
|
||
|
||
// Always single-quote: a dropped filename can contain shell metacharacters
|
||
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
|
||
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
|
||
const quote = (p: string) => `'${p.replace(/'/g, "'\\''")}'`;
|
||
|
||
(async () => {
|
||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||
if (event.payload.type !== "drop") return;
|
||
if (!insideThisTerminal(event.payload.position)) return;
|
||
|
||
const paths = event.payload.paths ?? [];
|
||
if (paths.length === 0) return;
|
||
|
||
setImagePasteMsg(`Adding ${paths.length} file${paths.length > 1 ? "s" : ""}…`);
|
||
const containerPaths: string[] = [];
|
||
for (const p of paths) {
|
||
try {
|
||
containerPaths.push(await uploadHostFileToTerminal(sessionId, p));
|
||
} catch (err) {
|
||
console.error("File drop upload failed for", p, err);
|
||
}
|
||
}
|
||
if (containerPaths.length === 0) {
|
||
setImagePasteMsg("File drop failed");
|
||
return;
|
||
}
|
||
sendInput(sessionId, containerPaths.map(quote).join(" ") + " ");
|
||
setImagePasteMsg(`Added ${containerPaths.length} file path${containerPaths.length > 1 ? "s" : ""}`);
|
||
});
|
||
if (cancelled) un();
|
||
else unlisten = un;
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
unlisten?.();
|
||
};
|
||
}, [sessionId, sendInput]);
|
||
|
||
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).
|
||
// eslint-disable-next-line no-control-regex
|
||
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||
// Same sink, same rule: what xterm matched came off the container's
|
||
// output, so it is validated before it reaches the OS opener. A click
|
||
// here is a deliberate act on visible text, but "visible" is exactly
|
||
// what a userinfo-spoofed URL subverts.
|
||
const safe = sanitizeRelayUrl(uri);
|
||
if (!safe) {
|
||
console.warn("Refusing to open a link that failed validation");
|
||
return;
|
||
}
|
||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||
}, { urlRegex });
|
||
term.loadAddon(webLinksAddon);
|
||
|
||
term.open(containerRef.current);
|
||
|
||
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
|
||
// stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw.
|
||
// Both prevent the keystroke from reaching the container (where Ctrl+C
|
||
// would send SIGINT and cancel running work).
|
||
term.attachCustomKeyEventHandler((event) => {
|
||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
||
const sel = term.getSelection();
|
||
if (sel) {
|
||
const out = event.altKey ? sel : trimSelection(sel);
|
||
navigator.clipboard.writeText(out).catch((e) =>
|
||
console.error("Ctrl+Shift+C clipboard write failed:", e),
|
||
);
|
||
}
|
||
return false; // prevent xterm from processing this key
|
||
}
|
||
// Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status
|
||
// bar, bound to the active session; trigger it via the store).
|
||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
|
||
useAppState.getState().sttToggle();
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// 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 OSC 52 clipboard write sequences from programs inside the container.
|
||
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
|
||
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
|
||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||
const idx = data.indexOf(";");
|
||
if (idx === -1) return false;
|
||
const payload = data.substring(idx + 1);
|
||
if (payload === "?") return false; // clipboard read request, not supported
|
||
try {
|
||
const decoded = atob(payload);
|
||
navigator.clipboard.writeText(decoded).catch((e) =>
|
||
console.error("OSC 52 clipboard write failed:", e),
|
||
);
|
||
} catch (e) {
|
||
console.error("OSC 52 decode failed:", e);
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// URL relay (OSC 7777) — a CLI inside the container asked for a URL to be
|
||
// opened in a browser. The container has none; `triple-c-open` (installed
|
||
// as xdg-open / $BROWSER / sensible-browser / ...) forwards the request
|
||
// here instead.
|
||
//
|
||
// The container is untrusted, so this never opens anything by itself:
|
||
// parseUrlRelayOsc enforces the http/https allowlist and the payload is
|
||
// rate-limited, then the user gets the same confirmation toast the
|
||
// long-URL detector uses. One click is a small price for not handing a
|
||
// sandboxed agent a "make the host's logged-in browser fetch this"
|
||
// primitive.
|
||
const relayDisposable = term.parser.registerOscHandler(URL_RELAY_OSC, (data) => {
|
||
const url = parseUrlRelayOsc(data);
|
||
if (!url) {
|
||
console.warn("URL relay: rejected request from container");
|
||
return true; // consumed either way — never let it reach the screen
|
||
}
|
||
if (!relayLimiterRef.current.allow(url)) {
|
||
console.warn("URL relay: rate-limited", url);
|
||
return true;
|
||
}
|
||
promptUrl(url, "Container asked to open a URL");
|
||
return true;
|
||
});
|
||
|
||
// Handle user input -> backend
|
||
const inputDisposable = term.onData((data) => {
|
||
sendInput(sessionId, data);
|
||
});
|
||
|
||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||
// Captured during capture phase so it fires before xterm's own handler.
|
||
const handleWheel = (e: WheelEvent) => {
|
||
lastUserScrollTimeRef.current = Date.now();
|
||
if (e.deltaY < 0) {
|
||
autoFollowRef.current = false;
|
||
setIsAutoFollow(false);
|
||
isAtBottomRef.current = false;
|
||
setIsAtBottom(false);
|
||
}
|
||
};
|
||
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
|
||
|
||
// Track scroll position to show "Jump to Current" button.
|
||
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
|
||
let scrollStateRafId: number | null = null;
|
||
const scrollDisposable = term.onScroll(() => {
|
||
const buf = term.buffer.active;
|
||
const atBottom = buf.viewportY >= buf.baseY;
|
||
isAtBottomRef.current = atBottom;
|
||
|
||
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
|
||
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
|
||
if (atBottom && isUserScroll && !autoFollowRef.current) {
|
||
autoFollowRef.current = true;
|
||
setIsAutoFollow(true);
|
||
}
|
||
|
||
if (scrollStateRafId === null) {
|
||
scrollStateRafId = requestAnimationFrame(() => {
|
||
scrollStateRafId = null;
|
||
setIsAtBottom(isAtBottomRef.current);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Track text selection to show copy hint in status bar
|
||
const selectionDisposable = term.onSelectionChange(() => {
|
||
setTerminalHasSelection(term.hasSelection());
|
||
});
|
||
|
||
// 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;
|
||
|
||
// The width is read per scan, not captured: only a break the terminal
|
||
// itself inserted may be deleted, and where that is moves with every
|
||
// resize.
|
||
const detector = new UrlDetector(
|
||
(url) => promptUrl(url, "Long URL detected"),
|
||
() => termRef.current?.cols ?? 0,
|
||
);
|
||
detectorRef.current = detector;
|
||
|
||
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
|
||
const textDecoder = new TextDecoder();
|
||
|
||
const outputPromise = onOutput(sessionId, (data) => {
|
||
if (aborted) return;
|
||
term.write(data, () => {
|
||
if (autoFollowRef.current) {
|
||
term.scrollToBottom();
|
||
if (!isAtBottomRef.current) {
|
||
isAtBottomRef.current = true;
|
||
setIsAtBottom(true);
|
||
}
|
||
}
|
||
});
|
||
detector.feed(data);
|
||
|
||
// Scan for SSO refresh marker in terminal output
|
||
if (!ssoTriggeredRef.current && projectId) {
|
||
const text = textDecoder.decode(data, { stream: true });
|
||
// Combine with overlap from previous chunk to handle marker spanning chunks
|
||
const combined = ssoBufferRef.current + text;
|
||
if (combined.includes(SSO_MARKER)) {
|
||
ssoTriggeredRef.current = true;
|
||
ssoBufferRef.current = "";
|
||
awsSsoRefresh(projectId).catch((e) =>
|
||
console.error("AWS SSO refresh failed:", e)
|
||
);
|
||
} else {
|
||
// Keep last N chars as overlap for next chunk
|
||
ssoBufferRef.current = combined.slice(-SSO_MARKER.length);
|
||
}
|
||
}
|
||
}).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);
|
||
if (autoFollowRef.current) {
|
||
term.scrollToBottom();
|
||
}
|
||
});
|
||
});
|
||
resizeObserver.observe(containerRef.current);
|
||
|
||
return () => {
|
||
aborted = true;
|
||
detector.dispose();
|
||
detectorRef.current = null;
|
||
ssoTriggeredRef.current = false;
|
||
ssoBufferRef.current = "";
|
||
osc52Disposable.dispose();
|
||
relayDisposable.dispose();
|
||
inputDisposable.dispose();
|
||
scrollDisposable.dispose();
|
||
selectionDisposable.dispose();
|
||
setTerminalHasSelection(false);
|
||
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||
outputPromise.then((fn) => fn?.());
|
||
exitPromise.then((fn) => fn?.());
|
||
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||
resizeObserver.disconnect();
|
||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||
webglRef.current = null;
|
||
term.dispose();
|
||
termRef.current = null;
|
||
};
|
||
}, [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();
|
||
if (autoFollowRef.current) {
|
||
term.scrollToBottom();
|
||
}
|
||
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 (!urlPrompt) return;
|
||
const timer = setTimeout(() => setUrlPrompt(null), 30_000);
|
||
return () => clearTimeout(timer);
|
||
}, [urlPrompt]);
|
||
|
||
// 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 (!urlPrompt) return;
|
||
// Validated again at the sink. `promptUrl` is the only writer and already
|
||
// sanitizes, so this can only fail if that invariant is broken — which is
|
||
// precisely when it matters that the last thing before `openUrl` checks.
|
||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||
setUrlPrompt(null);
|
||
if (!safe) {
|
||
console.warn("Refusing to open a URL that failed validation");
|
||
return;
|
||
}
|
||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
||
}, [urlPrompt]);
|
||
|
||
/**
|
||
* Open the prompted URL in the container's own browser instead of the host's.
|
||
*
|
||
* For a sign-in this is the shorter path: the callback listener the tool is
|
||
* waiting on is inside the container, so a container-side browser closes the
|
||
* loop with nothing crossing to the host. The page is published to the
|
||
* project's Browser tab, which is where the user completes it by hand.
|
||
*/
|
||
const handleOpenUrlInContainer = useCallback(() => {
|
||
if (!urlPrompt) return;
|
||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||
setUrlPrompt(null);
|
||
if (!safe) {
|
||
console.warn("Refusing to open a URL that failed validation");
|
||
return;
|
||
}
|
||
if (!projectId) return;
|
||
// Land on the pane that will show it, before the work starts: opening takes
|
||
// several seconds, and the progress line lives there.
|
||
useAppState.getState().openProjectHomeTab(projectId, "browser");
|
||
// A sign-in page is the one case where the *window* size matters least and
|
||
// the layout matters most, so it gets the ordinary desktop viewport.
|
||
// `true`: from a terminal there is no Browser pane on screen, so the page
|
||
// needs a window of its own or it opens somewhere the user isn't looking.
|
||
openPageInContainerBrowser(projectId, safe, 1280, 720, true)
|
||
.then((result) => {
|
||
const push = useAppState.getState().pushToast;
|
||
if (result.error) {
|
||
push({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||
} else {
|
||
push({
|
||
kind: "success",
|
||
message: "Opened in the container’s browser",
|
||
});
|
||
}
|
||
})
|
||
.catch((e) =>
|
||
useAppState.getState().pushToast({
|
||
kind: "error",
|
||
message: "Could not open it in the container’s browser",
|
||
detail: String(e),
|
||
}),
|
||
);
|
||
}, [urlPrompt, projectId]);
|
||
|
||
const handleScrollToBottom = useCallback(() => {
|
||
const term = termRef.current;
|
||
if (term) {
|
||
autoFollowRef.current = true;
|
||
setIsAutoFollow(true);
|
||
fitRef.current?.fit();
|
||
term.scrollToBottom();
|
||
isAtBottomRef.current = true;
|
||
setIsAtBottom(true);
|
||
}
|
||
}, []);
|
||
|
||
// Surface this terminal's scroll state to the status bar's "Jump to Current"
|
||
// control, but only while it's the active (visible) terminal.
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
setTerminalAtBottom(isAtBottom);
|
||
setScrollActiveToBottom(handleScrollToBottom);
|
||
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
|
||
|
||
// On unmount, if this was the active terminal, clear the status-bar scroll
|
||
// state so it doesn't point at a disposed terminal. (Tab switches don't
|
||
// unmount — the deactivating terminal stays mounted but hidden — so this
|
||
// only fires when the active session is actually closed.)
|
||
useEffect(() => {
|
||
return () => {
|
||
if (activeRef.current) {
|
||
setTerminalAtBottom(true);
|
||
setScrollActiveToBottom(() => {});
|
||
}
|
||
};
|
||
}, [setTerminalAtBottom, setScrollActiveToBottom]);
|
||
|
||
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
||
const term = termRef.current;
|
||
if (!term) return;
|
||
const sel = term.getSelection();
|
||
if (!sel) return;
|
||
const out = mode === "raw" ? sel : trimSelection(sel);
|
||
navigator.clipboard.writeText(out).catch((e) =>
|
||
console.error("Context menu clipboard write failed:", e),
|
||
);
|
||
}, []);
|
||
|
||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||
if (!termRef.current?.hasSelection()) return; // let default menu happen
|
||
e.preventDefault();
|
||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||
}, []);
|
||
|
||
const handleToggleAutoFollow = useCallback(() => {
|
||
const next = !autoFollowRef.current;
|
||
autoFollowRef.current = next;
|
||
setIsAutoFollow(next);
|
||
if (next) {
|
||
const term = termRef.current;
|
||
if (term) {
|
||
fitRef.current?.fit();
|
||
term.scrollToBottom();
|
||
isAtBottomRef.current = true;
|
||
setIsAtBottom(true);
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
ref={terminalContainerRef}
|
||
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||
>
|
||
{urlPrompt && (
|
||
<UrlToast
|
||
// A different URL is a different prompt, not an edit of this one.
|
||
key={urlPrompt.seq}
|
||
url={urlPrompt.url}
|
||
label={urlPrompt.label}
|
||
onOpen={handleOpenUrl}
|
||
onOpenInContainer={handleOpenUrlInContainer}
|
||
onDismiss={() => setUrlPrompt(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>
|
||
)}
|
||
{/* Auto-follow toggle - top right */}
|
||
<button
|
||
onClick={handleToggleAutoFollow}
|
||
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
||
isAutoFollow
|
||
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
||
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
||
}`}
|
||
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
||
>
|
||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||
</button>
|
||
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
||
FitAddon measures the host element it's mounted into; padding there
|
||
causes the grid to overhang and clip the rightmost column / bottom
|
||
row. The host below fills this wrapper's content box with no padding.
|
||
Kept to a tight, even gutter so the terminal claims as much area as
|
||
possible while leaving a little breathing room beside the scrollbar. */}
|
||
<div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
|
||
<div
|
||
ref={containerRef}
|
||
className="w-full h-full"
|
||
onContextMenu={handleContextMenu}
|
||
/>
|
||
</div>
|
||
{contextMenu && (
|
||
<TerminalContextMenu
|
||
x={contextMenu.x}
|
||
y={contextMenu.y}
|
||
onCopyTrimmed={() => {
|
||
writeSelection("trimmed");
|
||
setContextMenu(null);
|
||
}}
|
||
onCopyRaw={() => {
|
||
writeSelection("raw");
|
||
setContextMenu(null);
|
||
}}
|
||
onDismiss={() => setContextMenu(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|