feat(FileManager): persist filter, sort key, and sort order in local storage
456 lines
15 KiB
TypeScript
456 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Folder,
|
|
File,
|
|
FileText,
|
|
HardDrive,
|
|
Image as ImageIcon,
|
|
ChevronRight,
|
|
Home,
|
|
RefreshCw,
|
|
Upload,
|
|
FolderPlus,
|
|
ArrowLeft,
|
|
Loader2,
|
|
MoreVertical,
|
|
Check,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import {
|
|
createFolder,
|
|
deletePath,
|
|
humanFileSize,
|
|
joinPath,
|
|
listDirectory,
|
|
normalizePath,
|
|
putFileContents,
|
|
splitPath,
|
|
stat,
|
|
type EntryInfo,
|
|
} from '../webdav';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from './ui/dialog';
|
|
|
|
const TEXT_EXTS = new Set(['txt','cfg','ini','bas','asm','seq','rel','prg','log','csv','s','lst','md','markdown','json','xml','svg','html','htm']);
|
|
const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','bmp','webp']);
|
|
const DISK_EXTS = new Set(['d64','d71','d81','d82','g64','g71','t64','tap','crt','nib']);
|
|
|
|
function EntryIcon({ entry }: { entry: EntryInfo }) {
|
|
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
|
if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
|
if (DISK_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
|
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
|
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
|
}
|
|
|
|
interface FileBrowserProps {
|
|
currentPath: string;
|
|
onSelect: (path: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
|
|
// 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 [entries, setEntries] = useState<EntryInfo[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
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 on mount.
|
|
useEffect(() => {
|
|
const initial = normalizePath(currentPath || '/');
|
|
if (initial === '/') {
|
|
setPath('/');
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
stat(initial)
|
|
.then((info) => {
|
|
if (cancelled) return;
|
|
if (info && info.type === 'file') {
|
|
setPath(splitPath(info.path).parent);
|
|
} else if (info && info.type === 'folder') {
|
|
setPath(info.path);
|
|
} else {
|
|
setPath(splitPath(initial).parent);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setPath(splitPath(initial).parent);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const load = async (p: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const items = await listDirectory(p);
|
|
setEntries(items);
|
|
} catch (e: any) {
|
|
const msg = (e && e.message) || 'Failed to load directory';
|
|
setError(msg);
|
|
setEntries([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (path === null) return;
|
|
void load(path);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [path]);
|
|
|
|
const navigateUp = () => {
|
|
if (path === '/' || path === null) return;
|
|
setPath(splitPath(path).parent);
|
|
};
|
|
|
|
const navigateToFolder = (folderName: string) => {
|
|
if (path === null) return;
|
|
setPath(joinPath(path, folderName));
|
|
};
|
|
|
|
const selectEntry = (entry: EntryInfo) => {
|
|
if (entry.type !== 'file') return;
|
|
onSelect(entry.path);
|
|
onClose();
|
|
};
|
|
|
|
const selectCurrentFolder = () => {
|
|
if (path === null) return;
|
|
onSelect(path);
|
|
onClose();
|
|
};
|
|
|
|
const refresh = () => {
|
|
if (path !== null) void load(path);
|
|
};
|
|
|
|
const handleCreateFolder = async () => {
|
|
const name = newFolderName.trim();
|
|
if (!name || path === null) return;
|
|
try {
|
|
await createFolder(joinPath(path, name), true);
|
|
setNewFolderName('');
|
|
setShowNewFolder(false);
|
|
toast.success(`Created folder "${name}"`);
|
|
void load(path);
|
|
} catch (e: any) {
|
|
toast.error(`Failed to create folder: ${e?.message || e}`);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (entry: EntryInfo) => {
|
|
const ok = window.confirm(
|
|
entry.type === 'folder'
|
|
? `Delete folder "${entry.name}" and all its contents?`
|
|
: `Delete file "${entry.name}"?`,
|
|
);
|
|
if (!ok) return;
|
|
setActionEntry(null);
|
|
try {
|
|
await deletePath(entry.path);
|
|
toast.success(`Deleted ${entry.name}`);
|
|
if (path !== null) void load(path);
|
|
} catch (e: any) {
|
|
toast.error(`Failed to delete: ${e?.message || e}`);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async (file: File) => {
|
|
if (path === null) return;
|
|
const target = joinPath(path, file.name);
|
|
try {
|
|
const buf = await file.arrayBuffer();
|
|
await putFileContents(target, buf);
|
|
toast.success(`Uploaded ${file.name}`);
|
|
void load(path);
|
|
} catch (e: any) {
|
|
toast.error(`Failed to upload ${file.name}: ${e?.message || e}`);
|
|
}
|
|
};
|
|
|
|
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files) return;
|
|
Array.from(files).forEach((f) => void handleUpload(f));
|
|
e.target.value = '';
|
|
};
|
|
|
|
const pathParts = (path ?? '').split('/').filter(Boolean);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end" onClick={onClose}>
|
|
<div
|
|
className="bg-white w-full max-h-[80vh] rounded-t-xl flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="sticky top-0 bg-white border-b border-neutral-200 p-4">
|
|
<div className="flex items-center justify-between mb-3 gap-2">
|
|
<h3 className="font-medium">Browse Files</h3>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => void refresh()}
|
|
className="p-2 rounded hover:bg-neutral-100"
|
|
aria-label="Refresh"
|
|
title="Refresh"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setShowNewFolder((s) => !s)}
|
|
className="p-2 rounded hover:bg-neutral-100"
|
|
aria-label="New folder"
|
|
title="New folder"
|
|
>
|
|
<FolderPlus className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="p-2 rounded hover:bg-neutral-100"
|
|
aria-label="Upload"
|
|
title="Upload file"
|
|
>
|
|
<Upload className="w-4 h-4" />
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={onPickFiles}
|
|
/>
|
|
<button onClick={onClose} className="text-sm text-blue-600 ml-1">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showNewFolder && (
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<input
|
|
value={newFolderName}
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void handleCreateFolder();
|
|
if (e.key === 'Escape') {
|
|
setShowNewFolder(false);
|
|
setNewFolderName('');
|
|
}
|
|
}}
|
|
placeholder="New folder name"
|
|
className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
onClick={() => void handleCreateFolder()}
|
|
className="px-2 py-1 text-sm bg-blue-600 text-white rounded"
|
|
>
|
|
Create
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 text-sm text-neutral-600 overflow-x-auto">
|
|
<button
|
|
onClick={() => setPath('/')}
|
|
className="flex-shrink-0 p-1 rounded hover:bg-neutral-100"
|
|
title="Root"
|
|
>
|
|
<Home className="w-4 h-4" />
|
|
</button>
|
|
{pathParts.map((part, index) => (
|
|
<div key={index} className="flex items-center gap-2 flex-shrink-0">
|
|
<ChevronRight className="w-3 h-3" />
|
|
<button
|
|
onClick={() => {
|
|
const newPath = '/' + pathParts.slice(0, index + 1).join('/');
|
|
setPath(newPath);
|
|
}}
|
|
className="hover:text-blue-600"
|
|
>
|
|
{part}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto flex-1">
|
|
{path === null && (
|
|
<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" />
|
|
Resolving location…
|
|
</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" />
|
|
Loading…
|
|
</div>
|
|
)}
|
|
|
|
{!loading && error && (
|
|
<div className="p-4 text-sm">
|
|
<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={() => 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
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && path !== null && (
|
|
<>
|
|
{path !== '/' && (
|
|
<button
|
|
onClick={navigateUp}
|
|
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100 text-left"
|
|
>
|
|
<div className="text-neutral-400">
|
|
<ArrowLeft className="w-5 h-5" />
|
|
</div>
|
|
<span className="text-neutral-600">..</span>
|
|
</button>
|
|
)}
|
|
|
|
{entries.map((entry) => (
|
|
<div
|
|
key={entry.path}
|
|
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 {
|
|
selectEntry(entry);
|
|
}
|
|
}}
|
|
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
|
>
|
|
<EntryIcon entry={entry} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-neutral-900 truncate">{entry.name}</div>
|
|
{entry.type === 'file' && (
|
|
<div className="text-xs text-neutral-500 truncate">
|
|
{humanFileSize(entry.size)}
|
|
{entry.lastModified
|
|
? ` · ${entry.lastModified.toLocaleDateString()}`
|
|
: ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{entry.type === 'folder' && (
|
|
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setActionEntry(entry);
|
|
}}
|
|
className="p-2 rounded hover:bg-neutral-100"
|
|
aria-label={`Actions for ${entry.name}`}
|
|
title="Actions"
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{entries.length === 0 && (
|
|
<div className="p-8 text-center text-neutral-500 text-sm">
|
|
Empty folder
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="sticky bottom-0 bg-white border-t border-neutral-200 p-4">
|
|
<button
|
|
onClick={selectCurrentFolder}
|
|
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"
|
|
title="Use the current folder as your selection"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
Select Folder: {path ?? '/'}
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|