diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index e36faab..1b54e5a 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebglAddon } from "@xterm/addon-webgl"; @@ -6,6 +6,8 @@ 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; @@ -14,11 +16,15 @@ interface Props { export default function TerminalView({ sessionId, active }: Props) { const containerRef = useRef(null); + const terminalContainerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const webglRef = useRef(null); + const detectorRef = useRef(null); const { sendInput, resize, onOutput, onExit } = useTerminal(); + const [detectedUrl, setDetectedUrl] = useState(null); + useEffect(() => { if (!containerRef.current) return; @@ -82,9 +88,13 @@ export default function TerminalView({ sessionId, active }: Props) { // 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; @@ -116,6 +126,8 @@ export default function TerminalView({ sessionId, active }: Props) { return () => { aborted = true; + detector.dispose(); + detectorRef.current = null; inputDisposable.dispose(); outputPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.()); @@ -160,11 +172,39 @@ export default function TerminalView({ sessionId, active }: Props) { } }, [active]); + // Auto-dismiss toast after 30 seconds + useEffect(() => { + if (!detectedUrl) return; + const timer = setTimeout(() => setDetectedUrl(null), 30_000); + return () => clearTimeout(timer); + }, [detectedUrl]); + + const handleOpenUrl = useCallback(() => { + if (detectedUrl) { + openUrl(detectedUrl).catch((e) => + console.error("Failed to open URL:", e), + ); + setDetectedUrl(null); + } + }, [detectedUrl]); + return (
+ ref={terminalContainerRef} + className={`w-full h-full relative ${active ? "" : "hidden"}`} + > + {detectedUrl && ( + setDetectedUrl(null)} + /> + )} +
+
); } diff --git a/app/src/components/terminal/UrlToast.tsx b/app/src/components/terminal/UrlToast.tsx new file mode 100644 index 0000000..3cdd979 --- /dev/null +++ b/app/src/components/terminal/UrlToast.tsx @@ -0,0 +1,101 @@ +interface Props { + url: string; + onOpen: () => void; + onDismiss: () => void; +} + +export default function UrlToast({ url, onOpen, onDismiss }: Props) { + return ( +
+
+
+ Long URL detected +
+
+ {url} +
+
+ + + + +
+ ); +} diff --git a/app/src/index.css b/app/src/index.css index 5174d88..9b8ba4f 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -46,3 +46,10 @@ body { ::-webkit-scrollbar-thumb:hover { background: var(--border-color); } + +/* Toast slide-down animation */ +@keyframes slide-down { + from { opacity: 0; transform: translate(-50%, -8px); } + to { opacity: 1; transform: translate(-50%, 0); } +} +.animate-slide-down { animation: slide-down 0.2s ease-out; } diff --git a/app/src/lib/urlDetector.ts b/app/src/lib/urlDetector.ts new file mode 100644 index 0000000..fcacc50 --- /dev/null +++ b/app/src/lib/urlDetector.ts @@ -0,0 +1,78 @@ +/** + * Detects long URLs that span multiple hard-wrapped lines in PTY output. + * + * The Linux PTY hard-wraps long lines with \r\n at the terminal column width, + * which breaks xterm.js WebLinksAddon URL detection. This class reassembles + * those wrapped URLs and fires a callback for ones >= 100 chars. + */ + +const ANSI_RE = + /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g; + +const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap +const DEBOUNCE_MS = 300; +const MIN_URL_LENGTH = 100; + +export type UrlCallback = (url: string) => void; + +export class UrlDetector { + private decoder = new TextDecoder(); + private buffer = ""; + private timer: ReturnType | null = null; + private lastEmitted = ""; + private callback: UrlCallback; + + constructor(callback: UrlCallback) { + this.callback = callback; + } + + /** Feed raw PTY output chunks. */ + feed(data: Uint8Array): void { + this.buffer += this.decoder.decode(data, { stream: true }); + + // Cap buffer to avoid unbounded growth + if (this.buffer.length > MAX_BUFFER) { + this.buffer = this.buffer.slice(-MAX_BUFFER); + } + + // Debounce — scan after 300 ms of silence + if (this.timer !== null) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + this.scan(); + }, DEBOUNCE_MS); + } + + private scan(): void { + const clean = this.buffer.replace(ANSI_RE, ""); + const lines = clean.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/https?:\/\/[^\s'"]+/); + if (!match) continue; + + // Start with the URL fragment found on this line + let url = match[0]; + + // Concatenate subsequent continuation lines (non-empty, no spaces, no leading whitespace) + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j]; + if (!next || next.startsWith(" ") || next.includes(" ")) break; + url += next; + i = j; // skip this line in the outer loop + } + + if (url.length >= MIN_URL_LENGTH && url !== this.lastEmitted) { + this.lastEmitted = url; + this.callback(url); + } + } + } + + dispose(): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } +}