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;
|
||||
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 futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
@@ -212,4 +213,51 @@ impl ExecSessionManager {
|
||||
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_resize,
|
||||
commands::terminal_commands::close_terminal_session,
|
||||
commands::terminal_commands::paste_image_to_terminal,
|
||||
// Updates
|
||||
commands::update_commands::get_app_version,
|
||||
commands::update_commands::check_for_updates,
|
||||
|
||||
@@ -21,9 +21,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
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 { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -85,6 +86,40 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
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
|
||||
let aborted = false;
|
||||
|
||||
@@ -129,6 +164,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
detector.dispose();
|
||||
detectorRef.current = null;
|
||||
inputDisposable.dispose();
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||
@@ -179,6 +215,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
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) =>
|
||||
@@ -200,6 +243,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
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
|
||||
ref={containerRef}
|
||||
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(
|
||||
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
||||
const eventName = `terminal-output-${sessionId}`;
|
||||
@@ -76,6 +84,7 @@ export function useTerminal() {
|
||||
open,
|
||||
close,
|
||||
sendInput,
|
||||
pasteImage,
|
||||
resize,
|
||||
onOutput,
|
||||
onExit,
|
||||
|
||||
@@ -47,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
||||
export const closeTerminalSession = (sessionId: string) =>
|
||||
invoke<void>("close_terminal_session", { sessionId });
|
||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||
|
||||
// Updates
|
||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Two-phase approach: when a URL candidate extends to the end of the buffer,
|
||||
* emission is deferred (the rest of the URL may arrive in the next PTY chunk).
|
||||
* A confirmation timer emits the pending URL if no further data arrives.
|
||||
* 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 =
|
||||
@@ -57,38 +58,31 @@ export class UrlDetector {
|
||||
}
|
||||
|
||||
private scan(): void {
|
||||
// 1. Strip ANSI escape sequences
|
||||
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)
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
// 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 (lines.length === 0) return;
|
||||
if (!flat) return;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/https?:\/\/[^\s'"]+/);
|
||||
if (!match) continue;
|
||||
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
|
||||
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
// Start with the URL fragment found on this line
|
||||
let url = match[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
|
||||
}
|
||||
while ((m = urlRe.exec(flat)) !== null) {
|
||||
const url = m[0];
|
||||
|
||||
// 4. Filter by length
|
||||
if (url.length < MIN_URL_LENGTH) continue;
|
||||
|
||||
// If the URL reaches the last line of the buffer, the rest may still
|
||||
// be arriving in the next PTY chunk — defer emission.
|
||||
if (lastLineIndex >= lines.length - 1) {
|
||||
// 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;
|
||||
@@ -97,7 +91,7 @@ export class UrlDetector {
|
||||
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;
|
||||
if (url !== this.lastEmitted) {
|
||||
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 (this.pendingUrl) {
|
||||
this.emitPending();
|
||||
|
||||
Reference in New Issue
Block a user