- 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>
149 lines
7.2 KiB
TypeScript
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 };
|
|
}
|