defaults to `<owner home>/fofos`, and stores the FULL path — never the tilde. a tilde means "whoever is asking", and the people asking will not all be the same person: this plugin is grantable, and the site is in one place regardless of who is looking at it. stored as `~/fofos` it would give every user a different answer to a question that has one. THE DEFAULT IS COMPUTED, NOT WRITTEN. no row means the default is worked out on every read, so it follows the machine. writing it in at install freezes whichever home existed on install day, and a restore onto a different machine then points at a directory that is not there while looking deliberately chosen. a saved path is a decision and is stored exactly as given; only its absence is derived. the form says which of the two it is showing, because "not set" reading identically to "set to the default" is how somebody comes to believe they configured something they did not. THE PICKER USES THE PLATFORM'S FILE API — /file-browser/ls and /mkdir — not routes of this plugin's own. they already refuse to leave home, and they already tell a missing directory from a forbidden one, which is a distinction that cost somebody an afternoon and would have been re-guessed here. shipping a second listing and mkdir would be a second filesystem surface to audit for the sake of one form. that API is an execution capability and never grantable, so for anybody but the owner it 403s — said in a sentence rather than shown as an empty tree, because an empty listing is data and must never be how a refusal looks. it also browses the owner's home and nothing above it; a path outside home is still reachable by typing, since the picker fills the field and the FIELD is what saves. one value on screen, one Save, and picking a folder commits nothing by itself. "Use this folder" takes the one that is OPEN rather than one highlighted in the list. that makes looking inside and choosing one gesture, and it is the only way to choose a folder with nothing in it — which a freshly made one always is. a SECOND table, not a column on fofos_admin_connection, whose url and token are both notNull: a root path could not be saved there without also having an HTTP connection, and that connection is being replaced rather than extended. when it goes, its table is a clean drop instead of surgery on columns that outlived it. the rule against tables in this plugin is unbent — what it forbids is the CLIENT'S work, and this row holds a path somebody can retype. needs `bun db:push`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.9 KiB
TypeScript
192 lines
7.9 KiB
TypeScript
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<Listing | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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<Listing>(
|
|
`/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 (
|
|
<div className="flex flex-col gap-2 rounded-md border border-border">
|
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">{absolute(rootDir, path)}</span>
|
|
{loading && <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />}
|
|
</div>
|
|
|
|
<div className="max-h-56 overflow-auto px-1">
|
|
{parent !== null && (
|
|
<button
|
|
onClick={() => void load(parent)}
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
|
|
>
|
|
<CornerLeftUp className="size-3.5 shrink-0 text-muted-foreground" />
|
|
Up one level
|
|
</button>
|
|
)}
|
|
|
|
{directories.map((entry) => (
|
|
<button
|
|
key={entry.name}
|
|
onClick={() => void load(path === '/' ? `/${entry.name}` : `${path}/${entry.name}`)}
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
|
|
>
|
|
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60" />
|
|
</button>
|
|
))}
|
|
|
|
{/* 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 && (
|
|
<p className="px-2 py-2 text-xs text-muted-foreground">No folders here.</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="px-3 text-xs text-destructive">{error}</p>}
|
|
|
|
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2">
|
|
{creating ? (
|
|
<>
|
|
<Input
|
|
autoFocus
|
|
value={newName}
|
|
onChange={(event) => 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}
|
|
/>
|
|
<Button size="sm" className="h-7" onClick={() => void create()} disabled={!newName.trim()}>
|
|
Create
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-7" onClick={() => setCreating(false)}>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button variant="outline" size="sm" className="h-7" onClick={() => setCreating(true)}>
|
|
<FolderPlus className="size-3.5" />
|
|
New folder
|
|
</Button>
|
|
<div className="flex-1" />
|
|
<Button variant="ghost" size="sm" className="h-7" onClick={onCancel}>
|
|
Cancel
|
|
</Button>
|
|
{/* 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. */}
|
|
<Button size="sm" className="h-7" disabled={!rootDir} onClick={() => onPick(absolute(rootDir, path))}>
|
|
Use this folder
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|