Compare commits
No commits in common. "6af14371e81d049d0fa419b02d96d68b50db4b08" and "4a2f6032d208e503cd1c6f46ae4aad945bfb8a7a" have entirely different histories.
6af14371e8
...
4a2f6032d2
|
|
@ -1,7 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2, FileWarning } from 'lucide-react';
|
import { Folder, File, ChevronRight, Home, RefreshCw, Upload, FolderPlus, Trash2, ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
basename,
|
|
||||||
createFolder,
|
createFolder,
|
||||||
deletePath,
|
deletePath,
|
||||||
humanFileSize,
|
humanFileSize,
|
||||||
|
|
@ -10,7 +9,6 @@ import {
|
||||||
normalizePath,
|
normalizePath,
|
||||||
putFileContents,
|
putFileContents,
|
||||||
splitPath,
|
splitPath,
|
||||||
stat,
|
|
||||||
type EntryInfo,
|
type EntryInfo,
|
||||||
} from '../webdav';
|
} from '../webdav';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
@ -24,14 +22,7 @@ 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) {
|
||||||
// If `currentPath` is itself a file (e.g. the IEC device is currently
|
const [path, setPath] = useState(() => normalizePath(currentPath || '/'));
|
||||||
// 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);
|
||||||
|
|
@ -40,45 +31,6 @@ 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);
|
||||||
|
|
@ -95,7 +47,6 @@ 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]);
|
||||||
|
|
@ -126,7 +77,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 || path === null) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
await createFolder(joinPath(path, name), true);
|
await createFolder(joinPath(path, name), true);
|
||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
|
|
@ -155,7 +106,6 @@ 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();
|
||||||
|
|
@ -174,7 +124,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}>
|
||||||
|
|
@ -300,36 +250,6 @@ 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" />
|
||||||
|
|
@ -364,14 +284,10 @@ 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 border-b border-neutral-100 ${
|
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100"
|
||||||
isSelected ? 'bg-blue-50' : 'hover:bg-neutral-50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -414,8 +330,7 @@ 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">
|
||||||
|
|
@ -430,10 +345,9 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
||||||
{mode === 'pick-folder' ? (
|
{mode === 'pick-folder' ? (
|
||||||
<button
|
<button
|
||||||
onClick={selectCurrentFolder}
|
onClick={selectCurrentFolder}
|
||||||
disabled={path === null}
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg"
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,7 @@
|
||||||
* normalizePath / splitPath / joinPath / basename – path helpers
|
* normalizePath / splitPath / joinPath / basename – path helpers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { WebDAVManager, type WebDAVEntry } from './vendor/webdav-component/esm/index.js';
|
||||||
WebDAVManager,
|
|
||||||
PROPFIND_LIST_BODY,
|
|
||||||
type WebDAVEntry,
|
|
||||||
} from './vendor/webdav-component/esm/index.js';
|
|
||||||
|
|
||||||
export type { WebDAVEntry };
|
export type { WebDAVEntry };
|
||||||
|
|
||||||
|
|
@ -106,55 +102,32 @@ 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);
|
||||||
p = u.pathname;
|
return normalizePath(decodeURIComponent(u.pathname));
|
||||||
} else {
|
|
||||||
p = uri;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
p = uri;
|
/* ignore */
|
||||||
}
|
}
|
||||||
// Strip a host prefix if the server added one (e.g. lighttpd).
|
// Already-relative URI: strip a host prefix if the server added one,
|
||||||
|
// 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 {
|
||||||
// The vendored `webdav-component` computes `entry.path` by stripping
|
const rawPath = e.path && e.path.length > 0 ? e.path : e.uri;
|
||||||
// `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,
|
name: e.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,
|
||||||
|
|
@ -172,18 +145,11 @@ 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) continue;
|
if (e && e.name) {
|
||||||
const info = toEntryInfo(e, base);
|
entries.push(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) => {
|
||||||
|
|
@ -199,40 +165,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -161,13 +161,6 @@ 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):
|
||||||
|
|
@ -661,7 +654,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' and elem.type == Member.M_COLLECTION:
|
if depth == '1':
|
||||||
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>')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user