feat: enhance FileBrowser component with action menu and improved folder/file selection

This commit is contained in:
Jaime Idolpx 2026-06-07 19:20:01 -04:00
parent 6af14371e8
commit e060c73d48

View File

@ -1,7 +1,19 @@
import { useEffect, useRef, useState } from 'react';
import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2, FileWarning } from 'lucide-react';
import {
basename,
Folder,
File,
ChevronRight,
Home,
RefreshCw,
Upload,
FolderPlus,
ArrowLeft,
Loader2,
MoreVertical,
Check,
Trash2,
} from 'lucide-react';
import {
createFolder,
deletePath,
humanFileSize,
@ -14,6 +26,13 @@ import {
type EntryInfo,
} from '../webdav';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from './ui/dialog';
interface FileBrowserProps {
currentPath: string;
@ -21,27 +40,19 @@ interface FileBrowserProps {
onClose: () => void;
}
type Mode = 'pick-file' | 'pick-folder' | 'browse';
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
// If `currentPath` is itself a file (e.g. the IEC device is currently
// pointing at /sd/foo.d64), we want to open the file browser on the
// parent directory (/sd/) and remember that the user came from `foo.d64`.
// We resolve this asynchronously on mount; until then we show a loader
// so we never try to list a file as if it were a folder.
// Resolve the initial path: if `currentPath` is itself a file, jump
// to its parent so we never try to list a file as if it were a folder.
const [path, setPath] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [initError, setInitError] = useState<string | null>(null);
const [entries, setEntries] = useState<EntryInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>('browse');
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Resolve the initial path: if `currentPath` is a file, jump to its
// parent and remember the file so it can be highlighted.
// Resolve the initial path on mount.
useEffect(() => {
const initial = normalizePath(currentPath || '/');
if (initial === '/') {
@ -53,25 +64,16 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
.then((info) => {
if (cancelled) return;
if (info && info.type === 'file') {
const parent = splitPath(info.path).parent;
setPath(parent);
setSelectedFile(info.path);
} else {
// Either a directory, or the path doesn't exist (in which case
// the safest fallback is to open the parent so the user can
// navigate from there).
if (info && info.type === 'folder') {
setPath(splitPath(info.path).parent);
} else if (info && info.type === 'folder') {
setPath(info.path);
} else {
setPath(splitPath(initial).parent);
setInitError(`"${initial}" not found; opened the parent folder.`);
}
}
})
.catch((e) => {
.catch(() => {
if (cancelled) return;
setPath(splitPath(initial).parent);
setInitError(`Failed to resolve "${initial}": ${e?.message || e}`);
});
return () => {
cancelled = true;
@ -101,27 +103,29 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
}, [path]);
const navigateUp = () => {
if (path === '/') return;
if (path === '/' || path === null) return;
setPath(splitPath(path).parent);
};
const navigateToFolder = (folderName: string) => {
if (path === null) return;
setPath(joinPath(path, folderName));
};
const selectFile = (entry: EntryInfo) => {
const selectEntry = (entry: EntryInfo) => {
if (entry.type !== 'file') return;
onSelect(entry.path);
onClose();
};
const selectCurrentFolder = () => {
if (path === null) return;
onSelect(path);
onClose();
};
const refresh = () => {
void load(path);
if (path !== null) void load(path);
};
const handleCreateFolder = async () => {
@ -145,10 +149,11 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
: `Delete file "${entry.name}"?`,
);
if (!ok) return;
setActionEntry(null);
try {
await deletePath(entry.path);
toast.success(`Deleted ${entry.name}`);
void load(path);
if (path !== null) void load(path);
} catch (e: any) {
toast.error(`Failed to delete: ${e?.message || e}`);
}
@ -194,32 +199,6 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={() => {
setMode((m) => (m === 'pick-file' ? 'browse' : 'pick-file'));
}}
className={`px-2 py-1 text-xs rounded border ${
mode === 'pick-file'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-neutral-700 border-neutral-300'
}`}
title="When on, tapping a file picks it"
>
Pick file
</button>
<button
onClick={() => {
setMode((m) => (m === 'pick-folder' ? 'browse' : 'pick-folder'));
}}
className={`px-2 py-1 text-xs rounded border ${
mode === 'pick-folder'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-neutral-700 border-neutral-300'
}`}
title="When on, the bottom action picks the current folder"
>
Pick folder
</button>
<button
onClick={() => setShowNewFolder((s) => !s)}
className="p-2 rounded hover:bg-neutral-100"
@ -307,29 +286,6 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
</div>
)}
{path !== null && initError && (
<div className="m-3 p-3 rounded border border-amber-200 bg-amber-50 text-amber-900 text-sm flex items-start gap-2">
<FileWarning className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{initError}</span>
</div>
)}
{path !== null && selectedFile && (
<div className="m-3 p-3 rounded border border-blue-200 bg-blue-50 text-blue-900 text-sm flex items-center gap-2">
<FileWarning className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 truncate">
Opened the parent of <code className="px-1 rounded bg-blue-100">{basename(selectedFile)}</code>
</span>
<button
onClick={() => selectFile({ name: basename(selectedFile), path: selectedFile, type: 'file', size: 0, lastModified: null, contentType: null })}
className="text-blue-700 underline whitespace-nowrap"
title="Select this file"
>
Use this file
</button>
</div>
)}
{loading && (
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
<Loader2 className="w-6 h-6 animate-spin" />
@ -342,7 +298,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
<div className="text-red-600 mb-2">Failed to load directory</div>
<div className="text-neutral-500 text-xs break-all">{error}</div>
<button
onClick={() => void load(path)}
onClick={() => path !== null && void load(path)}
className="mt-3 inline-flex items-center gap-1 text-blue-600 text-sm"
>
<RefreshCw className="w-3 h-3" /> Retry
@ -350,7 +306,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
</div>
)}
{!loading && !error && (
{!loading && !error && path !== null && (
<>
{path !== '/' && (
<button
@ -364,21 +320,17 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
</button>
)}
{entries.map((entry) => {
const isSelected = selectedFile !== null && entry.path === selectedFile;
return (
{entries.map((entry) => (
<div
key={entry.path}
className={`w-full px-4 py-3 flex items-center gap-3 border-b border-neutral-100 ${
isSelected ? 'bg-blue-50' : 'hover:bg-neutral-50'
}`}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100"
>
<button
onClick={() => {
if (entry.type === 'folder') {
navigateToFolder(entry.name);
} else if (mode === 'pick-file') {
selectFile(entry);
} else {
selectEntry(entry);
}
}}
className="flex-1 flex items-center gap-3 text-left min-w-0"
@ -406,16 +358,18 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
)}
</button>
<button
onClick={() => void handleDelete(entry)}
className="p-2 rounded hover:bg-red-50 text-red-600"
aria-label={`Delete ${entry.name}`}
title="Delete"
onClick={(e) => {
e.stopPropagation();
setActionEntry(entry);
}}
className="p-2 rounded hover:bg-neutral-100"
aria-label={`Actions for ${entry.name}`}
title="Actions"
>
<Trash2 className="w-4 h-4" />
<MoreVertical className="w-4 h-4" />
</button>
</div>
);
})}
))}
{entries.length === 0 && (
<div className="p-8 text-center text-neutral-500 text-sm">
@ -427,26 +381,65 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
</div>
<div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
{mode === 'pick-folder' ? (
<button
onClick={selectCurrentFolder}
disabled={path === null}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50"
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50 inline-flex items-center justify-center gap-2"
title="Use the current folder as your selection"
>
<Check className="w-4 h-4" />
Select Folder: {path ?? '/'}
</button>
) : mode === 'pick-file' ? (
<div className="text-xs text-neutral-500 text-center">
Tap a file above to select it. ({entries.filter((e) => e.type === 'file').length} files)
</div>
) : (
<div className="text-xs text-neutral-500 text-center">
{entries.filter((e) => e.type === 'folder').length} folders ·{' '}
{entries.filter((e) => e.type === 'file').length} files
</div>
{/* Action menu modal */}
<Dialog
open={actionEntry !== null}
onOpenChange={(open) => !open && setActionEntry(null)}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="truncate">{actionEntry?.name}</DialogTitle>
<DialogDescription>
{actionEntry?.type === 'folder' ? 'Folder' : 'File'}
{actionEntry?.type === 'file' && actionEntry.size > 0
? ` · ${humanFileSize(actionEntry.size)}`
: ''}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{actionEntry?.type === 'file' && (
<button
onClick={() => actionEntry && selectEntry(actionEntry)}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
>
<Check className="w-4 h-4 text-blue-600" />
<span>Select this file</span>
</button>
)}
{actionEntry?.type === 'folder' && (
<button
onClick={() => {
if (actionEntry) navigateToFolder(actionEntry.name);
setActionEntry(null);
}}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
>
<Folder className="w-4 h-4 text-blue-600" />
<span>Open this folder</span>
</button>
)}
<button
onClick={() => actionEntry && void handleDelete(actionEntry)}
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}