the directory picker becomes shared, because two plugins wanted it

it was written inside fofos-admin, and then kanban's project form asked for a path
and got a TEXT FIELD — because a plugin cannot import from a sibling plugin and I
took the cheap way out rather than moving it. that is the wrong trade for a
component whose entire job is not letting somebody type a path wrong.

nothing in it was fofos-specific: it browses the owner's home over the platform's
own /file-browser/ls and /mkdir. so it moves to the officerdev barrel that plugins
already import from, and both use the one.

it takes the platform's `errorText` rather than fofos-admin's `readableError`,
which knew about that plugin's three particular failures — unconfigured,
unreachable, refused. here there are two: cannot read that folder, or not allowed.

copying it instead would have left two of them to drift, and the version that
drifts is always the one somebody is looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 07:09:37 +01:00
co-authored by Claude Opus 5
parent a970cb7c5f
commit 500bb925eb
2 changed files with 203 additions and 0 deletions
@@ -0,0 +1,201 @@
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';
// The platform's own formatter rather than fofos-admin's, which knew about that plugin's three
// specific failures. Here the only failures are "cannot read that folder" and "not allowed".
import { errorText } from 'helpers/error-text';
/**
* Pick a directory, and make one if it is not there yet.
*
* ── Shared, because two plugins wanted it and a plugin cannot import from another ──
*
* Written inside fofos-admin, then wanted verbatim by kanban's project form. A plugin cannot import
* from a sibling plugin, so the choice was to copy it or to move it here — and there is nothing
* fofos-specific in it: it browses the owner's home over the platform's own file API. Copying would
* have meant two of them drifting apart, which for a component whose whole job is "do not let somebody
* type a path wrong" is the wrong kind of thing to have two of.
*
* ── It uses the PLATFORM's file API, not routes of any 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(errorText(failure, 'could not read that folder'));
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(errorText(failure, 'could not read that folder'));
}
};
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>
);
};
+2
View File
@@ -6,6 +6,8 @@ export { panelCatalogs } from './locales';
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title';
export * from './AppRegistry';
/** Shared UI a plugin can use. `DirectoryPicker` is here because two plugins wanted the same one. */
export { DirectoryPicker } from './components/DirectoryPicker';
export * from './WidgetRegistry';
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)