src/provider/terminal-provider.jsx
Copy Code
import React from "react";
import { Channel } from "@tauri-apps/api/core";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { useSecurity } from "@/provider/security-provider";
import { yieldToUi } from "@/lib/async";
import { invoke } from "@/lib/tauri";
import {
appendSessionLog,
createSessionRecord,
finalizeSessionRecord,
getSessionLog,
updateSessionRecord,
} from "@/lib/session-history";
import { TerminalContext } from "../context/terminal-context";
const STORAGE_KEY = "ghost-shell-terminal-sessions";
function getXtermTheme() {
const isDark = document.documentElement.classList.contains("dark");
return {
background: isDark ? "#141414" : "#ffffff",
foreground: isDark ? "#e5e5e5" : "#171717",
cursor: "#22c55e",
selectionBackground: isDark ? "#264f3a" : "#bbf7d0",
selectionInactiveBackground: isDark ? "#264f3a88" : "#bbf7d088",
};
}
function writeStatusLine(term, stage, message) {
term.writeln(`\r\x1b[90m[${stage}] ${message}\x1b[0m`);
}
function captureScrollback(term) {
const buffer = term.buffer.active;
const lines = [];
for (let i = 0; i < buffer.length; i++) {
lines.push(buffer.getLine(i)?.translateToString(true) ?? "");
}
return lines.join("\n");
}
function createWriteFlusher(sessionId, runtimesRef, onUserInput) {
let pending = "";
let scheduled = false;
const flush = () => {
scheduled = false;
if (!pending) return;
const chunk = pending;
pending = "";
onUserInput?.(chunk);
const runtime = runtimesRef.current?.get(sessionId);
if (runtime) {
if (!runtime.typedBuffer) {
runtime.typedBuffer = "";
}
for (let i = 0; i < chunk.length; i++) {
const char = chunk[i];
if (char === "\r" || char === "\n") {
const cmd = runtime.typedBuffer.trim().toLowerCase();
if (cmd === "exit" || cmd === "logout") {
runtime.userExited = true;
}
runtime.typedBuffer = "";
} else if (char === "\x7f" || char === "\x08") {
runtime.typedBuffer = runtime.typedBuffer.slice(0, -1);
} else if (char === "\x04") {
runtime.userExited = true;
} else if (char.match(/^[a-zA-Z0-9_-]$/)) {
runtime.typedBuffer += char;
}
}
}
invoke("ssh_write", {
sessionId,
data: new TextEncoder().encode(chunk),
}).catch(() => {});
};
return (data) => {
pending += data;
if (!scheduled) {
scheduled = true;
queueMicrotask(flush);
}
};
}
function createTerminalInstance(sessionId, runtimesRef, onUserInput) {
const term = new Terminal({
scrollback: 10000,
convertEol: true,
cursorBlink: true,
fontSize: 13,
fontFamily: "Geist Mono, ui-monospace, Menlo, monospace",
theme: getXtermTheme(),
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
const search = new SearchAddon();
term.loadAddon(search);
term._searchAddon = search;
term.onData(createWriteFlusher(sessionId, runtimesRef, onUserInput));
let resizeTimer;
term.onResize(({ cols, rows }) => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
invoke("ssh_resize", { sessionId, cols, rows }).catch(() => {});
}, 100);
});
return { term, fit };
}
function loadPersistedState() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function savePersistedState(
sessions,
activeId,
runtimesRef,
{ captureBuffers = false } = {},
) {
let buffers = {};
if (captureBuffers) {
for (const session of sessions) {
const runtime = runtimesRef.current.get(session.id);
if (runtime?.term) {
buffers[session.id] = captureScrollback(runtime.term);
}
}
} else {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
buffers = JSON.parse(raw).buffers ?? {};
}
} catch {
buffers = {};
}
}
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({
sessions: sessions.map((s) => ({
id: s.id,
title: s.title,
status: s.status,
stage: s.stage,
stageMessage: s.stageMessage,
wasConnected: s.status === "connected",
host: {
id: s.host.id,
name: s.host.name,
address: s.host.address,
port: s.host.port,
username: s.host.username,
key_id: s.host.key_id ?? null,
},
})),
activeId,
buffers,
}),
);
}
function clearPersistedState() {
sessionStorage.removeItem(STORAGE_KEY);
}
export function TerminalProvider({ children }) {
const { unlocked } = useSecurity();
const [sessions, setSessions] = React.useState([]);
const [activeId, setActiveId] = React.useState(null);
const [authPrompt, setAuthPrompt] = React.useState(null);
const runtimesRef = React.useRef(new Map());
const connectQueueRef = React.useRef(new Set());
const restoredRef = React.useRef(false);
const persistTimerRef = React.useRef(null);
const logFlushTimersRef = React.useRef(new Map());
const hadSessionsRef = React.useRef(false);
const sessionsRef = React.useRef(sessions);
const activeIdRef = React.useRef(activeId);
React.useEffect(() => {
sessionsRef.current = sessions;
activeIdRef.current = activeId;
if (sessions.length > 0) hadSessionsRef.current = true;
}, [sessions, activeId]);
const flushRuntimeLog = React.useCallback((sessionId) => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime?.pendingLog) return;
appendSessionLog(sessionId, runtime.pendingLog);
runtime.pendingLog = "";
clearTimeout(logFlushTimersRef.current.get(sessionId));
logFlushTimersRef.current.delete(sessionId);
}, []);
const appendRuntimeLog = React.useCallback(
(sessionId, chunk) => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime || !chunk) return;
runtime.pendingLog = (runtime.pendingLog ?? "") + chunk;
if (logFlushTimersRef.current.has(sessionId)) return;
logFlushTimersRef.current.set(
sessionId,
setTimeout(() => flushRuntimeLog(sessionId), 2000),
);
},
[flushRuntimeLog],
);
const persistNow = React.useCallback((captureBuffers = false) => {
const currentSessions = sessionsRef.current;
if (currentSessions.length === 0) return;
for (const session of currentSessions) {
flushRuntimeLog(session.id);
}
savePersistedState(currentSessions, activeIdRef.current, runtimesRef, {
captureBuffers,
});
}, [flushRuntimeLog]);
const schedulePersist = React.useCallback(
(captureBuffers = false) => {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = setTimeout(
() => persistNow(captureBuffers),
captureBuffers ? 0 : 3000,
);
},
[persistNow],
);
const closeSession = React.useCallback(
(id) => {
flushRuntimeLog(id);
const runtime = runtimesRef.current.get(id);
if (runtime) {
runtime.term.dispose();
runtimesRef.current.delete(id);
}
connectQueueRef.current.delete(id);
finalizeSessionRecord(id, "closed");
invoke("ssh_disconnect", { sessionId: id }).catch(() => {});
const filteredSessions = sessionsRef.current.filter((s) => s.id !== id);
const nextActiveId = activeIdRef.current === id
? (filteredSessions.length ? filteredSessions[filteredSessions.length - 1].id : null)
: activeIdRef.current;
// Persist the filtered sessions list immediately!
if (filteredSessions.length === 0) {
clearPersistedState();
} else {
savePersistedState(filteredSessions, nextActiveId, runtimesRef, {
captureBuffers: true,
});
}
setSessions(filteredSessions);
setActiveId(nextActiveId);
setAuthPrompt((prev) => (prev?.sessionId === id ? null : prev));
},
[flushRuntimeLog],
);
const updateSession = React.useCallback((id, patch) => {
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
);
if (patch.status) {
updateSessionRecord(id, { status: patch.status });
}
}, []);
const refreshTerminal = React.useCallback((sessionId) => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime?.term || !runtime?.fit) return;
const mountEl = runtime.mountEl;
if (!mountEl || !mountEl.isConnected) return;
try {
runtime.fit.fit();
runtime.term.refresh(0, Math.max(runtime.term.rows - 1, 0));
} catch {
/* ignore fit errors during layout */
}
}, []);
const connectSession = React.useCallback(
async (sessionId, secrets = {}) => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime) return;
updateSession(sessionId, {
status: "connecting",
stage: "init",
stageMessage: "Starting connection...",
authType: null,
});
if (secrets && Object.keys(secrets).length > 0) {
runtime.credentials = { ...runtime.credentials, ...secrets };
}
runtime.userExited = false;
if (runtime.channel) {
runtime.channel.onmessage = null;
}
const channel = new Channel();
runtime.channel = channel;
channel.onmessage = (event) => {
switch (event.type) {
case "status": {
const line = `\r[${event.stage}] ${event.message}\r\n`;
updateSession(sessionId, {
stage: event.stage,
stageMessage: event.message,
});
writeStatusLine(runtime.term, event.stage, event.message);
appendRuntimeLog(sessionId, line);
break;
}
case "connected":
runtime.reconnectCount = 0;
updateSession(sessionId, {
status: "connected",
stage: "connected",
stageMessage: "Connected",
authType: null,
});
appendRuntimeLog(sessionId, "\r\n── Connected ──\r\n");
if (runtime.pendingCredentialSave) {
const { type, value, hostId, keyId } = runtime.pendingCredentialSave;
delete runtime.pendingCredentialSave;
if (type === "password") {
invoke("save_host_password", { hostId, password: value }).catch(
() => {},
);
runtime.host = { ...runtime.host, password: value };
} else if (keyId) {
invoke("save_key_passphrase", {
keyId,
passphrase: value,
}).catch(() => {});
}
}
break;
case "data": {
const bytes = new Uint8Array(event.bytes);
runtime.term.write(bytes);
appendRuntimeLog(
sessionId,
new TextDecoder().decode(bytes, { stream: true }),
);
break;
}
case "closed": {
handleDisconnect(sessionId, event.message || "Connection closed");
break;
}
case "error": {
handleDisconnect(sessionId, event.message || "Connection error");
break;
}
case "needPassword":
updateSession(sessionId, {
status: "auth-required",
authType: "password",
});
setAuthPrompt({ sessionId, type: "password" });
break;
case "needPassphrase":
updateSession(sessionId, {
status: "auth-required",
authType: "passphrase",
});
setAuthPrompt({ sessionId, type: "passphrase" });
break;
default:
break;
}
};
await yieldToUi();
try {
refreshTerminal(sessionId);
const connectionSecrets = {
...runtime.credentials,
...secrets
};
await invoke("ssh_connect", {
sessionId,
hostId: runtime.host.id,
cols: runtime.term.cols,
rows: runtime.term.rows,
password: connectionSecrets.password ?? null,
passphrase: connectionSecrets.passphrase ?? null,
onEvent: channel,
});
} catch (err) {
const message = String(err);
if (!message.includes("Couldn't find callback id")) {
updateSession(sessionId, {
status: "error",
stageMessage: message,
});
runtime.term.writeln(`\r\n\x1b[31mError: ${message}\x1b[0m`);
appendRuntimeLog(sessionId, `\r\nError: ${message}\r\n`);
}
}
},
[updateSession, refreshTerminal, appendRuntimeLog, flushRuntimeLog, closeSession],
);
const attemptReconnect = React.useCallback(
async (sessionId) => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime) return;
const exists = sessionsRef.current.some((s) => s.id === sessionId);
if (!exists) return;
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (session && (session.status === "connecting" || session.status === "connected")) {
return;
}
if (!runtime.reconnectCount) {
runtime.reconnectCount = 0;
}
runtime.reconnectCount++;
if (runtime.reconnectCount > 5) {
updateSession(sessionId, {
status: "error",
stageMessage: "Reconnection failed after 5 attempts.",
});
runtime.term.writeln(`\r\n\x1b[31m[Reconnection failed after 5 attempts. Focus tab to try again.]\x1b[0m\r\n`);
runtime.reconnectCount = 0;
return;
}
const delay = Math.min(1000 * Math.pow(2, runtime.reconnectCount - 1), 10000);
setTimeout(async () => {
const stillExists = sessionsRef.current.some((s) => s.id === sessionId);
if (!stillExists) return;
const currentSession = sessionsRef.current.find((s) => s.id === sessionId);
if (currentSession && (currentSession.status === "connecting" || currentSession.status === "connected")) {
return;
}
runtime.term.writeln(`\r\x1b[90m── Reconnecting (Attempt ${runtime.reconnectCount}/5) ──\x1b[0m`);
try {
await connectSession(sessionId);
} catch {
// connectSession catches internally
}
}, delay);
},
[connectSession, updateSession],
);
const handleDisconnect = React.useCallback(
(sessionId, reason) => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime) return;
if (
runtime.userExited ||
(reason &&
(reason.includes("Process exited") ||
reason.includes("exit") ||
reason.includes("Closed by application") ||
reason.includes("Disconnected by application")))
) {
closeSession(sessionId);
return;
}
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (session && session.status === "disconnected") {
return;
}
updateSession(sessionId, {
status: "disconnected",
stageMessage: `Disconnected: ${reason}`,
});
runtime.term.writeln(`\r\n\x1b[33m[Connection lost: ${reason}]\x1b[0m`);
runtime.term.writeln(`\x1b[36m[Attempting to reconnect...]\x1b[0m\r\n`);
attemptReconnect(sessionId);
},
[closeSession, updateSession, attemptReconnect],
);
const checkConnectionAlive = React.useCallback(
async (sessionId) => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime) return;
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (!session) return;
if (session.status === "disconnected" || session.status === "error") {
runtime.reconnectCount = 0;
runtime.term.writeln(`\r\n\x1b[36m[Focus detected: reconnecting...]\x1b[0m\r\n`);
attemptReconnect(sessionId);
return;
}
if (session.status !== "connected") return;
try {
const isAlive = await invoke("ssh_is_alive", { sessionId });
if (!isAlive) {
handleDisconnect(sessionId, "Connection timeout / closed");
}
} catch {
handleDisconnect(sessionId, "Connection check failed");
}
},
[handleDisconnect, attemptReconnect],
);
const attachTerminal = React.useCallback(
(sessionId, element) => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime || !element) return;
const detached =
runtime.opened &&
runtime.mountEl &&
(!runtime.term.element?.isConnected ||
runtime.term.element.parentElement !== element);
if (detached) {
runtime.savedScrollback = captureScrollback(runtime.term);
runtime.term.dispose();
const { term, fit } = createTerminalInstance(sessionId, runtimesRef, (data) =>
appendRuntimeLog(sessionId, data),
);
runtime.term = term;
runtime.fit = fit;
runtime.opened = false;
}
runtime.mountEl = element;
if (!runtime.opened) {
runtime.term.open(element);
runtime.opened = true;
const scrollback =
runtime.savedScrollback || getSessionLog(sessionId)?.log || "";
if (scrollback) {
runtime.term.write(scrollback);
runtime.savedScrollback = null;
}
if (connectQueueRef.current.has(sessionId)) {
connectQueueRef.current.delete(sessionId);
connectSession(sessionId);
}
}
refreshTerminal(sessionId);
if (sessionId === activeId) {
runtime.term.focus();
}
},
[connectSession, activeId, refreshTerminal, appendRuntimeLog],
);
const registerRuntime = React.useCallback(
(id, host, meta, scrollback = "", append = true, createHistory = true) => {
const { term, fit } = createTerminalInstance(id, runtimesRef, (data) =>
appendRuntimeLog(id, data),
);
term.attachCustomKeyEventHandler((e) => {
const isMac = navigator.userAgent.toLowerCase().includes("mac");
const isMod = isMac ? e.metaKey : e.ctrlKey;
const isW = e.key === "w" || e.key === "W" || e.keyCode === 87;
const isC = e.key === "c" || e.key === "C" || e.keyCode === 67;
const isV = e.key === "v" || e.key === "V" || e.keyCode === 86;
const isF = e.key === "f" || e.key === "F" || e.keyCode === 70;
if (e.type === "keydown") {
// --- macOS Shell Key Bindings (Cmd + Backspace, Cmd + Arrows, Option + Arrows) ---
if (isMac) {
// Cmd + Backspace: remove complete word (Ctrl+W)
if (e.metaKey && (e.key === "Backspace" || e.keyCode === 8)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x17"), // Send Ctrl+W
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
// Option + Backspace: remove complete word (Ctrl+W)
if (e.altKey && (e.key === "Backspace" || e.keyCode === 8)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x17"), // Send Ctrl+W
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
// Cmd + Left Arrow: move cursor to beginning of line (Ctrl+A)
if (e.metaKey && (e.key === "ArrowLeft" || e.keyCode === 37)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x01"), // Send Ctrl+A
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
// Cmd + Right Arrow: move cursor to end of line (Ctrl+E)
if (e.metaKey && (e.key === "ArrowRight" || e.keyCode === 39)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x05"), // Send Ctrl+E
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
// Option + Left Arrow: move cursor one word backward (ESC + b)
if (e.altKey && (e.key === "ArrowLeft" || e.keyCode === 37)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x1bb"), // Send ESC + b
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
// Option + Right Arrow: move cursor one word forward (ESC + f)
if (e.altKey && (e.key === "ArrowRight" || e.keyCode === 39)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x1bf"), // Send ESC + f
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
} else {
// Windows/Linux equivalents
// Ctrl + Backspace: remove complete word (Ctrl+W)
if (e.ctrlKey && (e.key === "Backspace" || e.keyCode === 8)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x17"), // Send Ctrl+W
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
// Ctrl + Left Arrow: move cursor one word backward (ESC + b)
if (e.ctrlKey && (e.key === "ArrowLeft" || e.keyCode === 37)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x1bb"), // Send ESC + b
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
// Ctrl + Right Arrow: move cursor one word forward (ESC + f)
if (e.ctrlKey && (e.key === "ArrowRight" || e.keyCode === 39)) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode("\x1bf"), // Send ESC + f
}).catch(() => {});
e.preventDefault();
e.stopPropagation();
return false;
}
}
// Ctrl+W or Cmd+W (Close Tab)
if ((e.ctrlKey || e.metaKey) && isW) {
closeSession(id);
e.preventDefault();
e.stopPropagation();
return false;
}
// Copy: Cmd+C (Mac) or Ctrl+C (Windows/Linux if selection exists)
if (isMod && isC) {
if (isMac || term.hasSelection()) {
if (term.hasSelection()) {
navigator.clipboard.writeText(term.getSelection());
}
e.preventDefault();
e.stopPropagation();
return false;
}
}
// Paste: Cmd+V (Mac) or Ctrl+V (Windows/Linux)
if (isMod && isV) {
navigator.clipboard.readText().then((text) => {
if (text) {
invoke("ssh_write", {
sessionId: id,
data: new TextEncoder().encode(text),
}).catch(() => {});
}
});
e.preventDefault();
e.stopPropagation();
return false;
}
// Find: Cmd+F (Mac) or Ctrl+F (Windows/Linux)
if (isMod && isF) {
window.dispatchEvent(
new CustomEvent("toggle-terminal-search", {
detail: { sessionId: id },
}),
);
e.preventDefault();
e.stopPropagation();
return false;
}
}
return true;
});
runtimesRef.current.set(id, {
term,
fit,
host,
channel: null,
opened: false,
mountEl: null,
savedScrollback: scrollback,
pendingLog: "",
});
if (createHistory) {
createSessionRecord({ id, host });
}
const session = {
id,
host,
title: meta.title ?? host.name,
status: meta.status ?? "disconnected",
stage: meta.stage ?? "restored",
stageMessage:
meta.stageMessage ?? "Session restored — reconnect to continue",
authType: null,
};
if (append) {
setSessions((prev) => [...prev, session]);
}
return session;
},
[appendRuntimeLog, closeSession],
);
const openSession = React.useCallback(
(host) => {
const id = `session-${host.id}-${Date.now()}`;
registerRuntime(id, host, {
title: host.name,
status: "connecting",
stage: "init",
stageMessage: "Waiting for terminal...",
});
setActiveId(id);
connectQueueRef.current.add(id);
return id;
},
[registerRuntime],
);
const setActive = React.useCallback(
(id) => {
setActiveId(id);
if (!id) return;
requestAnimationFrame(() => {
refreshTerminal(id);
requestAnimationFrame(() => {
refreshTerminal(id);
runtimesRef.current.get(id)?.term.focus();
checkConnectionAlive(id);
});
});
},
[refreshTerminal, checkConnectionAlive],
);
const reconnect = React.useCallback(
(id) => {
const runtime = runtimesRef.current.get(id);
if (!runtime) return;
const line = "\r── Reconnecting ──\r\n";
runtime.term.writeln("\r\x1b[90m── Reconnecting ──\x1b[0m");
appendRuntimeLog(id, line);
setAuthPrompt((prev) => (prev?.sessionId === id ? null : prev));
connectSession(id);
},
[connectSession, appendRuntimeLog],
);
const disposeRuntimes = React.useCallback(() => {
clearTimeout(persistTimerRef.current);
const currentSessions = sessionsRef.current;
const currentActiveId = activeIdRef.current;
if (currentSessions.length > 0) {
for (const session of currentSessions) {
flushRuntimeLog(session.id);
}
savePersistedState(currentSessions, currentActiveId, runtimesRef, {
captureBuffers: true,
});
}
for (const id of [...runtimesRef.current.keys()]) {
invoke("ssh_disconnect", { sessionId: id }).catch(() => {});
runtimesRef.current.get(id)?.term.dispose();
}
runtimesRef.current.clear();
connectQueueRef.current.clear();
setSessions((prev) => (prev.length === 0 ? prev : []));
setActiveId((prev) => (prev === null ? prev : null));
setAuthPrompt((prev) => (prev === null ? prev : null));
restoredRef.current = false;
}, [flushRuntimeLog]);
const closeAll = React.useCallback(() => {
for (const session of sessionsRef.current) {
flushRuntimeLog(session.id);
finalizeSessionRecord(session.id, "closed");
}
disposeRuntimes();
clearPersistedState();
}, [disposeRuntimes, flushRuntimeLog]);
const failSession = React.useCallback(
(sessionId, message) => {
invoke("ssh_disconnect", { sessionId }).catch(() => {});
const runtime = runtimesRef.current.get(sessionId);
updateSession(sessionId, {
status: "error",
stageMessage: message,
authType: null,
});
if (runtime?.term) {
runtime.term.writeln(`\r\n\x1b[31m${message}\x1b[0m`);
}
appendRuntimeLog(sessionId, `\r\n${message}\r\n`);
flushRuntimeLog(sessionId);
},
[updateSession, appendRuntimeLog, flushRuntimeLog],
);
const cancelAuth = React.useCallback(() => {
if (!authPrompt) return;
const { sessionId } = authPrompt;
setAuthPrompt(null);
failSession(sessionId, "Connection canceled by user");
}, [authPrompt, failSession]);
const submitAuth = React.useCallback(
async (value, savePassphrase = false) => {
if (!authPrompt) return;
const { sessionId, type } = authPrompt;
if (!value?.trim()) {
failSession(sessionId, "Connection error: credentials are required");
setAuthPrompt(null);
return;
}
setAuthPrompt(null);
if (savePassphrase) {
const runtime = runtimesRef.current.get(sessionId);
if (runtime) {
runtime.pendingCredentialSave = {
type,
value,
hostId: runtime.host.id,
keyId: runtime.host.key_id ?? null,
};
}
}
await connectSession(
sessionId,
type === "password" ? { password: value } : { passphrase: value },
);
},
[authPrompt, connectSession, failSession],
);
React.useEffect(() => {
if (!unlocked) {
disposeRuntimes();
return;
}
if (restoredRef.current || runtimesRef.current.size > 0) return;
const saved = loadPersistedState();
if (!saved?.sessions?.length) return;
const restoredSessions = saved.sessions.map((s) => {
const scrollback =
saved.buffers?.[s.id] ?? getSessionLog(s.id)?.log ?? "";
registerRuntime(
s.id,
s.host,
{
title: s.title,
status: "disconnected",
stage: "restored",
stageMessage: "Restoring session...",
},
scrollback,
false,
false,
);
if (s.wasConnected) {
connectQueueRef.current.add(s.id);
}
return {
id: s.id,
host: s.host,
title: s.title,
status: s.wasConnected ? "connecting" : "disconnected",
stage: "restored",
stageMessage: s.wasConnected
? "Reconnecting..."
: "Session restored — click Reconnect to continue",
authType: null,
};
});
setSessions(restoredSessions);
restoredRef.current = true;
if (saved.activeId) {
setActiveId(saved.activeId);
}
}, [unlocked, disposeRuntimes, registerRuntime]);
React.useEffect(() => {
if (!unlocked) return;
if (sessions.length === 0) {
if (hadSessionsRef.current) {
clearPersistedState();
hadSessionsRef.current = false;
}
return;
}
schedulePersist(false);
return () => clearTimeout(persistTimerRef.current);
}, [sessions, activeId, unlocked, schedulePersist]);
React.useEffect(() => {
if (!unlocked) return;
const persist = () => persistNow(true);
const disconnect = () => {
for (const id of runtimesRef.current.keys()) {
invoke("ssh_disconnect", { sessionId: id }).catch(() => {});
}
};
window.addEventListener("beforeunload", persist);
window.addEventListener("beforeunload", disconnect);
return () => {
window.removeEventListener("beforeunload", persist);
window.removeEventListener("beforeunload", disconnect);
};
}, [unlocked, persistNow]);
React.useEffect(() => {
const observer = new MutationObserver(() => {
for (const runtime of runtimesRef.current.values()) {
runtime.term.options.theme = getXtermTheme();
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
React.useEffect(() => {
const handleWindowFocus = () => {
const activeId = activeIdRef.current;
if (activeId) {
checkConnectionAlive(activeId);
}
};
window.addEventListener("focus", handleWindowFocus);
return () => {
window.removeEventListener("focus", handleWindowFocus);
};
}, [checkConnectionAlive]);
const findInTerminal = React.useCallback((sessionId, query, direction = "next") => {
const runtime = runtimesRef.current.get(sessionId);
if (!runtime?.term?._searchAddon) return;
const isDark = document.documentElement.classList.contains("dark");
const options = {
incremental: direction === "next",
decorations: {
matchBackground: isDark ? "#a16207" : "#fef08a",
matchBorder: isDark ? "#eab308" : "#ca8a04",
activeMatchBackground: isDark ? "#15803d" : "#bbf7d0",
activeMatchBorder: isDark ? "#22c55e" : "#16a34a",
}
};
if (direction === "next") {
runtime.term._searchAddon.findNext(query, options);
} else {
runtime.term._searchAddon.findPrevious(query, options);
}
}, []);
const focusTerminal = React.useCallback((sessionId) => {
runtimesRef.current.get(sessionId)?.term.focus();
}, []);
const value = React.useMemo(
() => ({
sessions,
activeId,
openSession,
setActive,
closeSession,
reconnect,
closeAll,
attachTerminal,
refreshTerminal,
authPrompt,
submitAuth,
cancelAuth,
findInTerminal,
focusTerminal,
checkConnectionAlive,
}),
[
sessions,
activeId,
openSession,
setActive,
closeSession,
reconnect,
closeAll,
attachTerminal,
refreshTerminal,
authPrompt,
submitAuth,
cancelAuth,
findInTerminal,
focusTerminal,
checkConnectionAlive,
],
);
return (
<TerminalContext.Provider value={value}>
{children}
</TerminalContext.Provider>
);
}