import React, { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react'; import { AlertCircle, Loader2, RefreshCw } from 'lucide-react'; import { Button } from '../ui/button'; import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '../ui/context-menu'; import { TREE_ROW_HEIGHT, type NodeDescriptor } from './SftpPaneTreeNode'; import { INITIAL_TREE_PATHS_STATE, treePathsReducer } from './sftpTreePathsReducer'; import { useSftpPaneTreeContextMenu } from './useSftpPaneTreeContextMenu'; import { useSftpPaneTreeRows } from './useSftpPaneTreeRows'; import { SftpMoveToDialog } from './SftpMoveToDialog'; import type { SftpFileEntry } from '../../types'; import { getParentPath, isWindowsRoot, joinPath, resolveSftpWindowsPathOptions } from '../../application/state/sftp/utils'; import { buildSftpColumnTemplate, filterHiddenFiles, filterSftpTreeEntriesByName, isNavigableDirectory, isSftpColumnMenuKey, sortSftpEntries } from './utils'; import type { SftpTransferSource } from './SftpContext'; import type { SftpPaneTreeViewProps } from './SftpPaneTreeView.types'; import { sftpTreeSelectionStore, useSftpTreeSelectionState } from '../../application/state/sftp/sftpTreeSelectionStore'; import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts'; import { useI18n } from '../../application/i18n/I18nProvider'; import { SftpColumnMenuItems } from './SftpColumnMenuItems'; import { shouldShowSftpUploadFolderMenu, shouldShowSftpUploadFilesMenu, } from './sftpUploadMenu'; interface ContextTarget { entry: SftpFileEntry; entryPath: string; } export const getSftpTreeEntryOpenAction = ( entry: SftpFileEntry, ): 'up' | 'navigate' | 'open' => { if (entry.name === '..') return 'up'; if (isNavigableDirectory(entry)) return 'navigate'; return 'open'; }; export const SftpPaneTreeView = React.memo(({ pane, side, onPrepareSelection, onLoadChildren, onMoveEntriesToPath, onNavigateUp, onNavigateTo, onRefresh, onOpenEntry, onDragStart, onDragEnd, openRenameDialog, openDeleteConfirm, onCopyToOtherPane, onReceiveFromOtherPane, onOpenFileWithSystemDefault, onOpenFileWith, onEditFile, onDownloadFile, onExtractArchive, onEditPermissions, draggedFiles, openNewFolderDialog, openNewFileDialog, onUploadExternalFiles, onUploadExternalFileList, onUploadExternalFolder, sorting, reloadRequest, }) => { const { columnWidths, visibleColumns, directoriesFirst, handleSort, handleResizeStart, toggleColumnVisibility, toggleDirectoriesFirst, sortField, sortOrder, } = sorting; const { t } = useI18n(); const columnTemplate = buildSftpColumnTemplate(columnWidths, visibleColumns); const tRef = useRef(t); tRef.current = t; const [dragOverNodePath, setDragOverNodePath] = useState(null); const onUploadExternalFilesRef = useRef(onUploadExternalFiles); onUploadExternalFilesRef.current = onUploadExternalFiles; const onUploadExternalFileListRef = useRef(onUploadExternalFileList); onUploadExternalFileListRef.current = onUploadExternalFileList; const uploadInputRef = useRef(null); const uploadTargetPathRef = useRef(undefined); const uploadEnabled = shouldShowSftpUploadFilesMenu({ isLocal: !!pane.connection?.isLocal, hasFileListUpload: !!onUploadExternalFileList, }); const folderUploadEnabled = shouldShowSftpUploadFolderMenu({ isLocal: !!pane.connection?.isLocal, hasFolderUpload: !!onUploadExternalFolder, }); const triggerUploadPicker = useCallback((targetPath?: string) => { if (!uploadEnabled) return; const input = uploadInputRef.current; if (!input) return; uploadTargetPathRef.current = targetPath; input.value = ''; input.click(); }, [uploadEnabled]); const handleUploadInputChange = useCallback((e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) { uploadTargetPathRef.current = undefined; return; } const targetPath = uploadTargetPathRef.current; uploadTargetPathRef.current = undefined; void onUploadExternalFileListRef.current?.(files, targetPath); }, []); const scrollContainerRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [viewportHeight, setViewportHeight] = useState(0); const scrollFrameRef = useRef(null); useLayoutEffect(() => { const container = scrollContainerRef.current; if (!container) 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); }; }, []); useEffect(() => { return () => { if (scrollFrameRef.current !== null) { window.cancelAnimationFrame(scrollFrameRef.current); } }; }, []); const pendingScrollTopRef = useRef(0); const handleScroll = useCallback((e: React.UIEvent) => { pendingScrollTopRef.current = e.currentTarget.scrollTop; if (scrollFrameRef.current !== null) return; scrollFrameRef.current = window.requestAnimationFrame(() => { scrollFrameRef.current = null; setScrollTop(pendingScrollTopRef.current); }); }, []); const [contextTarget, setContextTarget] = useState(null); const [showMoveToDialog, setShowMoveToDialog] = useState(false); const [moveToPath, setMoveToPath] = useState(''); const [moveTargetPaths, setMoveTargetPaths] = useState([]); const [moveToError, setMoveToError] = useState(null); const [isMoving, setIsMoving] = useState(false); const [moveToSuggestions, setMoveToSuggestions] = useState([]); const [moveToSuggestionIndex, setMoveToSuggestionIndex] = useState(-1); const moveToInputRef = useRef(null); const moveToSuggestionsTimerRef = useRef | null>(null); const childrenCacheRef = useRef>(new Map()); const sortedChildrenCacheRef = useRef>(new Map()); const [treePaths, dispatchTreePaths] = useReducer(treePathsReducer, INITIAL_TREE_PATHS_STATE); const { expandedPaths, loadingPaths, errorPaths } = treePaths; const treeSelectionState = useSftpTreeSelectionState(pane.id); const selectedPaths = treeSelectionState.selectedPaths; const lastClickedPathRef = useRef(null); const expandedPathsRef = useRef(expandedPaths); expandedPathsRef.current = expandedPaths; const loadingPathsRef = useRef(loadingPaths); loadingPathsRef.current = loadingPaths; const selectedPathsRef = useRef(selectedPaths); selectedPathsRef.current = selectedPaths; const treeSelectionStateRef = useRef(treeSelectionState); treeSelectionStateRef.current = treeSelectionState; const treeGenerationRef = useRef(0); const previousRootPathRef = useRef(pane.connection?.currentPath ?? ''); const previousConnectionIdRef = useRef(pane.connection?.id ?? null); const [rootEntries, setRootEntries] = useState(pane.files ?? []); const [resolvedRootPath, setResolvedRootPath] = useState(pane.connection?.currentPath ?? ''); useEffect(() => { if (selectedPaths.size === 0) { lastClickedPathRef.current = null; sftpKeyboardSelectionStore.clear(pane.id); } }, [pane.id, selectedPaths.size]); const onOpenEntryRef = useRef(onOpenEntry); onOpenEntryRef.current = onOpenEntry; const onNavigateUpRef = useRef(onNavigateUp); onNavigateUpRef.current = onNavigateUp; const onNavigateToRef = useRef(onNavigateTo); onNavigateToRef.current = onNavigateTo; const onPrepareSelectionRef = useRef(onPrepareSelection); onPrepareSelectionRef.current = onPrepareSelection; const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath); onMoveEntriesToPathRef.current = onMoveEntriesToPath; const onDragStartRef = useRef(onDragStart); onDragStartRef.current = onDragStart; const onDragEndRef = useRef(onDragEnd); onDragEndRef.current = onDragEnd; const onCopyToOtherPaneRef = useRef(onCopyToOtherPane); onCopyToOtherPaneRef.current = onCopyToOtherPane; const onReceiveFromOtherPaneRef = useRef(onReceiveFromOtherPane); onReceiveFromOtherPaneRef.current = onReceiveFromOtherPane; const onOpenFileWithSystemDefaultRef = useRef(onOpenFileWithSystemDefault); onOpenFileWithSystemDefaultRef.current = onOpenFileWithSystemDefault; const onOpenFileWithRef = useRef(onOpenFileWith); onOpenFileWithRef.current = onOpenFileWith; const onEditFileRef = useRef(onEditFile); onEditFileRef.current = onEditFile; const onDownloadFileRef = useRef(onDownloadFile); onDownloadFileRef.current = onDownloadFile; const onExtractArchiveRef = useRef(onExtractArchive); onExtractArchiveRef.current = onExtractArchive; const onEditPermissionsRef = useRef(onEditPermissions); onEditPermissionsRef.current = onEditPermissions; const openRenameDialogRef = useRef(openRenameDialog); openRenameDialogRef.current = openRenameDialog; const openDeleteConfirmRef = useRef(openDeleteConfirm); openDeleteConfirmRef.current = openDeleteConfirm; const openNewFolderDialogRef = useRef(openNewFolderDialog); openNewFolderDialogRef.current = openNewFolderDialog; const openNewFileDialogRef = useRef(openNewFileDialog); openNewFileDialogRef.current = openNewFileDialog; const onLoadChildrenRef = useRef(onLoadChildren); onLoadChildrenRef.current = onLoadChildren; const onRefreshRef = useRef(onRefresh); onRefreshRef.current = onRefresh; const sideRef = useRef(side); sideRef.current = side; const draggedFilesRef = useRef(draggedFiles); draggedFilesRef.current = draggedFiles; const invalidateTreeCache = useCallback(() => { treeGenerationRef.current += 1; childrenCacheRef.current.clear(); sortedChildrenCacheRef.current.clear(); }, []); const invalidatePathCache = useCallback((targetPath: string) => { treeGenerationRef.current += 1; childrenCacheRef.current.delete(targetPath); sortedChildrenCacheRef.current.delete(targetPath); }, []); const prevSortKeyRef = useRef(`${sortField}:${sortOrder}:${directoriesFirst}:${pane.showHiddenFiles}:${pane.filter}`); const sortKey = `${sortField}:${sortOrder}:${directoriesFirst}:${pane.showHiddenFiles}:${pane.filter}`; if (prevSortKeyRef.current !== sortKey) { prevSortKeyRef.current = sortKey; sortedChildrenCacheRef.current.clear(); } // Ancestor keep decisions follow expand/load/error visibility; invalidate // sorted snapshots when those states change which descendants the filter may use. const prevExpandedPathsRef = useRef(expandedPaths); if (prevExpandedPathsRef.current !== expandedPaths) { prevExpandedPathsRef.current = expandedPaths; sortedChildrenCacheRef.current.clear(); } const prevLoadingPathsRef = useRef(loadingPaths); if (prevLoadingPathsRef.current !== loadingPaths) { prevLoadingPathsRef.current = loadingPaths; sortedChildrenCacheRef.current.clear(); } const prevErrorPathsRef = useRef(errorPaths); if (prevErrorPathsRef.current !== errorPaths) { prevErrorPathsRef.current = errorPaths; sortedChildrenCacheRef.current.clear(); } useEffect(() => { const currentPath = pane.connection?.currentPath ?? ''; if (!currentPath) { setResolvedRootPath(''); setRootEntries([]); return; } if (!pane.loading) { setResolvedRootPath(currentPath); setRootEntries(pane.files ?? []); sortedChildrenCacheRef.current.delete(currentPath); } }, [pane.connection?.currentPath, pane.loading, pane.files]); const loadChildrenForPath = useCallback(async (entryPath: string) => { const generation = treeGenerationRef.current; dispatchTreePaths({ type: 'START_LOADING', path: entryPath }); try { const children = await onLoadChildrenRef.current(entryPath); if (generation !== treeGenerationRef.current) { return false; } childrenCacheRef.current.set(entryPath, children); // Ancestor visibility depends on loaded descendants, so drop every // sorted/filtered snapshot (not only this path) before the next row build. sortedChildrenCacheRef.current.clear(); dispatchTreePaths({ type: 'FINISH_LOADING', path: entryPath }); return true; } catch { if (generation === treeGenerationRef.current) { dispatchTreePaths({ type: 'LOAD_ERROR', path: entryPath }); } return false; } }, []); const toggleExpand = useCallback(async (entry: SftpFileEntry, entryPath: string) => { if (!isNavigableDirectory(entry)) return; if (expandedPathsRef.current.has(entryPath)) { dispatchTreePaths({ type: 'COLLAPSE', path: entryPath }); return; } if (loadingPathsRef.current.has(entryPath)) return; if (!childrenCacheRef.current.has(entryPath)) { const loaded = await loadChildrenForPath(entryPath); if (!loaded) return; } dispatchTreePaths({ type: 'EXPAND', path: entryPath }); }, [loadChildrenForPath]); const reloadExpandedPaths = useCallback(async (paths: string[]) => { await Promise.all(paths.map((path) => loadChildrenForPath(path))); }, [loadChildrenForPath]); const reloadRootPath = useCallback(async (rootPath: string) => { try { const children = await onLoadChildrenRef.current(rootPath); if ((pane.connection?.currentPath ?? '') !== rootPath) return; setResolvedRootPath(rootPath); setRootEntries(children); sortedChildrenCacheRef.current.delete(rootPath); } catch { // Ignore refresh failures; the next explicit refresh can retry. } }, [pane.connection?.currentPath]); useEffect(() => { const rootPath = pane.connection?.currentPath ?? ''; const connectionId = pane.connection?.id ?? null; const pathChanged = previousRootPathRef.current !== rootPath; const connectionChanged = previousConnectionIdRef.current !== connectionId; previousRootPathRef.current = rootPath; previousConnectionIdRef.current = connectionId; if (pathChanged || connectionChanged) { invalidateTreeCache(); dispatchTreePaths({ type: 'RESET' }); sftpTreeSelectionStore.clearSelection(pane.id); sftpKeyboardSelectionStore.clear(pane.id); lastClickedPathRef.current = null; } }, [pane.connection?.currentPath, pane.connection?.id, pane.id, invalidateTreeCache]); useEffect(() => { if (!reloadRequest.token) return; const rootPath = pane.connection?.currentPath; if (!rootPath) return; if (reloadRequest.full || !reloadRequest.paths || reloadRequest.paths.length === 0) { const expanded = Array.from(expandedPathsRef.current); invalidateTreeCache(); if (expanded.length > 0) { void reloadExpandedPaths(expanded); } return; } const targets = Array.from(new Set(reloadRequest.paths)); for (const targetPath of targets) { invalidatePathCache(targetPath); } const shouldReloadRoot = targets.includes(rootPath); if (shouldReloadRoot) { void reloadRootPath(rootPath); } const expandedTargets = targets.filter((targetPath) => targetPath !== rootPath && expandedPathsRef.current.has(targetPath), ); if (expandedTargets.length > 0) { void reloadExpandedPaths(expandedTargets); } }, [invalidatePathCache, invalidateTreeCache, pane.connection?.currentPath, reloadExpandedPaths, reloadRequest, reloadRootPath]); const focusTreeContainer = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; if (document.activeElement !== container) { container.focus(); } }, []); const handleNodeClick = useCallback((entry: SftpFileEntry, entryPath: string, e: React.MouseEvent) => { focusTreeContainer(); const state = treeSelectionStateRef.current; const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1; const nextSelection: string[] = (() => { if (e.shiftKey && lastClickedPathRef.current) { const items = state.visibleItems; const lastIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? -1; if (lastIdx !== -1 && currentIdx !== -1) { const parentPath = getParentPath(entryPath); const start = Math.min(lastIdx, currentIdx); const end = Math.max(lastIdx, currentIdx); return items .slice(start, end + 1) .filter(item => getParentPath(item.path) === parentPath) .map(item => item.path); } } if (e.ctrlKey || e.metaKey) { const next = new Set(selectedPathsRef.current); if (next.has(entryPath)) next.delete(entryPath); else next.add(entryPath); return Array.from(next); } return [entryPath]; })(); onPrepareSelectionRef.current(); sftpTreeSelectionStore.setSelection(pane.id, nextSelection); if (currentIdx !== -1) { if (e.shiftKey && lastClickedPathRef.current) { const anchorIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? currentIdx; sftpKeyboardSelectionStore.set(pane.id, anchorIdx, currentIdx); } else { sftpKeyboardSelectionStore.set(pane.id, currentIdx, currentIdx); } } lastClickedPathRef.current = entryPath; }, [focusTreeContainer, pane.id]); const openTreeEntry = useCallback((entry: SftpFileEntry, entryPath: string) => { const action = getSftpTreeEntryOpenAction(entry); if (action === 'up') { onNavigateUpRef.current(); return; } if (action === 'navigate') { onNavigateToRef.current(entryPath); return; } onOpenEntryRef.current(entry, entryPath); }, []); const stableOnRefresh = useCallback(() => onRefreshRef.current(), []); const handleTreeContainerKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.altKey || e.ctrlKey || e.metaKey) return; const state = treeSelectionStateRef.current; const items = state.visibleItems; if (items.length === 0) return; if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); const delta = e.key === 'ArrowDown' ? 1 : -1; const currentSelected = [...selectedPathsRef.current]; let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id); if (currentSelected.length === 0) { anchorIdx = e.shiftKey ? 0 : -1; focusIdx = -1; } else { const focusPath = items[focusIdx]?.path; if (!focusPath || !state.selectedPaths.has(focusPath)) { focusIdx = state.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0; anchorIdx = focusIdx; sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx); } } let nextIdx = focusIdx + delta; if (nextIdx < 0) nextIdx = 0; if (nextIdx >= items.length) nextIdx = items.length - 1; onPrepareSelectionRef.current(); if (e.shiftKey && currentSelected.length > 0) { const start = Math.min(anchorIdx, nextIdx); const end = Math.max(anchorIdx, nextIdx); const paths = items.slice(start, end + 1).map((item) => item.path); sftpTreeSelectionStore.setSelection(pane.id, paths); sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx); } else { sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]); sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx); } lastClickedPathRef.current = items[nextIdx].path; return; } if (e.key === 'Enter' && !e.shiftKey) { const selected = sftpTreeSelectionStore.getSelectedItems(pane.id); if (selected.length !== 1) return; e.preventDefault(); e.stopPropagation(); const item = selected[0]; const entry = entryByPathRef.current.get(item.path); if (!entry) return; if (entry.name === '..') { openTreeEntry(entry, item.path); return; } if (item.isDirectory) { void toggleExpand(entry, item.path); return; } openTreeEntry(entry, item.path); } }, [openTreeEntry, pane.id, toggleExpand]); const { nodeDescriptors, flatVisibleNodes, entryByPath } = useMemo(() => { const flat: Array<{ entry: SftpFileEntry; entryPath: string }> = []; const descriptors: NodeDescriptor[] = []; const pathMap = new Map(); const currentPath = resolvedRootPath; const windowsOpts = resolveSftpWindowsPathOptions( currentPath, pane.connection?.homeDir, ); const isRootPath = currentPath === '/' || isWindowsRoot(currentPath, windowsOpts); if (!isRootPath && currentPath) { const files = rootEntries; let parentEntry = files.find(f => f.name === '..'); if (!parentEntry) { parentEntry = { name: '..', type: 'directory', size: 0, sizeFormatted: '--', lastModified: 0, lastModifiedFormatted: '--', }; } const parentPath = getParentPath(currentPath, windowsOpts); flat.push({ entry: parentEntry, entryPath: parentPath }); pathMap.set(parentPath, parentEntry); descriptors.push({ type: 'node', entry: parentEntry, entryPath: parentPath, depth: 0, isExpanded: false, isLoading: false, }); } const getSortedEntries = (entries: SftpFileEntry[], parentPath: string): SftpFileEntry[] => { const cached = sortedChildrenCacheRef.current.get(parentPath); if (cached) return cached; const sorted = sortSftpEntries( filterSftpTreeEntriesByName( filterHiddenFiles(entries, pane.showHiddenFiles), pane.filter, { parentPath, joinPath, isDirectory: isNavigableDirectory, getChildren: (entryPath) => { // Match buildTree visibility: collapsed, loading, and error paths // do not render children, so cached descendants must not keep // ancestors either (reload/failure would otherwise leave a // nonmatching parent with only a spinner/error row). if (!expandedPaths.has(entryPath)) return undefined; if (loadingPaths.has(entryPath) || errorPaths.has(entryPath)) { return undefined; } const children = childrenCacheRef.current.get(entryPath); if (!children) return undefined; // Match visible-row hidden policy so ancestor keep decisions // cannot latch onto descendants the user cannot see. return filterHiddenFiles(children, pane.showHiddenFiles); }, }, ), sortField, sortOrder, directoriesFirst, ); sortedChildrenCacheRef.current.set(parentPath, sorted); return sorted; }; const buildTree = (entries: SftpFileEntry[], parentPath: string, depth: number) => { for (const entry of getSortedEntries(entries, parentPath)) { if (entry.name === '..') continue; // Skip ".." from file list; already handled above const entryPath = joinPath(parentPath, entry.name); flat.push({ entry, entryPath }); pathMap.set(entryPath, entry); descriptors.push({ type: 'node', entry, entryPath, depth, isExpanded: expandedPaths.has(entryPath), isLoading: loadingPaths.has(entryPath), }); if (isNavigableDirectory(entry) && expandedPaths.has(entryPath)) { if (loadingPaths.has(entryPath)) { descriptors.push({ type: 'loading', key: `${entryPath}-loading`, depth }); } else if (errorPaths.has(entryPath)) { descriptors.push({ type: 'error', key: `${entryPath}-error`, depth }); } else { buildTree(childrenCacheRef.current.get(entryPath) ?? [], entryPath, depth + 1); } } } }; buildTree(rootEntries, currentPath, 0); return { nodeDescriptors: descriptors, flatVisibleNodes: flat, entryByPath: pathMap }; }, [ rootEntries, resolvedRootPath, pane.connection?.homeDir, pane.showHiddenFiles, pane.filter, sortField, sortOrder, directoriesFirst, expandedPaths, loadingPaths, errorPaths, ]); const entryByPathRef = useRef(entryByPath); entryByPathRef.current = entryByPath; useEffect(() => { if (selectedPaths.size !== 1) return; const selectedPath = selectedPaths.values().next().value; if (!selectedPath) return; const selectedIndex = nodeDescriptors.findIndex( (descriptor) => descriptor.type === 'node' && descriptor.entryPath === selectedPath, ); const container = scrollContainerRef.current; if (selectedIndex < 0 || !container) return; const rowTop = selectedIndex * TREE_ROW_HEIGHT; const rowBottom = rowTop + TREE_ROW_HEIGHT; if (rowTop < container.scrollTop) { container.scrollTop = rowTop; } else if (rowBottom > container.scrollTop + container.clientHeight) { container.scrollTop = rowBottom - container.clientHeight; } }, [nodeDescriptors, selectedPaths]); const prevVisiblePathsRef = useRef([]); useEffect(() => { const currentPaths = flatVisibleNodes .filter(({ entry }) => entry.name !== '..') .map(({ entryPath }) => entryPath); const prev = prevVisiblePathsRef.current; if ( currentPaths.length === prev.length && currentPaths.every((p, i) => p === prev[i]) ) { return; } prevVisiblePathsRef.current = currentPaths; sftpTreeSelectionStore.setVisibleItems( pane.id, flatVisibleNodes .filter(({ entry }) => entry.name !== '..') .map(({ entry, entryPath }) => ({ path: entryPath, name: entry.name, isDirectory: isNavigableDirectory(entry), sourcePath: getParentPath(entryPath), })), ); }, [flatVisibleNodes, pane.id]); useEffect(() => { return () => { sftpTreeSelectionStore.clearPane(pane.id); }; }, [pane.id]); useEffect(() => { return sftpTreeEnterStore.subscribe(() => { const action = sftpTreeEnterStore.get(); if (!action || action.paneId !== pane.id) return; sftpTreeEnterStore.clear(); const entry = entryByPathRef.current.get(action.entryPath); if (!entry) return; if (entry.name === '..') { onNavigateUpRef.current(); } else if (action.isDirectory) { void toggleExpand(entry, action.entryPath); } else { onOpenEntryRef.current(entry, action.entryPath); } }); }, [pane.id, toggleExpand]); const getActionPaths = useCallback((entryPath: string) => { const selected = selectedPathsRef.current; return selected.has(entryPath) ? Array.from(selected) : [entryPath]; }, []); const toTransferSources = useCallback((paths: string[]): SftpTransferSource[] => { const sources: SftpTransferSource[] = []; for (const path of paths) { const entry = entryByPathRef.current.get(path); if (!entry || entry.name === '..') continue; sources.push({ name: entry.name, isDirectory: isNavigableDirectory(entry), sourceConnectionId: pane.connection?.id, sourcePath: getParentPath(path), }); } return sources; }, [pane.connection?.id]); const stableOnOpenEntry = useCallback((entry: SftpFileEntry, entryPath: string) => { openTreeEntry(entry, entryPath); }, [openTreeEntry]); const stableOnDragStart = useCallback((entry: SftpFileEntry, entryPath: string, isDir: boolean, e: React.DragEvent) => { const files = toTransferSources(getActionPaths(entryPath)); if (files.length === 0) { files.push({ name: entry.name, isDirectory: isDir, sourceConnectionId: pane.connection?.id, sourcePath: getParentPath(entryPath), }); } e.dataTransfer.effectAllowed = 'copyMove'; e.dataTransfer.setData('text/plain', files.map((f) => f.name).join('\n')); onDragStartRef.current(files, sideRef.current); }, [getActionPaths, pane.connection?.id, toTransferSources]); const stableOnDragEnd = useCallback(() => onDragEndRef.current(), []); const applyLocalMoveMutation = useCallback(( sourceParentPaths: string[], targetPath: string, movedEntries: SftpFileEntry[], ) => { if (movedEntries.length === 0) return; const currentPath = pane.connection?.currentPath ?? ''; const movedNameSet = new Set(movedEntries.map((entry) => entry.name)); const uniqueSourceParents = Array.from(new Set(sourceParentPaths)); if (currentPath) { if (uniqueSourceParents.includes(currentPath)) { setRootEntries((prev) => prev.filter((entry) => !movedNameSet.has(entry.name))); } else if (currentPath === targetPath) { setRootEntries((prev) => { const next = [...prev]; for (const entry of movedEntries) { if (!next.some((candidate) => candidate.name === entry.name)) { next.push(entry); } } return next; }); } } for (const sourceParent of uniqueSourceParents) { if (sourceParent === currentPath) continue; const cached = childrenCacheRef.current.get(sourceParent); if (!cached) continue; childrenCacheRef.current.set( sourceParent, cached.filter((entry) => !movedNameSet.has(entry.name)), ); } if (targetPath !== currentPath) { const targetCache = childrenCacheRef.current.get(targetPath); if (targetCache) { const next = [...targetCache]; for (const entry of movedEntries) { if (!next.some((candidate) => candidate.name === entry.name)) { next.push(entry); } } childrenCacheRef.current.set(targetPath, next); } } // Ancestor filter keep decisions depend on cached descendants; drop every // sorted/filtered snapshot (not only source/target) so Move To / drag-move // cannot leave stale parents visible or hide the new parent under search. sortedChildrenCacheRef.current.clear(); }, [pane.connection?.currentPath]); const executeMoveAction = useCallback(async (sourcePaths: string[], targetPath: string) => { try { await onMoveEntriesToPathRef.current(sourcePaths, targetPath); const sourceParents = sourcePaths.map((p) => getParentPath(p)); const movedEntries = sourcePaths .map((p) => entryByPathRef.current.get(p)) .filter((e): e is SftpFileEntry => Boolean(e)); applyLocalMoveMutation(sourceParents, targetPath, movedEntries); const syncTargets = Array.from(new Set( [targetPath, ...sourceParents].filter((p) => p && expandedPathsRef.current.has(p)), )); if (syncTargets.length > 0) void reloadExpandedPaths(syncTargets); } catch { throw new Error('Move failed'); } }, [applyLocalMoveMutation, reloadExpandedPaths]); const fetchMoveToSuggestions = useCallback((inputPath: string) => { if (moveToSuggestionsTimerRef.current) clearTimeout(moveToSuggestionsTimerRef.current); if (!inputPath.trim()) { setMoveToSuggestions([]); return; } const parentDir = inputPath.endsWith('/') || inputPath.endsWith('\\') ? inputPath : getParentPath(inputPath); const prefix = inputPath.endsWith('/') || inputPath.endsWith('\\') ? '' : inputPath.split(/[/\\]/).pop()?.toLowerCase() ?? ''; moveToSuggestionsTimerRef.current = setTimeout(async () => { try { const entries = await onLoadChildrenRef.current(parentDir); const dirs = entries .filter((e) => isNavigableDirectory(e) && e.name !== '..') .map((e) => joinPath(parentDir, e.name)) .filter((p) => !prefix || p.split(/[/\\]/).pop()!.toLowerCase().startsWith(prefix)); setMoveToSuggestions(dirs.slice(0, 8)); } catch { setMoveToSuggestions([]); } }, 200); }, []); const handleMoveToSubmit = useCallback(async () => { const target = moveToPath.trim(); if (!target || isMoving) return; setIsMoving(true); setMoveToError(null); try { await onLoadChildrenRef.current(target); await executeMoveAction(moveTargetPaths, target); setShowMoveToDialog(false); } catch { setMoveToError(tRef.current('sftp.moveTo.pathNotFound')); } finally { setIsMoving(false); } }, [moveToPath, isMoving, executeMoveAction, moveTargetPaths]); const getSamePaneDragPaths = useCallback((): string[] | null => { const dragged = draggedFilesRef.current; if (!dragged || dragged.length === 0) return null; if (dragged[0]?.side !== sideRef.current) 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]); const handleNodeDragOver = useCallback((entryPath: string, e: React.DragEvent) => { const entry = entryByPathRef.current.get(entryPath); if (!entry) return; const isDir = isNavigableDirectory(entry); const samePaneDragPaths = getSamePaneDragPaths(); if (samePaneDragPaths && isDir && entry.name !== '..') { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; setDragOverNodePath(entryPath); return; } const isInternalDrag = draggedFilesRef.current && draggedFilesRef.current[0]?.side !== sideRef.current; if (isInternalDrag && isDir && entry.name !== '..') { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; setDragOverNodePath(entryPath); return; } const hasFiles = e.dataTransfer.types.includes('Files'); if (hasFiles && isDir && entry.name !== '..') { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; setDragOverNodePath(entryPath); } }, [getSamePaneDragPaths]); const handleNodeDrop = useCallback((entryPath: string, e: React.DragEvent) => { const entry = entryByPathRef.current.get(entryPath); if (!entry) return; const isDir = isNavigableDirectory(entry); const samePaneDragPaths = getSamePaneDragPaths(); if (samePaneDragPaths && isDir && entry.name !== '..') { e.preventDefault(); e.stopPropagation(); setDragOverNodePath(null); const movedEntries = samePaneDragPaths .map((path) => entryByPathRef.current.get(path)) .filter((value): value is SftpFileEntry => Boolean(value)); const sourceParents = samePaneDragPaths.map((path) => getParentPath(path)); void (async () => { try { await onMoveEntriesToPathRef.current(samePaneDragPaths, entryPath); applyLocalMoveMutation(sourceParents, entryPath, movedEntries); const syncTargets = Array.from( new Set( [entryPath, ...sourceParents].filter((path) => path && expandedPathsRef.current.has(path)), ), ); if (syncTargets.length > 0) { void reloadExpandedPaths(syncTargets); } } catch { // Ignore optimistic move refresh failures; the visible tree keeps its current state. } })(); return; } const hasFiles = e.dataTransfer.types.includes('Files'); const isInternalDrag = draggedFilesRef.current && draggedFilesRef.current[0]?.side !== sideRef.current; if (isInternalDrag && isDir && entry.name !== '..') { e.preventDefault(); e.stopPropagation(); setDragOverNodePath(null); onReceiveFromOtherPaneRef.current( draggedFilesRef.current.map((file) => ({ ...file, targetPath: entryPath })), ); return; } if (hasFiles && isDir && entry.name !== '..') { e.preventDefault(); e.stopPropagation(); setDragOverNodePath(null); if (onUploadExternalFilesRef.current) { void onUploadExternalFilesRef.current(e.dataTransfer, entryPath); } } }, [applyLocalMoveMutation, getSamePaneDragPaths, reloadExpandedPaths]); const handleNodeDragLeave = useCallback(() => { setDragOverNodePath(null); }, []); const handleNodeContextMenu = useCallback((entry: SftpFileEntry, entryPath: string, _e: React.MouseEvent) => { setContextTarget({ entry, entryPath }); }, []); const { totalHeight, treeRows, visibleRange } = useSftpPaneTreeRows({ nodeDescriptors, scrollTop, viewportHeight, tRef, columnTemplate, visibleColumns, selectedPaths, dragOverNodePath, toggleExpand, handleNodeClick, stableOnOpenEntry, stableOnDragStart, stableOnDragEnd, handleNodeDragOver, handleNodeDrop, handleNodeDragLeave, handleNodeContextMenu, }); const contextMenuContent = useSftpPaneTreeContextMenu({ contextTarget, pane, toggleExpand, stableOnOpenEntry, stableOnRefresh, getActionPaths, toTransferSources, executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled, setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex, setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef, onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef, openNewFolderDialogRef, openNewFileDialogRef, }); return (
{ if (!isSftpColumnMenuKey(e.key, e.shiftKey)) return; e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); e.currentTarget.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: rect.left + 16, clientY: rect.top + rect.height / 2, })); }} style={{ display: 'grid', gridTemplateColumns: columnTemplate }} >
handleSort('name')} > {t('sftp.columns.name')} {sortField === 'name' && ( {sortOrder === 'asc' ? '↑' : '↓'} )}
handleResizeStart('name', e)} />
{visibleColumns.modified && (
handleSort('modified')} > {t('sftp.columns.modified')} {sortField === 'modified' && ( {sortOrder === 'asc' ? '↑' : '↓'} )}
handleResizeStart('modified', e)} />
)} {visibleColumns.size && (
handleSort('size')} > {sortField === 'size' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} {t('sftp.columns.size')}
handleResizeStart('size', e)} />
)} {visibleColumns.type && (
handleSort('type')} > {sortField === 'type' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} {t('sftp.columns.kind')}
handleResizeStart('type', e)} />
)} {visibleColumns.owner && (
handleSort('owner')} > {sortField === 'owner' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} {t('sftp.columns.owner')}
)}
{pane.error && !pane.reconnecting && pane.files.length === 0 ? (
{t(pane.error)}
) : (
{treeRows}
)}
{contextMenuContent}
{uploadEnabled && ( )} {pane.loading && !pane.connection?.reusedConnection && !pane.reconnecting && (
{pane.connectionLogs.length > 0 && (
{pane.connectionLogs.map((log, i) => (
{log}
))}
)}
)} {pane.reconnecting && (
{t('sftp.reconnecting.title')}
{t('sftp.reconnecting.desc')}
)}
); }); SftpPaneTreeView.displayName = 'SftpPaneTreeView';