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

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

View File

@@ -0,0 +1,37 @@
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
export interface SftpSelectionTarget {
side: "left" | "right";
tabId: string;
}
export const keepOnlyPaneSelections = (
sftp: SftpStateApi,
target: SftpSelectionTarget | null,
) => {
sftp.clearSelectionsExcept(target);
const paneIds = [
...sftp.leftTabs.tabs.map((tab) => tab.id),
...sftp.rightTabs.tabs.map((tab) => tab.id),
];
for (const paneId of paneIds) {
if (target?.tabId === paneId) continue;
sftpTreeSelectionStore.clearSelection(paneId);
}
};
export const keepOnlyActivePaneSelections = (
sftp: SftpStateApi,
side: "left" | "right",
): SftpSelectionTarget | null => {
const tabId = sftp.getActiveTabId(side);
if (!tabId) {
keepOnlyPaneSelections(sftp, null);
return null;
}
const target = { side, tabId } as const;
keepOnlyPaneSelections(sftp, target);
return target;
};

View File

@@ -0,0 +1,53 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import {
getGlobalSftpBookmarksSnapshot,
setGlobalSftpBookmarks,
subscribeGlobalSftpBookmarks,
} from "../../../application/state/sftp/globalSftpBookmarks";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
interface UseGlobalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useGlobalSftpBookmarks = ({
currentPath,
}: UseGlobalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(
subscribeGlobalSftpBookmarks,
getGlobalSftpBookmarksSnapshot,
getGlobalSftpBookmarksSnapshot,
);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const addBookmark = useCallback((path: string) => {
if (!path) return;
if (bookmarks.some((b) => b.path === path)) return;
setGlobalSftpBookmarks((prev) => [...prev, createSftpBookmark(path, { global: true })]);
}, [bookmarks]);
const deleteBookmark = useCallback((id: string) => {
setGlobalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
const reorderBookmark = useCallback((fromId: string, toId: string) => {
setGlobalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
}, []);
const renameBookmark = useCallback((id: string, label: string) => {
setGlobalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
addBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};

View File

@@ -0,0 +1,8 @@
/** @deprecated Import from `@/application/state/sftp/localSftpBookmarks` instead. */
export {
useLocalSftpBookmarks,
subscribeLocalSftpBookmarks,
getLocalSftpBookmarksSnapshot,
rehydrateLocalSftpBookmarks,
setLocalSftpBookmarks,
} from "../../../application/state/sftp/localSftpBookmarks";

View File

@@ -0,0 +1,79 @@
import { useCallback, useMemo } from "react";
import type { Host, SftpBookmark } from "../../../domain/models";
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
interface UseSftpBookmarksParams {
host: Host | undefined;
currentPath: string | undefined;
onUpdateHost: ((host: Host) => void) | undefined;
}
interface UseSftpBookmarksResult {
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
toggleBookmark: () => void;
deleteBookmark: (id: string) => void;
reorderBookmark: (fromId: string, toId: string) => void;
renameBookmark: (id: string, label: string) => void;
}
export const useSftpBookmarks = ({
host,
currentPath,
onUpdateHost,
}: UseSftpBookmarksParams): UseSftpBookmarksResult => {
const bookmarks = useMemo(() => host?.sftpBookmarks ?? [], [host]);
const isCurrentPathBookmarked = useMemo(
() =>
!!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const updateHostBookmarks = useCallback(
(newBookmarks: SftpBookmark[]) => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, sftpBookmarks: newBookmarks });
},
[host, onUpdateHost],
);
const toggleBookmark = useCallback(() => {
if (!currentPath || !host) return;
if (isCurrentPathBookmarked) {
updateHostBookmarks(bookmarks.filter((b) => b.path !== currentPath));
} else {
updateHostBookmarks([...bookmarks, createSftpBookmark(currentPath)]);
}
}, [currentPath, host, isCurrentPathBookmarked, bookmarks, updateHostBookmarks]);
const deleteBookmark = useCallback(
(id: string) => {
updateHostBookmarks(bookmarks.filter((b) => b.id !== id));
},
[bookmarks, updateHostBookmarks],
);
const reorderBookmark = useCallback(
(fromId: string, toId: string) => {
updateHostBookmarks(moveSftpBookmark(bookmarks, fromId, toId));
},
[bookmarks, updateHostBookmarks],
);
const renameBookmark = useCallback(
(id: string, label: string) => {
updateHostBookmarks(renameSftpBookmark(bookmarks, id, label));
},
[bookmarks, updateHostBookmarks],
);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
reorderBookmark,
renameBookmark,
};
};

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpClipboardStore` instead. */
export {
sftpClipboardStore,
useSftpClipboard,
type SftpClipboardFile,
} from "../../../application/state/sftp/sftpClipboardStore";

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpDialogActionStore` instead. */
export {
sftpDialogActionStore,
useSftpDialogAction,
useSftpDialogActionHandler,
} from "../../../application/state/sftp/sftpDialogActionStore";

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpFocusStore` instead. */
export {
sftpFocusStore,
useSftpFocusedSide,
type SftpFocusedSide,
} from "../../../application/state/sftp/sftpFocusStore";

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpHostViewModeStore` instead. */
export {
getHostViewMode,
setHostViewMode,
useSftpHostViewMode,
} from "../../../application/state/sftp/sftpHostViewModeStore";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
/**
* Lightweight store that tracks the sorted display file names per SFTP pane.
* Used by keyboard shortcuts to navigate with ArrowUp/ArrowDown in list view.
*/
const paneItems = new Map<string, string[]>();
export const sftpListOrderStore = {
/** Update the ordered list of file names for a pane (call from SftpPaneFileList). */
setItems: (paneId: string, names: string[]) => {
paneItems.set(paneId, names);
},
/** Get the ordered list of file names (excluding "..") for arrow key navigation. */
getItems: (paneId: string): string[] => paneItems.get(paneId) ?? [],
clearPane: (paneId: string) => {
paneItems.delete(paneId);
},
};

View File

@@ -0,0 +1,373 @@
import { useCallback, useRef, useState } from "react";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
import { getFileName, getParentPath } from "../../../application/state/sftp/utils";
import { logger } from "../../../lib/logger";
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
const RESERVED_NAMES = new Set([
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]);
interface UseSftpPaneDialogsParams {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
onCreateDirectoryAtPath: SftpPaneCallbacks["onCreateDirectoryAtPath"];
onCreateFile: SftpPaneCallbacks["onCreateFile"];
onCreateFileAtPath: SftpPaneCallbacks["onCreateFileAtPath"];
onRenameFileAtPath: SftpPaneCallbacks["onRenameFileAtPath"];
onDeleteFilesAtPath: SftpPaneCallbacks["onDeleteFilesAtPath"];
onClearSelection: SftpPaneCallbacks["onClearSelection"];
onMutateSuccess?: (paths?: string[]) => void;
}
interface UseSftpPaneDialogsResult {
showHostPicker: boolean;
hostSearch: string;
showNewFolderDialog: boolean;
newFolderName: string;
showNewFileDialog: boolean;
newFileName: string;
fileNameError: string | null;
showOverwriteConfirm: boolean;
overwriteTarget: string | null;
showRenameDialog: boolean;
renameTarget: string | null;
renameName: string;
showDeleteConfirm: boolean;
deleteTargets: string[];
isCreating: boolean;
isCreatingFile: boolean;
isRenaming: boolean;
isDeleting: boolean;
setShowHostPicker: (open: boolean) => void;
setHostSearch: (value: string) => void;
setShowNewFolderDialog: (open: boolean) => void;
setNewFolderName: (value: string) => void;
setShowNewFileDialog: (open: boolean) => void;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
setShowOverwriteConfirm: (open: boolean) => void;
setShowRenameDialog: (open: boolean) => void;
setRenameName: (value: string) => void;
setShowDeleteConfirm: (open: boolean) => void;
handleCreateFolder: () => Promise<void>;
handleCreateFile: (forceOverwrite?: boolean) => Promise<void>;
handleConfirmOverwrite: () => Promise<void>;
handleRename: () => Promise<void>;
handleDelete: () => Promise<void>;
openNewFolderDialogAtPath: (path: string) => void;
openNewFileDialogAtPath: (path: string) => void;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (names: string[]) => void;
getNextUntitledName: (existingFiles: string[]) => string;
}
export const useSftpPaneDialogs = ({
t,
pane,
onCreateDirectory,
onCreateDirectoryAtPath,
onCreateFile,
onCreateFileAtPath,
onRenameFileAtPath,
onDeleteFilesAtPath,
onClearSelection,
onMutateSuccess,
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
const [showHostPicker, setShowHostPicker] = useState(false);
const [hostSearch, setHostSearch] = useState("");
const [showNewFolderDialogState, setShowNewFolderDialogState] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [showNewFileDialogState, setShowNewFileDialogState] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [createTargetPath, setCreateTargetPath] = useState<string | null>(null);
const [fileNameError, setFileNameError] = useState<string | null>(null);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameName, setRenameName] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Refs for values accessed inside useCallback to avoid stale closures
const newFolderNameRef = useRef(newFolderName);
newFolderNameRef.current = newFolderName;
const newFileNameRef = useRef(newFileName);
newFileNameRef.current = newFileName;
const createTargetPathRef = useRef(createTargetPath);
createTargetPathRef.current = createTargetPath;
const renameTargetRef = useRef(renameTarget);
renameTargetRef.current = renameTarget;
const renameNameRef = useRef(renameName);
renameNameRef.current = renameName;
const deleteTargetsRef = useRef(deleteTargets);
deleteTargetsRef.current = deleteTargets;
const paneRef = useRef(pane);
paneRef.current = pane;
const validateFileName = useCallback(
(name: string): string | null => {
const trimmed = name.trim();
if (!trimmed) return null;
const invalidMatch = trimmed.match(INVALID_FILENAME_CHARS);
if (invalidMatch) {
return t("sftp.error.invalidFileName", { chars: invalidMatch[0] });
}
const baseName = trimmed.split(".")[0].toUpperCase();
if (RESERVED_NAMES.has(baseName)) {
return t("sftp.error.reservedName");
}
return null;
},
[t],
);
const getNextUntitledName = useCallback((existingFiles: string[]): string => {
const existingSet = new Set(existingFiles.map((f) => f.toLowerCase()));
if (!existingSet.has("untitled.txt")) {
return "untitled.txt";
}
let counter = 1;
while (counter < 1000) {
const name = `untitled (${counter}).txt`;
if (!existingSet.has(name.toLowerCase())) {
return name;
}
counter++;
}
return `untitled_${Date.now()}.txt`;
}, []);
const handleCreateFolder = useCallback(async () => {
if (!newFolderNameRef.current.trim() || isCreating) return;
setIsCreating(true);
try {
if (createTargetPathRef.current) {
await onCreateDirectoryAtPath(createTargetPathRef.current, newFolderNameRef.current.trim());
} else {
await onCreateDirectory(newFolderNameRef.current.trim());
}
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
setShowNewFolderDialogState(false);
setCreateTargetPath(null);
setNewFolderName("");
} catch (err) {
logger.warn("Failed to create folder", err);
} finally {
setIsCreating(false);
}
}, [isCreating, onCreateDirectory, onCreateDirectoryAtPath, onMutateSuccess]);
const handleCreateFile = useCallback(async (forceOverwrite = false) => {
const trimmedName = newFileNameRef.current.trim();
if (!trimmedName || isCreatingFile) return;
const error = validateFileName(trimmedName);
if (error) {
setFileNameError(error);
return;
}
const currentPane = paneRef.current;
if (!forceOverwrite && (!createTargetPathRef.current || createTargetPathRef.current === currentPane.connection?.currentPath)) {
const existingFile = currentPane.files.find(
(f) =>
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
);
if (existingFile) {
setOverwriteTarget(trimmedName);
setShowOverwriteConfirm(true);
return;
}
}
setIsCreatingFile(true);
try {
if (createTargetPathRef.current) {
await onCreateFileAtPath(createTargetPathRef.current, trimmedName);
} else {
await onCreateFile(trimmedName);
}
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
setShowNewFileDialogState(false);
setShowOverwriteConfirm(false);
setOverwriteTarget(null);
setCreateTargetPath(null);
setNewFileName("");
setFileNameError(null);
} catch (err) {
logger.warn("Failed to create file", err);
} finally {
setIsCreatingFile(false);
}
}, [isCreatingFile, validateFileName, onCreateFile, onCreateFileAtPath, onMutateSuccess]);
const handleConfirmOverwrite = useCallback(async () => {
await handleCreateFile(true);
}, [handleCreateFile]);
const handleRename = useCallback(async () => {
if (!renameTargetRef.current || !renameNameRef.current.trim() || isRenaming) return;
setIsRenaming(true);
try {
// renameTarget is always a full path; use the path-aware variant
await onRenameFileAtPath(renameTargetRef.current, renameNameRef.current.trim());
onMutateSuccess?.([getParentPath(renameTargetRef.current)]);
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
} catch (err) {
logger.warn("Failed to rename file", err);
} finally {
setIsRenaming(false);
}
}, [isRenaming, onRenameFileAtPath, onMutateSuccess]);
const handleDelete = useCallback(async () => {
if (deleteTargetsRef.current.length === 0 || isDeleting) return;
setIsDeleting(true);
try {
// deleteTargets are full paths; group by parent dir and use path-aware variant
const byDir = new Map<string, string[]>();
for (const fullPath of deleteTargetsRef.current) {
const dir = getParentPath(fullPath);
const name = getFileName(fullPath);
const list = byDir.get(dir) ?? [];
list.push(name);
byDir.set(dir, list);
}
const connectionId = paneRef.current.connection?.id;
if (!connectionId) {
throw new Error("Pane connection is no longer available");
}
for (const [dir, names] of byDir) {
await onDeleteFilesAtPath(connectionId, dir, names);
}
onMutateSuccess?.(Array.from(byDir.keys()));
setShowDeleteConfirm(false);
setDeleteTargets([]);
onClearSelection();
} catch (err) {
logger.warn("Failed to delete files", err);
} finally {
setIsDeleting(false);
}
}, [isDeleting, onDeleteFilesAtPath, onMutateSuccess, onClearSelection]);
// entryPath is the full path; renameName is initialized to the basename
const openRenameDialog = useCallback((entryPath: string) => {
setRenameTarget(entryPath);
setRenameName(getFileName(entryPath) || entryPath);
setShowRenameDialog(true);
}, []);
const setShowNewFolderDialog = useCallback((open: boolean) => {
if (!open) {
setCreateTargetPath(null);
}
setShowNewFolderDialogState(open);
}, []);
const setShowNewFileDialog = useCallback((open: boolean) => {
if (!open) {
setCreateTargetPath(null);
}
setShowNewFileDialogState(open);
}, []);
const openNewFolderDialogAtPath = useCallback((path: string) => {
setCreateTargetPath(path);
setNewFolderName("");
setShowNewFolderDialogState(true);
}, []);
const openNewFileDialogAtPath = useCallback((path: string) => {
setCreateTargetPath(path);
setNewFileName("");
setFileNameError(null);
setShowNewFileDialogState(true);
}, []);
const openDeleteConfirm = useCallback((names: string[]) => {
setDeleteTargets(names);
setShowDeleteConfirm(true);
}, []);
return {
showHostPicker,
hostSearch,
showNewFolderDialog: showNewFolderDialogState,
newFolderName,
showNewFileDialog: showNewFileDialogState,
newFileName,
fileNameError,
showOverwriteConfirm,
overwriteTarget,
showRenameDialog,
renameTarget,
renameName,
showDeleteConfirm,
deleteTargets,
isCreating,
isCreatingFile,
isRenaming,
isDeleting,
setShowHostPicker,
setHostSearch,
setShowNewFolderDialog,
setNewFolderName,
setShowNewFileDialog,
setNewFileName,
setFileNameError,
setShowOverwriteConfirm,
setShowRenameDialog,
setRenameName,
setShowDeleteConfirm,
handleCreateFolder,
handleCreateFile,
handleConfirmOverwrite,
handleRename,
handleDelete,
openNewFolderDialogAtPath,
openNewFileDialogAtPath,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
};
};

View File

@@ -0,0 +1,288 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { isNavigableDirectory } from "../utils";
import { joinPath } from "../../../application/state/sftp/utils";
interface UseSftpPaneDragAndSelectParams {
side: "left" | "right";
pane: {
selectedFiles: Set<string>;
connection?: { currentPath: string; id: string } | null;
};
sortedDisplayFiles: SftpFileEntry[];
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onDragStart: SftpDragCallbacks["onDragStart"];
onReceiveFromOtherPane: SftpPaneCallbacks["onReceiveFromOtherPane"];
onMoveEntriesToPath: SftpPaneCallbacks["onMoveEntriesToPath"];
onUploadExternalFiles?: SftpPaneCallbacks["onUploadExternalFiles"];
onOpenEntry: SftpPaneCallbacks["onOpenEntry"];
onRangeSelect: SftpPaneCallbacks["onRangeSelect"];
onToggleSelection: SftpPaneCallbacks["onToggleSelection"];
}
interface UseSftpPaneDragAndSelectResult {
dragOverEntry: string | null;
isDragOverPane: boolean;
paneContainerRef: React.RefObject<HTMLDivElement>;
handlePaneDragOver: (e: React.DragEvent) => void;
handlePaneDragLeave: (e: React.DragEvent) => void;
handlePaneDrop: (e: React.DragEvent) => Promise<void>;
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleRowDragLeave: () => void;
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
handleRowOpen: (entry: SftpFileEntry) => void;
}
export const useSftpPaneDragAndSelect = ({
side,
pane,
sortedDisplayFiles,
draggedFiles,
onDragStart,
onReceiveFromOtherPane,
onMoveEntriesToPath,
onUploadExternalFiles,
onOpenEntry,
onRangeSelect,
onToggleSelection,
}: UseSftpPaneDragAndSelectParams): UseSftpPaneDragAndSelectResult => {
const [dragOverEntry, setDragOverEntry] = useState<string | null>(null);
const [isDragOverPane, setIsDragOverPane] = useState(false);
const paneContainerRef = useRef<HTMLDivElement>(null);
const lastSelectedIndexRef = useRef<number | null>(null);
const selectedFilesRef = useRef(pane.selectedFiles);
selectedFilesRef.current = pane.selectedFiles;
const sortedFilesRef = useRef(sortedDisplayFiles);
sortedFilesRef.current = sortedDisplayFiles;
const draggedFilesRef = useRef(draggedFiles);
draggedFilesRef.current = draggedFiles;
const onReceiveRef = useRef(onReceiveFromOtherPane);
onReceiveRef.current = onReceiveFromOtherPane;
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
const onUploadRef = useRef(onUploadExternalFiles);
onUploadRef.current = onUploadExternalFiles;
useEffect(() => {
if (pane.selectedFiles.size === 0) {
lastSelectedIndexRef.current = null;
}
}, [pane.selectedFiles.size]);
const getSamePaneDragPaths = useCallback((): string[] | null => {
const dragged = draggedFilesRef.current;
if (!dragged || dragged.length === 0) return null;
if (dragged[0]?.side !== side) return null;
const currentConnectionId = pane.connection?.id;
const paths = dragged
.filter((file) => file.sourceConnectionId === currentConnectionId && file.sourcePath)
.map((file) => joinPath(file.sourcePath!, file.name));
return paths.length > 0 ? paths : null;
}, [pane.connection?.id, side]);
const handlePaneDragOver = useCallback((e: React.DragEvent) => {
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
return;
}
if (!draggedFilesRef.current || draggedFilesRef.current[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
}, [side]);
const handlePaneDragLeave = useCallback((e: React.DragEvent) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && paneContainerRef.current?.contains(relatedTarget)) return;
setIsDragOverPane(false);
setDragOverEntry(null);
}, []);
const handlePaneDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverPane(false);
setDragOverEntry(null);
if (draggedFilesRef.current && draggedFilesRef.current.length > 0) {
if (draggedFilesRef.current[0]?.side !== side) {
onReceiveRef.current(draggedFilesRef.current);
}
return;
}
if (e.dataTransfer.items.length > 0 && onUploadRef.current) {
await onUploadRef.current(e.dataTransfer);
}
}, [side]);
const handleFileDragStart = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (entry.name === "..") {
e.preventDefault();
return;
}
const selectedNames = new Set(selectedFilesRef.current);
const files = selectedNames.has(entry.name)
? sortedFilesRef.current
.filter((f) => selectedNames.has(f.name))
.map((f) => ({
name: f.name,
isDirectory: isNavigableDirectory(f),
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
side,
}))
: [
{
name: entry.name,
isDirectory: isNavigableDirectory(entry),
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
side,
},
];
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
onDragStart(files, side);
},
[onDragStart, pane.connection?.currentPath, pane.connection?.id, side],
);
const handleEntryDragOver = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
const samePaneDragPaths = getSamePaneDragPaths();
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
setDragOverEntry(entry.name);
return;
}
// Handle cross-pane internal drag
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(entry.name);
}
return;
}
// Handle external file drag (from OS file explorer)
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
setDragOverEntry(entry.name);
}
},
[getSamePaneDragPaths, side],
);
const handleEntryDrop = useCallback(
async (entry: SftpFileEntry, e: React.DragEvent) => {
const samePaneDragPaths = getSamePaneDragPaths();
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
const targetPath = pane.connection?.currentPath
? joinPath(pane.connection.currentPath, entry.name)
: undefined;
if (targetPath) {
await onMoveEntriesToPathRef.current(samePaneDragPaths, targetPath);
}
return;
}
// Handle cross-pane internal drag
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
const targetPath = pane.connection?.currentPath
? joinPath(pane.connection.currentPath, entry.name)
: undefined;
onReceiveRef.current(
draggedFilesRef.current.map((file) => ({ ...file, targetPath })),
);
}
return;
}
// Handle external file drop on a directory entry
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
if (onUploadRef.current && pane.connection?.currentPath) {
const targetPath = joinPath(pane.connection.currentPath, entry.name);
void onUploadRef.current(e.dataTransfer, targetPath);
}
}
},
[getSamePaneDragPaths, side, pane.connection?.currentPath],
);
const handleRowSelect = useCallback(
(entry: SftpFileEntry, index: number, e: React.MouseEvent) => {
if (entry.name === "..") return;
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const selectedFileNames = sortedFilesRef.current
.slice(start, end + 1)
.filter((f) => f.name !== "..")
.map((f) => f.name);
onRangeSelect(selectedFileNames);
} else {
onToggleSelection(entry.name, e.ctrlKey || e.metaKey);
lastSelectedIndexRef.current = index;
}
},
[onRangeSelect, onToggleSelection],
);
const handleRowOpen = useCallback(
(entry: SftpFileEntry) => {
onOpenEntry(entry);
},
[onOpenEntry],
);
const handleRowDragLeave = useCallback(() => {
setDragOverEntry(null);
}, []);
return {
dragOverEntry,
isDragOverPane,
paneContainerRef,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handleFileDragStart,
handleEntryDragOver,
handleEntryDrop,
handleRowDragLeave,
handleRowSelect,
handleRowOpen,
};
};

View File

@@ -0,0 +1,90 @@
import { useMemo } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import { isWindowsRoot, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
import type { SortField, SortOrder } from "../utils";
import { filterHiddenFiles, filterSftpEntriesByName, sortSftpEntries } from "../utils";
interface UseSftpPaneFilesParams {
files: SftpFileEntry[];
filter: string;
connection: SftpPane["connection"] | null;
showHiddenFiles: boolean;
enableListView: boolean;
sortField: SortField;
sortOrder: SortOrder;
directoriesFirst: boolean;
}
interface UseSftpPaneFilesResult {
filteredFiles: SftpFileEntry[];
displayFiles: SftpFileEntry[];
sortedDisplayFiles: SftpFileEntry[];
}
export const useSftpPaneFiles = ({
files,
filter,
connection,
showHiddenFiles,
enableListView,
sortField,
sortOrder,
directoriesFirst,
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
// Extract ".." once and process the remaining files through filter -> sort
// in fewer passes, instead of repeatedly filtering/finding ".." entries.
const filteredFiles = useMemo(() => {
if (!enableListView) return [] as SftpFileEntry[];
return filterSftpEntriesByName(
filterHiddenFiles(files, showHiddenFiles),
filter,
);
}, [enableListView, files, filter, showHiddenFiles]);
const { displayFiles, sortedDisplayFiles } = useMemo(() => {
if (!connection || !enableListView) {
return { displayFiles: [] as SftpFileEntry[], sortedDisplayFiles: [] as SftpFileEntry[] };
}
const isRootPath =
connection.currentPath === "/" ||
isWindowsRoot(
connection.currentPath,
resolveSftpWindowsPathOptions(connection.currentPath, connection.homeDir),
);
// Split ".." from other files in a single pass
let parentEntry: SftpFileEntry | undefined;
const otherFiles: SftpFileEntry[] = [];
for (const f of filteredFiles) {
if (f.name === "..") {
parentEntry = f;
} else {
otherFiles.push(f);
}
}
// For non-root paths, always ensure a ".." entry exists
if (!isRootPath && !parentEntry) {
parentEntry = {
name: "..",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
};
}
const display = parentEntry ? [parentEntry, ...otherFiles] : otherFiles;
const sorted = otherFiles.length
? sortSftpEntries(otherFiles, sortField, sortOrder, directoriesFirst)
: otherFiles;
const sortedDisplay = parentEntry ? [parentEntry, ...sorted] : sorted;
return { displayFiles: display, sortedDisplayFiles: sortedDisplay };
}, [connection, directoriesFirst, enableListView, filteredFiles, sortField, sortOrder]);
return { filteredFiles, displayFiles, sortedDisplayFiles };
};

View File

@@ -0,0 +1,164 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import {
isWindowsPath,
normalizeSftpNavigationPath,
} from "../../../application/state/sftp/utils";
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
interface UseSftpPanePathParams {
connection: SftpPane["connection"] | null;
files: SftpFileEntry[];
showHiddenFiles: boolean;
onNavigateTo: (path: string) => void;
}
interface UseSftpPanePathResult {
isEditingPath: boolean;
editingPathValue: string;
showPathSuggestions: boolean;
pathSuggestionIndex: number;
pathInputRef: React.RefObject<HTMLInputElement>;
pathDropdownRef: React.RefObject<HTMLDivElement>;
pathSuggestions: { path: string; type: "folder" | "history" }[];
setEditingPathValue: (value: string) => void;
setShowPathSuggestions: (value: boolean) => void;
setPathSuggestionIndex: (value: number) => void;
handlePathBlur: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
}
export const useSftpPanePath = ({
connection,
files,
showHiddenFiles,
onNavigateTo,
}: UseSftpPanePathParams): UseSftpPanePathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const [showPathSuggestions, setShowPathSuggestions] = useState(false);
const [pathSuggestionIndex, setPathSuggestionIndex] = useState(-1);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathDropdownRef = useRef<HTMLDivElement>(null);
const pathSuggestions = useMemo(() => {
if (!isEditingPath || !connection) return [];
const currentValue = editingPathValue.trim().toLowerCase();
const suggestions: { path: string; type: "folder" | "history" }[] = [];
const folders = filterHiddenFiles(files, showHiddenFiles).filter(
(f) => isNavigableDirectory(f) && f.name !== "..",
);
folders.forEach((f) => {
const fullPath =
connection.currentPath === "/"
? `/${f.name}`
: `${connection.currentPath}/${f.name}`;
if (
!currentValue ||
fullPath.toLowerCase().includes(currentValue) ||
f.name.toLowerCase().includes(currentValue)
) {
suggestions.push({ path: fullPath, type: "folder" });
}
});
const quickPaths = ["/home", "/var", "/etc", "/tmp", "/usr", "/opt", "/root"];
quickPaths.forEach((qp) => {
if (!currentValue || qp.toLowerCase().includes(currentValue)) {
if (!suggestions.some((s) => s.path === qp)) {
suggestions.push({ path: qp, type: "history" });
}
}
});
return suggestions.slice(0, 8);
}, [connection, editingPathValue, files, isEditingPath, showHiddenFiles]);
const handlePathDoubleClick = () => {
if (!connection) return;
setEditingPathValue(connection.currentPath);
setIsEditingPath(true);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = useCallback((pathOverride?: string) => {
// Only treat //host/share as UNC when this pane is already on a Windows-style path.
const acceptForwardSlashUnc = !!(
connection && isWindowsPath(connection.currentPath)
);
const newPath = normalizeSftpNavigationPath(
pathOverride ?? editingPathValue,
{ acceptForwardSlashUnc },
);
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
if (connection && newPath !== connection.currentPath) {
onNavigateTo(newPath);
}
}, [connection, editingPathValue, onNavigateTo]);
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (showPathSuggestions && pathSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev < pathSuggestions.length - 1 ? prev + 1 : 0,
);
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev > 0 ? prev - 1 : pathSuggestions.length - 1,
);
return;
} else if (e.key === "Tab" && pathSuggestionIndex >= 0) {
e.preventDefault();
setEditingPathValue(pathSuggestions[pathSuggestionIndex].path);
return;
}
}
if (e.key === "Enter") {
if (pathSuggestionIndex >= 0 && pathSuggestions[pathSuggestionIndex]) {
handlePathSubmit(pathSuggestions[pathSuggestionIndex].path);
} else {
handlePathSubmit();
}
} else if (e.key === "Escape") {
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
}
};
const handlePathBlur = useCallback(() => {
setTimeout(() => {
if (!pathDropdownRef.current?.contains(document.activeElement)) {
handlePathSubmit();
}
}, 150);
}, [handlePathSubmit]);
return {
isEditingPath,
editingPathValue,
showPathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
pathSuggestions,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
};
};

View File

@@ -0,0 +1,5 @@
/** @deprecated Import from `@/application/state/sftp/useSftpPaneSorting` instead. */
export {
useSftpPaneSorting,
type UseSftpPaneSortingResult,
} from "../../../application/state/sftp/useSftpPaneSorting";

View File

@@ -0,0 +1,128 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
interface UseSftpPaneVirtualListParams {
isActive: boolean;
enabled?: boolean;
sortedDisplayFiles: SftpFileEntry[];
layoutKey?: string;
}
interface UseSftpPaneVirtualListResult {
fileListRef: React.RefObject<HTMLDivElement>;
rowHeight: number;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
}
export const useSftpPaneVirtualList = ({
isActive,
enabled = true,
sortedDisplayFiles,
layoutKey,
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const [rowHeight, setRowHeight] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollFrameRef = useRef<number | null>(null);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive || !enabled) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [enabled, isActive, layoutKey, sortedDisplayFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive || !enabled || sortedDisplayFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-row="true"]',
) as HTMLElement | null;
if (!rowElement) return;
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
setRowHeight(nextHeight);
}
});
return () => window.cancelAnimationFrame(raf);
}, [enabled, isActive, layoutKey, rowHeight, sortedDisplayFiles.length]);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
if (!isActive || !enabled) return;
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
},
[enabled, isActive],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = enabled && isActive && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedDisplayFiles.length * rowHeight
: 0;
const startIndex = shouldVirtualizeLocal
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
: 0;
const endIndex = shouldVirtualizeLocal
? Math.min(
sortedDisplayFiles.length - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
)
: sortedDisplayFiles.length - 1;
const visibleRowsLocal = shouldVirtualizeLocal
? sortedDisplayFiles
.slice(startIndex, endIndex + 1)
.map((entry, idx) => ({
entry,
index: startIndex + idx,
top: (startIndex + idx) * rowHeight,
}))
: sortedDisplayFiles.map((entry, index) => ({
entry,
index,
top: 0,
}));
return {
shouldVirtualize: shouldVirtualizeLocal,
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [enabled, isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
return {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
};
};

View File

@@ -0,0 +1,6 @@
/** @deprecated Import from `@/application/state/sftp/sftpTreeSelectionStore` instead. */
export {
sftpTreeSelectionStore,
useSftpTreeSelectionState,
type SftpTreeSelectionItem,
} from "../../../application/state/sftp/sftpTreeSelectionStore";

View File

@@ -0,0 +1,799 @@
import { useCallback, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { TransferStatus } from "../../../domain/models";
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
runBoundedConcurrency,
} from "../../../application/state/sftp/transferConcurrency";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../utils";
import { reportSftpUploadResults } from "../reportSftpUploadResults";
import { editorTabStore } from "../../../application/state/editorTabStore";
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
/**
* Multi-select roots each start their own interleaved folder walk / session
* work. Bound them so many selected directories cannot stampede the scheduler.
*/
const MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY = DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
export const useSftpViewFileOps = ({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
/** Host ID at the time the file was opened, to prevent saving to wrong host.
* Uses hostId (not connectionId) because auto-reconnect after a transient
* disconnect generates a fresh connectionId for the same endpoint. */
hostId?: string;
} | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
// Refs for frequently-changing state used inside stable callbacks
const fileOpenerTargetRef = useRef(fileOpenerTarget);
fileOpenerTargetRef.current = fileOpenerTarget;
const textEditorTargetRef = useRef(textEditorTarget);
textEditorTargetRef.current = textEditorTarget;
const onEditPermissionsLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
if (!pane.connection) return;
setPermissionsState({
file,
side: "left",
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
});
},
[sftpRef],
);
const onEditPermissionsRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.rightPane;
if (!pane.connection) return;
setPermissionsState({
file,
side: "right",
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
});
},
[sftpRef],
);
const handleEditFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
assertSftpFileFitsBuiltinEditor(file.size);
setLoadingTextContent(true);
setTextEditorTarget({ file, side, fullPath: resolvedFullPath, hostId: pane.connection.hostId });
const content = await sftpRef.current.readTextFile(side, resolvedFullPath);
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to load file", "SFTP");
setTextEditorTarget(null);
} finally {
setLoadingTextContent(false);
}
},
[sftpRef],
);
const handleOpenFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const savedOpener = getOpenerForFileRef.current(file.name);
if (savedOpener && savedOpener.openerType) {
if (savedOpener.openerType === "builtin-editor") {
handleEditFileForSide(side, file, resolvedFullPath);
return;
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
side,
resolvedFullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: autoSyncRef.current },
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
}
return;
}
}
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
setShowFileOpenerDialog(true);
},
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
);
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
const target = fileOpenerTargetRef.current;
if (!target) return;
if (setAsDefault) {
const ext = getFileExtension(target.file.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
handleEditFileForSide(target.side, target.file, target.fullPath);
} else if (openerType === "system-app" && systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
target.side,
target.fullPath,
target.file.name,
systemApp.path,
{ enableWatch: autoSyncRef.current },
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
}
}
setFileOpenerTarget(null);
},
[setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
const result = await sftpRef.current.selectApplication();
if (result) {
return { path: result.path, name: result.name };
}
return null;
}, [sftpRef]);
const handleSaveTextFile = useCallback(
async (content: string) => {
const target = textEditorTargetRef.current;
if (!target) return;
// Verify the SFTP connection hasn't switched to a different host.
// We check hostId (not connectionId) because auto-reconnect after a
// transient disconnect generates a fresh connectionId for the same
// endpoint. The auto-connect effect in SftpSidePanel blocks
// host-switching while the editor is open, so a hostId mismatch here
// reliably indicates a genuinely different endpoint.
const currentPane = target.side === "left"
? sftpRef.current.leftPane
: sftpRef.current.rightPane;
if (target.hostId && currentPane.connection?.hostId !== target.hostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
await sftpRef.current.writeTextFile(
target.side,
target.fullPath,
content,
);
},
[sftpRef],
);
const handlePromoteToTab = useCallback((snapshot: TextEditorModalSnapshot) => {
const target = textEditorTargetRef.current;
if (!target) return;
const pane = target.side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
const connection = pane.connection;
if (!connection || !target.hostId) return;
const editorId = editorTabStore.promoteFromModal({
sessionId: connection.id,
sftpTabId: pane.id,
hostId: target.hostId,
remotePath: target.fullPath,
fileName: target.file.name,
languageId: snapshot.languageId || getLanguageId(target.file.name),
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
});
activeTabStore.setActiveTabId(toEditorTabId(editorId));
// Close the modal
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, [sftpRef]);
const onEditFileLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
[handleEditFileForSide],
);
const onEditFileRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("right", file, fullPath),
[handleEditFileForSide],
);
const onOpenFileLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("left", file, fullPath),
[handleOpenFileForSide],
);
const onOpenFileRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("right", file, fullPath),
[handleOpenFileForSide],
);
const handleOpenFileWithSystemDefaultForSide = useCallback(
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
void sftpRef.current.openWithSystemDefault(side, resolvedFullPath, file.name, { enableWatch: autoSyncRef.current });
},
[sftpRef, autoSyncRef],
);
const onOpenFileWithSystemDefaultLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithSystemDefaultForSide("left", file, fullPath),
[handleOpenFileWithSystemDefaultForSide],
);
const onOpenFileWithSystemDefaultRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithSystemDefaultForSide("right", file, fullPath),
[handleOpenFileWithSystemDefaultForSide],
);
const handleOpenFileWithForSide = useCallback(
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
setShowFileOpenerDialog(true);
},
[sftpRef],
);
const onOpenFileWithLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("left", file, fullPath),
[handleOpenFileWithForSide],
);
const onOpenFileWithRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("right", file, fullPath),
[handleOpenFileWithForSide],
);
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer, targetPath);
reportSftpUploadResults({ results, t, toast });
} catch (error) {
logger.error("[SftpView] Failed to upload external files:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onUploadExternalFilesLeft = useCallback(
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("left", dataTransfer, targetPath),
[handleUploadExternalFilesForSide],
);
const onUploadExternalFilesRight = useCallback(
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("right", dataTransfer, targetPath),
[handleUploadExternalFilesForSide],
);
const handleUploadExternalFileListForSide = useCallback(
async (side: "left" | "right", fileList: FileList, targetPath?: string) => {
try {
const results = await sftpRef.current.uploadExternalFileList(side, fileList, targetPath);
reportSftpUploadResults({ results, t, toast });
} catch (error) {
logger.error("[SftpView] Failed to upload picked files:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onUploadExternalFileListLeft = useCallback(
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("left", fileList, targetPath),
[handleUploadExternalFileListForSide],
);
const onUploadExternalFileListRight = useCallback(
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("right", fileList, targetPath),
[handleUploadExternalFileListForSide],
);
const handleUploadExternalFolderForSide = useCallback(
async (side: "left" | "right", targetPath?: string) => {
if (!selectDirectory) {
toast.error(t("sftp.error.uploadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.uploadFolder"));
if (!selectedDirectory) return;
try {
const results = await sftpRef.current.uploadExternalFolderPath(side, selectedDirectory, targetPath);
const folderName = selectedDirectory.split(/[/\\]/).filter(Boolean).pop() || selectedDirectory;
reportSftpUploadResults({
results,
t,
toast,
successMessage: `${t("sftp.uploadFolder")}: ${folderName}`,
});
} catch (error) {
logger.error("[SftpView] Failed to upload picked folder:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[selectDirectory, sftpRef, t],
);
const onUploadExternalFolderLeft = useCallback(
(targetPath?: string) => handleUploadExternalFolderForSide("left", targetPath),
[handleUploadExternalFolderForSide],
);
const onUploadExternalFolderRight = useCallback(
(targetPath?: string) => handleUploadExternalFolderForSide("right", targetPath),
[handleUploadExternalFolderForSide],
);
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const isDirectory = isNavigableDirectory(file);
try {
// For local files, use blob download.
if (pane.connection.isLocal) {
if (isDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const content = await sftpRef.current.readBinaryFile(side, resolvedFullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
return;
}
// For remote SFTP files/directories, use transfer-center downloads
// (dedicated pool sessions via downloadToLocal).
if (!showSaveDialog || !getSftpIdForConnection) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const sftpId = getSftpIdForConnection(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
if (isDirectory) {
if (!selectDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
const targetPath = joinFsPath(selectedDirectory, file.name);
try {
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath: resolvedFullPath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceHostId: pane.connection.hostId,
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory: true,
});
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
} else if (status === "attention") {
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("sftp.error.downloadFailed");
if (!errorMessage.includes("cancelled") && !errorMessage.includes("canceled")) {
toast.error(errorMessage, "SFTP");
}
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
// User cancelled
return;
}
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
// Route through downloadToLocal so FileZilla-style transfer pool
// sessions are used (browse session stays free for listing).
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath: resolvedFullPath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceHostId: pane.connection.hostId,
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory: false,
totalBytes: fileSize,
});
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
} else if (status === "attention") {
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
}
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
const errorMessage = e instanceof Error ? e.message : String(e);
const isCancelError = errorMessage.includes("cancelled") || errorMessage.includes("canceled");
if (!isCancelError) toast.error(errorMessage || t("sftp.error.downloadFailed"), "SFTP");
}
},
[
sftpRef,
t,
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
],
);
const onDownloadFileLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("left", file, fullPath),
[handleDownloadFileForSide],
);
const onDownloadFileRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("right", file, fullPath),
[handleDownloadFileForSide],
);
const handleExtractArchiveForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const resolvedPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
toast.info(t("sftp.extract.extracting", { fileName: file.name }), "SFTP");
try {
const bridge = netcattyBridge.get();
if (pane.connection.isLocal) {
if (!bridge?.extractLocalArchive) {
throw new Error("Local extract unavailable");
}
await bridge.extractLocalArchive(resolvedPath);
} else {
const sftpId = getSftpIdForConnection?.(pane.connection.id);
if (!sftpId || !bridge?.extractSftpArchive) {
throw new Error("SFTP session not found");
}
await bridge.extractSftpArchive(sftpId, resolvedPath, pane.filenameEncoding);
}
await sftpRef.current.refresh(side);
toast.success(t("sftp.extract.success", { fileName: file.name }), "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to extract archive:", e);
toast.error(
t("sftp.extract.error", {
fileName: file.name,
error: e instanceof Error ? e.message : String(e),
}),
"SFTP",
);
}
},
[getSftpIdForConnection, sftpRef, t],
);
const onExtractArchiveLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("left", file, fullPath),
[handleExtractArchiveForSide],
);
const onExtractArchiveRight = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("right", file, fullPath),
[handleExtractArchiveForSide],
);
// Multi-file download. For local panes, each file auto-downloads as a blob
// (no prompt). For remote panes, prompts for a target directory once and
// streams all selected entries into it — avoids the per-file save dialog
// that would otherwise appear N times.
const handleDownloadFilesForSide = useCallback(
async (side: "left" | "right", files: SftpFileEntry[]) => {
if (files.length === 0) return;
if (files.length === 1) {
await handleDownloadFileForSide(side, files[0]);
return;
}
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
if (pane.connection.isLocal) {
// Sequential: each local download materializes a full ArrayBuffer.
await runBoundedConcurrency(
files,
LOCAL_BLOB_DOWNLOAD_CONCURRENCY,
async (file) => {
await handleDownloadFileForSide(side, file);
},
);
return;
}
if (!selectDirectory || !getSftpIdForConnection) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const sftpId = getSftpIdForConnection(pane.connection.id);
if (!sftpId) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
// Bound root jobs: each directory root walks and transfers independently,
// so unbounded Promise.allSettled would multiply session / LIST pressure.
const results: Array<PromiseSettledResult<{ file: SftpFileEntry; status: TransferStatus }>> = [];
await runBoundedConcurrency(
files,
MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY,
async (file, index) => {
try {
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const targetPath = joinFsPath(selectedDirectory, file.name);
const isDirectory = isNavigableDirectory(file);
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceHostId: pane.connection.hostId,
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory,
totalBytes: isDirectory ? undefined : fileSize,
});
results[index] = { status: "fulfilled", value: { file, status } };
} catch (reason) {
results[index] = { status: "rejected", reason };
}
},
);
for (const result of results) {
if (!result) continue;
if (result.status === "fulfilled") {
const { file, status } = result.value;
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
} else if (status === "attention") {
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
}
} else {
logger.error("[SftpView] Failed to download file:", result.reason);
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
const isCancelError = errorMessage.includes("cancelled") || errorMessage.includes("canceled");
if (!isCancelError) toast.error(errorMessage || t("sftp.error.downloadFailed"), "SFTP");
}
}
},
[
sftpRef,
t,
selectDirectory,
getSftpIdForConnection,
handleDownloadFileForSide,
],
);
const onDownloadFilesLeft = useCallback(
(files: SftpFileEntry[]) => handleDownloadFilesForSide("left", files),
[handleDownloadFilesForSide],
);
const onDownloadFilesRight = useCallback(
(files: SftpFileEntry[]) => handleDownloadFilesForSide("right", files),
[handleDownloadFilesForSide],
);
const onOpenEntryLeft = useCallback(
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
sftpRef.current.openEntry("left", entry);
return;
}
if (behaviorRef.current === "transfer") {
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
const sourceConnectionId = pane.connection?.id;
const fileData = [{
name: entry.name,
isDirectory: isDir,
sourceConnectionId,
sourcePath,
}];
sftpRef.current.startTransfer(fileData, "left", "right", {
sourceConnectionId,
sourcePath,
});
} else {
onOpenFileLeft(entry, fullPath);
}
},
[sftpRef, onOpenFileLeft, behaviorRef],
);
const onOpenEntryRight = useCallback(
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.rightPane;
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
sftpRef.current.openEntry("right", entry);
return;
}
if (behaviorRef.current === "transfer") {
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
const sourceConnectionId = pane.connection?.id;
const fileData = [{
name: entry.name,
isDirectory: isDir,
sourceConnectionId,
sourcePath,
}];
sftpRef.current.startTransfer(fileData, "right", "left", {
sourceConnectionId,
sourcePath,
});
} else {
onOpenFileRight(entry, fullPath);
}
},
[sftpRef, onOpenFileRight, behaviorRef],
);
return {
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab: handlePromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
onEditPermissionsLeft,
onEditPermissionsRight,
onOpenEntryLeft,
onOpenEntryRight,
onEditFileLeft,
onEditFileRight,
onOpenFileLeft,
onOpenFileRight,
onOpenFileWithSystemDefaultLeft,
onOpenFileWithSystemDefaultRight,
onOpenFileWithLeft,
onOpenFileWithRight,
onDownloadFileLeft,
onDownloadFileRight,
onExtractArchiveLeft,
onExtractArchiveRight,
onDownloadFilesLeft,
onDownloadFilesRight,
onUploadExternalFilesLeft,
onUploadExternalFilesRight,
onUploadExternalFileListLeft,
onUploadExternalFileListRight,
onUploadExternalFolderLeft,
onUploadExternalFolderRight,
};
};

View File

@@ -0,0 +1,94 @@
import type React from "react";
import type { MutableRefObject } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
export interface UseSftpViewFileOpsParams {
sftpRef: MutableRefObject<SftpStateApi>;
behaviorRef: MutableRefObject<string>;
autoSyncRef: MutableRefObject<boolean>;
getOpenerForFileRef: MutableRefObject<
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
>;
setOpenerForExtension: (
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
}
export interface UseSftpViewFileOpsResult {
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setPermissionsState: React.Dispatch<
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null>
>;
showTextEditor: boolean;
setShowTextEditor: React.Dispatch<React.SetStateAction<boolean>>;
textEditorTarget: {
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null;
setTextEditorTarget: React.Dispatch<
React.SetStateAction<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>
>;
textEditorContent: string;
setTextEditorContent: React.Dispatch<React.SetStateAction<string>>;
loadingTextContent: boolean;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: React.Dispatch<React.SetStateAction<boolean>>;
fileOpenerTarget: {
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null;
setFileOpenerTarget: React.Dispatch<
React.SetStateAction<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>
>;
handleSaveTextFile: (content: string) => Promise<void>;
onPromoteToTab: (snapshot: TextEditorModalSnapshot) => void;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
onEditPermissionsLeft: (file: SftpFileEntry, fullPath?: string) => void;
onEditPermissionsRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenEntryLeft: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenEntryRight: (entry: SftpFileEntry, fullPath?: string) => void;
onEditFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onEditFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithSystemDefaultLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithSystemDefaultRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFileListLeft: (fileList: FileList, targetPath?: string) => void;
onUploadExternalFileListRight: (fileList: FileList, targetPath?: string) => void;
onUploadExternalFolderLeft: (targetPath?: string) => Promise<void>;
onUploadExternalFolderRight: (targetPath?: string) => Promise<void>;
}

View File

@@ -0,0 +1,405 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
import { toast } from "../../ui/toast";
import { requireCopyToOtherPaneTarget } from "../copyToOtherPane";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
t: (key: string, vars?: Record<string, string | number>) => string;
}
interface UseSftpViewPaneActionsResult {
dragCallbacks: SftpDragCallbacks;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onConnectLeft: (
host: Parameters<SftpStateApi["connect"]>[1],
options?: Parameters<SftpStateApi["connect"]>[2],
) => void;
onConnectRight: (
host: Parameters<SftpStateApi["connect"]>[1],
options?: Parameters<SftpStateApi["connect"]>[2],
) => void;
onDisconnectLeft: () => Promise<boolean>;
onDisconnectRight: () => Promise<boolean>;
onPrepareSelectionLeft: () => void;
onPrepareSelectionRight: () => void;
onNavigateToLeft: (path: string) => void;
onNavigateToRight: (path: string) => void;
onNavigateUpLeft: () => void;
onNavigateUpRight: () => void;
onRefreshLeft: () => void;
onRefreshRight: () => void;
onRefreshTabLeft: (tabId: string) => void;
onRefreshTabRight: (tabId: string) => void;
onSetFilenameEncodingLeft: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onSetFilenameEncodingRight: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onToggleSelectionLeft: (name: string, multi: boolean) => void;
onToggleSelectionRight: (name: string, multi: boolean) => void;
onRangeSelectLeft: (fileNames: string[]) => void;
onRangeSelectRight: (fileNames: string[]) => void;
onClearSelectionLeft: () => void;
onClearSelectionRight: () => void;
onSetFilterLeft: (filter: string) => void;
onSetFilterRight: (filter: string) => void;
onCreateDirectoryLeft: (name: string) => void;
onCreateDirectoryRight: (name: string) => void;
onCreateDirectoryAtPathLeft: (path: string, name: string) => void;
onCreateDirectoryAtPathRight: (path: string, name: string) => void;
onCreateFileLeft: (name: string) => void;
onCreateFileRight: (name: string) => void;
onCreateFileAtPathLeft: (path: string, name: string) => void;
onCreateFileAtPathRight: (path: string, name: string) => void;
onDeleteFilesLeft: (names: string[]) => void;
onDeleteFilesRight: (names: string[]) => void;
onDeleteFilesAtPathLeft: (connectionId: string, path: string, names: string[]) => void;
onDeleteFilesAtPathRight: (connectionId: string, path: string, names: string[]) => void;
onRenameFileLeft: (old: string, newName: string) => void;
onRenameFileRight: (old: string, newName: string) => void;
onRenameFileAtPathLeft: (oldPath: string, newName: string) => void;
onRenameFileAtPathRight: (oldPath: string, newName: string) => void;
onMoveEntriesToPathLeft: (sourcePaths: string[], targetPath: string) => void;
onMoveEntriesToPathRight: (sourcePaths: string[], targetPath: string) => void;
onCopyToOtherPaneLeft: (files: SftpTransferSource[]) => void;
onCopyToOtherPaneRight: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneLeft: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
}
export async function disconnectSftpPaneAfterConfirmation(params: {
confirmClose: () => Promise<boolean>;
disconnect: () => Promise<void>;
}): Promise<boolean> {
if (!await params.confirmClose()) return false;
await params.disconnect();
return true;
}
export const useSftpViewPaneActions = ({
sftpRef,
t,
}: UseSftpViewPaneActionsParams): UseSftpViewPaneActionsResult => {
const tRef = useRef(t);
tRef.current = t;
const [draggedFiles, setDraggedFiles] = useState<
(SftpTransferSource & { side: "left" | "right" })[] | null
>(null);
const handleDragStart = useCallback(
(
files: SftpTransferSource[],
side: "left" | "right",
) => {
setDraggedFiles(files.map((f) => ({ ...f, side })));
},
[],
);
const handleDragEnd = useCallback(() => {
setDraggedFiles(null);
}, []);
const startGroupedTransfer = useCallback(
(files: SftpTransferSource[], sourceSide: "left" | "right", targetSide: "left" | "right") => {
if (!requireCopyToOtherPaneTarget(
sftpRef.current,
targetSide,
() => toast.info(tRef.current("sftp.copyToOtherPane.unavailable"), "SFTP"),
)) {
return;
}
const groups = new Map<string, SftpTransferSource[]>();
for (const file of files) {
const key = `${file.sourceConnectionId ?? ""}::${file.sourcePath ?? ""}`;
const group = groups.get(key) ?? [];
group.push(file);
groups.set(key, group);
}
for (const group of groups.values()) {
const [{ sourceConnectionId, sourcePath, targetPath }] = group;
void sftpRef.current.startTransfer(group, sourceSide, targetSide, {
sourceConnectionId,
sourcePath,
targetPath,
});
}
},
[sftpRef],
);
const onCopyToOtherPaneLeft = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "left", "right"),
[startGroupedTransfer],
);
const onCopyToOtherPaneRight = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const onReceiveFromOtherPaneLeft = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const onReceiveFromOtherPaneRight = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "left", "right"),
[startGroupedTransfer],
);
const onConnectLeft = useCallback(
(
host: Parameters<SftpStateApi["connect"]>[1],
options?: Parameters<SftpStateApi["connect"]>[2],
) => sftpRef.current.connect("left", host, options),
[sftpRef],
);
const onConnectRight = useCallback(
(
host: Parameters<SftpStateApi["connect"]>[1],
options?: Parameters<SftpStateApi["connect"]>[2],
) => sftpRef.current.connect("right", host, options),
[sftpRef],
);
// Returns `true` if the disconnect actually happened, `false` if the user
// canceled the dirty-editor prompt. Callers that kick off a replacement
// connect (e.g. the host picker) MUST gate their follow-up on this result
// so a canceled prompt doesn't silently drop the user onto a new host.
const confirmCloseActivePaneEditors = useCallback(async (side: "left" | "right"): Promise<boolean> => {
const pane = sftpRef.current.getActivePane(side);
if (!pane?.connection?.id && !pane?.id) return true;
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
return editorTabStore.confirmCloseByOwner(
{ sessionId: pane.connection?.id, sftpTabId: pane.id },
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
}, [sftpRef]);
const onDisconnectLeft = useCallback(async (): Promise<boolean> => {
return disconnectSftpPaneAfterConfirmation({
confirmClose: () => confirmCloseActivePaneEditors("left"),
disconnect: () => sftpRef.current.disconnect("left"),
});
}, [confirmCloseActivePaneEditors, sftpRef]);
const onDisconnectRight = useCallback(async (): Promise<boolean> => {
return disconnectSftpPaneAfterConfirmation({
confirmClose: () => confirmCloseActivePaneEditors("right"),
disconnect: () => sftpRef.current.disconnect("right"),
});
}, [confirmCloseActivePaneEditors, sftpRef]);
const onPrepareSelectionLeft = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "left");
}, [sftpRef]);
const onPrepareSelectionRight = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "right");
}, [sftpRef]);
const onNavigateToLeft = useCallback(
(path: string) => sftpRef.current.navigateTo("left", path),
[sftpRef],
);
const onNavigateToRight = useCallback(
(path: string) => sftpRef.current.navigateTo("right", path),
[sftpRef],
);
const onNavigateUpLeft = useCallback(() => sftpRef.current.navigateUp("left"), [sftpRef]);
const onNavigateUpRight = useCallback(() => sftpRef.current.navigateUp("right"), [sftpRef]);
const onRefreshLeft = useCallback(() => sftpRef.current.refresh("left"), [sftpRef]);
const onRefreshRight = useCallback(() => sftpRef.current.refresh("right"), [sftpRef]);
const onRefreshTabLeft = useCallback((tabId: string) => sftpRef.current.refresh("left", { tabId }), [sftpRef]);
const onRefreshTabRight = useCallback((tabId: string) => sftpRef.current.refresh("right", { tabId }), [sftpRef]);
const onSetFilenameEncodingLeft = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("left", encoding),
[sftpRef],
);
const onSetFilenameEncodingRight = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("right", encoding),
[sftpRef],
);
const onToggleSelectionLeft = useCallback(
(name: string, multi: boolean) => {
onPrepareSelectionLeft();
sftpRef.current.toggleSelection("left", name, multi);
},
[onPrepareSelectionLeft, sftpRef],
);
const onToggleSelectionRight = useCallback(
(name: string, multi: boolean) => {
onPrepareSelectionRight();
sftpRef.current.toggleSelection("right", name, multi);
},
[onPrepareSelectionRight, sftpRef],
);
const onRangeSelectLeft = useCallback(
(fileNames: string[]) => {
onPrepareSelectionLeft();
sftpRef.current.rangeSelect("left", fileNames);
},
[onPrepareSelectionLeft, sftpRef],
);
const onRangeSelectRight = useCallback(
(fileNames: string[]) => {
onPrepareSelectionRight();
sftpRef.current.rangeSelect("right", fileNames);
},
[onPrepareSelectionRight, sftpRef],
);
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
const onSetFilterLeft = useCallback(
(filter: string) => sftpRef.current.setFilter("left", filter),
[sftpRef],
);
const onSetFilterRight = useCallback(
(filter: string) => sftpRef.current.setFilter("right", filter),
[sftpRef],
);
const onCreateDirectoryLeft = useCallback(
(name: string) => sftpRef.current.createDirectory("left", name),
[sftpRef],
);
const onCreateDirectoryRight = useCallback(
(name: string) => sftpRef.current.createDirectory("right", name),
[sftpRef],
);
const onCreateDirectoryAtPathLeft = useCallback(
(path: string, name: string) => sftpRef.current.createDirectoryAtPath("left", path, name),
[sftpRef],
);
const onCreateDirectoryAtPathRight = useCallback(
(path: string, name: string) => sftpRef.current.createDirectoryAtPath("right", path, name),
[sftpRef],
);
const onCreateFileLeft = useCallback(
(name: string) => sftpRef.current.createFile("left", name),
[sftpRef],
);
const onCreateFileRight = useCallback(
(name: string) => sftpRef.current.createFile("right", name),
[sftpRef],
);
const onCreateFileAtPathLeft = useCallback(
(path: string, name: string) => sftpRef.current.createFileAtPath("left", path, name),
[sftpRef],
);
const onCreateFileAtPathRight = useCallback(
(path: string, name: string) => sftpRef.current.createFileAtPath("right", path, name),
[sftpRef],
);
const onDeleteFilesLeft = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("left", names),
[sftpRef],
);
const onDeleteFilesRight = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("right", names),
[sftpRef],
);
const onDeleteFilesAtPathLeft = useCallback(
(connectionId: string, path: string, names: string[]) =>
sftpRef.current.deleteFilesAtPath("left", connectionId, path, names),
[sftpRef],
);
const onDeleteFilesAtPathRight = useCallback(
(connectionId: string, path: string, names: string[]) =>
sftpRef.current.deleteFilesAtPath("right", connectionId, path, names),
[sftpRef],
);
const onRenameFileLeft = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("left", old, newName),
[sftpRef],
);
const onRenameFileRight = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("right", old, newName),
[sftpRef],
);
const onRenameFileAtPathLeft = useCallback(
(oldPath: string, newName: string) => sftpRef.current.renameFileAtPath("left", oldPath, newName),
[sftpRef],
);
const onRenameFileAtPathRight = useCallback(
(oldPath: string, newName: string) => sftpRef.current.renameFileAtPath("right", oldPath, newName),
[sftpRef],
);
const onMoveEntriesToPathLeft = useCallback(
(sourcePaths: string[], targetPath: string) => sftpRef.current.moveEntriesToPath("left", sourcePaths, targetPath),
[sftpRef],
);
const onMoveEntriesToPathRight = useCallback(
(sourcePaths: string[], targetPath: string) => sftpRef.current.moveEntriesToPath("right", sourcePaths, targetPath),
[sftpRef],
);
const dragCallbacks = useMemo<SftpDragCallbacks>(
() => ({
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
}),
[handleDragStart, handleDragEnd],
);
return {
dragCallbacks,
draggedFiles,
onConnectLeft,
onConnectRight,
onDisconnectLeft,
onDisconnectRight,
onPrepareSelectionLeft,
onPrepareSelectionRight,
onNavigateToLeft,
onNavigateToRight,
onNavigateUpLeft,
onNavigateUpRight,
onRefreshLeft,
onRefreshRight,
onRefreshTabLeft,
onRefreshTabRight,
onSetFilenameEncodingLeft,
onSetFilenameEncodingRight,
onToggleSelectionLeft,
onToggleSelectionRight,
onRangeSelectLeft,
onRangeSelectRight,
onClearSelectionLeft,
onClearSelectionRight,
onSetFilterLeft,
onSetFilterRight,
onCreateDirectoryLeft,
onCreateDirectoryRight,
onCreateDirectoryAtPathLeft,
onCreateDirectoryAtPathRight,
onCreateFileLeft,
onCreateFileRight,
onCreateFileAtPathLeft,
onCreateFileAtPathRight,
onDeleteFilesLeft,
onDeleteFilesRight,
onDeleteFilesAtPathLeft,
onDeleteFilesAtPathRight,
onRenameFileLeft,
onRenameFileRight,
onRenameFileAtPathLeft,
onRenameFileAtPathRight,
onMoveEntriesToPathLeft,
onMoveEntriesToPathRight,
onCopyToOtherPaneLeft,
onCopyToOtherPaneRight,
onReceiveFromOtherPaneLeft,
onReceiveFromOtherPaneRight,
};
};

View File

@@ -0,0 +1,235 @@
import { useEffect, useMemo, useRef } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { RemoteFile, SftpFilenameEncoding } from "../../../types";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
import { useSftpViewFileOps } from "./useSftpViewFileOps";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { formatFileSize, formatDate } from '../../../application/state/sftp/utils';
import { isSessionError } from "../../../application/state/sftp/errors";
import { filterHiddenFiles } from "../utils";
interface UseSftpViewPaneCallbacksParams {
sftpRef: MutableRefObject<SftpStateApi>;
behaviorRef: MutableRefObject<string>;
autoSyncRef: MutableRefObject<boolean>;
getOpenerForFileRef: MutableRefObject<
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
>;
setOpenerForExtension: (
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<void>;
deleteLocalFile?: (path: string) => Promise<void>;
listDrives: () => Promise<string[]>;
}
export const useSftpViewPaneCallbacks = ({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
listLocalFiles,
listDrives,
}: UseSftpViewPaneCallbacksParams) => {
const paneActions = useSftpViewPaneActions({ sftpRef, t });
const fileOps = useSftpViewFileOps({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
});
const listLocalFilesRef = useRef(listLocalFiles);
const listSftpRef = useRef(listSftp);
const getSftpIdForConnectionRef = useRef(getSftpIdForConnection);
useEffect(() => {
listLocalFilesRef.current = listLocalFiles;
listSftpRef.current = listSftp;
getSftpIdForConnectionRef.current = getSftpIdForConnection;
}, [listLocalFiles, listSftp, getSftpIdForConnection]);
const makeListDirectory = (side: "left" | "right", getPane: () => SftpPane) =>
async (path: string) => {
const pane = getPane();
if (!pane.connection) return [];
const toSize = (raw: string) => parseInt(raw) || 0;
const toTs = (raw: string) => new Date(raw).getTime();
const normalizeEntries = (rawFiles: RemoteFile[]) =>
filterHiddenFiles(
rawFiles.map(f => {
const s = toSize(f.size);
const ms = toTs(f.lastModified);
return {
name: f.name,
type: f.type as 'file' | 'directory' | 'symlink',
size: s,
sizeFormatted: formatFileSize(s),
lastModified: ms,
lastModifiedFormatted: formatDate(ms),
permissions: f.permissions,
owner: f.owner,
linkTarget: f.linkTarget as 'file' | 'directory' | null | undefined,
hidden: f.hidden,
};
}),
pane.showHiddenFiles,
);
if (pane.connection.isLocal) {
return normalizeEntries(await listLocalFilesRef.current(path));
}
const sftpId = getSftpIdForConnectionRef.current?.(pane.connection.id);
if (!sftpId) {
const error = new Error("SFTP session not found");
sftpRef.current.reportSessionError(side, error);
throw error;
}
let rawFiles: RemoteFile[] | undefined;
try {
rawFiles = await listSftpRef.current?.(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
sftpRef.current.reportSessionError(side, err as Error);
}
throw err;
}
if (!rawFiles) return [];
return normalizeEntries(rawFiles);
};
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
const leftCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectLeft,
onDisconnect: paneActions.onDisconnectLeft,
onPrepareSelection: paneActions.onPrepareSelectionLeft,
onNavigateTo: paneActions.onNavigateToLeft,
onNavigateUp: paneActions.onNavigateUpLeft,
onRefresh: paneActions.onRefreshLeft,
onRefreshTab: paneActions.onRefreshTabLeft,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingLeft,
onOpenEntry: fileOps.onOpenEntryLeft,
onToggleSelection: paneActions.onToggleSelectionLeft,
onRangeSelect: paneActions.onRangeSelectLeft,
onClearSelection: paneActions.onClearSelectionLeft,
onSetFilter: paneActions.onSetFilterLeft,
onCreateDirectory: paneActions.onCreateDirectoryLeft,
onCreateDirectoryAtPath: paneActions.onCreateDirectoryAtPathLeft,
onCreateFile: paneActions.onCreateFileLeft,
onCreateFileAtPath: paneActions.onCreateFileAtPathLeft,
onDeleteFiles: paneActions.onDeleteFilesLeft,
onDeleteFilesAtPath: paneActions.onDeleteFilesAtPathLeft,
onRenameFile: paneActions.onRenameFileLeft,
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
onEditPermissions: fileOps.onEditPermissionsLeft,
onEditFile: fileOps.onEditFileLeft,
onOpenFile: fileOps.onOpenFileLeft,
onOpenFileWithSystemDefault: fileOps.onOpenFileWithSystemDefaultLeft,
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onDownloadFiles: fileOps.onDownloadFilesLeft,
onExtractArchive: fileOps.onExtractArchiveLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
onListDrives: listDrives,
}),
[],
);
const rightCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectRight,
onDisconnect: paneActions.onDisconnectRight,
onPrepareSelection: paneActions.onPrepareSelectionRight,
onNavigateTo: paneActions.onNavigateToRight,
onNavigateUp: paneActions.onNavigateUpRight,
onRefresh: paneActions.onRefreshRight,
onRefreshTab: paneActions.onRefreshTabRight,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingRight,
onOpenEntry: fileOps.onOpenEntryRight,
onToggleSelection: paneActions.onToggleSelectionRight,
onRangeSelect: paneActions.onRangeSelectRight,
onClearSelection: paneActions.onClearSelectionRight,
onSetFilter: paneActions.onSetFilterRight,
onCreateDirectory: paneActions.onCreateDirectoryRight,
onCreateDirectoryAtPath: paneActions.onCreateDirectoryAtPathRight,
onCreateFile: paneActions.onCreateFileRight,
onCreateFileAtPath: paneActions.onCreateFileAtPathRight,
onDeleteFiles: paneActions.onDeleteFilesRight,
onDeleteFilesAtPath: paneActions.onDeleteFilesAtPathRight,
onRenameFile: paneActions.onRenameFileRight,
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
onEditPermissions: fileOps.onEditPermissionsRight,
onEditFile: fileOps.onEditFileRight,
onOpenFile: fileOps.onOpenFileRight,
onOpenFileWithSystemDefault: fileOps.onOpenFileWithSystemDefaultRight,
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onDownloadFiles: fileOps.onDownloadFilesRight,
onExtractArchive: fileOps.onExtractArchiveRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
onListDrives: listDrives,
}),
[],
);
/* eslint-enable react-hooks/exhaustive-deps */
return {
leftCallbacks,
rightCallbacks,
dragCallbacks: paneActions.dragCallbacks,
draggedFiles: paneActions.draggedFiles,
permissionsState: fileOps.permissionsState,
setPermissionsState: fileOps.setPermissionsState,
showTextEditor: fileOps.showTextEditor,
setShowTextEditor: fileOps.setShowTextEditor,
textEditorTarget: fileOps.textEditorTarget,
setTextEditorTarget: fileOps.setTextEditorTarget,
textEditorContent: fileOps.textEditorContent,
setTextEditorContent: fileOps.setTextEditorContent,
loadingTextContent: fileOps.loadingTextContent,
showFileOpenerDialog: fileOps.showFileOpenerDialog,
setShowFileOpenerDialog: fileOps.setShowFileOpenerDialog,
fileOpenerTarget: fileOps.fileOpenerTarget,
setFileOpenerTarget: fileOps.setFileOpenerTarget,
handleSaveTextFile: fileOps.handleSaveTextFile,
onPromoteToTab: fileOps.onPromoteToTab,
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
handleSelectSystemApp: fileOps.handleSelectSystemApp,
};
};

View File

@@ -0,0 +1,260 @@
import React, { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
import {
getSftpTabDuplicateRequest,
type SftpTabDuplicateMode,
} from "../sftpTabDuplication";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
sftpRef: MutableRefObject<SftpStateApi>;
hosts?: Host[];
}
interface UseSftpViewTabsResult {
leftPanes: SftpStateApi["leftPane"][];
rightPanes: SftpStateApi["rightPane"][];
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
hostSearchRight: string;
setShowHostPickerLeft: React.Dispatch<React.SetStateAction<boolean>>;
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => string;
handleAddTabRight: () => string;
handleCloseTabLeft: (tabId: string) => Promise<void>;
handleCloseTabRight: (tabId: string) => Promise<void>;
handleSelectTabLeft: (tabId: string) => void;
handleSelectTabRight: (tabId: string) => void;
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleMoveTabFromLeftToRight: (tabId: string) => void;
handleMoveTabFromRightToLeft: (tabId: string) => void;
handleDuplicateTabLeft: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
handleDuplicateTabRight: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
handleHostSelectLeft: (
host: Host | "local",
options?: Parameters<SftpStateApi["connect"]>[2],
) => void;
handleHostSelectRight: (
host: Host | "local",
options?: Parameters<SftpStateApi["connect"]>[2],
) => void;
}
export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
const [hostSearchLeft, setHostSearchLeft] = useState("");
const [hostSearchRight, setHostSearchRight] = useState("");
const hostsRef = React.useRef(hosts);
hostsRef.current = hosts;
const handleAddTabLeft = useCallback(() => {
const tabId = sftpRef.current.addTab("left");
setShowHostPickerLeft(true);
return tabId;
}, [sftpRef]);
const handleAddTabRight = useCallback(() => {
const tabId = sftpRef.current.addTab("right");
setShowHostPickerRight(true);
return tabId;
}, [sftpRef]);
const confirmCloseEditorTabsByOwner = useCallback(async (owner: {
sessionId?: string;
sftpTabId?: string;
}): Promise<boolean> => {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const ok = await saveEditorTab(id);
const tab = editorTabStore.getTab(id);
if (!ok || (tab && tab.content !== tab.baselineContent)) {
throw new Error(tab?.saveError ?? "Save failed");
}
};
return editorTabStore.confirmCloseByOwner(
owner,
choice,
saveTab,
releaseEditorTabSaveCoordinator,
);
}, []);
const handleCloseSftpTab = useCallback(async (side: "left" | "right", tabId: string) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
if (pane?.connection?.id || pane) {
const ok = await confirmCloseEditorTabsByOwner({
sessionId: pane?.connection?.id,
sftpTabId: tabId,
});
if (!ok) return;
}
await sftpRef.current.closeTab(side, tabId);
}, [confirmCloseEditorTabsByOwner, sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => (
handleCloseSftpTab("left", tabId)
), [handleCloseSftpTab]);
const handleCloseTabRight = useCallback((tabId: string) => (
handleCloseSftpTab("right", tabId)
), [handleCloseSftpTab]);
const handleSelectTabLeft = useCallback((tabId: string) => {
sftpRef.current.selectTab("left", tabId);
}, [sftpRef]);
const handleSelectTabRight = useCallback((tabId: string) => {
sftpRef.current.selectTab("right", tabId);
}, [sftpRef]);
const leftPanes = useMemo(
() => (sftp.leftTabs.tabs.length > 0 ? sftp.leftTabs.tabs : [sftp.leftPane]),
[sftp.leftTabs.tabs, sftp.leftPane],
);
const rightPanes = useMemo(
() => (sftp.rightTabs.tabs.length > 0 ? sftp.rightTabs.tabs : [sftp.rightPane]),
[sftp.rightTabs.tabs, sftp.rightPane],
);
const handleReorderTabsLeft = useCallback(
(draggedId: string, targetId: string, position: "before" | "after") => {
sftpRef.current.reorderTabs("left", draggedId, targetId, position);
},
[sftpRef],
);
const handleReorderTabsRight = useCallback(
(draggedId: string, targetId: string, position: "before" | "after") => {
sftpRef.current.reorderTabs("right", draggedId, targetId, position);
},
[sftpRef],
);
const handleMoveTabFromLeftToRight = useCallback((tabId: string) => {
sftpRef.current.moveTabToOtherSide("left", tabId);
}, [sftpRef]);
const handleMoveTabFromRightToLeft = useCallback((tabId: string) => {
sftpRef.current.moveTabToOtherSide("right", tabId);
}, [sftpRef]);
const handleDuplicateTab = useCallback(
async (side: "left" | "right", tabId: string, mode: SftpTabDuplicateMode) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
const request = getSftpTabDuplicateRequest(pane, mode);
if (!request) return null;
const host = request.kind === "local"
? "local"
: hostsRef.current.find((item) => item.id === request.hostId);
if (!host) return null;
let duplicatedTabId: string | null = null;
await sftpRef.current.connect(side, host, {
forceNewTab: true,
ignoreSharedCache: mode === "defaultPath",
initialPath: request.path,
onTabCreated: (createdTabId) => {
duplicatedTabId = createdTabId;
},
});
return duplicatedTabId;
},
[sftpRef],
);
const handleDuplicateTabLeft = useCallback(
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("left", tabId, mode),
[handleDuplicateTab],
);
const handleDuplicateTabRight = useCallback(
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("right", tabId, mode),
[handleDuplicateTab],
);
const handleHostSelectLeft = useCallback((
host: Host | "local",
options?: Parameters<SftpStateApi["connect"]>[2],
) => {
sftpRef.current.connect("left", host, options);
setShowHostPickerLeft(false);
}, [sftpRef]);
const handleHostSelectRight = useCallback((
host: Host | "local",
options?: Parameters<SftpStateApi["connect"]>[2],
) => {
sftpRef.current.connect("right", host, options);
setShowHostPickerRight(false);
}, [sftpRef]);
const leftTabsInfo = useMemo(
() =>
sftp.leftTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
canDuplicate: pane.connection?.status === "connected",
})),
[sftp.leftTabs.tabs],
);
const rightTabsInfo = useMemo(
() =>
sftp.rightTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
canDuplicate: pane.connection?.status === "connected",
})),
[sftp.rightTabs.tabs],
);
return {
leftPanes,
rightPanes,
leftTabsInfo,
rightTabsInfo,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleAddTabLeft,
handleAddTabRight,
handleCloseTabLeft,
handleCloseTabRight,
handleSelectTabLeft,
handleSelectTabRight,
handleReorderTabsLeft,
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleDuplicateTabLeft,
handleDuplicateTabRight,
handleHostSelectLeft,
handleHostSelectRight,
};
};