/** * Folder actions menu contribution registry. * * A slot section (Automations / Goals / KB) renders inside the directory * card's pill grid, while the folder actions menu renders in the folder * HEADER. They are siblings, so a prop cannot carry a section's callback to * the menu — and the callbacks are section-local closures (Automations' refresh * is a `useState` setter), so they cannot be hoisted into static config either. * * The bridge is therefore an external store keyed by folder scope, read with * `useSyncExternalStore` (React's tearing guarantee) so an ALREADY-OPEN menu * re-renders when a late-mounting section registers. * * Two registration paths: * - `useFolderMenuItem` — contributes one declarative item. * - `useFolderMenuRefresher` — contributes a callback with NO item of its own, * so the single `MAINTENANCE` refresh item can fan out to it. * * The plugin-facing contribution is strictly declarative: no `ReactNode`, no * `pressed`. The HOST keeps those on its own `FolderMenuItem` (add-to-workspace's * popover, urgency sort's `aria-pressed`); that exception is not a plugin * capability. The contributing plugin's identity is stamped by this module from * the plugin context — never read from the payload, so a plugin cannot declare * itself as another. * * See change: move-slot-actions-to-menu (folder-actions-menu spec). */ import type React from "react"; import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { useCurrentPluginId } from "./plugin-context.js"; /** Host-owned verb taxonomy, in render order. Grouping is by verb, never by plugin. */ export const FOLDER_MENU_GROUPS = ["workspace", "directory", "create", "open", "maintenance"] as const; export type FolderMenuGroup = (typeof FOLDER_MENU_GROUPS)[number]; export function isFolderMenuGroup(value: unknown): value is FolderMenuGroup { return typeof value === "string" && (FOLDER_MENU_GROUPS as readonly string[]).includes(value); } /** The plugin-facing contribution — declarative only. */ export interface FolderMenuContribution { /** Stable id; drives the item's test id (`folder-menu-item-`). */ id: string; group: FolderMenuGroup; label: string; /** mdi path string. */ icon: string; onSelect: () => void; /** Short state marker rendered on the item and folded into its accessible name. */ badge?: string; /** Renders as a disabled control; its callback is never invoked. */ disabled?: boolean; } /** A contribution plus the identity the registry stamped on it. */ export interface RegisteredFolderMenuItem extends FolderMenuContribution { pluginId: string; } /** * A contribution is well-formed only with all five required fields and a group * inside the taxonomy. An unknown group is a version mismatch, so the item is * DROPPED rather than rendered ungrouped — inventing a home hides the mismatch. * * The OPTIONAL fields are type-checked too, because the host renders them: a * non-string `badge` reaches React as a child and throws while the menu is * opening (one plugin taking the whole menu down), and a truthy non-boolean * `disabled` silently swallows every activation of that item. */ export function isValidFolderMenuContribution(value: unknown): value is FolderMenuContribution { if (!value || typeof value !== "object") return false; const c = value as Record; return ( typeof c.id === "string" && c.id.length > 0 && isFolderMenuGroup(c.group) && typeof c.label === "string" && c.label.length > 0 && typeof c.icon === "string" && c.icon.length > 0 && typeof c.onSelect === "function" && (c.badge === undefined || typeof c.badge === "string") && (c.disabled === undefined || typeof c.disabled === "boolean") ); } /** * Registration-independent ordering: sort by contributing plugin id, then by * contribution id. When distinct plugins collide on one id the lower plugin id * wins, so the outcome never depends on load order. */ export function selectFolderMenuItems( entries: readonly RegisteredFolderMenuItem[], ): RegisteredFolderMenuItem[] { const sorted = [...entries].sort( (a, b) => a.pluginId.localeCompare(b.pluginId) || a.id.localeCompare(b.id), ); const seen = new Set(); const out: RegisteredFolderMenuItem[] = []; for (const entry of sorted) { if (seen.has(entry.id)) continue; seen.add(entry.id); out.push(entry); } return out; } // ── Store ──────────────────────────────────────────────────────────────────── const EMPTY_ITEMS: readonly RegisteredFolderMenuItem[] = Object.freeze([]); interface ScopeState { items: Map; refreshers: Map void>; listeners: Set<() => void>; snapshot: readonly RegisteredFolderMenuItem[] | null; } export interface FolderMenuStore { /** Registers one item; returns a dispose that only evicts its OWN registration. */ registerItem(scope: string, pluginId: string, contribution: FolderMenuContribution): () => void; /** Registers a refresher with no item of its own. */ registerRefresher(scope: string, refresh: () => void): () => void; /** Ordered, collision-resolved snapshot. Referentially stable until the scope mutates. */ getItems(scope: string): readonly RegisteredFolderMenuItem[]; /** Invokes every refresher registered for the scope; one throwing does not stop the rest. */ runRefreshers(scope: string): void; subscribe(scope: string, onChange: () => void): () => void; } export function createFolderMenuStore(): FolderMenuStore { const scopes = new Map(); function state(scope: string): ScopeState { let s = scopes.get(scope); if (!s) { s = { items: new Map(), refreshers: new Map(), listeners: new Set(), snapshot: null }; scopes.set(scope, s); } return s; } function invalidate(s: ScopeState) { s.snapshot = null; for (const listener of s.listeners) listener(); } return { registerItem(scope, pluginId, contribution) { if (!isValidFolderMenuContribution(contribution)) return () => {}; const s = state(scope); // Key by (pluginId, id): a section that unmounts and remounts — including // a development double-mount — re-registers the same key, so the LATEST // registration wins and the live callback is never dropped. const key = `${pluginId}\u0000${contribution.id}`; // Whitelist the declarative fields rather than spreading. A spread would // carry an unknown key straight through to the host renderer, and the // renderer honours `node` — so a plugin compiled with `as any` (or a // plain-JS one) could smuggle back the arbitrary markup this change // exists to remove. "No ReactNode" has to hold at RUNTIME, not only in // the type system, exactly as `SlotPill.actions`'s removal does. const { id, group, label, icon, onSelect, badge, disabled } = contribution; const entry: RegisteredFolderMenuItem = { id, group, label, icon, onSelect, badge, disabled, pluginId }; s.items.set(key, entry); invalidate(s); return () => { // Identity guard: a superseded registration's dispose (React runs the // remount's effect before the unmount's cleanup in some orders) must // not evict the live entry. if (s.items.get(key) !== entry) return; s.items.delete(key); invalidate(s); }; }, registerRefresher(scope, refresh) { const s = state(scope); const token = {}; s.refreshers.set(token, refresh); return () => { s.refreshers.delete(token); }; }, getItems(scope) { const s = scopes.get(scope); if (!s) return EMPTY_ITEMS; if (s.snapshot === null) s.snapshot = Object.freeze(selectFolderMenuItems([...s.items.values()])); return s.snapshot; }, runRefreshers(scope) { const s = scopes.get(scope); if (!s) return; for (const refresh of [...s.refreshers.values()]) { try { refresh(); } catch (err) { console.error("[folder-menu] refresher threw:", err); } } }, subscribe(scope, onChange) { const s = state(scope); s.listeners.add(onChange); return () => { s.listeners.delete(onChange); }; }, }; } // ── Provider ───────────────────────────────────────────────────────────────── const FolderMenuContext = createContext(null); /** * Default store used when no provider is mounted, so a slot section rendered * in isolation (a unit test, a standalone harness) still behaves rather than * silently no-opping in a way nobody notices. */ const defaultStore = createFolderMenuStore(); export function FolderMenuProvider({ store, children, }: { store?: FolderMenuStore; children: React.ReactNode; }) { const own = useRef(null); if (!store && !own.current) own.current = createFolderMenuStore(); const value = store ?? own.current!; return {children}; } export function useFolderMenuStore(): FolderMenuStore { return useContext(FolderMenuContext) ?? defaultStore; } // ── Hooks ──────────────────────────────────────────────────────────────────── /** * Registers one menu item for `scope` for as long as the caller is mounted. * A `null` scope registers nothing — the KB section also renders in the * worktree-card placement, whose scope has no folder actions menu. * * Re-registration is keyed on the RENDERED fields, so a state-driven label / * badge / disabled change reaches an open menu while an unchanged re-render * does not churn the snapshot. `onSelect` is read through a ref, so the item * always invokes the current closure without re-registering. */ export function useFolderMenuItem( scope: string | null | undefined, contribution: FolderMenuContribution | null | undefined, ): void { const store = useFolderMenuStore(); const pluginId = useCurrentPluginId(); const latest = useRef(contribution); latest.current = contribution; const id = contribution?.id; const group = contribution?.group; const label = contribution?.label; const icon = contribution?.icon; const badge = contribution?.badge; const disabled = contribution?.disabled; useEffect(() => { if (!scope || !pluginId || id === undefined) return; return store.registerItem(scope, pluginId, { id, group: group as FolderMenuGroup, label: label as string, icon: icon as string, badge, disabled, onSelect: () => latest.current?.onSelect(), }); }, [store, scope, pluginId, id, group, label, icon, badge, disabled]); } /** * Registers a refresher for `scope` — no menu item of its own. The single * `MAINTENANCE` refresh item fans out to every refresher registered for the * folder. */ export function useFolderMenuRefresher( scope: string | null | undefined, refresh: (() => void) | null | undefined, ): void { const store = useFolderMenuStore(); const latest = useRef(refresh); latest.current = refresh; useEffect(() => { if (!scope) return; return store.registerRefresher(scope, () => latest.current?.()); }, [store, scope]); } /** Host read hook: the folder's contributed items, ordered and collision-resolved. */ export function useFolderMenuItems(scope: string): readonly RegisteredFolderMenuItem[] { const store = useFolderMenuStore(); const subscribe = useMemo(() => (cb: () => void) => store.subscribe(scope, cb), [store, scope]); return useSyncExternalStore( subscribe, () => store.getItems(scope), () => EMPTY_ITEMS, ); } /** Host hook returning a stable `(scope) => void` fan-out over registered refreshers. */ export function useFolderMenuRefreshRunner(): (scope: string) => void { const store = useFolderMenuStore(); return useMemo(() => (scope: string) => store.runRefreshers(scope), [store]); }