Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 8d39586de5 two minutes, not ten
The way this gets used is: ship a plugin, open the page, look for the badge. Ten
minutes of that is indistinguishable from the feature not working — a release
tagged at 12:42 was invisible at 12:45 because the last fetch was at 12:37, which
is correct and no comfort.

Bounded by the page being open: nothing fetches when nobody is looking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:47:09 +01:00
pastilhasandClaude Opus 5 eaa92738dc give :section back — the splat took it from fifteen plugins
`/<route>` and `/<route>/:section` are what every plugin has always had, and
fifteen of them read useParams().section. Replacing the pair with a splat took
that parameter away from all of them at once: the screens still mounted and every
one lost its selection, which is worse than a 404 because nothing says so.

The splat stays, for depth. React-router ranks a static or dynamic segment above
it, so a plugin with one segment gets `section` again and only deeper paths fall
through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:37:14 +01:00
pastilhasandClaude Opus 5 b2349525fd the update badge stops going stale
releaseState compares the tag at HEAD with the newest tag on origin/<branch> — a
remote-tracking ref, and nothing refreshed it. Tags arrived at clone and at the
fetch pullPlugin does, so you only learned an update existed BY UPDATING.

alpha had photos at 2026.08.27.01, last fetched 27 August, comparing this
morning's release against a snapshot from before it existed. Truthfully at the
latest, and useless.

refreshRemote fetches in the background: the request answers from the refs it has
and the next one is current. Rate-limited per checkout, one at a time, silent on
failure — the list still has a true answer without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:31:30 +01:00
pastilhasandClaude Opus 5 898209725a a plugin owns the routing under its mount
Reverts the manifest route table. Declaring a plugin's URLs to the platform put
the platform in the middle of a decision that is not its own, and it was more
code than what it replaced.

One splat where there were two routes. A splat matches zero segments, so the
bare route still works, and the plugin reads its own position off the location.
The platform owns where a plugin starts and nothing after it — the same line
mountPrefix already draws around its API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:17:33 +01:00
pastilhasandClaude Opus 5 1fe767ea77 a plugin screen can declare its routes
Every screen got `/<route>` and `/<route>/:section` and nothing deeper, so a
section had to live in the query string. A screen may now name its routes in
the manifest, and give one its own arrangement of the screen's panels.

Absent, the old pair is unchanged — which is every plugin but fofos-admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 04:57:18 +01:00
pastilhasandClaude Opus 5 43f32a7440 export useAuthoredLayout, for a workspace inside a panel
A nested workspace wants the same bargain PluginScreen makes: structure from source every render, only
sizes and zoom persisted. `useDashboardState` was the only export, and it persists the whole tree —
right for a dashboard somebody arranged, wrong for a layout its author ships, because the structure
would freeze at whatever it was the first time a screen was opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:02:37 +01:00
pastilhasandClaude Opus 5 cdbf72c7fe the file browser says when something is a link
An arrow beside the name, and the target in its tooltip.

`stat` follows a symlink, so the listing has always described what the link POINTS AT and never that
there was a link in the way. That is right for behaviour — a link to a folder must still say
`directory` so opening it works — and it is why `stat` cannot answer this question at all.

`readdir({ withFileTypes: true })` can, without a second syscall per entry: the kind comes off the
directory entry itself. `readlink` is asked only for the entries that are links, which in an ordinary
folder is none of them.

A BROKEN link now gets a row, and did not before. `stat` failed on it, the entry returned null, and it
disappeared from the listing — nothing said it was there, nothing could delete it, and the folder
quietly had fewer things in it than `ls` showed. It is drawn in the destructive colour with its dead
target in the tooltip.

The target is stored exactly as written rather than resolved. `../originals` moves with the folder and
`/home/…/originals` does not, and rewriting one into the other would hide that difference at the
moment somebody is looking in order to understand it.

An arrow, not a badge or a different icon: the icon says what the thing IS, and a symlink to a folder
is still a folder to whoever uses it. What the arrow adds is the indirection — which is what explains
why deleting it freed no space, or why two places show the same file.

Verified against real links: to a file, to a directory, absolute, and broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:10:21 +01:00
pastilhasandClaude Opus 5 ab2336516f dev-proxy: a stylesheet's url() is a URL
Two bugs, one line apart, both silent.

The `url(/…)` rule only matched an UNQUOTED target, so `url("/fonts/x.woff2")` — the form every
generator emits — went through untouched. And it only ran on HTML, so a linked stylesheet got the
narrow `/_bun/` rule and nothing else.

Together that meant a site's own @font-face rules kept pointing at `/fonts/…`, which inside the frame
is the platform's root. Every face 404'd and the preview rendered in a system fallback: nothing looked
broken, it just looked like a different site than the one being previewed.

JavaScript keeps the narrow rule — an absolute path in JS may not be a URL. In CSS it always is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 06:42:14 +01:00
pastilhasandClaude Opus 5 9f243f569f native controls follow the theme
Nothing set `color-scheme`, so the browser drew every control it renders itself as if the page were
light. On a dark theme a <select>'s dropdown opened as a white sheet with the page's own pale text on
it — unreadable, and unreachable by any utility class, because that list is painted by the browser and
is not in the document.

`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with no
styleable interior: date and colour pickers, the spinner on a number input, and default scrollbars
where a theme has not replaced them.

The `dark` class decides it, because that is the signal that exists — 203 of the 231 themes set
applyDarkClass, and the provider puts it on <html> beside data-theme, so the two always agree about the
same element. Both are specificity 0-1-0 and themes import after this file, so a theme that disagrees
can say so. This is a default, not a decision taken away.

Explicit colours on select/option/optgroup underneath, because color-scheme gives the browser's generic
dark rather than the theme's. Where a browser honours them — Firefox, Chrome on Linux and Windows — the
list becomes the theme's own popover surface. Where it does not, Safari and Chrome on macOS use a
genuinely native menu and the scheme carries it, which is a great deal better than white on white.

Base-layer rules, so a utility still wins and `bg-transparent` on a select behaves as it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:38:59 +01:00
pastilhasandClaude Opus 5 160788780b the dev proxy did not rewrite srcset
Responsive images 404'd in every preview, and did it silently.

The attribute rule matches a whole attribute value, so it could never have handled srcset — that is a
comma-separated LIST of "url descriptor" pairs, and prefixing it as one value would mangle the first
descriptor onto the first URL. It did not match srcset at all.

Silently is the operative word. An <img> with a srcset also has a src, and the src WAS prefixed — but a
browser given a valid srcset chooses from it and never looks at src. So the markup read correctly, one
attribute pointed at the platform root, and the image was just missing. Found by a logo that would not
load in the fofos layout preview.

imagesrcset is rewritten too. That is the one on <link rel="preload" as="image">, easy to miss and
expensive to miss: a preload that 404s costs a request and leaves the real image undiscovered until the
body is parsed, which is the opposite of why anybody adds one.

Six tests, including the case that found it — src prefixed, srcset not, image missing — and a
protocol-relative candidate left alone, which is the rule everywhere else in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:10:58 +01:00
pastilhasandClaude Opus 5 41f54a292b the constraint goes on the grid child, not inside it
The previous fix put `min-w-0` on the scroll container, which was one level too low to stop anything.
DialogContent is a grid; the element that expands is its DIRECT child, and between the two sat a
`flex flex-col gap-3` wrapper with no constraint at all. It grew to fit the title and took the card out
of the dialog with it, exactly as before.

Found by mapping the element chain from DialogContent down rather than reasoning about which element
"should" be the one — the wrapper does not appear in any of the code you read while thinking about the
card, which is why two attempts missed it.

Also confirmed the previous fix was genuinely live before blaming it: the served chunk on fofos carried
the new class and not the old one. The code was right and the diagnosis was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 23:00:14 +01:00
pastilhasandClaude Opus 5 ae70dd5ea8 the download dialog stops growing to fit its titles
A long video title pushed the modal wider than itself and ran out of the card. The title had `truncate`
and its parents had `min-w-0 flex-1`, and none of it bit.

DialogContent is a GRID. A grid item defaults to `min-width: auto` — it refuses to shrink below its
content — so the list container expanded past the dialog's own `max-w-lg`, the card grew with it, and
`truncate` never had a width to truncate against. Every constraint below that point was measured against
a box that had already given up.

`82d24a8f` made the card a column, which was a real fix for a real half of this: the title, thumbnail
and button used to share a line and the title won. But a column still cannot be narrower than the
widest thing the grid above it will allow, so the fix held only until a title long enough came along.

Worth knowing beyond this dialog: every DialogContent in the app is that same grid, so any dialog
holding unbreakable content has the same trap waiting. Fixed here rather than in the shared component,
because changing the layout of every dialog in the platform to fix one modal is not a change to make
without asking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 22:40:57 +01:00
pastilhas 060033322d deploy 2026.08.30.03: 7 of 7 hosts, fofos plugins:update back to 5/5 2026-08-30 11:45:49 +01:00
pastilhasandClaude Opus 5 c97da0292a release 2026.08.30.03
One commit — 537cc29c, fixing what the 2026.08.30.01 rollout found on fofos: a plugin declaring
`@tiptap/pm` was reported as a failed install. Nothing was broken on disk. That package has no root
entry point by design — it is tiptap's single pinned copy of ProseMirror and you import
`@tiptap/pm/state`, never the bare name — so the checker asked a question it could not answer.

Worth its own release rather than waiting: `bun run plugins:update` exits 1 on fofos every time until
this lands, so the plugin step of every future deploy fails there and reads as a new problem each time.

No schema change and no dependency change, with the two commands that prove it in the plan. docs:check
green. Only fofos has fofos-admin installed, so everywhere else this is a no-op in behaviour.

── Why .03 ──

2026.08.30.02 was tagged against a commit that was not on master: the rollout's own report had just been
pushed and the release was cut without pulling first. The branch push was rejected and the tag was not,
so a tag pointing off-branch existed for about ten seconds. The watcher claimed the version inside that
window, and a claim is permanent by design — so .02 is spent and carries a claim with no .done, which is
what "attempted and did not succeed" should look like.

No host moved. Every one stayed on 816f8695 throughout.

The lesson is in the ordering, and it is now the practice: pull, then push the branch, then tag only
once the branch has landed. Pushing both in one command means a rejected branch still publishes a tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:38:06 +01:00
pastilhasandClaude Opus 5 d2c07a9fd2 deploy 2026.08.30.01: 7 of 7 hosts, officer-dev skipped as the host it ran on
fofos-admin failed its resolvability check on fofos — @tiptap/pm has no root
entry point, so the install was perfect and the checker was wrong. Fixed by
537cc29c, which is one commit past this tag and therefore deployed nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:23:23 +01:00
pastilhasandClaude Opus 5 537cc29c3e a package with no root entry point is not a broken install
`unresolvableDependencies` asked `Bun.resolveSync(name, pluginDir)` of every declared package. That is
the right question for almost all of them and unanswerable for a few.

`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no "." — it exists only as
subpaths. You import `@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name Bun
correctly says it cannot, and this read that as a failed install.

It cost a deploy. fofos-admin declares `@tiptap/pm` because tiptap needs it present; the install was
perfect — the directory, its dist and thirteen prosemirror packages all there — and the plugin was
reported as failed on every host that has it.

The strong check stays first, because it is the one that catches a package which installed but cannot
be loaded on this machine — `sharp` resolving to a platform-specific binary is the shape it was written
for. Only a package that fails it is asked the weaker question: is it there. Present-but-unimportable
still fails, since it never reaches the second line.

Verified on the host rather than reasoned about: of the five packages fofos-admin declares, four
resolve and @tiptap/pm alone does not, and it is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:20:25 +01:00
pastilhasandClaude Opus 5 816f869535 release 2026.08.30.01
The file browser's clipboard moves to the server, files can be dragged between windows, and plugins
reach further into the file browser.

No schema change and no dependency change — checked rather than assumed, and the plan records the two
commands that prove it. The clipboard needs no migration: it is a key in `user_state`, a JSONB column
that already exists, written through the same route the dock and the view mode already use.

Eleven commits since 2026.08.29.04, one of which is that release's report.

Surveyed all seven target hosts while writing the plan. Every one answered and every one is clean.
`fofos` sits ahead of the previous tag and behind this one — it has been hand-pulled all day while the
fofos plugin work was written against it — and that commit is an ancestor of this tag, so its
fast-forward is ordinary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:13:11 +01:00
pastilhasandClaude Opus 5 d058cd9258 re-reading a folder does not blank it
`loading` swaps the whole grid for a spinner. That is right when arriving somewhere new and wrong when
re-reading a folder already on screen — and every refresh went through the same path: after a paste,
after a delete, and now every time a window comes forward. On a folder of a hundred files that is a
flash and a lost scroll position, to replace information that was already correct.

The spinner is now for a folder that cannot yet be shown: nothing listed, or a different folder than
the one listed. Re-reading the same folder leaves what is there until better information arrives, so
the only visible change is whatever actually changed.

`shownPath` is what separates the two cases. From inside `refresh` they are identical — it is called
the same way by a navigation and by a refresh — and they need opposite treatment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:09:31 +01:00
pastilhasandClaude Opus 5 dfe837dfd0 a window re-reads its folder when it comes forward
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.

Same trigger as the clipboard, for the same reason: coming back to a window is exactly when its
contents need to be true, and never while nobody is looking at it.

`refresh` is held in a ref because it is rebuilt on every render — in the dependency array it would
tear down and re-add both listeners several times a second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:05:36 +01:00
pastilhasandClaude Opus 5 a53c07d71f files can be dragged between windows
A custom MIME 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, a folder in each. So a row carries
`application/x-officer-files` with its paths, a folder accepts the drop, and the file moves.

What it cannot do is cross different browsers, or exchange with Finder — dragging out to the desktop
needs a Chrome-only API and only works outward. Worth knowing before expecting it.

`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. The container's
drop handler checks for ours FIRST, because a drag from inside has no `Files` and would otherwise fall
through to "read the dropped files", find none, and do nothing at all.

Dragging also writes the clipboard, which is cheap now that it 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 is a gesture over the same mechanism rather than a second one beside it.

Dragging one of several selected rows takes the whole selection; dragging an unselected row takes just
that one. Dropping a folder into itself or into its own child is refused here rather than by the
server, and the comparison uses a trailing separator so /a/bc is not read as living inside /a/b.

`onDragOver` calls preventDefault, which is the single line whose absence makes a drop target look
dead with no error anywhere. `dropEffect` is set by the target rather than the source, 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:58:38 +01:00
pastilhasandClaude Opus 5 e3071f5d50 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 — 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 this kind of thing, and `useUserState`
already reads and writes it through React Query with an optimistic local update. So the clipboard is
one key in it: no new table, no new route, no 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.

── Other windows have to notice ──

USER_STATE is held with `staleTime: Infinity`, which is right for a dock layout and wrong for
something another window can change while this one is idle. It is invalidated on `focus` and on
`visibilitychange` — the second because a background tab brought forward by switching tabs fires only
that one in some browsers.

Focus rather than a poll or a socket, and that is a judgement rather than a shortcut: pasting into the
other window means focusing it first, so the refresh happens exactly when it is needed and never while
nobody is looking.

── And it says what it is holding ──

An icon and a toast were enough while the clipboard died with the tab: you had just pressed the key,
so you knew what was on it. Now somebody can arrive at a folder with three files pending from
yesterday and paste them without ever having been told. So the toolbar says how many and whether they
will be copied or moved, and offers a way to let go of them. Invisible persistent state is how a file
gets moved by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:55:24 +01:00
pastilhasandClaude Opus 5 601849be11 a file action gets an authenticated client, because it cannot build one
`run` is an event handler outside React, so it cannot call `useClient` — and a
bare fetch gets a 401, since this API authenticates with a bearer token rather
than a cookie and only the hook holds it.

So the browser hands one over in the context. Without it, the only way to call
Officer's own API from an action was to reimplement the token handling, which is
the kind of thing that works on one page and silently stops when the token
rotates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:18:52 +01:00
pastilhasandClaude Opus 5 1f75fb19af forward pluginActions into menuProps, or the menu never sees it
"Create Gallery" was in the bundle and not in the menu. The prop was typed on
both ends and carried by neither: FileItem builds `menuProps` field by field, so
declaring a prop does not make it arrive — and it was not destructured in
FileItem at all, only in the menu component below it.

Two halves of the same mistake, and the symptom is silent in both directions:
the action existed, applied, and was never asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:11:18 +01:00
pastilhasandClaude Opus 5 a6d4a18592 a plugin can put an action in the file browser's context menu
Tasks already reach that menu and are right for "do this to these files" — a
script, run as the owner, reported through the job system, needing no frontend.

They are 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` and the generated
module collects them. Presence on disk is the declaration, exactly as panels.ts
and layout.ts already are — the manifest says nothing, because an action carries
its own label and decides for itself whether it applies.

They are registered by injection rather than imported by the browser.
Plugins.gen.tsx is generated INTO the app, and the file browser lives in a
workspace package that app depends on; importing it there would point the
dependency backwards and make the package unusable outside Officer's frontend.

The selection is resolved by FileGrid, which has it, and FileItem is handed
ready-made items. 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.

An action whose `appliesTo` throws hides itself rather than breaking the menu for
everything else on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:59:20 +01:00
pastilhasandClaude Opus 5 e756007551 the file browser can be a file picker
A prop, not a second component. `onPickFile` turns opening a file into handing it
back rather than opening a viewer; folders still navigate, because choosing a
file means getting to it.

Building a picker separately would mean reimplementing a listing, a breadcrumb,
sorting, thumbnails, upload and keyboard handling — all of it free to drift from
the real browser, and none of it better. fofos-admin needs one to choose media
and it is the first of several, so this belongs here rather than in the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:30:22 +01:00
pastilhasandClaude Opus 5 9719e36723 a plugin may not bring its own React
Installing tiptap for fofos-admin took the whole plugin screen down: "Cannot
read properties of null (reading 'useRef')", both panels, nothing rendering.

Two Reacts. The platform has 19.2.4; `@tiptap/react` declares React as a PEER,
bun installs peers, and 19.2.8 landed in plugins/node_modules where it wins
resolution for everything underneath. React holds its hook dispatcher in module
state, so a second instance is not a duplicated dependency — it is a broken one,
and the error names nothing that would lead anybody here.

Easy to reach by accident, since nobody asks for React. It arrives underneath
something that does.

So platform-owned packages are stripped from the merged list AND deleted after
the install, and the second is the one that does the work: no plugin declares
React, so there is nothing in the list to strip. Deleting afterwards is crude and
deterministic, which is the right trade for a failure whose symptom is every
panel on a screen refusing to render.

Stripped rather than pinned. Matching the platform's version would still be two
copies, and two copies is the entire problem — bun resolves up the tree, so
removing them is what makes a plugin use the one already loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:19:35 +01:00
pastilhasandClaude Opus 5 912ecd04f7 a plugin can hand the agent its own MCP server
openseo is the first: it fronts something that already ships an MCP server, and
the choice was to 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
one manifest 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 twice.

The manifest declares a PATH and never a URL, and that is the safety property
rather than a convention: mcp-host.json carries OFFICER_AUTH_TOKEN, which 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 reach this machine.

Installed AND enabled, since a disabled plugin's routes are not mounted and
offering the agent a tool list that 404s is worse than offering nothing. A
plugin that cannot be read loses its tools for the session rather than stopping
the agent from starting.

`generateMcpConfig` becomes async because it now asks the database. Awaited, not
fire-and-forget: setMcpConfigPath is what the manager reads when it spawns, so a
config arriving later is an agent running without its plugin tools and no sign
of why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:53:22 +01:00
pastilhasandClaude Opus 5 dd8c7fd7bd deploy 2026.08.29.04: 7 of 7 hosts, officer-dev skipped as the host it ran on
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 14:30:07 +01:00
35 changed files with 1536 additions and 60 deletions
+76
View File
@@ -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.
+99
View File
@@ -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.
+114
View File
@@ -0,0 +1,114 @@
# 2026.08.30.01 — rollout report
**7 of 7 targets succeeded. `officer-dev` skipped by design — it is the host the rollout ran on.**
One plugin failed on one host (`fofos-admin` on `fofos`) and it is a false alarm in the checker, not a
broken install. The owner fixed it mid-rollout in `537cc29c`, which is NOT in this tag. Detail below.
No schema change, no `bun install`, no `bun db:push` — the plan says so and shows the two
`git diff --name-only` commands behind the claim. Nothing was improvised.
## Per host
| Host | HEAD before | HEAD after | plugins:update | `officer` restart_time | /api/health | file-browser `ls` |
| --- | --- | --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | — | — | **SKIPPED — ran here** |
| `alpha` | `e9b71522` | `816f8695` | 15/15, exit 0 | 6 → 7 | 401 | 200, 77 entries |
| `fofos` | `601849be` | `816f8695` | **4/5, exit 1** | 3 → 4 | 401 | 200, 39 entries |
| `macbookpro` | `e9b71522` | `816f8695` | no plugins installed | 6 → 7 | 401 | 200, 62 entries |
| `macbruno` | `e9b71522` | `816f8695` | 1/1, exit 0 | 9 → 10 | 401 | 200, 41 entries |
| `production-pertento-bridge` | `e9b71522` | `816f8695` | 1/1, exit 0 | 7 → 8 | 401 | 200, 53 entries |
| `edge-pertento-bridge` | `e9b71522` | `816f8695` | 2/2, exit 0 | 8 → 9 | 401 | 200, 29 entries |
| `deadpool-rezio-bridge` | `e9b71522` | `816f8695` | 2/2, exit 0 | 25 → 26 | 401 | 200, 49 entries |
Every host landed on `816f8695`, exactly the tag. `fofos` started ahead of the previous tag and behind
this one, as the plan predicted, so its fast-forward moved 12 files rather than 23.
`401` on `/api/health` is healthy — the plan says so and all seven agreed. Ports were read from each
host's own `.env`, never assumed: 9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere.
## The file browser check
This release touched the listing loader, the clipboard and the row markup, so the plan asked for one
signed `/api/file-browser/ls` per host. **Seven hosts, seven `200`s**, every one with a plausible count
and none of them zero. The counts differ because the homes differ.
Signed as that host's own owner, read from its own `users` table rather than assumed —
`conceiba@gmail.com` on `macbruno`, `brunorezio@gmail.com` on `deadpool-rezio-bridge`,
`pastilhas@officer.dev` on the rest.
## Schema
**No schema verification was performed, because this release changes no schema.** No `db:push` step
exists in the plan and none was run, so there is nothing to verify by query. The clipboard is a key in
`user_state`, a JSONB column that already existed.
The plugin sidecars applied their OWN schemas during `plugins:update`, which belongs to the plugin
updater and not to this release: `music` on `alpha` (4 tables), `proxy-manager` on `fofos` (3) and on
`macbruno` (3), `offscale` on `edge-pertento-bridge` (1), `email` on `deadpool-rezio-bridge` (1).
## The one failure: `fofos-admin` on `fofos`
`bun run plugins:update` exited 1 with:
```
FAILED: packages installed, but @tiptap/pm cannot be resolved from …/plugins/fofos-admin
```
**The install is fine and the plugin is not broken.** Verified on the host rather than assumed:
- `plugins/node_modules/@tiptap/pm` is present, with its `dist` and the prosemirror packages.
- `@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths. Code imports `@tiptap/pm/state`, never the bare name.
- `Bun.resolveSync('@tiptap/pm', pluginDir)` therefore fails, while `@tiptap/react` and
`@tiptap/starter-kit` both resolve. `unresolvableDependencies` reads that as a failed install.
- `fofos-admin`'s own checkout is clean, so this is not the usual uncommitted-change cause.
Also verified there, and it is this release working as intended: `react` resolves out of the PLATFORM's
`node_modules`, not the plugins tree. The eviction added in `9719e367` did its job.
**The owner shipped the fix at 11:20 while the rollout was in flight — `537cc29c`, "a package with no
root entry point is not a broken install".** It is one commit PAST this tag and is therefore deployed
nowhere. `fofos` will keep failing that step on every `plugins:update` until a release carries it.
What this cost on `fofos`: `fofos-admin` did not finish its update — no assets publish, no access grant,
no `recorded at`. Its code was already at the latest commit, and the platform mounted `/fofos-admin`
normally. The other four plugins updated.
## A commit landed on master mid-rollout, and the tag absorbed it
`alpha`, `fofos`, `macbookpro`, `macbruno` and `production-pertento-bridge` fetched `origin/master` at
`816f8695`. `edge-pertento-bridge` and `deadpool-rezio-bridge` fetched it at `537cc29c`.
Because every host merged `--ff-only deploy/2026.08.30.01` and not the branch, **all seven are on the
tag and none carries the extra commit.** This is the exact failure of 2026-08-23, and merging the tag is
what prevented it.
## 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`. `macbruno` moved `proxy-manager`,
which declares no sidecar, so nothing extra bounced there.
After every restart, `pm2 jlist` showed **every `officer*` process `online` on all seven hosts**. None
was `errored` and none failed to come back.
`ecosystem.config.cjs` was never restarted anywhere. No vault was touched. On
`production-pertento-bridge` the eight Pertento-owned processes — `Dashboard`, `Tracking`, `Uptime` and
the five `Crons *` — were filtered out of every read and never named in a restart.
## What I did not do
- **`officer-dev` — skipped entirely.** Not pulled, not restarted. The rollout ran inside its
`officer-claude-code` sidecar, and restarting it would have ended the session writing this report. Its
working tree already held this code. The owner restarts it deliberately, as the last step.
- **Did not fix the `fofos-admin` checker.** The fix exists in `537cc29c` and belongs to a release, not
to a rollout editing five hosts.
- **Did not re-run `plugins:update` on `fofos`** after diagnosing it. It would fail identically — the
code that decides is in the tag.
- **Did not run `bun run docs:check`.** Not the commander's job.
- **One untracked file noted, not touched:** `scripts/chk.ts` on `fofos`. Untracked is a note, not a
skip, and a fast-forward could never have touched it. Every host was clean on
`--untracked-files=no`.
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.30.03 — a package with no root entry point is not a broken install
**One commit.** `537cc29c`, fixing what the 2026.08.30.01 rollout found on `fofos`.
**Why .03 and not .02.** `2026.08.30.02` was tagged against a commit that was not on `master` — the
rollout's own report had just been pushed and the release was cut without pulling first. The branch push
was rejected, the tag was not, and a tag pointing off-branch was published for about ten seconds before
being withdrawn. The watcher claimed the version inside that window.
A claim is permanent by design and that is correct: `.02` was attempted, so `.02` is spent and cannot be
deployed. It has a claim and no `.done`, which is exactly what "attempted and did not succeed" should
look like in the record. Nothing reached any host — every one stayed on `816f8695` throughout.
**No schema change and no dependency change.** Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.30.01..deploy/2026.08.30.03 | grep -iE 'schema|/db/|databases' # nothing
git diff --name-only deploy/2026.08.30.01..deploy/2026.08.30.03 -- package.json bun.lock # nothing
```
So **no `bun db:push` and no `bun install` step exists in this plan, and neither should be improvised.**
## What it fixes, and what it does not
`unresolvableDependencies` asked `Bun.resolveSync(name, pluginDir)` of every package a plugin declares.
That is the right question for almost all of them and unanswerable for a few.
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths, because it is tiptap's single pinned copy of the ProseMirror packages and you import
`@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name, Bun correctly says it cannot,
and the checker read that as a failed install.
**Nothing was ever broken on disk.** The install on `fofos` is complete — the directory, its `dist` and
thirteen prosemirror packages. The editor works. What failed was the verification, which then failed
`plugins:update`, which exits non-zero if any plugin failed.
The consequence worth shipping for: **`bun run plugins:update` exits 1 on `fofos` every time** until
this lands, so the plugin step of every future deploy fails there and looks like a new problem.
The strong check stays first, so a package that installed but cannot be loaded on this machine still
fails — `sharp` resolving to a platform-specific binary is the shape it was written for. Only a package
that fails it is asked the weaker question: is it there.
## 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.03`, `bun run plugins:update`), plus:
```bash
pm2 restart officer
```
`officer` only — `plugins:update` has already restarted the plugin sidecars.
**`fofos` is the host this release is for.** Its `plugins:update` failed 4/5 on the last rollout and
should now be 5/5, exit 0. That is the check that matters:
```
→ fofos-admin
packages: installed and resolvable
```
If `fofos` still reports 4/5 with `@tiptap/pm cannot be resolved`, this release did not work and that
is the finding — report it rather than working around it.
Everywhere else this is a no-op in behaviour: no other host has `fofos-admin` installed, so no other
host was failing and none should start.
### 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.
No file-browser check this time — this release touches one function in the plugin installer and
nothing a browser reaches.
## Per host
Every host went to `816f8695` an hour ago and all seven came back online, so they start from there.
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. It already holds this commit and its `officer` was restarted by hand at 11:23. |
| `alpha` | Ordinary target. No `fofos-admin`, so nothing changes in behaviour. |
| `fofos` | **The reason for this release.** Expect `plugins:update` to go 4/5 → 5/5. |
| `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**. |
| `edge-pertento-bridge` | Ordinary target. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
Note for whoever reads the report: `scripts/chk.ts` is untracked on `fofos` and was noted last time. It
is still a note, not a skip.
## What "done" looks like
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
`restart_time`. And for `fofos` specifically, the `fofos-admin` line reading
`packages: installed and resolvable`.
+55
View File
@@ -0,0 +1,55 @@
# 2026.08.30.03 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped entirely, as the host the rollout ran on.
Every target went `816f8695` → `c97da029` by `git merge --ff-only deploy/2026.08.30.03`. No host was
dirty in tracked files, none had to be skipped, and no merge was anything other than a fast-forward.
**The release did what it was cut for.** `fofos` went 4/5 → **5/5, exit 0**, with `fofos-admin` reading
`packages: installed and resolvable`. That was the one check that mattered and it passed.
**No schema step and no `bun install` step** — the plan has neither, and neither was improvised. So there
is no per-host schema query in this report, because this release introduced nothing to query for.
## Per host
| Host | HEAD before → after | `plugins:update` | `officer` restart_time | health |
| --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | **SKIPPED** — the host this ran on |
| `alpha` | `816f8695` → `c97da029` | 15/15 | 7 → 8, online | `401` (9000) |
| `fofos` | `816f8695` → `c97da029` | **5/5, exit 0** | 4 → 5, online | `401` (9000) |
| `macbookpro` | `816f8695` → `c97da029` | no plugins installed | 7 → 8, online | `401` (9010) |
| `macbruno` | `816f8695` → `c97da029` | 1/1 | 10 → 11, online | `401` (9000) |
| `production-pertento-bridge` | `816f8695` → `c97da029` | 1/1 | 8 → 9, online | `401` (9010) |
| `edge-pertento-bridge` | `816f8695` → `c97da029` | 2/2 | 9 → 10, online | `401` (9000) |
| `deadpool-rezio-bridge` | `816f8695` → `c97da029` | 2/2 | 26 → 27, online | `401` (9000) |
`401` on `/api/health` is healthy — the endpoint is behind auth.
## What is worth reading
- **`fofos` is fixed.** `plugins:update` there had failed 4/5 on every rollout since `@tiptap/pm` was
installed, and it now exits 0. The plugin step of the next deploy should be unremarkable on that host.
- **`macbookpro` needed a fetch retry.** The first `git fetch` failed with
`Connection closed by 77.42.39.155 port 22` — gitea's public SSH, rate-limited at the proxy. That host
resolves `gitea.officer.dev` to the public address where `alpha` reaches it over the tailnet. The first
retry succeeded and the rollout continued normally. A later probe was refused again, so the window is
intermittent rather than closed. **Nothing to fix in the deploy; the remote on that host is the thing
that is wrong**, and pointing it at the tailnet address would remove a class of failure that costs a
retry on every release.
- **`scripts/chk.ts` is still untracked on `fofos`.** Noted, not a skip, exactly as the plan said. It has
survived two rollouts now; somebody may want to commit it or delete it.
- **`plugins:update` restarted sidecars on several hosts** before the `pm2 restart officer` the plan
asked for — `officer-music` on `alpha`, `officer-gitea` on `production-pertento-bridge`,
`officer-offscale` on `edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. That is the
documented behaviour of the plugins step, not something this rollout chose.
- **On `production-pertento-bridge` only `officer` was restarted.** Pertento's eight production processes
were not touched.
## What was not done
- `officer-dev` was not pulled, not updated and not restarted. It already holds `c97da029` and its
`officer` was restarted by hand at 11:23. **The owner restarts this host deliberately, as the last
step**, once happy with the rest.
- No `bun db:push`, no `bun install`, no direct SQL. The plan called for none.
- No file-browser check, per the plan.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "officer", "name": "officer",
"version": "2026.08.29.04", "version": "2026.08.30.03",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
+31 -7
View File
@@ -8,7 +8,17 @@ import { useInitialData } from '@/state/useInitialData';
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(), // `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 // 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. // 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. * `/plugins` → `/marketplace`, KEEPING the query string.
@@ -87,20 +97,34 @@ export function App() {
404 on a route that worked yesterday reads as the feature having been removed. */} 404 on a route that worked yesterday reads as the feature having been removed. */}
<Route path="/plugins" element={<PluginsRedirect />} /> <Route path="/plugins" element={<PluginsRedirect />} />
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from {/* Installed plugins. Core routes above stay hand-written; everything below is generated from
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard what is installed, because a bundler cannot follow a runtime import specifier. */}
hands the whole subtree to the plugin's own router, which react-router nests natively. */} {/* One route per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */} screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
{/*
Three routes, and the third is the only new one.
`/<route>` and `/<route>/:section` are what every plugin has always had, and fifteen of
them read `useParams().section`. A splat ALONE replaced that for a day and took the
parameter away from all of them at once — the screens still mounted and every one of them
lost its selection, which is a worse failure than a 404 because nothing says so.
The splat is added for depth rather than instead of them: `/fofos-admin/layout/header` was
not expressible before, and a plugin that wants its own subtree reads the location.
React-router ranks a static or dynamic segment above a splat, so a plugin with one segment
still gets `section` and only deeper paths fall through.
*/}
{installedPlugins.flatMap((plugin) => [ {installedPlugins.flatMap((plugin) => [
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />, <Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
// panels read `useParams` themselves, so which section is open is the URL rather than state
// passed between them.
<Route <Route
key={`${plugin.screenId}-section`} key={`${plugin.screenId}-section`}
path={`${plugin.route}/:section`} path={`${plugin.route}/:section`}
element={<Dashboard.PluginScreen {...plugin} />} element={<Dashboard.PluginScreen {...plugin} />}
/>, />,
<Route
key={`${plugin.screenId}-deep`}
path={`${plugin.route}/*`}
element={<Dashboard.PluginScreen {...plugin} />}
/>,
])} ])}
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} /> <Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} /> <Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
+77 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { proxyOverrideScript, rewriteAssetPaths, rewriteHtml } from './html-rewrite'; import { proxyOverrideScript, rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
const BASE = '/api/dev-proxy/3001'; const BASE = '/api/dev-proxy/3001';
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!); const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
@@ -58,6 +58,43 @@ describe('rewriteHtml', () => {
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('<title>')); expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('<title>'));
}); });
test('every candidate in a srcset is prefixed, descriptors intact', () => {
expect(rewriteHtml('<img srcset="/a.webp 96w, /b.webp 320w">', BASE)).toContain(
`srcset="${BASE}/a.webp 96w, ${BASE}/b.webp 320w"`,
);
});
/*
The failure this was found by. An <img> with a srcset also has a src, and the src WAS prefixed — but
a browser given a valid srcset picks from it and never reads src. So the logo was missing while the
markup looked right.
*/
test('a srcset is chosen over src by the browser, so prefixing src alone is not enough', () => {
const out = rewriteHtml('<img src="/logo.webp" srcset="/logo.webp 96w">', BASE);
expect(out).toContain(`src="${BASE}/logo.webp"`);
expect(out).toContain(`srcset="${BASE}/logo.webp 96w"`);
});
test('a candidate with no descriptor is prefixed too', () => {
expect(rewriteHtml('<img srcset="/only.webp">', BASE)).toContain(`srcset="${BASE}/only.webp"`);
});
test('imagesrcset on a preload link is prefixed — the one that is easy to miss', () => {
expect(rewriteHtml('<link rel="preload" as="image" imagesrcset="/hero.webp 1280w">', BASE)).toContain(
`imagesrcset="${BASE}/hero.webp 1280w"`,
);
});
test('a protocol-relative candidate is left alone, like everywhere else here', () => {
expect(rewriteHtml('<img srcset="//cdn.example.com/a.webp 96w, /b.webp 320w">', BASE)).toContain(
`srcset="//cdn.example.com/a.webp 96w, ${BASE}/b.webp 320w"`,
);
});
test('`..` is collapsed in a srcset candidate as well', () => {
expect(rewriteHtml('<img srcset="/../../x.webp 96w">', BASE)).toContain(`srcset="${BASE}/x.webp 96w"`);
});
test('a query string and hash survive normalisation', () => { test('a query string and hash survive normalisation', () => {
expect(attrs(rewriteHtml('<script src="/../x.js?v=2#frag">', BASE))).toEqual(['/api/dev-proxy/3001/x.js?v=2#frag']); expect(attrs(rewriteHtml('<script src="/../x.js?v=2#frag">', BASE))).toEqual(['/api/dev-proxy/3001/x.js?v=2#frag']);
}); });
@@ -175,3 +212,42 @@ describe('the injected location reporter', () => {
expect(r.posted).toHaveLength(2); expect(r.posted).toHaveLength(2);
}); });
}); });
describe('stylesheets', () => {
test('a quoted url is prefixed — the form every generator emits', () => {
expect(rewriteCss('@font-face{src:url("/fonts/Averta.woff2") format("woff2")}', BASE)).toContain(
`url("${BASE}/fonts/Averta.woff2")`,
);
});
test('single quotes and bare urls too', () => {
expect(rewriteCss("a{background:url('/bg.png')}", BASE)).toContain(`url('${BASE}/bg.png')`);
expect(rewriteCss('a{background:url(/bg.png)}', BASE)).toContain(`url(${BASE}/bg.png)`);
});
test('the quote style is preserved, not normalised', () => {
expect(rewriteCss('a{background:url("/bg.png")}', BASE)).not.toContain("url('");
});
test('a url that is already absolute is left alone', () => {
const external = 'a{background:url("https://cdn.example.com/bg.png")}';
expect(rewriteCss(external, BASE)).toBe(external);
const protocolRelative = 'a{background:url("//cdn.example.com/bg.png")}';
expect(rewriteCss(protocolRelative, BASE)).toBe(protocolRelative);
});
test('a relative url is left alone — it already resolves against the stylesheet', () => {
const relative = 'a{background:url("./bg.png")}';
expect(rewriteCss(relative, BASE)).toBe(relative);
});
test('a stylesheet still gets the /_bun/ rule', () => {
expect(rewriteCss('@import "/_bun/x.css";', BASE)).toContain(`${BASE}/_bun/x.css`);
});
test('inline CSS in HTML gets the same treatment, quotes included', () => {
expect(rewriteHtml('<div style=\'background:url("/a.png")\'></div>', BASE)).toContain(
`url("${BASE}/a.png")`,
);
});
});
+74 -3
View File
@@ -89,7 +89,38 @@ const normalisePath = (path: string): string => {
/** `/x` → `<base>/x`, with `..` already collapsed. Never touches `//host` or a full URL. */ /** `/x` → `<base>/x`, with `..` already collapsed. Never touches `//host` or a full URL. */
const prefix = (base: string, path: string): string => `${base}${normalisePath(path)}`; const prefix = (base: string, path: string): string => `${base}${normalisePath(path)}`;
// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override /**
* A `srcset`, which is a LIST and cannot be prefixed as one value.
*
* `srcset="/a.webp 96w, /b.webp 2x"` — comma-separated candidates, each a URL plus an optional
* descriptor. The attribute rule below matches a whole attribute value, so it can only ever have
* rewritten the first URL and left the descriptor mangled onto it; it does not match `srcset` at all,
* which is why responsive images 404'd silently.
*
* Silently is the operative word. A `<picture>` or an `<img srcset>` still has a `src`, and that one WAS
* prefixed — but a browser given a valid srcset chooses from it and never looks at src. So the markup
* appeared correct, one attribute pointed at the platform root, and the image was simply missing.
*
* Split on commas rather than parsed: a URL cannot contain an unescaped comma in this position, and the
* descriptor never does.
*/
const prefixSrcset = (base: string, list: string): string =>
list
.split(',')
.map((candidate) => {
const trimmed = candidate.trim();
if (!trimmed) return '';
// The URL is everything up to the first space; the rest is `96w`, `2x`, or nothing.
const space = trimmed.search(/\s/);
const url = space === -1 ? trimmed : trimmed.slice(0, space);
const descriptor = space === -1 ? '' : trimmed.slice(space);
// Same rule as everywhere else here: root-absolute only, never protocol-relative.
return url.startsWith('/') && !url.startsWith('//') ? `${prefix(base, url)}${descriptor}` : trimmed;
})
.filter(Boolean)
.join(', ');
// HTML: rewrite src/href/action/srcset attributes with absolute paths + inject fetch/XHR override
export const rewriteHtml = (html: string, base: string) => export const rewriteHtml = (html: string, base: string) =>
html html
.replace(/<head([^>]*)>/i, `<head$1>${proxyOverrideScript(base)}`) .replace(/<head([^>]*)>/i, `<head$1>${proxyOverrideScript(base)}`)
@@ -97,12 +128,52 @@ export const rewriteHtml = (html: string, base: string) =>
/(href|src|action)=("|')(\/(?!\/)[^"']*)\2/gi, /(href|src|action)=("|')(\/(?!\/)[^"']*)\2/gi,
(_match, attr: string, quote: string, path: string) => `${attr}=${quote}${prefix(base, path)}${quote}`, (_match, attr: string, quote: string, path: string) => `${attr}=${quote}${prefix(base, path)}${quote}`,
) )
.replace(/url\((\/(?!\/)[^)"']*)\)/g, (_match, path: string) => `url(${prefix(base, path)})`); /*
`srcset` and `imagesrcset`, which the attribute rule above cannot do — see prefixSrcset.
// JS/CSS: only `/_bun/` paths. `imagesrcset` is the one on `<link rel="preload" as="image">`. It is easy to miss and expensive
to miss: a preload that 404s costs a request AND leaves the real image undiscovered until the
body is parsed, which is the opposite of what the preload was added for.
*/
.replace(
/(imagesrcset|srcset)=("|')([^"']*)\2/gi,
(_match, attr: string, quote: string, list: string) => `${attr}=${quote}${prefixSrcset(base, list)}${quote}`,
)
.replace(CSS_URL, cssUrlPrefixer(base));
/*
`url(/…)` — a font, a background image, an `@import`. CSS allows the target bare, single-quoted or
double-quoted, and all three mean the same thing, so all three have to match. The quote used is
captured and put back rather than normalised: rewriting is not the place to have an opinion about
someone else's stylesheet.
This rule missed every QUOTED url until 2026-08-31, which is a bad way to fail. `url(/x.png)` was
rewritten and `url("/x.png")` was not, so whether a stylesheet worked through the proxy came down to
the formatting preference of whatever generated it.
*/
const CSS_URL = /url\(\s*(["']?)(\/(?!\/)[^)"']*)\1\s*\)/g;
const cssUrlPrefixer =
(base: string) =>
(_match: string, quote: string, path: string): string =>
`url(${quote}${prefix(base, path)}${quote})`;
// JS: only `/_bun/` paths.
// //
// Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are // Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are
// not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it // not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it
// is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to // is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to
// match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that. // match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that.
export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`); export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`);
/*
A STYLESHEET gets more than that, because a stylesheet's absolute paths are unambiguous: `url(/…)` is
a URL and nothing else, so the argument for being narrow in JavaScript does not apply here.
This was the whole of it for a long time, and the symptom was specific: a site's own `@font-face` rules
kept pointing at `/fonts/…`, which inside the frame is the PLATFORM's root, so every face 404'd and the
preview quietly rendered in a system fallback. Nothing looked broken — it just looked like a different
site than the one being previewed, which is the one thing a preview must never do.
*/
export const rewriteCss = (text: string, base: string) =>
rewriteAssetPaths(text, base).replace(CSS_URL, cssUrlPrefixer(base));
+8 -3
View File
@@ -1,7 +1,7 @@
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import { resolveAuthToken } from '../../auth-token'; import { resolveAuthToken } from '../../auth-token';
import { isSuperAdmin } from '../../super-admin'; import { isSuperAdmin } from '../../super-admin';
import { rewriteAssetPaths, rewriteHtml } from './html-rewrite'; import { rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
/** /**
* See a dev server running on THIS machine's loopback, from a browser that is somewhere else. * See a dev server running on THIS machine's loopback, from a browser that is somewhere else.
@@ -145,7 +145,8 @@ devProxyRouter.all('/:port/*', async (ctx) => {
const type = res.headers.get('content-type') ?? ''; const type = res.headers.get('content-type') ?? '';
const isHtml = type.includes('text/html'); const isHtml = type.includes('text/html');
const rewritable = isHtml || type.includes('javascript') || type.includes('text/css'); const isCss = type.includes('text/css');
const rewritable = isHtml || isCss || type.includes('javascript');
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
// Anything framed here is the owner's own dev server; the platform frames it deliberately. // Anything framed here is the owner's own dev server; the platform frames it deliberately.
@@ -169,7 +170,11 @@ devProxyRouter.all('/:port/*', async (ctx) => {
} }
const text = await res.text(); const text = await res.text();
const body = isHtml ? rewriteHtml(text, prefix) : rewriteAssetPaths(text, prefix); const body = isHtml
? rewriteHtml(text, prefix)
: isCss
? rewriteCss(text, prefix)
: rewriteAssetPaths(text, prefix);
// Both are wrong after a rewrite: the length changed, and the body was decoded on the way through. // Both are wrong after a rewrite: the length changed, and the body was decoded on the way through.
headers.delete('content-length'); headers.delete('content-length');
headers.delete('content-encoding'); headers.delete('content-encoding');
+43 -8
View File
@@ -1,6 +1,7 @@
import { createRouter } from '@@/create-router'; import { createRouter } from '@@/create-router';
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { readdir, lstat, readlink, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home'; import { resolveHomeDir } from '@@/user-home';
@@ -188,9 +189,17 @@ router.get('/ls', async (ctx) => {
await cleanOldCacheDirs(rootDir); await cleanOldCacheDirs(rootDir);
} }
let names: string[]; /*
`withFileTypes`, so a symlink can be spotted without a second syscall per entry.
The kind comes off the directory entry itself on every filesystem that reports it, and where it
does not Node falls back to an lstat it would otherwise have had to make anyway. `stat` below
FOLLOWS the link — which is right, and is why it cannot answer this question: a symlink to a folder
must still say `directory` so that opening it works.
*/
let names: Dirent[];
try { try {
names = await readdir(absPath); names = await readdir(absPath, { withFileTypes: true });
} catch (ex) { } catch (ex) {
// A missing directory resets the browser to the root, which is the right answer for a stale path. // A missing directory resets the browser to the root, which is the right answer for a stale path.
// //
@@ -213,18 +222,44 @@ router.get('/ls', async (ctx) => {
const claimed = await pluginFileTypes(); const claimed = await pluginFileTypes();
const entries = await Promise.all( const entries = await Promise.all(
names.map(async (name) => { names.map(async (entry) => {
const name = entry.name;
const fullPath = resolve(absPath, name); const fullPath = resolve(absPath, name);
// Skip entries that escape the home dir (shouldn't happen but be safe) // Skip entries that escape the home dir (shouldn't happen but be safe)
if (!isInside(rootDir, fullPath)) return null; if (!isInside(rootDir, fullPath)) return null;
/*
Where a link POINTS, exactly as it was written.
Not resolved to an absolute path: a relative target is a fact about the link, and rewriting it
would hide that `../originals` moves with the folder while `/home/…/originals` does not. Only
asked for the entries that are links, which in an ordinary folder is none of them.
*/
const target = entry.isSymbolicLink() ? await readlink(fullPath).catch(() => null) : null;
const s = await stat(fullPath).catch(() => null); const s = await stat(fullPath).catch(() => null);
if (!s) return null;
const isDirectory = s.isDirectory(); /*
A BROKEN link still gets a row, and did not before.
`stat` follows, so a link whose target is gone fails here and the entry returned null — the file
vanished from the listing entirely. Nothing said it was there, nothing could delete it, and the
folder simply had fewer things in it than `ls` showed. `lstat` describes the link itself, which
exists whatever it points at.
*/
const own = s ?? (target !== null ? await lstat(fullPath).catch(() => null) : null);
if (!own) return null;
const isDirectory = s?.isDirectory() ?? false;
return { return {
name, name,
type: isDirectory ? 'directory' : 'file', type: isDirectory ? 'directory' : 'file',
size: s.size, size: own.size,
modifiedAt: s.mtimeMs, modifiedAt: own.mtimeMs,
/** Where it points, or absent when it is an ordinary file. See the note above. */
symlink: target,
/** A link whose target is gone. `type` is `file` for want of anything truer to say. */
broken: target !== null && s === null ? true : undefined,
/* /*
Whether `/thumb` will produce something, decided HERE so there is one list of extensions Whether `/thumb` will produce something, decided HERE so there is one list of extensions
rather than one on each side of the wire. rather than one on each side of the wire.
+3 -1
View File
@@ -98,6 +98,8 @@ function findWeb(
screens: [ screens: [
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout }, { 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('; ')}.` }; 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); const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
+30 -1
View File
@@ -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 // 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 // install. The alias counter runs over screens for that reason — two screens of one plugin must not
// share an import name. // 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; let i = 0;
for (const { plugin } of withWeb) { for (const { plugin } of withWeb) {
for (const screen of plugin.web!.screens) { for (const screen of plugin.web!.screens) {
@@ -85,8 +103,19 @@ export function generatePluginsModule(states: PluginState[]): string {
} }
const body = `${HEADER} const body = `${HEADER}
import type { AppRegistryMeta, LayoutNode } from 'officerdev'; import type { AppRegistryMeta, FileAction, LayoutNode } from 'officerdev';
${imports.join('\n')} ${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 = { export type GeneratedPlugin = {
/** The plugin this screen belongs to. Two screens of one plugin share it. */ /** The plugin this screen belongs to. Two screens of one plugin share it. */
+39 -1
View File
@@ -523,6 +523,31 @@ export type PluginManifest = {
*/ */
hooks?: Partial<Record<string, string>>; 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[]; osDependencies?: OsDependency[];
/** /**
@@ -611,7 +636,20 @@ export type DiscoveredPlugin = {
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin * Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
* with a partial frontend. * 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;
}; };
/** /**
+67 -2
View File
@@ -1,3 +1,4 @@
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { PLATFORM_DIR } from '../data-path'; import { PLATFORM_DIR } from '../data-path';
import type { DiscoveredPlugin } from './manifest'; import type { DiscoveredPlugin } from './manifest';
@@ -104,6 +105,32 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
return { ok: true, ran: false, output: '', conflicts }; return { ok: true, ran: false, output: '', conflicts };
} }
/*
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 = { const body = {
name: '@officer/plugin-dependencies', name: '@officer/plugin-dependencies',
private: true, private: true,
@@ -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 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 [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited; 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) { } catch (error) {
return { ok: false, ran: true, output: error instanceof Error ? error.message : String(error), conflicts }; return { ok: false, ran: true, output: error instanceof Error ? error.message : String(error), conflicts };
} }
@@ -135,12 +181,31 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
export function unresolvableDependencies(plugin: DiscoveredPlugin): string[] { export function unresolvableDependencies(plugin: DiscoveredPlugin): string[] {
const names = Object.keys(plugin.manifest.npmDependencies ?? {}); const names = Object.keys(plugin.manifest.npmDependencies ?? {});
return names.filter((name) => { return names.filter((name) => {
// The strong check: it imports. This is what catches a package that installed but cannot be loaded
// on this machine — `sharp` resolving to a platform-specific binary is the shape it was written for.
try { try {
Bun.resolveSync(name, plugin.dir); Bun.resolveSync(name, plugin.dir);
return false; return false;
} catch { } catch {
return true; // fall through
} }
/*
Some packages have NO root entry point, and that is not a fault.
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths, and you import `@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name,
Bun correctly says it cannot, and the check above read that as a broken install.
It cost a deploy: fofos-admin declares `@tiptap/pm` because tiptap needs it present, the install
was perfect — the directory, its dist and thirteen prosemirror packages all there — and the plugin
was reported as failed on every host that had it.
So a package that does not resolve is asked the weaker question instead: is it THERE. Present but
unimportable still fails, because the strong check ran first and only a package with no root entry
reaches this line.
*/
return !existsSync(join(PLUGINS_DIR, 'node_modules', name, 'package.json'));
}); });
} }
+66 -5
View File
@@ -29,12 +29,18 @@ import { join } from 'node:path';
* is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on. * is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on.
* That holds for any tag names anyone ever chooses, including ones that do not sort. * That holds for any tag names anyone ever chooses, including ones that do not sort.
* *
* ── What this does NOT do ── * ── The answer is never awaited on the network ──
* *
* It never fetches. Every answer comes from refs already on disk, because this is called once per plugin * Every answer comes from refs already on disk. This is called once per plugin on a request that renders
* on a request that renders a list — twenty-one network round trips would make the plugins screen feel * a list, and twenty-one network round trips would make the plugins screen feel broken.
* broken. Tags arrive at clone and at the fetch `pullPlugin` already does, so "check for updates" is a *
* consequence of updating rather than a separate poll. * But refs on disk go stale, and for a long time nothing refreshed them: tags arrived at clone and at
* the fetch `pullPlugin` does, so you only learned an update existed BY UPDATING. A machine that had not
* updated a plugin since August compared this morning's release against a snapshot from before it
* existed and said, truthfully and uselessly, that it was at the latest.
*
* So `refreshRemote` runs a fetch in the background and this request answers from what is there now. The
* NEXT read is current. Rate-limited per checkout and run one at a time — see the note on it.
*/ */
export type ReleaseState = { export type ReleaseState = {
@@ -47,6 +53,58 @@ export type ReleaseState = {
reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout'; reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout';
}; };
/**
* A background `git fetch --tags`, at most one per checkout per REFRESH_MS.
*
* ── Not awaited, on purpose ──
*
* The caller is rendering a list. Waiting would put the network on the critical path of a page that has
* to feel instant, which is the reason this file never fetched in the first place. The request answers
* from the refs it has; the fetch it starts is for the next one.
*
* The cost of that is one stale view after a release, corrected by a refresh. The alternative — never
* fetching — is a badge that is wrong until somebody updates for an unrelated reason.
*
* ── One at a time ──
*
* Twenty-one plugins would otherwise spawn twenty-one gits at once, every ten minutes, on machines that
* are not all fast. Chained rather than pooled because there is no hurry: nothing is waiting on it.
*
* Failure is silence. A checkout with no remote, an unreachable host, a repository that has moved — none
* of those should surface as anything, because the list still has a true answer without them.
*/
/*
Two minutes, and the number is chosen for how releases are actually watched.
It was ten. The way this gets used is: ship a plugin, open the page, look for the badge — and ten
minutes of that is indistinguishable from the feature not working. A release tagged at 12:42 was
invisible at 12:45 because the last fetch happened at 12:37, which is correct behaviour and no comfort.
The cost is bounded by the page being open: nothing fetches when nobody is looking, and the fetches are
chained, so the worst case is one small round trip per plugin every two minutes while somebody watches
the plugins screen.
*/
const REFRESH_MS = 2 * 60 * 1000;
const fetchedAt = new Map<string, number>();
let queue: Promise<unknown> = Promise.resolve();
export function refreshRemote(dir: string): void {
const last = fetchedAt.get(dir) ?? 0;
if (Date.now() - last < REFRESH_MS) return;
// Stamped BEFORE the fetch runs, so a slow one cannot be started again by the next twenty requests.
fetchedAt.set(dir, Date.now());
queue = queue.then(async () => {
const proc = Bun.spawn(['git', '-C', dir, 'fetch', '--tags', '--quiet', 'origin'], {
stdout: 'ignore',
stderr: 'ignore',
// A host that hangs must not hold the queue: every plugin behind it would stop refreshing too.
signal: AbortSignal.timeout(20_000),
});
await proc.exited.catch(() => {});
});
}
const run = async (dir: string, args: string[]): Promise<string | null> => { const run = async (dir: string, args: string[]): Promise<string | null> => {
const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' }); const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text(); const out = await new Response(proc.stdout).text();
@@ -61,6 +119,9 @@ export async function releaseState(dir: string): Promise<ReleaseState> {
return { ...none, updateable: false, reason: 'not-a-checkout' }; return { ...none, updateable: false, reason: 'not-a-checkout' };
} }
// Started, not awaited: this answer comes from the refs on disk and the fetch is for the next one.
refreshRemote(dir);
// `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and // `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and
// therefore returns null, when HEAD is between releases — which is the first half of the rule. // therefore returns null, when HEAD is between releases — which is the first half of the rule.
const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']); const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']);
+47 -2
View File
@@ -18,6 +18,8 @@ import { createSidecarConnector } from '../connect';
import { sign } from '../../jwt'; import { sign } from '../../jwt';
import { getUserByEmail, getOwnerUser } from 'officerdb'; import { getUserByEmail, getOwnerUser } from 'officerdb';
import { DATA_PATH, findEmailAccountName } from '../../data-path'; 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'; 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 // 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 ── // ── MCP config ──
function generateMcpConfig(): string { async function generateMcpConfig(): Promise<string> {
// Was `.container-context`, from the architecture where each user ran inside their own Docker // 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 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 // 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 userRoot = join(DATA_PATH, email!);
const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':'); 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 = { const hostConfig = {
mcpServers: { mcpServers: {
...pluginMcpServers,
'officer-tools': { 'officer-tools': {
type: 'stdio', type: 'stdio',
command: 'bun', command: 'bun',
@@ -236,7 +275,13 @@ function ensureAnthropicEnv(): void {
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the // deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
// terminal `claude` loads too. // 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(); ensureAnthropicEnv();
console.log(`[agent] started for ${email} (HOME=${homeDir})`); console.log(`[agent] started for ${email} (HOME=${homeDir})`);
@@ -6,7 +6,7 @@ import { TaskRunnerDialog } from './components/TaskRunnerDialog';
import { AgentRunnerDialog } from './components/AgentRunnerDialog'; import { AgentRunnerDialog } from './components/AgentRunnerDialog';
import { VideoDownloadDialog } from './components/VideoDownloadDialog'; import { VideoDownloadDialog } from './components/VideoDownloadDialog';
import { DictateDialog } from './components/DictateDialog'; import { DictateDialog } from './components/DictateDialog';
import { useFileBrowserApp } from './useFileBrowserApp'; import { useFileBrowserApp, type PickedFile } from './useFileBrowserApp';
type FileBrowserAppProps = { type FileBrowserAppProps = {
basePath?: string; basePath?: string;
@@ -27,6 +27,14 @@ type FileBrowserAppProps = {
* where the folder is the URL and already does. * where the folder is the URL and already does.
*/ */
panelId?: string; 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 = ({ export const FileBrowserApp = ({
@@ -36,8 +44,9 @@ export const FileBrowserApp = ({
urlPath, urlPath,
panelId, panelId,
pathParam, pathParam,
onPickFile,
}: FileBrowserAppProps) => { }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam); const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam, onPickFile);
const { handleNavigate } = fileBrowserManager; const { handleNavigate } = fileBrowserManager;
return ( return (
@@ -4,6 +4,8 @@ import { ChevronUp, ChevronDown } from 'lucide-react';
import type { DirEntry } from '../../../../hooks/useFilesAPI'; import type { DirEntry } from '../../../../hooks/useFilesAPI';
import { getFileType } from '../../../FileViewer'; import { getFileType } from '../../../FileViewer';
import type { UseFileBrowserAppType } from '../useFileBrowserApp'; import type { UseFileBrowserAppType } from '../useFileBrowserApp';
import { useClient } from 'hooks/useClient';
import { fileActionsFor } from '../../file-actions';
import { FileItem } from './FileItem'; import { FileItem } from './FileItem';
import { useTranslation } from '../../../../locales'; import { useTranslation } from '../../../../locales';
@@ -61,6 +63,8 @@ const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
// For plugin actions: `run` is outside React and cannot call the hook itself.
const client = useClient();
const { const {
visibleEntries: entries, visibleEntries: entries,
viewMode, viewMode,
@@ -68,6 +72,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
rootOverride, rootOverride,
selected, selected,
clipboard, clipboard,
refresh,
handleOpen, handleOpen,
handleDelete, handleDelete,
handleRename, handleRename,
@@ -167,8 +172,23 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
const cols = useColumnCount(scrollRef); const cols = useColumnCount(scrollRef);
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>(); const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
const anySelected = selected.size > 0; const anySelected = selected.size > 0;
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`); 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 rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
@@ -269,6 +289,14 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
onReadAloud={handleReadAloud} onReadAloud={handleReadAloud}
onExtract={handleExtract} onExtract={handleExtract}
taskGroups={getMatchingTaskGroups(entry.name, entry.type)} 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} onRunTask={handleRunTask}
agentGroups={getMatchingAgentGroups(entry.name, entry.type)} agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
onRunAgent={handleRunAgent} onRunAgent={handleRunAgent}
@@ -277,6 +305,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
onCompress={handleCompress} onCompress={handleCompress}
onDuplicate={handleDuplicate} onDuplicate={handleDuplicate}
onPasteInto={handlePasteInto} onPasteInto={handlePasteInto}
onDragStart={fileBrowserManager.handleEntryDragStart}
onDropInto={fileBrowserManager.handleEntryDrop}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}
isPinned={isPinned(entryPath(entry.name))} isPinned={isPinned(entryPath(entry.name))}
canPaste={clipboard !== null} canPaste={clipboard !== null}
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import {
Folder, Folder,
Link2,
Trash2, Trash2,
Pencil, Pencil,
MoreVertical, MoreVertical,
@@ -23,6 +24,7 @@ import {
FileArchive, FileArchive,
Pin, Pin,
PinOff, PinOff,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import { Thumbnail } from './Thumbnail'; import { Thumbnail } from './Thumbnail';
@@ -84,9 +86,25 @@ export type FileItemProps = {
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void; onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
onCreateDashboard: (entry: DirEntry) => void; onCreateDashboard: (entry: DirEntry) => void;
onOpenInEditor: (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; onCompress: (entry: DirEntry) => void;
onDuplicate: (entry: DirEntry) => void; onDuplicate: (entry: DirEntry) => void;
onPasteInto: (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; onTogglePin: (entry: DirEntry) => void;
isPinned: boolean; isPinned: boolean;
canPaste: boolean; canPaste: boolean;
@@ -177,6 +195,7 @@ type MenuItemsProps = {
canPaste: boolean; canPaste: boolean;
onOpen: (e: DirEntry) => void; onOpen: (e: DirEntry) => void;
onOpenInEditor: (e: DirEntry) => void; onOpenInEditor: (e: DirEntry) => void;
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
onDelete: (e: DirEntry) => void; onDelete: (e: DirEntry) => void;
onStartRename: () => void; onStartRename: () => void;
onChat: (e: DirEntry) => void; onChat: (e: DirEntry) => void;
@@ -212,6 +231,7 @@ const MenuItems = ({
canPaste, canPaste,
onOpen, onOpen,
onOpenInEditor, onOpenInEditor,
pluginActions,
onDelete, onDelete,
onStartRename, onStartRename,
onChat, onChat,
@@ -254,7 +274,7 @@ const MenuItems = ({
const hasTasks = taskGroups.length > 0; const hasTasks = taskGroups.length > 0;
const hasAgents = agentGroups.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 // 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. // Run Task > Video > Convert would just add a hop.
const nestTasks = taskGroups.length > 1; const nestTasks = taskGroups.length > 1;
@@ -305,6 +325,14 @@ const MenuItems = ({
{t('files.menu.extract')} {t('files.menu.extract')}
</Item> </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 && ( {hasTasks && (
<Sub> <Sub>
<SubTrigger className="cursor-pointer"> <SubTrigger className="cursor-pointer">
@@ -565,6 +593,7 @@ export const FileItem = ({
viewMode, viewMode,
selected, selected,
anySelected, anySelected,
pluginActions,
selectedCount, selectedCount,
isCut, isCut,
onOpen, onOpen,
@@ -589,6 +618,8 @@ export const FileItem = ({
onCompress, onCompress,
onDuplicate, onDuplicate,
onPasteInto, onPasteInto,
onDragStart,
onDropInto,
onTogglePin, onTogglePin,
isPinned, isPinned,
canPaste, canPaste,
@@ -601,6 +632,21 @@ export const FileItem = ({
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDir = entry.type === 'directory'; const isDir = entry.type === 'directory';
/*
A link, and where it goes.
Drawn as an arrow beside the name rather than as a badge or a different icon: the icon already says
what the thing IS — a folder, a photograph — and a symlink to a folder is still a folder to anyone
using it. What the arrow adds is that there is an indirection in the way, which is the thing that
explains why deleting it did not free any space, or why two places show the same file.
The target goes in `title`. It is often long and absolute, so it belongs in a tooltip rather than in
a row that has to fit a name, a type, a size and a date.
*/
const linkLabel = entry.symlink
? `${entry.broken ? 'Broken symlink' : 'Symlink'} → ${entry.symlink}`
: undefined;
const icon = isDir ? ( const icon = isDir ? (
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" /> <Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
) : ( ) : (
@@ -735,6 +781,10 @@ export const FileItem = ({
onCreateDashboard, onCreateDashboard,
onOpen, onOpen,
onOpenInEditor, 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, onCompress,
onDuplicate, onDuplicate,
onPasteInto, onPasteInto,
@@ -757,6 +807,24 @@ export const FileItem = ({
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()} 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)} /> <Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
<span className="shrink-0">{listVisual}</span> <span className="shrink-0">{listVisual}</span>
@@ -768,7 +836,18 @@ export const FileItem = ({
onCancel={() => setRenaming(false)} onCancel={() => setRenaming(false)}
/> />
) : ( ) : (
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span> <span className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium text-duck-dark">{entry.name}</span>
{linkLabel && (
<span
title={linkLabel}
aria-label={linkLabel}
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
>
<Link2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)} )}
</div> </div>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right"> <span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
@@ -804,6 +883,15 @@ export const FileItem = ({
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()} 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"> <div className="absolute top-1 left-1">
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} /> <Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
@@ -825,7 +913,18 @@ export const FileItem = ({
onCancel={() => setRenaming(false)} onCancel={() => setRenaming(false)}
/> />
) : ( ) : (
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span> <span className="flex w-full min-w-0 items-center justify-center gap-1">
<span className="truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
{linkLabel && (
<span
title={linkLabel}
aria-label={linkLabel}
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
>
<Link2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)} )}
<span className="text-xs text-duck-dark/50"> <span className="text-xs text-duck-dark/50">
@@ -1,5 +1,5 @@
import { useRef } from 'react'; 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 type { UseFileBrowserAppType } from '../../useFileBrowserApp';
import { useTranslation } from '../../../../../locales'; import { useTranslation } from '../../../../../locales';
@@ -9,7 +9,7 @@ type DefaultActionsProps = {
export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => { export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setNewEntry, handleUpload, clipboard, handlePaste } = fileBrowserManager; const { setNewEntry, handleUpload, clipboard, handlePaste, setClipboard } = fileBrowserManager;
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null); const folderInputRef = useRef<HTMLInputElement | null>(null);
@@ -56,14 +56,39 @@ export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
/> />
</button> </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 && ( {clipboard !== null && (
<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 <button
onClick={handlePaste} onClick={handlePaste}
title={t('files.menu.paste')} title={t('files.menu.paste')}
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors" className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/10 cursor-pointer transition-colors"
> >
<ClipboardPaste className="h-4 w-4" /> <ClipboardPaste className="h-4 w-4" />
</button> </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>
)} )}
</> </>
); );
@@ -213,7 +213,11 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
</div> </div>
</form> </form>
) : ( ) : (
<div className="flex flex-col gap-3"> // `min-w-0` on the DIRECT grid child. DialogContent is a grid, and a grid item defaults to
// `min-width: auto` — so this is the element that grows to fit a long title and takes the
// card out of the dialog with it. The constraint has to be here; on the scroll container
// inside it was one level too low to stop anything.
<div className="flex min-w-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<button <button
type="button" type="button"
@@ -248,7 +252,19 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
</div> </div>
)} )}
<div className="flex max-h-[52vh] flex-col gap-2 overflow-y-auto pr-1"> {/*
`min-w-0` is the one that makes `truncate` work, three levels down.
DialogContent is a GRID, and a grid item defaults to `min-width: auto` — it refuses to
shrink below its content. So a long title pushed this container wider than the dialog's
own `max-w-lg`, the card grew with it, and the `truncate` on the title never had a width
to truncate against. The text simply ran out of the modal.
Making the card a column fixed a different half of this and was not enough: a column still
cannot be narrower than the widest thing a grid will let it be. The constraint has to come
from above, and this is the highest point where it was missing.
*/}
<div className="flex max-h-[52vh] min-w-0 flex-col gap-2 overflow-y-auto pr-1">
{entries.map((e, i) => ( {entries.map((e, i) => (
<div <div
key={`${e.url}-${i}`} key={`${e.url}-${i}`}
@@ -10,6 +10,7 @@ import { folderFromPathname, folderHref, relativeToFolder, withoutViewerParams }
import { useTasks, runsWithoutPrompting, type TaskSummary } from '../useTasks'; import { useTasks, runsWithoutPrompting, type TaskSummary } from '../useTasks';
import { useAgents, type AgentSummary } from '../useAgents'; import { useAgents, type AgentSummary } from '../useAgents';
import { useUserState } from 'state/useUserState'; import { useUserState } from 'state/useUserState';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { runTaskNow } from './components/runTaskNow'; import { runTaskNow } from './components/runTaskNow';
@@ -36,12 +37,17 @@ export const FILES_PATH_PARAM = 'path';
* not fill the back stack, and no pathname involved at all. A reload lands where you were and the link * 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. * 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 = ( export const useFileBrowserApp = (
basePath: string, basePath: string,
rootOverride?: string, rootOverride?: string,
urlPath = false, urlPath = false,
panelId?: string, panelId?: string,
pathParam?: 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 { t } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
@@ -192,7 +198,25 @@ export const useFileBrowserApp = (
const { togglePin, isPinned } = usePinnedFiles(); const { togglePin, isPinned } = usePinnedFiles();
const [uploadProgress, setUploadProgress] = useState<number | null>(null); const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set()); 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 [renamingName, setRenamingName] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null); const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
@@ -223,6 +247,10 @@ export const useFileBrowserApp = (
filesRef.current = files; filesRef.current = files;
const currentPathRef = useRef(currentPath); const currentPathRef = useRef(currentPath);
currentPathRef.current = 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 visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`); const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
@@ -230,8 +258,29 @@ export const useFileBrowserApp = (
const firstListing = useRef(true); 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 () => { 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 { try {
const data = await filesRef.current.listDir(currentPathRef.current); const data = await filesRef.current.listDir(currentPathRef.current);
if (data.reset) { if (data.reset) {
@@ -239,6 +288,7 @@ export const useFileBrowserApp = (
return; return;
} }
setEntries(data.entries); setEntries(data.entries);
shownPath.current = currentPathRef.current;
if (data.rootDir) setRootDir(data.rootDir); if (data.rootDir) setRootDir(data.rootDir);
} catch (err: any) { } catch (err: any) {
console.error('[FileBrowser] refresh error:', err); console.error('[FileBrowser] refresh error:', err);
@@ -302,6 +352,50 @@ export const useFileBrowserApp = (
}; };
}, [searchQuery]); }, [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 // Clipboard image paste
useEffect(() => { useEffect(() => {
const handler = (ev: ClipboardEvent) => { const handler = (ev: ClipboardEvent) => {
@@ -475,6 +569,21 @@ export const useFileBrowserApp = (
setCurrentPath(next); setCurrentPath(next);
return; 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 // 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 // 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 // was a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a
@@ -970,6 +1079,78 @@ export const useFileBrowserApp = (
} }
}; };
/*
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) => { const handleDragEnter = (ev: React.DragEvent) => {
ev.preventDefault(); ev.preventDefault();
dragCounter.current++; dragCounter.current++;
@@ -995,6 +1176,28 @@ export const useFileBrowserApp = (
dragCounter.current = 0; dragCounter.current = 0;
setDragging(false); 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; const items = ev.dataTransfer.items;
if (!items || items.length === 0) return; if (!items || items.length === 0) return;
@@ -1088,7 +1291,12 @@ export const useFileBrowserApp = (
// Selection // Selection
selected, selected,
setSelected, setSelected,
handleEntryDragStart,
handleEntryDrop,
clipboard, 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, renamingName,
setRenamingName, setRenamingName,
// Search // 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 { useRecentFiles } from './useRecentFiles';
export { usePinnedFiles } from './usePinnedFiles'; export { usePinnedFiles } from './usePinnedFiles';
export { FileBrowserApp, FileBrowserPanelWrapper } from './FileBrowserApp'; export { FileBrowserApp, FileBrowserPanelWrapper } from './FileBrowserApp';
export type { PickedFile } from './FileBrowserApp/useFileBrowserApp';
export { FileBrowserWidget } from './FileBrowserWidget'; export { FileBrowserWidget } from './FileBrowserWidget';
export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal'; export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
@@ -27,3 +28,5 @@ export const appRegistryMetas: AppRegistryMeta[] = [
availableOnPanel: false, availableOnPanel: false,
}, },
]; ];
export type { FileAction, FileActionEntry, FileActionContext } from './file-actions';
export { registerFileActions, fileActionsFor } from './file-actions';
@@ -205,6 +205,20 @@ export type DirEntry = {
* response, which simply means no thumbnail — the file icon, exactly as before. * response, which simply means no thumbnail — the file icon, exactly as before.
*/ */
thumb?: 'image' | 'video' | null; thumb?: 'image' | 'video' | null;
/**
* Where this points, when it is a symlink — exactly as the link was written.
*
* Relative targets stay relative. `../originals` and `/home/…/originals` behave differently when the
* folder moves, and resolving one into the other would hide the difference at the moment somebody is
* looking to understand it.
*
* `type` still describes the TARGET, so a link to a folder opens like a folder. This says only that
* there is a link in the way. Absent on an older response, which reads as "not a link" — the same
* thing every entry said before.
*/
symlink?: string | null;
/** The target is gone. The row is drawn anyway, because the link itself is really there. */
broken?: boolean;
}; };
type ListDirResponse = { type ListDirResponse = {
+3
View File
@@ -59,6 +59,9 @@ export {
useRecentFiles, useRecentFiles,
usePinnedFiles, usePinnedFiles,
FileBrowserApp, FileBrowserApp,
registerFileActions,
type FileAction,
type PickedFile,
FileBrowserPanelWrapper, FileBrowserPanelWrapper,
FileBrowserWidget, FileBrowserWidget,
TaskRunnerModal, TaskRunnerModal,
@@ -542,7 +542,10 @@
"type": "Typ", "type": "Typ",
"size": "Größe", "size": "Größe",
"date": "Datum" "date": "Datum"
} },
"toCopy": "zu kopieren",
"toMove": "zu verschieben",
"clearClipboard": "Zwischenablage leeren"
}, },
"taskRunner": { "taskRunner": {
"stop": "Stopp", "stop": "Stopp",
@@ -542,7 +542,10 @@
"type": "Type", "type": "Type",
"size": "Size", "size": "Size",
"date": "Date" "date": "Date"
} },
"toCopy": "to copy",
"toMove": "to move",
"clearClipboard": "Clear clipboard"
}, },
"taskRunner": { "taskRunner": {
"stop": "Stop", "stop": "Stop",
@@ -542,7 +542,10 @@
"type": "Tipo", "type": "Tipo",
"size": "Tamaño", "size": "Tamaño",
"date": "Fecha" "date": "Fecha"
} },
"toCopy": "para copiar",
"toMove": "para mover",
"clearClipboard": "Vaciar portapapeles"
}, },
"taskRunner": { "taskRunner": {
"stop": "Detener", "stop": "Detener",
@@ -542,7 +542,10 @@
"type": "Type", "type": "Type",
"size": "Taille", "size": "Taille",
"date": "Date" "date": "Date"
} },
"toCopy": "à copier",
"toMove": "à déplacer",
"clearClipboard": "Vider le presse-papiers"
}, },
"taskRunner": { "taskRunner": {
"stop": "Arrêter", "stop": "Arrêter",
@@ -542,7 +542,10 @@
"type": "Tipo", "type": "Tipo",
"size": "Tamanho", "size": "Tamanho",
"date": "Data" "date": "Data"
} },
"toCopy": "a copiar",
"toMove": "a mover",
"clearClipboard": "Limpar área de transferência"
}, },
"taskRunner": { "taskRunner": {
"stop": "Parar", "stop": "Parar",
@@ -542,7 +542,10 @@
"type": "Typ", "type": "Typ",
"size": "Storlek", "size": "Storlek",
"date": "Datum" "date": "Datum"
} },
"toCopy": "att kopiera",
"toMove": "att flytta",
"clearClipboard": "Töm urklipp"
}, },
"taskRunner": { "taskRunner": {
"stop": "Stoppa", "stop": "Stoppa",
+9
View File
@@ -2,6 +2,15 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
export type { UseSettingsType, UserSettings, UserState } from './useSettings'; export type { UseSettingsType, UserSettings, UserState } from './useSettings';
export { useUserState } from './useUserState'; export { useUserState } from './useUserState';
export { useDashboardState } from './useDashboardState'; export { useDashboardState } from './useDashboardState';
/*
Exported for a workspace nested INSIDE a panel — see plugins/fofos-admin/web/WorkPanel.tsx.
`useDashboardState` persists the whole tree, which is right for a dashboard somebody arranged and
wrong for a layout its author ships: the structure would be frozen at whatever it was the first time
a screen was opened. `useAuthoredLayout` keeps the structure in source and persists only sizes and
zoom, which is what a nested authored layout wants for the same reason `PluginScreen` does.
*/
export { useAuthoredLayout } from './useAuthoredLayout';
export { useModels, useVisibleModels, useUserVisibleModels, useEnabledModels, modelKey } from './useModels'; export { useModels, useVisibleModels, useUserVisibleModels, useEnabledModels, modelKey } from './useModels';
export type { ModelOption } from './useModels'; export type { ModelOption } from './useModels';
export { useAccessPolicy } from './useAccessPolicy'; export { useAccessPolicy } from './useAccessPolicy';
+45
View File
@@ -204,6 +204,51 @@
background-attachment: fixed; background-attachment: fixed;
background-size: cover; background-size: cover;
} }
/*
NATIVE CONTROLS FOLLOW THE THEME.
------------------------------------------------------------------------------------------
Nothing here set `color-scheme`, so the browser drew every control it renders itself as if the page
were light — on a dark theme, a `<select>`'s dropdown opened as a white sheet with the page's own
pale text on it. Unreadable, and unreachable by any utility class, because that list is painted by
the browser and is not in the document.
`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with
no styleable interior: date and colour pickers, the spinner on a number input, and default
scrollbars where a theme has not replaced them.
── Why the `dark` class decides it ──
It is the signal that exists. 203 of the 231 themes set `applyDarkClass`, and the provider puts the
class on <html> beside `data-theme`, so the two are always in agreement about the same element.
Both are specificity 0-1-0 and themes are imported after this file, so a theme that disagrees can
simply say so — this is a default, not a decision taken away.
── And the explicit colours underneath ──
`color-scheme` gives the browser's generic dark, not the theme's. Where a browser honours colours on
`<option>` — Firefox, and Chrome on Linux and Windows — these make the list the theme's own popover
surface instead. Where it does not (Safari, Chrome on macOS, which use a genuinely native menu), the
scheme above is what carries it, and generic dark is a great deal better than white-on-white.
A utility still wins: these are base-layer rules, so `bg-transparent` on a select behaves exactly as
it did.
*/
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
select,
option,
optgroup {
background-color: var(--popover);
color: var(--popover-foreground);
}
} }
/* /*