Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816f869535 | ||
|
|
d058cd9258 | ||
|
|
dfe837dfd0 | ||
|
|
a53c07d71f | ||
|
|
e3071f5d50 | ||
|
|
601849be11 | ||
|
|
1f75fb19af | ||
|
|
a6d4a18592 | ||
|
|
e756007551 | ||
|
|
9719e36723 | ||
|
|
912ecd04f7 | ||
|
|
dd8c7fd7bd |
@@ -0,0 +1,76 @@
|
||||
# 2026.08.29.04 — deploy report
|
||||
|
||||
**7 of 7 targets succeeded. `officer-dev` skipped by design — it is the host the rollout ran on.**
|
||||
|
||||
Every host went `03fbb118` → `e9b71522` except `fofos`, which started further back at `2738f799` and
|
||||
travelled further. All eight rows below end on `e9b71522`, the commit `deploy/2026.08.29.04` points at.
|
||||
|
||||
Nothing failed. Nothing was left half-applied. No host was unreachable — `macbookpro` was awake.
|
||||
|
||||
## What ran, per host
|
||||
|
||||
The same four steps everywhere: tracked-only dirty check, `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.08.29.04`, `bun run plugins:update`, then `pm2 restart officer` and
|
||||
nothing else. The plan asked for no `bun install` and no `bun db:push`, and neither was run.
|
||||
|
||||
| Host | HEAD before | HEAD after | plugins | `officer` restarts | health | `ls` |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | **SKIPPED — the rollout ran here** | — | — | — |
|
||||
| `alpha` | `03fbb118` | `e9b71522` | 15/15 | 5 → 6 | 401 | 200, 77 entries |
|
||||
| `fofos` | `2738f799` | `e9b71522` | 5/5 | 93 → 94 | 401 | 200, 39 entries |
|
||||
| `macbookpro` | `03fbb118` | `e9b71522` | none installed | 5 → 6 | 401 (9010) | 200, 62 entries |
|
||||
| `macbruno` | `03fbb118` | `e9b71522` | 1/1 | 8 → 9 | 401 | 200, 41 entries |
|
||||
| `production-pertento-bridge` | `03fbb118` | `e9b71522` | 1/1 | 6 → 7 | 401 (9010) | 200, 53 entries |
|
||||
| `edge-pertento-bridge` | `03fbb118` | `e9b71522` | 2/2 | 7 → 8 | 401 | 200, 29 entries |
|
||||
| `deadpool-rezio-bridge` | `03fbb118` | `e9b71522` | 2/2 | 24 → 25 | 401 | 200, 49 entries |
|
||||
|
||||
`plugins:update` exited 0 on every host that has plugins. `401` on `/api/health` is healthy — the plan
|
||||
says so and every host gave the same answer.
|
||||
|
||||
## The file browser check
|
||||
|
||||
This release rewrote the file browser and the viewer, so the plan asked for one signed
|
||||
`/api/file-browser/ls` call per host. **Seven hosts, seven `200`s**, each with a plausible entry count
|
||||
for that machine. The counts differ because the homes differ; none was zero and none was a 500.
|
||||
|
||||
Signed as user id 1 with that host's own owner, which is not the same address everywhere —
|
||||
`conceiba@gmail.com` on `macbruno` and `brunorezio@gmail.com` on `deadpool-rezio-bridge`. Read from each
|
||||
host's own `users` table rather than assumed.
|
||||
|
||||
## Schema
|
||||
|
||||
**No schema verification was performed, because this release changes no schema.** The plan states that
|
||||
and shows the two `git diff --name-only` commands behind the claim. No `bun db:push` step exists in it
|
||||
and none was improvised.
|
||||
|
||||
The plugin sidecars did apply their own schemas during `plugins:update` — `music` on `alpha`,
|
||||
`fofos-admin` and `proxy-manager` on `fofos`, `proxy-manager` on `macbruno`, `offscale` on
|
||||
`edge-pertento-bridge`, `email` on `deadpool-rezio-bridge`. Each printed its own
|
||||
`schema: applied … (N table(s) verified)`. That belongs to the plugin updater, not to this release.
|
||||
|
||||
## More restarted than `officer`
|
||||
|
||||
`plugins:update` restarts a sidecar whenever it moves a plugin, before the plan's own restart. Naming
|
||||
only `officer` would understate what moved, so: `officer-music` on `alpha`, `officer-av-club` on
|
||||
`fofos`, `officer-gitea` on `production-pertento-bridge`, `officer-gitea` and `officer-offscale` on
|
||||
`edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. Every one came back `online`.
|
||||
|
||||
`ecosystem.config.cjs` was never restarted anywhere. No vault was touched.
|
||||
|
||||
## What I did not do
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. It already sits on this code, and
|
||||
restarting it would have ended the session writing this report. The owner restarts it deliberately,
|
||||
last, once happy with the rest.
|
||||
- **`fofos` has an untracked `scripts/chk.ts`.** Untracked is not dirty; the fast-forward could not touch
|
||||
it and it was left exactly where it was. A note, not a skip.
|
||||
- **`production-pertento-bridge` prints an nvm warning** on every interactive shell — its `~/.npmrc` has a
|
||||
`globalconfig`/`prefix` setting nvm objects to. It is noise, it broke nothing, and it is not this
|
||||
release's doing. Worth clearing at some point so it stops appearing in deploy output.
|
||||
- **No `--force` anywhere**, no dependency install, no database write.
|
||||
|
||||
## One thing worth knowing
|
||||
|
||||
The plan flags a **platform event bus** — `files:uploaded`, `files:placed`, `archive:extracted` — that no
|
||||
plugin subscribes to yet. The dispatch path therefore ships unexercised. If something odd shows up near an
|
||||
upload on any of these hosts, that is the first place to look.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.30.01 — the file browser's clipboard moves to the server, and plugins reach further in
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.04..deploy/2026.08.30.01 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --name-only deploy/2026.08.29.04..deploy/2026.08.30.01 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exists in this plan, and neither should be improvised.**
|
||||
Eleven commits; one of them is the previous release's report.
|
||||
|
||||
The clipboard is per-user state and it needs no migration — it is a key in `user_state`, a JSONB column
|
||||
that already exists, written by the same `PATCH /user/state` the dock and the view mode already use.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **The file browser's clipboard lives on the server.** It was `useState`, so it existed in one tab and
|
||||
died with it: copying in one window and pasting in another did nothing, and a reload lost a pending cut
|
||||
with no sign that it had. It is now a key in `user_state`, per user rather than per browser — cutting on
|
||||
a desktop and pasting on a phone is one gesture across two screens.
|
||||
- **Files can be dragged between windows.** A custom MIME type on the DataTransfer survives between tabs
|
||||
and windows of the same browser, so a row can be dragged from one Officer window into a folder in
|
||||
another. It cannot cross different browsers or exchange with Finder; that is a property of the platform,
|
||||
not a gap to report.
|
||||
- **A window re-reads its folder and the clipboard when it comes forward**, so the window a file was cut
|
||||
from stops listing it. And **re-reading no longer blanks the list** — the spinner is now for a folder
|
||||
that cannot yet be shown, so a refresh after a paste or a delete leaves the rows and the scroll position
|
||||
where they were.
|
||||
- **A plugin can put an action in the file browser's context menu**, and gets an authenticated client to
|
||||
use in it. Three commits, because the first two shipped the registration without forwarding it into the
|
||||
menu, and then without a way to call the API.
|
||||
- **The file browser can be a file picker** — `onPickFile` turns opening a file into handing it back,
|
||||
rather than a second component that reimplements a listing, a breadcrumb and a sort.
|
||||
- **A plugin can hand the agent its own MCP server**, declared in its manifest.
|
||||
- **A plugin may not bring its own React.** `npmDependencies` strips and then deletes `react` and
|
||||
`react-dom` after install: a plugin's peer copy landing in `plugins/node_modules` gives the app two
|
||||
Reacts and every hook throws.
|
||||
|
||||
## Nothing here changes behaviour anyone has to be warned about
|
||||
|
||||
No route is removed, no permission changes, nothing 404s that did not before. The clipboard is additive:
|
||||
a browser that has never set one reads `null` and behaves exactly as before.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the whole rollout (dirty check,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.30.01`, `bun run plugins:update`), plus:
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only — `plugins:update` has already restarted the plugin sidecars.
|
||||
|
||||
### Verify it came back
|
||||
|
||||
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
|
||||
|
||||
### And check the file browser still lists
|
||||
|
||||
This release touched the file browser heavily — the listing loader, the clipboard and the row markup —
|
||||
so one call per host:
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { sign } = await import('./src/servers/jwt');
|
||||
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
|
||||
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
|
||||
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
|
||||
|
||||
## Per host
|
||||
|
||||
Surveyed 2026-08-30 while writing this. Every host answered, and every one is clean
|
||||
(`--untracked-files=no`).
|
||||
|
||||
| Host | HEAD at survey | Note |
|
||||
| --- | --- | --- |
|
||||
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||
| `alpha` | `e9b71522` | Exactly on the previous tag. Ordinary target. |
|
||||
| `fofos` | `601849be` | **Ahead of the previous tag, behind this one.** It has been hand-pulled all day while the fofos plugin work was written against it. That commit is an ancestor of this tag, so the fast-forward is ordinary — it simply travels fewer commits. It is also the only host with `av-club` and `fofos-admin` installed, so `plugins:update` moves more here than anywhere else. |
|
||||
| `macbookpro` | `e9b71522` | macOS, port **9010**, a laptop. Answered the survey; if it has slept since, unreachable is a SKIP with a note, not a failure. |
|
||||
| `macbruno` | `e9b71522` | macOS, login **`brunoalcadapadezmonteirodaconceicao`**. |
|
||||
| `production-pertento-bridge` | `e9b71522` | Port **9010**. |
|
||||
| `edge-pertento-bridge` | `e9b71522` | Ordinary target. |
|
||||
| `deadpool-rezio-bridge` | `e9b71522` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
|
||||
`restart_time`, and the `ls` check's output.
|
||||
|
||||
Nothing in this release needs a database query, a backup or an install. If you find yourself running one,
|
||||
stop — the plan is wrong, and that is worth reporting rather than working around.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.29.04",
|
||||
"version": "2026.08.30.01",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -8,7 +8,17 @@ import { useInitialData } from '@/state/useInitialData';
|
||||
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
|
||||
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
|
||||
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
|
||||
import { plugins as installedPlugins } from './Plugins.gen';
|
||||
import { plugins as installedPlugins, pluginFileActions } from './Plugins.gen';
|
||||
import { registerFileActions } from 'officerdev';
|
||||
|
||||
/*
|
||||
Plugin actions for the file browser's context menu, handed over once at module load.
|
||||
|
||||
Injected rather than imported by the browser itself: `Plugins.gen.tsx` is generated INTO this app, and
|
||||
the file browser lives in a workspace package this app depends on. Importing it there would point the
|
||||
dependency backwards and make that package unusable outside Officer's own frontend.
|
||||
*/
|
||||
registerFileActions(pluginFileActions);
|
||||
|
||||
/**
|
||||
* `/plugins` → `/marketplace`, KEEPING the query string.
|
||||
|
||||
@@ -98,6 +98,8 @@ function findWeb(
|
||||
screens: [
|
||||
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout },
|
||||
],
|
||||
// Optional and unannounced — presence is the declaration, as it is for panels and layout.
|
||||
fileActions: fileOrNull(join(webDir, 'file-actions.ts')),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -157,7 +159,7 @@ function findWeb(
|
||||
}
|
||||
|
||||
if (problems.length) return { web: null, error: `${appName}: ${problems.join('; ')}.` };
|
||||
return { web: { screens } };
|
||||
return { web: { screens, fileActions: fileOrNull(join(webDir, 'file-actions.ts')) } };
|
||||
}
|
||||
|
||||
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
|
||||
|
||||
@@ -66,6 +66,24 @@ export function generatePluginsModule(states: PluginState[]): string {
|
||||
// it always did; caldav has two, so it emits two routes, two layout keys and two dock tiles from one
|
||||
// install. The alias counter runs over screens for that reason — two screens of one plugin must not
|
||||
// share an import name.
|
||||
/*
|
||||
File-browser actions, one block per PLUGIN rather than per screen.
|
||||
|
||||
The context menu is global — it is the same menu wherever the browser is mounted — so an action
|
||||
belongs to the plugin, not to one of its screens. A plugin with two screens contributes its actions
|
||||
once.
|
||||
*/
|
||||
const actionImports: string[] = [];
|
||||
const actionEntries: string[] = [];
|
||||
let a = 0;
|
||||
for (const { plugin } of withWeb) {
|
||||
if (!plugin.web?.fileActions) continue;
|
||||
const alias = `Actions${a}`;
|
||||
a += 1;
|
||||
actionImports.push(`import { fileActions as ${alias} } from '${specifier(plugin.web.fileActions)}';`);
|
||||
actionEntries.push(` ...${alias},`);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (const { plugin } of withWeb) {
|
||||
for (const screen of plugin.web!.screens) {
|
||||
@@ -85,8 +103,19 @@ export function generatePluginsModule(states: PluginState[]): string {
|
||||
}
|
||||
|
||||
const body = `${HEADER}
|
||||
import type { AppRegistryMeta, LayoutNode } from 'officerdev';
|
||||
import type { AppRegistryMeta, FileAction, LayoutNode } from 'officerdev';
|
||||
${imports.join('\n')}
|
||||
${actionImports.join('\n')}
|
||||
|
||||
/**
|
||||
* Context-menu actions plugins contribute to the file browser.
|
||||
*
|
||||
* Flat and global: the menu is the same wherever the browser is mounted, so these are not scoped to a
|
||||
* screen. Each decides for itself whether it applies to what was selected.
|
||||
*/
|
||||
export const pluginFileActions: FileAction[] = [
|
||||
${actionEntries.join('\n')}
|
||||
];
|
||||
|
||||
export type GeneratedPlugin = {
|
||||
/** The plugin this screen belongs to. Two screens of one plugin share it. */
|
||||
|
||||
@@ -523,6 +523,31 @@ export type PluginManifest = {
|
||||
*/
|
||||
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[];
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PLATFORM_DIR } from '../data-path';
|
||||
import type { DiscoveredPlugin } from './manifest';
|
||||
@@ -104,7 +105,33 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
|
||||
return { ok: true, ran: false, output: '', conflicts };
|
||||
}
|
||||
|
||||
const body = {
|
||||
/*
|
||||
Packages the PLATFORM owns, which a plugin may never bring its own copy of.
|
||||
|
||||
React holds module-level state — the hook dispatcher — so two copies in one page is not a duplicated
|
||||
dependency, it is a broken one. The second instance's `useRef` reads a null dispatcher and every panel
|
||||
fails to render with "Cannot read properties of null (reading 'useRef')", which names nothing that
|
||||
would lead somebody here.
|
||||
|
||||
It is easy to arrive at by accident and hard to diagnose: nobody asks for React. `@tiptap/react`
|
||||
declares it as a PEER, bun installs peers, and the copy lands in plugins/node_modules where it wins
|
||||
resolution for everything under it. That took the fofos-admin screen down the moment tiptap arrived.
|
||||
|
||||
Stripped rather than pinned. Matching the platform's version would still be two copies, and two copies
|
||||
is the whole problem — bun resolves up the tree, so removing them entirely is what makes a plugin use
|
||||
the one the platform already loaded.
|
||||
|
||||
Removed AFTER the install as well as before it, and the second is the one that does the work: no
|
||||
plugin declares React, so there is nothing to strip from the merged list. It arrives as a PEER of
|
||||
something that does, which bun installs and the generated file never mentions.
|
||||
*/
|
||||
const PLATFORM_OWNED = ['react', 'react-dom'];
|
||||
|
||||
for (const owned of PLATFORM_OWNED) {
|
||||
if (owned in merged) delete merged[owned];
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: '@officer/plugin-dependencies',
|
||||
private: true,
|
||||
'//': 'GENERATED from every plugin manifest by src/servers/plugins/npm-deps.ts. Not edited, not committed.',
|
||||
@@ -117,7 +144,26 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
|
||||
const proc = Bun.spawn(['bun', 'install'], { cwd: PLUGINS_DIR, stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, ran: true, output: `${out}${err}`.trim(), conflicts };
|
||||
|
||||
/*
|
||||
Evict the platform's singletons, however they got here.
|
||||
|
||||
This is the step that matters. Stripping the merged list above only covers a plugin ASKING for
|
||||
React, and none do — it arrives as a peer of something that does, so bun installs it without the
|
||||
generated file ever naming it. Deleting it afterwards is crude and it is deterministic, which is
|
||||
the right trade for a failure whose symptom is every panel on a screen refusing to render.
|
||||
*/
|
||||
const evicted: string[] = [];
|
||||
for (const owned of PLATFORM_OWNED) {
|
||||
const path = join(PLUGINS_DIR, 'node_modules', owned);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
evicted.push(owned);
|
||||
}
|
||||
}
|
||||
|
||||
const note = evicted.length ? `\nremoved ${evicted.join(', ')} — the platform owns those` : '';
|
||||
return { ok: code === 0, ran: true, output: `${out}${err}${note}`.trim(), conflicts };
|
||||
} catch (error) {
|
||||
return { ok: false, ran: true, output: error instanceof Error ? error.message : String(error), conflicts };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { createSidecarConnector } from '../connect';
|
||||
import { sign } from '../../jwt';
|
||||
import { getUserByEmail, getOwnerUser } from 'officerdb';
|
||||
import { DATA_PATH, findEmailAccountName } from '../../data-path';
|
||||
import { listPluginInstalls } from 'officerdb';
|
||||
import { discoverPlugins } from '../../plugins/discover';
|
||||
import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
|
||||
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
|
||||
@@ -140,7 +142,7 @@ loadState(dbUser.id);
|
||||
|
||||
// ── MCP config ──
|
||||
|
||||
function generateMcpConfig(): string {
|
||||
async function generateMcpConfig(): Promise<string> {
|
||||
// Was `.container-context`, from the architecture where each user ran inside their own Docker
|
||||
// container and this directory described that container to the agent. Nothing about it is
|
||||
// container-related now — it holds exactly one file, the MCP server config handed to the CLI. The
|
||||
@@ -152,8 +154,45 @@ function generateMcpConfig(): string {
|
||||
const userRoot = join(DATA_PATH, email!);
|
||||
|
||||
const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':');
|
||||
|
||||
/*
|
||||
MCP servers contributed by plugins.
|
||||
|
||||
A plugin that fronts something with its own MCP server can either reimplement that vocabulary as
|
||||
Officer tools or hand the real one over. Reimplementing means a second copy that goes stale, and it
|
||||
fails silently — a tool exists upstream that Officer cannot see. Handing it over costs this block.
|
||||
|
||||
The plugin declares a PATH, never a URL, and it is resolved against this Officer's own API. That is
|
||||
the whole safety property: the config below carries `OFFICER_AUTH_TOKEN`, a token that signs as the
|
||||
owner, so a manifest able to name an arbitrary host would be a manifest able to post the owner's
|
||||
credential anywhere. A path can only ever reach this machine, through this machine's own gate.
|
||||
|
||||
Installed AND enabled, because a disabled plugin's routes are not mounted — offering the agent a
|
||||
tool list that 404s is worse than not offering it.
|
||||
*/
|
||||
const pluginMcpServers: Record<string, { type: 'http'; url: string; headers: Record<string, string> }> = {};
|
||||
try {
|
||||
const [installs, discovered] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
|
||||
const live = new Set(installs.filter((entry) => entry.enabled).map((entry) => entry.appName));
|
||||
|
||||
for (const plugin of discovered.plugins) {
|
||||
const declared = plugin.manifest?.mcp?.path;
|
||||
if (!declared || !live.has(plugin.appName)) continue;
|
||||
const path = declared.startsWith('/') ? declared : `/${declared}`;
|
||||
pluginMcpServers[plugin.appName] = {
|
||||
type: 'http',
|
||||
url: `${OFFICER_API_URL}/api/${plugin.appName}${path}`,
|
||||
headers: { Authorization: `Bearer ${OFFICER_AUTH_TOKEN}` },
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// A plugin that cannot be read must not stop an agent from starting. It loses its MCP tools for this
|
||||
// session and everything else works, which is the right side to fail on.
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
mcpServers: {
|
||||
...pluginMcpServers,
|
||||
'officer-tools': {
|
||||
type: 'stdio',
|
||||
command: 'bun',
|
||||
@@ -236,7 +275,13 @@ function ensureAnthropicEnv(): void {
|
||||
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
|
||||
// terminal `claude` loads too.
|
||||
|
||||
setMcpConfigPath(generateMcpConfig());
|
||||
/*
|
||||
Awaited, because the config now asks the database which plugins are installed.
|
||||
|
||||
Not fire-and-forget: `setMcpConfigPath` is what the agent manager reads when it spawns, so a config
|
||||
that arrives after the first spawn is an agent running without its plugin tools and no sign of why.
|
||||
*/
|
||||
setMcpConfigPath(await generateMcpConfig());
|
||||
ensureAnthropicEnv();
|
||||
|
||||
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
||||
import { AgentRunnerDialog } from './components/AgentRunnerDialog';
|
||||
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
||||
import { DictateDialog } from './components/DictateDialog';
|
||||
import { useFileBrowserApp } from './useFileBrowserApp';
|
||||
import { useFileBrowserApp, type PickedFile } from './useFileBrowserApp';
|
||||
|
||||
type FileBrowserAppProps = {
|
||||
basePath?: string;
|
||||
@@ -27,6 +27,14 @@ type FileBrowserAppProps = {
|
||||
* where the folder is the URL and already does.
|
||||
*/
|
||||
panelId?: string;
|
||||
/**
|
||||
* Turn this into a CHOOSER: opening a file hands it back instead of opening a viewer.
|
||||
*
|
||||
* Folders still navigate, because choosing a file means getting to it. A prop rather than a second
|
||||
* component — a picker built separately is a listing, a breadcrumb, a sort, thumbnails and keyboard
|
||||
* handling reimplemented, all of it free to drift from the real browser.
|
||||
*/
|
||||
onPickFile?: (file: PickedFile) => void;
|
||||
};
|
||||
|
||||
export const FileBrowserApp = ({
|
||||
@@ -36,8 +44,9 @@ export const FileBrowserApp = ({
|
||||
urlPath,
|
||||
panelId,
|
||||
pathParam,
|
||||
onPickFile,
|
||||
}: FileBrowserAppProps) => {
|
||||
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam);
|
||||
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam, onPickFile);
|
||||
const { handleNavigate } = fileBrowserManager;
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||
import { getFileType } from '../../../FileViewer';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { fileActionsFor } from '../../file-actions';
|
||||
import { FileItem } from './FileItem';
|
||||
import { useTranslation } from '../../../../locales';
|
||||
|
||||
@@ -61,6 +63,8 @@ const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
|
||||
export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
const { t } = useTranslation();
|
||||
// For plugin actions: `run` is outside React and cannot call the hook itself.
|
||||
const client = useClient();
|
||||
const {
|
||||
visibleEntries: entries,
|
||||
viewMode,
|
||||
@@ -68,6 +72,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
rootOverride,
|
||||
selected,
|
||||
clipboard,
|
||||
refresh,
|
||||
handleOpen,
|
||||
handleDelete,
|
||||
handleRename,
|
||||
@@ -167,8 +172,23 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
const cols = useColumnCount(scrollRef);
|
||||
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
||||
const anySelected = selected.size > 0;
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
|
||||
const pluginActionsFor = (entry: DirEntry) => {
|
||||
const names = selected.has(entry.name) && selected.size > 1 ? [...selected] : [entry.name];
|
||||
const payload = entries
|
||||
.filter((candidate) => names.includes(candidate.name))
|
||||
.map((candidate) => ({ name: candidate.name, path: entryPath(candidate.name), type: candidate.type }));
|
||||
|
||||
return fileActionsFor(payload).map((action) => ({
|
||||
key: action.key,
|
||||
label: action.label,
|
||||
Icon: action.icon,
|
||||
onClick: () => action.run(payload, { currentPath, refresh, client }),
|
||||
}));
|
||||
};
|
||||
|
||||
const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
@@ -269,6 +289,14 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
onReadAloud={handleReadAloud}
|
||||
onExtract={handleExtract}
|
||||
taskGroups={getMatchingTaskGroups(entry.name, entry.type)}
|
||||
/*
|
||||
Plugin actions, resolved here because this is where the SELECTION lives.
|
||||
|
||||
An action receives the whole selection when the right-clicked item is part of one, and just
|
||||
that item otherwise — what every file manager does. Acting on a selection the click was not
|
||||
part of is the surprising alternative.
|
||||
*/
|
||||
pluginActions={pluginActionsFor(entry)}
|
||||
onRunTask={handleRunTask}
|
||||
agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
|
||||
onRunAgent={handleRunAgent}
|
||||
@@ -277,6 +305,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
onCompress={handleCompress}
|
||||
onDuplicate={handleDuplicate}
|
||||
onPasteInto={handlePasteInto}
|
||||
onDragStart={fileBrowserManager.handleEntryDragStart}
|
||||
onDropInto={fileBrowserManager.handleEntryDrop}
|
||||
onTogglePin={handleTogglePin}
|
||||
isPinned={isPinned(entryPath(entry.name))}
|
||||
canPaste={clipboard !== null}
|
||||
|
||||
+62
-1
@@ -23,6 +23,7 @@ import {
|
||||
FileArchive,
|
||||
Pin,
|
||||
PinOff,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { Thumbnail } from './Thumbnail';
|
||||
@@ -84,9 +85,25 @@ export type FileItemProps = {
|
||||
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
|
||||
onCreateDashboard: (entry: DirEntry) => void;
|
||||
onOpenInEditor: (entry: DirEntry) => void;
|
||||
/**
|
||||
* Context-menu actions contributed by plugins, already resolved against the selection.
|
||||
*
|
||||
* Resolved by the caller, not here: whether an action receives one entry or the whole selection is a
|
||||
* question about the SELECTION, which this component does not have and should not need.
|
||||
*/
|
||||
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
|
||||
onCompress: (entry: DirEntry) => void;
|
||||
onDuplicate: (entry: DirEntry) => void;
|
||||
onPasteInto: (entry: DirEntry) => void;
|
||||
/**
|
||||
* Dragging this row, and dropping onto it.
|
||||
*
|
||||
* A custom MIME type on the DataTransfer survives between windows of the same browser, which is what
|
||||
* makes "drag from one Officer window into another" work at all. See useFileBrowserApp for the type
|
||||
* and why `Files` is left alone.
|
||||
*/
|
||||
onDragStart: (ev: React.DragEvent, entry: DirEntry) => void;
|
||||
onDropInto: (ev: React.DragEvent, entry: DirEntry) => Promise<void>;
|
||||
onTogglePin: (entry: DirEntry) => void;
|
||||
isPinned: boolean;
|
||||
canPaste: boolean;
|
||||
@@ -177,6 +194,7 @@ type MenuItemsProps = {
|
||||
canPaste: boolean;
|
||||
onOpen: (e: DirEntry) => void;
|
||||
onOpenInEditor: (e: DirEntry) => void;
|
||||
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
|
||||
onDelete: (e: DirEntry) => void;
|
||||
onStartRename: () => void;
|
||||
onChat: (e: DirEntry) => void;
|
||||
@@ -212,6 +230,7 @@ const MenuItems = ({
|
||||
canPaste,
|
||||
onOpen,
|
||||
onOpenInEditor,
|
||||
pluginActions,
|
||||
onDelete,
|
||||
onStartRename,
|
||||
onChat,
|
||||
@@ -254,7 +273,7 @@ const MenuItems = ({
|
||||
|
||||
const hasTasks = taskGroups.length > 0;
|
||||
const hasAgents = agentGroups.length > 0;
|
||||
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents;
|
||||
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents || Boolean(pluginActions?.length);
|
||||
// Only nest when more than one category matched — a file usually matches a single category, and
|
||||
// Run Task > Video > Convert would just add a hop.
|
||||
const nestTasks = taskGroups.length > 1;
|
||||
@@ -305,6 +324,14 @@ const MenuItems = ({
|
||||
{t('files.menu.extract')}
|
||||
</Item>
|
||||
)}
|
||||
{/* Plugin actions, flat rather than nested. There are few of them and each is one click; a
|
||||
submenu for two items is a hop that buys nothing. */}
|
||||
{pluginActions?.map((action) => (
|
||||
<Item key={action.key} onClick={action.onClick} className="cursor-pointer">
|
||||
{action.Icon ? <action.Icon className="mr-2 h-4 w-4" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
{action.label}
|
||||
</Item>
|
||||
))}
|
||||
{hasTasks && (
|
||||
<Sub>
|
||||
<SubTrigger className="cursor-pointer">
|
||||
@@ -565,6 +592,7 @@ export const FileItem = ({
|
||||
viewMode,
|
||||
selected,
|
||||
anySelected,
|
||||
pluginActions,
|
||||
selectedCount,
|
||||
isCut,
|
||||
onOpen,
|
||||
@@ -589,6 +617,8 @@ export const FileItem = ({
|
||||
onCompress,
|
||||
onDuplicate,
|
||||
onPasteInto,
|
||||
onDragStart,
|
||||
onDropInto,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
canPaste,
|
||||
@@ -735,6 +765,10 @@ export const FileItem = ({
|
||||
onCreateDashboard,
|
||||
onOpen,
|
||||
onOpenInEditor,
|
||||
// Typed on both ends and forwarded by neither, which is why "Create Gallery" was in the bundle and
|
||||
// not in the menu. `menuProps` is assembled field by field, so a new prop is not carried by being
|
||||
// declared.
|
||||
pluginActions,
|
||||
onCompress,
|
||||
onDuplicate,
|
||||
onPasteInto,
|
||||
@@ -757,6 +791,24 @@ export const FileItem = ({
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, entry)}
|
||||
/*
|
||||
A folder is a drop target; a file is not. `onDragOver` must preventDefault or the browser
|
||||
refuses the drop entirely — that is the single line whose absence makes a target look
|
||||
dead, with no error anywhere.
|
||||
|
||||
`dropEffect` is set here rather than at dragstart because only the target knows whether
|
||||
it can accept: the cursor says "move" over a folder and "no" over a file, which is the
|
||||
only feedback a drag has.
|
||||
*/
|
||||
onDragOver={(ev) => {
|
||||
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(ev) => void onDropInto(ev, entry)}
|
||||
>
|
||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||
<span className="shrink-0">{listVisual}</span>
|
||||
@@ -804,6 +856,15 @@ export const FileItem = ({
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, entry)}
|
||||
onDragOver={(ev) => {
|
||||
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(ev) => void onDropInto(ev, entry)}
|
||||
>
|
||||
<div className="absolute top-1 left-1">
|
||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||
|
||||
+34
-9
@@ -1,5 +1,5 @@
|
||||
import { useRef } from 'react';
|
||||
import { FolderPlus, Upload, FolderUp, ClipboardPaste } from 'lucide-react';
|
||||
import { FolderPlus, Upload, FolderUp, ClipboardPaste, X } from 'lucide-react';
|
||||
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
|
||||
import { useTranslation } from '../../../../../locales';
|
||||
|
||||
@@ -9,7 +9,7 @@ type DefaultActionsProps = {
|
||||
|
||||
export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setNewEntry, handleUpload, clipboard, handlePaste } = fileBrowserManager;
|
||||
const { setNewEntry, handleUpload, clipboard, handlePaste, setClipboard } = fileBrowserManager;
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -56,14 +56,39 @@ export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
What is on the clipboard, said out loud.
|
||||
|
||||
It used to be an icon and a toast. That was enough while the clipboard died with the tab — you
|
||||
had just pressed the key, so you knew what was on it. Now it is per-user and survives reloads,
|
||||
tabs and days, which means somebody can arrive at a folder with three files pending from
|
||||
yesterday and paste them without ever having been told. Invisible persistent state is how a
|
||||
file gets moved by accident.
|
||||
|
||||
So it says how many and whether they will be copied or moved, and offers a way to let go of
|
||||
them. `cut` is worth distinguishing in the label rather than only in the icon: it is the one
|
||||
that takes the originals with it.
|
||||
*/}
|
||||
{clipboard !== null && (
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
title={t('files.menu.paste')}
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-duck-dark/5 pl-2">
|
||||
<span className="text-xs text-duck-teal whitespace-nowrap">
|
||||
{clipboard.paths.length} {clipboard.mode === 'cut' ? t('files.toMove') : t('files.toCopy')}
|
||||
</span>
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
title={t('files.menu.paste')}
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClipboard(null)}
|
||||
title={t('files.clearClipboard')}
|
||||
className="p-1.5 rounded-md text-duck-teal/60 hover:bg-duck-dark/10 hover:text-duck-teal cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+210
-2
@@ -10,6 +10,7 @@ import { folderFromPathname, folderHref, relativeToFolder, withoutViewerParams }
|
||||
import { useTasks, runsWithoutPrompting, type TaskSummary } from '../useTasks';
|
||||
import { useAgents, type AgentSummary } from '../useAgents';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { runTaskNow } from './components/runTaskNow';
|
||||
@@ -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
|
||||
* can be sent to somebody, which is what was actually wanted.
|
||||
*/
|
||||
/** What a picker is handed when somebody opens a file in pick mode. */
|
||||
export type PickedFile = { name: string; path: string; type: string };
|
||||
|
||||
export const useFileBrowserApp = (
|
||||
basePath: string,
|
||||
rootOverride?: string,
|
||||
urlPath = false,
|
||||
panelId?: string,
|
||||
pathParam?: string,
|
||||
/** Set to turn this into a chooser: opening a file calls this instead of a viewer. */
|
||||
onPickFile?: (file: PickedFile) => void,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
@@ -192,7 +198,25 @@ export const useFileBrowserApp = (
|
||||
const { togglePin, isPinned } = usePinnedFiles();
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [clipboard, setClipboard] = useState<ClipboardState>(null);
|
||||
/*
|
||||
The clipboard lives on the SERVER, not in this component.
|
||||
|
||||
── Why ──
|
||||
|
||||
It was `useState`, which meant it existed in one tab and died with it. Copying in one window and
|
||||
pasting in another — the obvious way to move a file between two folders you can both see — did
|
||||
nothing, and a reload lost a pending cut with no sign that it had.
|
||||
|
||||
`user_state` is a per-user JSONB row that already exists for exactly this kind of thing, and
|
||||
`useUserState` already reads and writes it through React Query with an optimistic local update. So
|
||||
a clipboard is one key in it and there is no new table, route or socket.
|
||||
|
||||
Per user rather than per browser, deliberately: cutting on the desktop and pasting on the phone is
|
||||
the same gesture across two screens, and there is no reading of "my clipboard" where the two should
|
||||
disagree.
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
const [clipboard, setClipboard] = useUserState<ClipboardState>('fileClipboard', null);
|
||||
const [renamingName, setRenamingName] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
|
||||
@@ -223,6 +247,10 @@ export const useFileBrowserApp = (
|
||||
filesRef.current = files;
|
||||
const currentPathRef = useRef(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
// Read inside `refresh`, which is not re-created per render and would otherwise close over a
|
||||
// stale list the first time it ran.
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
@@ -230,8 +258,29 @@ export const useFileBrowserApp = (
|
||||
|
||||
const firstListing = useRef(true);
|
||||
|
||||
/**
|
||||
* The folder the entries currently on screen belong to.
|
||||
*
|
||||
* What separates "re-read this folder" from "go to a different one", which look identical from
|
||||
* inside `refresh` and need opposite treatment — see below.
|
||||
*/
|
||||
const shownPath = useRef<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
/*
|
||||
Blank the list only when there is nothing true to show.
|
||||
|
||||
`loading` swaps the whole grid for a spinner, which is right when arriving somewhere new and
|
||||
wrong when re-reading a folder already on screen: every refresh — after a paste, after a delete,
|
||||
and now every time the window comes forward — made the contents vanish and come back. On a
|
||||
folder of a hundred files that is a flash and a lost scroll position, for information that was
|
||||
already correct.
|
||||
|
||||
So the spinner is for a folder we cannot yet show: nothing listed, or a different folder than the
|
||||
one listed. Re-reading the same folder leaves what is there until better information arrives, and
|
||||
the only visible change is whatever actually changed.
|
||||
*/
|
||||
if (entriesRef.current.length === 0 || shownPath.current !== currentPathRef.current) setLoading(true);
|
||||
try {
|
||||
const data = await filesRef.current.listDir(currentPathRef.current);
|
||||
if (data.reset) {
|
||||
@@ -239,6 +288,7 @@ export const useFileBrowserApp = (
|
||||
return;
|
||||
}
|
||||
setEntries(data.entries);
|
||||
shownPath.current = currentPathRef.current;
|
||||
if (data.rootDir) setRootDir(data.rootDir);
|
||||
} catch (err: any) {
|
||||
console.error('[FileBrowser] refresh error:', err);
|
||||
@@ -302,6 +352,50 @@ export const useFileBrowserApp = (
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
/*
|
||||
Pull the clipboard again whenever this window comes forward.
|
||||
|
||||
`useUserState` holds USER_STATE with `staleTime: Infinity` — right for a dock layout, wrong for
|
||||
something another window can change while this one is idle. Without this, window A cuts, window B
|
||||
still shows nothing to paste, and the feature reads as broken in exactly the case it was built for.
|
||||
|
||||
Focus rather than a poll or a socket, and that is a judgement rather than a shortcut: pasting into
|
||||
the other window means focusing the other window first, so the refresh happens at precisely the
|
||||
moment it is needed and never while nobody is looking. A socket would be live for a case that does
|
||||
not arise — two visible windows dragging between each other already carry the paths in the drag
|
||||
itself.
|
||||
|
||||
`visibilitychange` as well as `focus`, because a background tab brought forward by switching tabs
|
||||
fires only the second one in some browsers.
|
||||
*/
|
||||
/*
|
||||
...and the FOLDER as well as the clipboard.
|
||||
|
||||
Cutting in one window and pasting in another moves the file on disk, and the window it left went on
|
||||
listing it until somebody pressed refresh. The clipboard was the obvious half of "these two windows
|
||||
disagree"; the listing is the half that matters more, because a file that is not there any more is
|
||||
a lie rather than a stale label.
|
||||
|
||||
Held in a ref because `refreshRef.current` is rebuilt on every render — putting it in the
|
||||
dependency array would tear down and re-add both listeners several times a second.
|
||||
*/
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
|
||||
useEffect(() => {
|
||||
const onForeground = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void queryClient.invalidateQueries({ queryKey: ['USER_STATE'] });
|
||||
void refreshRef.current();
|
||||
};
|
||||
window.addEventListener('focus', onForeground);
|
||||
document.addEventListener('visibilitychange', onForeground);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onForeground);
|
||||
document.removeEventListener('visibilitychange', onForeground);
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// Clipboard image paste
|
||||
useEffect(() => {
|
||||
const handler = (ev: ClipboardEvent) => {
|
||||
@@ -475,6 +569,21 @@ export const useFileBrowserApp = (
|
||||
setCurrentPath(next);
|
||||
return;
|
||||
}
|
||||
/*
|
||||
PICK MODE.
|
||||
|
||||
When a caller is using this browser to CHOOSE a file rather than to work in a folder, opening a
|
||||
file means handing it back — not opening a viewer the caller would then have to close. Folders
|
||||
still navigate above, because choosing a file requires getting to it.
|
||||
|
||||
A prop rather than a second browser: a picker built separately would be a listing, a breadcrumb,
|
||||
a sort, thumbnails and keyboard handling reimplemented, all of it drifting from the real one.
|
||||
This is the same component with one branch.
|
||||
*/
|
||||
if (onPickFile) {
|
||||
onPickFile({ name: entry.name, path: entryPath(entry.name), type: entry.type });
|
||||
return;
|
||||
}
|
||||
// An archive extracts rather than opening a pane. The viewer has nothing to show for one — it renders
|
||||
// a placeholder reading "Archive file" and the name you can already see in the listing — so a pane
|
||||
// was a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a
|
||||
@@ -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) => {
|
||||
ev.preventDefault();
|
||||
dragCounter.current++;
|
||||
@@ -995,6 +1176,28 @@ export const useFileBrowserApp = (
|
||||
dragCounter.current = 0;
|
||||
setDragging(false);
|
||||
|
||||
/*
|
||||
A drag that began in an Officer window, dropped on this folder's background.
|
||||
|
||||
Checked before the upload path, because a drag from inside carries no `Files` and would
|
||||
otherwise fall through to "read the dropped files", find none, and do nothing at all.
|
||||
*/
|
||||
const dragged = draggedPaths(ev);
|
||||
if (dragged) {
|
||||
const into = dragged.filter((path) => currentPath !== path && !currentPath.startsWith(`${path}/`));
|
||||
// Dropping a folder into the folder it already lives in. Nothing to do, and nothing to say.
|
||||
if (into.length === 0) return;
|
||||
try {
|
||||
await files.move(into, currentPath);
|
||||
setClipboard(null);
|
||||
await refresh();
|
||||
toast.success(t('files.pastedItems', { count: into.length }));
|
||||
} catch {
|
||||
toast.error(t('files.pasteFailed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const items = ev.dataTransfer.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
@@ -1088,7 +1291,12 @@ export const useFileBrowserApp = (
|
||||
// Selection
|
||||
selected,
|
||||
setSelected,
|
||||
handleEntryDragStart,
|
||||
handleEntryDrop,
|
||||
clipboard,
|
||||
// Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale
|
||||
// clipboard is something a person has to be able to clear rather than wait out.
|
||||
setClipboard,
|
||||
renamingName,
|
||||
setRenamingName,
|
||||
// Search
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Actions a PLUGIN contributes to the file browser's context menu.
|
||||
*
|
||||
* ── Why this exists beside tasks ──
|
||||
*
|
||||
* A task already reaches that menu, and for "do this to these files" it is the right thing: it runs a
|
||||
* script as the owner, reports through the job system, and needs no frontend at all.
|
||||
*
|
||||
* It is the wrong shape for anything that continues in the interface. "Create Gallery" from a folder is
|
||||
* not a job to watch — it is a name to type and a screen to land on, and expressing that as a script
|
||||
* would mean a job whose only output is somewhere to go next.
|
||||
*
|
||||
* So: a plugin exports `fileActions` from `web/file-actions.ts`, the generated plugin module collects
|
||||
* them, and the menu renders the ones that apply. Presence on disk is the declaration, exactly as
|
||||
* `panels.ts` and `layout.ts` already are.
|
||||
*
|
||||
* ── They run in the BROWSER ──
|
||||
*
|
||||
* Which is the whole difference. An action gets the selection and does what a panel would do — open a
|
||||
* dialog, call its own plugin's API, navigate. It has no privileges of its own: everything it calls
|
||||
* goes through the same permission gate as every other request that page makes.
|
||||
*/
|
||||
|
||||
/** One entry the action was invoked on. */
|
||||
export type FileActionEntry = {
|
||||
name: string;
|
||||
/** Absolute within the browser's root, as the browser addresses it. */
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
};
|
||||
|
||||
export type FileActionContext = {
|
||||
/** The folder currently open, so an action can act on "here" rather than on a selection. */
|
||||
currentPath: string;
|
||||
/** Re-read the listing. For an action that creates or removes something on disk. */
|
||||
refresh: () => void;
|
||||
/**
|
||||
* An authenticated client for Officer's API, rooted at `/api`.
|
||||
*
|
||||
* Handed over rather than left to the action, because there is no way for it to build one: `run` is
|
||||
* called from an event handler outside React, so it cannot use `useClient`, and a bare `fetch` gets a
|
||||
* 401 — this API authenticates with a bearer token, not a cookie, and only the hook holds it.
|
||||
*/
|
||||
client: {
|
||||
get: <T>(url: string) => Promise<T>;
|
||||
post: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
put: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
delete: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export type FileAction = {
|
||||
/** Unique across every plugin. Prefixed with the plugin name by convention, like panel keys. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** A lucide icon component, as the rest of the menu uses. */
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* Whether this action makes sense for what was selected.
|
||||
*
|
||||
* Given EVERY entry the action would receive, so an action that only handles images can refuse a
|
||||
* mixed selection rather than half-working. Returning false hides it — a menu of greyed-out items
|
||||
* nobody can explain is worse than a shorter menu.
|
||||
*/
|
||||
appliesTo: (entries: FileActionEntry[]) => boolean;
|
||||
run: (entries: FileActionEntry[], context: FileActionContext) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The registry, filled by the app at startup.
|
||||
*
|
||||
* ── Why injection rather than an import ──
|
||||
*
|
||||
* The generated plugin module lives in the APP (`src/apps/officer-web/Plugins.gen.tsx`) and this is a
|
||||
* workspace package the app depends on. Importing it from here would point the dependency backwards and
|
||||
* make this package unusable by anything that is not Officer's own frontend.
|
||||
*
|
||||
* So the package owns the list and the app hands it over — one call, at startup, before anything
|
||||
* renders a file browser.
|
||||
*/
|
||||
let registered: FileAction[] = [];
|
||||
|
||||
export const registerFileActions = (actions: FileAction[]): void => {
|
||||
registered = actions;
|
||||
};
|
||||
|
||||
/**
|
||||
* The actions that apply to this selection, in registration order.
|
||||
*
|
||||
* `entries` is what the action would receive: the whole selection when the right-clicked item is part
|
||||
* of a multiple selection, and just that item otherwise. That is what every file manager does, and the
|
||||
* alternative — acting on a selection the click was not part of — is the surprising one.
|
||||
*/
|
||||
export const fileActionsFor = (entries: FileActionEntry[]): FileAction[] =>
|
||||
registered.filter((action) => {
|
||||
try {
|
||||
return action.appliesTo(entries);
|
||||
} catch {
|
||||
// A plugin's predicate that throws hides its own action rather than breaking the menu for
|
||||
// everything else on it.
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export { useTasks, type TaskSummary } from './useTasks';
|
||||
export { useRecentFiles } from './useRecentFiles';
|
||||
export { usePinnedFiles } from './usePinnedFiles';
|
||||
export { FileBrowserApp, FileBrowserPanelWrapper } from './FileBrowserApp';
|
||||
export type { PickedFile } from './FileBrowserApp/useFileBrowserApp';
|
||||
export { FileBrowserWidget } from './FileBrowserWidget';
|
||||
export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
|
||||
|
||||
@@ -27,3 +28,5 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
export type { FileAction, FileActionEntry, FileActionContext } from './file-actions';
|
||||
export { registerFileActions, fileActionsFor } from './file-actions';
|
||||
|
||||
@@ -59,6 +59,9 @@ export {
|
||||
useRecentFiles,
|
||||
usePinnedFiles,
|
||||
FileBrowserApp,
|
||||
registerFileActions,
|
||||
type FileAction,
|
||||
type PickedFile,
|
||||
FileBrowserPanelWrapper,
|
||||
FileBrowserWidget,
|
||||
TaskRunnerModal,
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Typ",
|
||||
"size": "Größe",
|
||||
"date": "Datum"
|
||||
}
|
||||
},
|
||||
"toCopy": "zu kopieren",
|
||||
"toMove": "zu verschieben",
|
||||
"clearClipboard": "Zwischenablage leeren"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stopp",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Type",
|
||||
"size": "Size",
|
||||
"date": "Date"
|
||||
}
|
||||
},
|
||||
"toCopy": "to copy",
|
||||
"toMove": "to move",
|
||||
"clearClipboard": "Clear clipboard"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stop",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Tipo",
|
||||
"size": "Tamaño",
|
||||
"date": "Fecha"
|
||||
}
|
||||
},
|
||||
"toCopy": "para copiar",
|
||||
"toMove": "para mover",
|
||||
"clearClipboard": "Vaciar portapapeles"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Detener",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Type",
|
||||
"size": "Taille",
|
||||
"date": "Date"
|
||||
}
|
||||
},
|
||||
"toCopy": "à copier",
|
||||
"toMove": "à déplacer",
|
||||
"clearClipboard": "Vider le presse-papiers"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Arrêter",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Tipo",
|
||||
"size": "Tamanho",
|
||||
"date": "Data"
|
||||
}
|
||||
},
|
||||
"toCopy": "a copiar",
|
||||
"toMove": "a mover",
|
||||
"clearClipboard": "Limpar área de transferência"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Parar",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Typ",
|
||||
"size": "Storlek",
|
||||
"date": "Datum"
|
||||
}
|
||||
},
|
||||
"toCopy": "att kopiera",
|
||||
"toMove": "att flytta",
|
||||
"clearClipboard": "Töm urklipp"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stoppa",
|
||||
|
||||
Reference in New Issue
Block a user