Compare commits
9 Commits
afd7156a36
...
bbd38746df
| Author | SHA1 | Date | |
|---|---|---|---|
| bbd38746df | |||
| ecc54c1fc9 | |||
| ba8de6634e | |||
| a1b2fa5ea7 | |||
| 65cc6bb602 | |||
| f66f5f6d77 | |||
| 4b901c96b5 | |||
| ec61853957 | |||
| ef48b167b0 |
|
|
@ -62,6 +62,7 @@
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "3.6.0",
|
"date-fns": "3.6.0",
|
||||||
"embla-carousel-react": "8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "0.487.0",
|
"lucide-react": "0.487.0",
|
||||||
"motion": "12.23.24",
|
"motion": "12.23.24",
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,14 @@ export default function DeviceDetailOverlay({
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex-shrink-0 border-b border-neutral-200/70 relative">
|
<div className="flex-shrink-0 border-b border-neutral-200/70 relative">
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 my-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors flex-shrink-0"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className={`flex flex-col items-center gap-0.5 flex-shrink-0 ${
|
<div className={`flex flex-col items-center gap-0.5 flex-shrink-0 ${
|
||||||
activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400'
|
activeDevice.physical ? 'text-green-600' : activeDevice.enabled ? 'text-blue-600' : 'text-neutral-400'
|
||||||
}`}>
|
}`}>
|
||||||
|
|
@ -413,19 +421,11 @@ export default function DeviceDetailOverlay({
|
||||||
<p className="text-xs text-neutral-400 capitalize">{activeDevice.type}</p>
|
<p className="text-xs text-neutral-400 capitalize">{activeDevice.type}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{devices.length > 1 && (
|
{devices.length > 1 && (
|
||||||
<span className="text-xs text-neutral-400 tabular-nums px-1">
|
<span className="text-xs text-neutral-400 tabular-nums px-1 flex-shrink-0">
|
||||||
{activeIndex + 1} / {devices.length}
|
{activeIndex + 1} / {devices.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-500 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X, DatabaseZap } from 'lucide-react';
|
import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X, DatabaseZap, AlertCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { humanFileSize, splitPath } from '../webdav';
|
import { humanFileSize, splitPath, stat, fileExists } from '../webdav';
|
||||||
import type { EntryInfo } from '../webdav';
|
import type { EntryInfo } from '../webdav';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import { MediaEntry } from './MediaEntry';
|
import { MediaEntry } from './MediaEntry';
|
||||||
|
|
@ -13,8 +13,10 @@ import {
|
||||||
isLocateDbLoaded,
|
isLocateDbLoaded,
|
||||||
resetLocateDb,
|
resetLocateDb,
|
||||||
buildLocateDb,
|
buildLocateDb,
|
||||||
|
subscribeLoadProgress,
|
||||||
type LocateEntry,
|
type LocateEntry,
|
||||||
type ScanPhase,
|
type ScanPhase,
|
||||||
|
type ScanCounts,
|
||||||
} from '../locate-db';
|
} from '../locate-db';
|
||||||
|
|
||||||
interface SearchLocalProps {
|
interface SearchLocalProps {
|
||||||
|
|
@ -116,9 +118,19 @@ function TypeBadge({ type, isDir }: { type: string; isDir: boolean }) {
|
||||||
return <span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 font-mono">{type}</span>;
|
return <span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 font-mono">{type}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
if (m > 0) return `${m}m ${s}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
function scanPhaseLabel(phase: ScanPhase, value: number): string {
|
function scanPhaseLabel(phase: ScanPhase, value: number): string {
|
||||||
if (phase === 'scanning') return `Scanning… ${humanFileSize(value)}`;
|
if (phase === 'scanning') return `Scanning… ${humanFileSize(value)}`;
|
||||||
if (phase === 'building') return `Building… ${value.toLocaleString()} entries`;
|
if (phase === 'building') return `Building… ${value.toLocaleString()} entries`;
|
||||||
|
if (phase === 'compressing') return value ? `Compressed to ${humanFileSize(value)}` : 'Compressing…';
|
||||||
return `Saving… ${humanFileSize(value)}`;
|
return `Saving… ${humanFileSize(value)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,21 +178,27 @@ function FilterChips({
|
||||||
|
|
||||||
export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }: SearchLocalProps) {
|
export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }: SearchLocalProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scanAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const scanStartRef = useRef<number>(0);
|
||||||
const [query, setQuery] = useState(() => _store.query);
|
const [query, setQuery] = useState(() => _store.query);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
const [scanPhase, setScanPhase] = useState<ScanPhase | null>(null);
|
const [scanPhase, setScanPhase] = useState<ScanPhase | null>(null);
|
||||||
const [scanValue, setScanValue] = useState(0);
|
const [scanValue, setScanValue] = useState(0);
|
||||||
|
const [scanPath, setScanPath] = useState<string | null>(null);
|
||||||
|
const [scanCounts, setScanCounts] = useState<ScanCounts | null>(null);
|
||||||
|
const [scanElapsed, setScanElapsed] = useState(0);
|
||||||
const [results, setResults] = useState<SearchResult[]>(() => _store.results);
|
const [results, setResults] = useState<SearchResult[]>(() => _store.results);
|
||||||
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
|
const [hasSearched, setHasSearched] = useState(() => _store.hasSearched);
|
||||||
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
||||||
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
||||||
const [showScanConfirm, setShowScanConfirm] = useState(false);
|
const [showScanConfirm, setShowScanConfirm] = useState(false);
|
||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
// The locate-database load progress is rendered by the SearchPane (above
|
|
||||||
// this panel). The pane pre-fetches the database on mount, so by the time
|
|
||||||
// the user clicks Search the database is almost always ready.
|
|
||||||
const [dbPhase, setDbPhase] = useState<'idle' | 'ready'>('ready');
|
const [dbPhase, setDbPhase] = useState<'idle' | 'ready'>('ready');
|
||||||
|
const [checking, setChecking] = useState(() => !isLocateDbLoaded());
|
||||||
|
const [sdMissing, setSdMissing] = useState(false);
|
||||||
|
const [locateMissing, setLocateMissing] = useState(false);
|
||||||
|
const [loadState, setLoadState] = useState<{ kind: 'idle' | 'engine' | 'database' | 'ready'; received: number; total: number | null }>({ kind: 'idle', received: 0, total: null });
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterText, setFilterText] = useState(() => _store.filterText);
|
const [filterText, setFilterText] = useState(() => _store.filterText);
|
||||||
const [filterSystem, setFilterSystem] = useState<string | null>(() => _store.filterSystem);
|
const [filterSystem, setFilterSystem] = useState<string | null>(() => _store.filterSystem);
|
||||||
|
|
@ -190,9 +208,34 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
const [sortDir, setSortDir] = useState<SortDir>(() => _store.sortDir);
|
const [sortDir, setSortDir] = useState<SortDir>(() => _store.sortDir);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLocateDbLoaded()) setDbPhase('ready');
|
return subscribeLoadProgress(s => {
|
||||||
|
if (s.phase === 'ready' || s.phase === 'error') {
|
||||||
|
setLoadState({ kind: 'ready', received: 0, total: null });
|
||||||
|
setDbPhase('ready');
|
||||||
|
} else if (s.phase === 'idle') {
|
||||||
|
setLoadState({ kind: 'idle', received: 0, total: null });
|
||||||
|
} else {
|
||||||
|
setLoadState({ kind: s.phase, received: s.received, total: s.total });
|
||||||
|
}
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!checking) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const sdEntry = await stat('/sd').catch(() => null);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!sdEntry) { setSdMissing(true); setChecking(false); return; }
|
||||||
|
const hasLocate = await fileExists('/sd/.locate').catch(() => false);
|
||||||
|
if (cancelled) return;
|
||||||
|
setLocateMissing(!hasLocate);
|
||||||
|
setChecking(false);
|
||||||
|
if (hasLocate) openLocateDb().catch(() => {});
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_store.query = query;
|
_store.query = query;
|
||||||
_store.results = results;
|
_store.results = results;
|
||||||
|
|
@ -212,6 +255,10 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { scanAbortRef.current?.abort(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const mountedPaths = useMemo(() => {
|
const mountedPaths = useMemo(() => {
|
||||||
const paths = new Set<string>();
|
const paths = new Set<string>();
|
||||||
for (const d of Object.values(config?.devices?.iec ?? {})) {
|
for (const d of Object.values(config?.devices?.iec ?? {})) {
|
||||||
|
|
@ -265,23 +312,44 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScan = async () => {
|
const handleScan = async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
scanAbortRef.current = controller;
|
||||||
|
scanStartRef.current = Date.now();
|
||||||
setIsScanning(true);
|
setIsScanning(true);
|
||||||
setScanPhase('scanning');
|
setScanPhase('scanning');
|
||||||
setScanValue(0);
|
setScanValue(0);
|
||||||
|
setScanCounts(null);
|
||||||
|
setScanElapsed(0);
|
||||||
try {
|
try {
|
||||||
const { count, bytes } = await buildLocateDb((phase, value) =>
|
const { count, bytes } = await buildLocateDb(
|
||||||
flushSync(() => { setScanPhase(phase); setScanValue(value); })
|
(phase, value, path, counts) =>
|
||||||
|
flushSync(() => {
|
||||||
|
setScanPhase(phase);
|
||||||
|
setScanValue(value);
|
||||||
|
if (path !== undefined) setScanPath(path);
|
||||||
|
if (counts !== undefined) setScanCounts(counts);
|
||||||
|
setScanElapsed(Math.floor((Date.now() - scanStartRef.current) / 1000));
|
||||||
|
}),
|
||||||
|
controller.signal,
|
||||||
);
|
);
|
||||||
toast.success(`Indexed ${count.toLocaleString()} items · ${humanFileSize(bytes)}`);
|
toast.success(`Indexed ${count.toLocaleString()} items · ${humanFileSize(bytes)}`);
|
||||||
setDbPhase('ready');
|
setDbPhase('ready');
|
||||||
|
setLocateMissing(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Scan failed: ${e?.message ?? e}`);
|
if (e?.name === 'AbortError') toast.info('Scan stopped.');
|
||||||
|
else toast.error(`Scan failed: ${e?.message ?? e}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
scanAbortRef.current = null;
|
||||||
setIsScanning(false);
|
setIsScanning(false);
|
||||||
setScanPhase(null);
|
setScanPhase(null);
|
||||||
|
setScanPath(null);
|
||||||
|
setScanCounts(null);
|
||||||
|
setScanElapsed(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStopScan = () => scanAbortRef.current?.abort();
|
||||||
|
|
||||||
const handleRefreshDb = () => {
|
const handleRefreshDb = () => {
|
||||||
resetLocateDb();
|
resetLocateDb();
|
||||||
setDbPhase('idle');
|
setDbPhase('idle');
|
||||||
|
|
@ -350,7 +418,47 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search input */}
|
{/* Search input slot — swaps content based on device/db state */}
|
||||||
|
{sdMissing ? (
|
||||||
|
<div className="flex-shrink-0 px-4 py-4 border-b border-neutral-100">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-3 rounded-xl bg-red-50 border border-red-200">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-700">No SD card detected</p>
|
||||||
|
<p className="text-xs text-red-500 mt-0.5">Insert an SD card and reload.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : checking ? (
|
||||||
|
<div className="flex-shrink-0 px-4 py-3 border-b border-neutral-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 text-neutral-400 animate-spin flex-shrink-0" />
|
||||||
|
<span className="text-sm text-neutral-400">Checking device…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (loadState.kind === 'engine' || loadState.kind === 'database') ? (
|
||||||
|
<div className="flex-shrink-0 px-4 py-3 border-b border-neutral-100" aria-live="polite">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-neutral-600">
|
||||||
|
{loadState.kind === 'engine' ? 'Loading engine' : 'Loading database'}…
|
||||||
|
</span>
|
||||||
|
{loadState.received > 0 && (
|
||||||
|
<span className="text-xs text-neutral-400 ml-auto tabular-nums">
|
||||||
|
{loadState.total
|
||||||
|
? `${humanFileSize(loadState.received)} / ${humanFileSize(loadState.total)}`
|
||||||
|
: humanFileSize(loadState.received)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full bg-blue-500 rounded-full transition-all duration-150 ease-out ${loadState.total ? '' : 'animate-pulse w-1/3'}`}
|
||||||
|
style={loadState.total ? { width: `${Math.min(100, Math.round(loadState.received / loadState.total * 100))}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
|
<div className="flex-shrink-0 px-4 pt-3 pb-3 border-b border-neutral-100">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
|
|
@ -384,7 +492,12 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)} */}
|
)} */}
|
||||||
<button onClick={() => setShowScanConfirm(true)} disabled={busy} className="p-1.5 rounded-lg hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600 disabled:opacity-40 transition-colors flex-shrink-0" title="Scan /sd and rebuild database">
|
<button
|
||||||
|
onClick={() => setShowScanConfirm(true)}
|
||||||
|
disabled={busy}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors flex-shrink-0 disabled:opacity-40 ${locateMissing ? 'text-blue-600 bg-blue-50 ring-1 ring-blue-300 hover:bg-blue-100' : 'hover:bg-neutral-100 text-neutral-400 hover:text-neutral-600'}`}
|
||||||
|
title="Scan /sd and rebuild database"
|
||||||
|
>
|
||||||
<DatabaseZap className="w-4 h-4" />
|
<DatabaseZap className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{hasSearched && (
|
{hasSearched && (
|
||||||
|
|
@ -399,6 +512,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filter + sort bar — same style as MediaManager */}
|
{/* Filter + sort bar — same style as MediaManager */}
|
||||||
<div className={`overflow-hidden flex-shrink-0 transition-all duration-200 ease-in-out ${showFilter ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0'}`}>
|
<div className={`overflow-hidden flex-shrink-0 transition-all duration-200 ease-in-out ${showFilter ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||||
|
|
@ -442,10 +556,32 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
onScroll={e => { _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }}
|
||||||
>
|
>
|
||||||
{isScanning && scanPhase && (
|
{isScanning && scanPhase && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
<div className="flex flex-col items-center justify-center py-16 gap-3 px-6">
|
||||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||||
<p className="text-sm font-medium text-neutral-700">{scanPhaseLabel(scanPhase, scanValue)}</p>
|
<p className="text-sm font-medium text-neutral-700">{scanPhaseLabel(scanPhase, scanValue)}</p>
|
||||||
|
{scanCounts && (
|
||||||
|
<p className="text-xs text-neutral-500 tabular-nums">
|
||||||
|
{scanCounts.dirs.toLocaleString()} folders · {scanCounts.files.toLocaleString()} files
|
||||||
|
{scanElapsed > 0 && <span className="text-neutral-400"> · {formatDuration(scanElapsed)}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{scanPath ? (
|
||||||
|
<p
|
||||||
|
className="text-xs text-neutral-400 w-full text-center truncate"
|
||||||
|
style={{ direction: 'rtl', unicodeBidi: 'plaintext' }}
|
||||||
|
title={scanPath}
|
||||||
|
>
|
||||||
|
{scanPath}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<p className="text-xs text-neutral-400">Scanning /sd recursively…</p>
|
<p className="text-xs text-neutral-400">Scanning /sd recursively…</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleStopScan}
|
||||||
|
className="mt-2 px-4 py-1.5 text-xs font-medium rounded-lg border border-neutral-300 text-neutral-600 hover:bg-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -514,12 +650,14 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSearching && !hasSearched && (
|
{!isSearching && !isScanning && !hasSearched && (
|
||||||
<div className="py-16 text-center px-6">
|
<div className="py-16 text-center px-6">
|
||||||
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
<Search className="w-10 h-10 mx-auto mb-3 text-neutral-300" />
|
||||||
<p className="text-sm font-medium text-neutral-600 mb-1">Search your device</p>
|
<p className="text-sm font-medium text-neutral-600 mb-1">Search your device</p>
|
||||||
<p className="text-xs text-neutral-400">
|
<p className="text-xs text-neutral-400">
|
||||||
{dbPhase === 'idle'
|
{locateMissing
|
||||||
|
? 'No search index found. Tap the highlighted scan button to build one.'
|
||||||
|
: dbPhase === 'idle'
|
||||||
? 'The locate database will be downloaded on first search, or tap the scan icon to rebuild it.'
|
? 'The locate database will be downloaded on first search, or tap the scan icon to rebuild it.'
|
||||||
: 'Type a filename or path to search the index.'}
|
: 'Type a filename or path to search the index.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Loader2, Database } from 'lucide-react';
|
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import SearchLocal from './SearchLocal';
|
import SearchLocal from './SearchLocal';
|
||||||
import SearchAssembly64 from './SearchAssembly64';
|
import SearchAssembly64 from './SearchAssembly64';
|
||||||
import SearchCommoServe from './SearchCommoServe';
|
import SearchCommoServe from './SearchCommoServe';
|
||||||
import SearchCSDbNG from './SearchCSDbNG';
|
import SearchCSDbNG from './SearchCSDbNG';
|
||||||
import { humanFileSize } from '../webdav';
|
|
||||||
import {
|
|
||||||
openLocateDb,
|
|
||||||
isLocateDbLoaded,
|
|
||||||
subscribeLoadProgress,
|
|
||||||
} from '../locate-db';
|
|
||||||
|
|
||||||
interface SearchPaneProps {
|
interface SearchPaneProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -35,22 +28,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
// ~17 MB /sd/.locate download happening on the panel itself, not buried
|
// ~17 MB /sd/.locate download happening on the panel itself, not buried
|
||||||
// inside the search action. Multiple subscribers (this header bar + the
|
// inside the search action. Multiple subscribers (this header bar + the
|
||||||
// SearchLocal panel) can observe the same in-flight load.
|
// SearchLocal panel) can observe the same in-flight load.
|
||||||
const [loadState, setLoadState] = useState<{ kind: 'idle' | 'engine' | 'database' | 'ready' | 'error'; received: number; total: number | null; message?: string }>({ kind: 'idle', received: 0, total: null });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Kick off the load immediately on mount (if not already loaded).
|
|
||||||
// openLocateDb is a no-op once the database is loaded, so this is safe
|
|
||||||
// to call every time the pane opens.
|
|
||||||
if (!isLocateDbLoaded()) {
|
|
||||||
openLocateDb().catch(() => { /* errors are surfaced via loadState */ });
|
|
||||||
}
|
|
||||||
return subscribeLoadProgress(s => {
|
|
||||||
if (s.phase === 'idle') setLoadState({ kind: 'idle', received: 0, total: null });
|
|
||||||
else if (s.phase === 'ready') setLoadState({ kind: 'ready', received: 0, total: null });
|
|
||||||
else if (s.phase === 'error') setLoadState({ kind: 'error', received: 0, total: null });
|
|
||||||
else setLoadState({ kind: s.phase, received: s.received, total: s.total });
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Jump to starting tab without animation on first render
|
// Jump to starting tab without animation on first render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -73,15 +50,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
localStorage.setItem('search.tab', String(idx));
|
localStorage.setItem('search.tab', String(idx));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active-transfer UI for the slim bar that lives just under the tab bar.
|
|
||||||
// We only show the bar while a transfer is in flight; once it's ready
|
|
||||||
// (or never started) the bar collapses to zero height.
|
|
||||||
const showLoadBar = loadState.kind === 'engine' || loadState.kind === 'database';
|
|
||||||
const activeLabel = loadState.kind === 'engine' ? 'Loading engine' : 'Loading database';
|
|
||||||
const loadPct = loadState.total && loadState.total > 0
|
|
||||||
? Math.min(100, Math.round((loadState.received / loadState.total) * 100))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -98,44 +66,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Locate-database load bar. Visible only while a transfer is in
|
|
||||||
flight; collapses to zero height otherwise so it doesn't take
|
|
||||||
up space when there's nothing to show. */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 overflow-hidden border-b border-neutral-200/70 transition-[max-height,opacity] duration-200 ease-in-out"
|
|
||||||
style={{ maxHeight: showLoadBar ? '64px' : '0px', opacity: showLoadBar ? 1 : 0 }}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<div className="px-4 py-2 flex items-center gap-3">
|
|
||||||
<Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin flex-shrink-0" />
|
|
||||||
<Database className="w-3.5 h-3.5 text-neutral-400 flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
|
||||||
<span className="text-xs text-neutral-600 font-medium truncate">
|
|
||||||
{activeLabel}…
|
|
||||||
{loadState.received > 0 && (
|
|
||||||
<span className="text-neutral-400 font-normal">
|
|
||||||
{' '}
|
|
||||||
{loadState.total === null
|
|
||||||
? humanFileSize(loadState.received)
|
|
||||||
: `${humanFileSize(loadState.received)} / ${humanFileSize(loadState.total)}`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{loadPct !== null && (
|
|
||||||
<span className="text-xs text-neutral-500 tabular-nums">{loadPct}%</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="h-1 bg-neutral-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full bg-blue-500 transition-all duration-150 ease-out ${loadPct === null ? 'animate-pulse w-1/3' : ''}`}
|
|
||||||
style={loadPct !== null ? { width: `${loadPct}%` } : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Swipeable panels */}
|
{/* Swipeable panels */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||||
import wasmUrl from '@sqlite.org/sqlite-wasm/sqlite3.wasm?url';
|
import wasmUrl from '@sqlite.org/sqlite-wasm/sqlite3.wasm?url';
|
||||||
|
import { gzip } from 'fflate';
|
||||||
import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav';
|
import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav';
|
||||||
|
|
||||||
const LOCATE_PATH = '/sd/.locate';
|
const LOCATE_PATH = '/sd/.locate';
|
||||||
|
const LOCATE_GZ_PATH = '/sd/.locate.gz';
|
||||||
|
const SCAN_PROGRESS_INTERVAL = 50;
|
||||||
|
|
||||||
|
function dirPath(path: string): string {
|
||||||
|
const i = path.lastIndexOf('/');
|
||||||
|
return i > 0 ? path.slice(0, i) : '/';
|
||||||
|
}
|
||||||
// Vite rewrites this `?url` import to the hashed asset path
|
// Vite rewrites this `?url` import to the hashed asset path
|
||||||
// (e.g. /assets/sqlite3-BVKGSWc-.wasm) and respects the configured base path.
|
// (e.g. /assets/sqlite3-BVKGSWc-.wasm) and respects the configured base path.
|
||||||
const WASM_URL: string = wasmUrl;
|
const WASM_URL: string = wasmUrl;
|
||||||
|
|
@ -159,7 +167,7 @@ export async function openLocateDb(
|
||||||
_setLoadState({ phase: 'ready' });
|
_setLoadState({ phase: 'ready' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScanPhase = 'scanning' | 'building' | 'saving';
|
export type ScanPhase = 'scanning' | 'building' | 'saving' | 'compressing';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively scan /sd, build a fresh SQLite locate database in memory,
|
* Recursively scan /sd, build a fresh SQLite locate database in memory,
|
||||||
|
|
@ -170,47 +178,158 @@ export type ScanPhase = 'scanning' | 'building' | 'saving';
|
||||||
* building → number of entries inserted so far
|
* building → number of entries inserted so far
|
||||||
* saving → bytes of the serialized DB written to the server
|
* saving → bytes of the serialized DB written to the server
|
||||||
*/
|
*/
|
||||||
|
export type ScanCounts = { dirs: number; files: number };
|
||||||
|
|
||||||
export async function buildLocateDb(
|
export async function buildLocateDb(
|
||||||
onProgress?: (phase: ScanPhase, value: number) => void,
|
onProgress?: (phase: ScanPhase, value: number, path?: string, counts?: ScanCounts) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<{ count: number; bytes: number }> {
|
): Promise<{ count: number; bytes: number }> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const sqlite3 = await getSqlite3();
|
const sqlite3 = await getSqlite3();
|
||||||
|
|
||||||
// ── 1. Recursive PROPFIND on /sd ────────────────────────────────────────
|
// ── 1. BFS directory walk with Depth:1 per directory ────────────────────
|
||||||
const entries = await listDirectory(
|
// Depth:infinity is not supported by the device's WebDAV server, so we
|
||||||
'/sd', true,
|
// walk the tree manually, one directory at a time.
|
||||||
bytes => onProgress?.('scanning', bytes),
|
const entries: Awaited<ReturnType<typeof listDirectory>> = [];
|
||||||
);
|
const queue: string[] = ['/sd'];
|
||||||
|
let totalBytes = 0;
|
||||||
|
let dirCount = 0;
|
||||||
|
let fileCount = 0;
|
||||||
|
let lastFolder = '/sd';
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const dir = queue.shift()!;
|
||||||
|
lastFolder = dir;
|
||||||
|
let prevBytes = 0;
|
||||||
|
const batch = await listDirectory(dir, false, bytes => {
|
||||||
|
totalBytes += bytes - prevBytes;
|
||||||
|
prevBytes = bytes;
|
||||||
|
onProgress?.('scanning', totalBytes, dir, { dirs: dirCount, files: fileCount });
|
||||||
|
}, signal);
|
||||||
|
|
||||||
|
// If this directory has a .config with a URL-scheme base_url it points to
|
||||||
|
// a remote source — skip indexing its contents entirely.
|
||||||
|
const configEntry = batch.find(e => e.type === 'file' && basename(e.path) === '.config');
|
||||||
|
if (configEntry) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(getWebDAVBaseUrl() + configEntry.path, { signal });
|
||||||
|
if (res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
const m = text.match(/^base_url\s*:\s*(.+)$/m);
|
||||||
|
if (m) {
|
||||||
|
const val = m[1].trim().replace(/^['"]|['"]$/g, '');
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(val)) continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* non-fatal — proceed normally */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of batch) {
|
||||||
|
entries.push(e);
|
||||||
|
if (e.type === 'folder') { dirCount++; queue.push(e.path); }
|
||||||
|
else fileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
// ── 2. Build in-memory DB ───────────────────────────────────────────────
|
// ── 2. Build in-memory DB ───────────────────────────────────────────────
|
||||||
|
const scanStart = Date.now();
|
||||||
const db = new sqlite3.oo1.DB(':memory:', 'ct');
|
const db = new sqlite3.oo1.DB(':memory:', 'ct');
|
||||||
db.exec(
|
db.exec(
|
||||||
|
'CREATE TABLE dirs (' +
|
||||||
|
' id INTEGER PRIMARY KEY,' +
|
||||||
|
' path TEXT NOT NULL UNIQUE,' +
|
||||||
|
' scanned INTEGER NOT NULL DEFAULT 0' +
|
||||||
|
');' +
|
||||||
'CREATE TABLE files (' +
|
'CREATE TABLE files (' +
|
||||||
' path TEXT PRIMARY KEY,' +
|
' id INTEGER PRIMARY KEY,' +
|
||||||
|
' dir_id INTEGER NOT NULL,' +
|
||||||
|
' name TEXT NOT NULL,' +
|
||||||
' size INTEGER NOT NULL DEFAULT 0,' +
|
' size INTEGER NOT NULL DEFAULT 0,' +
|
||||||
' mtime INTEGER NOT NULL DEFAULT 0,' +
|
' mtime INTEGER NOT NULL DEFAULT 0,' +
|
||||||
' is_dir INTEGER NOT NULL DEFAULT 0' +
|
' is_dir INTEGER NOT NULL DEFAULT 0,' +
|
||||||
|
' UNIQUE(dir_id, name)' +
|
||||||
');' +
|
');' +
|
||||||
'CREATE INDEX IF NOT EXISTS idx_path ON files(path);',
|
'CREATE INDEX files_dir_idx ON files(dir_id);' +
|
||||||
|
'CREATE VIRTUAL TABLE files_fts USING fts5(' +
|
||||||
|
" path, content='', tokenize=\"unicode61\"" +
|
||||||
|
');' +
|
||||||
|
'CREATE TABLE status (' +
|
||||||
|
' id INTEGER PRIMARY KEY DEFAULT 1,' +
|
||||||
|
' total_dirs INTEGER NOT NULL DEFAULT 0,' +
|
||||||
|
' total_files INTEGER NOT NULL DEFAULT 0,' +
|
||||||
|
' last_scan INTEGER NOT NULL DEFAULT 0,' +
|
||||||
|
' duration INTEGER NOT NULL DEFAULT 0,' +
|
||||||
|
" last_folder TEXT NOT NULL DEFAULT ''" +
|
||||||
|
');',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── 3. Insert all unique directory paths ────────────────────────────────
|
||||||
|
const allDirPaths = new Set<string>(['/sd']);
|
||||||
|
for (const e of entries) {
|
||||||
|
allDirPaths.add(dirPath(e.path));
|
||||||
|
if (e.type === 'folder') allDirPaths.add(e.path);
|
||||||
|
}
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
const stmt = db.prepare('INSERT OR REPLACE INTO files VALUES (?,?,?,?)');
|
const stmtDir = db.prepare('INSERT OR IGNORE INTO dirs(path, scanned) VALUES (?, 1)');
|
||||||
|
try {
|
||||||
|
for (const p of allDirPaths) stmtDir.bind([p]).stepReset();
|
||||||
|
} finally { stmtDir.finalize(); }
|
||||||
|
db.exec('COMMIT');
|
||||||
|
|
||||||
|
const dirIdMap = new Map<string, number>();
|
||||||
|
db.exec({
|
||||||
|
sql: 'SELECT id, path FROM dirs',
|
||||||
|
rowMode: 'array',
|
||||||
|
callback: (row: any[]) => dirIdMap.set(row[1] as string, row[0] as number),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. Insert files + FTS ────────────────────────────────────────────────
|
||||||
|
db.exec('BEGIN');
|
||||||
|
const stmtFile = db.prepare(
|
||||||
|
'INSERT INTO files(dir_id, name, size, mtime, is_dir) VALUES (?,?,?,?,?)',
|
||||||
|
);
|
||||||
|
const stmtFts = db.prepare('INSERT INTO files_fts(rowid, path) VALUES (?,?)');
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < entries.length; i++) {
|
for (let i = 0; i < entries.length; i++) {
|
||||||
const e = entries[i];
|
const e = entries[i];
|
||||||
stmt.bind([
|
const dirId = dirIdMap.get(dirPath(e.path));
|
||||||
e.path,
|
if (dirId === undefined) continue;
|
||||||
|
stmtFile.bind([
|
||||||
|
dirId,
|
||||||
|
basename(e.path) || e.path,
|
||||||
e.size,
|
e.size,
|
||||||
e.lastModified ? Math.floor(e.lastModified.getTime() / 1000) : 0,
|
e.lastModified ? Math.floor(e.lastModified.getTime() / 1000) : 0,
|
||||||
e.type === 'folder' ? 1 : 0,
|
e.type === 'folder' ? 1 : 0,
|
||||||
]).stepReset();
|
]).stepReset();
|
||||||
if (i % 250 === 0) onProgress?.('building', i);
|
const rowid = sqlite3.capi.sqlite3_last_insert_rowid(db.pointer);
|
||||||
|
stmtFts.bind([rowid, e.path]).stepReset();
|
||||||
|
if (i % SCAN_PROGRESS_INTERVAL === 0) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
onProgress?.('building', i, e.path, { dirs: dirCount, files: fileCount });
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
stmt.finalize();
|
stmtFile.finalize();
|
||||||
|
stmtFts.finalize();
|
||||||
}
|
}
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
onProgress?.('building', entries.length);
|
signal?.throwIfAborted();
|
||||||
|
onProgress?.('building', entries.length, undefined, { dirs: dirCount, files: fileCount });
|
||||||
|
|
||||||
|
// ── 5. Write status ──────────────────────────────────────────────────────
|
||||||
|
db.exec({
|
||||||
|
sql: 'INSERT OR REPLACE INTO status(id,total_dirs,total_files,last_scan,duration,last_folder) VALUES (1,?,?,?,?,?)',
|
||||||
|
bind: [
|
||||||
|
dirCount,
|
||||||
|
fileCount,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
Math.floor((Date.now() - scanStart) / 1000),
|
||||||
|
lastFolder,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// ── 3. Serialize to bytes ────────────────────────────────────────────────
|
// ── 3. Serialize to bytes ────────────────────────────────────────────────
|
||||||
const pSaved = sqlite3.wasm.pstack.pointer;
|
const pSaved = sqlite3.wasm.pstack.pointer;
|
||||||
|
|
@ -234,6 +353,15 @@ export async function buildLocateDb(
|
||||||
await putFileContents(LOCATE_PATH, dbBytes);
|
await putFileContents(LOCATE_PATH, dbBytes);
|
||||||
onProgress?.('saving', dbBytes.length);
|
onProgress?.('saving', dbBytes.length);
|
||||||
|
|
||||||
|
// ── 5. Gzip at level 9 and upload ───────────────────────────────────────
|
||||||
|
onProgress?.('compressing', 0);
|
||||||
|
const gzBytes = await new Promise<Uint8Array>((resolve, reject) =>
|
||||||
|
gzip(dbBytes, { level: 9 }, (err, data) => err ? reject(err) : resolve(data)),
|
||||||
|
);
|
||||||
|
await deletePath(LOCATE_GZ_PATH).catch(() => {});
|
||||||
|
await putFileContents(LOCATE_GZ_PATH, gzBytes);
|
||||||
|
onProgress?.('compressing', gzBytes.length);
|
||||||
|
|
||||||
// Invalidate cached DB so the next search reloads the fresh file.
|
// Invalidate cached DB so the next search reloads the fresh file.
|
||||||
resetLocateDb();
|
resetLocateDb();
|
||||||
|
|
||||||
|
|
@ -272,7 +400,9 @@ export function searchLocate(query: string, limit = 500): LocateEntry[] {
|
||||||
const needle = toSqlLike(query);
|
const needle = toSqlLike(query);
|
||||||
const rows: LocateEntry[] = [];
|
const rows: LocateEntry[] = [];
|
||||||
_db.exec({
|
_db.exec({
|
||||||
sql: "SELECT path, size, mtime, is_dir FROM files WHERE path LIKE ? ESCAPE '\\' LIMIT ?",
|
sql: "SELECT d.path || '/' || f.name, f.size, f.mtime, f.is_dir " +
|
||||||
|
"FROM files f JOIN dirs d ON f.dir_id = d.id " +
|
||||||
|
"WHERE (d.path || '/' || f.name) LIKE ? ESCAPE '\\' LIMIT ?",
|
||||||
bind: [needle, limit],
|
bind: [needle, limit],
|
||||||
rowMode: 'array',
|
rowMode: 'array',
|
||||||
callback: (row: any[]) => {
|
callback: (row: any[]) => {
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,7 @@ export async function listDirectory(
|
||||||
path: string,
|
path: string,
|
||||||
recursive = false,
|
recursive = false,
|
||||||
onProgress?: (bytes: number) => void,
|
onProgress?: (bytes: number) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<EntryInfo[]> {
|
): Promise<EntryInfo[]> {
|
||||||
const manager = getWebDAVClient();
|
const manager = getWebDAVClient();
|
||||||
const base = manager.client.baseUrl;
|
const base = manager.client.baseUrl;
|
||||||
|
|
@ -190,6 +191,7 @@ export async function listDirectory(
|
||||||
method: 'PROPFIND',
|
method: 'PROPFIND',
|
||||||
headers: { 'Depth': String(depth), 'Content-Type': 'text/xml; charset=utf-8' },
|
headers: { 'Depth': String(depth), 'Content-Type': 'text/xml; charset=utf-8' },
|
||||||
body: PROPFIND_LIST_BODY,
|
body: PROPFIND_LIST_BODY,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
if (!response.body) throw new Error('No response body');
|
if (!response.body) throw new Error('No response body');
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user