[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
37
components/sftp/hooks/selectionScope.ts
Normal file
37
components/sftp/hooks/selectionScope.ts
Normal 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;
|
||||
};
|
||||
53
components/sftp/hooks/useGlobalSftpBookmarks.ts
Normal file
53
components/sftp/hooks/useGlobalSftpBookmarks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
8
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
8
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/localSftpBookmarks` instead. */
|
||||
export {
|
||||
useLocalSftpBookmarks,
|
||||
subscribeLocalSftpBookmarks,
|
||||
getLocalSftpBookmarksSnapshot,
|
||||
rehydrateLocalSftpBookmarks,
|
||||
setLocalSftpBookmarks,
|
||||
} from "../../../application/state/sftp/localSftpBookmarks";
|
||||
79
components/sftp/hooks/useSftpBookmarks.ts
Normal file
79
components/sftp/hooks/useSftpBookmarks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
6
components/sftp/hooks/useSftpClipboard.ts
Normal file
6
components/sftp/hooks/useSftpClipboard.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpClipboardStore` instead. */
|
||||
export {
|
||||
sftpClipboardStore,
|
||||
useSftpClipboard,
|
||||
type SftpClipboardFile,
|
||||
} from "../../../application/state/sftp/sftpClipboardStore";
|
||||
6
components/sftp/hooks/useSftpDialogAction.ts
Normal file
6
components/sftp/hooks/useSftpDialogAction.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpDialogActionStore` instead. */
|
||||
export {
|
||||
sftpDialogActionStore,
|
||||
useSftpDialogAction,
|
||||
useSftpDialogActionHandler,
|
||||
} from "../../../application/state/sftp/sftpDialogActionStore";
|
||||
6
components/sftp/hooks/useSftpFocusedPane.ts
Normal file
6
components/sftp/hooks/useSftpFocusedPane.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpFocusStore` instead. */
|
||||
export {
|
||||
sftpFocusStore,
|
||||
useSftpFocusedSide,
|
||||
type SftpFocusedSide,
|
||||
} from "../../../application/state/sftp/sftpFocusStore";
|
||||
6
components/sftp/hooks/useSftpHostViewMode.ts
Normal file
6
components/sftp/hooks/useSftpHostViewMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpHostViewModeStore` instead. */
|
||||
export {
|
||||
getHostViewMode,
|
||||
setHostViewMode,
|
||||
useSftpHostViewMode,
|
||||
} from "../../../application/state/sftp/sftpHostViewModeStore";
|
||||
1034
components/sftp/hooks/useSftpKeyboardShortcuts.ts
Normal file
1034
components/sftp/hooks/useSftpKeyboardShortcuts.ts
Normal file
File diff suppressed because it is too large
Load Diff
20
components/sftp/hooks/useSftpListOrderStore.ts
Normal file
20
components/sftp/hooks/useSftpListOrderStore.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
373
components/sftp/hooks/useSftpPaneDialogs.ts
Normal file
373
components/sftp/hooks/useSftpPaneDialogs.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
288
components/sftp/hooks/useSftpPaneDragAndSelect.ts
Normal file
288
components/sftp/hooks/useSftpPaneDragAndSelect.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
90
components/sftp/hooks/useSftpPaneFiles.ts
Normal file
90
components/sftp/hooks/useSftpPaneFiles.ts
Normal 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 };
|
||||
};
|
||||
164
components/sftp/hooks/useSftpPanePath.ts
Normal file
164
components/sftp/hooks/useSftpPanePath.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
5
components/sftp/hooks/useSftpPaneSorting.ts
Normal file
5
components/sftp/hooks/useSftpPaneSorting.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/useSftpPaneSorting` instead. */
|
||||
export {
|
||||
useSftpPaneSorting,
|
||||
type UseSftpPaneSortingResult,
|
||||
} from "../../../application/state/sftp/useSftpPaneSorting";
|
||||
128
components/sftp/hooks/useSftpPaneVirtualList.ts
Normal file
128
components/sftp/hooks/useSftpPaneVirtualList.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
6
components/sftp/hooks/useSftpTreeSelectionStore.ts
Normal file
6
components/sftp/hooks/useSftpTreeSelectionStore.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @deprecated Import from `@/application/state/sftp/sftpTreeSelectionStore` instead. */
|
||||
export {
|
||||
sftpTreeSelectionStore,
|
||||
useSftpTreeSelectionState,
|
||||
type SftpTreeSelectionItem,
|
||||
} from "../../../application/state/sftp/sftpTreeSelectionStore";
|
||||
799
components/sftp/hooks/useSftpViewFileOps.ts
Normal file
799
components/sftp/hooks/useSftpViewFileOps.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
94
components/sftp/hooks/useSftpViewFileOps.types.ts
Normal file
94
components/sftp/hooks/useSftpViewFileOps.types.ts
Normal 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>;
|
||||
}
|
||||
405
components/sftp/hooks/useSftpViewPaneActions.ts
Normal file
405
components/sftp/hooks/useSftpViewPaneActions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
235
components/sftp/hooks/useSftpViewPaneCallbacks.ts
Normal file
235
components/sftp/hooks/useSftpViewPaneCallbacks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
260
components/sftp/hooks/useSftpViewTabs.ts
Normal file
260
components/sftp/hooks/useSftpViewTabs.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user