[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
798
components/sftp/SftpPaneFileList.tsx
Normal file
798
components/sftp/SftpPaneFileList.tsx
Normal file
@@ -0,0 +1,798 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { SftpTransferSource } from "./SftpContext";
|
||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
|
||||
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { isExtractableArchive } from "../../domain/sftpArchive";
|
||||
import { SftpFileRow } from "./SftpFileRow";
|
||||
import type { SftpListDensity } from "../../domain/sftpListDensity";
|
||||
import { SftpColumnMenuItems } from "./SftpColumnMenuItems";
|
||||
import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList";
|
||||
import {
|
||||
getSftpListUploadFilesTargetPath,
|
||||
getSftpUploadFilesLabelKey,
|
||||
getSftpUploadFolderLabelKey,
|
||||
shouldShowSftpUploadFolderMenu,
|
||||
shouldShowSftpUploadFilesMenu,
|
||||
} from "./sftpUploadMenu";
|
||||
|
||||
interface SftpPaneFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
side: "left" | "right";
|
||||
isPaneFocused: boolean;
|
||||
sorting: UseSftpPaneSortingResult;
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
shouldVirtualize: boolean;
|
||||
totalHeight: number;
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
isDragOverPane: boolean;
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
onRefresh: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onClearSelection: () => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
getNextUntitledName: (existingNames: string[]) => string;
|
||||
setNewFileName: (value: string) => void;
|
||||
setFileNameError: (value: string | null) => void;
|
||||
// Row rendering
|
||||
dragOverEntry: string | null;
|
||||
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
|
||||
handleRowOpen: (entry: SftpFileEntry) => void;
|
||||
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onDragEnd: () => void;
|
||||
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
handleRowDragLeave: () => void;
|
||||
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onOpenFileWithSystemDefault?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||
onExtractArchive?: (entry: SftpFileEntry) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
|
||||
// Whether this pane is rendering a local filesystem. Upload menu items only
|
||||
// make sense for remote (SFTP) panes, so they are suppressed when isLocal.
|
||||
isLocal?: boolean;
|
||||
openRenameDialog: (name: string) => void;
|
||||
openDeleteConfirm: (targets: string[]) => void;
|
||||
rowHeight: number;
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
listDensity?: SftpListDensity;
|
||||
}
|
||||
|
||||
const SftpErrorWithLogs: React.FC<{
|
||||
error: string;
|
||||
connectionLogs: string[];
|
||||
onRetry: () => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({ error, connectionLogs, onRetry, t }) => {
|
||||
const [showLogs, setShowLogs] = useState(connectionLogs.length > 0);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
|
||||
<Unplug size={28} className="text-destructive/70" />
|
||||
<span className="text-xs text-center px-6 max-w-xs leading-relaxed">{t(error)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
{connectionLogs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
|
||||
{showLogs ? "Hide logs" : "Show logs"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showLogs && connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
t,
|
||||
pane,
|
||||
side,
|
||||
isPaneFocused,
|
||||
sorting,
|
||||
fileListRef,
|
||||
handleFileListScroll,
|
||||
shouldVirtualize,
|
||||
totalHeight,
|
||||
sortedDisplayFiles,
|
||||
isDragOverPane,
|
||||
draggedFiles,
|
||||
onRefresh,
|
||||
onNavigateTo,
|
||||
onClearSelection,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
getNextUntitledName,
|
||||
setNewFileName,
|
||||
setFileNameError,
|
||||
dragOverEntry,
|
||||
handleRowSelect,
|
||||
handleRowOpen,
|
||||
handleFileDragStart,
|
||||
onDragEnd,
|
||||
handleEntryDragOver,
|
||||
handleRowDragLeave,
|
||||
handleEntryDrop,
|
||||
onCopyToOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onOpenFileWithSystemDefault,
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onExtractArchive,
|
||||
onEditPermissions,
|
||||
onUploadExternalFileList,
|
||||
onUploadExternalFolder,
|
||||
isLocal = false,
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
rowHeight,
|
||||
visibleRows,
|
||||
listDensity = "comfortable",
|
||||
}) => {
|
||||
const {
|
||||
columnWidths,
|
||||
visibleColumns,
|
||||
directoriesFirst,
|
||||
sortField,
|
||||
sortOrder,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
toggleColumnVisibility,
|
||||
toggleDirectoriesFirst,
|
||||
} = sorting;
|
||||
const filesByName = useMemo(() => {
|
||||
const map = new Map<string, SftpFileEntry>();
|
||||
sortedDisplayFiles.forEach((entry) => {
|
||||
map.set(entry.name, entry);
|
||||
});
|
||||
return map;
|
||||
}, [sortedDisplayFiles]);
|
||||
|
||||
// Push sorted file names into the list order store for keyboard navigation
|
||||
useEffect(() => {
|
||||
const names = sortedDisplayFiles
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
sftpListOrderStore.setItems(pane.id, names);
|
||||
return () => sftpListOrderStore.clearPane(pane.id);
|
||||
}, [sortedDisplayFiles, pane.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pane.selectedFiles.size !== 1) return;
|
||||
const selectedName = Array.from(pane.selectedFiles)[0];
|
||||
if (!selectedName) return;
|
||||
|
||||
const container = fileListRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const row = Array.from(container.querySelectorAll<HTMLElement>('[data-sftp-row="true"]'))
|
||||
.find((element) => element.dataset.entryName === selectedName);
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldVirtualize || rowHeight <= 0) return;
|
||||
const itemIndex = sortedDisplayFiles.findIndex((entry) => entry.name === selectedName);
|
||||
if (itemIndex < 0) return;
|
||||
container.scrollTop = getSftpVirtualListScrollTop({
|
||||
itemIndex,
|
||||
rowHeight,
|
||||
currentScrollTop: container.scrollTop,
|
||||
viewportHeight: container.clientHeight,
|
||||
});
|
||||
}, [fileListRef, pane.selectedFiles, rowHeight, shouldVirtualize, sortedDisplayFiles]);
|
||||
|
||||
// Use refs for frequently-changing values in context-menu actions
|
||||
const selectedFilesRef = useRef(pane.selectedFiles);
|
||||
selectedFilesRef.current = pane.selectedFiles;
|
||||
|
||||
const handleBackgroundClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-sftp-row="true"]')) return;
|
||||
if (pane.selectedFiles.size === 0) return;
|
||||
onClearSelection();
|
||||
}, [onClearSelection, pane.selectedFiles.size]);
|
||||
|
||||
// Hidden file input backing the "Upload File(s)" context menu item. It sends
|
||||
// the original FileList through uploadFromFileList so Electron can still
|
||||
// resolve local paths for stream uploads.
|
||||
const uploadEnabled = shouldShowSftpUploadFilesMenu({
|
||||
isLocal,
|
||||
hasFileListUpload: !!onUploadExternalFileList,
|
||||
});
|
||||
const folderUploadEnabled = shouldShowSftpUploadFolderMenu({
|
||||
isLocal,
|
||||
hasFolderUpload: !!onUploadExternalFolder,
|
||||
});
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadTargetPathRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const triggerUploadPicker = useCallback((targetPath?: string) => {
|
||||
if (isLocal || !onUploadExternalFileList) return;
|
||||
const input = uploadInputRef.current;
|
||||
if (!input) return;
|
||||
uploadTargetPathRef.current = targetPath;
|
||||
// Reset value so selecting the same files twice still fires onChange.
|
||||
input.value = "";
|
||||
input.click();
|
||||
}, [isLocal, onUploadExternalFileList]);
|
||||
|
||||
const handleUploadInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) {
|
||||
uploadTargetPathRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
if (!onUploadExternalFileList) {
|
||||
uploadTargetPathRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
const targetPath = uploadTargetPathRef.current;
|
||||
uploadTargetPathRef.current = undefined;
|
||||
void onUploadExternalFileList(files, targetPath);
|
||||
}, [onUploadExternalFileList]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
(entry: SftpFileEntry, index: number) => (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<SftpFileRow
|
||||
entry={entry}
|
||||
index={index}
|
||||
isSelected={pane.selectedFiles.has(entry.name)}
|
||||
showSelectionHighlight={isPaneFocused}
|
||||
isDragOver={dragOverEntry === entry.name}
|
||||
columnWidths={columnWidths}
|
||||
visibleColumns={visibleColumns}
|
||||
onSelect={handleRowSelect}
|
||||
onOpen={handleRowOpen}
|
||||
onDragStart={handleFileDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={handleEntryDragOver}
|
||||
onDragLeave={handleRowDragLeave}
|
||||
onDrop={handleEntryDrop}
|
||||
density={listDensity}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
{entry.name !== ".." && (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleRowOpen(entry)}>
|
||||
{isNavigableDirectory(entry) ? (
|
||||
<>
|
||||
<Folder size={14} className="mr-2" /> {t("sftp.context.open")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.open")}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
{isNavigableDirectory(entry) && (
|
||||
<ContextMenuItem onClick={() => onNavigateTo(joinPath(pane.connection.currentPath, entry.name))}>
|
||||
<ArrowRight size={14} className="mr-2" /> {t("sftp.context.navigateTo")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onOpenFileWithSystemDefault && (
|
||||
<ContextMenuItem onClick={() => onOpenFileWithSystemDefault(entry)}>
|
||||
<AppWindow size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.openWithDefault")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onOpenFileWith && (
|
||||
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.openWith")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && !isKnownBinaryFile(entry.name) && onEditFile && (
|
||||
<ContextMenuItem onClick={() => onEditFile(entry)}>
|
||||
<Edit2 size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onDownloadFile &&
|
||||
(!isNavigableDirectory(entry) || !pane.connection?.isLocal) && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
if (
|
||||
onDownloadFiles &&
|
||||
currentSelected.has(entry.name) &&
|
||||
currentSelected.size > 1
|
||||
) {
|
||||
const entries = Array.from(currentSelected)
|
||||
.map((name) => filesByName.get(String(name)))
|
||||
.filter((f): f is SftpFileEntry => !!f);
|
||||
onDownloadFiles(entries);
|
||||
} else {
|
||||
onDownloadFile(entry);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onExtractArchive && isExtractableArchive(entry.name) && (
|
||||
<ContextMenuItem onClick={() => onExtractArchive(entry)}>
|
||||
<Archive size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.extract")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const files = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected)
|
||||
: [entry.name];
|
||||
const fileData = files.map((name) => {
|
||||
const fileName = String(name);
|
||||
const file = filesByName.get(fileName);
|
||||
return {
|
||||
name: fileName,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
sourceConnectionId: pane.connection?.id,
|
||||
sourcePath: pane.connection?.currentPath,
|
||||
};
|
||||
});
|
||||
onCopyToOtherPane(fileData);
|
||||
}}
|
||||
>
|
||||
<Copy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyToOtherPane")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyPath")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
{(() => {
|
||||
const sourceParent = getParentPath(joinPath(pane.connection?.currentPath ?? "", entry.name));
|
||||
const targetParent = getParentPath(sourceParent);
|
||||
if (sourceParent === targetParent) return null;
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const sourcePaths = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
|
||||
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
|
||||
void onMoveEntriesToPath(sourcePaths, targetParent);
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.moveToParent")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
})()}
|
||||
<ContextMenuItem onClick={() => openRenameDialog(joinPath(pane.connection?.currentPath ?? "", entry.name))}>
|
||||
<Pencil size={14} className="mr-2" /> {t("common.rename")}
|
||||
</ContextMenuItem>
|
||||
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
|
||||
<ContextMenuItem onClick={() => onEditPermissions(entry)}>
|
||||
<Shield size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.permissions")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const files = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
|
||||
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
|
||||
openDeleteConfirm(files);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onRefresh}>
|
||||
<RefreshCw size={14} className="mr-2" /> {t("common.refresh")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
|
||||
<FolderPlus size={14} className="mr-2" /> {t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
|
||||
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
{uploadEnabled && onUploadExternalFileList && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
|
||||
triggerUploadPicker(target);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} className="mr-2" />{" "}
|
||||
{t(getSftpUploadFilesLabelKey(entry))}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{folderUploadEnabled && onUploadExternalFolder && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const target = getSftpListUploadFilesTargetPath(entry, pane.connection?.currentPath ?? "");
|
||||
void onUploadExternalFolder(target);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} className="mr-2" />{" "}
|
||||
{t(getSftpUploadFolderLabelKey(entry))}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
),
|
||||
[
|
||||
columnWidths,
|
||||
visibleColumns,
|
||||
filesByName,
|
||||
handleEntryDragOver,
|
||||
handleEntryDrop,
|
||||
handleFileDragStart,
|
||||
handleRowDragLeave,
|
||||
handleRowOpen,
|
||||
handleRowSelect,
|
||||
dragOverEntry,
|
||||
isPaneFocused,
|
||||
onCopyToOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onExtractArchive,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
onNavigateTo,
|
||||
onOpenFileWithSystemDefault,
|
||||
onOpenFileWith,
|
||||
onRefresh,
|
||||
onUploadExternalFileList,
|
||||
onUploadExternalFolder,
|
||||
uploadEnabled,
|
||||
folderUploadEnabled,
|
||||
openDeleteConfirm,
|
||||
openRenameDialog,
|
||||
pane.connection,
|
||||
pane.selectedFiles,
|
||||
listDensity,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
t,
|
||||
triggerUploadPicker,
|
||||
],
|
||||
);
|
||||
|
||||
const fileRows = useMemo(
|
||||
() =>
|
||||
shouldVirtualize
|
||||
? visibleRows.map(({ entry, index, top }) => (
|
||||
<div
|
||||
key={entry.name}
|
||||
className="absolute left-0 right-0 border-b border-border/30"
|
||||
style={{ top, height: rowHeight }}
|
||||
>
|
||||
{renderRow(entry, index)}
|
||||
</div>
|
||||
))
|
||||
: sortedDisplayFiles.map((entry, index) => (
|
||||
<React.Fragment key={entry.name}>
|
||||
{renderRow(entry, index)}
|
||||
</React.Fragment>
|
||||
)),
|
||||
[
|
||||
renderRow,
|
||||
rowHeight,
|
||||
shouldVirtualize,
|
||||
sortedDisplayFiles,
|
||||
visibleRows,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* File list header */}
|
||||
<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"
|
||||
data-section="terminal-sftp-list-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: buildSftpColumnTemplate(columnWidths, visibleColumns),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 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 min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 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 min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end 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 min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end 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 min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end 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>
|
||||
|
||||
{/* File list with empty area context menu */}
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={fileListRef}
|
||||
data-section="terminal-sftp-list"
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
|
||||
)}
|
||||
onClick={handleBackgroundClick}
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<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.error && !pane.reconnecting ? (
|
||||
<SftpErrorWithLogs
|
||||
error={pane.error}
|
||||
connectionLogs={pane.connectionLogs}
|
||||
onRetry={onRefresh}
|
||||
t={t}
|
||||
/>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
shouldVirtualize ? "relative" : "divide-y divide-border/30",
|
||||
)}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{fileRows}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop overlay */}
|
||||
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-primary">
|
||||
<ArrowDown size={32} />
|
||||
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onRefresh}>
|
||||
<RefreshCw size={14} className="mr-2" />{t("sftp.context.refresh")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
|
||||
<FolderPlus size={14} className="mr-2" />{t("sftp.newFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
}}>
|
||||
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
|
||||
</ContextMenuItem>
|
||||
{uploadEnabled && onUploadExternalFileList && (
|
||||
<ContextMenuItem onClick={() => triggerUploadPicker(undefined)}>
|
||||
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFiles")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{folderUploadEnabled && onUploadExternalFolder && (
|
||||
<ContextMenuItem onClick={() => void onUploadExternalFolder(undefined)}>
|
||||
<Upload size={14} className="mr-2" />{t("sftp.context.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* Hidden file input backing the "Upload File(s)" context menu item. */}
|
||||
{uploadEnabled && onUploadExternalFileList && (
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUploadInputChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
|
||||
<span>
|
||||
{t("sftp.itemsCount", {
|
||||
count: sortedDisplayFiles.length - (sortedDisplayFiles[0]?.name === ".." ? 1 : 0),
|
||||
})}
|
||||
{pane.selectedFiles.size > 0 &&
|
||||
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
|
||||
</span>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{pane.connection.currentPath}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
|
||||
{pane.loading && !pane.connection?.reusedConnection && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user