Files
Netcatty/components/SftpView.tsx
T
LAPTOP-O016UC3M\Qi Chen 5a687667b5 Improves code consistency and formatting across components
Refactors code for consistent indentation, spacing, and style.
Enhances readability and maintainability by aligning formatting.
No logic changes; only visual and stylistic improvements applied.
2025-12-11 17:08:03 +08:00

1395 lines
46 KiB
TypeScript

/**
* SftpView - SFTP File Browser (Refactored)
*
* This is the main SFTP view component that provides a dual-pane file browser
* for transferring files between local and remote systems.
*
* Components have been extracted to:
* - components/sftp/utils.ts - Utility functions
* - components/sftp/SftpBreadcrumb.tsx - Path navigation
* - components/sftp/SftpFileRow.tsx - File list row
* - components/sftp/SftpTransferItem.tsx - Transfer queue item
* - components/sftp/SftpConflictDialog.tsx - Conflict resolution
* - components/sftp/SftpPermissionsDialog.tsx - Permissions editor
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
*/
import React, { memo, useCallback, useMemo, useRef, useState } from "react";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { SftpPane, useSftpState } from "../application/state/useSftpState";
import { cn } from "../lib/utils";
import { Host, SftpFileEntry, SSHKey } from "../types";
import { Button } from "./ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "./ui/context-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
// Import extracted components
import {
ColumnWidths,
SftpBreadcrumb,
SftpConflictDialog,
SftpFileRow,
SftpHostPicker,
SftpPermissionsDialog,
SftpTransferItem,
SortField,
SortOrder,
} from "./sftp";
import {
AlertCircle,
ArrowDown,
ChevronLeft,
Copy,
Folder,
FolderPlus,
HardDrive,
Home,
Loader2,
Monitor,
Pencil,
Plus,
RefreshCw,
Search,
Shield,
Trash2,
X,
} from "lucide-react";
// SFTP Pane component
interface SftpPaneViewProps {
side: "left" | "right";
pane: SftpPane;
hosts: Host[];
filteredFiles: SftpFileEntry[];
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;
onOpenEntry: (entry: SftpFileEntry) => void;
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
onRangeSelect: (fileNames: string[]) => void;
onClearSelection: () => void;
onSetFilter: (filter: string) => void;
onCreateDirectory: (name: string) => Promise<void>;
onDeleteFiles: (fileNames: string[]) => Promise<void>;
onRenameFile: (oldName: string, newName: string) => Promise<void>;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPane: (
files: { name: string; isDirectory: boolean }[],
) => void;
onEditPermissions?: (file: SftpFileEntry) => void;
draggedFiles:
| { name: string; isDirectory: boolean; side: "left" | "right" }[]
| null;
onDragStart: (
files: { name: string; isDirectory: boolean }[],
side: "left" | "right",
) => void;
onDragEnd: () => void;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
side,
pane,
hosts,
filteredFiles,
onConnect,
onDisconnect,
onNavigateTo,
onNavigateUp,
onRefresh,
onOpenEntry,
onToggleSelection,
onRangeSelect,
onClearSelection,
onSetFilter,
onCreateDirectory,
onDeleteFiles,
onRenameFile,
onCopyToOtherPane,
onReceiveFromOtherPane,
onEditPermissions,
draggedFiles,
onDragStart,
onDragEnd,
}) => {
// Dialog states
const [showHostPicker, setShowHostPicker] = useState(false);
const [hostSearch, setHostSearch] = useState("");
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameName, setRenameName] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
// Drag states
const [dragOverEntry, setDragOverEntry] = useState<string | null>(null);
const [isDragOverPane, setIsDragOverPane] = useState(false);
const paneContainerRef = useRef<HTMLDivElement>(null);
const fileListRef = useRef<HTMLDivElement>(null);
const lastSelectedIndexRef = useRef<number | null>(null);
// Sorting state
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
// Column widths (percentages)
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
name: 45,
modified: 25,
size: 15,
type: 15,
});
const resizingRef = useRef<{
field: keyof ColumnWidths;
startX: number;
startWidth: number;
} | null>(null);
// Editable path state
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const [showPathSuggestions, setShowPathSuggestions] = useState(false);
const [pathSuggestionIndex, setPathSuggestionIndex] = useState(-1);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathDropdownRef = useRef<HTMLDivElement>(null);
// Path suggestions
const pathSuggestions = useMemo(() => {
if (!isEditingPath || !pane.connection) return [];
const currentValue = editingPathValue.trim().toLowerCase();
const suggestions: { path: string; type: "folder" | "history" }[] = [];
const folders = filteredFiles.filter(
(f) => f.type === "directory" && f.name !== "..",
);
folders.forEach((f) => {
const fullPath =
pane.connection?.currentPath === "/"
? `/${f.name}`
: `${pane.connection?.currentPath}/${f.name}`;
if (
!currentValue ||
fullPath.toLowerCase().includes(currentValue) ||
f.name.toLowerCase().includes(currentValue)
) {
suggestions.push({ path: fullPath, type: "folder" });
}
});
const quickPaths = [
"/home",
"/var",
"/etc",
"/tmp",
"/usr",
"/opt",
"/root",
];
quickPaths.forEach((qp) => {
if (!currentValue || qp.toLowerCase().includes(currentValue)) {
if (!suggestions.some((s) => s.path === qp)) {
suggestions.push({ path: qp, type: "history" });
}
}
});
return suggestions.slice(0, 8);
}, [isEditingPath, editingPathValue, filteredFiles, pane.connection]);
// Display files with parent entry
const displayFiles = useMemo(() => {
if (!pane.connection) return [];
if (pane.connection.currentPath === "/") return filteredFiles;
const parentEntry: SftpFileEntry = {
name: "..",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
};
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")];
}, [pane.connection, filteredFiles]);
// Sorted files
const sortedDisplayFiles = useMemo(() => {
if (!displayFiles.length) return displayFiles;
const parentEntry = displayFiles.find((f) => f.name === "..");
const otherFiles = displayFiles.filter((f) => f.name !== "..");
const sorted = [...otherFiles].sort((a, b) => {
if (sortField !== "type") {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
}
let cmp = 0;
switch (sortField) {
case "name":
cmp = a.name.localeCompare(b.name);
break;
case "size":
cmp = (a.size || 0) - (b.size || 0);
break;
case "modified":
cmp = (a.lastModified || 0) - (b.lastModified || 0);
break;
case "type": {
const extA =
a.type === "directory"
? "folder"
: a.name.split(".").pop()?.toLowerCase() || "";
const extB =
b.type === "directory"
? "folder"
: b.name.split(".").pop()?.toLowerCase() || "";
cmp = extA.localeCompare(extB);
break;
}
}
return sortOrder === "asc" ? cmp : -cmp;
});
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
};
// Column resize handlers
const handleResizeStart = (
field: keyof ColumnWidths,
e: React.MouseEvent,
) => {
e.preventDefault();
e.stopPropagation();
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
}));
}, []);
const handleResizeEnd = useCallback(() => {
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
// Path editing handlers
const handlePathDoubleClick = () => {
if (!pane.connection) return;
setEditingPathValue(pane.connection.currentPath);
setIsEditingPath(true);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = (pathOverride?: string) => {
const newPath = (pathOverride ?? editingPathValue).trim() || "/";
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
if (pane.connection && newPath !== pane.connection.currentPath) {
onNavigateTo(newPath.startsWith("/") ? newPath : `/${newPath}`);
}
};
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (showPathSuggestions && pathSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev < pathSuggestions.length - 1 ? prev + 1 : 0,
);
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev > 0 ? prev - 1 : pathSuggestions.length - 1,
);
return;
} else if (e.key === "Tab" && pathSuggestionIndex >= 0) {
e.preventDefault();
setEditingPathValue(pathSuggestions[pathSuggestionIndex].path);
return;
}
}
if (e.key === "Enter") {
if (pathSuggestionIndex >= 0 && pathSuggestions[pathSuggestionIndex]) {
handlePathSubmit(pathSuggestions[pathSuggestionIndex].path);
} else {
handlePathSubmit();
}
} else if (e.key === "Escape") {
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
}
};
const handlePathBlur = () => {
setTimeout(() => {
if (!pathDropdownRef.current?.contains(document.activeElement)) {
handlePathSubmit();
}
}, 150);
};
// File operations
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
try {
await onCreateDirectory(newFolderName.trim());
setShowNewFolderDialog(false);
setNewFolderName("");
} catch {
/* Error handling */
}
};
const handleRename = async () => {
if (!renameTarget || !renameName.trim()) return;
try {
await onRenameFile(renameTarget, renameName.trim());
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
} catch {
/* Error handling */
}
};
const handleDelete = async () => {
if (deleteTargets.length === 0) return;
try {
await onDeleteFiles(deleteTargets);
setShowDeleteConfirm(false);
setDeleteTargets([]);
onClearSelection();
} catch {
/* Error handling */
}
};
// Drag handlers
const handlePaneDragOver = (e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
};
const handlePaneDragLeave = (e: React.DragEvent) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && paneContainerRef.current?.contains(relatedTarget))
return;
setIsDragOverPane(false);
setDragOverEntry(null);
};
const handlePaneDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverPane(false);
setDragOverEntry(null);
if (!draggedFiles || draggedFiles[0]?.side === side) return;
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
};
const handleFileDragStart = (entry: SftpFileEntry, e: React.DragEvent) => {
if (entry.name === "..") {
e.preventDefault();
return;
}
const selectedNames = Array.from(pane.selectedFiles);
const files = selectedNames.includes(entry.name)
? sortedDisplayFiles
.filter((f) => selectedNames.includes(f.name))
.map((f) => ({
name: f.name,
isDirectory: f.type === "directory",
side,
}))
: [{ name: entry.name, isDirectory: entry.type === "directory", side }];
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
onDragStart(files, side);
};
const handleEntryDragOver = (entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (entry.type === "directory" && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(entry.name);
}
};
const handleEntryDrop = (entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (entry.type === "directory" && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
}
};
const openRenameDialog = (name: string) => {
setRenameTarget(name);
setRenameName(name);
setShowRenameDialog(true);
};
const openDeleteConfirm = (names: string[]) => {
setDeleteTargets(names);
setShowDeleteConfirm(true);
};
// No connection state
if (!pane.connection) {
return (
<div className="absolute inset-0 flex flex-col">
<div className="h-12 px-4 border-b border-border/60 flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
{side === "left" ? <Monitor size={14} /> : <HardDrive size={14} />}
<span>{side === "left" ? "Local" : "Remote"}</span>
</div>
<Button
variant="outline"
size="sm"
className="h-8 px-3"
onClick={() => setShowHostPicker(true)}
>
<Plus size={14} className="mr-2" /> Select host
</Button>
</div>
<div className="flex-1 flex flex-col items-center justify-center text-center gap-4 p-6">
<div className="h-14 w-14 rounded-xl bg-secondary/60 text-primary flex items-center justify-center">
{side === "left" ? <Monitor size={24} /> : <HardDrive size={24} />}
</div>
<div>
<div className="text-sm font-semibold mb-1">
Select a host to start
</div>
<div className="text-xs text-muted-foreground">
Choose a local or remote filesystem to browse
</div>
</div>
<Button onClick={() => setShowHostPicker(true)}>
<Plus size={14} className="mr-2" /> Select host
</Button>
</div>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={() => onConnect("local")}
onSelectHost={onConnect}
/>
</div>
);
}
return (
<div
ref={paneContainerRef}
className={cn(
"absolute inset-0 flex flex-col transition-colors",
isDragOverPane && "bg-primary/5",
)}
onDragOver={handlePaneDragOver}
onDragLeave={handlePaneDragLeave}
onDrop={handlePaneDrop}
>
{/* Header */}
<div className="h-12 px-4 border-b border-border/60 flex items-center gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
{pane.connection.isLocal ? (
<Monitor size={14} />
) : (
<HardDrive size={14} />
)}
<span>{pane.connection.hostLabel}</span>
{(pane.connection.status === "connecting" || pane.reconnecting) && (
<Loader2 size={12} className="animate-spin text-muted-foreground" />
)}
{pane.reconnecting && (
<span className="text-xs text-muted-foreground">
Reconnecting...
</span>
)}
{pane.connection.status === "error" && !pane.reconnecting && (
<AlertCircle size={12} className="text-destructive" />
)}
</div>
<Button
variant="outline"
size="sm"
className="h-8 px-3"
onClick={() => setShowHostPicker(true)}
>
<RefreshCw size={12} className="mr-1" /> Change
</Button>
<div className="flex items-center gap-1 ml-auto">
<div className="relative">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={pane.filter}
onChange={(e) => onSetFilter(e.target.value)}
placeholder="Filter..."
className="h-8 w-36 pl-8 pr-7 text-xs bg-secondary/40"
/>
{pane.filter && (
<button
onClick={() => onSetFilter("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onRefresh}
title="Refresh"
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</div>
</div>
{/* Toolbar */}
<div className="h-10 px-4 flex items-center gap-2 border-b border-border/40 bg-secondary/20">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onNavigateUp}
title="Go up"
>
<ChevronLeft size={14} />
</Button>
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-7 w-full text-xs bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title="Double-click to edit path"
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
)}
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setShowNewFolderDialog(true)}
>
<FolderPlus size={12} className="mr-1" /> New Folder
</Button>
</div>
</div>
{/* File list header */}
<div
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%`,
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("name")}
>
<span>Name</span>
{sortField === "name" && (
<span className="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>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("modified")}
>
<span>Modified</span>
{sortField === "modified" && (
<span className="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>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end"
onClick={() => handleSort("size")}
>
{sortField === "size" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>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>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground justify-end"
onClick={() => handleSort("type")}
>
{sortField === "type" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>Kind</span>
</div>
</div>
{/* File list */}
<div
ref={fileListRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
)}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : pane.error ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{pane.error}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>
Retry
</Button>
</div>
) : 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">Empty directory</span>
</div>
) : (
<div className="divide-y divide-border/30">
{sortedDisplayFiles.map((entry, idx) => (
<ContextMenu key={`${entry.name}-${idx}`}>
<ContextMenuTrigger>
<SftpFileRow
entry={entry}
isSelected={pane.selectedFiles.has(entry.name)}
isDragOver={dragOverEntry === entry.name}
columnWidths={columnWidths}
onSelect={(e) => {
if (entry.name === "..") return;
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(
lastSelectedIndexRef.current,
idx,
);
const end = Math.max(lastSelectedIndexRef.current, idx);
const selectedFileNames = sortedDisplayFiles
.slice(start, end + 1)
.filter((f) => f.name !== "..")
.map((f) => f.name);
onRangeSelect(selectedFileNames);
} else {
onToggleSelection(entry.name, e.ctrlKey || e.metaKey);
lastSelectedIndexRef.current = idx;
}
}}
onOpen={() => onOpenEntry(entry)}
onDragStart={(e) => handleFileDragStart(entry, e)}
onDragEnd={onDragEnd}
onDragOver={(e) => handleEntryDragOver(entry, e)}
onDragLeave={() => setDragOverEntry(null)}
onDrop={(e) => handleEntryDrop(entry, e)}
/>
</ContextMenuTrigger>
{entry.name !== ".." && (
<ContextMenuContent>
<ContextMenuItem onClick={() => onOpenEntry(entry)}>
{entry.type === "directory" ? "Open" : "Download"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
: [entry.name];
const fileData = files.map((name) => {
const file = sortedDisplayFiles.find(
(f) => f.name === name,
);
return {
name,
isDirectory: file?.type === "directory" || false,
};
});
onCopyToOtherPane(fileData);
}}
>
<Copy size={14} className="mr-2" /> Copy to other pane
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => openRenameDialog(entry.name)}
>
<Pencil size={14} className="mr-2" /> Rename
</ContextMenuItem>
{onEditPermissions &&
pane.connection &&
!pane.connection.isLocal && (
<ContextMenuItem
onClick={() => onEditPermissions(entry)}
>
<Shield size={14} className="mr-2" /> Permissions
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
: [entry.name];
openDeleteConfirm(files);
}}
>
<Trash2 size={14} className="mr-2" /> Delete
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onRefresh}>
<RefreshCw size={14} className="mr-2" /> Refresh
</ContextMenuItem>
<ContextMenuItem
onClick={() => setShowNewFolderDialog(true)}
>
<FolderPlus size={14} className="mr-2" /> New Folder
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
))}
</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">Drop files here</span>
</div>
</div>
)}
</div>
{/* 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>
{sortedDisplayFiles.filter((f) => f.name !== "..").length} items
{pane.selectedFiles.size > 0 &&
` - ${pane.selectedFiles.size} selected`}
</span>
<span className="truncate max-w-[200px]">
{pane.connection.currentPath}
</span>
</div>
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>New Folder</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Folder name</Label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Enter folder name"
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFolderDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim()}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Rename</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>New name</Label>
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder="Enter new name"
onKeyDown={(e) => e.key === "Enter" && handleRename()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowRenameDialog(false)}
>
Cancel
</Button>
<Button onClick={handleRename} disabled={!renameName.trim()}>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
Delete {deleteTargets.length} item
{deleteTargets.length > 1 ? "s" : ""}?
</DialogTitle>
<DialogDescription>
This action cannot be undone. The following will be deleted:
</DialogDescription>
</DialogHeader>
<div className="max-h-32 overflow-auto text-sm space-y-1">
{deleteTargets.map((name) => (
<div
key={name}
className="flex items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} />
<span className="truncate">{name}</span>
</div>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={() => {
onDisconnect();
onConnect("local");
}}
onSelectHost={(host) => {
onDisconnect();
onConnect(host);
}}
/>
</div>
);
};
const SftpPaneView = memo(SftpPaneViewInner);
SftpPaneView.displayName = "SftpPaneView";
// Main SftpView component
interface SftpViewProps {
hosts: Host[];
keys: SSHKey[];
}
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys }) => {
const isActive = useIsSftpActive();
const sftp = useSftpState(hosts, keys);
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
side: "left" | "right";
} | null>(null);
const [draggedFiles, setDraggedFiles] = useState<
{ name: string; isDirectory: boolean; side: "left" | "right" }[] | null
>(null);
// Memoized callbacks
const handleDragStart = useCallback(
(
files: { name: string; isDirectory: boolean }[],
side: "left" | "right",
) => {
setDraggedFiles(files.map((f) => ({ ...f, side })));
},
[],
);
const handleDragEnd = useCallback(() => {
setDraggedFiles(null);
}, []);
/* eslint-disable react-hooks/exhaustive-deps -- sftp methods are stable references from useSftpState hook */
const handleCopyToOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftp.startTransfer(files, "left", "right"),
[sftp.startTransfer],
);
const handleCopyToOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftp.startTransfer(files, "right", "left"),
[sftp.startTransfer],
);
const handleReceiveFromOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftp.startTransfer(files, "right", "left"),
[sftp.startTransfer],
);
const handleReceiveFromOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftp.startTransfer(files, "left", "right"),
[sftp.startTransfer],
);
const handleConnectLeft = useCallback(
(host: Host) => sftp.connect("left", host),
[sftp.connect],
);
const handleConnectRight = useCallback(
(host: Host) => sftp.connect("right", host),
[sftp.connect],
);
const handleDisconnectLeft = useCallback(
() => sftp.disconnect("left"),
[sftp.disconnect],
);
const handleDisconnectRight = useCallback(
() => sftp.disconnect("right"),
[sftp.disconnect],
);
const handleNavigateToLeft = useCallback(
(path: string) => sftp.navigateTo("left", path),
[sftp.navigateTo],
);
const handleNavigateToRight = useCallback(
(path: string) => sftp.navigateTo("right", path),
[sftp.navigateTo],
);
const handleNavigateUpLeft = useCallback(
() => sftp.navigateUp("left"),
[sftp.navigateUp],
);
const handleNavigateUpRight = useCallback(
() => sftp.navigateUp("right"),
[sftp.navigateUp],
);
const handleRefreshLeft = useCallback(
() => sftp.refresh("left"),
[sftp.refresh],
);
const handleRefreshRight = useCallback(
() => sftp.refresh("right"),
[sftp.refresh],
);
const handleOpenEntryLeft = useCallback(
(entry: SftpFileEntry) => sftp.openEntry("left", entry),
[sftp.openEntry],
);
const handleOpenEntryRight = useCallback(
(entry: SftpFileEntry) => sftp.openEntry("right", entry),
[sftp.openEntry],
);
const handleToggleSelectionLeft = useCallback(
(name: string, multi: boolean) => sftp.toggleSelection("left", name, multi),
[sftp.toggleSelection],
);
const handleToggleSelectionRight = useCallback(
(name: string, multi: boolean) =>
sftp.toggleSelection("right", name, multi),
[sftp.toggleSelection],
);
const handleRangeSelectLeft = useCallback(
(fileNames: string[]) => sftp.rangeSelect("left", fileNames),
[sftp.rangeSelect],
);
const handleRangeSelectRight = useCallback(
(fileNames: string[]) => sftp.rangeSelect("right", fileNames),
[sftp.rangeSelect],
);
const handleClearSelectionLeft = useCallback(
() => sftp.clearSelection("left"),
[sftp.clearSelection],
);
const handleClearSelectionRight = useCallback(
() => sftp.clearSelection("right"),
[sftp.clearSelection],
);
const handleSetFilterLeft = useCallback(
(filter: string) => sftp.setFilter("left", filter),
[sftp.setFilter],
);
const handleSetFilterRight = useCallback(
(filter: string) => sftp.setFilter("right", filter),
[sftp.setFilter],
);
const handleCreateDirectoryLeft = useCallback(
(name: string) => sftp.createDirectory("left", name),
[sftp.createDirectory],
);
const handleCreateDirectoryRight = useCallback(
(name: string) => sftp.createDirectory("right", name),
[sftp.createDirectory],
);
const handleDeleteFilesLeft = useCallback(
(names: string[]) => sftp.deleteFiles("left", names),
[sftp.deleteFiles],
);
const handleDeleteFilesRight = useCallback(
(names: string[]) => sftp.deleteFiles("right", names),
[sftp.deleteFiles],
);
const handleRenameFileLeft = useCallback(
(old: string, newName: string) => sftp.renameFile("left", old, newName),
[sftp.renameFile],
);
const handleRenameFileRight = useCallback(
(old: string, newName: string) => sftp.renameFile("right", old, newName),
[sftp.renameFile],
);
/* eslint-enable react-hooks/exhaustive-deps */
const handleEditPermissionsLeft = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "left" }),
[],
);
const handleEditPermissionsRight = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "right" }),
[],
);
const leftFilteredFiles = useMemo(
() => sftp.getFilteredFiles(sftp.leftPane),
// eslint-disable-next-line react-hooks/exhaustive-deps -- sftp.getFilteredFiles is a stable reference
[sftp.leftPane, sftp.getFilteredFiles],
);
const rightFilteredFiles = useMemo(
() => sftp.getFilteredFiles(sftp.rightPane),
// eslint-disable-next-line react-hooks/exhaustive-deps -- sftp.getFilteredFiles is a stable reference
[sftp.rightPane, sftp.getFilteredFiles],
);
const visibleTransfers = useMemo(
() => sftp.transfers.slice(-5),
[sftp.transfers],
);
const containerStyle: React.CSSProperties = isActive
? {}
: {
visibility: "hidden",
pointerEvents: "none",
position: "absolute",
zIndex: -1,
};
return (
<div
className={cn(
"absolute inset-0 min-h-0 flex flex-col",
isActive ? "z-20" : "",
)}
style={containerStyle}
>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
<div className="relative border-r border-border/70">
<SftpPaneView
side="left"
pane={sftp.leftPane}
hosts={hosts}
filteredFiles={leftFilteredFiles}
onConnect={handleConnectLeft}
onDisconnect={handleDisconnectLeft}
onNavigateTo={handleNavigateToLeft}
onNavigateUp={handleNavigateUpLeft}
onRefresh={handleRefreshLeft}
onOpenEntry={handleOpenEntryLeft}
onToggleSelection={handleToggleSelectionLeft}
onRangeSelect={handleRangeSelectLeft}
onClearSelection={handleClearSelectionLeft}
onSetFilter={handleSetFilterLeft}
onCreateDirectory={handleCreateDirectoryLeft}
onDeleteFiles={handleDeleteFilesLeft}
onRenameFile={handleRenameFileLeft}
onCopyToOtherPane={handleCopyToOtherPaneLeft}
onReceiveFromOtherPane={handleReceiveFromOtherPaneLeft}
onEditPermissions={handleEditPermissionsLeft}
draggedFiles={draggedFiles}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
</div>
<div className="relative">
<SftpPaneView
side="right"
pane={sftp.rightPane}
hosts={hosts}
filteredFiles={rightFilteredFiles}
onConnect={handleConnectRight}
onDisconnect={handleDisconnectRight}
onNavigateTo={handleNavigateToRight}
onNavigateUp={handleNavigateUpRight}
onRefresh={handleRefreshRight}
onOpenEntry={handleOpenEntryRight}
onToggleSelection={handleToggleSelectionRight}
onRangeSelect={handleRangeSelectRight}
onClearSelection={handleClearSelectionRight}
onSetFilter={handleSetFilterRight}
onCreateDirectory={handleCreateDirectoryRight}
onDeleteFiles={handleDeleteFilesRight}
onRenameFile={handleRenameFileRight}
onCopyToOtherPane={handleCopyToOtherPaneRight}
onReceiveFromOtherPane={handleReceiveFromOtherPaneRight}
onEditPermissions={handleEditPermissionsRight}
draggedFiles={draggedFiles}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
</div>
</div>
{sftp.transfers.length > 0 && (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
<span className="font-medium">
Transfers
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({sftp.activeTransfersCount} active)
</span>
)}
</span>
{sftp.transfers.some(
(t) => t.status === "completed" || t.status === "cancelled",
) && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={sftp.clearCompletedTransfers}
>
Clear completed
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => sftp.cancelTransfer(task.id)}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
/>
))}
</div>
</div>
)}
<SftpConflictDialog
conflicts={sftp.conflicts}
onResolve={sftp.resolveConflict}
formatFileSize={sftp.formatFileSize}
/>
<SftpPermissionsDialog
open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)}
file={permissionsState?.file ?? null}
onSave={(file, permissions) => {
if (permissionsState) {
const fullPath = sftp.joinPath(
permissionsState.side === "left"
? sftp.leftPane.connection?.currentPath || ""
: sftp.rightPane.connection?.currentPath || "",
file.name,
);
sftp.changePermissions(
permissionsState.side,
fullPath,
permissions,
);
}
setPermissionsState(null);
}}
/>
</div>
);
};
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts && prev.keys === next.keys;
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
SftpView.displayName = "SftpView";