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