import { useCallback, useEffect, useRef, useState } from 'react'; import { ChevronRight, CornerLeftUp, Folder, FolderPlus, Loader2 } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { readableError } from './errors'; /** * Pick a directory, and make one if it is not there yet. * * ── It uses the PLATFORM's file API, not routes of this plugin's own ── * * `/api/file-browser/ls` and `/api/file-browser/mkdir` already exist, already refuse to leave the * home directory, and already distinguish "missing" from "not allowed" — a distinction that cost * somebody an afternoon to get right and would have been re-guessed here. A plugin that shipped its * own listing and mkdir would be a second filesystem surface to audit for the sake of one form. * * The consequence is worth stating: this browses the OWNER'S HOME and nothing above it. The file API * is an execution capability and never grantable, so for anybody but the owner these calls 403 — the * component says so instead of showing an empty tree, because an empty listing is data and must never * be how a refusal looks. * * A path outside home is still reachable by TYPING it in the field this sits under. The picker is a * convenience over the common case, not the only way in. * * ── `rootDir` comes from the server, so the full path is real ── * * `ls` answers with the absolute `rootDir` it resolved as well as the relative path inside it. Joining * those two is what lets this hand back an absolute path without the browser ever guessing at what * home means — which it cannot know and would get wrong for anybody but the owner. */ type Entry = { name: string; type: 'directory' | 'file' }; type Listing = { path: string; rootDir?: string; entries: Entry[] }; /** `/a/b` under `/home/x` → `/home/x/a/b`. The root itself is `/`, which must not become a trailing slash. */ const absolute = (rootDir: string, path: string) => `${rootDir}${path === '/' ? '' : path}`.replace(/\/+$/, '') || '/'; export const DirectoryPicker = ({ startAt, onPick, onCancel, }: { /** Relative to home, e.g. `/fofos`. Where the browser opens. */ startAt?: string; /** The chosen directory, ABSOLUTE. */ onPick: (absolutePath: string) => void; onCancel: () => void; }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [path, setPath] = useState(startAt ?? '/'); const [listing, setListing] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const load = useCallback(async (target: string) => { setLoading(true); setError(null); try { const result = await clientRef.current.get( `/file-browser/ls?root=home&path=${encodeURIComponent(target)}`, ); setListing(result); // `ls` resets to the root when a path has gone, and reports where it actually ended up. Following // its answer rather than our request is what stops a stale path showing the wrong folder's contents. setPath(result.path); } catch (failure) { setError(readableError(failure)); setListing(null); } finally { setLoading(false); } }, []); useEffect(() => { void load(startAt ?? '/'); }, [load, startAt]); const rootDir = listing?.rootDir ?? ''; const directories = (listing?.entries ?? []).filter((entry) => entry.type === 'directory'); const parent = path === '/' ? null : path.slice(0, path.lastIndexOf('/')) || '/'; const create = async () => { const name = newName.trim(); if (!name) return; setError(null); try { const target = path === '/' ? `/${name}` : `${path}/${name}`; await clientRef.current.post('/file-browser/mkdir?root=home', { path: target }); setNewName(''); setCreating(false); // Straight into it. Making a folder here means meaning to use it, and stopping at its parent // makes somebody find what they just made in a list. await load(target); } catch (failure) { setError(readableError(failure)); } }; return (
{absolute(rootDir, path)} {loading && }
{parent !== null && ( )} {directories.map((entry) => ( ))} {/* Only when there is genuinely nothing, and worded as a fact about this folder. Files are not listed at all — this picks a directory, and showing them would be a list of things that cannot be chosen. */} {!loading && !error && directories.length === 0 && (

No folders here.

)}
{error &&

{error}

}
{creating ? ( <> setNewName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void create(); if (event.key === 'Escape') setCreating(false); }} placeholder="New folder name" className="h-7 min-w-0 flex-1 text-xs" autoComplete="off" spellCheck={false} /> ) : ( <>
{/* Chooses the folder currently OPEN, not one highlighted in the list. Opening a folder to look inside it and then choosing it is one gesture rather than two, and it is the only way to choose a folder that has nothing in it yet — which a freshly made one always is. */} )}
); };