mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: convert file manager into a plugin
This commit is contained in:
1 parent
b76da9ab52
commit
c85c1fdb86
259 files changed
+54155
-16791
No files matched your search
@@ -0,0 +1,498 @@
|
||||
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 "@/types/index";
|
||||
|
||||
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) {
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user