feat: enhance FileBrowser component to handle file and folder resolution with improved error handling
This commit is contained in:
parent
e81974e9ff
commit
6af14371e8
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2, FileWarning } from 'lucide-react';
|
||||
import {
|
||||
basename,
|
||||
createFolder,
|
||||
deletePath,
|
||||
humanFileSize,
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
normalizePath,
|
||||
putFileContents,
|
||||
splitPath,
|
||||
stat,
|
||||
type EntryInfo,
|
||||
} from '../webdav';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -22,7 +24,14 @@ interface FileBrowserProps {
|
|||
type Mode = 'pick-file' | 'pick-folder' | 'browse';
|
||||
|
||||
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) {
|
||||
const [path, setPath] = useState(() => normalizePath(currentPath || '/'));
|
||||
// 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.
|
||||
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);
|
||||
|
|
@ -31,6 +40,45 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
const [newFolderName, setNewFolderName] = useState('');
|
||||
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.
|
||||
useEffect(() => {
|
||||
const initial = normalizePath(currentPath || '/');
|
||||
if (initial === '/') {
|
||||
setPath('/');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
stat(initial)
|
||||
.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(info.path);
|
||||
} else {
|
||||
setPath(splitPath(initial).parent);
|
||||
setInitError(`"${initial}" not found; opened the parent folder.`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
setPath(splitPath(initial).parent);
|
||||
setInitError(`Failed to resolve "${initial}": ${e?.message || e}`);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const load = async (p: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -47,6 +95,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (path === null) return;
|
||||
void load(path);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path]);
|
||||
|
|
@ -77,7 +126,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
|
||||
const handleCreateFolder = async () => {
|
||||
const name = newFolderName.trim();
|
||||
if (!name) return;
|
||||
if (!name || path === null) return;
|
||||
try {
|
||||
await createFolder(joinPath(path, name), true);
|
||||
setNewFolderName('');
|
||||
|
|
@ -106,6 +155,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (path === null) return;
|
||||
const target = joinPath(path, file.name);
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
|
|
@ -124,7 +174,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
e.target.value = '';
|
||||
};
|
||||
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
const pathParts = (path ?? '').split('/').filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end" onClick={onClose}>
|
||||
|
|
@ -250,6 +300,36 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
</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>
|
||||
)}
|
||||
|
||||
{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" />
|
||||
|
|
@ -284,10 +364,14 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
</button>
|
||||
)}
|
||||
|
||||
{entries.map((entry) => (
|
||||
{entries.map((entry) => {
|
||||
const isSelected = selectedFile !== null && entry.path === selectedFile;
|
||||
return (
|
||||
<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"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -330,7 +414,8 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{entries.length === 0 && (
|
||||
<div className="p-8 text-center text-neutral-500 text-sm">
|
||||
|
|
@ -345,9 +430,10 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
|||
{mode === 'pick-folder' ? (
|
||||
<button
|
||||
onClick={selectCurrentFolder}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg"
|
||||
disabled={path === null}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Select Folder: {path}
|
||||
Select Folder: {path ?? '/'}
|
||||
</button>
|
||||
) : mode === 'pick-file' ? (
|
||||
<div className="text-xs text-neutral-500 text-center">
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@
|
|||
* normalizePath / splitPath / joinPath / basename – path helpers
|
||||
*/
|
||||
|
||||
import { WebDAVManager, type WebDAVEntry } from './vendor/webdav-component/esm/index.js';
|
||||
import {
|
||||
WebDAVManager,
|
||||
PROPFIND_LIST_BODY,
|
||||
type WebDAVEntry,
|
||||
} from './vendor/webdav-component/esm/index.js';
|
||||
|
||||
export type { WebDAVEntry };
|
||||
|
||||
|
|
@ -195,6 +199,40 @@ export async function fileExists(path: string): Promise<boolean> {
|
|||
return manager.client.exists(pathToUrl(normalizePath(path), base));
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a path via a depth-0 PROPFIND to determine whether it is a file
|
||||
* or a folder, and return its parsed `WebDAVEntry`. Returns `null` if the
|
||||
* path does not exist (or the server returns 404).
|
||||
*/
|
||||
export async function stat(path: string): Promise<EntryInfo | null> {
|
||||
const manager = getWebDAVClient();
|
||||
const base = manager.client.baseUrl;
|
||||
const url = pathToUrl(normalizePath(path), base);
|
||||
try {
|
||||
const doc = await manager.client.propfind(url, PROPFIND_LIST_BODY, 0);
|
||||
const resp = doc.querySelector('response');
|
||||
if (!resp) return null;
|
||||
const isDir = !!doc.querySelector('resourcetype collection');
|
||||
// We re-use the same path-resolution logic as `toEntryInfo` to keep
|
||||
// naming and path handling consistent.
|
||||
const href = doc.querySelector('href')?.textContent ?? url;
|
||||
const fakeEntry = {
|
||||
uri: href,
|
||||
url: href,
|
||||
path: href,
|
||||
name: doc.querySelector('displayname')?.textContent ?? '',
|
||||
isDir,
|
||||
size: null,
|
||||
mime: null,
|
||||
modified: null,
|
||||
permissions: null,
|
||||
} as unknown as WebDAVEntry;
|
||||
return toEntryInfo(fakeEntry, base);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFolder(path: string, _recursive = true): Promise<void> {
|
||||
const manager = getWebDAVClient();
|
||||
const base = manager.client.baseUrl;
|
||||
|
|
|
|||
|
|
@ -661,7 +661,7 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
|
|||
w.write('</D:prop>\n<D:status>HTTP/1.1 200 OK</D:status>\n</D:propstat>\n</D:response>\n')
|
||||
|
||||
write_props_member(w, elem)
|
||||
if depth == '1':
|
||||
if depth == '1' and elem.type == Member.M_COLLECTION:
|
||||
for m in elem.getMembers():
|
||||
write_props_member(w,m)
|
||||
w.write('</D:multistatus>')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user