Compare commits
2 Commits
v0.1.52-wi
...
v0.1.54-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| db51abb970 | |||
| d947824436 |
@@ -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,
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
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 detectorRef = useRef<UrlDetector | null>(null);
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
|
|
||||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -85,6 +86,40 @@ 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;
|
||||||
|
|
||||||
@@ -129,6 +164,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
detector.dispose();
|
detector.dispose();
|
||||||
detectorRef.current = null;
|
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);
|
||||||
@@ -179,6 +215,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [detectedUrl]);
|
}, [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(() => {
|
const handleOpenUrl = useCallback(() => {
|
||||||
if (detectedUrl) {
|
if (detectedUrl) {
|
||||||
openUrl(detectedUrl).catch((e) =>
|
openUrl(detectedUrl).catch((e) =>
|
||||||
@@ -200,6 +243,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
onDismiss={() => setDetectedUrl(null)}
|
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"
|
className="w-full h-full"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
|
* 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,
|
* 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
|
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
|
||||||
* those wrapped URLs and fires a callback for ones >= 100 chars.
|
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
|
||||||
|
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
|
||||||
*
|
*
|
||||||
* Two-phase approach: when a URL candidate extends to the end of the buffer,
|
* When a URL match extends to the end of the flattened buffer, emission is
|
||||||
* emission is deferred (the rest of the URL may arrive in the next PTY chunk).
|
* deferred (more chunks may still be arriving). A confirmation timer emits
|
||||||
* A confirmation timer emits the pending URL if no further data arrives.
|
* the pending URL if no further data arrives within 500 ms.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ANSI_RE =
|
const ANSI_RE =
|
||||||
@@ -57,38 +58,31 @@ export class UrlDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private scan(): void {
|
private scan(): void {
|
||||||
|
// 1. Strip ANSI escape sequences
|
||||||
const clean = this.buffer.replace(ANSI_RE, "");
|
const clean = this.buffer.replace(ANSI_RE, "");
|
||||||
const lines = clean.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
||||||
|
|
||||||
// Remove trailing empty elements (artifacts of trailing \n from split)
|
// 2. Flatten the buffer:
|
||||||
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
|
||||||
lines.pop();
|
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
|
||||||
}
|
const flat = clean
|
||||||
|
.replace(/(\r?\n){2,}/g, " ")
|
||||||
|
.replace(/[\r\n]/g, "");
|
||||||
|
|
||||||
if (lines.length === 0) return;
|
if (!flat) return;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
|
||||||
const match = lines[i].match(/https?:\/\/[^\s'"]+/);
|
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
|
||||||
if (!match) continue;
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
// Start with the URL fragment found on this line
|
while ((m = urlRe.exec(flat)) !== null) {
|
||||||
let url = match[0];
|
const url = m[0];
|
||||||
let lastLineIndex = i;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
lastLineIndex = j;
|
|
||||||
i = j; // skip this line in the outer loop
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 4. Filter by length
|
||||||
if (url.length < MIN_URL_LENGTH) continue;
|
if (url.length < MIN_URL_LENGTH) continue;
|
||||||
|
|
||||||
// If the URL reaches the last line of the buffer, the rest may still
|
// 5. If the match extends to the very end of the flattened string,
|
||||||
// be arriving in the next PTY chunk — defer emission.
|
// more chunks may still be arriving — defer emission.
|
||||||
if (lastLineIndex >= lines.length - 1) {
|
if (m.index + url.length >= flat.length) {
|
||||||
this.pendingUrl = url;
|
this.pendingUrl = url;
|
||||||
this.confirmTimer = setTimeout(() => {
|
this.confirmTimer = setTimeout(() => {
|
||||||
this.confirmTimer = null;
|
this.confirmTimer = null;
|
||||||
@@ -97,7 +91,7 @@ export class UrlDetector {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL is clearly complete (more content follows it in the buffer)
|
// 6. URL is clearly complete (more content follows) — dedup + emit
|
||||||
this.pendingUrl = null;
|
this.pendingUrl = null;
|
||||||
if (url !== this.lastEmitted) {
|
if (url !== this.lastEmitted) {
|
||||||
this.lastEmitted = url;
|
this.lastEmitted = url;
|
||||||
@@ -105,7 +99,7 @@ export class UrlDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan finished without finding a URL reaching the buffer end.
|
// 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 we had a pending URL from a previous scan, it's now confirmed complete.
|
||||||
if (this.pendingUrl) {
|
if (this.pendingUrl) {
|
||||||
this.emitPending();
|
this.emitPending();
|
||||||
|
|||||||
Reference in New Issue
Block a user