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(({ side, paneId, isFirstPane, children }) => { const activeTabId = useActiveTabId(side); const isActive = activeTabId ? paneId === activeTabId : isFirstPane; const containerStyle: React.CSSProperties = isActive ? {} : { visibility: "hidden", pointerEvents: "none" }; return (
{children}
); }); 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 = ({ 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({ 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(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>[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(() => ({ 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(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 ( ); } return (
{treeEverMounted && (
)}
{ if (targetPath && targetPath !== pane.connection?.currentPath) { requestTreeReload([targetPath]); } }} />
); }; 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 };