Compare commits

..

4 Commits

3 changed files with 182 additions and 21 deletions

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; 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 { import {
basename,
createFolder, createFolder,
deletePath, deletePath,
humanFileSize, humanFileSize,
@ -9,6 +10,7 @@ import {
normalizePath, normalizePath,
putFileContents, putFileContents,
splitPath, splitPath,
stat,
type EntryInfo, type EntryInfo,
} from '../webdav'; } from '../webdav';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -22,7 +24,14 @@ interface FileBrowserProps {
type Mode = 'pick-file' | 'pick-folder' | 'browse'; type Mode = 'pick-file' | 'pick-folder' | 'browse';
export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrowserProps) { 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 [entries, setEntries] = useState<EntryInfo[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -31,6 +40,45 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null); 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) => { const load = async (p: string) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -47,6 +95,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
}; };
useEffect(() => { useEffect(() => {
if (path === null) return;
void load(path); void load(path);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [path]); }, [path]);
@ -77,7 +126,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
const name = newFolderName.trim(); const name = newFolderName.trim();
if (!name) return; if (!name || path === null) return;
try { try {
await createFolder(joinPath(path, name), true); await createFolder(joinPath(path, name), true);
setNewFolderName(''); setNewFolderName('');
@ -106,6 +155,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
}; };
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
if (path === null) return;
const target = joinPath(path, file.name); const target = joinPath(path, file.name);
try { try {
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
@ -124,7 +174,7 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
e.target.value = ''; e.target.value = '';
}; };
const pathParts = path.split('/').filter(Boolean); const pathParts = (path ?? '').split('/').filter(Boolean);
return ( return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end" onClick={onClose}> <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>
<div className="overflow-y-auto flex-1"> <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 && ( {loading && (
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2"> <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" /> <Loader2 className="w-6 h-6 animate-spin" />
@ -284,10 +364,14 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
</button> </button>
)} )}
{entries.map((entry) => ( {entries.map((entry) => {
const isSelected = selectedFile !== null && entry.path === selectedFile;
return (
<div <div
key={entry.path} 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 <button
onClick={() => { onClick={() => {
@ -330,7 +414,8 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
))} );
})}
{entries.length === 0 && ( {entries.length === 0 && (
<div className="p-8 text-center text-neutral-500 text-sm"> <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' ? ( {mode === 'pick-folder' ? (
<button <button
onClick={selectCurrentFolder} 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> </button>
) : mode === 'pick-file' ? ( ) : mode === 'pick-file' ? (
<div className="text-xs text-neutral-500 text-center"> <div className="text-xs text-neutral-500 text-center">

View File

@ -20,7 +20,11 @@
* normalizePath / splitPath / joinPath / basename path helpers * 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 }; export type { WebDAVEntry };
@ -102,32 +106,55 @@ export interface EntryInfo {
* path-relative work everywhere else. * path-relative work everywhere else.
*/ */
function pathFromUri(uri: string, baseUrl: string): string { function pathFromUri(uri: string, baseUrl: string): string {
let p: string;
try { try {
// Absolute URL → take just the pathname. // Absolute URL → take just the pathname.
if (/^https?:\/\//i.test(uri)) { if (/^https?:\/\//i.test(uri)) {
const u = new URL(uri); const u = new URL(uri);
return normalizePath(decodeURIComponent(u.pathname)); p = u.pathname;
} else {
p = uri;
} }
} catch { } catch {
/* ignore */ p = uri;
} }
// Already-relative URI: strip a host prefix if the server added one, // Strip a host prefix if the server added one (e.g. lighttpd).
// then ensure a leading `/`.
let p = uri;
try { try {
const host = new URL(baseUrl).hostname; const host = new URL(baseUrl).hostname;
if (p.startsWith('/' + host + '/')) p = p.slice(host.length + 1); if (p.startsWith('/' + host + '/')) p = p.slice(host.length + 1);
} catch { } catch {
/* ignore */ /* ignore */
} }
// Always URL-decode so the rest of the app sees literal characters
// (e.g. spaces) instead of %20 in names and paths.
try {
p = decodeURIComponent(p);
} catch {
/* leave as-is on malformed encoding */
}
return normalizePath(p); return normalizePath(p);
} }
function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo { function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo {
const rawPath = e.path && e.path.length > 0 ? e.path : e.uri; // The vendored `webdav-component` computes `entry.path` by stripping
// `baseUrl.length` from the href — that only works when the server
// sends absolute hrefs (e.g. `http://localhost/sd/foo`). When the
// server sends relative hrefs (`/sd/foo`), the strip chops off the
// start of the path. Use `entry.uri` directly instead, which the
// parser sets verbatim from the href and is always reliable.
const rawPath = (e.uri && e.uri.length > 0 ? e.uri : e.path) || '';
const fullPath = pathFromUri(rawPath, baseUrl); const fullPath = pathFromUri(rawPath, baseUrl);
// Prefer the server-provided displayname when it looks like a leaf
// (no slashes). Some servers (notably older webdav3.py) used to
// return the full path as the displayname; in that case fall back to
// the leaf of the resolved path.
const looksLikePath = (s: string) => s.includes('/');
const name =
(e.name && !looksLikePath(e.name) ? e.name : '') ||
basename(fullPath) ||
e.name;
return { return {
name: e.name, name,
path: fullPath, path: fullPath,
type: e.isDir ? 'folder' : 'file', type: e.isDir ? 'folder' : 'file',
size: typeof e.size === 'number' ? e.size : 0, size: typeof e.size === 'number' ? e.size : 0,
@ -145,11 +172,18 @@ export async function listDirectory(path: string): Promise<EntryInfo[]> {
// The manager's `open` requires an absolute URL on the configured server. // The manager's `open` requires an absolute URL on the configured server.
const collectionUrl = pathToUrl(normalizePath(path), base); const collectionUrl = pathToUrl(normalizePath(path), base);
const listing = await manager.open(collectionUrl); const listing = await manager.open(collectionUrl);
const selfPath = normalizePath(path);
const entries: EntryInfo[] = []; const entries: EntryInfo[] = [];
for (const e of Object.values(listing)) { for (const e of Object.values(listing)) {
if (e && e.name) { if (!e || !e.name) continue;
entries.push(toEntryInfo(e, base)); const info = toEntryInfo(e, base);
} // Filter out the listing root itself (it appears as an entry whose
// path equals the requested collection) and any entries whose path
// doesn't sit directly under the requested collection.
if (info.path === selfPath) continue;
const parent = splitPath(info.path).parent;
if (parent !== selfPath) continue;
entries.push(info);
} }
// Folders first, then alpha (case-insensitive). // Folders first, then alpha (case-insensitive).
entries.sort((a, b) => { entries.sort((a, b) => {
@ -165,6 +199,40 @@ export async function fileExists(path: string): Promise<boolean> {
return manager.client.exists(pathToUrl(normalizePath(path), base)); 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> { export async function createFolder(path: string, _recursive = true): Promise<void> {
const manager = getWebDAVClient(); const manager = getWebDAVClient();
const base = manager.client.baseUrl; const base = manager.client.baseUrl;

View File

@ -161,6 +161,13 @@ class DirCollection(FileMember, Collection):
p = FileMember.getProperties(self) # inherit file properties p = FileMember.getProperties(self) # inherit file properties
p['iscollection'] = 1 p['iscollection'] = 1
p['getcontenttype'] = DirCollection.COLLECTION_MIME_TYPE p['getcontenttype'] = DirCollection.COLLECTION_MIME_TYPE
# Inherited displayname is `self.name`, which for a DirCollection is the
# full virtual path (e.g. "/sd/40 tracks/") because __init__ sets
# self.name = virdir. WebDAV clients expect a basename, so override it
# with just the leaf name.
leaf = os.path.basename(self.fsname.rstrip('/\\'))
if leaf:
p['displayname'] = leaf
return p return p
def getMembers(self): def getMembers(self):
@ -654,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') 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) write_props_member(w, elem)
if depth == '1': if depth == '1' and elem.type == Member.M_COLLECTION:
for m in elem.getMembers(): for m in elem.getMembers():
write_props_member(w,m) write_props_member(w,m)
w.write('</D:multistatus>') w.write('</D:multistatus>')