Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
- `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
|
`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
|
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
|
- `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,
|
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`.
|
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.
|
||||||
@@ -107,7 +107,6 @@ export function App() {
|
|||||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||||
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
|
||||||
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
||||||
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
||||||
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
|
<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 './Jobs';
|
||||||
export * from './QrTransfer';
|
export * from './QrTransfer';
|
||||||
export * from './SendOff';
|
export * from './SendOff';
|
||||||
export * from './Todos';
|
|
||||||
export * from './Bots';
|
export * from './Bots';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { mkdir, stat } from 'node:fs/promises';
|
import { mkdir, rm, stat } from 'node:fs/promises';
|
||||||
import { extname, join } from 'node:path';
|
import { extname, join } from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
@@ -179,6 +179,44 @@ const spawnFfmpeg = async (args: string[], output: string): Promise<boolean> =>
|
|||||||
* Errors are swallowed to `false` rather than thrown — a file sharp cannot read is a fact about the
|
* Errors are swallowed to `false` rather than thrown — a file sharp cannot read is a fact about the
|
||||||
* file, and the caller already turns that into a fallback icon and a remembered failure.
|
* file, and the caller already turns that into a fallback icon and a remembered failure.
|
||||||
*/
|
*/
|
||||||
|
/** Extensions sharp's bundled libheif cannot decode — see `decodeViaImageMagick`. */
|
||||||
|
const NEEDS_IMAGEMAGICK = new Set(['heic', 'heif']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a HEIC into something sharp can actually read.
|
||||||
|
*
|
||||||
|
* ── Why this is necessary at all ──
|
||||||
|
*
|
||||||
|
* sharp ships its own libvips and libheif, built WITHOUT the HEVC decoder — so it parses a HEIC's
|
||||||
|
* header, reports 4032x3024 quite happily, and then fails on the pixels with "bad seek". That reads
|
||||||
|
* exactly like a corrupt file and is not one. Every photograph an iPhone takes is HEVC-coded HEIF, so
|
||||||
|
* this is not an edge case; it is most of what arrives from a phone.
|
||||||
|
*
|
||||||
|
* ImageMagick links the SYSTEM libheif, which does have the codec — provided
|
||||||
|
* `libheif-plugin-libde265` is installed. That package is the entire difference: without it,
|
||||||
|
* ImageMagick fails the same way with "Unsupported codec". Observed on two hosts running identical
|
||||||
|
* libheif 1.17.6, one working and one not.
|
||||||
|
*
|
||||||
|
* ── This is deliberately the same approach av-club already uses ──
|
||||||
|
*
|
||||||
|
* `plugins/av-club/sidecar/sharp/decodable.ts` solved this first and has been converting these exact
|
||||||
|
* files for weeks. Copied rather than shared because the platform must not import from a plugin — the
|
||||||
|
* dependency only runs the other way — and fifteen lines of spawn is a smaller cost than inverting that.
|
||||||
|
*
|
||||||
|
* PNG for the intermediate, not JPEG: it is lossless, so the single real encode is the final one and the
|
||||||
|
* thumbnail is not a copy of a copy.
|
||||||
|
*/
|
||||||
|
const decodeViaImageMagick = async (input: string, output: string): Promise<boolean> => {
|
||||||
|
const proc = Bun.spawn(['convert', `${input}[0]`, output], { stdout: 'ignore', stderr: 'ignore' });
|
||||||
|
const timer = setTimeout(() => proc.kill(), 30_000);
|
||||||
|
await proc.exited;
|
||||||
|
clearTimeout(timer);
|
||||||
|
// The file is the test, never the exit code — ImageMagick exits 0 having written nothing when the
|
||||||
|
// codec is missing, which is precisely the case this exists to handle.
|
||||||
|
const written = await stat(output).catch(() => null);
|
||||||
|
return written !== null && written.size > 0;
|
||||||
|
};
|
||||||
|
|
||||||
const resizeImage = async (input: string, output: string, size: number): Promise<boolean> => {
|
const resizeImage = async (input: string, output: string, size: number): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await sharp(input, { failOn: 'none', limitInputPixels: false, animated: false })
|
await sharp(input, { failOn: 'none', limitInputPixels: false, animated: false })
|
||||||
@@ -237,7 +275,25 @@ export async function buildThumbnail(
|
|||||||
|
|
||||||
let ok: boolean;
|
let ok: boolean;
|
||||||
if (kind === 'image') {
|
if (kind === 'image') {
|
||||||
|
/*
|
||||||
|
HEIC goes through ImageMagick first. Everything else — jpeg, png, webp, avif, svg — sharp reads
|
||||||
|
directly, so this costs nothing for the common case.
|
||||||
|
|
||||||
|
The intermediate lands in the same cache directory and is removed either way: a failure here
|
||||||
|
must not leave a stray PNG that a later run mistakes for a finished thumbnail.
|
||||||
|
*/
|
||||||
|
const ext = extname(absPath).slice(1).toLowerCase();
|
||||||
|
if (NEEDS_IMAGEMAGICK.has(ext)) {
|
||||||
|
const intermediate = `${cachePath}.decoded.png`;
|
||||||
|
try {
|
||||||
|
ok =
|
||||||
|
(await decodeViaImageMagick(absPath, intermediate)) && (await resizeImage(intermediate, cachePath, size));
|
||||||
|
} finally {
|
||||||
|
await rm(intermediate, { force: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ok = await resizeImage(absPath, cachePath, size);
|
ok = await resizeImage(absPath, cachePath, size);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
|
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
|
||||||
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
|
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
|
||||||
|
|||||||
@@ -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.
|
// the closing fence.
|
||||||
// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target.
|
// - `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`
|
// ── This module has ONE caller now, and that is deliberate ──
|
||||||
// router both import it, so the board and the generated index cannot disagree about what the tree holds.
|
//
|
||||||
|
// `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. */
|
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
|
||||||
const todoDir = () => resolve(process.cwd(), 'todo');
|
const todoDir = () => resolve(process.cwd(), 'todo');
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { themeFavoritesRouter } from './api/themes/favorites';
|
|||||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||||
import { usersRouter } from './api/users/users-router';
|
import { usersRouter } from './api/users/users-router';
|
||||||
import { rolesRouter } from './api/roles/router';
|
import { rolesRouter } from './api/roles/router';
|
||||||
import { todosRouter } from './api/todos/router';
|
|
||||||
import { apiKeysRouter } from './api/api-keys/router';
|
import { apiKeysRouter } from './api/api-keys/router';
|
||||||
import { skillsRouter } from './api/skills/skills';
|
import { skillsRouter } from './api/skills/skills';
|
||||||
import { tasksRouter } from './api/tasks/tasks';
|
import { tasksRouter } from './api/tasks/tasks';
|
||||||
@@ -131,7 +130,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
|||||||
['/theme-favorites', themeFavoritesRouter],
|
['/theme-favorites', themeFavoritesRouter],
|
||||||
['/users', usersRouter],
|
['/users', usersRouter],
|
||||||
['/roles', rolesRouter],
|
['/roles', rolesRouter],
|
||||||
['/todos', todosRouter],
|
|
||||||
['/skills', skillsRouter],
|
['/skills', skillsRouter],
|
||||||
['/tasks', tasksRouter],
|
['/tasks', tasksRouter],
|
||||||
['/agents', agentsRouter],
|
['/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.
|
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
|
||||||
routes: ['/marketplace', '/plugins'],
|
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',
|
key: 'server-admin',
|
||||||
label: 'Server settings',
|
label: 'Server settings',
|
||||||
|
|||||||
@@ -502,6 +502,32 @@ const BUNDLED: CatalogueEntry[] = [
|
|||||||
],
|
],
|
||||||
has: { api: true, schema: true, sidecar: true, web: true },
|
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',
|
appName: 'example',
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
|||||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||||
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
||||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||||
import { appRegistryMetas as todoBoardMetas } from '../apps/TodoBoard';
|
|
||||||
import type { QueryClient } from '@tanstack/react-query';
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
import { globalQueryKey } from 'hooks/useGlobal';
|
import { globalQueryKey } from 'hooks/useGlobal';
|
||||||
import { metasToRegistry } from './useAppRegistry';
|
import { metasToRegistry } from './useAppRegistry';
|
||||||
@@ -31,7 +30,6 @@ export const apps = [
|
|||||||
...qrTransferMetas,
|
...qrTransferMetas,
|
||||||
...sendOffMetas,
|
...sendOffMetas,
|
||||||
...commandHqMetas,
|
...commandHqMetas,
|
||||||
...todoBoardMetas,
|
|
||||||
...appStoreMetas,
|
...appStoreMetas,
|
||||||
...pluginsMetas,
|
...pluginsMetas,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
||||||
export type { PageTitleOverride } from './page-title';
|
export type { PageTitleOverride } from './page-title';
|
||||||
export * from './AppRegistry';
|
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';
|
export * from './WidgetRegistry';
|
||||||
|
|
||||||
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
|
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
|
||||||
|
|||||||
Reference in New Issue
Block a user