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. */}
>
)}