From 6af14371e81d049d0fa419b02d96d68b50db4b08 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Sun, 7 Jun 2026 19:09:07 -0400 Subject: [PATCH] feat: enhance FileBrowser component to handle file and folder resolution with improved error handling --- src/app/components/FileBrowser.tsx | 104 ++++++++++++++++++++++++++--- src/app/webdav.ts | 40 ++++++++++- webdav3.py | 2 +- 3 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/app/components/FileBrowser.tsx b/src/app/components/FileBrowser.tsx index 7047b70..8b2c34e 100644 --- a/src/app/components/FileBrowser.tsx +++ b/src/app/components/FileBrowser.tsx @@ -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(null); + const [selectedFile, setSelectedFile] = useState(null); + const [initError, setInitError] = useState(null); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -31,6 +40,45 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow const [newFolderName, setNewFolderName] = useState(''); const fileInputRef = useRef(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 (
@@ -250,6 +300,36 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
+ {path === null && ( +
+ + Resolving location… +
+ )} + + {path !== null && initError && ( +
+ + {initError} +
+ )} + + {path !== null && selectedFile && ( +
+ + + Opened the parent of {basename(selectedFile)} + + +
+ )} + {loading && (
@@ -284,10 +364,14 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow )} - {entries.map((entry) => ( + {entries.map((entry) => { + const isSelected = selectedFile !== null && entry.path === selectedFile; + return (
- ))} + ); + })} {entries.length === 0 && (
@@ -345,9 +430,10 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow {mode === 'pick-folder' ? ( ) : mode === 'pick-file' ? (
diff --git a/src/app/webdav.ts b/src/app/webdav.ts index 448b2dd..4ee5b1e 100644 --- a/src/app/webdav.ts +++ b/src/app/webdav.ts @@ -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 { 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 { + 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 { const manager = getWebDAVClient(); const base = manager.client.baseUrl; diff --git a/webdav3.py b/webdav3.py index c65bc92..3839087 100644 --- a/webdav3.py +++ b/webdav3.py @@ -661,7 +661,7 @@ class DAVRequestHandler(BaseHTTPRequestHandler): w.write('\nHTTP/1.1 200 OK\n\n\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('')