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
789 lines
28 KiB
TypeScript
789 lines
28 KiB
TypeScript
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition } from "react";
|
|
import { useI18n } from "../../application/i18n/I18nProvider";
|
|
import { logger } from "../../lib/logger";
|
|
import { useRenderTracker } from "../../lib/useRenderTracker";
|
|
import { cn } from "../../lib/utils";
|
|
import { SftpPaneDialogs } from "./SftpPaneDialogs";
|
|
import { SftpClipboardUploadDialog } from "./SftpClipboardUploadDialog";
|
|
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
|
|
import { SftpPaneFileList } from "./SftpPaneFileList";
|
|
import { SftpPaneToolbar } from "./SftpPaneToolbar";
|
|
import { SftpPaneTreeView } from "./SftpPaneTreeView";
|
|
import {
|
|
useActiveTabId,
|
|
useSftpConnectedHosts,
|
|
useSftpDrag,
|
|
useSftpHosts,
|
|
useSftpPaneCallbacks,
|
|
useSftpUpdateHosts,
|
|
useSftpWritableHosts,
|
|
} from "./SftpContext";
|
|
import type { SftpPane } from "../../application/state/sftp/types";
|
|
import { joinPath, getParentPath } from "../../application/state/sftp/utils";
|
|
import type { Host, SftpBookmark } from "../../domain/models";
|
|
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
|
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
|
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
|
import { useSftpPanePath } from "./hooks/useSftpPanePath";
|
|
import { useSftpPaneSorting, type UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
|
|
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
|
import { sftpPaneViewModeStore } from "../../application/state/sftp/sftpPaneViewModeStore";
|
|
import { useSftpDialogActionHandler } from "../../application/state/sftp/sftpDialogActionStore";
|
|
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
|
import { useLocalSftpBookmarks } from "../../application/state/sftp/localSftpBookmarks";
|
|
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
|
|
import { useSftpHostViewMode } from "../../application/state/sftp/sftpHostViewModeStore";
|
|
import { useSftpListDensity } from "../../application/state/sftp/sftpListDensityStore";
|
|
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
|
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
|
|
import { sftpClipboardUploadStore } from "./clipboardUpload";
|
|
|
|
interface TreeReloadRequest {
|
|
token: number;
|
|
paths?: string[];
|
|
full?: boolean;
|
|
}
|
|
|
|
interface SftpPaneWrapperProps {
|
|
side: "left" | "right";
|
|
paneId: string;
|
|
isFirstPane: boolean;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const SftpPaneWrapper = memo<SftpPaneWrapperProps>(({ side, paneId, isFirstPane, children }) => {
|
|
const activeTabId = useActiveTabId(side);
|
|
const isActive = activeTabId ? paneId === activeTabId : isFirstPane;
|
|
|
|
const containerStyle: React.CSSProperties = isActive
|
|
? {}
|
|
: { visibility: "hidden", pointerEvents: "none" };
|
|
|
|
return (
|
|
<div
|
|
className={cn("absolute inset-0", isActive ? "z-10" : "z-0")}
|
|
style={containerStyle}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
});
|
|
SftpPaneWrapper.displayName = "SftpPaneWrapper";
|
|
|
|
interface SftpPaneViewProps {
|
|
side: "left" | "right";
|
|
pane: SftpPane;
|
|
dialogActionScopeId: string;
|
|
isPaneFocused: boolean;
|
|
sftpDefaultViewMode: 'list' | 'tree';
|
|
showHeader?: boolean;
|
|
showEmptyHeader?: boolean;
|
|
onToggleShowHiddenFiles?: () => void;
|
|
onGoToTerminalCwd?: () => void;
|
|
onLocatePathInTerminal?: () => void;
|
|
followTerminalCwd?: boolean;
|
|
onToggleFollowTerminalCwd?: () => void;
|
|
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
|
|
forceActive?: boolean;
|
|
}
|
|
|
|
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|
side,
|
|
pane,
|
|
dialogActionScopeId,
|
|
isPaneFocused,
|
|
sftpDefaultViewMode,
|
|
showHeader = true,
|
|
showEmptyHeader = true,
|
|
onToggleShowHiddenFiles,
|
|
onGoToTerminalCwd,
|
|
onLocatePathInTerminal,
|
|
followTerminalCwd,
|
|
onToggleFollowTerminalCwd,
|
|
forceActive,
|
|
}) => {
|
|
const activeTabId = useActiveTabId(side);
|
|
const isActive = forceActive || (activeTabId ? pane.id === activeTabId : true);
|
|
|
|
const callbacks = useSftpPaneCallbacks(side);
|
|
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
|
const hosts = useSftpHosts();
|
|
const connectedHosts = useSftpConnectedHosts();
|
|
const writableHosts = useSftpWritableHosts();
|
|
|
|
const { t } = useI18n();
|
|
const hostId = pane.connection?.hostId;
|
|
const { hostViewMode, setHostViewMode: saveHostViewMode } = useSftpHostViewMode(hostId);
|
|
const [, startTransition] = useTransition();
|
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
|
const initialViewMode = hostViewMode ?? sftpDefaultViewMode ?? 'list';
|
|
const [viewMode, setViewMode] = useState<'list' | 'tree'>(initialViewMode);
|
|
const { density: listDensity, setDensity: setListDensity } = useSftpListDensity();
|
|
const [treeReloadRequest, setTreeReloadRequest] = useState<TreeReloadRequest>({ token: 0, full: true });
|
|
// Lazy-mount: only render the tree component once tree mode has been activated
|
|
const [treeEverMounted, setTreeEverMounted] = useState(initialViewMode === 'tree');
|
|
useEffect(() => {
|
|
if (viewMode === 'tree' && !treeEverMounted) setTreeEverMounted(true);
|
|
}, [viewMode, treeEverMounted]);
|
|
const filterInputRef = useRef<HTMLInputElement>(null);
|
|
const clipboardUploadRequestSnapshot = useSyncExternalStore(
|
|
sftpClipboardUploadStore.subscribe,
|
|
sftpClipboardUploadStore.getSnapshot,
|
|
sftpClipboardUploadStore.getSnapshot,
|
|
);
|
|
const clipboardUploadRequest =
|
|
clipboardUploadRequestSnapshot?.scopeId === dialogActionScopeId
|
|
&& clipboardUploadRequestSnapshot.side === side
|
|
&& isActive
|
|
? clipboardUploadRequestSnapshot
|
|
: null;
|
|
|
|
const requestTreeReload = useCallback((paths?: string[], full = false) => {
|
|
setTreeReloadRequest((prev) => ({
|
|
token: prev.token + 1,
|
|
paths,
|
|
full,
|
|
}));
|
|
}, []);
|
|
|
|
const requestNestedTreeReload = useCallback((paths?: string[]) => {
|
|
const targets = Array.from(new Set((paths ?? []).filter(Boolean)));
|
|
if (targets.length > 0) {
|
|
requestTreeReload(targets);
|
|
}
|
|
}, [requestTreeReload]);
|
|
|
|
useRenderTracker(`SftpPaneView[${side}]`, {
|
|
side,
|
|
paneId: pane.id,
|
|
paneConnected: pane.connected,
|
|
panePath: pane.currentPath,
|
|
showHeader,
|
|
draggedFilesCount: draggedFiles?.length ?? 0,
|
|
});
|
|
|
|
const {
|
|
sortField,
|
|
sortOrder,
|
|
directoriesFirst,
|
|
columnWidths,
|
|
visibleColumns,
|
|
handleSort,
|
|
handleResizeStart,
|
|
toggleColumnVisibility,
|
|
toggleDirectoriesFirst,
|
|
} = useSftpPaneSorting();
|
|
|
|
// Bookmark support
|
|
const updateHosts = useSftpUpdateHosts();
|
|
const currentHost = useMemo(
|
|
() => writableHosts.find((h) => h.id === pane.connection?.hostId),
|
|
[writableHosts, pane.connection?.hostId],
|
|
);
|
|
const onUpdateHost = useCallback(
|
|
(updated: Host) => updateHosts(writableHosts.map((h) => (h.id === updated.id ? updated : h))),
|
|
[updateHosts, writableHosts],
|
|
);
|
|
const remoteBookmarks = useSftpBookmarks({
|
|
host: currentHost,
|
|
currentPath: pane.connection?.currentPath,
|
|
onUpdateHost,
|
|
});
|
|
const localBookmarks = useLocalSftpBookmarks({
|
|
currentPath: pane.connection?.currentPath,
|
|
});
|
|
const globalBookmarks = useGlobalSftpBookmarks({
|
|
currentPath: pane.connection?.currentPath,
|
|
});
|
|
const hostBookmarks = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
|
const mergedBookmarks = useMemo(
|
|
() => [
|
|
...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })),
|
|
...hostBookmarks.bookmarks.map((b) => ({ ...b, global: false as const })),
|
|
],
|
|
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
|
|
);
|
|
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
|
|
const toggleBookmark = useCallback(() => {
|
|
if (globalBookmarks.isCurrentPathBookmarked && !hostBookmarks.isCurrentPathBookmarked) {
|
|
const currentPath = pane.connection?.currentPath;
|
|
if (currentPath) {
|
|
const bm = globalBookmarks.bookmarks.find((b) => b.path === currentPath);
|
|
if (bm) globalBookmarks.deleteBookmark(bm.id);
|
|
}
|
|
} else {
|
|
hostBookmarks.toggleBookmark();
|
|
}
|
|
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
|
|
const deleteBookmark = useCallback(
|
|
(bookmark: SftpBookmark) => {
|
|
if (bookmark.global) {
|
|
globalBookmarks.deleteBookmark(bookmark.id);
|
|
} else {
|
|
hostBookmarks.deleteBookmark(bookmark.id);
|
|
}
|
|
},
|
|
[hostBookmarks, globalBookmarks],
|
|
);
|
|
const reorderBookmark = useCallback(
|
|
(from: SftpBookmark, to: SftpBookmark) => {
|
|
if (from.global && to.global) {
|
|
globalBookmarks.reorderBookmark(from.id, to.id);
|
|
} else if (!from.global && !to.global) {
|
|
hostBookmarks.reorderBookmark(from.id, to.id);
|
|
}
|
|
},
|
|
[hostBookmarks, globalBookmarks],
|
|
);
|
|
const renameBookmark = useCallback(
|
|
(bookmark: SftpBookmark, label: string) => {
|
|
if (bookmark.global) {
|
|
globalBookmarks.renameBookmark(bookmark.id, label);
|
|
} else {
|
|
hostBookmarks.renameBookmark(bookmark.id, label);
|
|
}
|
|
},
|
|
[hostBookmarks, globalBookmarks],
|
|
);
|
|
|
|
const { sortedDisplayFiles } = useSftpPaneFiles({
|
|
files: pane.files,
|
|
filter: pane.filter,
|
|
connection: pane.connection,
|
|
showHiddenFiles: pane.showHiddenFiles,
|
|
enableListView: viewMode === 'list',
|
|
sortField,
|
|
sortOrder,
|
|
directoriesFirst,
|
|
});
|
|
const {
|
|
isEditingPath,
|
|
editingPathValue,
|
|
showPathSuggestions,
|
|
pathSuggestionIndex,
|
|
pathInputRef,
|
|
pathDropdownRef,
|
|
pathSuggestions,
|
|
setEditingPathValue,
|
|
setShowPathSuggestions,
|
|
setPathSuggestionIndex,
|
|
handlePathBlur,
|
|
handlePathKeyDown,
|
|
handlePathDoubleClick,
|
|
handlePathSubmit,
|
|
} = useSftpPanePath({
|
|
connection: pane.connection,
|
|
files: pane.files,
|
|
showHiddenFiles: pane.showHiddenFiles,
|
|
onNavigateTo: callbacks.onNavigateTo,
|
|
});
|
|
const {
|
|
showHostPicker,
|
|
hostSearch,
|
|
showNewFolderDialog,
|
|
newFolderName,
|
|
showNewFileDialog,
|
|
newFileName,
|
|
fileNameError,
|
|
showOverwriteConfirm,
|
|
overwriteTarget,
|
|
showRenameDialog,
|
|
renameTarget: _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,
|
|
} = useSftpPaneDialogs({
|
|
t,
|
|
pane,
|
|
onCreateDirectory: callbacks.onCreateDirectory,
|
|
onCreateDirectoryAtPath: callbacks.onCreateDirectoryAtPath,
|
|
onCreateFile: callbacks.onCreateFile,
|
|
onCreateFileAtPath: callbacks.onCreateFileAtPath,
|
|
onRenameFileAtPath: callbacks.onRenameFileAtPath,
|
|
onDeleteFilesAtPath: callbacks.onDeleteFilesAtPath,
|
|
onClearSelection: callbacks.onClearSelection,
|
|
onMutateSuccess: (paths?: string[]) => requestNestedTreeReload(paths),
|
|
});
|
|
const handleUploadExternalFiles = useCallback(async (dataTransfer: DataTransfer, targetPath?: string) => {
|
|
await callbacks.onUploadExternalFiles?.(dataTransfer, targetPath);
|
|
const affectedPath = targetPath ?? pane.connection?.currentPath;
|
|
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
|
|
requestTreeReload([affectedPath]);
|
|
}
|
|
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
|
|
|
const handleUploadExternalFileList = useCallback(async (fileList: FileList, targetPath?: string) => {
|
|
await callbacks.onUploadExternalFileList?.(fileList, targetPath);
|
|
const affectedPath = targetPath ?? pane.connection?.currentPath;
|
|
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
|
|
requestTreeReload([affectedPath]);
|
|
}
|
|
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
|
|
|
const handleUploadExternalFolder = useCallback(async (targetPath?: string) => {
|
|
await callbacks.onUploadExternalFolder?.(targetPath);
|
|
const affectedPath = targetPath ?? pane.connection?.currentPath;
|
|
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
|
|
requestTreeReload([affectedPath]);
|
|
}
|
|
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
|
|
|
const handleExtractArchive = useCallback((entry: Parameters<NonNullable<typeof callbacks.onExtractArchive>>[0], fullPath?: string) => {
|
|
const archivePath = fullPath ?? joinPath(pane.connection?.currentPath ?? "", entry.name);
|
|
void Promise.resolve(callbacks.onExtractArchive?.(entry, fullPath)).then(() => {
|
|
const parentPath = getParentPath(archivePath);
|
|
if (parentPath) requestNestedTreeReload([parentPath]);
|
|
});
|
|
}, [callbacks, pane.connection?.currentPath, requestNestedTreeReload]);
|
|
|
|
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
|
|
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
|
|
}, [callbacks]);
|
|
const {
|
|
dragOverEntry,
|
|
isDragOverPane,
|
|
paneContainerRef,
|
|
handlePaneDragOver,
|
|
handlePaneDragLeave,
|
|
handlePaneDrop,
|
|
handleFileDragStart,
|
|
handleEntryDragOver,
|
|
handleEntryDrop,
|
|
handleRowDragLeave,
|
|
handleRowSelect,
|
|
handleRowOpen,
|
|
} = useSftpPaneDragAndSelect({
|
|
side,
|
|
pane,
|
|
sortedDisplayFiles,
|
|
draggedFiles,
|
|
onDragStart,
|
|
onReceiveFromOtherPane: callbacks.onReceiveFromOtherPane,
|
|
onMoveEntriesToPath: callbacks.onMoveEntriesToPath,
|
|
onUploadExternalFiles: handleUploadExternalFiles,
|
|
onOpenEntry: callbacks.onOpenEntry,
|
|
onRangeSelect: callbacks.onRangeSelect,
|
|
onToggleSelection: callbacks.onToggleSelection,
|
|
});
|
|
const {
|
|
fileListRef,
|
|
rowHeight,
|
|
handleFileListScroll,
|
|
shouldVirtualize,
|
|
totalHeight,
|
|
visibleRows,
|
|
} = useSftpPaneVirtualList({
|
|
isActive,
|
|
enabled: viewMode === 'list',
|
|
sortedDisplayFiles,
|
|
layoutKey: listDensity,
|
|
});
|
|
|
|
const toFullPath = useCallback(
|
|
(target: string) => {
|
|
const currentPath = pane.connection?.currentPath;
|
|
if (!currentPath || target.includes("/") || target.includes("\\")) {
|
|
return target;
|
|
}
|
|
return joinPath(currentPath, target);
|
|
},
|
|
[pane.connection?.currentPath],
|
|
);
|
|
|
|
// Handle keyboard shortcut dialog actions
|
|
const dialogActionHandlers = useMemo(
|
|
() => ({
|
|
onRename: (fileName: string) => openRenameDialog(toFullPath(fileName)),
|
|
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames.map(toFullPath)),
|
|
onNewFolder: () => {
|
|
setNewFolderName("");
|
|
setShowNewFolderDialog(true);
|
|
},
|
|
onNewFile: () => {
|
|
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
|
setNewFileName(defaultName);
|
|
setFileNameError(null);
|
|
setShowNewFileDialog(true);
|
|
},
|
|
}),
|
|
[
|
|
getNextUntitledName,
|
|
openDeleteConfirm,
|
|
openRenameDialog,
|
|
pane.files,
|
|
toFullPath,
|
|
setFileNameError,
|
|
setNewFileName,
|
|
setNewFolderName,
|
|
setShowNewFileDialog,
|
|
setShowNewFolderDialog,
|
|
],
|
|
);
|
|
|
|
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
|
|
|
|
const handleSortWithTransition = useCallback((field: typeof sortField) => {
|
|
startTransition(() => handleSort(field));
|
|
}, [handleSort, startTransition]);
|
|
const sortingControls = useMemo<UseSftpPaneSortingResult>(() => ({
|
|
sortField,
|
|
sortOrder,
|
|
directoriesFirst,
|
|
columnWidths,
|
|
visibleColumns,
|
|
handleSort: handleSortWithTransition,
|
|
handleResizeStart,
|
|
toggleColumnVisibility,
|
|
toggleDirectoriesFirst,
|
|
}), [
|
|
columnWidths,
|
|
directoriesFirst,
|
|
handleResizeStart,
|
|
handleSortWithTransition,
|
|
sortField,
|
|
sortOrder,
|
|
toggleColumnVisibility,
|
|
toggleDirectoriesFirst,
|
|
visibleColumns,
|
|
]);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
callbacks.onRefresh();
|
|
if (viewMode === 'tree') {
|
|
requestTreeReload(undefined, true);
|
|
}
|
|
}, [callbacks, requestTreeReload, viewMode]);
|
|
|
|
const onSetFilterRef = useRef(callbacks.onSetFilter);
|
|
onSetFilterRef.current = callbacks.onSetFilter;
|
|
const onClearSelectionRef = useRef(callbacks.onClearSelection);
|
|
onClearSelectionRef.current = callbacks.onClearSelection;
|
|
|
|
const handleSetViewMode = useCallback((mode: 'list' | 'tree') => {
|
|
setViewMode(mode);
|
|
saveHostViewMode(mode);
|
|
if (mode === 'tree') {
|
|
setShowFilterBar(false);
|
|
onSetFilterRef.current('');
|
|
onClearSelectionRef.current();
|
|
}
|
|
}, [saveHostViewMode]);
|
|
|
|
useEffect(() => {
|
|
sftpPaneViewModeStore.set(pane.id, viewMode);
|
|
if (viewMode === 'list') {
|
|
sftpTreeSelectionStore.clearPane(pane.id);
|
|
} else {
|
|
sftpListOrderStore.clearPane(pane.id);
|
|
}
|
|
return () => sftpPaneViewModeStore.clear(pane.id);
|
|
}, [pane.id, viewMode]);
|
|
|
|
// When connecting to a host, restore its saved view mode preference
|
|
const prevHostIdRef = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (hostId && hostId !== prevHostIdRef.current) {
|
|
setViewMode(hostViewMode ?? sftpDefaultViewMode);
|
|
}
|
|
prevHostIdRef.current = hostId;
|
|
}, [hostId, hostViewMode, sftpDefaultViewMode]);
|
|
|
|
useEffect(() => {
|
|
logger.debug("SftpPaneView active state", {
|
|
side,
|
|
paneId: pane.id,
|
|
isActive,
|
|
});
|
|
}, [isActive, pane.id, side]);
|
|
|
|
const lastHandledTransferMutationTokenRef = useRef(0);
|
|
useEffect(() => {
|
|
if (!pane.connection || pane.transferMutationToken === 0) return;
|
|
if (pane.transferMutationToken === lastHandledTransferMutationTokenRef.current) return;
|
|
lastHandledTransferMutationTokenRef.current = pane.transferMutationToken;
|
|
callbacks.onRefreshTab(pane.id);
|
|
if (viewMode === 'tree') {
|
|
requestTreeReload(undefined, true);
|
|
}
|
|
}, [callbacks, pane.connection, pane.id, pane.transferMutationToken, requestTreeReload, viewMode]);
|
|
|
|
if (!pane.connection) {
|
|
return (
|
|
<SftpPaneEmptyState
|
|
side={side}
|
|
showEmptyHeader={showEmptyHeader}
|
|
t={t}
|
|
showHostPicker={showHostPicker}
|
|
setShowHostPicker={setShowHostPicker}
|
|
hostSearch={hostSearch}
|
|
setHostSearch={setHostSearch}
|
|
hosts={hosts}
|
|
connectedHosts={connectedHosts}
|
|
onConnect={callbacks.onConnect}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={paneContainerRef}
|
|
data-section="terminal-sftp-pane"
|
|
data-sftp-pane-side={side}
|
|
data-sftp-view-mode={viewMode}
|
|
className={cn(
|
|
"absolute inset-0 flex flex-col transition-colors",
|
|
isDragOverPane && "bg-primary/5",
|
|
)}
|
|
onDragOver={handlePaneDragOver}
|
|
onDragLeave={handlePaneDragLeave}
|
|
onDrop={handlePaneDrop}
|
|
>
|
|
<SftpPaneToolbar
|
|
t={t}
|
|
pane={pane}
|
|
onNavigateTo={callbacks.onNavigateTo}
|
|
onSetFilter={callbacks.onSetFilter}
|
|
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
|
|
onRefresh={handleRefresh}
|
|
showFilterBar={showFilterBar}
|
|
setShowFilterBar={setShowFilterBar}
|
|
filterInputRef={filterInputRef}
|
|
isEditingPath={isEditingPath}
|
|
editingPathValue={editingPathValue}
|
|
setEditingPathValue={setEditingPathValue}
|
|
setShowPathSuggestions={setShowPathSuggestions}
|
|
showPathSuggestions={showPathSuggestions}
|
|
setPathSuggestionIndex={setPathSuggestionIndex}
|
|
pathSuggestions={pathSuggestions}
|
|
pathSuggestionIndex={pathSuggestionIndex}
|
|
pathInputRef={pathInputRef}
|
|
pathDropdownRef={pathDropdownRef}
|
|
handlePathBlur={handlePathBlur}
|
|
handlePathKeyDown={handlePathKeyDown}
|
|
handlePathDoubleClick={handlePathDoubleClick}
|
|
handlePathSubmit={handlePathSubmit}
|
|
getNextUntitledName={getNextUntitledName}
|
|
setNewFileName={setNewFileName}
|
|
setFileNameError={setFileNameError}
|
|
setShowNewFileDialog={setShowNewFileDialog}
|
|
setShowNewFolderDialog={setShowNewFolderDialog}
|
|
setNewFolderName={setNewFolderName}
|
|
bookmarks={mergedBookmarks}
|
|
isCurrentPathBookmarked={isCurrentPathBookmarked}
|
|
onToggleBookmark={toggleBookmark}
|
|
onAddGlobalBookmark={globalBookmarks.addBookmark}
|
|
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
|
|
onNavigateToBookmark={callbacks.onNavigateTo}
|
|
onDeleteBookmark={deleteBookmark}
|
|
onReorderBookmark={reorderBookmark}
|
|
onRenameBookmark={renameBookmark}
|
|
showHiddenFiles={pane.showHiddenFiles}
|
|
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
|
|
onGoToTerminalCwd={onGoToTerminalCwd}
|
|
onLocatePathInTerminal={onLocatePathInTerminal}
|
|
followTerminalCwd={followTerminalCwd}
|
|
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
|
|
viewMode={viewMode}
|
|
onSetViewMode={handleSetViewMode}
|
|
listDensity={listDensity}
|
|
onSetListDensity={setListDensity}
|
|
onListDrives={callbacks.onListDrives}
|
|
/>
|
|
|
|
{treeEverMounted && (
|
|
<div
|
|
className={viewMode === 'tree' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}
|
|
data-section="terminal-sftp-tree"
|
|
>
|
|
<SftpPaneTreeView
|
|
pane={pane}
|
|
side={side}
|
|
onPrepareSelection={callbacks.onPrepareSelection}
|
|
onLoadChildren={callbacks.onListDirectory}
|
|
onMoveEntriesToPath={handleMoveEntriesToPath}
|
|
onNavigateUp={callbacks.onNavigateUp}
|
|
onNavigateTo={callbacks.onNavigateTo}
|
|
onRefresh={handleRefresh}
|
|
onOpenEntry={callbacks.onOpenEntry}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
openRenameDialog={openRenameDialog}
|
|
openDeleteConfirm={openDeleteConfirm}
|
|
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
|
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
|
|
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
|
|
onOpenFileWith={callbacks.onOpenFileWith}
|
|
onEditFile={callbacks.onEditFile}
|
|
onDownloadFile={callbacks.onDownloadFile}
|
|
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
|
onEditPermissions={callbacks.onEditPermissions}
|
|
draggedFiles={draggedFiles}
|
|
openNewFolderDialog={openNewFolderDialogAtPath}
|
|
openNewFileDialog={openNewFileDialogAtPath}
|
|
onUploadExternalFiles={handleUploadExternalFiles}
|
|
onUploadExternalFileList={handleUploadExternalFileList}
|
|
onUploadExternalFolder={handleUploadExternalFolder}
|
|
sorting={sortingControls}
|
|
reloadRequest={treeReloadRequest}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={viewMode === 'list' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}
|
|
>
|
|
<SftpPaneFileList
|
|
t={t}
|
|
pane={pane}
|
|
side={side}
|
|
isPaneFocused={isPaneFocused}
|
|
sorting={sortingControls}
|
|
fileListRef={fileListRef}
|
|
handleFileListScroll={handleFileListScroll}
|
|
shouldVirtualize={shouldVirtualize}
|
|
totalHeight={totalHeight}
|
|
sortedDisplayFiles={sortedDisplayFiles}
|
|
isDragOverPane={isDragOverPane}
|
|
draggedFiles={draggedFiles}
|
|
onRefresh={handleRefresh}
|
|
onNavigateTo={callbacks.onNavigateTo}
|
|
onClearSelection={callbacks.onClearSelection}
|
|
setShowNewFolderDialog={setShowNewFolderDialog}
|
|
setShowNewFileDialog={setShowNewFileDialog}
|
|
getNextUntitledName={getNextUntitledName}
|
|
setNewFileName={setNewFileName}
|
|
setFileNameError={setFileNameError}
|
|
dragOverEntry={dragOverEntry}
|
|
handleRowSelect={handleRowSelect}
|
|
handleRowOpen={handleRowOpen}
|
|
handleFileDragStart={handleFileDragStart}
|
|
onDragEnd={onDragEnd}
|
|
handleEntryDragOver={handleEntryDragOver}
|
|
handleRowDragLeave={handleRowDragLeave}
|
|
handleEntryDrop={handleEntryDrop}
|
|
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
|
onMoveEntriesToPath={handleMoveEntriesToPath}
|
|
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
|
|
onOpenFileWith={callbacks.onOpenFileWith}
|
|
onEditFile={callbacks.onEditFile}
|
|
onDownloadFile={callbacks.onDownloadFile}
|
|
onDownloadFiles={callbacks.onDownloadFiles}
|
|
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
|
onEditPermissions={callbacks.onEditPermissions}
|
|
onUploadExternalFileList={handleUploadExternalFileList}
|
|
onUploadExternalFolder={handleUploadExternalFolder}
|
|
isLocal={!!pane.connection?.isLocal}
|
|
openRenameDialog={openRenameDialog}
|
|
openDeleteConfirm={openDeleteConfirm}
|
|
rowHeight={rowHeight}
|
|
visibleRows={visibleRows}
|
|
listDensity={listDensity}
|
|
/>
|
|
</div>
|
|
|
|
<SftpPaneDialogs
|
|
t={t}
|
|
hostLabel={pane.connection?.hostLabel}
|
|
currentPath={pane.connection?.currentPath}
|
|
showNewFolderDialog={showNewFolderDialog}
|
|
setShowNewFolderDialog={setShowNewFolderDialog}
|
|
newFolderName={newFolderName}
|
|
setNewFolderName={setNewFolderName}
|
|
handleCreateFolder={handleCreateFolder}
|
|
isCreating={isCreating}
|
|
showNewFileDialog={showNewFileDialog}
|
|
setShowNewFileDialog={setShowNewFileDialog}
|
|
newFileName={newFileName}
|
|
setNewFileName={setNewFileName}
|
|
fileNameError={fileNameError}
|
|
setFileNameError={setFileNameError}
|
|
handleCreateFile={handleCreateFile}
|
|
isCreatingFile={isCreatingFile}
|
|
showOverwriteConfirm={showOverwriteConfirm}
|
|
setShowOverwriteConfirm={setShowOverwriteConfirm}
|
|
overwriteTarget={overwriteTarget}
|
|
handleOverwriteConfirm={handleConfirmOverwrite}
|
|
showRenameDialog={showRenameDialog}
|
|
setShowRenameDialog={setShowRenameDialog}
|
|
renameName={renameName}
|
|
setRenameName={setRenameName}
|
|
handleRename={handleRename}
|
|
isRenaming={isRenaming}
|
|
showDeleteConfirm={showDeleteConfirm}
|
|
setShowDeleteConfirm={setShowDeleteConfirm}
|
|
deleteTargets={deleteTargets}
|
|
handleDelete={handleDelete}
|
|
isDeleting={isDeleting}
|
|
showHostPicker={showHostPicker}
|
|
setShowHostPicker={setShowHostPicker}
|
|
hosts={hosts}
|
|
connectedHosts={connectedHosts}
|
|
side={side}
|
|
hostSearch={hostSearch}
|
|
setHostSearch={setHostSearch}
|
|
onConnect={callbacks.onConnect}
|
|
onDisconnect={callbacks.onDisconnect}
|
|
/>
|
|
|
|
<SftpClipboardUploadDialog
|
|
request={clipboardUploadRequest}
|
|
currentPath={pane.connection?.currentPath}
|
|
onUploaded={(targetPath) => {
|
|
if (targetPath && targetPath !== pane.connection?.currentPath) {
|
|
requestTreeReload([targetPath]);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const sftpPaneViewAreEqual = (
|
|
prev: SftpPaneViewProps,
|
|
next: SftpPaneViewProps,
|
|
): boolean => {
|
|
if (prev.pane !== next.pane) return false;
|
|
if (prev.side !== next.side) return false;
|
|
if (prev.dialogActionScopeId !== next.dialogActionScopeId) return false;
|
|
if (prev.isPaneFocused !== next.isPaneFocused) return false;
|
|
if (prev.showHeader !== next.showHeader) return false;
|
|
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
|
|
if (prev.sftpDefaultViewMode !== next.sftpDefaultViewMode) return false;
|
|
if (prev.followTerminalCwd !== next.followTerminalCwd) return false;
|
|
if (prev.onToggleFollowTerminalCwd !== next.onToggleFollowTerminalCwd) return false;
|
|
if (prev.onGoToTerminalCwd !== next.onGoToTerminalCwd) return false;
|
|
if (prev.onLocatePathInTerminal !== next.onLocatePathInTerminal) return false;
|
|
if (prev.onToggleShowHiddenFiles !== next.onToggleShowHiddenFiles) return false;
|
|
|
|
return true;
|
|
};
|
|
|
|
const SftpPaneView = memo(SftpPaneViewInner, sftpPaneViewAreEqual);
|
|
SftpPaneView.displayName = "SftpPaneView";
|
|
|
|
export { SftpPaneView, SftpPaneWrapper };
|