Compare commits
3 Commits
v0.1.36-wi
...
v0.1.39
| Author | SHA1 | Date | |
|---|---|---|---|
| 4721950eae | |||
| fba4b9442c | |||
| 48f0e2f64c |
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 41 KiB |
@@ -120,6 +120,9 @@ pub async fn create_container(
|
|||||||
|
|
||||||
let mut env_vars: Vec<String> = Vec::new();
|
let mut env_vars: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Tell CLI tools the terminal supports 24-bit RGB color
|
||||||
|
env_vars.push("COLORTERM=truecolor".to_string());
|
||||||
|
|
||||||
// Pass host UID/GID so the entrypoint can remap the container user
|
// Pass host UID/GID so the entrypoint can remap the container user
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -392,6 +395,10 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
|
|||||||
|
|
||||||
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
log::info!(
|
||||||
|
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||||
|
container_id
|
||||||
|
);
|
||||||
docker
|
docker
|
||||||
.remove_container(
|
.remove_container(
|
||||||
container_id,
|
container_id,
|
||||||
|
|||||||
@@ -269,8 +269,6 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{project.paths.map((pp, i) => (
|
{project.paths.map((pp, i) => (
|
||||||
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
||||||
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
||||||
<span className="mx-1">←</span>
|
|
||||||
<span>{pp.host_path}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ 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";
|
||||||
|
|
||||||
/** Strip ANSI escape sequences from a string. */
|
|
||||||
function stripAnsi(s: string): string {
|
|
||||||
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -21,6 +16,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = 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 { sendInput, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,13 +64,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
term.open(containerRef.current);
|
term.open(containerRef.current);
|
||||||
|
|
||||||
// Try WebGL renderer, fall back silently
|
// WebGL addon is loaded/disposed dynamically in the active effect
|
||||||
try {
|
// to avoid exhausting the browser's limited WebGL context pool.
|
||||||
const webglAddon = new WebglAddon();
|
|
||||||
term.loadAddon(webglAddon);
|
|
||||||
} catch {
|
|
||||||
// WebGL not available, canvas renderer is fine
|
|
||||||
}
|
|
||||||
|
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
@@ -88,50 +79,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── URL accumulator ──────────────────────────────────────────────
|
|
||||||
// Claude Code login emits a long OAuth URL that gets split across
|
|
||||||
// hard newlines (\n / \r\n). The WebLinksAddon only joins
|
|
||||||
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
|
|
||||||
// truncated and the link fails when clicked.
|
|
||||||
//
|
|
||||||
// Fix: buffer recent output, strip ANSI codes, and after a short
|
|
||||||
// debounce check for a URL that spans multiple lines. When found,
|
|
||||||
// write a single clean clickable copy to the terminal.
|
|
||||||
const textDecoder = new TextDecoder();
|
|
||||||
let outputBuffer = "";
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const flushUrlBuffer = () => {
|
|
||||||
const plain = stripAnsi(outputBuffer);
|
|
||||||
// Reassemble: strip hard newlines and carriage returns to join
|
|
||||||
// fragments that were split across terminal lines.
|
|
||||||
const joined = plain.replace(/[\r\n]+/g, "");
|
|
||||||
// Look for a long OAuth/auth URL (Claude login URLs contain
|
|
||||||
// "oauth" or "console.anthropic.com" or "/authorize").
|
|
||||||
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
|
|
||||||
if (match) {
|
|
||||||
const url = match[0];
|
|
||||||
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
|
|
||||||
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
|
|
||||||
}
|
|
||||||
outputBuffer = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
|
||||||
// Accumulate for URL detection (data is a Uint8Array, so decode it)
|
|
||||||
outputBuffer += textDecoder.decode(data);
|
|
||||||
// Cap buffer size to avoid memory growth
|
|
||||||
if (outputBuffer.length > 8192) {
|
|
||||||
outputBuffer = outputBuffer.slice(-4096);
|
|
||||||
}
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(flushUrlBuffer, 150);
|
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
if (aborted) unlisten();
|
if (aborted) unlisten();
|
||||||
return unlisten;
|
return unlisten;
|
||||||
@@ -145,12 +98,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return unlisten;
|
return unlisten;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls)
|
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls).
|
||||||
|
// Skip resize work for hidden terminals — containerRef will have 0 dimensions.
|
||||||
let resizeRafId: number | null = null;
|
let resizeRafId: number | null = null;
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (resizeRafId !== null) return;
|
if (resizeRafId !== null) return;
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
||||||
resizeRafId = requestAnimationFrame(() => {
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
resizeRafId = null;
|
resizeRafId = null;
|
||||||
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
});
|
});
|
||||||
@@ -159,21 +116,47 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
aborted = true;
|
aborted = true;
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
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);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
|
webglRef.current = null;
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Re-fit when tab becomes active
|
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
||||||
|
// Only the active terminal holds a WebGL context to avoid exhausting
|
||||||
|
// the browser's limited pool (~8-16 contexts).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active && fitRef.current && termRef.current) {
|
const term = termRef.current;
|
||||||
fitRef.current.fit();
|
if (!term) return;
|
||||||
termRef.current.focus();
|
|
||||||
|
if (active) {
|
||||||
|
// Attach WebGL renderer
|
||||||
|
if (!webglRef.current) {
|
||||||
|
try {
|
||||||
|
const addon = new WebglAddon();
|
||||||
|
addon.onContextLoss(() => {
|
||||||
|
try { addon.dispose(); } catch { /* ignore */ }
|
||||||
|
webglRef.current = null;
|
||||||
|
});
|
||||||
|
term.loadAddon(addon);
|
||||||
|
webglRef.current = addon;
|
||||||
|
} catch {
|
||||||
|
// WebGL not available, canvas renderer is fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fitRef.current?.fit();
|
||||||
|
term.focus();
|
||||||
|
} else {
|
||||||
|
// Release WebGL context for inactive terminals
|
||||||
|
if (webglRef.current) {
|
||||||
|
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||||
|
webglRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
|
|||||||