Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03fbb1189f | ||
|
|
21665ed024 | ||
|
|
111618bd7f | ||
|
|
9cb875ca79 | ||
|
|
c5b95becd2 | ||
|
|
f4b9596a88 | ||
|
|
09518e1e1f | ||
|
|
500bb925eb | ||
|
|
a970cb7c5f | ||
|
|
fb1cf0f6f1 | ||
|
|
a947df8d9d |
@@ -243,7 +243,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **21 entries as of 2026-08-27** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
never grantable at any level). **20 entries as of 2026-08-29** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 5 admin. It was 21 until the todo board became the `kanban` plugin and took `todos` with it — the same shape as caldav below, and for the same reason: a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026.08.29.02 — deploy report
|
||||
|
||||
**7 of 7 target hosts deployed. `officer-dev` skipped by design.** Every host prints
|
||||
`plugin plugin_config` — the two new `command_hq_sessions` columns exist on all eight databases.
|
||||
|
||||
All eight checkouts were at `58f50a52` (the `2026.08.29.01` tag) before this rollout, and all are at
|
||||
`78bf63c2` (`deploy/2026.08.29.02`) now. No host was ahead, none diverged, none was dirty.
|
||||
|
||||
This was the first release under a new watcher loop. The rollout itself behaved as written.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | plugins:update | backup | db:push | verification query | officer | health |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — **SKIPPED** | — | — | — | `plugin plugin_config` | not restarted | — |
|
||||
| `alpha` | `58f50a52` → `78bf63c2` | 15/15, exit 0 | 12,309,974 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (9000) |
|
||||
| `fofos` | `58f50a52` → `78bf63c2` | 5/5, exit 0 | 2,792,097 B | exit 0 | `plugin plugin_config` | 26 → 27 restarts, online | 401 (9000) |
|
||||
| `macbookpro` | `58f50a52` → `78bf63c2` | no plugins installed | 5,179,143 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (**9010**) |
|
||||
| `macbruno` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 86,480 B | exit 0 | `plugin plugin_config` | 6 → 7 restarts, online | 401 (9000) |
|
||||
| `production-pertento-bridge` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 187,488 B, **second attempt** | exit 0 | `plugin plugin_config` | 4 → 5 restarts, online | 401 (**9010**) |
|
||||
| `edge-pertento-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 5,916,050 B | exit 0 | `plugin plugin_config` | 5 → 6 restarts, online | 401 (9000) |
|
||||
| `deadpool-rezio-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 92,979 B | exit 0 | `plugin plugin_config` | 22 → 23 restarts, online | 401 (9000) |
|
||||
|
||||
`401` is the healthy answer from `/api/health` behind the session gate, as the plan says.
|
||||
|
||||
## What the release was for, confirmed on the host that matters
|
||||
|
||||
`fofos` carries `av-club`, and the commander template it now ships is on disk:
|
||||
`plugins/av-club/commanders/convert-on-arrival/COMMANDER.md`, plugin checkout at `5b2fe03`
|
||||
("Convert on arrival — the first plugin-shipped commander"). The plugin arrived through the standard
|
||||
`bun run plugins:update` step, as the plan predicted — nothing in this plan installed it.
|
||||
|
||||
## What did not go to plan
|
||||
|
||||
**`production-pertento-bridge` failed its backup on the first attempt and the host stopped there.**
|
||||
The plan's `pg_dump` is Ubuntu's 16.15; that host's Postgres is 18.3, and `pg_dump` refuses a newer
|
||||
server outright:
|
||||
|
||||
```
|
||||
pg_dump: error: aborting because of server version mismatch
|
||||
pg_dump: detail: server version: 18.3; pg_dump version: 16.15
|
||||
```
|
||||
|
||||
The host was left correct but half-done: code at the tag, plugins updated, **no `db:push` and no
|
||||
restart**, so the running process was still serving the old code against the old schema. Nothing was
|
||||
broken by stopping there.
|
||||
|
||||
Postgres on that host runs in a docker container (`postgres:18.3-alpine`), so a version-matched client
|
||||
was already present. The backup was retaken with `docker exec postgres pg_dump` — same dump, correct
|
||||
tool, read-only — and the plan then continued unchanged from step 2. Result above.
|
||||
|
||||
**This is a per-host detail worth writing down**, because it will recur on every release whose plan
|
||||
backs the database up: the plan's `pg_dump` line is host-portable only where the client is at least as
|
||||
new as the server. `production-pertento-bridge` is the one host where it is not.
|
||||
|
||||
**`fofos` health read `000` immediately after the restart** and `401` fifteen seconds later. The check
|
||||
was racing a cold start, not finding a fault. The health check now retries for up to thirty seconds
|
||||
before it reports a number.
|
||||
|
||||
**`fofos` has one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could not have
|
||||
disturbed it.
|
||||
|
||||
## Skipped
|
||||
|
||||
`officer-dev` — the rollout ran on it. Its checkout was already at `78bf63c2` and its database already
|
||||
carries both columns (verified by the same query: `plugin plugin_config`). **Its `officer` process was
|
||||
not restarted**, so it is running the previous build. The owner restarts it deliberately:
|
||||
|
||||
```
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`docs/servers.md` lists nothing under "Deliberately skipped" today, and no host was unreachable —
|
||||
`macbookpro` was awake.
|
||||
@@ -0,0 +1,85 @@
|
||||
# 2026.08.29.03 — the todo board leaves, and a plugin can claim a file type
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.02..deploy/2026.08.29.03 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --stat deploy/2026.08.29.02..deploy/2026.08.29.03 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
|
||||
Ten commits, all code.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **`/todos` is gone from the platform.** It is now the `kanban` plugin, in the marketplace. The
|
||||
`todos` permission, the route, the screen and the board were all removed.
|
||||
- **A plugin can claim a file type.** A manifest may declare `fileTypes`; the file browser then offers
|
||||
thumbnails and a full-size render for formats the platform itself cannot read. av-club claims
|
||||
`heic`/`heif`, so on a host with av-club installed, iPhone photographs get pictures instead of file
|
||||
icons and open in the viewer.
|
||||
- Thumbnails retry rather than giving up, and list and grid ask for the same size.
|
||||
- The directory picker moved into the platform so more than one plugin can use it.
|
||||
|
||||
## A behaviour change to expect, and it is deliberate
|
||||
|
||||
**`/todos` now 404s.** Anyone with it bookmarked gets nothing. The board is the `kanban` plugin, which
|
||||
is in the marketplace and installed nowhere by default — that is the intended state, not an oversight.
|
||||
|
||||
`role_permissions` rows naming `todos` are now orphaned. Harmless: absence denies, and the registry no
|
||||
longer has the key. **Do not clean them up as part of this rollout** — it is a data edit nobody asked
|
||||
for, and a row naming a permission that does not exist grants nothing.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the whole rollout this time (dirty check,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.03`, `bun run plugins:update`), plus:
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only — `plugins:update` has already restarted the plugin sidecars.
|
||||
|
||||
### Verify it came back
|
||||
|
||||
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
|
||||
|
||||
### And check the file browser still lists
|
||||
|
||||
This release touched the listing route, so one call per host is worth it:
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { sign } = await import('./src/servers/jwt');
|
||||
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
|
||||
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
|
||||
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
|
||||
|
||||
## Per host
|
||||
|
||||
Surveyed 2026-08-29 while writing this.
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||
| `alpha` | No av-club, so no HEIC support — expected, not a fault. |
|
||||
| `fofos` | **The only host with av-club installed**, and already at this commit and verified: 6 HEICs thumbnail and one renders at full size. Bring it to the tag so its HEAD matches the fleet. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
|
||||
| `production-pertento-bridge` | Port **9010**. Its `pg_dump` is older than its server — irrelevant here, since this release takes no backup and touches no schema. |
|
||||
| `edge-pertento-bridge` | Did not answer the survey while this plan was written. It may simply have been busy; treat it as an ordinary target and report what you find. |
|
||||
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
|
||||
`restart_time`, and the `ls` check's output.
|
||||
|
||||
Nothing in this release needs a database query, a backup or an install. If you find yourself running
|
||||
one, stop — the plan is wrong and that is worth reporting rather than working around.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.29.02",
|
||||
"version": "2026.08.29.03",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -107,7 +107,6 @@ export function App() {
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
||||
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
||||
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
||||
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /todos — the Kanban board over the repo's own `todo/` tree (one file per item, todo/README.md).
|
||||
// Selection and the theme filter are `?selected=` / `?theme=`, read by the panel itself.
|
||||
export const TodosScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/todos', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['todo-board'], fallback: 'todo-board' }} />;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel: the board carries its own filter bar and detail pane, driven by the URL.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'todo-board',
|
||||
appType: 'todo-board',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './TodosScreen';
|
||||
@@ -21,5 +21,4 @@ export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './SendOff';
|
||||
export * from './Todos';
|
||||
export * from './Bots';
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { snapshotPlugins } from '../../plugins/mount';
|
||||
import { sidecarPort } from '../../sidecar-registry';
|
||||
|
||||
/**
|
||||
* File types an installed plugin can show and the platform cannot.
|
||||
*
|
||||
* ── One place, so three consumers cannot disagree ──
|
||||
*
|
||||
* `ls` decides whether an entry promises a thumbnail, `/thumb` decides where to get one, and the viewer
|
||||
* decides whether a full-size render exists. Those must be the same answer: a listing that promises a
|
||||
* thumbnail nothing will produce is a request that 404s on every scroll, and a viewer that offers a
|
||||
* render for a type nothing claims is a spinner that never resolves.
|
||||
*
|
||||
* ── Core does not know which plugin this is ──
|
||||
*
|
||||
* It reads `fileTypes` from whatever is installed AND ENABLED, and calls a path by name on that
|
||||
* plugin's own sidecar. Nothing here imports plugin code and nothing names av-club. Disable the plugin
|
||||
* and the claims vanish with it, which is what makes the file browser degrade to exactly what it was
|
||||
* rather than to something broken.
|
||||
*
|
||||
* ── Why the sidecar directly, and not our own HTTP surface ──
|
||||
*
|
||||
* A loopback request back through `/api` would need a credential to satisfy the platform's own auth,
|
||||
* and minting one so the server can call itself is a worse thing to have than this. `sidecarPort` is
|
||||
* the port the plugin announced when it started; the proxy in front of it exists for browsers, and the
|
||||
* server is already inside.
|
||||
*
|
||||
* ── `x-officer-user` is the whole authentication, and that is the sidecar's own design ──
|
||||
*
|
||||
* A plugin sidecar binds loopback and accepts that header from anything already on the machine — the
|
||||
* platform is the thing that decides who may call, and by the time this runs the account gate and the
|
||||
* permission gate have both passed. `sidecarFetch` below sends it because the proxy in front of the
|
||||
* sidecar sends it, and a caller that skips it gets a 401 that looks like the plugin being broken.
|
||||
*/
|
||||
|
||||
export type FileTypeClaim = {
|
||||
plugin: string;
|
||||
/** Absolute URL on the plugin's sidecar, or null when it did not offer that half. */
|
||||
thumbnail: string | null;
|
||||
render: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension → who claims it. Lowercase keys, no leading dot.
|
||||
*
|
||||
* Not cached: `snapshotPlugins` reads the database, and this is called on a listing that is already
|
||||
* doing database work. A cache would need invalidating on install, uninstall, enable and disable —
|
||||
* four events to get wrong in exchange for a lookup that is already cheap.
|
||||
*/
|
||||
export async function pluginFileTypes(): Promise<Map<string, FileTypeClaim>> {
|
||||
const claims = new Map<string, FileTypeClaim>();
|
||||
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
try {
|
||||
({ states } = await snapshotPlugins());
|
||||
} catch {
|
||||
// The file browser still works. A database that cannot be read is a much louder problem elsewhere,
|
||||
// and losing every thumbnail to it would only obscure that.
|
||||
return claims;
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
|
||||
const declared = state.plugin.manifest?.fileTypes;
|
||||
if (!declared?.extensions?.length) continue;
|
||||
|
||||
const port = sidecarPort(state.plugin.appName);
|
||||
// Claimed but not running. Skipped rather than recorded, so `ls` does not promise a thumbnail that
|
||||
// cannot be fetched while the sidecar is restarting.
|
||||
if (!port) continue;
|
||||
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
for (const raw of declared.extensions) {
|
||||
const ext = String(raw).toLowerCase().replace(/^\./, '');
|
||||
// First claim wins. Two plugins claiming one extension is a conflict nobody has yet, and picking
|
||||
// arbitrarily-but-stably beats picking differently on each request.
|
||||
if (!ext || claims.has(ext)) continue;
|
||||
|
||||
claims.set(ext, {
|
||||
plugin: state.plugin.appName,
|
||||
thumbnail: declared.thumbnail ? `${base}${declared.thumbnail}` : null,
|
||||
render: declared.render ? `${base}${declared.render}` : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a plugin's sidecar the way its proxy would.
|
||||
*
|
||||
* The email is the account the request is being made FOR, not the server — a plugin scoping anything
|
||||
* per user reads this, and passing something else would be lying to it about who is asking.
|
||||
*/
|
||||
export async function sidecarFetch(url: string, email: string): Promise<Response> {
|
||||
return fetch(url, { headers: { 'x-officer-user': email } });
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
|
||||
import { pluginFileTypes, sidecarFetch } from './plugin-file-types';
|
||||
|
||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
try {
|
||||
@@ -204,6 +205,12 @@ router.get('/ls', async (ctx) => {
|
||||
}
|
||||
return ctx.json({ path: '/', entries: [], reset: true });
|
||||
}
|
||||
/*
|
||||
What a plugin can show that we cannot. Resolved ONCE per listing rather than per entry — it reads the
|
||||
database, and a folder of two hundred files would otherwise ask two hundred times for one answer.
|
||||
*/
|
||||
const claimed = await pluginFileTypes();
|
||||
|
||||
const entries = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const fullPath = resolve(absPath, name);
|
||||
@@ -228,7 +235,18 @@ router.get('/ls', async (ctx) => {
|
||||
|
||||
Costs nothing: this entry has just been stat'd and the answer is a lookup on its name.
|
||||
*/
|
||||
thumb: isDirectory ? null : thumbnailKind(name),
|
||||
/*
|
||||
Core's own answer first, then a plugin's claim.
|
||||
|
||||
A file only says `thumb` when something can actually produce one: core for the formats sharp
|
||||
and ffmpeg read, a plugin for what it declared in `fileTypes`. Without that second half, `.heic`
|
||||
promised a thumbnail nothing would make and cost a 422 on every scroll — which is why core
|
||||
stopped claiming it.
|
||||
*/
|
||||
thumb: isDirectory
|
||||
? null
|
||||
: (thumbnailKind(name) ??
|
||||
(claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase()) ? 'image' : null)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -375,12 +393,42 @@ router.get('/thumb', async (ctx) => {
|
||||
if (!relPath) throw errors.BAD_REQUEST('path is required');
|
||||
|
||||
const absPath = resolveUserPath(rootDir, relPath);
|
||||
const size = clampSize(ctx.req.query('size'));
|
||||
const kind = thumbnailKind(relPath);
|
||||
// 415 rather than 404: the file is there, it is simply not something a frame can be taken from. The
|
||||
// client does not ask for these, so this is the answer to a stale tab rather than to normal use.
|
||||
if (!kind) return ctx.text('no thumbnail for this file type', 415);
|
||||
|
||||
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, clampSize(ctx.req.query('size')));
|
||||
/*
|
||||
A type core cannot read, but an installed plugin can.
|
||||
|
||||
Forwarded to the plugin's own sidecar and cached HERE, under the same key everything else uses —
|
||||
so a HEIC thumbnail is produced once and then served from disk like any other, and the plugin is
|
||||
not asked again on every scroll.
|
||||
|
||||
`buildThumbnail` is given the fetch rather than a path: the caching, the concurrency limit and the
|
||||
remembered failures are all in that one function, and a second copy of them here for this one case
|
||||
is how the two would come to behave differently.
|
||||
*/
|
||||
if (!kind) {
|
||||
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
|
||||
if (claimed?.thumbnail) {
|
||||
const fetched = await buildThumbnail(absPath, getUserDataDir(user.email), 'image', size, async () => {
|
||||
const res = await sidecarFetch(
|
||||
`${claimed.thumbnail}?path=${encodeURIComponent(absPath)}&size=${size}`,
|
||||
user.email,
|
||||
);
|
||||
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
|
||||
});
|
||||
if (!fetched.ok) return ctx.text(fetched.reason, 422);
|
||||
return new Response(Bun.file(fetched.path), {
|
||||
headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, max-age=31536000, immutable' },
|
||||
});
|
||||
}
|
||||
|
||||
// 415 rather than 404: the file is there, it is simply not something anything here can read. The
|
||||
// client does not ask for these, so this answers a stale tab rather than normal use.
|
||||
return ctx.text('no thumbnail for this file type', 415);
|
||||
}
|
||||
|
||||
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, size);
|
||||
// 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about
|
||||
// the file, and logging it as a server error would bury the ones that are.
|
||||
if (!result.ok) return ctx.text(result.reason, 422);
|
||||
@@ -393,6 +441,43 @@ router.get('/thumb', async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A displayable version of a file the browser cannot show itself.
|
||||
*
|
||||
* Only for types an installed plugin CLAIMS — everything a browser can already decode keeps going
|
||||
* through `/raw`, which streams bytes, supports ranges and lets the browser render progressively. This
|
||||
* route exists for exactly one situation: the file is an image, and no browser can display it.
|
||||
*
|
||||
* Not cached on disk like `/thumb`. A full-size render is large, viewed once, and keyed on a width the
|
||||
* viewer chooses — caching it would fill the disk to make the second look at one photograph faster. The
|
||||
* short `max-age` lets the browser hold it while somebody zooms.
|
||||
*/
|
||||
router.get('/render', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
|
||||
if (!relPath) throw errors.BAD_REQUEST('path is required');
|
||||
|
||||
const absPath = resolveUserPath(rootDir, relPath);
|
||||
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
|
||||
// 415 and not 404: the file is there. Nothing installed can show it, which is a different answer and
|
||||
// the one the viewer needs in order to say so rather than spin.
|
||||
if (!claimed?.render) return ctx.text('nothing installed can display this file type', 415);
|
||||
|
||||
const asked = Number(ctx.req.query('width'));
|
||||
const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 2048;
|
||||
|
||||
const res = await sidecarFetch(`${claimed.render}?path=${encodeURIComponent(absPath)}&width=${width}`, user.email);
|
||||
if (!res.ok) return ctx.text(`${claimed.plugin} could not read this file`, 422);
|
||||
|
||||
return new Response(await res.arrayBuffer(), {
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('content-type') ?? 'image/webp',
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/raw', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||
|
||||
@@ -43,7 +43,18 @@ import sharp from 'sharp';
|
||||
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
|
||||
* file icons was the most visible gap this had.
|
||||
*/
|
||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg', 'heic', 'heif']);
|
||||
/*
|
||||
No `heic` or `heif`, and that is not an oversight.
|
||||
|
||||
sharp's bundled libheif is built without the HEVC decoder, so it parses a HEIC's header and then
|
||||
fails on the pixels. Listing them here made `ls` promise a thumbnail core cannot produce: every HEIC
|
||||
cost a request that 422'd before falling back to the icon, and the promise was the only reason the
|
||||
request happened at all.
|
||||
|
||||
Decoding them needs ImageMagick and `libheif-plugin-libde265` — a system dependency the core has no
|
||||
business acquiring for one format. That belongs to a plugin, and av-club already does it.
|
||||
*/
|
||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg']);
|
||||
/*
|
||||
`ts` is NOT here, and that is the one entry worth explaining.
|
||||
|
||||
@@ -205,6 +216,16 @@ export async function buildThumbnail(
|
||||
userDataDir: string,
|
||||
kind: 'image' | 'video',
|
||||
size: number,
|
||||
/**
|
||||
* Produce the bytes yourself instead of letting sharp or ffmpeg try.
|
||||
*
|
||||
* For a type core cannot read but an installed plugin can — see `plugin-file-types.ts`. It is passed
|
||||
* IN rather than handled by a second function so that everything around it is shared: the cache key
|
||||
* with its mtime and size, the concurrency limit, and the remembered failures. A parallel path for
|
||||
* plugin thumbnails would be a second cache to keep correct and a second place to forget the
|
||||
* negative caching, which is the part that stops one bad file spawning work on every scroll.
|
||||
*/
|
||||
produce?: () => Promise<Buffer | null>,
|
||||
): Promise<ThumbnailResult> {
|
||||
const info = await stat(absPath).catch(() => null);
|
||||
if (!info || !info.isFile()) return { ok: false, reason: 'not a file' };
|
||||
@@ -236,7 +257,11 @@ export async function buildThumbnail(
|
||||
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
|
||||
|
||||
let ok: boolean;
|
||||
if (kind === 'image') {
|
||||
if (produce) {
|
||||
const bytes = await produce().catch(() => null);
|
||||
if (bytes?.length) await Bun.write(cachePath, bytes);
|
||||
ok = !!bytes?.length;
|
||||
} else if (kind === 'image') {
|
||||
ok = await resizeImage(absPath, cachePath, size);
|
||||
} else {
|
||||
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
|
||||
@@ -247,7 +272,19 @@ export async function buildThumbnail(
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
rememberFailure(key);
|
||||
/*
|
||||
A PRODUCER'S FAILURE IS NOT REMEMBERED, and that distinction is the whole point of this branch.
|
||||
|
||||
When sharp or ffmpeg fails, the file is the problem — it will fail identically forever, and
|
||||
remembering that is what stops one bad video spawning work on every scroll.
|
||||
|
||||
A producer is a PLUGIN, in another process, reachable over a socket. It fails while it restarts,
|
||||
while it is busy behind its own concurrency limit, and for the few seconds after an update. Those
|
||||
all pass. Caching them meant a folder of HEIC came back as file icons and stayed that way until
|
||||
the SERVER was restarted — no client retry could ever get past it, because the answer was being
|
||||
served from memory without the plugin being asked again.
|
||||
*/
|
||||
if (!produce) rememberFailure(key);
|
||||
return { ok: false, reason: 'could not read an image from this file' };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './todo-tree';
|
||||
|
||||
/*
|
||||
/api/todos — the board over `todo/`, the platform's own backlog (one file per item, todo/README.md).
|
||||
|
||||
The FILESYSTEM is the database, deliberately: the items are already versioned, reviewed and synced
|
||||
between machines by git, and a second store would be a second truth. So this router is thin — read the
|
||||
tree, splice frontmatter — and everything it writes is a small, reviewable git diff.
|
||||
|
||||
Two rules from the format's contract, enforced here rather than trusted to callers:
|
||||
|
||||
- The BODY is the owner's half and is never rewritten. Only `patchFrontmatter` touches a file, and it
|
||||
stops at the closing fence.
|
||||
- `TODO.md` is generated output. Every write regenerates it via `writeIndex()`, so the index cannot
|
||||
drift from the tree — the board never edits it directly.
|
||||
|
||||
── Owner only, explicitly ──
|
||||
|
||||
`todos` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner.
|
||||
This is the same belt-and-braces the other admin routers add: a registry mistake must not turn into
|
||||
members reading — or editing — the platform's own backlog.
|
||||
*/
|
||||
|
||||
export const todosRouter = createRouter();
|
||||
|
||||
todosRouter.use(async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The todo board is owner-only');
|
||||
return next();
|
||||
});
|
||||
|
||||
/** `<theme>/<slug>`, both kebab-case — also the path traversal guard, since it admits no dot or slash pair. */
|
||||
const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
const fileForId = (id: string): string => {
|
||||
if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`);
|
||||
const [theme, slug] = id.split('/');
|
||||
const path = resolve(process.cwd(), join('todo', theme!, `${slug}.md`));
|
||||
if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`);
|
||||
return path;
|
||||
};
|
||||
|
||||
/** GET /api/todos — the whole tree, no bodies. What the board renders. */
|
||||
todosRouter.get('/', (ctx) => ctx.json(readTree()));
|
||||
|
||||
/** GET /api/todos/item?id=<theme>/<slug> — one item, body included. Query param because the id holds a slash. */
|
||||
todosRouter.get('/item', (ctx) => {
|
||||
const text = readFileSync(fileForId(ctx.req.query('id') ?? ''), 'utf8');
|
||||
return ctx.json({ meta: frontmatter(text), body: bodyOf(text) });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/todos/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`.
|
||||
* Regenerates TODO.md on every write. The body is untouchable through this API, on purpose.
|
||||
*/
|
||||
todosRouter.patch('/item', async (ctx) => {
|
||||
const { id, status, priority, due, order } = await ctx.req.json<{
|
||||
id: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due?: string;
|
||||
order?: number;
|
||||
}>();
|
||||
const path = fileForId(id);
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
if (status !== undefined) {
|
||||
if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`);
|
||||
updates.status = status;
|
||||
}
|
||||
// `urgent` or nothing — the format deliberately has no scale (todo/README.md).
|
||||
if (priority !== undefined) {
|
||||
if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`);
|
||||
updates.priority = priority;
|
||||
}
|
||||
if (due !== undefined) {
|
||||
if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`);
|
||||
updates.due = due;
|
||||
}
|
||||
if (order !== undefined) {
|
||||
if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`);
|
||||
updates.order = String(order);
|
||||
}
|
||||
if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update');
|
||||
|
||||
writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates));
|
||||
writeIndex();
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -10,8 +10,20 @@ import { join, resolve } from 'node:path';
|
||||
// the closing fence.
|
||||
// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target.
|
||||
//
|
||||
// This module is the one reader and one writer. `scripts/todo-index.ts` (the CLI) and the `/api/todos`
|
||||
// router both import it, so the board and the generated index cannot disagree about what the tree holds.
|
||||
// ── This module has ONE caller now, and that is deliberate ──
|
||||
//
|
||||
// `scripts/todo-index.ts` — the CLI behind `bun todo:index`, which regenerates TODO.md from a terminal
|
||||
// with no Officer running. The `/api/todos` router and the board are gone: they became the `kanban`
|
||||
// plugin on 2026-08-29, which generalised this reader over a root so it can show any project's folder
|
||||
// rather than only the one the server was started in.
|
||||
//
|
||||
// This copy stayed because the folder it reads is the PLATFORM'S OWN backlog, committed with the
|
||||
// platform's own code. Moving it would have made regenerating this repository's TODO.md depend on a
|
||||
// plugin being installed, which is the wrong way round for a repo's own documentation.
|
||||
//
|
||||
// So there are two copies of this logic now, and they will drift. That is a known cost, recorded here
|
||||
// and in the plugin's README rather than discovered later. The honest fix, on the day it matters, is to
|
||||
// delete the CLI and let the plugin own the format outright.
|
||||
|
||||
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
|
||||
const todoDir = () => resolve(process.cwd(), 'todo');
|
||||
|
||||
@@ -10,7 +10,6 @@ import { themeFavoritesRouter } from './api/themes/favorites';
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { rolesRouter } from './api/roles/router';
|
||||
import { todosRouter } from './api/todos/router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
@@ -131,7 +130,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/theme-favorites', themeFavoritesRouter],
|
||||
['/users', usersRouter],
|
||||
['/roles', rolesRouter],
|
||||
['/todos', todosRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
['/agents', agentsRouter],
|
||||
|
||||
@@ -435,17 +435,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
|
||||
routes: ['/marketplace', '/plugins'],
|
||||
},
|
||||
{
|
||||
key: 'todos',
|
||||
label: 'Todo board',
|
||||
description: "The platform's own backlog — todo/ in the repo, browsed and edited at /todos",
|
||||
// Admin because the subject is the platform's development, not the account's data: the tree lives in
|
||||
// the server's own checkout and a write is a git-visible edit to it. The router double-gates on
|
||||
// isSuperAdmin, same belt-and-braces as the rest of this kind.
|
||||
kind: 'admin',
|
||||
api: ['/todos'],
|
||||
routes: ['/todos'],
|
||||
},
|
||||
{
|
||||
key: 'server-admin',
|
||||
label: 'Server settings',
|
||||
|
||||
@@ -477,6 +477,38 @@ export type PluginManifest = {
|
||||
settings?: PluginSetting[];
|
||||
|
||||
websocket?: { sidecar: string };
|
||||
/**
|
||||
* File types this plugin can show that the platform cannot.
|
||||
*
|
||||
* ── Why this exists ──
|
||||
*
|
||||
* The file browser's thumbnailer reads what sharp and ffmpeg read, and that list is a constant in
|
||||
* core. HEIC is the case that forced the question: sharp's bundled libheif has no HEVC decoder, and
|
||||
* teaching core to shell out to ImageMagick would mean the platform acquiring a system dependency for
|
||||
* one format — which is a plugin's job, not core's.
|
||||
*
|
||||
* So a plugin may CLAIM extensions. `ls` unions these with core's own, so a file only promises a
|
||||
* thumbnail when something can actually produce one; `/thumb` forwards a claimed type to the plugin
|
||||
* that claimed it; and the viewer asks for `render` when opening one full size.
|
||||
*
|
||||
* ── The dependency still points the right way ──
|
||||
*
|
||||
* Core does not import plugin code and does not know av-club exists. It reads a claim at runtime from
|
||||
* whatever is installed and enabled, calls a route by name, and falls back to a file icon when nothing
|
||||
* claims the type. Uninstall the plugin and the file browser is exactly what it was.
|
||||
*
|
||||
* `thumbnail` and `render` are paths on the plugin's own sidecar, called with `?path=<absolute>` —
|
||||
* plus `&size=` for a thumbnail and `&width=` for a render. Both must answer image bytes.
|
||||
*/
|
||||
fileTypes?: {
|
||||
/** Lowercase, no leading dot. `['heic', 'heif']`. */
|
||||
extensions: string[];
|
||||
/** Sidecar path returning a small image. Omit and the platform will not offer thumbnails for these. */
|
||||
thumbnail?: string;
|
||||
/** Sidecar path returning a full-size displayable image. Omit and the viewer will not offer them. */
|
||||
render?: string;
|
||||
};
|
||||
|
||||
osDependencies?: OsDependency[];
|
||||
|
||||
/**
|
||||
|
||||
@@ -502,6 +502,32 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
],
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'kanban',
|
||||
publisher: 'officerdev',
|
||||
version: '2026.08.29.01',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Kanban',
|
||||
summary: 'A board over folders of markdown todo files — as many projects as you point it at',
|
||||
color: '#8b5cf6',
|
||||
icon: 'SquareKanban',
|
||||
source: 'https://gitea.officer.dev/plugins/kanban.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'kanban',
|
||||
label: 'Kanban',
|
||||
description: 'Read and edit todo boards over folders you have added',
|
||||
},
|
||||
],
|
||||
/*
|
||||
`schema: true` for one table, and it is worth saying what is in it: a list of FOLDERS. the items
|
||||
are markdown on disk and this plugin keeps no copy of them, so an uninstall with "delete its data"
|
||||
ticked loses the list and never a line of anybody's work.
|
||||
|
||||
`sidecar: false` — it reads and writes files with node's own fs. there is nothing to run.
|
||||
*/
|
||||
has: { api: true, schema: true, sidecar: false, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'example',
|
||||
publisher: 'officerdev',
|
||||
|
||||
@@ -13,7 +13,6 @@ import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||
import { appRegistryMetas as todoBoardMetas } from '../apps/TodoBoard';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { globalQueryKey } from 'hooks/useGlobal';
|
||||
import { metasToRegistry } from './useAppRegistry';
|
||||
@@ -31,7 +30,6 @@ export const apps = [
|
||||
...qrTransferMetas,
|
||||
...sendOffMetas,
|
||||
...commandHqMetas,
|
||||
...todoBoardMetas,
|
||||
...appStoreMetas,
|
||||
...pluginsMetas,
|
||||
];
|
||||
|
||||
+12
-1
@@ -629,7 +629,18 @@ export const FileItem = ({
|
||||
</Thumbnail>
|
||||
);
|
||||
|
||||
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 128) : icon;
|
||||
/*
|
||||
ONE SIZE FOR BOTH VIEWS, and it is 256 rather than the 128 the list used to ask for.
|
||||
|
||||
The size is part of the cache key, so two sizes meant two entries — and switching from grid to list
|
||||
re-rendered every picture from scratch. For a jpeg that is a few milliseconds nobody notices. For a
|
||||
HEIC, which a plugin decodes, it is four seconds per file: the list showed icons where the grid had
|
||||
just shown photographs, and the two views appeared to disagree about the same file.
|
||||
|
||||
The list draws it at 28px and the grid at about 80, so both are downscaling either way and 256 is
|
||||
sharp enough for a retina screen at both. One key, one render, and the views cannot diverge.
|
||||
*/
|
||||
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 256) : icon;
|
||||
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+32
-2
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
import { getHeaders } from 'hooks/useClient';
|
||||
|
||||
@@ -33,6 +33,21 @@ const API_URL = '/api';
|
||||
* every one of them would spawn an ffmpeg for something nobody has looked at.
|
||||
*/
|
||||
|
||||
/*
|
||||
How many times to try before settling for the icon, and how long to wait between.
|
||||
|
||||
A thumbnail can legitimately fail for a REASON THAT PASSES: the plugin that renders it is restarting,
|
||||
the server is busy behind its concurrency cap, or — for a format that needs decoding — the first
|
||||
request is simply slow enough that something upstream gave up. Treating the first failure as final
|
||||
meant a folder of HEIC came back as file icons and stayed that way until somebody reloaded the page,
|
||||
which is what made it look like a race rather than a retry that never happened.
|
||||
|
||||
Backing off rather than hammering: a genuinely undecodable file must not become a request loop, and
|
||||
three attempts over about seven seconds is enough to outlast a sidecar restart without becoming one.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_DELAYS_MS = [1200, 3000, 6000];
|
||||
|
||||
export const Thumbnail = ({
|
||||
path,
|
||||
root,
|
||||
@@ -53,6 +68,12 @@ export const Thumbnail = ({
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading');
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// A pending retry must not fire into an unmounted row — the grid is virtualised, so scrolling past a
|
||||
// slow thumbnail unmounts it mid-wait.
|
||||
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
|
||||
|
||||
if (state === 'failed') return <>{children}</>;
|
||||
|
||||
@@ -72,8 +93,17 @@ export const Thumbnail = ({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
// `attempt` is in the key, so a retry REMOUNTS the element. Changing only the src would leave
|
||||
// the browser's own failed-request cache in play and the second attempt would not be one.
|
||||
key={attempt}
|
||||
onLoad={() => setState('ready')}
|
||||
onError={() => setState('failed')}
|
||||
onError={() => {
|
||||
if (attempt + 1 >= MAX_ATTEMPTS) {
|
||||
setState('failed');
|
||||
return;
|
||||
}
|
||||
timer.current = setTimeout(() => setAttempt((n) => n + 1), RETRY_DELAYS_MS[attempt] ?? 6000);
|
||||
}}
|
||||
className={`size-full object-cover transition-opacity duration-150 ${
|
||||
state === 'ready' ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Loader2, FolderArchive, FileWarning, Download } from 'lucide-react';
|
||||
import { getLang, getRawUrl, getArchiveBaseName } from './file-types';
|
||||
import { getLang, getRawUrl, getRenderUrl, needsServerRender, getArchiveBaseName } from './file-types';
|
||||
import { useFileViewer } from './FileViewerContext';
|
||||
import { PdfRenderer } from './renderers/PdfRenderer';
|
||||
import { ImageRenderer } from './renderers/ImageRenderer';
|
||||
@@ -63,7 +63,15 @@ export const FileViewerBody = () => {
|
||||
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
||||
</div>
|
||||
) : fileType === 'image' ? (
|
||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
||||
<ImageRenderer
|
||||
/*
|
||||
HEIC and friends go through the server, because no browser decodes them. Everything else
|
||||
keeps using `/raw`: it streams, supports ranges, and lets the browser paint progressively —
|
||||
all of which a rendered copy gives up.
|
||||
*/
|
||||
src={needsServerRender(fileName) ? getRenderUrl(filePath, root) : getRawUrl(filePath, root)}
|
||||
fileName={fileName}
|
||||
/>
|
||||
) : fileType === 'video' ? (
|
||||
<VideoRenderer src={videoSrc} fileName={fileName} filePath={filePath} root={root} />
|
||||
) : fileType === 'audio' ? (
|
||||
|
||||
@@ -124,6 +124,34 @@ export function formatTime(s: number): string {
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image formats NO browser can display, whatever the server does.
|
||||
*
|
||||
* This is a fact about browsers, not about plugins — which is why it is a constant here rather than
|
||||
* something fetched. Chrome and Firefox have never decoded HEIC and Safari only does on Apple's own
|
||||
* platforms, so a `<img src>` pointed at one shows a broken image everywhere it matters.
|
||||
*
|
||||
* The server may or may not be able to render one into something showable; that depends on whether a
|
||||
* plugin claims the type, and `/file-browser/render` answers 415 when nothing does.
|
||||
*/
|
||||
export const BROWSER_CANNOT_DISPLAY = ['heic', 'heif'];
|
||||
|
||||
export const needsServerRender = (name: string): boolean =>
|
||||
BROWSER_CANNOT_DISPLAY.includes(name.split('.').pop()?.toLowerCase() ?? '');
|
||||
|
||||
/**
|
||||
* A displayable version of a file the browser cannot decode. 415 when nothing installed can make one.
|
||||
*
|
||||
* `width` is what the viewer will draw at, doubled for retina and clamped server-side — asking for the
|
||||
* full 4032px of a phone photograph to show it in a panel is megabytes nobody looks at.
|
||||
*/
|
||||
export function getRenderUrl(filePath: string, root?: string, width = 2048): string {
|
||||
const headers = getHeaders();
|
||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||
return `${API_URL}/file-browser/render?path=${encodeURIComponent(filePath)}&width=${width}&token=${encodeURIComponent(token)}${rootParam}`;
|
||||
}
|
||||
|
||||
export function getRawUrl(filePath: string, root?: string, audioTrack?: number): string {
|
||||
const headers = getHeaders();
|
||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import type { DragEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { AlertTriangle, CalendarClock, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TodoItem } from './useTodos';
|
||||
import { useTodoItem, useTodoPatch, useTodoTree } from './useTodos';
|
||||
|
||||
/**
|
||||
* The board over `todo/` — the platform's own backlog, one file per item (todo/README.md).
|
||||
*
|
||||
* Selection and the theme filter live in the URL (`?selected=`, `?theme=`), per the navigation audit:
|
||||
* a card is a real link, a filtered board is an addressable page, and back means what it should.
|
||||
*
|
||||
* Writes go through PATCH /todos/item, which touches frontmatter only and regenerates TODO.md — this
|
||||
* board never holds state the files do not; a drag is a file edit and a refetch.
|
||||
*/
|
||||
|
||||
const COLUMNS = [
|
||||
{ status: 'open', label: 'Open' },
|
||||
{ status: 'doing', label: 'Doing' },
|
||||
{ status: 'blocked', label: 'Blocked' },
|
||||
{ status: 'backburner', label: 'Backburner' },
|
||||
{ status: 'done', label: 'Done' },
|
||||
] as const;
|
||||
|
||||
export const TodoBoardPanel = () => {
|
||||
const { data, isLoading, error } = useTodoTree();
|
||||
const patch = useTodoPatch();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
const themeFilter = params.get('theme');
|
||||
const [dragOver, setDragOver] = useState<string | null>(null);
|
||||
|
||||
const href = (changes: Record<string, string | null>): string => {
|
||||
const next = new URLSearchParams(params);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null) next.delete(key);
|
||||
else next.set(key, value);
|
||||
}
|
||||
const qs = next.toString();
|
||||
return qs ? `?${qs}` : '.';
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground">
|
||||
The todo board is owner-only, and this account was refused. Nothing here is broken — there is simply nothing to
|
||||
show.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading || !data) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Reading todo/…</div>;
|
||||
}
|
||||
|
||||
const themes = data.themes.filter((t) => t.items.length > 0);
|
||||
const items = themes.flatMap((t) => t.items);
|
||||
const visible = themeFilter ? items.filter((i) => i.theme === themeFilter) : items;
|
||||
|
||||
const drop = (status: string) => (ev: DragEvent) => {
|
||||
ev.preventDefault();
|
||||
setDragOver(null);
|
||||
const id = ev.dataTransfer.getData('text/todo-id');
|
||||
if (id) patch.mutate({ id, status });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-b border-border px-4 py-2">
|
||||
<Link
|
||||
to={href({ theme: null })}
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-0.5 text-xs',
|
||||
!themeFilter
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
All · {items.filter((i) => i.status !== 'done').length}
|
||||
</Link>
|
||||
{themes.map((t) => {
|
||||
const open = t.items.filter((i) => i.status !== 'done').length;
|
||||
return (
|
||||
<Link
|
||||
key={t.theme}
|
||||
to={href({ theme: t.theme === themeFilter ? null : t.theme })}
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-0.5 text-xs',
|
||||
themeFilter === t.theme
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t.heading.split('—')[0]?.trim()} · {open}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
|
||||
{COLUMNS.map((col) => {
|
||||
const cards = visible
|
||||
.filter((i) => i.status === col.status)
|
||||
.sort((a, b) => (b.priority === 'urgent' ? 1 : 0) - (a.priority === 'urgent' ? 1 : 0));
|
||||
return (
|
||||
<div
|
||||
key={col.status}
|
||||
onDragOver={(ev) => {
|
||||
ev.preventDefault();
|
||||
setDragOver(col.status);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(null)}
|
||||
onDrop={drop(col.status)}
|
||||
className={cn(
|
||||
'flex w-64 shrink-0 flex-col rounded-lg border border-border bg-muted/30',
|
||||
dragOver === col.status && 'border-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between px-3 py-2 text-xs font-semibold text-muted-foreground">
|
||||
<span>{col.label}</span>
|
||||
<span>{cards.length}</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
|
||||
{cards.map((item) => (
|
||||
<TodoCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
href={href({ selected: item.id })}
|
||||
selected={selected === item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected ? <TodoDetail id={selected} closeHref={href({ selected: null })} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TodoCardProps = { item: TodoItem; href: string; selected: boolean };
|
||||
|
||||
const TodoCard = ({ item, href, selected }: TodoCardProps) => (
|
||||
<Link
|
||||
to={href}
|
||||
draggable
|
||||
onDragStart={(ev) => ev.dataTransfer.setData('text/todo-id', item.id)}
|
||||
className={cn(
|
||||
'block rounded-md border border-border bg-card p-2.5 text-left shadow-sm hover:border-primary/50',
|
||||
selected && 'border-primary',
|
||||
)}
|
||||
>
|
||||
<div className="text-xs leading-snug text-card-foreground">{item.title}</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span className="rounded bg-muted px-1 py-px">{item.theme}</span>
|
||||
{item.priority === 'urgent' && (
|
||||
<span className="flex items-center gap-0.5 rounded bg-destructive/15 px-1 py-px text-destructive">
|
||||
<AlertTriangle className="h-2.5 w-2.5" /> urgent
|
||||
</span>
|
||||
)}
|
||||
{item.due && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<CalendarClock className="h-2.5 w-2.5" /> {item.due}
|
||||
</span>
|
||||
)}
|
||||
<span>{item.created}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
type TodoDetailProps = { id: string; closeHref: string };
|
||||
|
||||
const TodoDetail = ({ id, closeHref }: TodoDetailProps) => {
|
||||
const { data, isLoading } = useTodoItem(id);
|
||||
const patch = useTodoPatch();
|
||||
const status = data?.meta.status ?? 'open';
|
||||
|
||||
return (
|
||||
<div className="flex w-[28rem] shrink-0 flex-col border-l border-border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={status}
|
||||
onChange={(ev) => patch.mutate({ id, status: ev.target.value })}
|
||||
className="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{COLUMNS.map((c) => (
|
||||
<option key={c.status} value={c.status}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Link to={closeHref} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="file-viewer-md min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm">
|
||||
{isLoading || !data ? (
|
||||
<span className="text-sm text-muted-foreground">Reading…</span>
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.body}</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { SquareKanban } from 'lucide-react';
|
||||
import { TodoBoardPanel } from './TodoBoardPanel';
|
||||
|
||||
export { TodoBoardPanel } from './TodoBoardPanel';
|
||||
export { useTodoTree, useTodoItem, useTodoPatch } from './useTodos';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'todo-board', name: 'Todo board', icon: SquareKanban, component: TodoBoardPanel },
|
||||
];
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export type TodoItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
theme: string;
|
||||
status: string;
|
||||
created: string;
|
||||
priority: string;
|
||||
due: string;
|
||||
order: number;
|
||||
file: string;
|
||||
};
|
||||
|
||||
export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] };
|
||||
|
||||
export type TodoPatch = { id: string; status?: string; priority?: string; due?: string; order?: number };
|
||||
|
||||
export const useTodoTree = () => {
|
||||
const client = useClient();
|
||||
return useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: () => client.get<{ themes: TodoTheme[] }>('/todos'),
|
||||
// The tree is files on disk that other agents edit while the board is open; refetch keeps the board
|
||||
// honest without a watcher, which the sync contract allows (todo/README.md — poll is the cheapest
|
||||
// thing that works).
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTodoItem = (id: string | null) => {
|
||||
const client = useClient();
|
||||
return useQuery({
|
||||
queryKey: ['todos', 'item', id],
|
||||
queryFn: () =>
|
||||
client.get<{ meta: Record<string, string>; body: string }>(`/todos/item?id=${encodeURIComponent(id!)}`),
|
||||
enabled: id !== null,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTodoPatch = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch: TodoPatch) => client.patch<{ ok: boolean }>('/todos/item', patch),
|
||||
onSuccess: (_data, patch) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['todos', 'item', patch.id] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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<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(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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user