[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:
788
components/sftp/SftpPaneView.tsx
Normal file
788
components/sftp/SftpPaneView.tsx
Normal file
@@ -0,0 +1,788 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user