From 1b7ffe3fcd95b7627509e09df22a8f17fb660892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 07:09:50 +0100 Subject: [PATCH] the directory picker moves to the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kanban wanted the same one and a plugin cannot import from a sibling, so it now lives in the officerdev barrel and this imports it from there. nothing in it was fofos-specific — it browses the owner's home over the platform's file API. deleted here rather than kept as a second copy. the copy that drifts is always the one somebody is looking at. Co-Authored-By: Claude Opus 5 --- manifest.ts | 2 +- web/DirectoryPicker.tsx | 191 -------------------------------------- web/RootLocationBlock.tsx | 3 +- 3 files changed, 3 insertions(+), 193 deletions(-) delete mode 100644 web/DirectoryPicker.tsx 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';