[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
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:
8
components/terminal/hooks/useServerStats.ts
Normal file
8
components/terminal/hooks/useServerStats.ts
Normal 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";
|
||||
186
components/terminal/hooks/useTerminalAuthState.ts
Normal file
186
components/terminal/hooks/useTerminalAuthState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
250
components/terminal/hooks/useTerminalContextActions.ts
Normal file
250
components/terminal/hooks/useTerminalContextActions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
430
components/terminal/hooks/useTerminalDragDrop.ts
Normal file
430
components/terminal/hooks/useTerminalDragDrop.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
111
components/terminal/hooks/useTerminalFilePaste.ts
Normal file
111
components/terminal/hooks/useTerminalFilePaste.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
580
components/terminal/hooks/useTerminalSearch.ts
Normal file
580
components/terminal/hooks/useTerminalSearch.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
170
components/terminal/hooks/useZmodemTransfer.ts
Normal file
170
components/terminal/hooks/useZmodemTransfer.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user