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)