Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db51abb970 | |||
| d947824436 | |||
| c2b21b794c | |||
| 40493ae284 |
@@ -72,3 +72,23 @@ pub async fn close_terminal_session(
|
|||||||
state.exec_manager.close_session(&session_id).await;
|
state.exec_manager.close_session(&session_id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn paste_image_to_terminal(
|
||||||
|
session_id: String,
|
||||||
|
image_data: Vec<u8>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
let file_name = format!("clipboard_{}.png", timestamp);
|
||||||
|
|
||||||
|
state
|
||||||
|
.exec_manager
|
||||||
|
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use bollard::container::UploadToContainerOptions;
|
||||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -212,4 +213,51 @@ impl ExecSessionManager {
|
|||||||
session.shutdown();
|
session.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
|
||||||
|
let sessions = self.sessions.lock().await;
|
||||||
|
let session = sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
|
Ok(session.container_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_file_to_container(
|
||||||
|
&self,
|
||||||
|
container_id: &str,
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Build a tar archive in memory containing the file
|
||||||
|
let mut tar_buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, file_name, data)
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: "/tmp".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(format!("/tmp/{}", file_name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ pub fn run() {
|
|||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
commands::terminal_commands::terminal_resize,
|
commands::terminal_commands::terminal_resize,
|
||||||
commands::terminal_commands::close_terminal_session,
|
commands::terminal_commands::close_terminal_session,
|
||||||
|
commands::terminal_commands::paste_image_to_terminal,
|
||||||
// Updates
|
// Updates
|
||||||
commands::update_commands::get_app_version,
|
commands::update_commands::get_app_version,
|
||||||
commands::update_commands::check_for_updates,
|
commands::update_commands::check_for_updates,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
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 { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
|
import UrlToast from "./UrlToast";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -14,10 +16,15 @@ interface Props {
|
|||||||
|
|
||||||
export default function TerminalView({ sessionId, active }: Props) {
|
export default function TerminalView({ sessionId, active }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -79,12 +86,50 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, 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
|
// Handle backend output -> terminal
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
|
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||||
|
detectorRef.current = detector;
|
||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
detector.feed(data);
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
if (aborted) unlisten();
|
if (aborted) unlisten();
|
||||||
return unlisten;
|
return unlisten;
|
||||||
@@ -116,7 +161,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
aborted = true;
|
aborted = true;
|
||||||
|
detector.dispose();
|
||||||
|
detectorRef.current = null;
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
@@ -160,11 +208,54 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [active]);
|
}, [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 (
|
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
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
className="w-full h-full"
|
||||||
style={{ padding: "8px" }}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,14 @@ export function useTerminal() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pasteImage = useCallback(
|
||||||
|
async (sessionId: string, imageData: Uint8Array) => {
|
||||||
|
const bytes = Array.from(imageData);
|
||||||
|
return commands.pasteImageToTerminal(sessionId, bytes);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const onOutput = useCallback(
|
const onOutput = useCallback(
|
||||||
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
||||||
const eventName = `terminal-output-${sessionId}`;
|
const eventName = `terminal-output-${sessionId}`;
|
||||||
@@ -76,6 +84,7 @@ export function useTerminal() {
|
|||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
sendInput,
|
sendInput,
|
||||||
|
pasteImage,
|
||||||
resize,
|
resize,
|
||||||
onOutput,
|
onOutput,
|
||||||
onExit,
|
onExit,
|
||||||
|
|||||||
@@ -46,3 +46,10 @@ body {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--border-color);
|
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; }
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
|||||||
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
||||||
export const closeTerminalSession = (sessionId: string) =>
|
export const closeTerminalSession = (sessionId: string) =>
|
||||||
invoke<void>("close_terminal_session", { sessionId });
|
invoke<void>("close_terminal_session", { sessionId });
|
||||||
|
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||||
|
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
|
|||||||
127
app/src/lib/urlDetector.ts
Normal file
127
app/src/lib/urlDetector.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* 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 flattens
|
||||||
|
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
|
||||||
|
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
|
||||||
|
*
|
||||||
|
* When a URL match extends to the end of the flattened buffer, emission is
|
||||||
|
* deferred (more chunks may still be arriving). A confirmation timer emits
|
||||||
|
* the pending URL if no further data arrives within 500 ms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
|
||||||
|
const MIN_URL_LENGTH = 100;
|
||||||
|
|
||||||
|
export type UrlCallback = (url: string) => void;
|
||||||
|
|
||||||
|
export class UrlDetector {
|
||||||
|
private decoder = new TextDecoder();
|
||||||
|
private buffer = "";
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastEmitted = "";
|
||||||
|
private pendingUrl: string | null = null;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel pending timers — new data arrived, rescan from scratch
|
||||||
|
if (this.timer !== null) clearTimeout(this.timer);
|
||||||
|
if (this.confirmTimer !== null) {
|
||||||
|
clearTimeout(this.confirmTimer);
|
||||||
|
this.confirmTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce — scan after 300 ms of silence
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
this.scan();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scan(): void {
|
||||||
|
// 1. Strip ANSI escape sequences
|
||||||
|
const clean = this.buffer.replace(ANSI_RE, "");
|
||||||
|
|
||||||
|
// 2. Flatten the buffer:
|
||||||
|
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
|
||||||
|
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
|
||||||
|
const flat = clean
|
||||||
|
.replace(/(\r?\n){2,}/g, " ")
|
||||||
|
.replace(/[\r\n]/g, "");
|
||||||
|
|
||||||
|
if (!flat) return;
|
||||||
|
|
||||||
|
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
|
||||||
|
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = urlRe.exec(flat)) !== null) {
|
||||||
|
const url = m[0];
|
||||||
|
|
||||||
|
// 4. Filter by length
|
||||||
|
if (url.length < MIN_URL_LENGTH) continue;
|
||||||
|
|
||||||
|
// 5. If the match extends to the very end of the flattened string,
|
||||||
|
// more chunks may still be arriving — defer emission.
|
||||||
|
if (m.index + url.length >= flat.length) {
|
||||||
|
this.pendingUrl = url;
|
||||||
|
this.confirmTimer = setTimeout(() => {
|
||||||
|
this.confirmTimer = null;
|
||||||
|
this.emitPending();
|
||||||
|
}, CONFIRM_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. URL is clearly complete (more content follows) — dedup + emit
|
||||||
|
this.pendingUrl = null;
|
||||||
|
if (url !== this.lastEmitted) {
|
||||||
|
this.lastEmitted = url;
|
||||||
|
this.callback(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan finished without a URL at the buffer end.
|
||||||
|
// If we had a pending URL from a previous scan, it's now confirmed complete.
|
||||||
|
if (this.pendingUrl) {
|
||||||
|
this.emitPending();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitPending(): void {
|
||||||
|
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
|
||||||
|
this.lastEmitted = this.pendingUrl;
|
||||||
|
this.callback(this.pendingUrl);
|
||||||
|
}
|
||||||
|
this.pendingUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
if (this.confirmTimer !== null) {
|
||||||
|
clearTimeout(this.confirmTimer);
|
||||||
|
this.confirmTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user