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
1035 lines
38 KiB
TypeScript
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]);
|
|
};
|