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
1143 lines
46 KiB
TypeScript
1143 lines
46 KiB
TypeScript
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<SftpPaneTreeViewProps>(({
|
|
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<string | null>(null);
|
|
const onUploadExternalFilesRef = useRef(onUploadExternalFiles);
|
|
onUploadExternalFilesRef.current = onUploadExternalFiles;
|
|
const onUploadExternalFileListRef = useRef(onUploadExternalFileList);
|
|
onUploadExternalFileListRef.current = onUploadExternalFileList;
|
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
|
const uploadTargetPathRef = useRef<string | undefined>(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<HTMLInputElement>) => {
|
|
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<HTMLDivElement>(null);
|
|
const [scrollTop, setScrollTop] = useState(0);
|
|
const [viewportHeight, setViewportHeight] = useState(0);
|
|
const scrollFrameRef = useRef<number | null>(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<HTMLDivElement>) => {
|
|
pendingScrollTopRef.current = e.currentTarget.scrollTop;
|
|
if (scrollFrameRef.current !== null) return;
|
|
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
|
scrollFrameRef.current = null;
|
|
setScrollTop(pendingScrollTopRef.current);
|
|
});
|
|
}, []);
|
|
const [contextTarget, setContextTarget] = useState<ContextTarget | null>(null);
|
|
const [showMoveToDialog, setShowMoveToDialog] = useState(false);
|
|
const [moveToPath, setMoveToPath] = useState('');
|
|
const [moveTargetPaths, setMoveTargetPaths] = useState<string[]>([]);
|
|
const [moveToError, setMoveToError] = useState<string | null>(null);
|
|
const [isMoving, setIsMoving] = useState(false);
|
|
const [moveToSuggestions, setMoveToSuggestions] = useState<string[]>([]);
|
|
const [moveToSuggestionIndex, setMoveToSuggestionIndex] = useState(-1);
|
|
const moveToInputRef = useRef<HTMLInputElement>(null);
|
|
const moveToSuggestionsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const childrenCacheRef = useRef<Map<string, SftpFileEntry[]>>(new Map());
|
|
const sortedChildrenCacheRef = useRef<Map<string, SftpFileEntry[]>>(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<string | null>(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<SftpFileEntry[]>(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<string>(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<HTMLDivElement>) => {
|
|
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<string, SftpFileEntry>();
|
|
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<string[]>([]);
|
|
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 (
|
|
<div className="relative flex-1 min-h-0 flex flex-col text-sm">
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none shrink-0"
|
|
data-section="terminal-sftp-tree-header"
|
|
tabIndex={0}
|
|
aria-label={t('sftp.columns.configure')}
|
|
onKeyDown={(e) => {
|
|
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 }}
|
|
>
|
|
<div
|
|
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 min-w-0 overflow-hidden"
|
|
onClick={() => handleSort('name')}
|
|
>
|
|
<span className="truncate whitespace-nowrap">{t('sftp.columns.name')}</span>
|
|
{sortField === 'name' && (
|
|
<span className="shrink-0 text-primary">
|
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
|
</span>
|
|
)}
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
|
onMouseDown={(e) => handleResizeStart('name', e)}
|
|
/>
|
|
</div>
|
|
{visibleColumns.modified && (
|
|
<div
|
|
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 min-w-0 overflow-hidden"
|
|
onClick={() => handleSort('modified')}
|
|
>
|
|
<span className="truncate whitespace-nowrap">{t('sftp.columns.modified')}</span>
|
|
{sortField === 'modified' && (
|
|
<span className="shrink-0 text-primary">
|
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
|
</span>
|
|
)}
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
|
onMouseDown={(e) => handleResizeStart('modified', e)}
|
|
/>
|
|
</div>
|
|
)}
|
|
{visibleColumns.size && (
|
|
<div
|
|
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground relative pr-2 min-w-0 overflow-hidden"
|
|
onClick={() => handleSort('size')}
|
|
>
|
|
{sortField === 'size' && (
|
|
<span className="shrink-0 text-primary">
|
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
|
</span>
|
|
)}
|
|
<span className="truncate whitespace-nowrap">{t('sftp.columns.size')}</span>
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
|
onMouseDown={(e) => handleResizeStart('size', e)}
|
|
/>
|
|
</div>
|
|
)}
|
|
{visibleColumns.type && (
|
|
<div
|
|
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground relative pr-2 min-w-0 overflow-hidden"
|
|
onClick={() => handleSort('type')}
|
|
>
|
|
{sortField === 'type' && (
|
|
<span className="shrink-0 text-primary">
|
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
|
</span>
|
|
)}
|
|
<span className="truncate whitespace-nowrap">{t('sftp.columns.kind')}</span>
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
|
onMouseDown={(e) => handleResizeStart('type', e)}
|
|
/>
|
|
</div>
|
|
)}
|
|
{visibleColumns.owner && (
|
|
<div
|
|
className="flex items-center justify-end gap-1 cursor-pointer hover:text-foreground min-w-0 overflow-hidden"
|
|
onClick={() => handleSort('owner')}
|
|
>
|
|
{sortField === 'owner' && (
|
|
<span className="shrink-0 text-primary">
|
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
|
</span>
|
|
)}
|
|
<span className="truncate whitespace-nowrap">{t('sftp.columns.owner')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<SftpColumnMenuItems
|
|
visibleColumns={visibleColumns}
|
|
directoriesFirst={directoriesFirst}
|
|
toggleColumnVisibility={toggleColumnVisibility}
|
|
toggleDirectoriesFirst={toggleDirectoriesFirst}
|
|
/>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="flex-1 min-h-0 overflow-y-auto outline-none"
|
|
tabIndex={0}
|
|
onScroll={handleScroll}
|
|
onKeyDown={handleTreeContainerKeyDown}
|
|
onMouseDown={focusTreeContainer}
|
|
>
|
|
{pane.error && !pane.reconnecting && pane.files.length === 0 ? (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
|
|
<AlertCircle size={28} className="text-destructive/70" />
|
|
<span className="max-w-xs text-xs leading-relaxed">{t(pane.error)}</span>
|
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={stableOnRefresh}>
|
|
<RefreshCw size={14} className="mr-2" />
|
|
{t('sftp.retry')}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={visibleRange.virtualized ? 'relative' : undefined}
|
|
style={visibleRange.virtualized ? { height: totalHeight } : undefined}
|
|
>
|
|
{treeRows}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
{contextMenuContent}
|
|
</ContextMenu>
|
|
{uploadEnabled && (
|
|
<input
|
|
ref={uploadInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleUploadInputChange}
|
|
/>
|
|
)}
|
|
{pane.loading && !pane.connection?.reusedConnection && !pane.reconnecting && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10 pointer-events-none">
|
|
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
|
{pane.connectionLogs.length > 0 && (
|
|
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
|
{pane.connectionLogs.map((log, i) => (
|
|
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
|
{log}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{pane.reconnecting && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
|
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
|
<Loader2 size={32} className="animate-spin text-primary" />
|
|
<div className="text-center">
|
|
<div className="text-sm font-medium">{t('sftp.reconnecting.title')}</div>
|
|
<div className="text-xs text-muted-foreground mt-1">{t('sftp.reconnecting.desc')}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<SftpMoveToDialog
|
|
showMoveToDialog={showMoveToDialog}
|
|
setShowMoveToDialog={setShowMoveToDialog}
|
|
setMoveToPath={setMoveToPath}
|
|
setMoveToError={setMoveToError}
|
|
setMoveToSuggestions={setMoveToSuggestions}
|
|
setMoveToSuggestionIndex={setMoveToSuggestionIndex}
|
|
setIsMoving={setIsMoving}
|
|
t={t}
|
|
moveToInputRef={moveToInputRef}
|
|
moveToPath={moveToPath}
|
|
fetchMoveToSuggestions={fetchMoveToSuggestions}
|
|
moveToSuggestions={moveToSuggestions}
|
|
moveToSuggestionIndex={moveToSuggestionIndex}
|
|
moveToError={moveToError}
|
|
isMoving={isMoving}
|
|
handleMoveToSubmit={handleMoveToSubmit}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
SftpPaneTreeView.displayName = 'SftpPaneTreeView';
|