Files
Termix/plugins/file-manager/src/frontend/FileManagerToolbar.tsx
T
ZacharyZcR 2770f14a25 fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F
2026-09-26 18:19:35 -05:00

537 lines
17 KiB
TypeScript

import React, { useEffect, useRef, useState } from "react";
import {
ArrowUp,
ChevronLeft,
ChevronRight,
Copy,
FilePlus,
Folder,
FolderPlus,
Grid3X3,
Laptop,
Layout,
List,
Plus,
RefreshCw,
Rows3,
Search,
Trash2,
Upload,
} from "lucide-react";
import {
Button,
Input,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import type { FileItem } from "./host-types";
type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc";
type ViewMode = "grid" | "list";
type Density = "comfortable" | "compact";
type FileManagerToolbarProps = {
t: (key: string) => string;
currentPath: string;
navIndex: number;
navHistoryLength: number;
isLoading: boolean;
sshSessionId: string | null;
selectedFiles: FileItem[];
searchQuery: string;
setSearchQuery: (query: string) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
density: Density;
setDensity: (density: Density) => void;
sortBy: SortBy;
setSortBy: (sortBy: SortBy) => void;
sortOrder: SortOrder;
setSortOrder: (sortOrder: SortOrder) => void;
setMobileSidebarOpen: (updater: (open: boolean) => boolean) => void;
goBack: () => void;
goForward: () => void;
goUp: () => void;
navigateTo: (path: string) => void;
handleRefreshDirectory: () => void;
handleDeleteFiles: (files: FileItem[]) => void;
handleCopyFiles: (files: FileItem[]) => void;
handleFilesDropped: (fileList: FileList) => void;
handleCreateNewFolder: () => void;
handleCreateNewFile: () => void;
/** Desktop app only: show the Local | Remote split-view toggle. */
showLocalPaneToggle?: boolean;
localPaneOpen?: boolean;
onToggleLocalPane?: () => void;
/** Desktop directories sidebar (mobile uses the overlay instead). */
sidebarOpen?: boolean;
onToggleSidebar?: () => void;
};
function Breadcrumb({
currentPath,
navigateTo,
t,
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t">) {
return (
<>
<Folder className="size-3.5 text-accent-brand shrink-0" />
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
{currentPath.split("/").map((part, i, arr) => (
<React.Fragment key={i}>
{part === "" && i === 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
navigateTo("/");
}}
className="hover:text-accent-brand transition-colors"
>
{t("fileManager.root")}
</button>
) : part !== "" ? (
<button
onClick={(e) => {
e.stopPropagation();
navigateTo(arr.slice(0, i + 1).join("/") || "/");
}}
className="hover:text-accent-brand transition-colors"
>
{part}
</button>
) : null}
{i < arr.length - 1 && part !== "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
{i === 0 && arr.length > 1 && part === "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
</React.Fragment>
))}
</div>
</>
);
}
function PathBar({
currentPath,
navigateTo,
t,
className,
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t"> & {
className: string;
}) {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(currentPath);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => {
if (!isEditing) return;
doneRef.current = false;
const timer = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => clearTimeout(timer);
}, [isEditing]);
const commit = (path: string) => {
if (doneRef.current) return;
doneRef.current = true;
setIsEditing(false);
const trimmed = path.trim();
if (trimmed && trimmed !== currentPath) {
navigateTo(trimmed);
}
};
const cancel = () => {
if (doneRef.current) return;
doneRef.current = true;
setIsEditing(false);
};
if (isEditing) {
return (
<div className={className}>
<Folder className="size-3.5 text-accent-brand shrink-0" />
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit(value);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
onBlur={() => commit(value)}
className="flex-1 min-w-0 bg-transparent text-xs font-semibold tracking-wide outline-none text-foreground"
/>
</div>
);
}
return (
<div
className={`${className} cursor-text`}
onClick={() => {
setValue(currentPath);
setIsEditing(true);
}}
>
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
</div>
);
}
export function FileManagerToolbar({
t,
currentPath,
navIndex,
navHistoryLength,
isLoading,
sshSessionId,
selectedFiles,
searchQuery,
setSearchQuery,
viewMode,
setViewMode,
density,
setDensity,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
setMobileSidebarOpen,
goBack,
goForward,
goUp,
navigateTo,
handleRefreshDirectory,
handleDeleteFiles,
handleCopyFiles,
handleFilesDropped,
handleCreateNewFolder,
handleCreateNewFile,
showLocalPaneToggle = false,
localPaneOpen = false,
onToggleLocalPane,
sidebarOpen = true,
onToggleSidebar,
}: FileManagerToolbarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleSearchShortcut = (event: KeyboardEvent) => {
if (
event.defaultPrevented ||
!(event.ctrlKey || event.metaKey) ||
event.altKey ||
event.shiftKey ||
event.key.toLowerCase() !== "f"
)
return;
const input = searchInputRef.current;
if (!input) return;
const visible =
typeof input.checkVisibility === "function"
? input.checkVisibility({ visibilityProperty: true })
: input.offsetParent !== null;
if (!visible) return;
const active = document.activeElement;
if (
active !== input &&
active?.closest(
'input, textarea, [contenteditable="true"], [role="dialog"]',
)
)
return;
event.preventDefault();
input.focus();
input.select();
};
document.addEventListener("keydown", handleSearchShortcut);
return () => document.removeEventListener("keydown", handleSearchShortcut);
}, []);
return (
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
// Below md the sidebar is an overlay; above it, a persisted
// show/hide of the directories panel.
const isDesktop =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 768px)").matches;
if (isDesktop && onToggleSidebar) onToggleSidebar();
else setMobileSidebarOpen((open) => !open);
}}
className={`size-8 rounded-none ${sidebarOpen ? "" : "md:bg-accent-brand/10 md:text-accent-brand"}`}
title={t("fileManager.toggleSidebar")}
aria-pressed={!sidebarOpen}
>
<Layout className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goBack}
disabled={navIndex <= 0}
className="size-8 rounded-none"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goForward}
disabled={navIndex >= navHistoryLength - 1}
className="size-8 rounded-none"
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goUp}
disabled={currentPath === "/"}
className="size-8 rounded-none"
>
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshDirectory}
className="size-8 rounded-none"
>
<RefreshCw
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
/>
</Button>
</div>
<PathBar
currentPath={currentPath}
navigateTo={navigateTo}
t={t}
className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden"
/>
<div className="flex items-center gap-2">
{selectedFiles.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 bg-accent-brand/10 border border-accent-brand/20 text-accent-brand text-[10px] font-black uppercase tracking-tighter">
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleDeleteFiles(selectedFiles)}
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleCopyFiles(selectedFiles)}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="relative w-28 md:w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder={t("fileManager.searchFiles")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50"
/>
</div>
{showLocalPaneToggle && (
<Button
variant={localPaneOpen ? "secondary" : "ghost"}
size="icon"
onClick={onToggleLocalPane}
title={
localPaneOpen
? t("fileManager.hideLocalFiles")
: t("fileManager.showLocalFiles")
}
aria-pressed={localPaneOpen}
className={`hidden md:inline-flex size-8 rounded-none border border-border ${localPaneOpen ? "bg-accent-brand/10 text-accent-brand border-accent-brand/40" : ""}`}
>
<Laptop className="size-4" />
</Button>
)}
<div className="flex items-center border border-border rounded-none overflow-hidden">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("grid")}
className={`size-8 rounded-none ${viewMode === "grid" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<Grid3X3 className="size-4" />
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("list")}
className={`size-8 rounded-none border-l border-border ${viewMode === "list" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<List className="size-4" />
</Button>
<Button
variant={density === "compact" ? "secondary" : "ghost"}
size="icon"
onClick={() =>
setDensity(density === "compact" ? "comfortable" : "compact")
}
className={`size-8 rounded-none border-l border-border ${density === "compact" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
title={t(
density === "compact"
? "fileManager.comfortableLayout"
: "fileManager.compactLayout",
)}
>
<Rows3 className="size-4" />
</Button>
</div>
<label
className="hidden md:block cursor-pointer"
title={t("fileManager.upload")}
>
<input
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files) handleFilesDropped(files);
}}
/>
<div className="h-8 px-3 flex items-center gap-1.5 border border-border hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-[10px] font-bold uppercase tracking-widest">
<Upload className="size-3.5" /> {t("fileManager.upload")}
</div>
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none font-bold uppercase tracking-widest text-[10px]"
>
<Plus className="size-3.5" />
{t("fileManager.new")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44 rounded-none border-border bg-card"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFolder(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FolderPlus className="size-4 text-accent-brand" />
{t("fileManager.newFolder")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFile(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FilePlus className="size-4 text-muted-foreground" />
{t("fileManager.newFile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-muted-foreground py-1">
{t("fileManager.sortBy")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(value) => setSortBy(value as SortBy)}
>
<DropdownMenuRadioItem
value="name"
className="rounded-none text-xs"
>
{t("fileManager.sortByName")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="modified"
className="rounded-none text-xs"
>
{t("fileManager.sortByDate")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="size"
className="rounded-none text-xs"
>
{t("fileManager.sortBySize")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sortOrder}
onValueChange={(value) => setSortOrder(value as SortOrder)}
>
<DropdownMenuRadioItem
value="asc"
className="rounded-none text-xs"
>
{t("fileManager.ascending")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="desc"
className="rounded-none text-xs"
>
{t("fileManager.descending")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
<PathBar
currentPath={currentPath}
navigateTo={navigateTo}
t={t}
className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden"
/>
</div>
</div>
);
}