the directory picker moves to the platform
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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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',
|
||||
|
||||
@@ -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<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(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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user