Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816f869535 | ||
|
|
d058cd9258 | ||
|
|
dfe837dfd0 | ||
|
|
a53c07d71f | ||
|
|
e3071f5d50 | ||
|
|
601849be11 | ||
|
|
1f75fb19af | ||
|
|
a6d4a18592 | ||
|
|
e756007551 | ||
|
|
9719e36723 | ||
|
|
912ecd04f7 | ||
|
|
dd8c7fd7bd | ||
|
|
e9b71522ed | ||
|
|
73a9b0bf51 | ||
|
|
882cb67f7a | ||
|
|
2738f799f2 | ||
|
|
82d24a8feb | ||
|
|
3d15f9fc8e | ||
|
|
990078efce | ||
|
|
53f3ca33b5 | ||
|
|
eaecf563ff | ||
|
|
321cac2e80 | ||
|
|
0ea4b822ae | ||
|
|
1da58e5231 | ||
|
|
cad0ea3c9c | ||
|
|
03638a7f62 | ||
|
|
000df7fe45 | ||
|
|
39c1122651 | ||
|
|
44f16fedee | ||
|
|
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`.
|
||||
|
||||
@@ -22,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**106 open, 33 closed**, across 34 themes.
|
||||
**108 open, 33 closed**, across 35 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
@@ -40,7 +40,8 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
| [Fleet — one command across every server](todo/fleet/) | 6 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 6 | 4 |
|
||||
| [Integrations — external tools worth wrapping as a plugin](todo/integrations/) | 1 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 7 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||
@@ -70,13 +71,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-08-29` **Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it** — [known-bugs/officer-blocks-its-event-loop-parsing-transcripts](todo/known-bugs/officer-blocks-its-event-loop-parsing-transcripts.md)
|
||||
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
|
||||
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
|
||||
- `2026-08-27` **AGENTS.md sweep — what is left after the static docs landed** — [docs-conventions/agents-md-remaining](todo/docs-conventions/agents-md-remaining.md)
|
||||
- `2026-08-27` **OpenSEO — a strong plugin candidate, checked rather than assumed** — [integrations/openseo-a-plugin-candidate](todo/integrations/openseo-a-plugin-candidate.md)
|
||||
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
|
||||
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
|
||||
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
|
||||
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
|
||||
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
|
||||
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 2026.08.29.03 — deploy report
|
||||
|
||||
**7 of 7 target hosts succeeded. `officer-dev` skipped, as required.**
|
||||
|
||||
Tag `deploy/2026.08.29.03` → `03fbb118`, matching `package.json`. Plan:
|
||||
[`deploy/2026.08.29.03.md`](2026.08.29.03.md). Every host ended at `03fbb118`.
|
||||
|
||||
Rolled out one host at a time, first release under a new watcher.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | `plugins:update` | `officer` restart_time | health | `file-browser/ls` |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `78bf63c2` | `03fbb118` | 15/15 | 4 → 5 | 401 | 200, 76 entries |
|
||||
| `fofos` | `21665ed0` | `03fbb118` | 5/5 | 45 → 46 | 401 | 200, 38 entries |
|
||||
| `macbookpro` | `78bf63c2` | `03fbb118` | none installed | 4 → 5 | 401 (:9010) | 200, 62 entries |
|
||||
| `macbruno` | `78bf63c2` | `03fbb118` | 1/1 | 7 → 8 | 401 | 200, 41 entries |
|
||||
| `production-pertento-bridge` | `78bf63c2` | `03fbb118` | 1/1 | 5 → 6 | 401 (:9010) | 200, 53 entries |
|
||||
| `edge-pertento-bridge` | `78bf63c2` | `03fbb118` | 2/2 | 6 → 7 | 401 | 200, 29 entries |
|
||||
| `deadpool-rezio-bridge` | `78bf63c2` | `03fbb118` | 2/2 | 23 → 24 | 401 | 200, 49 entries |
|
||||
|
||||
Every host: tracked tree clean, `git merge --ff-only deploy/2026.08.29.03` fast-forwarded, `officer`
|
||||
came back `online` with a moved `restart_time`, and no process on any host was left off-line. Ports
|
||||
were read from each checkout's own `.env` rather than assumed. `401` on `/api/health` is healthy per
|
||||
the plan.
|
||||
|
||||
## What was NOT done
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. The rollout ran there, inside
|
||||
`officer-claude-code`; restarting would have ended it mid-deploy. It is already at `03fbb118`, so no
|
||||
code is missing — only its `officer` process still runs the previous build. The owner restarts it
|
||||
deliberately.
|
||||
- **No `bun db:push`, no `bun install`, no SQL.** The plan states there is no schema and no dependency
|
||||
change, and says improvising either is itself the error. Nothing was queried because nothing was
|
||||
changed. **This release has no schema verification, by design.**
|
||||
- **Orphaned `todos` rows in `role_permissions` left alone**, as the plan instructs. Absence denies.
|
||||
|
||||
## Worth knowing
|
||||
|
||||
- **A commit landed on `master` mid-rollout.** `origin/master` reached `44f16fed` while hosts 4–7 were
|
||||
fetching. No host took it: every merge targeted the tag, not the branch, so the fleet is uniformly at
|
||||
`03fbb118`. `44f16fed` is undeployed and rides the next release. This is the 2026-08-23 failure not
|
||||
happening.
|
||||
- **`plugins:update` moved more than `officer`.** It restarts sidecars itself — around twelve on
|
||||
`alpha` — and added and started one each on `production-pertento-bridge` (gitea),
|
||||
`edge-pertento-bridge` (offscale) and `deadpool-rezio-bridge` (email).
|
||||
- **`fofos` has an untracked `scripts/chk.ts`.** Left in place. Untracked files are a note, not a skip.
|
||||
- **`edge-pertento-bridge` needed no special handling.** `docs/servers.md` warns it may be far behind
|
||||
after its outage; it was already at `deploy/2026.08.29.02`. That warning is now stale.
|
||||
- **`/todos` now 404s on all seven hosts.** Intended — the board is the `kanban` plugin, installed
|
||||
nowhere by default.
|
||||
@@ -0,0 +1,90 @@
|
||||
# 2026.08.29.04 — the file preview becomes a carousel, and zoom starts working again
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.03..deploy/2026.08.29.04 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --name-only deploy/2026.08.29.03..deploy/2026.08.29.04 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
|
||||
Sixteen commits; two of them are notes and one is the previous release's report.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **The file preview is a carousel.** It lists the folder it was opened from, shows thumbnails along the
|
||||
bottom, and steps with the arrow keys or the carets. Escape closes it.
|
||||
- **Zoom works on plugin screens, `/bots` and `/plugins` again.** `useAuthoredLayout` grafts back only
|
||||
what the user owns, and it knew about sizes and not about zoom — so every click was collected by
|
||||
nothing, compared equal, and dropped. The control rendered, took the click, and did nothing.
|
||||
- **An image no longer sticks.** Stepping to a new picture showed the previous one until the new one
|
||||
decoded, and the first fix for that lost a race against cached images and left the spinner up forever.
|
||||
Both are gone: loaded state is derived from which `src` actually loaded.
|
||||
- **A platform event bus**, and thumbnails warmed the moment files arrive. `files:uploaded`,
|
||||
`files:placed` and `archive:extracted` are emitted; a plugin may subscribe through its manifest. No
|
||||
plugin subscribes yet, so the dispatch path is unexercised in production — worth knowing if something
|
||||
odd appears near an upload.
|
||||
- **An embedded file browser can put its folder in a query parameter**, keep its own root crumb, and
|
||||
Back and Forward move between folders.
|
||||
- Shift+Enter renders as a line break in chat. Grid tiles have their gap back and the picture fills the
|
||||
tile. The download card's title stops escaping it.
|
||||
|
||||
## Nothing here changes behaviour anyone has to be warned about
|
||||
|
||||
No route is removed, no permission changes, nothing 404s that did not before. If a host comes back and
|
||||
something is missing, that is a fault rather than an intention — report it.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the whole rollout (dirty check,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.04`, `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 file browser and the viewer heavily, so one call per host:
|
||||
|
||||
```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. Every host was clean (`--untracked-files=no`) and six of the
|
||||
seven sit exactly on `deploy/2026.08.29.03` (`03fbb118`).
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||
| `alpha` | On `03fbb118`, clean. Ordinary target. |
|
||||
| `fofos` | **On `2738f799`, not on the tag** — it has been hand-pulled all day while this work was written against it. That commit is an ancestor of the tag, so the fast-forward is ordinary; it simply has fewer commits to travel. It is also the only host with `av-club` and `fofos-admin` installed, so `plugins:update` moves more here than anywhere else. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note, not a failure. |
|
||||
| `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` | Answered the survey this time, on `03fbb118` and clean. Ordinary target. |
|
||||
| `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.
|
||||
@@ -0,0 +1,76 @@
|
||||
# 2026.08.29.04 — deploy report
|
||||
|
||||
**7 of 7 targets succeeded. `officer-dev` skipped by design — it is the host the rollout ran on.**
|
||||
|
||||
Every host went `03fbb118` → `e9b71522` except `fofos`, which started further back at `2738f799` and
|
||||
travelled further. All eight rows below end on `e9b71522`, the commit `deploy/2026.08.29.04` points at.
|
||||
|
||||
Nothing failed. Nothing was left half-applied. No host was unreachable — `macbookpro` was awake.
|
||||
|
||||
## What ran, per host
|
||||
|
||||
The same four steps everywhere: tracked-only dirty check, `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.08.29.04`, `bun run plugins:update`, then `pm2 restart officer` and
|
||||
nothing else. The plan asked for no `bun install` and no `bun db:push`, and neither was run.
|
||||
|
||||
| Host | HEAD before | HEAD after | plugins | `officer` restarts | health | `ls` |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | **SKIPPED — the rollout ran here** | — | — | — |
|
||||
| `alpha` | `03fbb118` | `e9b71522` | 15/15 | 5 → 6 | 401 | 200, 77 entries |
|
||||
| `fofos` | `2738f799` | `e9b71522` | 5/5 | 93 → 94 | 401 | 200, 39 entries |
|
||||
| `macbookpro` | `03fbb118` | `e9b71522` | none installed | 5 → 6 | 401 (9010) | 200, 62 entries |
|
||||
| `macbruno` | `03fbb118` | `e9b71522` | 1/1 | 8 → 9 | 401 | 200, 41 entries |
|
||||
| `production-pertento-bridge` | `03fbb118` | `e9b71522` | 1/1 | 6 → 7 | 401 (9010) | 200, 53 entries |
|
||||
| `edge-pertento-bridge` | `03fbb118` | `e9b71522` | 2/2 | 7 → 8 | 401 | 200, 29 entries |
|
||||
| `deadpool-rezio-bridge` | `03fbb118` | `e9b71522` | 2/2 | 24 → 25 | 401 | 200, 49 entries |
|
||||
|
||||
`plugins:update` exited 0 on every host that has plugins. `401` on `/api/health` is healthy — the plan
|
||||
says so and every host gave the same answer.
|
||||
|
||||
## The file browser check
|
||||
|
||||
This release rewrote the file browser and the viewer, so the plan asked for one signed
|
||||
`/api/file-browser/ls` call per host. **Seven hosts, seven `200`s**, each with a plausible entry count
|
||||
for that machine. The counts differ because the homes differ; none was zero and none was a 500.
|
||||
|
||||
Signed as user id 1 with that host's own owner, which is not the same address everywhere —
|
||||
`conceiba@gmail.com` on `macbruno` and `brunorezio@gmail.com` on `deadpool-rezio-bridge`. Read from each
|
||||
host's own `users` table rather than assumed.
|
||||
|
||||
## Schema
|
||||
|
||||
**No schema verification was performed, because this release changes no schema.** The plan states that
|
||||
and shows the two `git diff --name-only` commands behind the claim. No `bun db:push` step exists in it
|
||||
and none was improvised.
|
||||
|
||||
The plugin sidecars did apply their own schemas during `plugins:update` — `music` on `alpha`,
|
||||
`fofos-admin` and `proxy-manager` on `fofos`, `proxy-manager` on `macbruno`, `offscale` on
|
||||
`edge-pertento-bridge`, `email` on `deadpool-rezio-bridge`. Each printed its own
|
||||
`schema: applied … (N table(s) verified)`. That belongs to the plugin updater, not to this release.
|
||||
|
||||
## More restarted than `officer`
|
||||
|
||||
`plugins:update` restarts a sidecar whenever it moves a plugin, before the plan's own restart. Naming
|
||||
only `officer` would understate what moved, so: `officer-music` on `alpha`, `officer-av-club` on
|
||||
`fofos`, `officer-gitea` on `production-pertento-bridge`, `officer-gitea` and `officer-offscale` on
|
||||
`edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. Every one came back `online`.
|
||||
|
||||
`ecosystem.config.cjs` was never restarted anywhere. No vault was touched.
|
||||
|
||||
## What I did not do
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. It already sits on this code, and
|
||||
restarting it would have ended the session writing this report. The owner restarts it deliberately,
|
||||
last, once happy with the rest.
|
||||
- **`fofos` has an untracked `scripts/chk.ts`.** Untracked is not dirty; the fast-forward could not touch
|
||||
it and it was left exactly where it was. A note, not a skip.
|
||||
- **`production-pertento-bridge` prints an nvm warning** on every interactive shell — its `~/.npmrc` has a
|
||||
`globalconfig`/`prefix` setting nvm objects to. It is noise, it broke nothing, and it is not this
|
||||
release's doing. Worth clearing at some point so it stops appearing in deploy output.
|
||||
- **No `--force` anywhere**, no dependency install, no database write.
|
||||
|
||||
## One thing worth knowing
|
||||
|
||||
The plan flags a **platform event bus** — `files:uploaded`, `files:placed`, `archive:extracted` — that no
|
||||
plugin subscribes to yet. The dispatch path therefore ships unexercised. If something odd shows up near an
|
||||
upload on any of these hosts, that is the first place to look.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.30.01 — the file browser's clipboard moves to the server, and plugins reach further in
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.04..deploy/2026.08.30.01 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --name-only deploy/2026.08.29.04..deploy/2026.08.30.01 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exists in this plan, and neither should be improvised.**
|
||||
Eleven commits; one of them is the previous release's report.
|
||||
|
||||
The clipboard is per-user state and it needs no migration — it is a key in `user_state`, a JSONB column
|
||||
that already exists, written by the same `PATCH /user/state` the dock and the view mode already use.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **The file browser's clipboard lives on the server.** It was `useState`, so it existed in one tab and
|
||||
died with it: copying in one window and pasting in another did nothing, and a reload lost a pending cut
|
||||
with no sign that it had. It is now a key in `user_state`, per user rather than per browser — cutting on
|
||||
a desktop and pasting on a phone is one gesture across two screens.
|
||||
- **Files can be dragged between windows.** A custom MIME type on the DataTransfer survives between tabs
|
||||
and windows of the same browser, so a row can be dragged from one Officer window into a folder in
|
||||
another. It cannot cross different browsers or exchange with Finder; that is a property of the platform,
|
||||
not a gap to report.
|
||||
- **A window re-reads its folder and the clipboard when it comes forward**, so the window a file was cut
|
||||
from stops listing it. And **re-reading no longer blanks the list** — the spinner is now for a folder
|
||||
that cannot yet be shown, so a refresh after a paste or a delete leaves the rows and the scroll position
|
||||
where they were.
|
||||
- **A plugin can put an action in the file browser's context menu**, and gets an authenticated client to
|
||||
use in it. Three commits, because the first two shipped the registration without forwarding it into the
|
||||
menu, and then without a way to call the API.
|
||||
- **The file browser can be a file picker** — `onPickFile` turns opening a file into handing it back,
|
||||
rather than a second component that reimplements a listing, a breadcrumb and a sort.
|
||||
- **A plugin can hand the agent its own MCP server**, declared in its manifest.
|
||||
- **A plugin may not bring its own React.** `npmDependencies` strips and then deletes `react` and
|
||||
`react-dom` after install: a plugin's peer copy landing in `plugins/node_modules` gives the app two
|
||||
Reacts and every hook throws.
|
||||
|
||||
## Nothing here changes behaviour anyone has to be warned about
|
||||
|
||||
No route is removed, no permission changes, nothing 404s that did not before. The clipboard is additive:
|
||||
a browser that has never set one reads `null` and behaves exactly as before.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the whole rollout (dirty check,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.30.01`, `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 file browser heavily — the listing loader, the clipboard and the row markup —
|
||||
so one call per host:
|
||||
|
||||
```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-30 while writing this. Every host answered, and every one is clean
|
||||
(`--untracked-files=no`).
|
||||
|
||||
| Host | HEAD at survey | Note |
|
||||
| --- | --- | --- |
|
||||
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||
| `alpha` | `e9b71522` | Exactly on the previous tag. Ordinary target. |
|
||||
| `fofos` | `601849be` | **Ahead of the previous tag, behind this one.** It has been hand-pulled all day while the fofos plugin work was written against it. That commit is an ancestor of this tag, so the fast-forward is ordinary — it simply travels fewer commits. It is also the only host with `av-club` and `fofos-admin` installed, so `plugins:update` moves more here than anywhere else. |
|
||||
| `macbookpro` | `e9b71522` | macOS, port **9010**, a laptop. Answered the survey; if it has slept since, unreachable is a SKIP with a note, not a failure. |
|
||||
| `macbruno` | `e9b71522` | macOS, login **`brunoalcadapadezmonteirodaconceicao`**. |
|
||||
| `production-pertento-bridge` | `e9b71522` | Port **9010**. |
|
||||
| `edge-pertento-bridge` | `e9b71522` | Ordinary target. |
|
||||
| `deadpool-rezio-bridge` | `e9b71522` | 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.30.01",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -8,7 +8,17 @@ import { useInitialData } from '@/state/useInitialData';
|
||||
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
|
||||
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
|
||||
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
|
||||
import { plugins as installedPlugins } from './Plugins.gen';
|
||||
import { plugins as installedPlugins, pluginFileActions } from './Plugins.gen';
|
||||
import { registerFileActions } from 'officerdev';
|
||||
|
||||
/*
|
||||
Plugin actions for the file browser's context menu, handed over once at module load.
|
||||
|
||||
Injected rather than imported by the browser itself: `Plugins.gen.tsx` is generated INTO this app, and
|
||||
the file browser lives in a workspace package this app depends on. Importing it there would point the
|
||||
dependency backwards and make that package unusable outside Officer's own frontend.
|
||||
*/
|
||||
registerFileActions(pluginFileActions);
|
||||
|
||||
/**
|
||||
* `/plugins` → `/marketplace`, KEEPING the query string.
|
||||
@@ -107,7 +117,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';
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join } from 'node:path';
|
||||
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
||||
import { assertPermissionTotality } from './servers/permissions/totality';
|
||||
import { bootstrapCaldav } from './servers/caldav-bootstrap';
|
||||
import { registerThumbnailWarmer } from './servers/events/warm-thumbnails';
|
||||
import { registerPluginHooks } from './servers/events/plugin-hooks';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
|
||||
import { listPluginRootClaims } from './servers/plugins/public-paths';
|
||||
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
|
||||
@@ -252,6 +254,19 @@ if (secured.repaired.length) {
|
||||
}
|
||||
for (const path of secured.failed) console.error(`[boot] could not close ${path} — check its owner and mode`);
|
||||
|
||||
/*
|
||||
Subscribe to platform events before anything can emit one.
|
||||
|
||||
Both are pure registration — no I/O, nothing that can fail — so they sit above the plugin mount rather
|
||||
than inside its try. If either threw, the server not starting would be the correct outcome, and
|
||||
wrapping them in the plugin block's `catch` would hide that.
|
||||
|
||||
The thumbnail warmer is core subscribing to core: the cache belongs to the platform, so the work has to
|
||||
happen here. Plugin hooks are the general mechanism, resolved per event from whatever is installed.
|
||||
*/
|
||||
registerThumbnailWarmer();
|
||||
registerPluginHooks();
|
||||
|
||||
// Mount whatever is installed, BEFORE serve().
|
||||
//
|
||||
// Without this a restart silently unmounts every plugin: `honoServer` is built with none at import,
|
||||
|
||||
@@ -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,8 @@ 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';
|
||||
import { emit } from '../../events/bus';
|
||||
|
||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
try {
|
||||
@@ -204,6 +206,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 +236,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)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -315,6 +334,7 @@ router.post('/upload', async (ctx) => {
|
||||
going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not
|
||||
create is a worse trade than a fragment somebody can see.
|
||||
*/
|
||||
const written: string[] = [];
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
const filePath = resolve(targetDir, file.name);
|
||||
@@ -322,8 +342,19 @@ router.post('/upload', async (ctx) => {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
|
||||
await writeAtomic(filePath, file);
|
||||
written.push(filePath);
|
||||
}
|
||||
|
||||
/*
|
||||
Announced AFTER the writes and BEFORE the response, which is the only ordering that is correct.
|
||||
|
||||
After, because a subscriber reads these from disk and they have to be there. Before returning is
|
||||
fine because `emit` schedules and returns — the upload does not wait for anybody. What it must never
|
||||
become is an await: a subscriber thumbnailing a HEIC would add five seconds to an upload that has
|
||||
already finished.
|
||||
*/
|
||||
emit('files:uploaded', { paths: written, email: user.email });
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -375,12 +406,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 +454,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);
|
||||
@@ -1150,6 +1248,25 @@ router.post('/extract', async (ctx) => {
|
||||
throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed');
|
||||
}
|
||||
|
||||
/*
|
||||
What actually came out, walked once rather than guessed from the archive listing.
|
||||
|
||||
Bounded, because an archive can hold a hundred thousand files and this runs on the request's way
|
||||
out. The warmer has its own cap too; this one keeps the WALK from being the expensive part.
|
||||
*/
|
||||
const unpacked: string[] = [];
|
||||
const collect = async (dir: string, depth = 0): Promise<void> => {
|
||||
if (depth > 8 || unpacked.length >= 500) return;
|
||||
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
||||
if (unpacked.length >= 500) return;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) await collect(full, depth + 1);
|
||||
else unpacked.push(full);
|
||||
}
|
||||
};
|
||||
await collect(finalDest);
|
||||
emit('archive:extracted', { paths: unpacked, email: user.email });
|
||||
|
||||
const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
|
||||
return ctx.json({ extractedPath });
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -127,6 +138,19 @@ const release = () => {
|
||||
* file another chance after somebody has installed a codec or replaced the file. Bounded, because a
|
||||
* long-lived process browsing a lot of broken media should not grow without limit.
|
||||
*/
|
||||
/**
|
||||
* Builds in flight, by cache key — so the same thumbnail is never made twice at once.
|
||||
*
|
||||
* Two callers want the same file all the time now: the warmer starts on upload, and the browser asks
|
||||
* for it a moment later. Without this they both decode the same 4032x3024 HEIC, take a slot each from a
|
||||
* limit of four, and finish at the same time having done the work twice — which turns a warmer meant to
|
||||
* help into one competing with the person waiting.
|
||||
*
|
||||
* Keyed on the same string the cache is, so it deduplicates exactly what would collide: same file, same
|
||||
* size, same content. Removed in a `finally`, or one failure would wedge that key until a restart.
|
||||
*/
|
||||
const inFlight = new Map<string, Promise<ThumbnailResult>>();
|
||||
|
||||
const failed = new Set<string>();
|
||||
const FAILED_LIMIT = 5_000;
|
||||
|
||||
@@ -205,6 +229,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' };
|
||||
@@ -224,35 +258,64 @@ export async function buildThumbnail(
|
||||
const cached = await stat(cachePath).catch(() => null);
|
||||
if (cached?.isFile() && cached.size > 0) return { ok: true, path: cachePath };
|
||||
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await acquire();
|
||||
try {
|
||||
/*
|
||||
// Somebody else is already making this one. Wait for theirs rather than starting a second.
|
||||
const running = inFlight.get(key);
|
||||
if (running) return running;
|
||||
|
||||
const work = (async (): Promise<ThumbnailResult> => {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await acquire();
|
||||
try {
|
||||
/*
|
||||
`force_original_aspect_ratio=decrease` fits inside the box without cropping, and never enlarges
|
||||
past the source — a 40px icon blown up to 256 is worse than a small one. `-2` on the odd dimension
|
||||
keeps it even, which the JPEG encoder requires for chroma subsampling.
|
||||
*/
|
||||
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
|
||||
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
|
||||
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
|
||||
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
|
||||
|
||||
let ok: boolean;
|
||||
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.
|
||||
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
|
||||
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
|
||||
// broken file. Take the first frame instead before giving up on it.
|
||||
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
|
||||
let ok: boolean;
|
||||
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.
|
||||
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
|
||||
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
|
||||
// broken file. Take the first frame instead before giving up on it.
|
||||
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
/*
|
||||
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' };
|
||||
}
|
||||
|
||||
return { ok: true, path: cachePath };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
})();
|
||||
|
||||
if (!ok) {
|
||||
rememberFailure(key);
|
||||
return { ok: false, reason: 'could not read an image from this file' };
|
||||
}
|
||||
|
||||
return { ok: true, path: cachePath };
|
||||
inFlight.set(key, work);
|
||||
try {
|
||||
return await work;
|
||||
} finally {
|
||||
release();
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { emit, listenerCount, on } from './bus';
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
test('a listener is told, with the payload', async () => {
|
||||
const seen: string[][] = [];
|
||||
const off = on('files:uploaded', (p) => void seen.push(p.paths));
|
||||
emit('files:uploaded', { paths: ['/a', '/b'], email: 'x@y' });
|
||||
await settle();
|
||||
expect(seen).toEqual([['/a', '/b']]);
|
||||
off();
|
||||
});
|
||||
|
||||
test('unsubscribing stops it', async () => {
|
||||
let calls = 0;
|
||||
const off = on('files:uploaded', () => void calls++);
|
||||
off();
|
||||
emit('files:uploaded', { paths: ['/a'], email: 'x@y' });
|
||||
await settle();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
// The property the whole design rests on: a subscriber must never be able to fail the operation that
|
||||
// triggered it. An upload is already written and answered by the time this runs.
|
||||
test('emit does not throw when a listener throws synchronously', async () => {
|
||||
const off = on('files:uploaded', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(() => emit('files:uploaded', { paths: [], email: 'x@y' })).not.toThrow();
|
||||
await settle();
|
||||
off();
|
||||
});
|
||||
|
||||
test('emit does not throw when a listener rejects', async () => {
|
||||
const off = on('files:uploaded', async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(() => emit('files:uploaded', { paths: [], email: 'x@y' })).not.toThrow();
|
||||
await settle();
|
||||
off();
|
||||
});
|
||||
|
||||
// One bad subscriber must not stop the others — they are independent, not a chain.
|
||||
test('a failing listener does not stop the next one', async () => {
|
||||
let reached = false;
|
||||
const offBad = on('archive:extracted', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const offGood = on('archive:extracted', () => void (reached = true));
|
||||
emit('archive:extracted', { paths: [], email: 'x@y' });
|
||||
await settle();
|
||||
expect(reached).toBe(true);
|
||||
offBad();
|
||||
offGood();
|
||||
});
|
||||
|
||||
test('emit returns immediately rather than awaiting listeners', () => {
|
||||
const off = on('files:placed', async () => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
});
|
||||
const started = performance.now();
|
||||
emit('files:placed', { paths: [], email: 'x@y' });
|
||||
expect(performance.now() - started).toBeLessThan(50);
|
||||
off();
|
||||
});
|
||||
|
||||
test('an event with no listeners is a no-op', () => {
|
||||
expect(listenerCount('files:placed')).toBe(0);
|
||||
expect(() => emit('files:placed', { paths: [], email: 'x@y' })).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Platform events, and who is told about them.
|
||||
*
|
||||
* ── A NAMED LIST, not an event bus ──
|
||||
*
|
||||
* The type below is closed on purpose. An open bus — `emit(anyString, anyPayload)` — becomes an API
|
||||
* nobody can change: the moment a plugin listens for `file:thing`, that string and the shape beside it
|
||||
* are a contract, and there is no list anywhere saying so. A closed union means adding an event is a
|
||||
* deliberate edit here, and removing one fails to compile rather than failing quietly at runtime.
|
||||
*
|
||||
* ── Every payload is absolute paths and an owner ──
|
||||
*
|
||||
* Not a file's contents, not a request, not a user object. A subscriber that needs more should read it
|
||||
* from the path, because by the time this fires the work is done and the disk is the truth. Passing
|
||||
* more would be inventing a second description of something already on the filesystem.
|
||||
*
|
||||
* ── Emitting NEVER blocks and NEVER throws ──
|
||||
*
|
||||
* This is fired after a response is decided, and the response must not wait for it. An upload that
|
||||
* takes five extra seconds because a subscriber is thumbnailing a HEIC is an upload somebody reports as
|
||||
* broken; a failing subscriber must not turn a completed upload into an error the caller sees.
|
||||
*
|
||||
* So `emit` returns immediately and swallows everything. That is deliberate rather than lazy: a
|
||||
* subscriber's failure is ITS failure, logged with its name, and the operation that triggered it has
|
||||
* already succeeded.
|
||||
*/
|
||||
|
||||
/** Absolute paths that now exist, and whose account they belong to. */
|
||||
export type FileEventPayload = {
|
||||
/** Absolute. Files, not directories — a subscriber wanting the folder can take the dirname. */
|
||||
paths: string[];
|
||||
/** The account the operation was performed for. */
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type PlatformEvents = {
|
||||
/** One or more files arrived through `POST /file-browser/upload`. */
|
||||
'files:uploaded': FileEventPayload;
|
||||
/** An archive was unpacked. `paths` is what came out of it. */
|
||||
'archive:extracted': FileEventPayload;
|
||||
/** Files were copied or moved to a new location. `paths` is where they are NOW. */
|
||||
'files:placed': FileEventPayload;
|
||||
};
|
||||
|
||||
export type EventName = keyof PlatformEvents;
|
||||
|
||||
type Listener<K extends EventName> = (payload: PlatformEvents[K]) => void | Promise<void>;
|
||||
|
||||
const listeners = new Map<EventName, Set<Listener<EventName>>>();
|
||||
|
||||
/**
|
||||
* Subscribe. Returns the unsubscribe, which nothing currently calls — core's own subscribers live for
|
||||
* the process — but returning it means a future one can be torn down without changing this.
|
||||
*/
|
||||
export function on<K extends EventName>(event: K, listener: Listener<K>): () => void {
|
||||
const set = listeners.get(event) ?? new Set();
|
||||
set.add(listener as Listener<EventName>);
|
||||
listeners.set(event, set);
|
||||
return () => set.delete(listener as Listener<EventName>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell everyone, and get out of the way.
|
||||
*
|
||||
* Synchronous return, work scheduled after. `queueMicrotask` rather than awaiting: the caller is a
|
||||
* request handler about to write a response, and the point is that it does not wait.
|
||||
*/
|
||||
export function emit<K extends EventName>(event: K, payload: PlatformEvents[K]): void {
|
||||
const set = listeners.get(event);
|
||||
if (!set?.size) return;
|
||||
|
||||
for (const listener of set) {
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
// A listener returning a rejected promise is the common case — most of them are async — and it
|
||||
// needs catching separately from a synchronous throw.
|
||||
void Promise.resolve(listener(payload)).catch((err: unknown) => {
|
||||
console.error(`[events] ${event} listener failed —`, err instanceof Error ? err.message : err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[events] ${event} listener threw —`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** For tests: how many listeners an event has. Nothing in the server should need this. */
|
||||
export const listenerCount = (event: EventName): number => listeners.get(event)?.size ?? 0;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { snapshotPlugins } from '../plugins/mount';
|
||||
import { sidecarPort } from '../sidecar-registry';
|
||||
import { on, type EventName, type FileEventPayload } from './bus';
|
||||
|
||||
/**
|
||||
* Letting plugins subscribe to platform events.
|
||||
*
|
||||
* ── The same shape as `fileTypes`, deliberately ──
|
||||
*
|
||||
* A manifest declares `hooks: { 'files:uploaded': '/v1/on-upload' }`. Core resolves that at emit time
|
||||
* from whatever is installed and enabled, POSTs the payload to the plugin's own sidecar, and knows
|
||||
* nothing about which plugin it is. Disable the plugin and the hook stops firing, with nothing to
|
||||
* unregister — the same property that makes a claimed file type disappear cleanly.
|
||||
*
|
||||
* ── Resolved at EMIT time, not at boot ──
|
||||
*
|
||||
* The obvious design registers every plugin's hooks once at startup. It is wrong here: plugins are
|
||||
* installed, updated, enabled and disabled while the server runs, and a registration made at boot
|
||||
* describes a machine that has since changed. Reading the current set costs one database call on an
|
||||
* event that has just done filesystem work, which is not the expensive part of anything.
|
||||
*
|
||||
* ── A hook cannot fail the operation that triggered it ──
|
||||
*
|
||||
* `emit` already swallows, and this adds nothing that could throw past it. The upload succeeded before
|
||||
* anybody was told. A plugin that is down, slow, or returns 500 gets a line in the log and no more —
|
||||
* because the alternative is an upload that reports failure for work that has nothing to do with it.
|
||||
*/
|
||||
|
||||
/** Long enough for real work, short enough that a wedged plugin does not hold a socket all day. */
|
||||
const HOOK_TIMEOUT_MS = 120_000;
|
||||
|
||||
async function dispatch(event: EventName, payload: FileEventPayload): Promise<void> {
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
try {
|
||||
({ states } = await snapshotPlugins());
|
||||
} catch {
|
||||
return; // a database that cannot be read is a louder problem elsewhere
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
|
||||
const path = state.plugin.manifest?.hooks?.[event];
|
||||
if (!path) continue;
|
||||
|
||||
const port = sidecarPort(state.plugin.appName);
|
||||
// Declared but not running — restarting, or never started. Skipped silently: a plugin being down is
|
||||
// not an error of the upload that happened to coincide with it.
|
||||
if (!port) continue;
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-officer-user': payload.email },
|
||||
body: JSON.stringify({ event, ...payload }),
|
||||
signal: AbortSignal.timeout(HOOK_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[hooks] ${state.plugin.appName} ${event} -> ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[hooks] ${state.plugin.appName} ${event} failed —`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire plugin dispatch into every event. Called once, from the server's own start-up.
|
||||
*
|
||||
* One listener per event rather than one per plugin: which plugins care is resolved when the event
|
||||
* fires, so there is nothing to add or remove as they come and go.
|
||||
*/
|
||||
export function registerPluginHooks(): void {
|
||||
const events: EventName[] = ['files:uploaded', 'archive:extracted', 'files:placed'];
|
||||
for (const event of events) on(event, (payload) => dispatch(event, payload));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../data-path';
|
||||
import { buildThumbnail, thumbnailKind } from '../api/file-browser/thumbnails';
|
||||
import { pluginFileTypes, sidecarFetch } from '../api/file-browser/plugin-file-types';
|
||||
import { on, type FileEventPayload } from './bus';
|
||||
|
||||
/**
|
||||
* Make the thumbnails before anybody asks for them.
|
||||
*
|
||||
* ── Why this is worth doing at all ──
|
||||
*
|
||||
* Thumbnails are generated on demand and cached. That is right — most files are never looked at — but
|
||||
* it means the first person to open a folder waits for the work. For a jpeg that is 40ms nobody
|
||||
* notices. For a HEIC it is four and a half seconds each, because ImageMagick decodes the whole
|
||||
* 4032x3024 frame before sharp sees it. A folder of photographs from a phone is thirteen seconds of
|
||||
* staring at file icons.
|
||||
*
|
||||
* An upload is the one moment the platform KNOWS which files are new, so it is the moment to do it.
|
||||
*
|
||||
* ── This is core subscribing to core, and that is the point ──
|
||||
*
|
||||
* The thumbnail cache belongs to the platform: keyed on its scheme, read by its route. A plugin doing
|
||||
* this work would produce them somewhere the file browser never looks. So the warmer lives here and
|
||||
* uses the same `buildThumbnail` the request path uses — which means a HEIC still warms through
|
||||
* av-club, via the `fileTypes` claim, without this file knowing that plugin exists.
|
||||
*
|
||||
* ── ONE size, and it is the one both views ask for ──
|
||||
*
|
||||
* The cache key includes the size. Warming 128 when the browser asks for 256 is work that produces
|
||||
* nothing anybody reads, so this must stay equal to what `FileItem` requests. Both views were made to
|
||||
* ask for 256 for exactly this reason.
|
||||
*/
|
||||
|
||||
const WARM_SIZE = 256;
|
||||
|
||||
/**
|
||||
* How many to warm at once.
|
||||
*
|
||||
* Lower than the request path's own limit on purpose. This is background work competing with a person
|
||||
* who is actually waiting: somebody opening a different folder while an upload warms must not queue
|
||||
* behind it. `buildThumbnail` has its own cap too, so this is a second, tighter gate rather than the
|
||||
* only one.
|
||||
*/
|
||||
const WARM_CONCURRENCY = 2;
|
||||
|
||||
/** Stop after this many. An unpacked archive can hold thousands, and warming all of them is not a favour. */
|
||||
const WARM_LIMIT = 200;
|
||||
|
||||
async function warm(payload: FileEventPayload): Promise<void> {
|
||||
const claimed = await pluginFileTypes();
|
||||
|
||||
// Only what will actually be asked for: core's own types plus whatever a plugin claims. Anything else
|
||||
// has no thumbnail to make and would be a spawn per file for nothing.
|
||||
const targets = payload.paths
|
||||
.filter((path) => {
|
||||
const name = path.slice(path.lastIndexOf('/') + 1);
|
||||
return !!thumbnailKind(name) || claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
|
||||
})
|
||||
.slice(0, WARM_LIMIT);
|
||||
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const userDataDir = join(DATA_PATH, payload.email);
|
||||
let index = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (index < targets.length) {
|
||||
const path = targets[index++]!;
|
||||
const name = path.slice(path.lastIndexOf('/') + 1);
|
||||
const kind = thumbnailKind(name);
|
||||
const claim = kind ? null : claimed.get(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
|
||||
|
||||
/*
|
||||
Exactly what the request path does, including its cache and its remembered failures — so a file
|
||||
warmed here is a cache HIT when the browser asks, and a file that cannot be thumbnailed is not
|
||||
attempted twice.
|
||||
*/
|
||||
await buildThumbnail(
|
||||
path,
|
||||
userDataDir,
|
||||
kind ?? 'image',
|
||||
WARM_SIZE,
|
||||
claim?.thumbnail
|
||||
? async () => {
|
||||
const res = await sidecarFetch(
|
||||
`${claim.thumbnail}?path=${encodeURIComponent(path)}&size=${WARM_SIZE}`,
|
||||
payload.email,
|
||||
);
|
||||
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
|
||||
}
|
||||
: undefined,
|
||||
).catch(() => {
|
||||
// Already logged by the builder where it matters. A warm that fails costs nothing: the request
|
||||
// path will try again and answer honestly.
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, targets.length) }, worker));
|
||||
}
|
||||
|
||||
/** Called once from start-up. */
|
||||
export function registerThumbnailWarmer(): void {
|
||||
on('files:uploaded', warm);
|
||||
on('archive:extracted', warm);
|
||||
on('files:placed', warm);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -98,6 +98,8 @@ function findWeb(
|
||||
screens: [
|
||||
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout },
|
||||
],
|
||||
// Optional and unannounced — presence is the declaration, as it is for panels and layout.
|
||||
fileActions: fileOrNull(join(webDir, 'file-actions.ts')),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -157,7 +159,7 @@ function findWeb(
|
||||
}
|
||||
|
||||
if (problems.length) return { web: null, error: `${appName}: ${problems.join('; ')}.` };
|
||||
return { web: { screens } };
|
||||
return { web: { screens, fileActions: fileOrNull(join(webDir, 'file-actions.ts')) } };
|
||||
}
|
||||
|
||||
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
|
||||
|
||||
@@ -66,6 +66,24 @@ export function generatePluginsModule(states: PluginState[]): string {
|
||||
// it always did; caldav has two, so it emits two routes, two layout keys and two dock tiles from one
|
||||
// install. The alias counter runs over screens for that reason — two screens of one plugin must not
|
||||
// share an import name.
|
||||
/*
|
||||
File-browser actions, one block per PLUGIN rather than per screen.
|
||||
|
||||
The context menu is global — it is the same menu wherever the browser is mounted — so an action
|
||||
belongs to the plugin, not to one of its screens. A plugin with two screens contributes its actions
|
||||
once.
|
||||
*/
|
||||
const actionImports: string[] = [];
|
||||
const actionEntries: string[] = [];
|
||||
let a = 0;
|
||||
for (const { plugin } of withWeb) {
|
||||
if (!plugin.web?.fileActions) continue;
|
||||
const alias = `Actions${a}`;
|
||||
a += 1;
|
||||
actionImports.push(`import { fileActions as ${alias} } from '${specifier(plugin.web.fileActions)}';`);
|
||||
actionEntries.push(` ...${alias},`);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (const { plugin } of withWeb) {
|
||||
for (const screen of plugin.web!.screens) {
|
||||
@@ -85,8 +103,19 @@ export function generatePluginsModule(states: PluginState[]): string {
|
||||
}
|
||||
|
||||
const body = `${HEADER}
|
||||
import type { AppRegistryMeta, LayoutNode } from 'officerdev';
|
||||
import type { AppRegistryMeta, FileAction, LayoutNode } from 'officerdev';
|
||||
${imports.join('\n')}
|
||||
${actionImports.join('\n')}
|
||||
|
||||
/**
|
||||
* Context-menu actions plugins contribute to the file browser.
|
||||
*
|
||||
* Flat and global: the menu is the same wherever the browser is mounted, so these are not scoped to a
|
||||
* screen. Each decides for itself whether it applies to what was selected.
|
||||
*/
|
||||
export const pluginFileActions: FileAction[] = [
|
||||
${actionEntries.join('\n')}
|
||||
];
|
||||
|
||||
export type GeneratedPlugin = {
|
||||
/** The plugin this screen belongs to. Two screens of one plugin share it. */
|
||||
|
||||
@@ -477,6 +477,77 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Platform events this plugin wants to be told about, and where to tell it.
|
||||
*
|
||||
* `{ 'files:uploaded': '/v1/on-upload' }` — the key is an event from `servers/events/bus.ts`, the
|
||||
* value a path on this plugin's own sidecar. Core POSTs `{ event, paths, email }` to it after the
|
||||
* operation has already succeeded.
|
||||
*
|
||||
* **A hook cannot fail the thing that triggered it.** The upload is done and answered before anybody
|
||||
* is told; a plugin that is down, slow or erroring gets a line in the log. Nothing here is a way to
|
||||
* veto or modify an operation, and that is deliberate — a hook that could refuse an upload would make
|
||||
* every upload's reliability the worst of every installed plugin's.
|
||||
*/
|
||||
hooks?: Partial<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* An MCP server this plugin serves, offered to the agent as its own.
|
||||
*
|
||||
* ── Why a path and not a URL ──
|
||||
*
|
||||
* The path is under this plugin's own mount, so `/mcp` becomes `/api/<plugin>/mcp` on THIS Officer.
|
||||
* A URL would let a plugin point the agent at any host, and the agent's config carries a token that
|
||||
* signs as the owner — so the one field that must not be free-form is the destination.
|
||||
*
|
||||
* ── Why this exists rather than wrapping the tools ──
|
||||
*
|
||||
* A plugin that fronts something with its own MCP server — openseo is the first — can either
|
||||
* reimplement that vocabulary as Officer tools or hand the real one over. Reimplementing means a
|
||||
* second copy that goes stale, and it fails silently: a tool exists upstream that Officer cannot see.
|
||||
* Handing it over costs this field.
|
||||
*
|
||||
* What a wrapper would have bought — authentication, logging, refusal — is already true of the path,
|
||||
* because the agent reaches it through Officer's own API with Officer's own gate in front. Wrapping
|
||||
* would buy it a second time.
|
||||
*/
|
||||
mcp?: {
|
||||
/** Under the plugin's mount. `/mcp` on a plugin mounted at `/api/openseo` is `/api/openseo/mcp`. */
|
||||
path: string;
|
||||
};
|
||||
|
||||
osDependencies?: OsDependency[];
|
||||
|
||||
/**
|
||||
@@ -565,7 +636,20 @@ export type DiscoveredPlugin = {
|
||||
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
|
||||
* with a partial frontend.
|
||||
*/
|
||||
web: { screens: DiscoveredScreen[] } | null;
|
||||
web: {
|
||||
screens: DiscoveredScreen[];
|
||||
/**
|
||||
* `web/file-actions.ts`, when the plugin has one.
|
||||
*
|
||||
* Presence is the declaration, as with `panels.ts` — the manifest says nothing about it because
|
||||
* there is nothing a human choice adds: an action carries its own label and decides for itself
|
||||
* whether it applies.
|
||||
*
|
||||
* Not per screen. The file browser's context menu is the same menu wherever it is mounted, so an
|
||||
* action belongs to the plugin.
|
||||
*/
|
||||
fileActions: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PLATFORM_DIR } from '../data-path';
|
||||
import type { DiscoveredPlugin } from './manifest';
|
||||
@@ -104,7 +105,33 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
|
||||
return { ok: true, ran: false, output: '', conflicts };
|
||||
}
|
||||
|
||||
const body = {
|
||||
/*
|
||||
Packages the PLATFORM owns, which a plugin may never bring its own copy of.
|
||||
|
||||
React holds module-level state — the hook dispatcher — so two copies in one page is not a duplicated
|
||||
dependency, it is a broken one. The second instance's `useRef` reads a null dispatcher and every panel
|
||||
fails to render with "Cannot read properties of null (reading 'useRef')", which names nothing that
|
||||
would lead somebody here.
|
||||
|
||||
It is easy to arrive at by accident and hard to diagnose: nobody asks for React. `@tiptap/react`
|
||||
declares it as a PEER, bun installs peers, and the copy lands in plugins/node_modules where it wins
|
||||
resolution for everything under it. That took the fofos-admin screen down the moment tiptap arrived.
|
||||
|
||||
Stripped rather than pinned. Matching the platform's version would still be two copies, and two copies
|
||||
is the whole problem — bun resolves up the tree, so removing them entirely is what makes a plugin use
|
||||
the one the platform already loaded.
|
||||
|
||||
Removed AFTER the install as well as before it, and the second is the one that does the work: no
|
||||
plugin declares React, so there is nothing to strip from the merged list. It arrives as a PEER of
|
||||
something that does, which bun installs and the generated file never mentions.
|
||||
*/
|
||||
const PLATFORM_OWNED = ['react', 'react-dom'];
|
||||
|
||||
for (const owned of PLATFORM_OWNED) {
|
||||
if (owned in merged) delete merged[owned];
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: '@officer/plugin-dependencies',
|
||||
private: true,
|
||||
'//': 'GENERATED from every plugin manifest by src/servers/plugins/npm-deps.ts. Not edited, not committed.',
|
||||
@@ -117,7 +144,26 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
|
||||
const proc = Bun.spawn(['bun', 'install'], { cwd: PLUGINS_DIR, stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, ran: true, output: `${out}${err}`.trim(), conflicts };
|
||||
|
||||
/*
|
||||
Evict the platform's singletons, however they got here.
|
||||
|
||||
This is the step that matters. Stripping the merged list above only covers a plugin ASKING for
|
||||
React, and none do — it arrives as a peer of something that does, so bun installs it without the
|
||||
generated file ever naming it. Deleting it afterwards is crude and it is deterministic, which is
|
||||
the right trade for a failure whose symptom is every panel on a screen refusing to render.
|
||||
*/
|
||||
const evicted: string[] = [];
|
||||
for (const owned of PLATFORM_OWNED) {
|
||||
const path = join(PLUGINS_DIR, 'node_modules', owned);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
evicted.push(owned);
|
||||
}
|
||||
}
|
||||
|
||||
const note = evicted.length ? `\nremoved ${evicted.join(', ')} — the platform owns those` : '';
|
||||
return { ok: code === 0, ran: true, output: `${out}${err}${note}`.trim(), conflicts };
|
||||
} catch (error) {
|
||||
return { ok: false, ran: true, output: error instanceof Error ? error.message : String(error), conflicts };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { createSidecarConnector } from '../connect';
|
||||
import { sign } from '../../jwt';
|
||||
import { getUserByEmail, getOwnerUser } from 'officerdb';
|
||||
import { DATA_PATH, findEmailAccountName } from '../../data-path';
|
||||
import { listPluginInstalls } from 'officerdb';
|
||||
import { discoverPlugins } from '../../plugins/discover';
|
||||
import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
|
||||
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
|
||||
@@ -140,7 +142,7 @@ loadState(dbUser.id);
|
||||
|
||||
// ── MCP config ──
|
||||
|
||||
function generateMcpConfig(): string {
|
||||
async function generateMcpConfig(): Promise<string> {
|
||||
// Was `.container-context`, from the architecture where each user ran inside their own Docker
|
||||
// container and this directory described that container to the agent. Nothing about it is
|
||||
// container-related now — it holds exactly one file, the MCP server config handed to the CLI. The
|
||||
@@ -152,8 +154,45 @@ function generateMcpConfig(): string {
|
||||
const userRoot = join(DATA_PATH, email!);
|
||||
|
||||
const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':');
|
||||
|
||||
/*
|
||||
MCP servers contributed by plugins.
|
||||
|
||||
A plugin that fronts something with its own MCP server can either reimplement that vocabulary as
|
||||
Officer tools or hand the real one over. Reimplementing means a second copy that goes stale, and it
|
||||
fails silently — a tool exists upstream that Officer cannot see. Handing it over costs this block.
|
||||
|
||||
The plugin declares a PATH, never a URL, and it is resolved against this Officer's own API. That is
|
||||
the whole safety property: the config below carries `OFFICER_AUTH_TOKEN`, a token that signs as the
|
||||
owner, so a manifest able to name an arbitrary host would be a manifest able to post the owner's
|
||||
credential anywhere. A path can only ever reach this machine, through this machine's own gate.
|
||||
|
||||
Installed AND enabled, because a disabled plugin's routes are not mounted — offering the agent a
|
||||
tool list that 404s is worse than not offering it.
|
||||
*/
|
||||
const pluginMcpServers: Record<string, { type: 'http'; url: string; headers: Record<string, string> }> = {};
|
||||
try {
|
||||
const [installs, discovered] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
|
||||
const live = new Set(installs.filter((entry) => entry.enabled).map((entry) => entry.appName));
|
||||
|
||||
for (const plugin of discovered.plugins) {
|
||||
const declared = plugin.manifest?.mcp?.path;
|
||||
if (!declared || !live.has(plugin.appName)) continue;
|
||||
const path = declared.startsWith('/') ? declared : `/${declared}`;
|
||||
pluginMcpServers[plugin.appName] = {
|
||||
type: 'http',
|
||||
url: `${OFFICER_API_URL}/api/${plugin.appName}${path}`,
|
||||
headers: { Authorization: `Bearer ${OFFICER_AUTH_TOKEN}` },
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// A plugin that cannot be read must not stop an agent from starting. It loses its MCP tools for this
|
||||
// session and everything else works, which is the right side to fail on.
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
mcpServers: {
|
||||
...pluginMcpServers,
|
||||
'officer-tools': {
|
||||
type: 'stdio',
|
||||
command: 'bun',
|
||||
@@ -236,7 +275,13 @@ function ensureAnthropicEnv(): void {
|
||||
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
|
||||
// terminal `claude` loads too.
|
||||
|
||||
setMcpConfigPath(generateMcpConfig());
|
||||
/*
|
||||
Awaited, because the config now asks the database which plugins are installed.
|
||||
|
||||
Not fire-and-forget: `setMcpConfigPath` is what the agent manager reads when it spawns, so a config
|
||||
that arrives after the first spawn is an agent running without its plugin tools and no sign of why.
|
||||
*/
|
||||
setMcpConfigPath(await generateMcpConfig());
|
||||
ensureAnthropicEnv();
|
||||
|
||||
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { remarkMentions, MENTION_HREF } from './remark-mentions';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { remarkLineBreaks } from './remark-line-breaks';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import {
|
||||
@@ -202,7 +203,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
base64 blob pushes the bubble past max-w and out of the pane. */}
|
||||
<div className="chat-md break-words">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
@@ -248,7 +249,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
@@ -546,7 +547,7 @@ const PlanCard = ({
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions]} components={MD_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]} components={MD_COMPONENTS}>
|
||||
{message.plan}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -637,7 +638,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
||||
{text ? (
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { remarkLineBreaks } from './remark-line-breaks';
|
||||
|
||||
type MdNode = { type: string; value?: string; children?: MdNode[] };
|
||||
|
||||
const run = (tree: MdNode): MdNode => {
|
||||
remarkLineBreaks()(tree);
|
||||
return tree;
|
||||
};
|
||||
|
||||
const para = (...children: MdNode[]): MdNode => ({ type: 'paragraph', children });
|
||||
const text = (value: string): MdNode => ({ type: 'text', value });
|
||||
|
||||
test('a single newline becomes a break', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('one\ntwo'))] });
|
||||
expect(tree.children![0]!.children).toEqual([
|
||||
{ type: 'text', value: 'one' },
|
||||
{ type: 'break' },
|
||||
{ type: 'text', value: 'two' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('several newlines make several breaks', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('a\nb\nc'))] });
|
||||
expect(tree.children![0]!.children!.filter((n) => n.type === 'break')).toHaveLength(2);
|
||||
});
|
||||
|
||||
// The empty-segment case: a trailing newline must give one break, never a break plus an empty text
|
||||
// node, which renders as an extra blank line and double-spaces everything after it.
|
||||
test('a trailing newline does not leave an empty text node', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('one\n'))] });
|
||||
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'one' }, { type: 'break' }]);
|
||||
});
|
||||
|
||||
// The property that makes this safe: by the time it runs, a newline inside a fence is the VALUE of a
|
||||
// `code` node, never a `text` node. Walking text alone cannot reach it.
|
||||
test('code blocks are untouched', () => {
|
||||
const code: MdNode = { type: 'code', value: 'line one\nline two' };
|
||||
const tree = run({ type: 'root', children: [code] });
|
||||
expect(tree.children![0]).toEqual({ type: 'code', value: 'line one\nline two' });
|
||||
});
|
||||
|
||||
test('inline code is untouched', () => {
|
||||
const tree = run({ type: 'root', children: [para({ type: 'inlineCode', value: 'a\nb' })] });
|
||||
expect(tree.children![0]!.children![0]).toEqual({ type: 'inlineCode', value: 'a\nb' });
|
||||
});
|
||||
|
||||
test('text without newlines is left exactly as it was', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('nothing to do here'))] });
|
||||
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'nothing to do here' }]);
|
||||
});
|
||||
|
||||
// Emphasis, links and mentions all nest text — the walk has to go into them, not just the top level.
|
||||
test('nested nodes are walked', () => {
|
||||
const tree = run({ type: 'root', children: [para({ type: 'emphasis', children: [text('one\ntwo')] })] });
|
||||
expect(tree.children![0]!.children![0]!.children).toEqual([
|
||||
{ type: 'text', value: 'one' },
|
||||
{ type: 'break' },
|
||||
{ type: 'text', value: 'two' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Make a single newline a line break, the way every chat app does.
|
||||
*
|
||||
* ── The message was never wrong ──
|
||||
*
|
||||
* Shift+Enter puts a real `\n` in the textarea and a real `\n` is stored, sent and given to the agent.
|
||||
* What loses it is MARKDOWN'S OWN RULE: inside a paragraph, a single line ending is whitespace, and only
|
||||
* a blank line starts a new paragraph. So
|
||||
*
|
||||
* one
|
||||
* two
|
||||
*
|
||||
* renders as "one two", correctly, per CommonMark. That is right for prose and wrong for a chat box,
|
||||
* where somebody pressing Shift+Enter is asking for a line.
|
||||
*
|
||||
* The fix therefore belongs at RENDER time. The alternative — appending two spaces to every line as the
|
||||
* message is saved — would edit what somebody typed on its way to disk, and every consumer downstream
|
||||
* (the agent, a transcript, an export) would carry trailing whitespace nobody asked for to satisfy a
|
||||
* display concern.
|
||||
*
|
||||
* This is what `remark-breaks` does. It is not in the lockfile, and `remark-mentions` beside this file
|
||||
* records the reasoning for not adding one: installs are frozen, and a dependency for a short tree walk
|
||||
* is a deliberate lockfile change for no benefit.
|
||||
*
|
||||
* ── Why the split is only inside text nodes ──
|
||||
*
|
||||
* By the time this runs the tree is already parsed, so a newline inside a code fence is the VALUE of a
|
||||
* `code` node and never a `text` node — walking text alone means fenced code, inline code and tables are
|
||||
* untouched. That is the property that makes this safe: it cannot reach anything where a newline means
|
||||
* something else.
|
||||
*/
|
||||
|
||||
type MdNode = { type: string; value?: string; children?: MdNode[] };
|
||||
|
||||
export function remarkLineBreaks() {
|
||||
return (tree: MdNode) => {
|
||||
const walk = (node: MdNode) => {
|
||||
if (!node.children?.length) return;
|
||||
|
||||
const next: MdNode[] = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type !== 'text' || !child.value?.includes('\n')) {
|
||||
walk(child);
|
||||
next.push(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
`one\ntwo` becomes text("one"), break, text("two").
|
||||
|
||||
Empty segments are dropped rather than emitted, so a trailing newline produces one break and
|
||||
not a break followed by an empty text node — which renders as an extra blank line and is the
|
||||
usual way this ends up double-spacing everything.
|
||||
*/
|
||||
const parts = child.value.split('\n');
|
||||
parts.forEach((part, index) => {
|
||||
if (index > 0) next.push({ type: 'break' });
|
||||
if (part) next.push({ type: 'text', value: part });
|
||||
});
|
||||
}
|
||||
|
||||
node.children = next;
|
||||
};
|
||||
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,20 @@ import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
||||
import { AgentRunnerDialog } from './components/AgentRunnerDialog';
|
||||
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
||||
import { DictateDialog } from './components/DictateDialog';
|
||||
import { useFileBrowserApp } from './useFileBrowserApp';
|
||||
import { useFileBrowserApp, type PickedFile } from './useFileBrowserApp';
|
||||
|
||||
type FileBrowserAppProps = {
|
||||
basePath?: string;
|
||||
rootOverride?: string;
|
||||
/** What the root crumb is called. Defaults to the base folder's own name. */
|
||||
rootLabel?: string;
|
||||
/**
|
||||
* Keep the current folder in this query param, on the route you are already on.
|
||||
*
|
||||
* For an embedded browser that wants a reloadable, shareable location. NOT `urlPath`, which owns the
|
||||
* pathname and navigates to `/files` — passing that from a panel takes the whole app with it.
|
||||
*/
|
||||
pathParam?: string;
|
||||
/** Put the current folder in `?path=`. Opt-in — only the /files screen is guaranteed one browser. */
|
||||
urlPath?: boolean;
|
||||
/**
|
||||
@@ -18,10 +27,26 @@ type FileBrowserAppProps = {
|
||||
* where the folder is the URL and already does.
|
||||
*/
|
||||
panelId?: string;
|
||||
/**
|
||||
* Turn this into a CHOOSER: opening a file hands it back instead of opening a viewer.
|
||||
*
|
||||
* Folders still navigate, because choosing a file means getting to it. A prop rather than a second
|
||||
* component — a picker built separately is a listing, a breadcrumb, a sort, thumbnails and keyboard
|
||||
* handling reimplemented, all of it free to drift from the real browser.
|
||||
*/
|
||||
onPickFile?: (file: PickedFile) => void;
|
||||
};
|
||||
|
||||
export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath, panelId }: FileBrowserAppProps) => {
|
||||
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId);
|
||||
export const FileBrowserApp = ({
|
||||
basePath = '/',
|
||||
rootOverride,
|
||||
rootLabel,
|
||||
urlPath,
|
||||
panelId,
|
||||
pathParam,
|
||||
onPickFile,
|
||||
}: FileBrowserAppProps) => {
|
||||
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam, onPickFile);
|
||||
const { handleNavigate } = fileBrowserManager;
|
||||
|
||||
return (
|
||||
@@ -31,6 +56,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath, panelId
|
||||
path={fileBrowserManager.currentPath}
|
||||
onNavigate={handleNavigate}
|
||||
basePath={basePath}
|
||||
rootLabel={rootLabel}
|
||||
hrefForPath={fileBrowserManager.hrefForPath}
|
||||
/>
|
||||
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
||||
|
||||
+10
-2
@@ -5,6 +5,14 @@ type BreadcrumbProps = {
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
basePath?: string;
|
||||
/**
|
||||
* What to call the root crumb.
|
||||
*
|
||||
* Defaults to the base folder's own name, which is right for `/files` and wrong for an embedder: a
|
||||
* panel scoped to `<root>/media/originals` is called Media, and "originals" is an implementation
|
||||
* detail of how that plugin lays out a directory. The person looking at it never chose that word.
|
||||
*/
|
||||
rootLabel?: string;
|
||||
/**
|
||||
* Where a folder lives, when this browser owns the address bar. Given, the crumbs are real links and
|
||||
* cmd-click opens the folder in a new tab; absent (a dashboard panel, the widget) they stay buttons,
|
||||
@@ -15,7 +23,7 @@ type BreadcrumbProps = {
|
||||
hrefForPath?: ((path: string) => { pathname: string; search: string }) | null;
|
||||
};
|
||||
|
||||
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: BreadcrumbProps) => {
|
||||
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath, rootLabel }: BreadcrumbProps) => {
|
||||
const relativePath = basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length) : path;
|
||||
const segments = relativePath.split('/').filter(Boolean);
|
||||
|
||||
@@ -35,7 +43,7 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: Br
|
||||
<nav className="flex items-center gap-1 text-sm flex-wrap">
|
||||
<Crumb to={basePath} className="flex items-center gap-1 text-duck-teal cursor-pointer font-medium">
|
||||
<Home className="h-4 w-4" />
|
||||
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
|
||||
<span>{rootLabel ?? (basePath === '/' ? 'home' : basePath.split('/').pop())}</span>
|
||||
</Crumb>
|
||||
|
||||
{segments.map((segment, i) => {
|
||||
|
||||
+43
-1
@@ -4,6 +4,8 @@ import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||
import { getFileType } from '../../../FileViewer';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { fileActionsFor } from '../../file-actions';
|
||||
import { FileItem } from './FileItem';
|
||||
import { useTranslation } from '../../../../locales';
|
||||
|
||||
@@ -15,7 +17,19 @@ type FileGridProps = {
|
||||
};
|
||||
|
||||
const LIST_ROW_HEIGHT = 42;
|
||||
const GRID_ROW_HEIGHT = 130;
|
||||
/*
|
||||
What one grid row actually occupies, and why the number changed.
|
||||
|
||||
It was 130 while the card was `p-4` around a 64px thumbnail plus two lines of text — about 148. The
|
||||
card overflowed its own row by roughly the size of the gap, so consecutive rows touched and the grid
|
||||
looked vertically stacked. The horizontal gap was fine, which is what made it read as a styling
|
||||
oddity rather than a measurement being wrong.
|
||||
|
||||
Now: a 96px image band, then `py-2` (16) around a 20px name and a 16px meta line, inside a 2px
|
||||
border. 96 + 16 + 20 + 16 + 4 = 152. Measured against the card rather than guessed, because guessing
|
||||
is how it came to be 130.
|
||||
*/
|
||||
const GRID_ROW_HEIGHT = 152;
|
||||
const GRID_GAP = 12;
|
||||
const HEADER_HEIGHT = 36;
|
||||
// Type-ahead: keys pressed within this window accumulate into one burst. 1–2 chars jump to the first
|
||||
@@ -49,6 +63,8 @@ const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
|
||||
export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
const { t } = useTranslation();
|
||||
// For plugin actions: `run` is outside React and cannot call the hook itself.
|
||||
const client = useClient();
|
||||
const {
|
||||
visibleEntries: entries,
|
||||
viewMode,
|
||||
@@ -56,6 +72,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
rootOverride,
|
||||
selected,
|
||||
clipboard,
|
||||
refresh,
|
||||
handleOpen,
|
||||
handleDelete,
|
||||
handleRename,
|
||||
@@ -155,8 +172,23 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
const cols = useColumnCount(scrollRef);
|
||||
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
||||
const anySelected = selected.size > 0;
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
|
||||
const pluginActionsFor = (entry: DirEntry) => {
|
||||
const names = selected.has(entry.name) && selected.size > 1 ? [...selected] : [entry.name];
|
||||
const payload = entries
|
||||
.filter((candidate) => names.includes(candidate.name))
|
||||
.map((candidate) => ({ name: candidate.name, path: entryPath(candidate.name), type: candidate.type }));
|
||||
|
||||
return fileActionsFor(payload).map((action) => ({
|
||||
key: action.key,
|
||||
label: action.label,
|
||||
Icon: action.icon,
|
||||
onClick: () => action.run(payload, { currentPath, refresh, client }),
|
||||
}));
|
||||
};
|
||||
|
||||
const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
@@ -257,6 +289,14 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
onReadAloud={handleReadAloud}
|
||||
onExtract={handleExtract}
|
||||
taskGroups={getMatchingTaskGroups(entry.name, entry.type)}
|
||||
/*
|
||||
Plugin actions, resolved here because this is where the SELECTION lives.
|
||||
|
||||
An action receives the whole selection when the right-clicked item is part of one, and just
|
||||
that item otherwise — what every file manager does. Acting on a selection the click was not
|
||||
part of is the surprising alternative.
|
||||
*/
|
||||
pluginActions={pluginActionsFor(entry)}
|
||||
onRunTask={handleRunTask}
|
||||
agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
|
||||
onRunAgent={handleRunAgent}
|
||||
@@ -265,6 +305,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
onCompress={handleCompress}
|
||||
onDuplicate={handleDuplicate}
|
||||
onPasteInto={handlePasteInto}
|
||||
onDragStart={fileBrowserManager.handleEntryDragStart}
|
||||
onDropInto={fileBrowserManager.handleEntryDrop}
|
||||
onTogglePin={handleTogglePin}
|
||||
isPinned={isPinned(entryPath(entry.name))}
|
||||
canPaste={clipboard !== null}
|
||||
|
||||
+108
-19
@@ -23,6 +23,7 @@ import {
|
||||
FileArchive,
|
||||
Pin,
|
||||
PinOff,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { Thumbnail } from './Thumbnail';
|
||||
@@ -84,9 +85,25 @@ export type FileItemProps = {
|
||||
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
|
||||
onCreateDashboard: (entry: DirEntry) => void;
|
||||
onOpenInEditor: (entry: DirEntry) => void;
|
||||
/**
|
||||
* Context-menu actions contributed by plugins, already resolved against the selection.
|
||||
*
|
||||
* Resolved by the caller, not here: whether an action receives one entry or the whole selection is a
|
||||
* question about the SELECTION, which this component does not have and should not need.
|
||||
*/
|
||||
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
|
||||
onCompress: (entry: DirEntry) => void;
|
||||
onDuplicate: (entry: DirEntry) => void;
|
||||
onPasteInto: (entry: DirEntry) => void;
|
||||
/**
|
||||
* Dragging this row, and dropping onto it.
|
||||
*
|
||||
* A custom MIME type on the DataTransfer survives between windows of the same browser, which is what
|
||||
* makes "drag from one Officer window into another" work at all. See useFileBrowserApp for the type
|
||||
* and why `Files` is left alone.
|
||||
*/
|
||||
onDragStart: (ev: React.DragEvent, entry: DirEntry) => void;
|
||||
onDropInto: (ev: React.DragEvent, entry: DirEntry) => Promise<void>;
|
||||
onTogglePin: (entry: DirEntry) => void;
|
||||
isPinned: boolean;
|
||||
canPaste: boolean;
|
||||
@@ -177,6 +194,7 @@ type MenuItemsProps = {
|
||||
canPaste: boolean;
|
||||
onOpen: (e: DirEntry) => void;
|
||||
onOpenInEditor: (e: DirEntry) => void;
|
||||
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
|
||||
onDelete: (e: DirEntry) => void;
|
||||
onStartRename: () => void;
|
||||
onChat: (e: DirEntry) => void;
|
||||
@@ -212,6 +230,7 @@ const MenuItems = ({
|
||||
canPaste,
|
||||
onOpen,
|
||||
onOpenInEditor,
|
||||
pluginActions,
|
||||
onDelete,
|
||||
onStartRename,
|
||||
onChat,
|
||||
@@ -254,7 +273,7 @@ const MenuItems = ({
|
||||
|
||||
const hasTasks = taskGroups.length > 0;
|
||||
const hasAgents = agentGroups.length > 0;
|
||||
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents;
|
||||
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents || Boolean(pluginActions?.length);
|
||||
// Only nest when more than one category matched — a file usually matches a single category, and
|
||||
// Run Task > Video > Convert would just add a hop.
|
||||
const nestTasks = taskGroups.length > 1;
|
||||
@@ -305,6 +324,14 @@ const MenuItems = ({
|
||||
{t('files.menu.extract')}
|
||||
</Item>
|
||||
)}
|
||||
{/* Plugin actions, flat rather than nested. There are few of them and each is one click; a
|
||||
submenu for two items is a hop that buys nothing. */}
|
||||
{pluginActions?.map((action) => (
|
||||
<Item key={action.key} onClick={action.onClick} className="cursor-pointer">
|
||||
{action.Icon ? <action.Icon className="mr-2 h-4 w-4" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
{action.label}
|
||||
</Item>
|
||||
))}
|
||||
{hasTasks && (
|
||||
<Sub>
|
||||
<SubTrigger className="cursor-pointer">
|
||||
@@ -565,6 +592,7 @@ export const FileItem = ({
|
||||
viewMode,
|
||||
selected,
|
||||
anySelected,
|
||||
pluginActions,
|
||||
selectedCount,
|
||||
isCut,
|
||||
onOpen,
|
||||
@@ -589,6 +617,8 @@ export const FileItem = ({
|
||||
onCompress,
|
||||
onDuplicate,
|
||||
onPasteInto,
|
||||
onDragStart,
|
||||
onDropInto,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
canPaste,
|
||||
@@ -629,8 +659,32 @@ export const FileItem = ({
|
||||
</Thumbnail>
|
||||
);
|
||||
|
||||
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 128) : icon;
|
||||
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
|
||||
/*
|
||||
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;
|
||||
|
||||
/*
|
||||
The grid picture fills the card's WIDTH and is cropped by height.
|
||||
|
||||
It used to be a fixed `h-16 w-20` box centred with `object-contain`, so a landscape photo sat in the
|
||||
middle of the card with air either side and a portrait one shrank to nothing. `w-full` with the
|
||||
card's own `overflow-hidden` doing the clipping means every tile reads as the same shape whatever
|
||||
the photograph is — which is the whole reason a grid of pictures is easier to scan than a grid of
|
||||
names.
|
||||
|
||||
No rounding here: the card clips, so rounding the image too would show a sliver of card colour
|
||||
inside the corner.
|
||||
*/
|
||||
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-24 w-full', 256) : iconLarge;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -711,6 +765,10 @@ export const FileItem = ({
|
||||
onCreateDashboard,
|
||||
onOpen,
|
||||
onOpenInEditor,
|
||||
// Typed on both ends and forwarded by neither, which is why "Create Gallery" was in the bundle and
|
||||
// not in the menu. `menuProps` is assembled field by field, so a new prop is not carried by being
|
||||
// declared.
|
||||
pluginActions,
|
||||
onCompress,
|
||||
onDuplicate,
|
||||
onPasteInto,
|
||||
@@ -733,6 +791,24 @@ export const FileItem = ({
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, entry)}
|
||||
/*
|
||||
A folder is a drop target; a file is not. `onDragOver` must preventDefault or the browser
|
||||
refuses the drop entirely — that is the single line whose absence makes a target look
|
||||
dead, with no error anywhere.
|
||||
|
||||
`dropEffect` is set here rather than at dragstart because only the target knows whether
|
||||
it can accept: the cursor says "move" over a folder and "no" over a file, which is the
|
||||
only feedback a drag has.
|
||||
*/
|
||||
onDragOver={(ev) => {
|
||||
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(ev) => void onDropInto(ev, entry)}
|
||||
>
|
||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||
<span className="shrink-0">{listVisual}</span>
|
||||
@@ -773,13 +849,22 @@ export const FileItem = ({
|
||||
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
|
||||
<div
|
||||
data-file-item
|
||||
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
|
||||
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors ${
|
||||
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
|
||||
} ${cutOpacity} select-none`}
|
||||
style={selected ? undefined : cardStyle()}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, entry)}
|
||||
onDragOver={(ev) => {
|
||||
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(ev) => void onDropInto(ev, entry)}
|
||||
>
|
||||
<div className="absolute top-1 left-1">
|
||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||
@@ -789,23 +874,27 @@ export const FileItem = ({
|
||||
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
|
||||
</div>
|
||||
|
||||
{gridVisual}
|
||||
{/* One fixed-height band whether it holds a photograph or a folder icon, so every tile is
|
||||
the same shape and the rows line up. The card clips; this does not round. */}
|
||||
<div className="flex h-24 w-full shrink-0 items-center justify-center overflow-hidden">{gridVisual}</div>
|
||||
|
||||
{renaming ? (
|
||||
<InlineRenameInput
|
||||
initialName={entry.name}
|
||||
onCommit={handleCommitRename}
|
||||
onCancel={() => setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-duck-dark text-center truncate w-full">{entry.name}</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col items-center gap-0.5 px-2 py-2">
|
||||
{renaming ? (
|
||||
<InlineRenameInput
|
||||
initialName={entry.name}
|
||||
onCommit={handleCommitRename}
|
||||
onCancel={() => setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-duck-dark/50">
|
||||
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
|
||||
{' · '}
|
||||
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-xs text-duck-dark/50">
|
||||
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
|
||||
{' · '}
|
||||
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-[600]">
|
||||
|
||||
+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'
|
||||
}`}
|
||||
|
||||
+34
-9
@@ -1,5 +1,5 @@
|
||||
import { useRef } from 'react';
|
||||
import { FolderPlus, Upload, FolderUp, ClipboardPaste } from 'lucide-react';
|
||||
import { FolderPlus, Upload, FolderUp, ClipboardPaste, X } from 'lucide-react';
|
||||
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
|
||||
import { useTranslation } from '../../../../../locales';
|
||||
|
||||
@@ -9,7 +9,7 @@ type DefaultActionsProps = {
|
||||
|
||||
export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setNewEntry, handleUpload, clipboard, handlePaste } = fileBrowserManager;
|
||||
const { setNewEntry, handleUpload, clipboard, handlePaste, setClipboard } = fileBrowserManager;
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -56,14 +56,39 @@ export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
What is on the clipboard, said out loud.
|
||||
|
||||
It used to be an icon and a toast. That was enough while the clipboard died with the tab — you
|
||||
had just pressed the key, so you knew what was on it. Now it is per-user and survives reloads,
|
||||
tabs and days, which means somebody can arrive at a folder with three files pending from
|
||||
yesterday and paste them without ever having been told. Invisible persistent state is how a
|
||||
file gets moved by accident.
|
||||
|
||||
So it says how many and whether they will be copied or moved, and offers a way to let go of
|
||||
them. `cut` is worth distinguishing in the label rather than only in the icon: it is the one
|
||||
that takes the originals with it.
|
||||
*/}
|
||||
{clipboard !== null && (
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
title={t('files.menu.paste')}
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-duck-dark/5 pl-2">
|
||||
<span className="text-xs text-duck-teal whitespace-nowrap">
|
||||
{clipboard.paths.length} {clipboard.mode === 'cut' ? t('files.toMove') : t('files.toCopy')}
|
||||
</span>
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
title={t('files.menu.paste')}
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClipboard(null)}
|
||||
title={t('files.clearClipboard')}
|
||||
className="p-1.5 rounded-md text-duck-teal/60 hover:bg-duck-dark/10 hover:text-duck-teal cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+46
-37
@@ -252,48 +252,57 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
||||
{entries.map((e, i) => (
|
||||
<div
|
||||
key={`${e.url}-${i}`}
|
||||
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
|
||||
/*
|
||||
A COLUMN, not a row. The title, the thumbnail and the button used to share one
|
||||
line, so a long title — which is most of them — competed with the button for width
|
||||
and won: `truncate` cannot bite while a sibling can be pushed out of the card.
|
||||
Split, the title has the whole width to truncate against and the button has a row
|
||||
it cannot be squeezed out of.
|
||||
*/
|
||||
className="flex flex-col gap-2 rounded-md border border-duck-dark/10 bg-background/40 p-2"
|
||||
>
|
||||
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
|
||||
{e.status === 'loading' ? (
|
||||
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||
) : e.status === 'error' ? (
|
||||
<AlertCircle size={16} className="text-destructive/70" />
|
||||
) : e.thumbnail && !audioOnly ? (
|
||||
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<Music size={16} className="text-duck-dark/40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
|
||||
{e.status === 'loading' ? (
|
||||
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||
) : e.status === 'error' ? (
|
||||
<AlertCircle size={16} className="text-destructive/70" />
|
||||
) : e.thumbnail && !audioOnly ? (
|
||||
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<Music size={16} className="text-duck-dark/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{e.status === 'loading' ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
|
||||
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
|
||||
</div>
|
||||
) : e.status === 'error' ? (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-destructive">
|
||||
{t('videoDownload.couldNotFetch')}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
|
||||
{e.title || e.url}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">
|
||||
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
|
||||
</>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{e.status === 'loading' ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
|
||||
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
|
||||
</div>
|
||||
) : e.status === 'error' ? (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-destructive">
|
||||
{t('videoDownload.couldNotFetch')}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
|
||||
{e.title || e.url}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">
|
||||
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{e.status === 'ready' && (
|
||||
<div className="shrink-0">
|
||||
<div className="flex justify-end">
|
||||
{e.dl === 'done' ? (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
|
||||
<Check size={14} /> {t('videoDownload.saved')}
|
||||
|
||||
+272
-5
@@ -10,6 +10,7 @@ import { folderFromPathname, folderHref, relativeToFolder, withoutViewerParams }
|
||||
import { useTasks, runsWithoutPrompting, type TaskSummary } from '../useTasks';
|
||||
import { useAgents, type AgentSummary } from '../useAgents';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { runTaskNow } from './components/runTaskNow';
|
||||
@@ -24,7 +25,30 @@ import { useTranslation } from '../../../locales';
|
||||
*/
|
||||
export const FILES_PATH_PARAM = 'path';
|
||||
|
||||
export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPath = false, panelId?: string) => {
|
||||
/**
|
||||
* `pathParam` — keep the current folder in a QUERY PARAM, on whatever route we are already on.
|
||||
*
|
||||
* This is not `urlPath` and must not be confused with it. `urlPath` owns the URL's PATHNAME: it reads
|
||||
* the folder out of `location.pathname` and navigates to `/files/<folder>`. That is correct for the
|
||||
* Files screen, which IS that route, and catastrophic inside a panel — a plugin passing `urlPath`
|
||||
* navigated the whole app to /files the moment anybody opened a folder.
|
||||
*
|
||||
* So an embedder gets its own mechanism: a named search param, written with `replace` so browsing does
|
||||
* not fill the back stack, and no pathname involved at all. A reload lands where you were and the link
|
||||
* can be sent to somebody, which is what was actually wanted.
|
||||
*/
|
||||
/** What a picker is handed when somebody opens a file in pick mode. */
|
||||
export type PickedFile = { name: string; path: string; type: string };
|
||||
|
||||
export const useFileBrowserApp = (
|
||||
basePath: string,
|
||||
rootOverride?: string,
|
||||
urlPath = false,
|
||||
panelId?: string,
|
||||
pathParam?: string,
|
||||
/** Set to turn this into a chooser: opening a file calls this instead of a viewer. */
|
||||
onPickFile?: (file: PickedFile) => void,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -64,14 +88,55 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
// visit still opens at home, because a URL with no `path` means the base. Either way the main
|
||||
// (unscoped) browser mirrors its folder into files/currentPath so the Create Dashboard flow can
|
||||
// default a new dashboard's cwd to it.
|
||||
const currentPath = urlPath ? folderFromPathname(location.pathname) : localPath;
|
||||
/*
|
||||
Three sources, mutually exclusive by construction: the pathname for /files, a named query param for
|
||||
an embedded browser, and component state for an ordinary panel.
|
||||
|
||||
── The param is RELATIVE to the base, and that is not only for tidiness ──
|
||||
|
||||
An absolute path put the account's own email in every link — `?media=/officerdev/data/
|
||||
someone@gmail.com/...` — so a URL sent to a colleague carried the owner's address with it. Relative,
|
||||
the same folder is `?media=ABOUT+US`: shorter, and it says nothing about the machine or the account.
|
||||
|
||||
It is also what makes the jail hold by construction rather than by comparison. The value is JOINED
|
||||
to `basePath`, so there is no "outside" to check for — only `..` could climb out, and a segment of
|
||||
`..` is refused outright.
|
||||
*/
|
||||
const paramRelative = pathParam ? searchParams.get(pathParam) : null;
|
||||
const paramPath =
|
||||
paramRelative && !paramRelative.split('/').includes('..')
|
||||
? `${basePath.replace(/\/$/, '')}/${paramRelative.replace(/^\/+/, '')}`
|
||||
: null;
|
||||
const currentPath = urlPath ? folderFromPathname(location.pathname) : pathParam ? (paramPath ?? basePath) : localPath;
|
||||
const setCurrentPath = (path: string) => {
|
||||
// Leaving a folder closes what was open in it. A pane shows a file from the folder you were in, so
|
||||
// carrying it into the next one leaves the two disagreeing — the listing says one place, the pane
|
||||
// another. Only the OVERLAY params go; anything else in the query (global UI state) is left alone,
|
||||
// which is the distinction a wholesale reset kept getting wrong.
|
||||
if (urlPath) navigate({ pathname: folderHref(path), search: withoutViewerParams(location.search) });
|
||||
else {
|
||||
else if (pathParam) {
|
||||
/*
|
||||
PUSHED, not replaced — so Back and Forward move between folders.
|
||||
|
||||
This was `replace` on the argument that browsing is not navigation worth a history entry. That
|
||||
was wrong, and wrong in a way the URL itself gives away: the moment a folder is addressable, it
|
||||
is a place, and Back meaning "the folder I was just in" is what anybody expects of a place. It
|
||||
also matches `/files`, which pushes — an embedded browser behaving differently from the full one
|
||||
is a difference nobody can see a reason for.
|
||||
|
||||
Reading is unchanged: `currentPath` is derived from the param, so a Back that rewrites the query
|
||||
moves the browser with it and needs nothing here.
|
||||
*/
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(withoutViewerParams(prev.toString()));
|
||||
// The base folder is the ABSENCE of the param, so a link to the top of an embedded browser is
|
||||
// the plain screen URL and nothing accumulates for having gone nowhere.
|
||||
const relative = path.startsWith(basePath) ? path.slice(basePath.replace(/\/$/, '').length + 1) : '';
|
||||
if (!relative) next.delete(pathParam);
|
||||
else next.set(pathParam, relative);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setLocalPath(path);
|
||||
// Written here rather than in an effect on `currentPath`, so it records a NAVIGATION and not the
|
||||
// initial render. An effect would fire once on mount with the restored value and persist it back,
|
||||
@@ -133,7 +198,25 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
const { togglePin, isPinned } = usePinnedFiles();
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [clipboard, setClipboard] = useState<ClipboardState>(null);
|
||||
/*
|
||||
The clipboard lives on the SERVER, not in this component.
|
||||
|
||||
── Why ──
|
||||
|
||||
It was `useState`, which meant it existed in one tab and died with it. Copying in one window and
|
||||
pasting in another — the obvious way to move a file between two folders you can both see — did
|
||||
nothing, and a reload lost a pending cut with no sign that it had.
|
||||
|
||||
`user_state` is a per-user JSONB row that already exists for exactly this kind of thing, and
|
||||
`useUserState` already reads and writes it through React Query with an optimistic local update. So
|
||||
a clipboard is one key in it and there is no new table, route or socket.
|
||||
|
||||
Per user rather than per browser, deliberately: cutting on the desktop and pasting on the phone is
|
||||
the same gesture across two screens, and there is no reading of "my clipboard" where the two should
|
||||
disagree.
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
const [clipboard, setClipboard] = useUserState<ClipboardState>('fileClipboard', null);
|
||||
const [renamingName, setRenamingName] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
|
||||
@@ -164,6 +247,10 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
filesRef.current = files;
|
||||
const currentPathRef = useRef(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
// Read inside `refresh`, which is not re-created per render and would otherwise close over a
|
||||
// stale list the first time it ran.
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
@@ -171,8 +258,29 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
|
||||
const firstListing = useRef(true);
|
||||
|
||||
/**
|
||||
* The folder the entries currently on screen belong to.
|
||||
*
|
||||
* What separates "re-read this folder" from "go to a different one", which look identical from
|
||||
* inside `refresh` and need opposite treatment — see below.
|
||||
*/
|
||||
const shownPath = useRef<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
/*
|
||||
Blank the list only when there is nothing true to show.
|
||||
|
||||
`loading` swaps the whole grid for a spinner, which is right when arriving somewhere new and
|
||||
wrong when re-reading a folder already on screen: every refresh — after a paste, after a delete,
|
||||
and now every time the window comes forward — made the contents vanish and come back. On a
|
||||
folder of a hundred files that is a flash and a lost scroll position, for information that was
|
||||
already correct.
|
||||
|
||||
So the spinner is for a folder we cannot yet show: nothing listed, or a different folder than the
|
||||
one listed. Re-reading the same folder leaves what is there until better information arrives, and
|
||||
the only visible change is whatever actually changed.
|
||||
*/
|
||||
if (entriesRef.current.length === 0 || shownPath.current !== currentPathRef.current) setLoading(true);
|
||||
try {
|
||||
const data = await filesRef.current.listDir(currentPathRef.current);
|
||||
if (data.reset) {
|
||||
@@ -180,6 +288,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
return;
|
||||
}
|
||||
setEntries(data.entries);
|
||||
shownPath.current = currentPathRef.current;
|
||||
if (data.rootDir) setRootDir(data.rootDir);
|
||||
} catch (err: any) {
|
||||
console.error('[FileBrowser] refresh error:', err);
|
||||
@@ -243,6 +352,50 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
/*
|
||||
Pull the clipboard again whenever this window comes forward.
|
||||
|
||||
`useUserState` holds USER_STATE with `staleTime: Infinity` — right for a dock layout, wrong for
|
||||
something another window can change while this one is idle. Without this, window A cuts, window B
|
||||
still shows nothing to paste, and the feature reads as broken in exactly the case it was built for.
|
||||
|
||||
Focus rather than a poll or a socket, and that is a judgement rather than a shortcut: pasting into
|
||||
the other window means focusing the other window first, so the refresh happens at precisely the
|
||||
moment it is needed and never while nobody is looking. A socket would be live for a case that does
|
||||
not arise — two visible windows dragging between each other already carry the paths in the drag
|
||||
itself.
|
||||
|
||||
`visibilitychange` as well as `focus`, because a background tab brought forward by switching tabs
|
||||
fires only the second one in some browsers.
|
||||
*/
|
||||
/*
|
||||
...and the FOLDER as well as the clipboard.
|
||||
|
||||
Cutting in one window and pasting in another moves the file on disk, and the window it left went on
|
||||
listing it until somebody pressed refresh. The clipboard was the obvious half of "these two windows
|
||||
disagree"; the listing is the half that matters more, because a file that is not there any more is
|
||||
a lie rather than a stale label.
|
||||
|
||||
Held in a ref because `refreshRef.current` is rebuilt on every render — putting it in the
|
||||
dependency array would tear down and re-add both listeners several times a second.
|
||||
*/
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
|
||||
useEffect(() => {
|
||||
const onForeground = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void queryClient.invalidateQueries({ queryKey: ['USER_STATE'] });
|
||||
void refreshRef.current();
|
||||
};
|
||||
window.addEventListener('focus', onForeground);
|
||||
document.addEventListener('visibilitychange', onForeground);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onForeground);
|
||||
document.removeEventListener('visibilitychange', onForeground);
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// Clipboard image paste
|
||||
useEffect(() => {
|
||||
const handler = (ev: ClipboardEvent) => {
|
||||
@@ -416,6 +569,21 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
setCurrentPath(next);
|
||||
return;
|
||||
}
|
||||
/*
|
||||
PICK MODE.
|
||||
|
||||
When a caller is using this browser to CHOOSE a file rather than to work in a folder, opening a
|
||||
file means handing it back — not opening a viewer the caller would then have to close. Folders
|
||||
still navigate above, because choosing a file requires getting to it.
|
||||
|
||||
A prop rather than a second browser: a picker built separately would be a listing, a breadcrumb,
|
||||
a sort, thumbnails and keyboard handling reimplemented, all of it drifting from the real one.
|
||||
This is the same component with one branch.
|
||||
*/
|
||||
if (onPickFile) {
|
||||
onPickFile({ name: entry.name, path: entryPath(entry.name), type: entry.type });
|
||||
return;
|
||||
}
|
||||
// An archive extracts rather than opening a pane. The viewer has nothing to show for one — it renders
|
||||
// a placeholder reading "Archive file" and the name you can already see in the listing — so a pane
|
||||
// was a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a
|
||||
@@ -911,6 +1079,78 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Dragging rows between windows.
|
||||
|
||||
── The MIME type is the whole trick ──
|
||||
|
||||
A custom type on the DataTransfer survives between tabs and windows of the SAME browser, which is
|
||||
the case somebody actually has: two Officer windows side by side, one folder in each. It does not
|
||||
survive between different browsers, and it cannot exchange with Finder — dragging out to the
|
||||
desktop needs a Chrome-only API and only works outward.
|
||||
|
||||
`Files` is deliberately untouched. That is the type the OS puts on a real file drag, and the
|
||||
existing upload path keys off it; a drag from inside carries only ours, so the two never meet.
|
||||
|
||||
── It also writes the clipboard ──
|
||||
|
||||
Belt and braces, and cheap now that the clipboard is per-user server state: if a drop lands
|
||||
somewhere that cannot read the payload, the paths are still held and a paste finishes the job. The
|
||||
drag becomes a gesture over the same mechanism rather than a second one beside it.
|
||||
*/
|
||||
const DRAG_TYPE = 'application/x-officer-files';
|
||||
|
||||
const handleEntryDragStart = (ev: React.DragEvent, entry: DirEntry) => {
|
||||
// Dragging one of several selected rows takes the whole selection; dragging an unselected row
|
||||
// takes just that one, which is what every file manager does and what a hand expects.
|
||||
const paths = selected.has(entry.name)
|
||||
? [...selected].map((name) => entryPath(name))
|
||||
: [entryPath(entry.name)];
|
||||
|
||||
ev.dataTransfer.setData(DRAG_TYPE, JSON.stringify(paths));
|
||||
// `move` so the cursor shows what will happen. The drop decides for real; this is the hint.
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
setClipboard({ paths, mode: 'cut' });
|
||||
};
|
||||
|
||||
/** Paths from a drag that started in an Officer window, or null if this is something else. */
|
||||
const draggedPaths = (ev: React.DragEvent): string[] | null => {
|
||||
if (!ev.dataTransfer.types.includes(DRAG_TYPE)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(ev.dataTransfer.getData(DRAG_TYPE));
|
||||
return Array.isArray(parsed) && parsed.every((p) => typeof p === 'string') ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop onto a folder row — move into it.
|
||||
*
|
||||
* Refuses a folder being dropped into itself or into its own child, which the server would reject
|
||||
* anyway but with an error rather than a shrug. Comparing with a trailing separator so `/a/bc` is
|
||||
* not read as living inside `/a/b`.
|
||||
*/
|
||||
const handleEntryDrop = async (ev: React.DragEvent, entry: DirEntry) => {
|
||||
const paths = draggedPaths(ev);
|
||||
if (!paths || entry.type !== 'directory') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const destination = entryPath(entry.name);
|
||||
const into = paths.filter((path) => destination !== path && !destination.startsWith(`${path}/`));
|
||||
if (into.length === 0) return;
|
||||
|
||||
try {
|
||||
await files.move(into, destination);
|
||||
setClipboard(null);
|
||||
await refresh();
|
||||
toast.success(t('files.pastedItems', { count: into.length }));
|
||||
} catch {
|
||||
toast.error(t('files.pasteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (ev: React.DragEvent) => {
|
||||
ev.preventDefault();
|
||||
dragCounter.current++;
|
||||
@@ -936,6 +1176,28 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
dragCounter.current = 0;
|
||||
setDragging(false);
|
||||
|
||||
/*
|
||||
A drag that began in an Officer window, dropped on this folder's background.
|
||||
|
||||
Checked before the upload path, because a drag from inside carries no `Files` and would
|
||||
otherwise fall through to "read the dropped files", find none, and do nothing at all.
|
||||
*/
|
||||
const dragged = draggedPaths(ev);
|
||||
if (dragged) {
|
||||
const into = dragged.filter((path) => currentPath !== path && !currentPath.startsWith(`${path}/`));
|
||||
// Dropping a folder into the folder it already lives in. Nothing to do, and nothing to say.
|
||||
if (into.length === 0) return;
|
||||
try {
|
||||
await files.move(into, currentPath);
|
||||
setClipboard(null);
|
||||
await refresh();
|
||||
toast.success(t('files.pastedItems', { count: into.length }));
|
||||
} catch {
|
||||
toast.error(t('files.pasteFailed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const items = ev.dataTransfer.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
@@ -1029,7 +1291,12 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
// Selection
|
||||
selected,
|
||||
setSelected,
|
||||
handleEntryDragStart,
|
||||
handleEntryDrop,
|
||||
clipboard,
|
||||
// Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale
|
||||
// clipboard is something a person has to be able to clear rather than wait out.
|
||||
setClipboard,
|
||||
renamingName,
|
||||
setRenamingName,
|
||||
// Search
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Actions a PLUGIN contributes to the file browser's context menu.
|
||||
*
|
||||
* ── Why this exists beside tasks ──
|
||||
*
|
||||
* A task already reaches that menu, and for "do this to these files" it is the right thing: it runs a
|
||||
* script as the owner, reports through the job system, and needs no frontend at all.
|
||||
*
|
||||
* It is the wrong shape for anything that continues in the interface. "Create Gallery" from a folder is
|
||||
* not a job to watch — it is a name to type and a screen to land on, and expressing that as a script
|
||||
* would mean a job whose only output is somewhere to go next.
|
||||
*
|
||||
* So: a plugin exports `fileActions` from `web/file-actions.ts`, the generated plugin module collects
|
||||
* them, and the menu renders the ones that apply. Presence on disk is the declaration, exactly as
|
||||
* `panels.ts` and `layout.ts` already are.
|
||||
*
|
||||
* ── They run in the BROWSER ──
|
||||
*
|
||||
* Which is the whole difference. An action gets the selection and does what a panel would do — open a
|
||||
* dialog, call its own plugin's API, navigate. It has no privileges of its own: everything it calls
|
||||
* goes through the same permission gate as every other request that page makes.
|
||||
*/
|
||||
|
||||
/** One entry the action was invoked on. */
|
||||
export type FileActionEntry = {
|
||||
name: string;
|
||||
/** Absolute within the browser's root, as the browser addresses it. */
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
};
|
||||
|
||||
export type FileActionContext = {
|
||||
/** The folder currently open, so an action can act on "here" rather than on a selection. */
|
||||
currentPath: string;
|
||||
/** Re-read the listing. For an action that creates or removes something on disk. */
|
||||
refresh: () => void;
|
||||
/**
|
||||
* An authenticated client for Officer's API, rooted at `/api`.
|
||||
*
|
||||
* Handed over rather than left to the action, because there is no way for it to build one: `run` is
|
||||
* called from an event handler outside React, so it cannot use `useClient`, and a bare `fetch` gets a
|
||||
* 401 — this API authenticates with a bearer token, not a cookie, and only the hook holds it.
|
||||
*/
|
||||
client: {
|
||||
get: <T>(url: string) => Promise<T>;
|
||||
post: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
put: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
delete: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export type FileAction = {
|
||||
/** Unique across every plugin. Prefixed with the plugin name by convention, like panel keys. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** A lucide icon component, as the rest of the menu uses. */
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* Whether this action makes sense for what was selected.
|
||||
*
|
||||
* Given EVERY entry the action would receive, so an action that only handles images can refuse a
|
||||
* mixed selection rather than half-working. Returning false hides it — a menu of greyed-out items
|
||||
* nobody can explain is worse than a shorter menu.
|
||||
*/
|
||||
appliesTo: (entries: FileActionEntry[]) => boolean;
|
||||
run: (entries: FileActionEntry[], context: FileActionContext) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The registry, filled by the app at startup.
|
||||
*
|
||||
* ── Why injection rather than an import ──
|
||||
*
|
||||
* The generated plugin module lives in the APP (`src/apps/officer-web/Plugins.gen.tsx`) and this is a
|
||||
* workspace package the app depends on. Importing it from here would point the dependency backwards and
|
||||
* make this package unusable by anything that is not Officer's own frontend.
|
||||
*
|
||||
* So the package owns the list and the app hands it over — one call, at startup, before anything
|
||||
* renders a file browser.
|
||||
*/
|
||||
let registered: FileAction[] = [];
|
||||
|
||||
export const registerFileActions = (actions: FileAction[]): void => {
|
||||
registered = actions;
|
||||
};
|
||||
|
||||
/**
|
||||
* The actions that apply to this selection, in registration order.
|
||||
*
|
||||
* `entries` is what the action would receive: the whole selection when the right-clicked item is part
|
||||
* of a multiple selection, and just that item otherwise. That is what every file manager does, and the
|
||||
* alternative — acting on a selection the click was not part of — is the surprising one.
|
||||
*/
|
||||
export const fileActionsFor = (entries: FileActionEntry[]): FileAction[] =>
|
||||
registered.filter((action) => {
|
||||
try {
|
||||
return action.appliesTo(entries);
|
||||
} catch {
|
||||
// A plugin's predicate that throws hides its own action rather than breaking the menu for
|
||||
// everything else on it.
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export { useTasks, type TaskSummary } from './useTasks';
|
||||
export { useRecentFiles } from './useRecentFiles';
|
||||
export { usePinnedFiles } from './usePinnedFiles';
|
||||
export { FileBrowserApp, FileBrowserPanelWrapper } from './FileBrowserApp';
|
||||
export type { PickedFile } from './FileBrowserApp/useFileBrowserApp';
|
||||
export { FileBrowserWidget } from './FileBrowserWidget';
|
||||
export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
|
||||
|
||||
@@ -27,3 +28,5 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
export type { FileAction, FileActionEntry, FileActionContext } from './file-actions';
|
||||
export { registerFileActions, fileActionsFor } from './file-actions';
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||
import { getFileType, getThumbUrl } from './file-types';
|
||||
import { useFileViewer } from './FileViewerContext';
|
||||
|
||||
/**
|
||||
* Move between the files in a folder without going back to the listing.
|
||||
*
|
||||
* ── Which files ──
|
||||
*
|
||||
* Everything in the same folder the viewer can SHOW — images, video, audio, pdf, markdown, code, text.
|
||||
* Not directories and not archives: opening one is an extraction rather than a view, so putting them in
|
||||
* a strip of things you step through would offer a step that does something else entirely.
|
||||
*
|
||||
* ── It lists the folder itself ──
|
||||
*
|
||||
* The viewer is given one path and nothing else, and it is rendered by two different screens that reach
|
||||
* it in two different ways. Listing the folder here means neither has to pass a collection, and the
|
||||
* strip is right the moment a file is opened from anywhere — a link somebody sent, a search result, or
|
||||
* the grid.
|
||||
*
|
||||
* ── The thumbnails are the file browser's ──
|
||||
*
|
||||
* Same `/thumb` route, same cache, same size the grid asks for. So a folder already browsed has a warm
|
||||
* strip, and a HEIC in it works exactly where it works elsewhere rather than being a second thing to
|
||||
* teach about formats.
|
||||
*/
|
||||
|
||||
const STEPPABLE = new Set(['image', 'video', 'audio', 'pdf', 'markdown', 'code', 'text']);
|
||||
|
||||
export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
|
||||
const { filePath, root, onNavigate } = useFileViewer();
|
||||
const files = useFilesAPI(root || 'home');
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
|
||||
const folder = filePath.slice(0, filePath.lastIndexOf('/')) || '/';
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void files
|
||||
.listDir(folder)
|
||||
.then((data) => {
|
||||
if (live) setEntries(data.entries ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
// A folder that cannot be listed simply gets no strip. The file itself is already open and
|
||||
// readable; failing loudly here would break a working view over its navigation.
|
||||
if (live) setEntries([]);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// `folder` only: re-listing on every file change would refetch the same directory for each step.
|
||||
}, [folder]);
|
||||
|
||||
const siblings = useMemo(
|
||||
() =>
|
||||
entries
|
||||
.filter((e) => e.type === 'file' && STEPPABLE.has(getFileType(e.name)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const index = siblings.findIndex((e) => `${folder === '/' ? '' : folder}/${e.name}` === filePath);
|
||||
const go = useCallback(
|
||||
(delta: number) => {
|
||||
if (index === -1 || !onNavigate) return;
|
||||
const next = siblings[index + delta];
|
||||
if (next) onNavigate(`${folder === '/' ? '' : folder}/${next.name}`);
|
||||
},
|
||||
[index, siblings, folder, onNavigate],
|
||||
);
|
||||
|
||||
/*
|
||||
Keyboard, on the window.
|
||||
|
||||
Ignored while somebody is typing — the viewer can be EDITING a file, and stealing arrow keys from a
|
||||
text area would make the editor unusable. `isContentEditable` catches the rich cases that are not
|
||||
inputs.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return;
|
||||
|
||||
if (event.key === 'Escape' && onClose) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
go(-1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
go(1);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [go, onClose]);
|
||||
|
||||
// One file in the folder is not a carousel. The strip would be a row containing the thing already on
|
||||
// screen, and two arrows that do nothing.
|
||||
if (!onNavigate || siblings.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-duck-dark/10 bg-background/60 px-2 py-2 dark:border-foreground/10">
|
||||
<Caret side="left" disabled={index <= 0} onClick={() => go(-1)} />
|
||||
|
||||
<div className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto">
|
||||
{siblings.map((entry, i) => {
|
||||
const path = `${folder === '/' ? '' : folder}/${entry.name}`;
|
||||
const active = i === index;
|
||||
return (
|
||||
<button
|
||||
key={entry.name}
|
||||
type="button"
|
||||
onClick={() => onNavigate(path)}
|
||||
title={entry.name}
|
||||
/* `ref` scrolls the active one into view as you step, so the strip follows rather than
|
||||
being something you have to drag along behind you. */
|
||||
ref={(el) => {
|
||||
if (active) el?.scrollIntoView({ block: 'nearest', inline: 'center' });
|
||||
}}
|
||||
className={`h-12 w-16 shrink-0 overflow-hidden rounded border-2 transition-colors ${
|
||||
active ? 'border-duck-teal' : 'border-transparent opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{entry.thumb ? (
|
||||
<img
|
||||
src={getThumbUrl(path, root)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-full items-center justify-center px-1 text-[9px] leading-tight break-all text-duck-dark/60 dark:text-foreground/60">
|
||||
{entry.name.slice(0, 18)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Caret side="right" disabled={index === -1 || index >= siblings.length - 1} onClick={() => go(1)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Caret = ({ side, disabled, onClick }: { side: 'left' | 'right'; disabled: boolean; onClick: () => void }) => {
|
||||
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={side === 'left' ? 'Previous (←)' : 'Next (→)'}
|
||||
className="shrink-0 rounded p-1 text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-25 dark:text-foreground/60 dark:hover:bg-foreground/5"
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -5,6 +5,14 @@ export type FileViewerContextValue = {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
root: string;
|
||||
/**
|
||||
* Show a different file, without leaving the viewer. Absent when the host cannot express it.
|
||||
*
|
||||
* Each host writes it differently — the Files screen puts a name in `?view=`, an embedded panel puts a
|
||||
* path — so the viewer asks rather than deciding, and a host that offers nothing simply gets no
|
||||
* carousel.
|
||||
*/
|
||||
onNavigate?: (filePath: string) => void;
|
||||
fileType: FileType;
|
||||
content: string | null;
|
||||
loading: boolean;
|
||||
|
||||
@@ -13,6 +13,8 @@ type FileViewerProviderProps = {
|
||||
root?: string;
|
||||
content?: string;
|
||||
onOpenFile?: (filePath: string, root: string) => void;
|
||||
/** Show a different file in the same viewer — what the carousel calls. */
|
||||
onNavigate?: (filePath: string) => void;
|
||||
onReplaceView?: (viewPath: string, viewRoot: string, ephemeralPath: string, ephemeralRoot: string) => void;
|
||||
autoPlay?: boolean;
|
||||
children: ReactNode;
|
||||
@@ -45,6 +47,7 @@ export const FileViewerProvider = ({
|
||||
root = 'home',
|
||||
content: directContent,
|
||||
onOpenFile,
|
||||
onNavigate,
|
||||
onReplaceView,
|
||||
autoPlay = false,
|
||||
children,
|
||||
@@ -171,6 +174,7 @@ export const FileViewerProvider = ({
|
||||
<FileViewerContext
|
||||
value={{
|
||||
filePath,
|
||||
onNavigate,
|
||||
fileName,
|
||||
root,
|
||||
fileType,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { X } from 'lucide-react';
|
||||
import { FileViewerProvider } from './FileViewerProvider';
|
||||
import { FileViewerHeader } from './FileViewerHeader';
|
||||
import { FileViewerBody } from './FileViewerBody';
|
||||
import { FileCarousel } from './FileCarousel';
|
||||
|
||||
type FileViewerViewProps = {
|
||||
filePath: string;
|
||||
@@ -9,10 +10,12 @@ type FileViewerViewProps = {
|
||||
root?: string;
|
||||
content?: string;
|
||||
onClose?: () => void;
|
||||
/** Enables the carousel. Without it the viewer shows one file and no strip. */
|
||||
onNavigate?: (filePath: string) => void;
|
||||
};
|
||||
|
||||
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => (
|
||||
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
|
||||
export const FileViewerView = ({ filePath, fileName, root, content, onClose, onNavigate }: FileViewerViewProps) => (
|
||||
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content} onNavigate={onNavigate}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
|
||||
<FileViewerHeader />
|
||||
@@ -28,6 +31,11 @@ export const FileViewerView = ({ filePath, fileName, root, content, onClose }: F
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileViewerBody />
|
||||
</div>
|
||||
|
||||
{/* Below the content, outside the scrolling area — a strip that scrolled away with a long
|
||||
markdown file would be a control you have to go looking for. Renders nothing when the folder
|
||||
holds one viewable file, or when the host gave no `onNavigate`. */}
|
||||
<FileCarousel onClose={onClose} />
|
||||
</div>
|
||||
</FileViewerProvider>
|
||||
);
|
||||
|
||||
@@ -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 ', '') ?? '';
|
||||
@@ -132,6 +160,19 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number):
|
||||
return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}${audioParam}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file browser's own thumbnail, at the size its grid asks for.
|
||||
*
|
||||
* Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a
|
||||
* format that needs a plugin to decode works here exactly where it works there.
|
||||
*/
|
||||
export function getThumbUrl(filePath: string, root?: string, size = 256): string {
|
||||
const headers = getHeaders();
|
||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
|
||||
}
|
||||
|
||||
export function getAudioTracksUrl(filePath: string, root?: string): string {
|
||||
const headers = getHeaders();
|
||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { FileViewerView } from './FileViewerView';
|
||||
export { FileCarousel } from './FileCarousel';
|
||||
export { FileViewerProvider } from './FileViewerProvider';
|
||||
export { FileViewerHeader } from './FileViewerHeader';
|
||||
export { FileViewerBody } from './FileViewerBody';
|
||||
|
||||
@@ -16,8 +16,22 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
/*
|
||||
WHICH src has loaded, not whether one has.
|
||||
|
||||
The first fix reset a boolean in an effect on `src`, and that has a race a cached image wins every
|
||||
time: the effect runs AFTER render, so the browser fires `onLoad` for an image already in cache
|
||||
before the effect gets to clear the flag — and then the effect clears it, and `onLoad` never comes
|
||||
again. The spinner stayed forever on exactly the files that were fastest.
|
||||
|
||||
Comparing the loaded src to the current one has no ordering at all. It is false the instant `src`
|
||||
changes, true the moment that src's own load fires, and there is no window in which the two can be
|
||||
written in the wrong order.
|
||||
*/
|
||||
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
|
||||
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
||||
const loaded = loadedSrc === src;
|
||||
const error = errorSrc === src;
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const dragStart = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
|
||||
@@ -77,6 +91,30 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||
if (zoom <= 1) setOffset({ x: 0, y: 0 });
|
||||
}, [zoom]);
|
||||
|
||||
/*
|
||||
A NEW FILE IS A NEW LOAD, and everything about the last one goes.
|
||||
|
||||
`loaded` was set once and never cleared, which did not matter while a viewer showed one file for its
|
||||
whole life. With a carousel it is the bug you see: the flag stays true, the opacity gate stays open,
|
||||
and the browser goes on painting the PREVIOUS photograph until the next one has decoded. Stepping
|
||||
through a folder looked like the image lagged a beat behind the strip — the strip was right and the
|
||||
picture was stale.
|
||||
|
||||
Zoom, rotation and pan reset with it. They are a view of one image, and carrying 3x and 90 degrees
|
||||
onto the next photograph means arriving somewhere nobody asked to be, in the corner of a picture
|
||||
they have not seen yet.
|
||||
*/
|
||||
/*
|
||||
Zoom, rotation and pan are a view of ONE image, so they reset when the image changes. These stay in
|
||||
an effect because they have no race to lose: nothing else writes them during a load, and being one
|
||||
render late is invisible.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setZoom(1);
|
||||
setRotation(0);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}, [src]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -104,8 +142,8 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||
<img
|
||||
src={src}
|
||||
alt={fileName}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(true)}
|
||||
onLoad={() => setLoadedSrc(src)}
|
||||
onError={() => setErrorSrc(src)}
|
||||
className="transition-transform duration-150 select-none"
|
||||
draggable={false}
|
||||
style={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -53,6 +53,15 @@ export function ViewerProvider({ children }: { children: ReactNode }) {
|
||||
fileName={fileName}
|
||||
onOpenFile={onOpenFile}
|
||||
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
|
||||
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
|
||||
screen's own convention — so the absolute path the carousel works in is converted back. */
|
||||
onNavigate={(next) =>
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
|
||||
return params;
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
@@ -83,6 +92,15 @@ export function EphemeralProvider({ children }: { children: ReactNode }) {
|
||||
root={ephemeralRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
|
||||
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
|
||||
screen's own convention — so the absolute path the carousel works in is converted back. */
|
||||
onNavigate={(next) =>
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
|
||||
return params;
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
@@ -104,6 +122,15 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) {
|
||||
root={ephemeral2Root}
|
||||
autoPlay={ephemeral2Auto}
|
||||
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
|
||||
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
|
||||
screen's own convention — so the absolute path the carousel works in is converted back. */
|
||||
onNavigate={(next) =>
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
|
||||
return params;
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
|
||||
@@ -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)
|
||||
@@ -57,6 +59,9 @@ export {
|
||||
useRecentFiles,
|
||||
usePinnedFiles,
|
||||
FileBrowserApp,
|
||||
registerFileActions,
|
||||
type FileAction,
|
||||
type PickedFile,
|
||||
FileBrowserPanelWrapper,
|
||||
FileBrowserWidget,
|
||||
TaskRunnerModal,
|
||||
@@ -64,6 +69,7 @@ export {
|
||||
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
|
||||
export {
|
||||
FileViewerView,
|
||||
FileCarousel,
|
||||
FileViewerProvider,
|
||||
FileViewerHeader,
|
||||
FileViewerBody,
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Typ",
|
||||
"size": "Größe",
|
||||
"date": "Datum"
|
||||
}
|
||||
},
|
||||
"toCopy": "zu kopieren",
|
||||
"toMove": "zu verschieben",
|
||||
"clearClipboard": "Zwischenablage leeren"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stopp",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Type",
|
||||
"size": "Size",
|
||||
"date": "Date"
|
||||
}
|
||||
},
|
||||
"toCopy": "to copy",
|
||||
"toMove": "to move",
|
||||
"clearClipboard": "Clear clipboard"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stop",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Tipo",
|
||||
"size": "Tamaño",
|
||||
"date": "Fecha"
|
||||
}
|
||||
},
|
||||
"toCopy": "para copiar",
|
||||
"toMove": "para mover",
|
||||
"clearClipboard": "Vaciar portapapeles"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Detener",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Type",
|
||||
"size": "Taille",
|
||||
"date": "Date"
|
||||
}
|
||||
},
|
||||
"toCopy": "à copier",
|
||||
"toMove": "à déplacer",
|
||||
"clearClipboard": "Vider le presse-papiers"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Arrêter",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Tipo",
|
||||
"size": "Tamanho",
|
||||
"date": "Data"
|
||||
}
|
||||
},
|
||||
"toCopy": "a copiar",
|
||||
"toMove": "a mover",
|
||||
"clearClipboard": "Limpar área de transferência"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Parar",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Typ",
|
||||
"size": "Storlek",
|
||||
"date": "Datum"
|
||||
}
|
||||
},
|
||||
"toCopy": "att kopiera",
|
||||
"toMove": "att flytta",
|
||||
"clearClipboard": "Töm urklipp"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stoppa",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import type { LayoutNode, LayoutPanel } from 'officerdev';
|
||||
import { __testing } from './useAuthoredLayout';
|
||||
|
||||
const { collectSizes, applySizes } = __testing;
|
||||
@@ -12,7 +12,7 @@ const { collectSizes, applySizes } = __testing;
|
||||
* way it can be wrong is silent, because a layout with the wrong numbers in it still renders.
|
||||
*/
|
||||
|
||||
const panel = (id: string, appType: string): LayoutNode => ({ type: 'panel', id, appType });
|
||||
const panel = (id: string, appType: string): LayoutPanel => ({ type: 'panel', id, appType });
|
||||
|
||||
const group = (id: string, children: { node: LayoutNode; size: number }[]): LayoutNode => ({
|
||||
type: 'group',
|
||||
@@ -127,3 +127,93 @@ describe('a repair that cannot stick must not be written', () => {
|
||||
expect(JSON.stringify(afterRepair)).toBe(JSON.stringify(current));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Zoom, which was silently deleted for as long as only sizes were grafted.
|
||||
*
|
||||
* The failure was invisible rather than merely unsaved: the round trip produced a tree identical to the
|
||||
* one already held, so `setValue`'s no-write guard dropped it and the header went on reading 100%. These
|
||||
* tests are the round trip, at the level where it went wrong.
|
||||
*/
|
||||
describe('zoom survives the graft', () => {
|
||||
const { collectZooms, applyZooms, graft } = __testing;
|
||||
|
||||
/** The user has zoomed the chat panel, as `setZoom` would leave it. */
|
||||
const zoomed = group('root', [
|
||||
{
|
||||
node: group('lists', [
|
||||
{ node: panel('hosts', 'proxy-manager-hosts'), size: 62 },
|
||||
{ node: panel('certs', 'proxy-manager-certificates'), size: 38 },
|
||||
]),
|
||||
size: 66,
|
||||
},
|
||||
{ node: { ...panel('chat', 'proxy-manager-chat'), zoom: 1.2 }, size: 34 },
|
||||
]);
|
||||
|
||||
test('collects only the panels that carry one', () => {
|
||||
expect(collectZooms(zoomed)).toEqual({ chat: 1.2 });
|
||||
expect(collectZooms(source)).toEqual({});
|
||||
});
|
||||
|
||||
test('grafts onto the author structure, which never carries zoom itself', () => {
|
||||
const merged = graft(source, zoomed);
|
||||
expect(collectZooms(merged)).toEqual({ chat: 1.2 });
|
||||
});
|
||||
|
||||
test('a zoom change is a real difference, so the no-write guard lets it through', () => {
|
||||
const current = graft(source, source);
|
||||
const next = graft(source, zoomed);
|
||||
expect(JSON.stringify(next)).not.toBe(JSON.stringify(current));
|
||||
});
|
||||
|
||||
test('sizes and zoom graft together, neither eating the other', () => {
|
||||
const resized = group('root', [
|
||||
{
|
||||
node: group('lists', [
|
||||
{ node: panel('hosts', 'proxy-manager-hosts'), size: 20 },
|
||||
{ node: panel('certs', 'proxy-manager-certificates'), size: 80 },
|
||||
]),
|
||||
size: 66,
|
||||
},
|
||||
{ node: { ...panel('chat', 'proxy-manager-chat'), zoom: 0.8 }, size: 34 },
|
||||
]);
|
||||
const merged = graft(source, resized);
|
||||
expect(collectSizes(merged)).toEqual({ root: [66, 34], lists: [20, 80] });
|
||||
expect(collectZooms(merged)).toEqual({ chat: 0.8 });
|
||||
});
|
||||
|
||||
/**
|
||||
* `setZoom` deletes the field on reset rather than writing 1, so an absent id has to mean "no opinion"
|
||||
* — otherwise Reset zoom would be unreachable on a screen whose author shipped a zoom of their own.
|
||||
*/
|
||||
test('a reset drops back to whatever the author said', () => {
|
||||
const authored = group('root', [
|
||||
{ node: { ...panel('a', 'x'), zoom: 1.4 }, size: 50 },
|
||||
{ node: panel('b', 'y'), size: 50 },
|
||||
]);
|
||||
// Nothing stored: the author's 1.4 stands.
|
||||
expect(collectZooms(graft(authored, authored))).toEqual({ a: 1.4 });
|
||||
// The user overrides it.
|
||||
const overridden = group('root', [
|
||||
{ node: { ...panel('a', 'x'), zoom: 0.9 }, size: 50 },
|
||||
{ node: panel('b', 'y'), size: 50 },
|
||||
]);
|
||||
expect(collectZooms(graft(authored, overridden))).toEqual({ a: 0.9 });
|
||||
// The user resets — `setZoom` removed the field — and the author's value returns.
|
||||
const reset = group('root', [
|
||||
{ node: panel('a', 'x'), size: 50 },
|
||||
{ node: panel('b', 'y'), size: 50 },
|
||||
]);
|
||||
expect(collectZooms(graft(authored, reset))).toEqual({ a: 1.4 });
|
||||
});
|
||||
|
||||
test('a panel id that no longer exists is simply not applied', () => {
|
||||
const stale = group('root', [{ node: { ...panel('gone', 'x'), zoom: 2 }, size: 100 }]);
|
||||
expect(collectZooms(graft(source, stale))).toEqual({});
|
||||
});
|
||||
|
||||
test('applyZooms returns the node by reference when nothing changes', () => {
|
||||
const node = panel('a', 'x');
|
||||
expect(applyZooms(node, {})).toBe(node);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useDashboardState } from './useDashboardState';
|
||||
|
||||
/**
|
||||
* A layout whose STRUCTURE belongs to whoever wrote the screen, and whose SIZES belong to whoever uses it.
|
||||
* A layout whose STRUCTURE belongs to whoever wrote the screen, and whose VIEW belongs to whoever uses it.
|
||||
*
|
||||
* The view is sizes and zoom: the two things a `locked` screen still lets a person change.
|
||||
*
|
||||
* ── The bug this ends ──
|
||||
*
|
||||
@@ -20,18 +22,30 @@ import { useDashboardState } from './useDashboardState';
|
||||
* first panel and SAVED, so the corruption survived every reload, every fix, and every restart. The
|
||||
* row had to be deleted from Postgres, which is not a thing a user can do.
|
||||
*
|
||||
* ── The bug this ended a second time, and why zoom is here ──
|
||||
*
|
||||
* Sizes were the only thing grafted back, so `zoom` — which lives on the panel node, because the node is
|
||||
* the thing already persisted — was collected by nothing and rebuilt from `source` every render. The
|
||||
* result was a control that rendered, took the click, and discarded it: `setValue` re-derived a tree
|
||||
* identical to the one it already had, the no-write guard below dropped it as a no-op, and the header
|
||||
* went on reading 100%. Silent on every plugin screen, `/bots` and `/plugins`, and reported as "the zoom
|
||||
* buttons do nothing".
|
||||
*
|
||||
* The lesson is the general one: anything the user owns has to be named here, because the default for a
|
||||
* field this hook does not know about is not "passed through" — it is "deleted, quietly".
|
||||
*
|
||||
* ── The fix, and why it is a shape rather than a rule ──
|
||||
*
|
||||
* The structure comes from `source` on every render and is never read back from storage. Only sizes are
|
||||
* The structure comes from `source` on every render and is never read back from storage. Only the view is
|
||||
* persisted. So a stale structure cannot exist, a repair has nothing to write itself into, and a plugin
|
||||
* that changes its layout reaches everybody on the next load — with no version to bump, no migration, and
|
||||
* nothing for an author to remember.
|
||||
*
|
||||
* The user keeps the one thing that was theirs.
|
||||
* The user keeps the things that were theirs.
|
||||
*
|
||||
* ── What is stored ──
|
||||
*
|
||||
* `source` with the user's sizes applied — a valid layout, because the storage validates one on both the
|
||||
* `source` with the user's view applied — a valid layout, because the storage validates one on both the
|
||||
* route and a CHECK constraint, and because a value that is always freshly derived from source cannot rot.
|
||||
* Its structure is written and never read. That asymmetry is the whole design and is why it is safe.
|
||||
*
|
||||
@@ -43,6 +57,7 @@ import { useDashboardState } from './useDashboardState';
|
||||
*/
|
||||
|
||||
type Sizes = Record<string, number[]>;
|
||||
type Zooms = Record<string, number>;
|
||||
|
||||
const isGroup = (node: LayoutNode): node is LayoutGroup => node.type === 'group';
|
||||
|
||||
@@ -54,6 +69,22 @@ function collectSizes(node: LayoutNode, into: Sizes = {}): Sizes {
|
||||
return into;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every panel's zoom, by panel id — and only where one is actually set.
|
||||
*
|
||||
* `setZoom` DELETES the field on a reset to 100% rather than writing 1, so an absent id means "the user
|
||||
* has no opinion" and the author's value stands. That is what makes Reset zoom reachable on a screen
|
||||
* whose author shipped a zoom of their own.
|
||||
*/
|
||||
function collectZooms(node: LayoutNode, into: Zooms = {}): Zooms {
|
||||
if (!isGroup(node)) {
|
||||
if (node.zoom !== undefined) into[node.id] = node.zoom;
|
||||
return into;
|
||||
}
|
||||
for (const child of node.children) collectZooms(child.node, into);
|
||||
return into;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put saved sizes back onto the author's structure.
|
||||
*
|
||||
@@ -78,22 +109,39 @@ function applySizes(node: LayoutNode, sizes: Sizes): LayoutNode {
|
||||
};
|
||||
}
|
||||
|
||||
/** Put saved zoom back onto the author's structure. A panel id nobody saved keeps whatever `source` says. */
|
||||
function applyZooms(node: LayoutNode, zooms: Zooms): LayoutNode {
|
||||
if (!isGroup(node)) {
|
||||
const saved = zooms[node.id];
|
||||
if (saved === undefined || saved === node.zoom) return node;
|
||||
return { ...node, zoom: saved };
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map((child) => ({ ...child, node: applyZooms(child.node, zooms) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The author's structure wearing the user's view. The only way either half of this hook builds a tree. */
|
||||
const graft = (source: LayoutNode, stored: LayoutNode): LayoutNode =>
|
||||
applyZooms(applySizes(source, collectSizes(stored)), collectZooms(stored));
|
||||
|
||||
/**
|
||||
* Same shape as `useDashboardState`, so a screen swaps one call and changes nothing else.
|
||||
*
|
||||
* `setValue` accepts a whole layout because that is what `WorkspaceView` reports — a resize hands back the
|
||||
* entire tree. Everything but its sizes is discarded here, which is precisely the point: the view is free
|
||||
* to report a normalised, repaired, or otherwise rewritten structure and none of it can be stored.
|
||||
* entire tree. Everything but the user's view is discarded here, which is precisely the point: the view is
|
||||
* free to report a normalised, repaired, or otherwise rewritten structure and none of it can be stored.
|
||||
*/
|
||||
export function useAuthoredLayout(key: string, source: LayoutNode) {
|
||||
const stored = useDashboardState<LayoutNode>(key, source);
|
||||
|
||||
const value = useMemo(() => applySizes(source, collectSizes(stored.value)), [source, stored.value]);
|
||||
const value = useMemo(() => graft(source, stored.value), [source, stored.value]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => {
|
||||
const next = typeof update === 'function' ? update(value) : update;
|
||||
const merged = applySizes(source, collectSizes(next));
|
||||
const merged = graft(source, next);
|
||||
|
||||
/*
|
||||
A write that changes nothing is dropped, and this is not an optimisation — it is what stops an
|
||||
@@ -109,6 +157,10 @@ export function useAuthoredLayout(key: string, source: LayoutNode) {
|
||||
panel keys, so every render of `/browser` produced a repair. Shipping the sizes-only hook turned a
|
||||
silent, harmless fallback into a hard crash on that screen.
|
||||
|
||||
It is also why a field this hook forgets to graft is INVISIBLE rather than merely unsaved: the
|
||||
change round-trips to the same tree, compares equal, and is dropped here. That is exactly how zoom
|
||||
was lost.
|
||||
|
||||
Comparing the serialised value is enough — these trees are small, this runs only on a real change,
|
||||
and it is the same check `setValue` would otherwise force React Query to make against a new object.
|
||||
*/
|
||||
@@ -123,4 +175,4 @@ export function useAuthoredLayout(key: string, source: LayoutNode) {
|
||||
}
|
||||
|
||||
/** The tree walking, exposed for its test. Not part of the hook's API — import the hook. */
|
||||
export const __testing = { collectSizes, applySizes };
|
||||
export const __testing = { collectSizes, applySizes, collectZooms, applyZooms, graft };
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
theme: integrations
|
||||
heading: 'Integrations — external tools worth wrapping as a plugin'
|
||||
kind: theme
|
||||
---
|
||||
|
||||
Third-party self-hosted tools evaluated for a plugin, with what was actually checked (license,
|
||||
activity, self-hosting shape, credential cost) rather than a name someone liked.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: integrations/openseo-a-plugin-candidate
|
||||
title: 'OpenSEO — a strong plugin candidate, checked rather than assumed'
|
||||
theme: integrations
|
||||
status: open
|
||||
created: 2026-08-27
|
||||
date_source: stated
|
||||
order: 1
|
||||
---
|
||||
|
||||
**OpenSEO (`every-app/open-seo`) — worth building a plugin around.** Asked to be evaluated 2026-08-27;
|
||||
checked against gitea rather than taken on the README's word.
|
||||
|
||||
**What it is:** a self-hosted, open-source alternative to Semrush/Ahrefs — keyword research, rank
|
||||
tracking, competitor insights, backlinks, site audits, AI visibility. MIT licensed, TypeScript,
|
||||
**14.4k stars, 1.7k forks, pushed 3 days ago** (verified via `gh repo view`, not the README's own
|
||||
framing) — a real, active project, not an abandoned demo.
|
||||
|
||||
**Why it fits this platform specifically, more than most tools would:** it ships **its own MCP server
|
||||
and Agent Skills**, built explicitly to be driven by "Claude Code, OpenClaw, or Hermes." That is
|
||||
Officer's whole plugin thesis already built by someone else — most candidates need Officer to invent
|
||||
the agent-facing surface; this one already has one.
|
||||
|
||||
**The self-hosting shape is the easy case, verified from `compose.yaml` and the docs, not assumed:**
|
||||
|
||||
- **One container, no separate database.** Cloudflare D1 (SQLite) by default — Postgres is opt-in only
|
||||
for installs that outgrow it (`docs/LOCAL_POSTGRES.md`). Same shape as Jellyfin, Gitea, Transmission:
|
||||
a single sidecar, nothing extra to provision.
|
||||
- **One required credential**: a DataForSEO API key, paid pay-as-you-go DIRECTLY to DataForSEO — not a
|
||||
subscription to OpenSEO itself unless using their hosted version. Two more are optional
|
||||
(OpenRouter for its own AI features, Google OAuth for Search Console). Real cost, worth saying
|
||||
plainly: this is free software over a metered API, not a free service.
|
||||
- **Self-hosting runs `AUTH_MODE=local_noauth` by design** — their own docs say explicitly _"only
|
||||
expose it behind your own auth-protected reverse proxy, tunnel, or private network."_ That sentence
|
||||
describes exactly what an Officer plugin already is: mounted behind Officer's own permission gate,
|
||||
never exposed on its own. This is the ordinary case for a plugin here, not a special risk to design
|
||||
around.
|
||||
|
||||
**What a plugin would actually need to add, and it may be less than usual:** because OpenSEO already
|
||||
runs its own MCP server, the plugin's job is closer to credential plumbing and a UI surface
|
||||
(`osDependencies`-style container/sidecar, the DataForSEO key in the per-user/server credential store,
|
||||
a panel embedding or proxying its web UI) than inventing new MCP tools from scratch — most of the
|
||||
agent-facing work is already done upstream.
|
||||
|
||||
Open, for whoever picks this up: whether Officer's agents call OpenSEO's own MCP endpoint directly
|
||||
(simplest, if reachable over whatever network the plugin runs on) or Officer wraps it as
|
||||
Officer-native MCP tools for consistency with how other plugins expose theirs; and where the container
|
||||
runs given `todo/infra/opm-officer-s-own-process-manager` is heading toward the Docker network
|
||||
specifically so container-backed dependencies like this one need no published host port.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: known-bugs/officer-blocks-its-event-loop-parsing-transcripts
|
||||
title: 'Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it'
|
||||
theme: known-bugs
|
||||
status: open
|
||||
created: 2026-08-29
|
||||
date_source: stated
|
||||
order: 7
|
||||
---
|
||||
|
||||
**Found 2026-08-29 while chasing "Minesweeper is laggy today".** It is not a Minesweeper bug. The game's
|
||||
display is relayed through officer, and officer periodically stops answering anything for up to a second.
|
||||
|
||||
**The measurements, in order.** Press-to-paint at x11vnc's own socket: **median 29ms, max 35ms**, stable
|
||||
even with a 1.5s idle gap before each click. Through the sidecar's own `/ws` relay: **median 29ms, max
|
||||
43ms** — so the sidecar adds nothing. In the browser, where it is actually felt: **35ms to 200ms+, varying
|
||||
per click**. All of the variance is introduced between the sidecar and the browser, and the only thing
|
||||
there is officer.
|
||||
|
||||
Officer answering a trivial unauthenticated 401 on `/api/health`, 30 samples: **median 1ms, p90 2ms, max
|
||||
490ms.** The loop is fine until it is blocked outright.
|
||||
|
||||
**What blocks it.** `summarizeTranscript` (`api/chat/claude-sessions.ts`) full-reads and `JSON.parse`s a
|
||||
whole transcript to build a session-list row. Timed against the largest on this machine — 196 MB, 70,137
|
||||
entries: `readFileSync` **323ms**, split + parse every line **541ms**, **864ms blocking, per call**.
|
||||
|
||||
It is cached, and the cache cannot help. The key is mtime, and the comment above it says why that was
|
||||
believed to be exact: _"Claude only ever appends"_. But a **live** session appends every few seconds, so
|
||||
its mtime moves, the entry misses, and the whole 196 MB is re-read and re-parsed — for a row in a list.
|
||||
The busier the agent, the more often. `468 MB across 174 transcripts` here, and the biggest one belongs to
|
||||
a commander that has been running all day.
|
||||
|
||||
**Why it reads as a Minesweeper regression.** Nothing in the plugin changed (last commit two days ago), and
|
||||
the same cost was invisible when that transcript was 10 MB and this took ~45ms. It scales with transcript
|
||||
size, so it arrives gradually and then all at once.
|
||||
|
||||
**Scope is much wider than the game.** Every HTTP request, every chat socket frame and every relayed plugin
|
||||
websocket shares that loop. The game is just the one surface where a 500ms stall is impossible to miss.
|
||||
|
||||
**The fix the file argues for itself.** _"Claude only ever appends"_ is exactly what makes incremental
|
||||
summarisation correct: keep the running summary keyed on `(path, size)` and consume only the bytes appended
|
||||
since last time, instead of re-reading from zero. Turns ~864ms into the cost of the new tail. `listClaudePwds`
|
||||
already does a bounded head read for `cwd` (`claude-sessions.ts`, "a bounded head read"), so the precedent
|
||||
for not reading the whole file is in the same module.
|
||||
|
||||
Not attempted here: it is a hot path shared by every surface, and the right shape — incremental parse versus
|
||||
moving summaries out of the request path entirely — is an architecture call rather than a patch.
|
||||
Reference in New Issue
Block a user