Files
Netcatty/components/sftp/SftpPaneToolbar.tsx
2026-05-18 20:00:10 +08:00

687 lines
25 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
import { cn } from "../../lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
import { SftpBreadcrumb } from "./SftpBreadcrumb";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpBookmark } from "../../domain/models";
interface SftpPaneToolbarProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onNavigateTo: (path: string) => void;
onSetFilter: (value: string) => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
onRefresh: () => void;
showFilterBar: boolean;
setShowFilterBar: (open: boolean) => void;
filterInputRef: React.RefObject<HTMLInputElement>;
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
setShowPathSuggestions: (open: boolean) => void;
showPathSuggestions: boolean;
setPathSuggestionIndex: (value: number) => void;
pathSuggestions: { path: string; type: "folder" | "history" }[];
pathSuggestionIndex: number;
pathInputRef: React.RefObject<HTMLInputElement>;
pathDropdownRef: React.RefObject<HTMLDivElement>;
handlePathBlur: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
startTransition: React.TransitionStartFunction;
getNextUntitledName: (existingNames: string[]) => string;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
setShowNewFileDialog: (open: boolean) => void;
setShowNewFolderDialog: (open: boolean) => void;
setNewFolderName: (value: string) => void;
// Bookmark props
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
onToggleBookmark: () => void;
onAddGlobalBookmark: (path: string) => void;
isCurrentPathGlobalBookmarked: boolean;
onNavigateToBookmark: (path: string) => void;
onDeleteBookmark: (id: string) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
viewMode: 'list' | 'tree';
onSetViewMode: (mode: 'list' | 'tree') => void;
onListDrives?: () => Promise<string[]>;
}
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
// bookmark ~20px, padding ~16px. Collapse early so the breadcrumb
// always gets at least ~200px of space.
const COLLAPSE_WIDTH = 400;
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
t,
pane,
onNavigateTo,
onSetFilter,
onSetFilenameEncoding,
onRefresh,
showFilterBar,
setShowFilterBar,
filterInputRef,
isEditingPath,
editingPathValue,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
showPathSuggestions,
pathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
startTransition,
getNextUntitledName,
setNewFileName,
setFileNameError,
setShowNewFileDialog,
setShowNewFolderDialog,
setNewFolderName,
bookmarks,
isCurrentPathBookmarked,
onToggleBookmark,
onAddGlobalBookmark,
isCurrentPathGlobalBookmarked,
onNavigateToBookmark,
onDeleteBookmark,
showHiddenFiles,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
viewMode,
onSetViewMode,
onListDrives,
}) => {
const outerRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(false);
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
useEffect(() => {
const connectionChanged = pane.connection?.id !== prevDisplayConnectionIdRef.current;
prevDisplayConnectionIdRef.current = pane.connection?.id;
// Sync immediately on connection change; otherwise defer until loading completes
if (connectionChanged || !pane.loading) {
setDisplayPath(pane.connection?.currentPath ?? "");
}
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
// Observe the overall toolbar width to decide whether to collapse action buttons
useEffect(() => {
const el = outerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setCollapsed(entry.contentRect.width < COLLAPSE_WIDTH);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const handleNewFolder = useCallback(() => {
setNewFolderName("");
setShowNewFolderDialog(true);
}, [setNewFolderName, setShowNewFolderDialog]);
const handleNewFile = useCallback(() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}, [getNextUntitledName, pane.files, setNewFileName, setFileNameError, setShowNewFileDialog]);
const handleToggleFilter = useCallback(() => {
setShowFilterBar(!showFilterBar);
if (!showFilterBar) {
setTimeout(() => filterInputRef.current?.focus(), 0);
}
}, [showFilterBar, setShowFilterBar, filterInputRef]);
const isRemote = !pane.connection?.isLocal;
// Buttons that always remain visible (not collapsed)
const pinnedButtons = (
<>
{onGoToTerminalCwd && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onGoToTerminalCwd}
>
<TerminalSquare size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", viewMode === 'list' && "bg-secondary text-foreground")}
aria-pressed={viewMode === 'list'}
aria-label={t('sftp.viewMode.list')}
onClick={() => onSetViewMode('list')}
>
<List size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.viewMode.list')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", viewMode === 'tree' && "bg-secondary text-foreground")}
aria-pressed={viewMode === 'tree'}
aria-label={t('sftp.viewMode.tree')}
onClick={() => onSetViewMode('tree')}
>
<ListTree size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.viewMode.tree')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", pane.filter && "text-primary")}
onClick={handleToggleFilter}
>
<Search size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.filter")}</TooltipContent>
</Tooltip>
</>
);
// Collapsible action buttons (shown inline when space allows)
const collapsibleButtons = (
<>
{isRemote && (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="end">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNewFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNewFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.refresh")}</TooltipContent>
</Tooltip>
</>
);
// Overflow dropdown menu items (same collapsible actions as menu items)
const overflowMenuItems = (
<div className="flex flex-col min-w-[140px]">
<div role="radiogroup" aria-label={t('sftp.viewMode.label')}>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
viewMode === 'list' && "text-primary"
)}
role="radio"
aria-checked={viewMode === 'list'}
onClick={() => onSetViewMode('list')}
>
<List size={14} className="shrink-0" />
{t('sftp.viewMode.list')}
</button>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
viewMode === 'tree' && "text-primary"
)}
role="radio"
aria-checked={viewMode === 'tree'}
onClick={() => onSetViewMode('tree')}
>
<ListTree size={14} className="shrink-0" />
{t('sftp.viewMode.tree')}
</button>
</div>
{isRemote && (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left">
<Languages size={14} className="shrink-0" />
{t("sftp.encoding.label")}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start" side="right">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={handleNewFolder}
>
<FolderPlus size={14} className="shrink-0" />
{t("sftp.newFolder")}
</button>
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={handleNewFile}
>
<FilePlus size={14} className="shrink-0" />
{t("sftp.newFile")}
</button>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
showHiddenFiles && "text-primary",
)}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} className="shrink-0" /> : <Eye size={14} className="shrink-0" />}
{t("settings.sftp.showHiddenFiles")}
</button>
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn("shrink-0", (pane.loading || pane.reconnecting) && "animate-spin")}
/>
{t("common.refresh")}
</button>
</div>
);
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
{/* Toolbar - always visible when connected */}
<div ref={outerRef} className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
{/* 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-5 w-full text-[10px] 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>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
>
<SftpBreadcrumb
path={displayPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
isLocal={!isRemote}
onListDrives={onListDrives}
/>
</div>
</TooltipTrigger>
<TooltipContent>{t("sftp.path.doubleClickToEdit")}</TooltipContent>
</Tooltip>
)}
{/* Bookmark button with dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
onClick={(e) => {
// If not bookmarked, toggle directly instead of opening popover
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
e.preventDefault();
onToggleBookmark();
}
}}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-64 p-0" align="start">
<div className="p-2 border-b border-border/40 flex gap-1">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="flex-1 justify-start text-xs h-7"
onClick={onToggleBookmark}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</Button>
{pane.connection?.currentPath && !isCurrentPathGlobalBookmarked && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2 shrink-0"
onClick={() => pane.connection?.currentPath && onAddGlobalBookmark(pane.connection.currentPath)}
>
{t("sftp.bookmark.addGlobal")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.bookmark.addGlobalTooltip")}</TooltipContent>
</Tooltip>
)}
</div>
{bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
{bookmarks.map((bm) => (
<div
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
{bm.global && (
<Globe size={10} className="shrink-0 text-primary" />
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
</TooltipTrigger>
<TooltipContent>{bm.path}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</div>
) : (
<div className="p-3 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</PopoverContent>
</Popover>
{/* Action buttons area - observed for overflow */}
<div className="ml-auto flex items-center gap-0.5 shrink-0">
{collapsed ? (
<>
{pinnedButtons}
<Dropdown>
<Tooltip>
<TooltipTrigger asChild>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<MoreHorizontal size={14} />
</Button>
</DropdownTrigger>
</TooltipTrigger>
<TooltipContent>{t("common.more")}</TooltipContent>
</Tooltip>
<DropdownContent align="end">
{overflowMenuItems}
</DropdownContent>
</Dropdown>
</>
) : (
<>
{pinnedButtons}
{collapsibleButtons}
</>
)}
</div>
</div>
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
startTransition(() => onSetFilter(""));
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
setShowFilterBar(false);
}}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.close")}</TooltipContent>
</Tooltip>
</div>
)}
</TooltipProvider>
);
});