[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
/** @deprecated Import from `@/application/state/useServerStats` instead. */
export {
useServerStats,
type DiskInfo,
type NetInterfaceInfo,
type ProcessInfo,
type ServerStats,
} from "../../../application/state/useServerStats";

View File

@@ -0,0 +1,186 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { RefObject } from "react";
import type { Host, TerminalSession } from "../../../types";
import type { PendingAuth } from "../runtime/createTerminalSessionStarters";
import type { TerminalAuthMethod } from "../TerminalAuthDialog";
import { logger } from "../../../lib/logger";
/**
* Password auth is valid when the user typed something — including a single
* space. SSH passwords may be whitespace-only; do not trim before this check
* (issue #2036).
*/
export const isAuthPasswordProvided = (password: string): boolean =>
password.length > 0;
export const buildSavedAuthHostUpdate = (
host: Host,
auth: {
authMethod: TerminalAuthMethod;
username: string;
password: string;
keyId: string | null;
},
): Host => ({
...host,
username: auth.username,
authMethod: auth.authMethod,
password: auth.authMethod === "password" ? auth.password : undefined,
savePassword: auth.authMethod === "password" ? true : host.savePassword,
identityFileId:
auth.authMethod === "key" || auth.authMethod === "certificate"
? (auth.keyId ?? undefined)
: undefined,
// Detach stale Keychain identity on explicit credential save (#1956):
// resolveHostAuth prefers identity credentials over host fields.
// Empty string (not undefined) so applyGroupDefaults treats this as an explicit
// host-level override and does not re-inherit a group-level identity; consumers
// check host.identityId truthiness so "" behaves as "no identity".
identityId: "",
});
export const useTerminalAuthState = ({
host,
pendingAuthRef,
termRef,
onUpdateHost,
onStartSession,
setStatus,
setProgressLogs,
}: {
host: Host;
pendingAuthRef: RefObject<PendingAuth>;
termRef: RefObject<XTerm | null>;
onUpdateHost?: (host: Host) => void;
onStartSession: (term: XTerm) => void;
setStatus: (status: TerminalSession["status"]) => void;
setProgressLogs: (next: string[] | ((prev: string[]) => string[])) => void;
}) => {
const [needsAuth, setNeedsAuth] = useState(false);
const [authRetryMessage, setAuthRetryMessage] = useState<string | null>(null);
const [authUsername, setAuthUsername] = useState(host.username || "root");
const [authMethod, setAuthMethod] = useState<TerminalAuthMethod>("password");
const [authPassword, setAuthPassword] = useState("");
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
const [authPassphrase, setAuthPassphrase] = useState("");
const [showAuthPassword, setShowAuthPassword] = useState(false);
const [showAuthPassphrase, setShowAuthPassphrase] = useState(false);
const [saveCredentials, setSaveCredentials] = useState(true);
useEffect(() => {
setNeedsAuth(false);
setAuthRetryMessage(null);
setAuthUsername(host.username || "root");
setAuthPassword("");
setAuthKeyId(null);
setAuthPassphrase("");
setShowAuthPassword(false);
setShowAuthPassphrase(false);
setSaveCredentials(true);
}, [host.id, host.username]);
const isValid = useMemo(() => {
if (!authUsername.trim()) return false;
if (authMethod === "password") return isAuthPasswordProvided(authPassword);
if (authMethod === "key" || authMethod === "certificate") return !!authKeyId;
return false;
}, [authKeyId, authMethod, authPassword, authUsername]);
const resetForRetry = useCallback(() => {
setNeedsAuth(false);
setAuthRetryMessage(null);
pendingAuthRef.current = null;
}, [pendingAuthRef]);
const submit = useCallback(
(opts?: { saveToHost?: boolean }) => {
if (!isValid) return;
const shouldSave = opts?.saveToHost ?? saveCredentials;
pendingAuthRef.current = {
authMethod,
username: authUsername,
password: authMethod === "password" ? authPassword : undefined,
keyId:
authMethod === "key" || authMethod === "certificate"
? (authKeyId ?? undefined)
: undefined,
passphrase:
authMethod === "key" || authMethod === "certificate"
? authPassphrase || undefined
: undefined,
savedToHost: shouldSave && Boolean(onUpdateHost),
};
if (shouldSave && onUpdateHost) {
onUpdateHost(
buildSavedAuthHostUpdate(host, {
authMethod,
username: authUsername,
password: authPassword,
keyId: authKeyId,
}),
);
}
setNeedsAuth(false);
setAuthRetryMessage(null);
setStatus("connecting");
setProgressLogs(["Authenticating with provided credentials..."]);
const term = termRef.current;
if (!term) return;
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal", err);
}
onStartSession(term);
},
[
authKeyId,
authMethod,
authPassphrase,
authPassword,
authUsername,
host,
isValid,
onStartSession,
onUpdateHost,
pendingAuthRef,
saveCredentials,
setProgressLogs,
setStatus,
termRef,
],
);
return {
needsAuth,
setNeedsAuth,
authRetryMessage,
setAuthRetryMessage,
authUsername,
setAuthUsername,
authMethod,
setAuthMethod,
authPassword,
setAuthPassword,
authKeyId,
setAuthKeyId,
authPassphrase,
setAuthPassphrase,
showAuthPassword,
setShowAuthPassword,
showAuthPassphrase,
setShowAuthPassphrase,
saveCredentials,
setSaveCredentials,
isValid,
resetForRetry,
submit,
};
};

View File

@@ -0,0 +1,250 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback } from "react";
import type { RefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
import { clearTerminalViewportAndSyncPty } from "../clearTerminalViewport";
import {
handleRemoteClipboardImageUpload,
type RemoteClipboardImageUploadResult,
} from "../clipboardImagePaste";
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
import { pulseCopyOnSelectUserCommand } from "../copyOnSelect";
import { getTerminalSelectionForClipboard } from "../normalizeTerminalSelection";
import {
getHistoryPreviewSelectionFromRoot,
requestHistoryPreviewHide,
selectHistoryPreviewAll,
findHistoryPreviewOverlay,
} from "../runtime/terminalHistoryScrollOverride";
type BroadcastPasteRefs = {
sourceSessionId: string;
sessionRef: RefObject<string | null>;
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
passwordPromptActiveRef?: RefObject<boolean | undefined>;
};
export const broadcastTerminalPasteData = (
data: string,
{
sourceSessionId,
sessionRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
}: BroadcastPasteRefs,
): boolean => {
if (
passwordPromptActiveRef?.current !== true
&& sessionRef.current
&& isBroadcastEnabledRef?.current
&& onBroadcastInputRef?.current
) {
onBroadcastInputRef.current(data, sourceSessionId);
return true;
}
return false;
};
export const useTerminalContextActions = ({
termRef,
sourceSessionId,
sessionRef,
onHasSelectionChange,
scrollOnPasteRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
isLocalConnection,
supportsRemoteImagePaste,
autoUploadClipboardImageOnPasteRef,
clearWipesScrollbackRef,
normalizeTextOnCopyRef,
terminalBackend,
getRemoteCwd,
scrollToBottomAfterProgrammaticInput,
onClipboardImageUploadResult,
}: {
termRef: RefObject<XTerm | null>;
sourceSessionId: string;
sessionRef: RefObject<string | null>;
onHasSelectionChange?: (hasSelection: boolean) => void;
scrollOnPasteRef?: RefObject<boolean>;
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
passwordPromptActiveRef?: RefObject<boolean | undefined>;
isLocalConnection: boolean;
supportsRemoteImagePaste: boolean;
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
autoUploadClipboardImageOnPasteRef?: RefObject<boolean | undefined>;
clearWipesScrollbackRef?: RefObject<boolean | undefined>;
/** When false, copy uses raw getSelection(). Default true when unset. */
normalizeTextOnCopyRef?: RefObject<boolean | undefined>;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
clearSessionPtyBuffer?: (sessionId: string) => void;
};
getRemoteCwd?: () => Promise<string | null | undefined>;
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
}) => {
const broadcastUserPasteData = useCallback((data: string) => {
return broadcastTerminalPasteData(data, {
sourceSessionId,
sessionRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
});
}, [isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, sessionRef, sourceSessionId]);
const onCopy = useCallback(() => {
const term = termRef.current;
if (!term) return;
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|| getTerminalSelectionForClipboard(
term,
normalizeTextOnCopyRef?.current ?? true,
);
if (selection) {
navigator.clipboard.writeText(selection);
}
}, [normalizeTextOnCopyRef, termRef]);
const onPaste = useCallback(async () => {
const term = termRef.current;
if (!term) return;
requestHistoryPreviewHide(term.element?.parentElement);
term.focus();
try {
const bridge = netcattyBridge.get();
await handleTerminalClipboardPaste({
bridge,
autoUploadClipboardImage:
supportsRemoteImagePaste && autoUploadClipboardImageOnPasteRef?.current === true,
clipboardImageBridge: bridge ?? undefined,
getRemoteCwd,
isLocalConnection,
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
onClipboardImageUploadResult,
readClipboardText: () => navigator.clipboard.readText(),
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData: broadcastUserPasteData,
sessionId: sessionRef.current,
scrollToBottomAfterProgrammaticInput,
terminalBackend,
term,
});
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}
}, [
autoUploadClipboardImageOnPasteRef,
broadcastUserPasteData,
getRemoteCwd,
isLocalConnection,
onClipboardImageUploadResult,
passwordPromptActiveRef,
sessionRef,
supportsRemoteImagePaste,
termRef,
scrollOnPasteRef,
scrollToBottomAfterProgrammaticInput,
terminalBackend,
]);
const onUploadClipboardImage = useCallback(async () => {
const term = termRef.current;
if (!term) return;
try {
const bridge = netcattyBridge.get();
const result = await handleRemoteClipboardImageUpload({
bridge,
getRemoteCwd: getRemoteCwd ?? (async () => undefined),
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
sessionId: supportsRemoteImagePaste ? sessionRef.current : null,
terminalBackend,
term,
scrollToBottomAfterProgrammaticInput,
});
onClipboardImageUploadResult?.(result);
} catch (err) {
logger.warn("Failed to upload clipboard image", err);
onClipboardImageUploadResult?.({ ok: false, reason: "upload-failed" });
}
}, [
getRemoteCwd,
passwordPromptActiveRef,
onClipboardImageUploadResult,
scrollToBottomAfterProgrammaticInput,
sessionRef,
supportsRemoteImagePaste,
termRef,
terminalBackend,
]);
const onPasteSelection = useCallback(() => {
const term = termRef.current;
if (!term) return;
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|| getTerminalSelectionForClipboard(
term,
normalizeTextOnCopyRef?.current ?? true,
);
if (!selection || !sessionRef.current) return;
requestHistoryPreviewHide(term.element?.parentElement);
term.focus();
pasteTextIntoTerminal(term, selection, {
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData: broadcastUserPasteData,
});
}, [broadcastUserPasteData, normalizeTextOnCopyRef, sessionRef, termRef, scrollOnPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;
if (!term) return;
pulseCopyOnSelectUserCommand(term);
const previewOverlay = findHistoryPreviewOverlay(term.element?.parentElement);
if (previewOverlay && selectHistoryPreviewAll(previewOverlay)) {
onHasSelectionChange?.(true);
return;
}
term.selectAll();
onHasSelectionChange?.(true);
}, [onHasSelectionChange, termRef]);
const onClear = useCallback(() => {
const term = termRef.current;
if (!term) return;
clearTerminalViewportAndSyncPty(term, {
wipeScrollback: clearWipesScrollbackRef?.current ?? true,
syncPty: () => {
const id = sessionRef.current;
if (id) {
terminalBackend.clearSessionPtyBuffer?.(id);
}
},
});
}, [clearWipesScrollbackRef, sessionRef, termRef, terminalBackend]);
const onSelectWord = useCallback(() => {
const term = termRef.current;
if (!term) return;
pulseCopyOnSelectUserCommand(term);
term.selectAll();
onHasSelectionChange?.(true);
}, [onHasSelectionChange, termRef]);
return {
onCopy,
onPaste,
onUploadClipboardImage: supportsRemoteImagePaste ? onUploadClipboardImage : undefined,
onPasteSelection,
onSelectAll,
onClear,
onSelectWord,
};
};

View File

@@ -0,0 +1,430 @@
import { Terminal as XTerm } from "@xterm/xterm";
import type React from "react";
import { useRef, useState } from "react";
import { logger } from "../../../lib/logger";
import {
buildZmodemDragDropFiles,
buildZmodemDragDropUploadCommand,
containsZmodemRzMissingMarker,
createZmodemRzMissingToken,
supportsZmodemDragDropSftpFallback,
supportsZmodemTerminalDragDrop,
type ZmodemDragDropFile,
} from "../../../lib/zmodemDragDrop";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import type { Host, TerminalSession } from "../../../types";
import { resolveSftpReuseSourceSessionId } from "../../../application/state/terminalConnectionReuse";
import {
resolveTerminalDropSftpHost,
TerminalDropNeedsSudoError,
} from "../../../domain/sftpDropElevation";
import { toast } from "../../ui/toast";
import {
extractRootPathsFromDropEntries,
type TerminalProps,
} from "../terminalHelpers";
interface UseTerminalDragDropOptions {
host: Host;
/** Password already resolved through host auth (host or Keychain identity). */
resolvedSudoPassword?: string;
/** Login username already resolved through host auth (host or Keychain identity). */
resolvedLoginUsername?: string;
isLocalConnection: boolean;
isNetworkDevice?: boolean;
onOpenSftp?: TerminalProps["onOpenSftp"];
resolveSftpInitialPath: (options?: {
preferFreshBackend?: boolean;
requireActiveShellCwd?: boolean;
}) => Promise<string | undefined>;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
sessionId: string;
sessionRef: React.MutableRefObject<string | null>;
status: TerminalSession["status"];
t: (key: string) => string;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
cancelZmodem?: (sessionId: string, options?: { interrupt?: boolean }) => void;
onSessionData?: (sessionId: string, cb: (chunk: string) => void) => () => void;
onZmodemEvent?: (
sessionId: string,
cb: (event: { type: string; transferType?: string }) => void,
) => () => void;
startZmodemDragDropUpload?: (
sessionId: string,
files: ZmodemDragDropFile[],
uploadCommand?: string,
) => Promise<{ success: boolean; error?: string }>;
};
isSensitiveInput?: () => boolean;
rzMissingFallbackTimeoutMs?: number;
termRef: React.MutableRefObject<XTerm | null>;
}
// Keep this aligned with the main-process drag-drop start watchdog. Falling
// back sooner interrupts valid rz handshakes on slow shells and jump routes.
export const DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS = 15_000;
export class ActiveTerminalCwdUnavailableError extends Error {
constructor() {
super("Could not determine the active terminal directory");
this.name = "ActiveTerminalCwdUnavailableError";
}
}
export function resolveTerminalDropErrorMessage(
error: unknown,
t: UseTerminalDragDropOptions["t"],
): string {
if (error instanceof ActiveTerminalCwdUnavailableError) {
return t("terminal.dragDrop.destinationUnknown");
}
if (error instanceof TerminalDropNeedsSudoError) {
return t("terminal.dragDrop.needsSudoElevation");
}
if (error instanceof Error && error.message === "No files to upload") {
return t("terminal.dragDrop.noFiles");
}
return t("terminal.dragDrop.errorMessage");
}
async function openSftpForTerminalDrop({
dropEntries,
host,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
sessionId,
}: {
dropEntries: DropEntry[];
host: Host;
onOpenSftp: NonNullable<UseTerminalDragDropOptions["onOpenSftp"]>;
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"];
resolvedLoginUsername?: string;
resolvedSudoPassword?: string;
sessionId: string;
}): Promise<void> {
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
const uploadHost = resolveTerminalDropSftpHost(host, initialPath, {
password: resolvedSudoPassword ?? host.password,
username: resolvedLoginUsername ?? host.username,
});
onOpenSftp(
uploadHost,
initialPath,
dropEntries,
sessionId,
resolveSftpReuseSourceSessionId(host, sessionId),
);
}
export async function resolveTerminalDropUploadInitialPath(
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
): Promise<string | undefined> {
const initialPath = await resolveSftpInitialPath({
preferFreshBackend: true,
requireActiveShellCwd: true,
});
if (!initialPath) {
throw new ActiveTerminalCwdUnavailableError();
}
return initialPath;
}
function createRzMissingWatcher({
sessionId,
terminalBackend,
token,
timeoutMs = DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS,
}: {
sessionId: string;
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
token: string;
timeoutMs?: number;
}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let buffer = "";
let unsubscribeData: (() => void) | undefined;
let unsubscribeZmodem: (() => void) | undefined;
let settle: (result: "missing" | "detected" | "timeout") => void = () => {};
const cleanup = () => {
if (timeout) clearTimeout(timeout);
timeout = undefined;
unsubscribeData?.();
unsubscribeData = undefined;
unsubscribeZmodem?.();
unsubscribeZmodem = undefined;
};
const promise = new Promise<"missing" | "detected" | "timeout">((resolve) => {
settle = (result) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => {
buffer = `${buffer}${chunk}`.slice(-512);
if (containsZmodemRzMissingMarker(buffer, token)) {
settle("missing");
}
});
unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => {
if (event.type === "detect" && event.transferType === "upload") {
settle("detected");
}
});
timeout = setTimeout(() => settle("timeout"), timeoutMs);
});
return {
promise,
stop: () => settle("detected"),
};
}
export async function handleTerminalDropEntries({
dropEntries,
host,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
isSensitiveInput,
rzMissingFallbackTimeoutMs,
termRef,
}: Pick<
UseTerminalDragDropOptions,
| "host"
| "resolvedLoginUsername"
| "resolvedSudoPassword"
| "isLocalConnection"
| "isNetworkDevice"
| "onOpenSftp"
| "resolveSftpInitialPath"
| "scrollToBottomAfterProgrammaticInput"
| "sessionId"
| "sessionRef"
| "terminalBackend"
| "isSensitiveInput"
| "rzMissingFallbackTimeoutMs"
| "termRef"
> & {
dropEntries: DropEntry[];
}): Promise<void> {
if (dropEntries.length === 0) {
return;
}
if (isLocalConnection) {
const paths = extractRootPathsFromDropEntries(dropEntries);
if (paths.length > 0 && termRef.current && sessionRef.current) {
const pathsText = paths.join(" ");
terminalBackend.writeToSession(sessionRef.current, pathsText, {
sensitive: isSensitiveInput?.() === true,
});
scrollToBottomAfterProgrammaticInput(pathsText);
termRef.current.focus();
}
return;
}
const requiresSftpForDirectoryDrop = dropEntries.some((entry) => (
entry.isDirectory || /[\\/]/.test(entry.relativePath)
));
if (
requiresSftpForDirectoryDrop
&& onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
) {
await openSftpForTerminalDrop({
dropEntries,
host,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
sessionId,
});
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
const files = await buildZmodemDragDropFiles(dropEntries);
if (files.length === 0) {
throw new Error("No files to upload");
}
if (!terminalBackend.startZmodemDragDropUpload) {
throw new Error("ZMODEM drag-drop upload is unavailable");
}
const shouldFallbackToSftpWhenRzMissing = Boolean(
onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
&& terminalBackend.onSessionData
&& terminalBackend.cancelZmodem,
);
const rzMissingToken = shouldFallbackToSftpWhenRzMissing
? createZmodemRzMissingToken()
: undefined;
const rzMissingWatcher = rzMissingToken
? createRzMissingWatcher({
sessionId,
terminalBackend,
token: rzMissingToken,
timeoutMs: rzMissingFallbackTimeoutMs,
})
: undefined;
const uploadCommand = rzMissingToken
? buildZmodemDragDropUploadCommand(rzMissingToken)
: undefined;
let result: { success: boolean; error?: string };
try {
result = await terminalBackend.startZmodemDragDropUpload(sessionId, files, uploadCommand);
} catch (error) {
rzMissingWatcher?.stop();
throw error;
}
if (!result.success) {
rzMissingWatcher?.stop();
throw new Error(result.error || "ZMODEM upload failed");
}
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
if (fallbackResult === "missing" || fallbackResult === "timeout") {
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
if (onOpenSftp) {
await openSftpForTerminalDrop({
dropEntries,
host,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
sessionId,
});
}
}
} else if (onOpenSftp) {
await openSftpForTerminalDrop({
dropEntries,
host,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
sessionId,
});
}
}
export function useTerminalDragDrop({
host,
resolvedLoginUsername,
resolvedSudoPassword,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
status,
t,
terminalBackend,
isSensitiveInput,
rzMissingFallbackTimeoutMs,
termRef,
}: UseTerminalDragDropOptions) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes("Files")) {
setIsDraggingOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) {
e.dataTransfer.dropEffect = "copy";
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDraggingOver(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDraggingOver(false);
if (!e.dataTransfer.types.includes("Files")) {
return;
}
if (status !== "connected") {
toast.error(t("terminal.dragDrop.notConnected"), t("terminal.dragDrop.errorTitle"));
return;
}
try {
const dropEntries = await extractDropEntries(e.dataTransfer);
await handleTerminalDropEntries({
dropEntries,
host,
resolvedLoginUsername,
resolvedSudoPassword,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
isSensitiveInput,
rzMissingFallbackTimeoutMs,
termRef,
});
} catch (error) {
logger.error("Failed to handle file drop", error);
const message = resolveTerminalDropErrorMessage(error, t);
toast.error(message, t("terminal.dragDrop.errorTitle"));
}
};
return {
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
isDraggingOver,
};
}

View File

@@ -0,0 +1,111 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import type React from "react";
import { useEffect } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import type { TerminalSession } from "../../../types";
import type { RemoteClipboardImageUploadResult } from "../clipboardImagePaste";
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
interface UseTerminalFilePasteOptions {
isLocalConnection: boolean;
status: TerminalSession["status"];
termRef: React.MutableRefObject<XTerm | null>;
sessionRef: React.MutableRefObject<string | null>;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
};
isSensitiveInput?: () => boolean;
scrollOnPasteRef?: React.RefObject<boolean>;
onPasteData?: (data: string) => boolean | void;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
/** Remote sessions only: auto-upload a clipboard image on paste. */
autoUploadClipboardImage?: boolean;
getRemoteCwd?: () => Promise<string | null | undefined>;
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
}
export function useTerminalFilePaste({
isLocalConnection,
status,
termRef,
sessionRef,
terminalBackend,
isSensitiveInput,
scrollOnPasteRef,
onPasteData,
scrollToBottomAfterProgrammaticInput,
containerRef,
autoUploadClipboardImage = false,
getRemoteCwd,
onClipboardImageUploadResult,
}: UseTerminalFilePasteOptions) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handlePaste = (event: ClipboardEvent) => {
if (status !== "connected") return;
const bridge = netcattyBridge.get();
const wantsImageUpload =
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
const canHandleLocalPaste =
isLocalConnection && !!(bridge?.readClipboardFiles || bridge?.hasClipboardImage);
if (!wantsImageUpload && !canHandleLocalPaste) return;
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
// is synchronous; calling it after an await is too late and the
// browser will have already performed the default paste action.
event.preventDefault();
event.stopPropagation();
void (async () => {
try {
const term = termRef.current;
if (!term) return;
await handleTerminalClipboardPaste({
bridge,
autoUploadClipboardImage: wantsImageUpload,
clipboardImageBridge: bridge ?? undefined,
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
onClipboardImageUploadResult,
readClipboardText: () => navigator.clipboard.readText(),
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData,
sessionId: sessionRef.current,
terminalBackend,
term,
scrollToBottomAfterProgrammaticInput,
});
} catch (error) {
logger.error("Failed to handle file paste", error);
}
})();
};
container.addEventListener("paste", handlePaste, true);
return () => {
container.removeEventListener("paste", handlePaste, true);
};
}, [
autoUploadClipboardImage,
containerRef,
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
onClipboardImageUploadResult,
onPasteData,
scrollOnPasteRef,
scrollToBottomAfterProgrammaticInput,
sessionRef,
status,
terminalBackend,
termRef,
]);
}

View File

@@ -0,0 +1,580 @@
import type { SearchAddon } from "@xterm/addon-search";
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
import { STORAGE_KEY_TERMINAL_SEARCH_OPEN } from "../../../infrastructure/config/storageKeys";
type SearchMatchCount = { current: number; total: number } | null;
type SearchAddonResetTarget = Pick<SearchAddon, "findNext" | "clearDecorations"> | null;
type TerminalSearchVisualElement = {
querySelectorAll: (selector: string) => ArrayLike<{ remove: () => void }>;
};
type TerminalSearchResetTarget = Pick<XTerm, "refresh" | "rows" | "clearSelection"> & {
element?: TerminalSearchVisualElement | null;
clearTextureAtlas?: () => void;
} | null;
type TerminalSearchGuardTarget = TerminalSearchResetTarget;
const SEARCH_DECORATIONS = {
matchBackground: "#FFFF0044",
matchBorder: "#FFFF00",
matchOverviewRuler: "#FFFF00",
activeMatchBackground: "#FF880088",
activeMatchBorder: "#FF8800",
activeMatchColorOverviewRuler: "#FF8800",
} as const;
const SEARCH_DECORATION_BACKGROUNDS = new Set<string>([
SEARCH_DECORATIONS.matchBackground.toLowerCase(),
SEARCH_DECORATIONS.activeMatchBackground.toLowerCase(),
]);
type StaleSearchDecoration = {
dispose: () => void;
options?: { backgroundColor?: string };
element?: {
classList?: { contains: (name: string) => boolean };
style?: { backgroundColor?: string };
};
};
type CellDecorationService = {
decorations?: Iterable<StaleSearchDecoration>;
forEachDecorationAtCell?: (
x: number,
y: number,
layer: "bottom" | "top" | undefined,
callback: (decoration: StaleSearchDecoration) => void,
) => void;
};
type SearchAddonInternals = {
clearDecorations?: () => void;
clearActiveDecoration?: () => void;
_highlightTimeout?: { clear?: () => void };
_state?: { reset?: () => void };
};
type TerminalDecorationHost = {
_core?: { _decorationService?: CellDecorationService };
_decorationService?: CellDecorationService;
};
export const SEARCH_DECORATION_TRACKER_KEY = "__netcattySearchDecorationTracker";
export type SearchDecorationTracker = {
disposeAll: () => number;
size: () => number;
markSearched: () => void;
hasSearched: () => boolean;
consumeSearched: () => boolean;
noteEmptyQueryReset: () => void;
consumeCloseSweep: () => boolean;
};
type TrackableTerminal = Pick<XTerm, "registerDecoration"> & {
[SEARCH_DECORATION_TRACKER_KEY]?: SearchDecorationTracker;
};
const SEARCH_OPTIONS = {
regex: false,
caseSensitive: false,
wholeWord: false,
decorations: SEARCH_DECORATIONS,
} as const;
/**
* SearchAddon schedules `_updateMatches` 200ms after writes/resizes and does
* not cancel that timer from `clearDecorations()`. A timeout that already
* captured the prior term can revive yellow match decorations after reset —
* re-clear once past that window (issue #2980).
*/
export const SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS = 250;
/**
* SearchAddon paints matches as HTML overlays (`.xterm-find-result-decoration`).
* Disposing the addon decoration does not always detach that node — after Esc
* closes the search bar and the terminal refits, the first two cells of the
* last hit can stay outlined on top of the buffer.
*/
export const SEARCH_DECORATION_NODE_SELECTOR =
".xterm-find-result-decoration, .xterm-find-active-result-decoration";
export const stripStaleSearchDecorationNodes = (
term?: { element?: TerminalSearchVisualElement | null } | null,
): void => {
const nodes = term?.element?.querySelectorAll(SEARCH_DECORATION_NODE_SELECTOR);
if (!nodes) return;
for (let i = 0; i < nodes.length; i += 1) {
nodes[i]?.remove();
}
};
export const isSearchDecorationBackground = (color?: string): boolean => (
Boolean(color) && SEARCH_DECORATION_BACKGROUNDS.has(color.trim().toLowerCase())
);
export const installSearchDecorationTracker = (
term: TrackableTerminal,
): SearchDecorationTracker => {
const existing = term[SEARCH_DECORATION_TRACKER_KEY];
if (existing) return existing;
const tracked = new Set<{ dispose: () => void }>();
let searched = false;
let pendingCloseSweep = false;
const originalRegister = term.registerDecoration.bind(term);
term.registerDecoration = (options) => {
// Keep search fill on the HTML overlay only. Passing backgroundColor into
// xterm lets WebGL bake the yellow into the glyph atlas, and those cells
// stay stained after Esc even when the decoration handle is gone.
const searchBackground = isSearchDecorationBackground(options.backgroundColor)
? options.backgroundColor
: undefined;
const decoration = originalRegister(
searchBackground ? { ...options, backgroundColor: undefined } : options,
);
if (!decoration) return decoration;
if (!searchBackground) return decoration;
tracked.add(decoration);
decoration.onRender((element) => {
element.style.backgroundColor = searchBackground;
});
decoration.onDispose(() => {
tracked.delete(decoration);
});
return decoration;
};
const tracker: SearchDecorationTracker = {
disposeAll: () => {
const leftover = [...tracked];
tracked.clear();
for (const decoration of leftover) decoration.dispose();
return leftover.length;
},
size: () => tracked.size,
markSearched: () => {
searched = true;
pendingCloseSweep = true;
},
hasSearched: () => searched,
consumeSearched: () => {
const value = searched;
searched = false;
return value;
},
noteEmptyQueryReset: () => {
searched = false;
},
consumeCloseSweep: () => {
const value = pendingCloseSweep;
pendingCloseSweep = false;
return value;
},
};
term[SEARCH_DECORATION_TRACKER_KEY] = tracker;
return tracker;
};
export const getSearchDecorationTracker = (term?: unknown): SearchDecorationTracker | null => {
if (!term || typeof term !== "object") return null;
const tracker = (term as TrackableTerminal)[SEARCH_DECORATION_TRACKER_KEY];
return tracker && typeof tracker.disposeAll === "function" ? tracker : null;
};
const isStaleSearchDecoration = (decoration: StaleSearchDecoration): boolean => (
isSearchDecorationBackground(decoration.options?.backgroundColor)
|| isSearchDecorationBackground(decoration.element?.style?.backgroundColor)
|| decoration.element?.classList?.contains("xterm-find-result-decoration") === true
|| decoration.element?.classList?.contains("xterm-find-active-result-decoration") === true
);
const readDecorationService = (term?: unknown): CellDecorationService | null => {
const host = term as TerminalDecorationHost & {
_core?: Record<string, unknown>;
} | null | undefined;
const direct = host?._core?._decorationService ?? host?._decorationService;
if (direct && (direct.decorations || direct.forEachDecorationAtCell)) {
return direct;
}
const core = host?._core;
if (!core || typeof core !== "object") return null;
for (const value of Object.values(core)) {
const candidate = value as CellDecorationService | undefined;
if (candidate && typeof candidate.forEachDecorationAtCell === "function") {
return candidate;
}
}
return null;
};
const readDecorationIterable = (
term?: unknown,
): Iterable<StaleSearchDecoration> | null => (
readDecorationService(term)?.decorations ?? null
);
export const disposeSearchDecorationsInViewport = (term?: unknown): number => {
const service = readDecorationService(term);
const view = term as {
cols?: number;
rows?: number;
buffer?: { active?: { viewportY?: number } };
} | null | undefined;
if (!service?.forEachDecorationAtCell || !view?.cols || !view.rows) return 0;
const viewportY = view.buffer?.active?.viewportY ?? 0;
const stale = new Set<StaleSearchDecoration>();
for (let y = viewportY; y < viewportY + view.rows; y += 1) {
for (let x = 0; x < view.cols; x += 1) {
service.forEachDecorationAtCell(x, y, undefined, (decoration) => {
if (isStaleSearchDecoration(decoration)) stale.add(decoration);
});
}
}
for (const decoration of stale) decoration.dispose();
return stale.size;
};
/**
* WebGL paints decoration backgroundColor into the cell. SearchAddon can lose
* a couple of those handles on Esc+refit, so walk the terminal decoration
* service and dispose anything still using the search yellow/orange.
*/
export const disposeStaleSearchDecorations = (term?: unknown): number => {
if (term && typeof term === "object" && "registerDecoration" in term) {
installSearchDecorationTracker(term as TrackableTerminal);
}
const trackedCount = getSearchDecorationTracker(term)?.disposeAll() ?? 0;
const viewportCount = disposeSearchDecorationsInViewport(term);
const decorations = readDecorationIterable(term);
if (!decorations) return trackedCount + viewportCount;
const stale: StaleSearchDecoration[] = [];
for (const decoration of decorations) {
if (isStaleSearchDecoration(decoration)) stale.push(decoration);
}
for (const decoration of stale) decoration.dispose();
return trackedCount + viewportCount + stale.length;
};
/** Cancel SearchAddon's 200ms _updateMatches timer and drop cached term/options. */
export const disarmSearchAddonRevival = (searchAddon?: unknown): void => {
const addon = searchAddon as SearchAddonInternals | null | undefined;
if (!addon) return;
addon._highlightTimeout?.clear?.();
addon._state?.reset?.();
addon.clearActiveDecoration?.();
addon.clearDecorations?.();
};
/**
* Delayed re-clear for addon decoration revival only. Do not clearSelection
* here: reset already cleared the search selection, and a user may have made
* a new manual selection during the guard window.
*/
export const clearTerminalSearchHighlights = (
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
term?: Pick<XTerm, "refresh" | "rows"> & {
element?: TerminalSearchVisualElement | null;
clearTextureAtlas?: () => void;
} | null,
): void => {
disarmSearchAddonRevival(searchAddon);
disposeStaleSearchDecorations(term);
stripStaleSearchDecorationNodes(term);
if (term && term.rows > 0) {
term.refresh(0, term.rows - 1);
}
};
/**
* After the search bar unmounts the terminal grows and is force-fitted.
* Re-sweep leftover overlays and the addon selection that a resize can revive
* as a 2-cell sliver of the last match.
*/
export const settleTerminalSearchAfterLayout = (
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
term?: TerminalSearchResetTarget,
onRepaint?: () => void,
): void => {
// Search-open state is shared across terminals. A sibling that never
// searched still sees the bar close and would otherwise lose a manual
// selection via clearSelection(). Emptying the query resets highlights
// while the bar stays open; keep the close-time repaint, but do not
// treat that stale search as a reason to wipe a later manual selection.
const tracker = getSearchDecorationTracker(term);
const shouldClearSelection = tracker ? tracker.consumeSearched() : true;
const shouldSweepLeftovers = tracker ? tracker.consumeCloseSweep() : true;
const shouldSweep = !tracker || shouldClearSelection || shouldSweepLeftovers;
if (!shouldSweep) return;
clearTerminalSearchHighlights(searchAddon, term);
if (shouldClearSelection) term?.clearSelection();
term?.clearTextureAtlas?.();
onRepaint?.();
};
export const resetTerminalSearch = (
searchAddon: SearchAddonResetTarget,
searchTermRef: { current: string },
term?: TerminalSearchResetTarget,
): void => {
searchTermRef.current = "";
// Drop decorations and cachedSearchTerm first so any not-yet-running addon
// `_updateMatches` timeout observes an empty cache and does not revive.
disarmSearchAddonRevival(searchAddon);
// clearDecorations() leaves the active-match selection; clear it explicitly.
term?.clearSelection();
// Empty find clears selection via the addon path. Do NOT pass SEARCH_OPTIONS:
// findNext always assigns lastSearchOptions, and decoration options would
// keep that latch armed for later write/resize updates.
try {
searchAddon?.findNext("");
} catch {
// Addon not activated yet.
}
// findNext("") assigns cachedSearchTerm back to "". Clear again so the cache
// is undefined rather than an empty string.
searchAddon?.clearDecorations();
// SearchAddon can drop a couple of decoration handles on Esc+refit. Those
// leftover yellow cells are still in xterm's decoration service and WebGL
// keeps painting them until we dispose them directly.
disposeStaleSearchDecorations(term);
// Disposing search decorations does not always detach the overlay nodes
// (Esc close leaves the first two cells of the last hit). Sweep them
// before refresh so WebGL/DOM cannot keep the yellow outline.
stripStaleSearchDecorationNodes(term);
// Disposing search decorations does not always repaint cells (observed on
// Windows after clearing or closing search). Keyword highlighting already
// forces a refresh after dispose; do the same here so yellow match
// backgrounds cannot linger.
if (term && term.rows > 0) {
term.refresh(0, term.rows - 1);
}
};
/**
* Pointer listeners used to tell a user-created selection apart from the
* addon's delayed findPrevious re-select. Keyboard selections in this 250ms
* window are rare enough that treating them as addon revival is acceptable.
*/
export const subscribeTerminalUserSelection = (
term: Pick<XTerm, "element"> | null | undefined,
mark: () => void,
): (() => void) => {
const el = term?.element;
if (!el) return () => {};
const onPointer = () => mark();
el.addEventListener("mousedown", onPointer);
el.addEventListener("touchstart", onPointer);
return () => {
el.removeEventListener("mousedown", onPointer);
el.removeEventListener("touchstart", onPointer);
};
};
export const armSearchHighlightRevivalGuard = ({
getSearchAddon,
getTerm,
subscribeUserSelection,
delayMs = SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
}: {
getSearchAddon: () => Pick<SearchAddon, "clearDecorations"> | null;
getTerm: () => TerminalSearchGuardTarget;
subscribeUserSelection?: (mark: () => void) => () => void;
delayMs?: number;
setTimeoutFn?: typeof setTimeout;
clearTimeoutFn?: typeof clearTimeout;
}): { arm: () => void; dispose: () => void; markUserSelection: () => void } => {
let timer: ReturnType<typeof setTimeout> | null = null;
let userTouchedSelection = false;
let unsubscribeUserSelection: (() => void) | null = null;
const markUserSelection = () => {
userTouchedSelection = true;
};
const dispose = () => {
if (timer !== null) {
clearTimeoutFn(timer);
timer = null;
}
unsubscribeUserSelection?.();
unsubscribeUserSelection = null;
};
const arm = () => {
dispose();
userTouchedSelection = false;
if (subscribeUserSelection) {
unsubscribeUserSelection = subscribeUserSelection(markUserSelection);
}
timer = setTimeoutFn(() => {
timer = null;
unsubscribeUserSelection?.();
unsubscribeUserSelection = null;
const term = getTerm();
clearTerminalSearchHighlights(getSearchAddon(), term);
// Addon findPrevious re-selects the prior active match. Clear that
// revived selection unless the user started a new one in this window.
if (!userTouchedSelection) {
term?.clearSelection();
}
}, delayMs);
};
return { arm, dispose, markUserSelection };
};
/** True when this terminal has a local query that shared search-close must clear. */
export const shouldResetOnSharedSearchClose = (localSearchTerm: string): boolean =>
localSearchTerm !== "";
export const useTerminalSearch = ({
searchAddonRef,
termRef,
}: {
searchAddonRef: RefObject<SearchAddon | null>;
termRef: RefObject<XTerm | null>;
}) => {
const [isSearchOpen, setIsSearchOpen] = useStoredBoolean(
STORAGE_KEY_TERMINAL_SEARCH_OPEN,
false,
);
const [searchMatchCount, setSearchMatchCount] = useState<SearchMatchCount>(null);
// Bumped each time the search hotkey fires. The SearchBar watches this token
// to refocus its input — without it, calling setIsSearchOpen(true) when
// already open is a no-op (React bails on the unchanged boolean) and focus
// never returns to the input. See issue #1789.
const [searchFocusToken, setSearchFocusToken] = useState(0);
const searchTermRef = useRef<string>("");
const revivalGuardRef = useRef<ReturnType<typeof armSearchHighlightRevivalGuard> | null>(null);
// Existing sessions (and Vite HMR) never go back through createXTermRuntime.
// Install on the live term so Esc can still find leaked decorations.
if (termRef.current) {
installSearchDecorationTracker(termRef.current);
}
if (revivalGuardRef.current === null) {
revivalGuardRef.current = armSearchHighlightRevivalGuard({
getSearchAddon: () => searchAddonRef.current,
getTerm: () => termRef.current,
subscribeUserSelection: (mark) => subscribeTerminalUserSelection(termRef.current, mark),
});
}
useEffect(() => () => {
revivalGuardRef.current?.dispose();
}, []);
const runReset = useCallback(() => {
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
revivalGuardRef.current?.arm();
}, [searchAddonRef, termRef]);
// Search open state is shared via localStorage across terminal sessions. When
// another session closes search, this session's bar unmounts without going
// through handleCloseSearch — clear leftover decorations only when this
// terminal actually searched (otherwise shared false would wipe unrelated
// manual selections in other splits).
useEffect(() => {
if (isSearchOpen) return;
setSearchMatchCount(null);
if (!shouldResetOnSharedSearchClose(searchTermRef.current)) return;
runReset();
}, [isSearchOpen, runReset]);
// Invoked by the searchTerminal hotkey (Cmd/Ctrl+F). Always opens the bar
// and bumps the focus token: when closed, setIsSearchOpen(true) mounts the
// SearchBar (whose isOpen effect focuses the input); when open, the token
// bump makes the SearchBar re-run its focus effect and refocus. Doing both
// unconditionally avoids reading `isSearchOpen` here — the xterm runtime
// captures this callback once at creation (it only re-runs on host.id /
// sessionId change), so a stale `isSearchOpen` closure would otherwise pick
// the wrong branch.
const requestSearchFocus = useCallback(() => {
setIsSearchOpen(true);
setSearchFocusToken((n) => n + 1);
}, [setIsSearchOpen]);
const handleToggleSearch = useCallback(() => {
const next = !isSearchOpen;
setIsSearchOpen(next);
if (!next) {
setSearchMatchCount(null);
runReset();
}
}, [isSearchOpen, runReset, setIsSearchOpen]);
const handleSearch = useCallback(
(term: string): boolean => {
const searchAddon = searchAddonRef.current;
if (!searchAddon || !term) {
runReset();
if (termRef.current) installSearchDecorationTracker(termRef.current);
getSearchDecorationTracker(termRef.current)?.noteEmptyQueryReset();
setSearchMatchCount(null);
return false;
}
searchTermRef.current = term;
revivalGuardRef.current?.dispose();
// Incremental typing (ro -> root) can leave the previous term's
// decorations in xterm even after clearDecorations(). Drop our tracked
// leftovers before painting the new matches.
if (termRef.current) installSearchDecorationTracker(termRef.current);
getSearchDecorationTracker(termRef.current)?.markSearched();
disposeStaleSearchDecorations(termRef.current);
searchAddon.clearDecorations();
const found = searchAddon.findNext(term, SEARCH_OPTIONS);
if (found) {
setSearchMatchCount({ current: 1, total: 1 });
} else {
setSearchMatchCount({ current: 0, total: 0 });
}
return found;
},
[runReset, searchAddonRef, termRef],
);
const handleFindNext = useCallback((): boolean => {
const searchAddon = searchAddonRef.current;
const term = searchTermRef.current;
if (!searchAddon || !term) return false;
return searchAddon.findNext(term, SEARCH_OPTIONS);
}, [searchAddonRef]);
const handleFindPrevious = useCallback((): boolean => {
const searchAddon = searchAddonRef.current;
const term = searchTermRef.current;
if (!searchAddon || !term) return false;
return searchAddon.findPrevious(term, SEARCH_OPTIONS);
}, [searchAddonRef]);
const handleCloseSearch = useCallback(() => {
setIsSearchOpen(false);
setSearchMatchCount(null);
runReset();
termRef.current?.focus();
}, [runReset, setIsSearchOpen, termRef]);
return {
isSearchOpen,
setIsSearchOpen,
searchMatchCount,
searchFocusToken,
requestSearchFocus,
handleToggleSearch,
handleSearch,
handleFindNext,
handleFindPrevious,
handleCloseSearch,
};
};

View File

@@ -0,0 +1,170 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
export interface ZmodemTransferEvent {
type: 'detect' | 'progress' | 'complete' | 'error';
sessionId: string;
transferType?: 'upload' | 'download';
filename?: string;
transferred?: number;
total?: number;
fileIndex?: number;
fileCount?: number;
finalizing?: boolean;
error?: string;
}
export interface ZmodemTransferState {
active: boolean;
transferType: 'upload' | 'download' | null;
filename: string | null;
transferred: number;
total: number;
fileIndex: number;
fileCount: number;
finalizing: boolean;
completed: boolean;
startedAt: number | null;
updatedAt: number | null;
bytesPerSecond: number | null;
error: string | null;
}
const initialState: ZmodemTransferState = {
active: false,
transferType: null,
filename: null,
transferred: 0,
total: 0,
fileIndex: 0,
fileCount: 0,
finalizing: false,
completed: false,
startedAt: null,
updatedAt: null,
bytesPerSecond: null,
error: null,
};
export function reduceZmodemTransferState(
prev: ZmodemTransferState,
event: ZmodemTransferEvent,
now: number = Date.now(),
): ZmodemTransferState {
switch (event.type) {
case 'detect':
return {
...initialState,
active: true,
transferType: event.transferType ?? null,
startedAt: now,
updatedAt: now,
};
case 'progress': {
const transferred = event.transferred ?? prev.transferred;
const fileChanged = (
prev.filename !== null
&& (
(typeof event.fileIndex === 'number' && event.fileIndex !== prev.fileIndex)
|| (typeof event.filename === 'string' && event.filename !== prev.filename)
)
);
const previousUpdatedAt = fileChanged ? now : (prev.updatedAt ?? now);
const elapsedSeconds = Math.max((now - previousUpdatedAt) / 1000, 0);
const deltaBytes = Math.max(transferred - prev.transferred, 0);
const bytesPerSecond = elapsedSeconds > 0 && deltaBytes > 0
? deltaBytes / elapsedSeconds
: fileChanged
? null
: prev.bytesPerSecond;
return {
...prev,
active: true,
transferType: event.transferType ?? prev.transferType,
filename: event.filename ?? prev.filename,
transferred,
total: event.total ?? prev.total,
fileIndex: event.fileIndex ?? prev.fileIndex,
fileCount: event.fileCount ?? prev.fileCount,
finalizing: !!event.finalizing,
completed: false,
startedAt: prev.startedAt ?? now,
updatedAt: now,
bytesPerSecond,
error: null,
};
}
case 'complete':
return {
...prev,
active: false,
finalizing: false,
completed: true,
updatedAt: now,
};
case 'error':
return {
...prev,
active: false,
finalizing: false,
completed: false,
updatedAt: now,
error: event.error ?? 'Unknown error',
};
}
}
export function useZmodemTransfer(sessionId: string | null) {
const [state, setState] = useState<ZmodemTransferState>(initialState);
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
const disposeRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!sessionId) return;
const bridge = netcattyBridge.get();
if (!bridge?.onZmodemEvent) return;
disposeRef.current = bridge.onZmodemEvent(sessionId, (event) => {
setState((prev) => reduceZmodemTransferState(prev, event));
});
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
});
// If the session exits mid-transfer (disconnect, shell exit, etc.),
// reset state so the progress indicator doesn't stay stuck.
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
setState(initialState);
});
return () => {
disposeRef.current?.();
disposeRef.current = null;
disposeOverwrite?.();
disposeExitRef.current?.();
disposeExitRef.current = null;
setState(initialState);
setOverwriteRequest(null);
};
}, [sessionId]);
const cancel = useCallback(() => {
if (!sessionId) return;
const bridge = netcattyBridge.get();
bridge?.cancelZmodem?.(sessionId);
}, [sessionId]);
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
setOverwriteRequest((req) => {
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
return null;
});
}, []);
return { ...state, cancel, overwriteRequest, respondOverwrite };
}