Add toast notification for wrapped long URLs in terminal
PTY hard-wraps long URLs (e.g. OAuth) with \r\n at column width, breaking xterm.js link detection. This adds a UrlDetector that reassembles wrapped URLs from the output stream and shows a non-intrusive floating toast with an "Open" button. Auto-dismisses after 30s, no terminal layout impact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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, resize, onOutput, onExit } = useTerminal();
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
||||
style={{ padding: "8px" }}
|
||||
/>
|
||||
ref={terminalContainerRef}
|
||||
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||||
>
|
||||
{detectedUrl && (
|
||||
<UrlToast
|
||||
url={detectedUrl}
|
||||
onOpen={handleOpenUrl}
|
||||
onDismiss={() => setDetectedUrl(null)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
style={{ padding: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
101
app/src/components/terminal/UrlToast.tsx
Normal file
101
app/src/components/terminal/UrlToast.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
interface Props {
|
||||
url: string;
|
||||
onOpen: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="animate-slide-down"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||
maxWidth: "min(90%, 600px)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Long URL detected
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
color: "var(--text-primary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background = "var(--accent-hover)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = "var(--accent)")
|
||||
}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
color: "var(--text-secondary)",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--text-primary)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = "var(--text-secondary)")
|
||||
}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user