diff --git a/src/workspaces/officerdev/src/components/DirectoryPicker.tsx b/src/workspaces/officerdev/src/components/DirectoryPicker.tsx new file mode 100644 index 00000000..2490bab9 --- /dev/null +++ b/src/workspaces/officerdev/src/components/DirectoryPicker.tsx @@ -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(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(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 ( +
+
+ + {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. */} + + + )} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index ce0b4804..46911b13 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -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)