Files
exbyte-depot-viewer-perforce/src/plugins.ts
Bonchellon f0fc2f687a v0.3.5 — theme system, plugin platform, sectioned Settings, tab animations
- Themes: built-in presets (Ember/Forest/Ocean/Rosé) + custom-theme editor
  (per-token color pickers, live preview, JSON export/import). src/themes.ts.
- Plugin platform: JS/ESM plugins loaded via blob-module import (CSP
  script-src blob:), Host SDK with menu / file-context / view / submit-hook
  contribution points + permission model. Backend commands to list/read/enable/
  install/remove plugins under <config>/plugins/. Example plugin + PLUGINS.md.
- Settings redesigned into a sectioned hub (General / Appearance / Plugins);
  Themes and Plugins moved in, removed from the Actions menu.
- "NEW" badge on History commits submitted < 3h ago.
- Smooth tab transitions: sliding underline + content fade on Changes/History,
  and matching fades on the view-panel and dock tab groups.
- Fix: settings close button used the default browser style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:50:45 +03:00

149 lines
7.2 KiB
TypeScript

// Plugin platform — Host SDK + loader + contribution registry.
//
// Plugins are plain JS/ESM modules (author in TS → compile to JS). Each ships a
// `plugin.json` manifest + a JS entry that exports `activate(host)`. We read the
// entry source from disk (Rust) and import it as an ES module from a blob: URL —
// no eval, works under CSP `script-src blob:`. The plugin only touches the app
// through the `host` object; capabilities are gated by declared permissions.
import { p4, PluginInfo } from "./p4";
import { t } from "./i18n";
import { resolveTheme, loadActiveId } from "./themes";
// ---- contribution shapes ----------------------------------------------------
export interface PluginMenuItem { pluginId: string; label: string; run: () => void | Promise<void> }
export interface PluginFileItem { pluginId: string; label: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise<void> }
export interface PluginView { pluginId: string; id: string; title: string; render: (container: HTMLElement) => void | (() => void) }
export interface PluginSubmitHook { pluginId: string; run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict> }
export type SubmitVerdict = { ok: boolean; message?: string };
interface Registry { menu: PluginMenuItem[]; fileItems: PluginFileItem[]; views: PluginView[]; submitHooks: PluginSubmitHook[] }
const registry: Registry = { menu: [], fileItems: [], views: [], submitHooks: [] };
let version = 0;
const listeners = new Set<() => void>();
function bump() { version++; listeners.forEach((l) => l()); }
export function getRegistry(): Registry { return registry; }
export function registryVersion(): number { return version; }
export function subscribeRegistry(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; }
function clearRegistry() { registry.menu = []; registry.fileItems = []; registry.views = []; registry.submitHooks = []; }
// ---- host context (provided by the app) -------------------------------------
export interface HostContext {
flash: (text: string, err?: boolean) => void;
notify: (title: string, body: string) => void;
info: () => { userName?: string; clientName?: string; clientRoot?: string } | null;
selectedFile: () => { depotFile?: string } | null;
refresh: () => void;
}
// ---- the Host SDK a plugin receives -----------------------------------------
export interface HostApi {
version: string;
plugin: { id: string; name: string; dir: string };
perms: string[];
has: (perm: string) => boolean;
p4: Record<string, (...a: unknown[]) => Promise<unknown>>;
ui: {
addMenuItem: (label: string, run: () => void | Promise<void>) => void;
addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise<void>) => void;
addView: (view: { id: string; title: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
addSubmitHook: (run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict>) => void;
};
flash: (text: string, err?: boolean) => void;
notify: (title: string, body: string) => void;
clipboard: { write: (text: string) => Promise<void> };
storage: { get: (k: string) => string | null; set: (k: string, v: string) => void; remove: (k: string) => void };
info: () => ReturnType<HostContext["info"]>;
selectedFile: () => { depotFile?: string } | null;
refresh: () => void;
theme: () => Record<string, string>;
t: typeof t;
log: (...a: unknown[]) => void;
}
const HOST_VERSION = "1.0";
// p4 methods a plugin may call, split by permission. Only whitelisted, existing methods.
const P4_READ = ["opened", "dirs", "describe", "changeSizes", "clientSpec"] as const;
const P4_WRITE = ["add", "edit", "del", "sync", "reconcile"] as const;
function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
const perms = Array.isArray(info.permissions) ? info.permissions : [];
const has = (p: string) => perms.includes(p);
const p4host: Record<string, (...a: unknown[]) => Promise<unknown>> = {};
const anyP4 = p4 as unknown as Record<string, (...a: unknown[]) => Promise<unknown>>;
if (has("p4:read")) for (const m of P4_READ) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a);
if (has("p4:write")) for (const m of P4_WRITE) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a);
const ns = `exd-plugin:${info.id}:`;
const host: HostApi = {
version: HOST_VERSION,
plugin: { id: info.id, name: info.name, dir: info.dir },
perms,
has,
p4: p4host,
ui: {
addMenuItem: (label, run) => { registry.menu.push({ pluginId: info.id, label, run }); bump(); },
addFileContextItem: (label, run) => { registry.fileItems.push({ pluginId: info.id, label, run }); bump(); },
addView: (view) => { registry.views.push({ pluginId: info.id, id: view.id, title: view.title, render: (el) => view.render(el, host) }); bump(); },
addSubmitHook: (run) => { registry.submitHooks.push({ pluginId: info.id, run }); bump(); },
},
flash: ctx.flash,
notify: has("notify") ? ctx.notify : () => {},
clipboard: { write: (text) => navigator.clipboard.writeText(text) },
storage: {
get: (k) => { try { return localStorage.getItem(ns + k); } catch { return null; } },
set: (k, v) => { try { localStorage.setItem(ns + k, v); } catch {} },
remove: (k) => { try { localStorage.removeItem(ns + k); } catch {} },
},
info: ctx.info,
selectedFile: ctx.selectedFile,
refresh: ctx.refresh,
theme: () => resolveTheme(loadActiveId()).vars,
t,
log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
};
return host;
}
// import a plugin's source as an ES module from a blob: URL, then activate it
async function loadOne(info: PluginInfo, ctx: HostContext): Promise<void> {
const code = await p4.pluginReadEntry(info.id);
const blob = new Blob([code], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
try {
const mod = await import(/* @vite-ignore */ url);
const activate = mod.activate || mod.default;
if (typeof activate !== "function") throw new Error("plugin has no activate(host) export");
await activate(makeHost(info, ctx));
} finally {
URL.revokeObjectURL(url);
}
}
/** (Re)load all enabled plugins. Clears the registry first so this is idempotent. */
export async function loadPlugins(ctx: HostContext): Promise<{ loaded: number; errors: string[] }> {
clearRegistry();
const errors: string[] = [];
let loaded = 0;
let list: PluginInfo[] = [];
try { list = await p4.pluginList(); } catch (e) { return { loaded: 0, errors: [String(e)] }; }
for (const info of list) {
if (!info.enabled) continue;
try { await loadOne(info, ctx); loaded++; }
catch (e) { errors.push(`${info.name || info.id}: ${String(e)}`); }
}
bump();
return { loaded, errors };
}
/** Run all plugin submit hooks; returns the first blocking verdict, or ok. */
export async function runSubmitHooks(files: { depotFile?: string }[]): Promise<SubmitVerdict> {
for (const h of registry.submitHooks) {
try { const v = await h.run(files); if (v && !v.ok) return v; }
catch { /* a broken hook must not block submit */ }
}
return { ok: true };
}