diff --git a/package.json b/package.json index 09b1c65..63c3061 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ }, "devDependencies": { "@tailwindcss/vite": "4.1.12", + "@types/react-dom": "^19.2.3", "@types/three": "^0.160.0", "@vitejs/plugin-react": "4.7.0", "tailwindcss": "4.1.12", diff --git a/src/app/components/MediaManager.tsx b/src/app/components/MediaManager.tsx index 9457fbe..2ab05e8 100644 --- a/src/app/components/MediaManager.tsx +++ b/src/app/components/MediaManager.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; import { AlignLeft, ArrowLeft, @@ -459,14 +460,16 @@ export default function MediaManager({ initialPath, rootPath, title, config, set // ── Directory loading ──────────────────────────────────────────────────── const [folderConfig, setFolderConfig] = useState | null>(null); + const [loadedCount, setLoadedCount] = useState(null); const load = useCallback(async (p: string) => { setLoading(true); setError(null); setSelected(new Set()); setFolderConfig(null); + setLoadedCount(null); try { - const entries = await listDirectory(p); + const entries = await listDirectory(p, false, bytes => flushSync(() => setLoadedCount(bytes))); setEntries(entries); try { const blob = await getFileContents(joinPath(p, '.config')); @@ -1179,7 +1182,8 @@ export default function MediaManager({ initialPath, rootPath, title, config, set
{loading && (
- Loading… + + {loadedCount === null ? 'Loading…' : humanFileSize(loadedCount)}
)} diff --git a/src/app/webdav.ts b/src/app/webdav.ts index f4fc513..6a95aca 100644 --- a/src/app/webdav.ts +++ b/src/app/webdav.ts @@ -171,20 +171,44 @@ function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo { * return all descendants flattened — requires the server to support it * (webdav3.py does after the depth-infinity fix). */ -export async function listDirectory(path: string, recursive = false): Promise { +export async function listDirectory( + path: string, + recursive = false, + onProgress?: (bytes: number) => void, +): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; const collectionUrl = pathToUrl(normalizePath(path), base); const selfPath = normalizePath(path); - // Parse the PROPFIND response directly rather than going through - // parsePropfindListing, which keys entries by display name and would - // silently drop any entries that share the same filename. Parsing - // the XML ourselves also avoids mutating the shared WebDAVManager - // navigation state (navToken, this.files). // eslint-disable-next-line @typescript-eslint/no-explicit-any const depth: any = recursive ? 'infinity' : 1; - const doc = await manager.client.propfind(collectionUrl, PROPFIND_LIST_BODY, depth); + + let doc: Document; + if (onProgress) { + const response = await fetch(collectionUrl, { + method: 'PROPFIND', + headers: { 'Depth': String(depth), 'Content-Type': 'text/xml; charset=utf-8' }, + body: PROPFIND_LIST_BODY, + }); + if (!response.body) throw new Error('No response body'); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.byteLength; + onProgress(received); + } + const combined = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } + doc = new DOMParser().parseFromString(new TextDecoder().decode(combined), 'text/xml'); + } else { + doc = await manager.client.propfind(collectionUrl, PROPFIND_LIST_BODY, depth); + } const entries: EntryInfo[] = []; for (const node of Array.from(doc.querySelectorAll('response'))) {