diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 590f4062..3dfedd80 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -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. diff --git a/src/servers/plugins/discover.ts b/src/servers/plugins/discover.ts index ca747665..b62e9b4d 100644 --- a/src/servers/plugins/discover.ts +++ b/src/servers/plugins/discover.ts @@ -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); diff --git a/src/servers/plugins/generate.ts b/src/servers/plugins/generate.ts index 473fab20..61d43012 100644 --- a/src/servers/plugins/generate.ts +++ b/src/servers/plugins/generate.ts @@ -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. */ diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index 62e6aa6d..95913df1 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -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 * 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; }; /** diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx index 59bafcaf..3ff50552 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx @@ -4,6 +4,7 @@ import { ChevronUp, ChevronDown } from 'lucide-react'; import type { DirEntry } from '../../../../hooks/useFilesAPI'; import { getFileType } from '../../../FileViewer'; import type { UseFileBrowserAppType } from '../useFileBrowserApp'; +import { fileActionsFor } from '../../file-actions'; import { FileItem } from './FileItem'; import { useTranslation } from '../../../../locales'; @@ -68,6 +69,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { rootOverride, selected, clipboard, + refresh, handleOpen, handleDelete, handleRename, @@ -167,8 +169,23 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { const cols = useColumnCount(scrollRef); const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set(); 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 }), + })); + }; + const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols); const virtualizer = useVirtualizer({ @@ -269,6 +286,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} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index dc99eb01..cede6a20 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -23,6 +23,7 @@ import { FileArchive, Pin, PinOff, + Sparkles, } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { Thumbnail } from './Thumbnail'; @@ -84,6 +85,13 @@ 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; @@ -177,6 +185,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 +221,7 @@ const MenuItems = ({ canPaste, onOpen, onOpenInEditor, + pluginActions, onDelete, onStartRename, onChat, @@ -254,7 +264,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 +315,14 @@ const MenuItems = ({ {t('files.menu.extract')} )} + {/* 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) => ( + + {action.Icon ? : } + {action.label} + + ))} {hasTasks && ( diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/file-actions.ts b/src/workspaces/officerdev/src/apps/FileBrowser/file-actions.ts new file mode 100644 index 00000000..392360bd --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/file-actions.ts @@ -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; + } + }); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/index.ts b/src/workspaces/officerdev/src/apps/FileBrowser/index.ts index 2e07d6e5..216c931e 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/index.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/index.ts @@ -28,3 +28,5 @@ export const appRegistryMetas: AppRegistryMeta[] = [ availableOnPanel: false, }, ]; +export type { FileAction, FileActionEntry, FileActionContext } from './file-actions'; +export { registerFileActions, fileActionsFor } from './file-actions'; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 828baf21..614293be 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -59,6 +59,8 @@ export { useRecentFiles, usePinnedFiles, FileBrowserApp, + registerFileActions, + type FileAction, type PickedFile, FileBrowserPanelWrapper, FileBrowserWidget,