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
+11 -1
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.
+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. */
+14 -1
View File
@@ -636,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;
}; };
/** /**
@@ -4,6 +4,7 @@ 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 { fileActionsFor } from '../../file-actions';
import { FileItem } from './FileItem'; import { FileItem } from './FileItem';
import { useTranslation } from '../../../../locales'; import { useTranslation } from '../../../../locales';
@@ -68,6 +69,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
rootOverride, rootOverride,
selected, selected,
clipboard, clipboard,
refresh,
handleOpen, handleOpen,
handleDelete, handleDelete,
handleRename, handleRename,
@@ -167,8 +169,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 }),
}));
};
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 +286,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}
@@ -23,6 +23,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,6 +85,13 @@ 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;
@@ -177,6 +185,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 +221,7 @@ const MenuItems = ({
canPaste, canPaste,
onOpen, onOpen,
onOpenInEditor, onOpenInEditor,
pluginActions,
onDelete, onDelete,
onStartRename, onStartRename,
onChat, onChat,
@@ -254,7 +264,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 +315,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">
@@ -0,0 +1,90 @@
/**
* 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;
};
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;
}
});
@@ -28,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';
+2
View File
@@ -59,6 +59,8 @@ export {
useRecentFiles, useRecentFiles,
usePinnedFiles, usePinnedFiles,
FileBrowserApp, FileBrowserApp,
registerFileActions,
type FileAction,
type PickedFile, type PickedFile,
FileBrowserPanelWrapper, FileBrowserPanelWrapper,
FileBrowserWidget, FileBrowserWidget,