Files
NetMesh/components/sftp/hooks/useSftpKeyboardShortcuts.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1035 lines
38 KiB
TypeScript

/**
* useSftpKeyboardShortcuts
*
* Hook that handles keyboard shortcuts for SFTP operations.
* Supports copy, cut, paste, select all, rename, delete, refresh, and new folder.
*/
import { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import { getParentPath, joinPath, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { sftpClipboardStore, SftpClipboardFile } from "../../../application/state/sftp/sftpClipboardStore";
import { sftpFocusStore } from "../../../application/state/sftp/sftpFocusStore";
import { sftpDialogActionStore } from "../../../application/state/sftp/sftpDialogActionStore";
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
import { sftpListOrderStore } from "./useSftpListOrderStore";
import { sftpPaneViewModeStore } from "../../../application/state/sftp/sftpPaneViewModeStore";
import { sftpFilterFocusStore } from "../../../application/state/sftp/sftpFilterFocusStore";
import { keepOnlyPaneSelections } from "./selectionScope";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { UploadEndpointPin } from "../../../application/state/sftp/uploadTargetPin";
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
import type { SftpFileEntry } from "../../../types";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import { toast } from "../../ui/toast";
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
import {
createDropEntriesFromClipboardFiles,
getSftpClipboardSystemTextPaths,
getSupportedClipboardUploadFiles,
isSftpNativeClipboardPasteEnabled,
resolveSftpClipboardUploadTarget,
shouldLetNativePasteEventHandleSftpPaste,
sftpClipboardUploadStore,
type ClipboardLocalFile,
} from "../clipboardUpload";
import {
advanceSftpTypeahead,
resolveSftpTypeaheadSource,
type SftpTypeaheadState,
} from "../../../domain/sftpTypeahead";
import {
resolveSftpActiveSelection,
resolveSftpSelectAllTarget,
} from "../../../domain/sftpSelection";
// SFTP action names that we handle
const SFTP_ACTIONS = new Set([
"sftpCopy",
"sftpCut",
"sftpPaste",
"sftpSelectAll",
"sftpRename",
"sftpDelete",
"sftpRefresh",
"sftpNewFolder",
"sftpOpen",
"sftpGoParent",
"sftpNavigateTo",
]);
let pendingSftpSystemClipboardWrite: Promise<void> | null = null;
const replaceSystemClipboardWithSftpPaths = async (paths: string[]) => {
const text = paths.join("\n");
if (!text) return;
const writeTask = (async () => {
const bridge = netcattyBridge.get();
try {
if (bridge?.writeClipboardText && await bridge.writeClipboardText(text)) return;
} catch {
// Fall back to the browser clipboard API.
}
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return;
await navigator.clipboard.writeText(text).catch(() => {});
})();
pendingSftpSystemClipboardWrite = writeTask;
try {
await writeTask;
} finally {
if (pendingSftpSystemClipboardWrite === writeTask) {
pendingSftpSystemClipboardWrite = null;
}
}
};
// ── Tree Enter key action store ──────────────────────────────────────
// Allows the keyboard shortcut hook to signal tree views to handle Enter.
type TreeEnterListener = () => void;
interface TreeEnterAction {
paneId: string;
entryPath: string;
isDirectory: boolean;
timestamp: number;
}
let _treeEnterAction: TreeEnterAction | null = null;
const _treeEnterListeners = new Set<TreeEnterListener>();
const notifyTreeEnterListeners = () => _treeEnterListeners.forEach((l) => l());
export const sftpTreeEnterStore = {
trigger: (paneId: string, entryPath: string, isDirectory: boolean) => {
_treeEnterAction = { paneId, entryPath, isDirectory, timestamp: Date.now() };
notifyTreeEnterListeners();
},
get: () => _treeEnterAction,
clear: () => {
_treeEnterAction = null;
notifyTreeEnterListeners();
},
subscribe: (listener: TreeEnterListener) => {
_treeEnterListeners.add(listener);
return () => { _treeEnterListeners.delete(listener); };
},
getSnapshot: () => _treeEnterAction,
};
// ── Keyboard selection anchor/focus tracking ────────────────────────
// Tracks the anchor (where Shift-selection started) and focus (cursor)
// indices per pane so Shift+Arrow extends correctly.
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
export const sftpKeyboardSelectionStore = {
get: (paneId: string) => _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 },
set: (paneId: string, anchor: number, focus: number) => {
_kbSelectionState.set(paneId, { anchor, focus });
},
clear: (paneId: string) => {
_kbSelectionState.delete(paneId);
},
};
// Basic navigation keys that work even when custom hotkeys are disabled.
const BASIC_NAV_KEYS: Record<string, string> = {
'Enter': 'sftpOpen',
'Backspace': 'sftpGoParent',
};
const isEditableShortcutTarget = (target: HTMLElement): boolean =>
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
const hasOpenDialog = (): boolean =>
!!document.querySelector('[role="dialog"][data-state="open"]');
interface UseSftpKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
sftpRef: MutableRefObject<SftpStateApi>;
dialogActionScopeId: string;
isActive: boolean;
}
/**
* Check if a keyboard event matches any SFTP action
*/
const matchSftpAction = (
e: KeyboardEvent,
keyBindings: KeyBinding[],
isMac: boolean
): { action: string; binding: KeyBinding } | null => {
for (const binding of keyBindings) {
if (binding.category !== "sftp") continue;
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
return { action: binding.action, binding };
}
}
return null;
};
export const useSftpKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId,
isActive,
}: UseSftpKeyboardShortcutsParams) => {
const typeaheadRef = useRef<{ paneId: string; state: SftpTypeaheadState } | null>(null);
const getFocusedPane = useCallback(() => {
const sftp = sftpRef.current;
const focusedSide = sftpFocusStore.getFocusedSide();
const pane = focusedSide === "left"
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
return { sftp, focusedSide, pane };
}, [sftpRef]);
const getClipboardUploadTarget = useCallback((pane: NonNullable<ReturnType<typeof getFocusedPane>["pane"]>) => {
const { selectedFileNames, treeSelection } = resolveSftpActiveSelection(
sftpPaneViewModeStore.get(pane.id),
Array.from(pane.selectedFiles) as string[],
sftpTreeSelectionStore.getSelectedItems(pane.id),
);
const treeActionSelection = treeSelection.filter((entry) => entry.name !== '..');
return resolveSftpClipboardUploadTarget({
currentPath: pane.connection!.currentPath,
selectedFileNames,
files: pane.files as SftpFileEntry[],
treeSelection: treeActionSelection,
});
}, []);
const showUploadResults = useCallback((results: Awaited<ReturnType<SftpStateApi["uploadExternalEntries"]>>) => {
if (results.some((result) => result.cancelled)) {
toast.info("Upload cancelled.", "SFTP");
return;
}
const successCount = results.filter((result) => result.success).length;
const failedFiles = results.filter((result) => !result.success && !result.cancelled);
if (successCount > 0) {
toast.success(`Uploaded ${successCount} item${successCount === 1 ? "" : "s"}.`, "SFTP");
}
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
toast.error(`Upload failed: ${failed.fileName}${errorMsg}`, "SFTP");
});
}, []);
const triggerPathBackedClipboardUpload = useCallback((
files: ClipboardLocalFile[],
focusedSide: "left" | "right",
targetPath: string,
connectionId: string,
tabId: string,
endpointPin: UploadEndpointPin,
) => {
const sftp = sftpRef.current;
const uploadFiles = getSupportedClipboardUploadFiles(files);
if (uploadFiles.length === 0) return;
const entries = createDropEntriesFromClipboardFiles(uploadFiles);
const pin = { connectionId, tabId, endpointPin };
sftpClipboardUploadStore.trigger({
scopeId: dialogActionScopeId,
side: focusedSide,
targetPath,
files: uploadFiles,
onConfirm: async () => {
try {
const results: Awaited<ReturnType<SftpStateApi["uploadExternalEntries"]>> = [];
const fileEntries: DropEntry[] = [];
for (const file of uploadFiles) {
if (file.isDirectory) {
try {
const folderResults = await sftp.uploadExternalFolderPath(
focusedSide,
file.path,
targetPath,
pin,
);
results.push(...folderResults);
} catch (error) {
results.push({
fileName: file.name,
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
} else {
fileEntries.push(
entries.find((entry) => entry.localPath === file.path) ?? {
file: null,
localPath: file.path,
relativePath: file.name,
isDirectory: false,
size: file.size,
},
);
}
}
if (fileEntries.length > 0) {
const fileResults = await sftp.uploadExternalEntries(focusedSide, fileEntries, {
targetPath,
...pin,
});
results.push(...fileResults);
}
showUploadResults(results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed.", "SFTP");
}
},
});
}, [dialogActionScopeId, showUploadResults, sftpRef]);
const triggerDropEntriesClipboardUpload = useCallback((
entries: DropEntry[],
focusedSide: "left" | "right",
targetPath: string,
connectionId: string,
tabId: string,
endpointPin: UploadEndpointPin,
) => {
const sftp = sftpRef.current;
if (entries.length === 0) return;
const rootNames = new Set<string>();
const previewFiles: ClipboardLocalFile[] = [];
for (const entry of entries) {
const rootName = entry.relativePath.split("/")[0];
if (rootNames.has(rootName)) continue;
rootNames.add(rootName);
previewFiles.push({
path: entry.localPath ?? entry.relativePath,
name: rootName,
isDirectory: entry.isDirectory,
size: entry.size,
});
}
const pin = { connectionId, tabId, endpointPin };
sftpClipboardUploadStore.trigger({
scopeId: dialogActionScopeId,
side: focusedSide,
targetPath,
files: previewFiles,
onConfirm: async () => {
try {
const results = await sftp.uploadExternalEntries(focusedSide, entries, {
targetPath,
...pin,
});
showUploadResults(results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed.", "SFTP");
}
},
});
}, [dialogActionScopeId, showUploadResults, sftpRef]);
const pasteInternalSftpClipboard = useCallback(async (
focusedSide: "left" | "right",
pane: NonNullable<ReturnType<typeof getFocusedPane>["pane"]>,
) => {
const sftp = sftpRef.current;
const clipboard = sftpClipboardStore.get();
if (!clipboard || clipboard.files.length === 0) return;
const isSameConnection = clipboard.sourceSide === focusedSide
&& clipboard.sourceConnectionId === pane.connection!.id;
if (isSameConnection) {
toast.info("Paste within the same pane is not supported. Use copy to other pane instead.", "SFTP");
return;
}
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
if (!sourcePane?.connection) {
toast.info("Paste source is no longer available.", "SFTP");
return;
}
try {
const isCut = clipboard.operation === "cut";
const pendingNames = new Set(clipboard.files.map((file) => file.name));
const completedNames = new Set<string>();
const failedNames = new Set<string>();
const updateClipboardAfterCompletion = (showToast: boolean) => {
if (!isCut) return;
const current = sftpClipboardStore.get();
if (
!current ||
current.operation !== "cut" ||
current.sourceConnectionId !== clipboard.sourceConnectionId ||
current.sourcePath !== clipboard.sourcePath ||
current.sourceSide !== clipboard.sourceSide
) {
return;
}
const remainingFiles = current.files.filter((file) => !completedNames.has(file.name));
if (remainingFiles.length === 0) {
sftpClipboardStore.clear();
} else {
sftpClipboardStore.updateFiles(remainingFiles);
}
if (showToast && failedNames.size > 0) {
toast.info("Some items could not be transferred and were kept in the clipboard.", "SFTP");
}
};
const handleTransferComplete = async (result: {
fileName: string;
originalFileName?: string;
status: string;
}) => {
if (!isCut) return;
const sourceFileName = result.originalFileName ?? result.fileName;
if (!pendingNames.has(sourceFileName)) return;
pendingNames.delete(sourceFileName);
if (result.status === "completed") {
try {
await sftp.deleteFilesAtPath(
clipboard.sourceSide,
clipboard.sourceConnectionId,
clipboard.sourcePath,
[sourceFileName],
);
completedNames.add(sourceFileName);
} catch {
failedNames.add(sourceFileName);
}
} else {
failedNames.add(sourceFileName);
}
updateClipboardAfterCompletion(pendingNames.size === 0);
};
await sftp.startTransfer(clipboard.files, clipboard.sourceSide, focusedSide, {
sourcePane,
sourcePath: clipboard.sourcePath,
sourceConnectionId: clipboard.sourceConnectionId,
onTransferComplete: handleTransferComplete,
});
} catch {
toast.error("Paste failed. Please try again.", "SFTP");
}
}, [sftpRef]);
const handlePaste = useCallback(
(e: ClipboardEvent) => {
if (!isActive) return;
if (!isSftpNativeClipboardPasteEnabled(hotkeyScheme, keyBindings)) return;
const target = e.target as HTMLElement;
if (isEditableShortcutTarget(target) || hasOpenDialog()) return;
const hasInternalClipboardFiles = sftpClipboardStore.hasFiles();
const { focusedSide, pane } = getFocusedPane();
if (!pane?.connection) return;
const targetPath = getClipboardUploadTarget(pane);
const connectionId = pane.connection.id;
const tabId = pane.id;
const endpointPin: UploadEndpointPin = {
isLocal: pane.connection.isLocal,
hostId: pane.connection.isLocal ? null : (pane.connection.hostId ?? null),
cacheKey: pane.connection.isLocal
? "local"
: (sftpRef.current.getConnectionCacheKey?.(connectionId) ?? null),
};
const pendingClipboardWrite = pendingSftpSystemClipboardWrite;
const bridge = netcattyBridge.get();
const dataTransfer = e.clipboardData;
const hasClipboardItems = (dataTransfer?.items?.length ?? 0) > 0;
// webkitGetAsEntry must be invoked synchronously during the paste event.
const dropEntriesPromise = dataTransfer?.items?.length
? extractDropEntries(dataTransfer).catch(() => [])
: null;
const pastedFileSnapshot = dataTransfer?.files?.length
? Array.from(dataTransfer.files).filter((file) => file.name)
: [];
if (!hasInternalClipboardFiles && !hasClipboardItems && !bridge?.readClipboardFiles) {
return;
}
const runPaste = async () => {
if (pendingClipboardWrite && hasInternalClipboardFiles) {
await pendingClipboardWrite;
await pasteInternalSftpClipboard(focusedSide, pane);
return;
}
if (bridge?.readClipboardFiles) {
const clipboardFiles = await bridge.readClipboardFiles();
if (clipboardFiles.length > 0) {
triggerPathBackedClipboardUpload(
clipboardFiles, focusedSide, targetPath, connectionId, tabId, endpointPin,
);
return;
}
}
if (dropEntriesPromise) {
const entries = await dropEntriesPromise;
if (entries.length > 0) {
triggerDropEntriesClipboardUpload(
entries, focusedSide, targetPath, connectionId, tabId, endpointPin,
);
return;
}
}
if (pastedFileSnapshot.length > 0) {
const pathBackedFiles: ClipboardLocalFile[] = pastedFileSnapshot
.map((file) => ({
path: bridge?.getPathForFile?.(file) || file.name,
name: file.name,
isDirectory: false,
size: file.size,
}))
.filter((file) => file.path.includes("/") || file.path.includes("\\"));
if (pathBackedFiles.length > 0) {
triggerPathBackedClipboardUpload(
pathBackedFiles, focusedSide, targetPath, connectionId, tabId, endpointPin,
);
return;
}
}
if (hasInternalClipboardFiles) {
await pasteInternalSftpClipboard(focusedSide, pane);
}
};
e.preventDefault();
e.stopPropagation();
void runPaste();
},
[
getClipboardUploadTarget,
getFocusedPane,
hotkeyScheme,
isActive,
keyBindings,
pasteInternalSftpClipboard,
sftpRef,
triggerDropEntriesClipboardUpload,
triggerPathBackedClipboardUpload,
],
);
const handleKeyDown = useCallback(
async (e: KeyboardEvent) => {
// Basic SFTP keyboard navigation should work whenever the SFTP tab is active,
// even if the user has disabled global/custom hotkeys.
if (!isActive) return;
const target = e.target as HTMLElement;
// Skip when a dialog or overlay is open to prevent SFTP shortcuts from
// firing while interacting with unrelated dialogs (e.g. settings, confirm).
if (hasOpenDialog()) {
return;
}
const isEditableTarget = isEditableShortcutTarget(target);
const isSftpSearchTarget = Boolean(target.closest?.(
'[data-section="terminal-sftp-path"], [data-section="terminal-sftp-filter-bar"]',
));
if (isEditableTarget && !isSftpSearchTarget) {
return;
}
if (hotkeyScheme !== "disabled") {
const isMac = hotkeyScheme === "mac";
const searchBinding = keyBindings.find((binding) => binding.action === "searchTerminal");
const searchKey = searchBinding ? (isMac ? searchBinding.mac : searchBinding.pc) : null;
if (searchKey && matchesKeyBinding(e, searchKey, isMac)) {
const { pane } = getFocusedPane();
if (!pane?.connection) return;
e.preventDefault();
e.stopPropagation();
sftpFilterFocusStore.request(pane.id);
return;
}
}
// Path/filter inputs may handle search above, but no other SFTP shortcut.
if (isEditableTarget) {
return;
}
// ── Printable keys: select the first visible name with this prefix ──
if (
e.key.length === 1
&& !e.ctrlKey
&& !e.metaKey
&& !e.altKey
&& !e.isComposing
&& !/^\s$/u.test(e.key)
&& !target.closest?.('[role="menu"], [role="listbox"]')
) {
const { sftp, focusedSide, pane } = getFocusedPane();
if (!pane?.connection) return;
const source = resolveSftpTypeaheadSource(
sftpPaneViewModeStore.get(pane.id),
sftpListOrderStore.getItems(pane.id),
sftpTreeSelectionStore.getPaneState(pane.id).visibleItems,
);
if (source.names.length === 0) return;
const previous = typeaheadRef.current?.paneId === pane.id
? typeaheadRef.current.state
: null;
const result = advanceSftpTypeahead(source.names, previous, e.key, Date.now());
typeaheadRef.current = { paneId: pane.id, state: result.state };
e.preventDefault();
e.stopPropagation();
if (result.matchIndex < 0) return;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (source.kind === 'list') {
sftp.rangeSelect(focusedSide, [source.names[result.matchIndex]]);
} else {
sftpTreeSelectionStore.setSelection(pane.id, [source.items[result.matchIndex].path]);
}
sftpKeyboardSelectionStore.set(pane.id, result.matchIndex, result.matchIndex);
return;
}
// ── Arrow Up/Down: move selection ────────────────────────────────
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
const sftp = sftpRef.current;
const focusedSide = sftpFocusStore.getFocusedSide();
const pane = focusedSide === "left"
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
if (!pane || !pane.connection) return;
const delta = e.key === 'ArrowDown' ? 1 : -1;
const viewMode = sftpPaneViewModeStore.get(pane.id);
// List view: navigate sorted display files. The explicit view mode is
// authoritative even when the active list has no visible items.
const listItems = sftpListOrderStore.getItems(pane.id);
if (viewMode === 'list') {
if (listItems.length === 0) return;
e.preventDefault();
e.stopPropagation();
// Resolve current focus position from tracked state, falling back
// to the actual selection when out of sync (e.g. after mouse click).
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
const currentSelected = Array.from(pane.selectedFiles) as string[];
if (currentSelected.length === 0) {
// No selection: start from before the list so the first arrow press lands on item 0.
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else if (!currentSelected.includes(listItems[focusIdx])) {
// Tracked focus doesn't match actual selection, re-sync
focusIdx = listItems.indexOf(currentSelected[currentSelected.length - 1]);
if (focusIdx < 0) focusIdx = 0;
anchorIdx = focusIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= listItems.length) nextIdx = listItems.length - 1;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (e.shiftKey) {
// Shift+Arrow: extend range from anchor to new focus
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
sftp.rangeSelect(focusedSide, listItems.slice(start, end + 1));
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
// Tree view: navigate visible items
const treeState = sftpTreeSelectionStore.getPaneState(pane.id);
if (treeState.visibleItems.length > 0) {
e.preventDefault();
e.stopPropagation();
const items = treeState.visibleItems;
const currentSelected = [...treeState.selectedPaths];
// Use tracked state, re-sync if needed
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
if (currentSelected.length === 0) {
// No selection: start from before the list so the first arrow press lands on item 0.
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else {
const focusPath = items[focusIdx]?.path;
if (!focusPath || !treeState.selectedPaths.has(focusPath)) {
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
anchorIdx = focusIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
}
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= items.length) nextIdx = items.length - 1;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (e.shiftKey) {
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
const paths = items.slice(start, end + 1).map(item => item.path);
sftpTreeSelectionStore.setSelection(pane.id, paths);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
return;
}
// Basic navigation actions (Enter, Backspace) must work even when
// custom hotkeys are disabled — they are essential SFTP navigation.
// When hotkeys are enabled, defer to matchSftpAction so user
// customizations are respected.
const basicNavAction = hotkeyScheme === "disabled" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey
? BASIC_NAV_KEYS[e.key]
: undefined;
if (hotkeyScheme === "disabled" && !basicNavAction) return;
const isMac = hotkeyScheme === "mac";
const matched = basicNavAction ? null : matchSftpAction(e, keyBindings, isMac);
if (!matched && !basicNavAction) return;
const action = basicNavAction ?? matched?.action;
if (!action || !SFTP_ACTIONS.has(action)) return;
const matchedKey = isMac ? matched?.binding.mac : matched?.binding.pc;
if (shouldLetNativePasteEventHandleSftpPaste(action, matchedKey)) {
return;
}
// Prevent default behavior
e.preventDefault();
e.stopPropagation();
const sftp = sftpRef.current;
const focusedSide = sftpFocusStore.getFocusedSide();
// Get the active pane for the focused side
const pane = focusedSide === "left"
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
if (!pane || !pane.connection) return;
const viewMode = sftpPaneViewModeStore.get(pane.id);
const isTreeView = viewMode === 'tree';
const treeSelectionState = sftpTreeSelectionStore.getPaneState(pane.id);
const { selectedFileNames, treeSelection } = resolveSftpActiveSelection(
viewMode,
Array.from(pane.selectedFiles) as string[],
sftpTreeSelectionStore.getSelectedItems(pane.id),
);
const treeActionSelection = treeSelection.filter((entry) => entry.name !== '..');
switch (action) {
case "sftpCopy": {
if (treeActionSelection.length > 0) {
const parentPaths = new Set(treeActionSelection.map((entry) => getParentPath(entry.path)));
if (parentPaths.size !== 1) {
toast.info("Tree selection across multiple folders can't be copied with shortcuts yet.", "SFTP");
return;
}
const clipboardFiles: SftpClipboardFile[] = treeActionSelection.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory,
}));
sftpClipboardStore.copy(
clipboardFiles,
Array.from(parentPaths)[0],
pane.connection.id,
focusedSide,
);
await replaceSystemClipboardWithSftpPaths(getSftpClipboardSystemTextPaths({
currentPath: pane.connection.currentPath,
selectedFileNames: [],
treeSelection: treeActionSelection,
}));
break;
}
// Copy selected files to clipboard
if (selectedFileNames.length === 0) return;
{
const filesByName = new Map((pane.files as SftpFileEntry[]).map(f => [f.name, f]));
const clipboardFiles: SftpClipboardFile[] = selectedFileNames.map((name: string) => {
const file = filesByName.get(name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
sftpClipboardStore.copy(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
await replaceSystemClipboardWithSftpPaths(getSftpClipboardSystemTextPaths({
currentPath: pane.connection.currentPath,
selectedFileNames,
treeSelection: [],
}));
}
break;
}
case "sftpCut": {
if (treeActionSelection.length > 0) {
const parentPaths = new Set(treeActionSelection.map((entry) => getParentPath(entry.path)));
if (parentPaths.size !== 1) {
toast.info("Tree selection across multiple folders can't be cut with shortcuts yet.", "SFTP");
return;
}
const clipboardFiles: SftpClipboardFile[] = treeActionSelection.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory,
}));
sftpClipboardStore.cut(
clipboardFiles,
Array.from(parentPaths)[0],
pane.connection.id,
focusedSide,
);
await replaceSystemClipboardWithSftpPaths(getSftpClipboardSystemTextPaths({
currentPath: pane.connection.currentPath,
selectedFileNames: [],
treeSelection: treeActionSelection,
}));
break;
}
// Cut selected files to clipboard
if (selectedFileNames.length === 0) return;
{
const filesByName = new Map((pane.files as SftpFileEntry[]).map(f => [f.name, f]));
const clipboardFiles: SftpClipboardFile[] = selectedFileNames.map((name: string) => {
const file = filesByName.get(name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
sftpClipboardStore.cut(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
await replaceSystemClipboardWithSftpPaths(getSftpClipboardSystemTextPaths({
currentPath: pane.connection.currentPath,
selectedFileNames,
treeSelection: [],
}));
}
break;
}
case "sftpPaste": {
await pasteInternalSftpClipboard(focusedSide, pane);
break;
}
case "sftpSelectAll": {
const selectAllTarget = resolveSftpSelectAllTarget(
viewMode,
treeSelectionState.visibleItems.length,
);
if (selectAllTarget === 'none') break;
if (selectAllTarget === 'tree') {
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftpTreeSelectionStore.selectAllVisible(pane.id);
break;
}
// Select all files in the current pane
// TODO: Reference already-computed filtered files from useSftpPaneFiles
// instead of re-implementing the hidden file + filter logic here.
// This requires either lifting the computed files into pane state or
// passing them via a shared store, which needs a larger refactor.
const term = pane.filter.trim().toLowerCase();
let visibleFiles = filterHiddenFiles(pane.files, pane.showHiddenFiles);
if (term) {
visibleFiles = visibleFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}
const allFileNames = visibleFiles
.filter((f) => f.name !== "..")
.map((f) => f.name);
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftp.rangeSelect(focusedSide, allFileNames);
break;
}
case "sftpRename": {
if (treeActionSelection.length === 1) {
sftpDialogActionStore.trigger("rename", dialogActionScopeId, [treeActionSelection[0].path]);
break;
}
// Trigger rename for the first selected file
if (selectedFileNames.length !== 1) return;
sftpDialogActionStore.trigger("rename", dialogActionScopeId, selectedFileNames);
break;
}
case "sftpDelete": {
if (treeActionSelection.length > 0) {
sftpDialogActionStore.trigger(
"delete",
dialogActionScopeId,
treeActionSelection.map((entry) => entry.path),
);
break;
}
// Delete selected files
if (selectedFileNames.length === 0) return;
sftpDialogActionStore.trigger("delete", dialogActionScopeId, selectedFileNames);
break;
}
case "sftpRefresh": {
// Refresh the current pane
sftp.refresh(focusedSide);
break;
}
case "sftpNewFolder": {
// Create new folder
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
break;
}
case "sftpOpen": {
if (!isTreeView && selectedFileNames.length === 1) {
const fileName = selectedFileNames[0];
const entry = (pane.files as SftpFileEntry[]).find(f => f.name === fileName);
if (entry) {
if (isNavigableDirectory(entry)) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, joinPath(pane.connection.currentPath, entry.name));
} else {
sftp.openEntry(focusedSide, entry);
}
}
break;
}
if (!isTreeView) break;
if (treeActionSelection.length === 1) {
const item = treeActionSelection[0];
if (item.isDirectory) _kbSelectionState.delete(pane.id);
sftpTreeEnterStore.trigger(pane.id, item.path, item.isDirectory);
}
break;
}
case "sftpGoParent": {
const windowsOpts = resolveSftpWindowsPathOptions(
pane.connection.currentPath,
pane.connection.homeDir,
);
const parentPath = getParentPath(pane.connection.currentPath, windowsOpts);
if (parentPath !== pane.connection.currentPath) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, parentPath);
}
break;
}
case "sftpNavigateTo": {
// Navigate to the selected directory (useful in tree view)
// Filter out ".." entry for consistency with other handlers
if (treeActionSelection.length === 1 && treeActionSelection[0].isDirectory) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, treeActionSelection[0].path);
break;
}
// In list view, navigate to selected directory
if (selectedFileNames.length === 1) {
const entry = (pane.files as SftpFileEntry[]).find(f => f.name === selectedFileNames[0]);
if (entry && isNavigableDirectory(entry)) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, joinPath(pane.connection.currentPath, entry.name));
}
}
break;
}
}
},
[dialogActionScopeId, getFocusedPane, hotkeyScheme, isActive, keyBindings, pasteInternalSftpClipboard, sftpRef]
);
useEffect(() => {
if (!isActive) return;
// Use capture phase to intercept before other handlers
const onKeyDown = (event: KeyboardEvent) => {
if (isAppLockOverlayActive()) return;
handleKeyDown(event);
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [handleKeyDown, isActive]);
useEffect(() => {
if (!isActive) return;
window.addEventListener("paste", handlePaste, true);
return () => window.removeEventListener("paste", handlePaste, true);
}, [handlePaste, isActive]);
};