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>
This commit is contained in:
148
src/plugins.ts
Normal file
148
src/plugins.ts
Normal file
@ -0,0 +1,148 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user