diff --git a/manifest.ts b/manifest.ts
index 3810679..ae4f861 100644
--- a/manifest.ts
+++ b/manifest.ts
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
- version: '2026.08.29.16',
+ version: '2026.08.29.17',
platform: '>=1.0.0',
label: 'Fofos',
diff --git a/web/DirectoryPicker.tsx b/web/DirectoryPicker.tsx
deleted file mode 100644
index e25f4ee..0000000
--- a/web/DirectoryPicker.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-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. */}
-
- >
- )}
-
-
- );
-};
diff --git a/web/RootLocationBlock.tsx b/web/RootLocationBlock.tsx
index 96a463a..e869ee8 100644
--- a/web/RootLocationBlock.tsx
+++ b/web/RootLocationBlock.tsx
@@ -4,7 +4,8 @@ import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
-import { DirectoryPicker } from './DirectoryPicker';
+// Shared, since kanban wanted the same picker — see the note in its own file.
+import { DirectoryPicker } from 'officerdev';
import { Block } from './Block';
import { WebsiteCheckout } from './WebsiteCheckout';
import { MediaFolder } from './MediaFolder';