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>
This commit is contained in:
2026-08-29 20:59:20 +01:00
co-authored by Claude Opus 5
parent e756007551
commit a6d4a18592
9 changed files with 196 additions and 5 deletions
+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
// 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. */