Improve file manager navigation and compact layout (#1308)

This commit is contained in:
ZacharyZcR
2026-08-24 01:09:00 +08:00
committed by GitHub
parent 433ede0b0d
commit 9ee50d624a
4 changed files with 129 additions and 23 deletions
@@ -154,6 +154,11 @@ function FileManagerContent({
const saved = localStorage.getItem("fileManagerViewMode"); const saved = localStorage.getItem("fileManagerViewMode");
return saved === "grid" || saved === "list" ? saved : "grid"; return saved === "grid" || saved === "list" ? saved : "grid";
}); });
const [density, setDensity] = useState<"comfortable" | "compact">(() =>
localStorage.getItem("fileManagerDensity") === "compact"
? "compact"
: "comfortable",
);
// Picking an interface preset seeds this key from another part of the app. // Picking an interface preset seeds this key from another part of the app.
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
@@ -884,6 +889,25 @@ function FileManagerContent({
} }
}, [navIndex, navHistory, sshSessionId]); }, [navIndex, navHistory, sshSessionId]);
useEffect(() => {
const handleMouseNavigation = (event: MouseEvent) => {
if (event.button !== 3 && event.button !== 4) return;
event.preventDefault();
if (event.button === 3) goBack();
else goForward();
};
const preventBrowserMouseNavigation = (event: MouseEvent) => {
if (event.button === 3 || event.button === 4) event.preventDefault();
};
window.addEventListener("mouseup", handleMouseNavigation);
window.addEventListener("auxclick", preventBrowserMouseNavigation);
return () => {
window.removeEventListener("mouseup", handleMouseNavigation);
window.removeEventListener("auxclick", preventBrowserMouseNavigation);
};
}, [goBack, goForward]);
const goUp = useCallback(() => { const goUp = useCallback(() => {
if (currentPath === "/") return; if (currentPath === "/") return;
const parent = const parent =
@@ -3145,6 +3169,10 @@ function FileManagerContent({
localStorage.setItem("fileManagerViewMode", viewMode); localStorage.setItem("fileManagerViewMode", viewMode);
}, [viewMode]); }, [viewMode]);
useEffect(() => {
localStorage.setItem("fileManagerDensity", density);
}, [density]);
useEffect(() => { useEffect(() => {
localStorage.setItem("fileManagerSortBy", sortBy); localStorage.setItem("fileManagerSortBy", sortBy);
localStorage.setItem("fileManagerSortOrder", sortOrder); localStorage.setItem("fileManagerSortOrder", sortOrder);
@@ -3223,6 +3251,8 @@ function FileManagerContent({
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
viewMode={viewMode} viewMode={viewMode}
setViewMode={setViewMode} setViewMode={setViewMode}
density={density}
setDensity={setDensity}
sortBy={sortBy} sortBy={sortBy}
setSortBy={setSortBy} setSortBy={setSortBy}
sortOrder={sortOrder} sortOrder={sortOrder}
@@ -3304,6 +3334,7 @@ function FileManagerContent({
} }
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
viewMode={viewMode} viewMode={viewMode}
density={density}
onRename={handleRenameConfirm} onRename={handleRenameConfirm}
editingFile={editingFile} editingFile={editingFile}
onStartEdit={handleStartEdit} onStartEdit={handleStartEdit}
@@ -53,6 +53,7 @@ interface FileManagerGridProps {
onDownload?: (files: FileItem[]) => void; onDownload?: (files: FileItem[]) => void;
onContextMenu?: (event: React.MouseEvent, file?: FileItem) => void; onContextMenu?: (event: React.MouseEvent, file?: FileItem) => void;
viewMode?: "grid" | "list"; viewMode?: "grid" | "list";
density?: "comfortable" | "compact";
onRename?: (file: FileItem, newName: string) => void; onRename?: (file: FileItem, newName: string) => void;
editingFile?: FileItem | null; editingFile?: FileItem | null;
onStartEdit?: (file: FileItem) => void; onStartEdit?: (file: FileItem) => void;
@@ -89,8 +90,18 @@ const getFileTypeColor = (file: FileItem): string => {
return "text-blue-400"; return "text-blue-400";
}; };
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { const getFileIcon = (
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6"; file: FileItem,
viewMode: "grid" | "list" = "grid",
compact = false,
) => {
const iconClass = compact
? viewMode === "grid"
? "size-6"
: "size-4"
: viewMode === "grid"
? "size-8"
: "size-6";
const colorClass = getFileTypeColor(file); const colorClass = getFileTypeColor(file);
if (file.type === "directory") { if (file.type === "directory") {
@@ -170,6 +181,7 @@ export function FileManagerGrid({
onDownload, onDownload,
onContextMenu, onContextMenu,
viewMode = "grid", viewMode = "grid",
density = "comfortable",
onRename, onRename,
editingFile, editingFile,
onStartEdit, onStartEdit,
@@ -197,10 +209,13 @@ export function FileManagerGrid({
const [editingName, setEditingName] = useState(""); const [editingName, setEditingName] = useState("");
const [gridCols, setGridCols] = useState(4); const [gridCols, setGridCols] = useState(4);
const LIST_ROW_H = 41; const compact = density === "compact";
const GRID_ROW_H = 112; const LIST_ROW_H = compact ? 29 : 41;
const LIST_HEADER_H = 33; const GRID_ROW_H = compact ? 76 : 112;
const CONTENT_PAD = 16; const LIST_HEADER_H = compact ? 25 : 33;
const CONTENT_PAD = compact ? 8 : 16;
const GRID_GAP = compact ? 8 : 16;
const GRID_CELL = compact ? 76 : 112;
const [dragState, setDragState] = useState<DragState>({ const [dragState, setDragState] = useState<DragState>({
type: "none", type: "none",
@@ -216,15 +231,17 @@ export function FileManagerGrid({
const updateCols = () => { const updateCols = () => {
const w = el.clientWidth - CONTENT_PAD * 2; const w = el.clientWidth - CONTENT_PAD * 2;
// gap-4 (16px) + ~min cell 96px const n = Math.max(
const n = Math.max(2, Math.min(8, Math.floor((w + 16) / 112))); 2,
Math.min(10, Math.floor((w + GRID_GAP) / GRID_CELL)),
);
setGridCols(n); setGridCols(n);
}; };
updateCols(); updateCols();
const ro = new ResizeObserver(updateCols); const ro = new ResizeObserver(updateCols);
ro.observe(el); ro.observe(el);
return () => ro.disconnect(); return () => ro.disconnect();
}, [viewMode]); }, [viewMode, CONTENT_PAD, GRID_GAP, GRID_CELL]);
const gridRowCount = useMemo( const gridRowCount = useMemo(
() => (viewMode === "grid" ? Math.ceil(files.length / gridCols) : 0), () => (viewMode === "grid" ? Math.ceil(files.length / gridCols) : 0),
@@ -252,7 +269,14 @@ export function FileManagerGrid({
useLayoutEffect(() => { useLayoutEffect(() => {
if (viewMode === "list") listVirtualizer.measure(); if (viewMode === "list") listVirtualizer.measure();
else gridVirtualizer.measure(); else gridVirtualizer.measure();
}, [viewMode, files.length, editingFile?.path, createIntent, gridCols]); }, [
viewMode,
density,
files.length,
editingFile?.path,
createIntent,
gridCols,
]);
useEffect(() => { useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => { const handleGlobalMouseMove = (e: MouseEvent) => {
@@ -558,7 +582,8 @@ export function FileManagerGrid({
const contentLeft = selectionBox.left - CONTENT_PAD; const contentLeft = selectionBox.left - CONTENT_PAD;
const contentRight = selectionBox.right - CONTENT_PAD; const contentRight = selectionBox.right - CONTENT_PAD;
const cellW = const cellW =
(gridRef.current.clientWidth - CONTENT_PAD * 2 + 16) / gridCols; (gridRef.current.clientWidth - CONTENT_PAD * 2 + GRID_GAP) /
gridCols;
const startCol = Math.max(0, Math.floor(contentLeft / cellW)); const startCol = Math.max(0, Math.floor(contentLeft / cellW));
const endCol = Math.min( const endCol = Math.min(
gridCols - 1, gridCols - 1,
@@ -618,6 +643,7 @@ export function FileManagerGrid({
files, files,
onSelectionChange, onSelectionChange,
viewMode, viewMode,
GRID_GAP,
createIntent, createIntent,
gridCols, gridCols,
gridRowCount, gridRowCount,
@@ -921,7 +947,8 @@ export function FileManagerGrid({
<div <div
ref={gridRef} ref={gridRef}
className={cn( className={cn(
"absolute inset-0 p-4 overflow-y-auto thin-scrollbar", "absolute inset-0 overflow-y-auto thin-scrollbar",
compact ? "p-2" : "p-4",
dragState.type === "external" && dragState.type === "external" &&
"bg-muted/20 border-2 border-dashed border-primary", "bg-muted/20 border-2 border-dashed border-primary",
)} )}
@@ -956,10 +983,10 @@ export function FileManagerGrid({
</span> </span>
</div> </div>
) : viewMode === "grid" ? ( ) : viewMode === "grid" ? (
<div className="flex flex-col gap-4"> <div className={cn("flex flex-col", compact ? "gap-2" : "gap-4")}>
{createIntent && ( {createIntent && (
<div <div
className="grid gap-4" className={cn("grid", compact ? "gap-2" : "gap-4")}
style={{ style={{
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
}} }}
@@ -989,7 +1016,10 @@ export function FileManagerGrid({
}} }}
> >
<div <div
className="grid gap-4 pb-4" className={cn(
"grid",
compact ? "gap-2 pb-2" : "gap-4 pb-4",
)}
style={{ style={{
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
}} }}
@@ -1004,7 +1034,8 @@ export function FileManagerGrid({
data-file-path={file.path} data-file-path={file.path}
draggable={true} draggable={true}
className={cn( className={cn(
"group flex flex-col items-center p-3 rounded-none border-2 border-transparent transition-all cursor-pointer hover:bg-muted/50 select-none", "group flex flex-col items-center rounded-none border-2 border-transparent transition-all cursor-pointer hover:bg-muted/50 select-none",
compact ? "p-1.5" : "p-3",
isSelected && isSelected &&
"bg-accent-brand/10 border-accent-brand/40", "bg-accent-brand/10 border-accent-brand/40",
dragState.target?.path === file.path && dragState.target?.path === file.path &&
@@ -1026,8 +1057,13 @@ export function FileManagerGrid({
onDrop={(e) => handleFileDrop(e, file)} onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd} onDragEnd={handleFileDragEnd}
> >
<div className="relative mb-2 pointer-events-none"> <div
{getFileIcon(file, viewMode)} className={cn(
"relative pointer-events-none",
compact ? "mb-1" : "mb-2",
)}
>
{getFileIcon(file, viewMode, compact)}
</div> </div>
<div className="w-full flex flex-col items-center pointer-events-none"> <div className="w-full flex flex-col items-center pointer-events-none">
{editingFile?.path === file.path ? ( {editingFile?.path === file.path ? (
@@ -1046,7 +1082,10 @@ export function FileManagerGrid({
/> />
) : ( ) : (
<p <p
className="text-[11px] font-bold tracking-tight text-center truncate w-full px-1" className={cn(
"font-bold tracking-tight text-center truncate w-full px-1",
compact ? "text-[10px]" : "text-[11px]",
)}
title={file.name} title={file.name}
> >
{file.name} {file.name}
@@ -1079,7 +1118,12 @@ export function FileManagerGrid({
</div> </div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border sticky top-0 bg-card z-10"> <div
className={cn(
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border bg-card",
compact ? "px-2 py-1" : "px-4 py-2",
)}
>
<div <div
className="flex items-center gap-1 cursor-pointer hover:text-accent-brand transition-colors" className="flex items-center gap-1 cursor-pointer hover:text-accent-brand transition-colors"
onClick={() => onSortChange?.("name")} onClick={() => onSortChange?.("name")}
@@ -1150,7 +1194,10 @@ export function FileManagerGrid({
data-file-path={file.path} data-file-path={file.path}
draggable={true} draggable={true}
className={cn( className={cn(
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 rounded-none select-none transition-colors", "grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 items-center cursor-pointer border-b border-border hover:bg-muted/50 rounded-none select-none transition-colors",
compact
? "px-2 py-1 text-[11px]"
: "px-4 py-2 text-xs",
isSelected && "bg-accent-brand/10", isSelected && "bg-accent-brand/10",
dragState.target?.path === file.path && dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed", "bg-accent-brand/20 border-accent-brand border-dashed",
@@ -1169,9 +1216,14 @@ export function FileManagerGrid({
onDrop={(e) => handleFileDrop(e, file)} onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd} onDragEnd={handleFileDragEnd}
> >
<div className="flex items-center gap-3 overflow-hidden pointer-events-none"> <div
className={cn(
"flex items-center overflow-hidden pointer-events-none",
compact ? "gap-2" : "gap-3",
)}
>
<div className="shrink-0"> <div className="shrink-0">
{getFileIcon(file, viewMode)} {getFileIcon(file, viewMode, compact)}
</div> </div>
{editingFile?.path === file.path ? ( {editingFile?.path === file.path ? (
<input <input
@@ -12,6 +12,7 @@ import {
List, List,
Plus, Plus,
RefreshCw, RefreshCw,
Rows3,
Search, Search,
Trash2, Trash2,
Upload, Upload,
@@ -33,6 +34,7 @@ import type { FileItem } from "@/types/index";
type SortBy = "name" | "modified" | "size"; type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc"; type SortOrder = "asc" | "desc";
type ViewMode = "grid" | "list"; type ViewMode = "grid" | "list";
type Density = "comfortable" | "compact";
type FileManagerToolbarProps = { type FileManagerToolbarProps = {
t: (key: string) => string; t: (key: string) => string;
@@ -46,6 +48,8 @@ type FileManagerToolbarProps = {
setSearchQuery: (query: string) => void; setSearchQuery: (query: string) => void;
viewMode: ViewMode; viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void; setViewMode: (mode: ViewMode) => void;
density: Density;
setDensity: (density: Density) => void;
sortBy: SortBy; sortBy: SortBy;
setSortBy: (sortBy: SortBy) => void; setSortBy: (sortBy: SortBy) => void;
sortOrder: SortOrder; sortOrder: SortOrder;
@@ -197,6 +201,8 @@ export function FileManagerToolbar({
setSearchQuery, setSearchQuery,
viewMode, viewMode,
setViewMode, setViewMode,
density,
setDensity,
sortBy, sortBy,
setSortBy, setSortBy,
sortOrder, sortOrder,
@@ -321,6 +327,21 @@ export function FileManagerToolbar({
> >
<List className="size-4" /> <List className="size-4" />
</Button> </Button>
<Button
variant={density === "compact" ? "secondary" : "ghost"}
size="icon"
onClick={() =>
setDensity(density === "compact" ? "comfortable" : "compact")
}
className={`size-8 rounded-none border-y-0 border-r-0 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> </div>
<label <label
+2
View File
@@ -2283,6 +2283,8 @@
"used": "Used", "used": "Used",
"of": "of", "of": "of",
"toggleSidebar": "Toggle Sidebar", "toggleSidebar": "Toggle Sidebar",
"compactLayout": "Use compact layout",
"comfortableLayout": "Use comfortable layout",
"cannotLoadPdf": "Cannot load PDF", "cannotLoadPdf": "Cannot load PDF",
"pdfLoadError": "There was an error loading this PDF file.", "pdfLoadError": "There was an error loading this PDF file.",
"loadingPdf": "Loading PDF...", "loadingPdf": "Loading PDF...",