mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
A package's locales/<tag>.json (English source text -> translation) is applied to the plugin's own manifest titles and labels and to its view data; new ui.locale() (API 1.3) returns the UI language for text the plugin builds itself. Catalogs only ever apply to their own plugin; text without an entry stays English. Co-authored-by: arnaud-ritti <[email protected]>
576 lines
19 KiB
TypeScript
576 lines
19 KiB
TypeScript
/**
|
||
* Client side of the plugin UI points: declarations the
|
||
* backend validated, projected onto the workbench's own registries.
|
||
*
|
||
* - `menus`: editor / project view / editor tab context menus, Tools in the main menu, and the
|
||
* toolbar above the commit message;
|
||
* - `keymaps`: default shortcuts below the IDE's (a taken keystroke is dropped);
|
||
* - `statusBar`: items shown before the code runs (its `set-status-item` with the same id updates them);
|
||
* - `settings`: a page under Settings › Plugins generated from the schema;
|
||
* - `views`: tool windows the plugin renders, or a web page of its package;
|
||
* - `customEditors`: files opening in a plugin editor sharing the text document, in its own tab or
|
||
* beside the text editor;
|
||
* - `fileIcons`: icons for the files a plugin knows, whatever icon theme is in use;
|
||
* - `fileTemplates`: entries of the New menu;
|
||
* - `commitChecks`: before-commit checks listed in Commit Options, run by the plugin's code.
|
||
*/
|
||
import { useMemo } from "react";
|
||
import type { PluginInfo } from "@/bindings";
|
||
import { getAction } from "@/platform/actions";
|
||
import { evaluateWhen, getContext } from "@/platform/context";
|
||
import { tx } from "@/platform/i18n";
|
||
import { isMacHost, registerExtensionBindings } from "@/platform/keymap";
|
||
import {
|
||
Checkbox,
|
||
Input,
|
||
Select,
|
||
T,
|
||
addFileIconProvider,
|
||
fileIconName,
|
||
registerExternalIcons,
|
||
type MenuItem,
|
||
} from "@/ui";
|
||
import { addContributedFileTemplate } from "@/features/templates/files/store";
|
||
import {
|
||
registerEditorKind,
|
||
registerFileEditorProvider,
|
||
registerCommitCheckProvider,
|
||
registerMenuGroup,
|
||
registerMenuSlotItem,
|
||
registerServiceProvider,
|
||
registerSettingsPage,
|
||
registerTextEditorWrapper,
|
||
registerToolWindow,
|
||
useSettingsForm,
|
||
type ContextMenuId,
|
||
type ContributedCommitProblem,
|
||
type ToolWindowAnchor,
|
||
} from "@/workbench/contributions";
|
||
import { pluginsBackend } from "./backend";
|
||
import { pluginIconName } from "./pluginIcon";
|
||
import { usePlugins } from "./store";
|
||
import { pluginActionId } from "./points";
|
||
import { defineUiPoint } from "./projection";
|
||
import { declareStatusItem } from "./runtimeUi";
|
||
import { pluginToolWindowId } from "./views/viewStore";
|
||
import {
|
||
CUSTOM_EDITOR_KIND,
|
||
CustomEditor,
|
||
basename,
|
||
customEditorDocument,
|
||
customEditorInput,
|
||
matchesPattern,
|
||
type CustomEditorDecl,
|
||
} from "./views/customEditor";
|
||
import { layoutActions, layoutWrapper } from "./views/editorLayout";
|
||
import { PluginView } from "./views/PluginView";
|
||
import { pluginServiceProvider } from "./views/serviceProvider";
|
||
import { WebView } from "./views/WebView";
|
||
import styles from "./PluginsPage.module.css";
|
||
|
||
// --- menus ------------------------------------------------------------------------
|
||
|
||
interface MenuDecl {
|
||
menu:
|
||
| "editor.context"
|
||
| "projectView.context"
|
||
| "editorTab.context"
|
||
| "viewItem.context"
|
||
| "tools"
|
||
| "commit.message";
|
||
view?: string | null;
|
||
command: string;
|
||
when?: string | null;
|
||
/** The command's title in the plugin's language, when it ships one. */
|
||
commandTitle?: string;
|
||
}
|
||
|
||
const CONTEXT_MENUS: Record<string, ContextMenuId> = {
|
||
"editor.context": "editor",
|
||
"projectView.context": "projectView",
|
||
"editorTab.context": "editorTab",
|
||
};
|
||
|
||
const contextItems = new Map<string, { pluginId: string; decl: MenuDecl }>();
|
||
let menuSeq = 0;
|
||
|
||
/** Plugin items of a context menu: their command's action with the menu's file as argument. */
|
||
export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]): MenuItem[] {
|
||
const out: MenuItem[] = [];
|
||
for (const { pluginId, decl } of contextItems.values()) {
|
||
if (CONTEXT_MENUS[decl.menu] !== menu || !evaluateWhen(decl.when ?? undefined)) continue;
|
||
const action = pluginActionId(pluginId, decl.command);
|
||
if (!getAction(action)) continue;
|
||
// The editor menu runs with the focused editor; the others name their file.
|
||
out.push({
|
||
kind: "action",
|
||
action,
|
||
arg: menu === "editor" ? undefined : { path: paths[0], paths: [...paths] },
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Plugin entries of a view item's context menu, with the item key as argument. */
|
||
export function pluginViewItemMenuItems(pluginId: string, view: string, key: string): MenuItem[] {
|
||
const ctx = { ...getContext(), viewItem: key, viewItemKind: key.split(":")[0] };
|
||
const out: MenuItem[] = [];
|
||
for (const { pluginId: owner, decl } of contextItems.values()) {
|
||
if (owner !== pluginId || decl.menu !== "viewItem.context" || decl.view !== view) continue;
|
||
if (!evaluateWhen(decl.when ?? undefined, ctx)) continue;
|
||
const action = pluginActionId(pluginId, decl.command);
|
||
if (!getAction(action)) continue;
|
||
out.push({ kind: "action", action, arg: key });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void {
|
||
if (decl.menu === "tools") {
|
||
const action = pluginActionId(pluginId, decl.command);
|
||
const title = decl.commandTitle ?? getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
|
||
return registerMenuSlotItem("pluginTools", { action, label: title });
|
||
}
|
||
if (decl.menu === "commit.message") {
|
||
const action = pluginActionId(pluginId, decl.command);
|
||
const label = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
|
||
return registerMenuSlotItem("commitMessage", {
|
||
action,
|
||
label,
|
||
icon: pluginIconName(pluginId, plugin.icon),
|
||
when: decl.when ?? null,
|
||
});
|
||
}
|
||
const key = `${++menuSeq}`;
|
||
contextItems.set(key, { pluginId, decl });
|
||
return () => void contextItems.delete(key);
|
||
}
|
||
|
||
// --- commit checks ----------------------------------------------------------------
|
||
|
||
interface CommitCheckDecl {
|
||
id: string;
|
||
title: string;
|
||
}
|
||
|
||
/** Declared before-commit checks by `<plugin id>/<check id>`. */
|
||
const commitChecks = new Map<string, { pluginId: string; check: string; title: string }>();
|
||
let unregisterCommitChecks: (() => void) | null = null;
|
||
|
||
/** (Re)publishes the checks, so Commit Options sees the change. */
|
||
function publishCommitChecks() {
|
||
unregisterCommitChecks?.();
|
||
unregisterCommitChecks = null;
|
||
if (!commitChecks.size) return;
|
||
const snapshot = [...commitChecks.entries()];
|
||
unregisterCommitChecks = registerCommitCheckProvider({
|
||
checks: () => snapshot.map(([key, c]) => ({ key, title: c.title })),
|
||
run: async (selection, keys) => {
|
||
const refs = keys.flatMap((k) => {
|
||
const c = commitChecks.get(k);
|
||
return c ? [{ pluginId: c.pluginId, check: c.check }] : [];
|
||
});
|
||
const results = await pluginsBackend()
|
||
.commitChecks(selection, refs)
|
||
.catch((e: unknown) =>
|
||
refs.map((r) => ({
|
||
...r,
|
||
title: commitChecks.get(`${r.pluginId}/${r.check}`)?.title ?? r.check,
|
||
problems: [],
|
||
error: e instanceof Error ? e.message : String(e),
|
||
})),
|
||
);
|
||
return results.flatMap((r): ContributedCommitProblem[] =>
|
||
r.error !== null
|
||
? [{ title: r.title, message: couldNotRun(r.error) }]
|
||
: r.problems.map((p) => ({
|
||
title: r.title,
|
||
message: p.message,
|
||
path: p.path ?? undefined,
|
||
line: p.range?.start.line,
|
||
})),
|
||
);
|
||
},
|
||
});
|
||
}
|
||
|
||
const couldNotRun = (reason: string) => tx`The check could not run: ${reason}`;
|
||
|
||
function projectCommitCheck(pluginId: string, decl: CommitCheckDecl): () => void {
|
||
const key = `${pluginId}/${decl.id}`;
|
||
commitChecks.set(key, { pluginId, check: decl.id, title: decl.title });
|
||
publishCommitChecks();
|
||
return () => {
|
||
commitChecks.delete(key);
|
||
publishCommitChecks();
|
||
};
|
||
}
|
||
|
||
// --- keymaps ----------------------------------------------------------------------
|
||
|
||
interface KeymapDecl {
|
||
command: string;
|
||
key: string;
|
||
mac?: string | null;
|
||
}
|
||
|
||
// --- status bar -------------------------------------------------------------------
|
||
|
||
interface StatusDecl {
|
||
id: string;
|
||
text: string;
|
||
tooltip?: string | null;
|
||
command?: string | null;
|
||
align: "left" | "right";
|
||
priority: number;
|
||
}
|
||
|
||
// --- settings ---------------------------------------------------------------------
|
||
|
||
export interface SettingSchema {
|
||
key: string;
|
||
type: "boolean" | "string" | "number" | "integer";
|
||
title: string;
|
||
description?: string | null;
|
||
default: unknown;
|
||
options?: string[] | null;
|
||
multiline?: boolean;
|
||
}
|
||
|
||
interface SettingsDecl {
|
||
title: string;
|
||
properties: SettingSchema[];
|
||
}
|
||
|
||
type PluginSettings = Record<string, Record<string, unknown>>;
|
||
|
||
/** `settings` with `key` of `pluginId` set (`null`: back to the default). */
|
||
export function withPluginSetting(
|
||
settings: PluginSettings | undefined,
|
||
pluginId: string,
|
||
key: string,
|
||
value: unknown,
|
||
): PluginSettings {
|
||
const own = { ...(settings?.[pluginId] ?? {}) };
|
||
if (value === null) delete own[key];
|
||
else own[key] = value;
|
||
const next = { ...(settings ?? {}) };
|
||
if (Object.keys(own).length) next[pluginId] = own;
|
||
else delete next[pluginId];
|
||
return next;
|
||
}
|
||
|
||
function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema }) {
|
||
const form = useSettingsForm();
|
||
const all = form.useValue("plugins") as PluginSettings | undefined;
|
||
const value = all?.[pluginId]?.[s.key] ?? s.default;
|
||
const set = (v: unknown) => form.set("plugins", withPluginSetting(all, pluginId, s.key, v === s.default ? null : v));
|
||
const id = `plugin-setting-${pluginId}-${s.key}`;
|
||
const description = s.description ? <div className={styles.muted}>{s.description}</div> : null;
|
||
if (s.type === "boolean")
|
||
return (
|
||
<div className={styles.settingRow}>
|
||
<Checkbox checked={value === true} label={s.title} onChange={set} />
|
||
{description}
|
||
</div>
|
||
);
|
||
return (
|
||
<div className={styles.settingRow}>
|
||
<label htmlFor={id}>{s.title}</label>
|
||
{s.options ? (
|
||
<Select
|
||
aria-label={s.title}
|
||
value={String(value)}
|
||
options={s.options.map((o) => ({ value: o, label: o }))}
|
||
onChange={set}
|
||
/>
|
||
) : (
|
||
<Input
|
||
id={id}
|
||
type={s.type === "string" ? "text" : "number"}
|
||
value={String(value ?? "")}
|
||
onChange={(e) => {
|
||
const raw = e.target.value;
|
||
if (s.type === "string") set(raw);
|
||
else if (raw.trim() !== "" && Number.isFinite(Number(raw)))
|
||
set(s.type === "integer" ? Math.trunc(Number(raw)) : Number(raw));
|
||
}}
|
||
/>
|
||
)}
|
||
{description}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function settingsPage(pluginId: string, decl: SettingsDecl) {
|
||
return function PluginSettingsPage() {
|
||
const properties = useMemo(() => decl.properties, []);
|
||
return (
|
||
<div className={styles.settingsPage}>
|
||
<p className={styles.muted}>
|
||
<T>Settings of the plugin; projects can override them.</T>
|
||
</p>
|
||
{properties.map((s) => (
|
||
<SettingControl key={s.key} pluginId={pluginId} s={s} />
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
}
|
||
|
||
/** Settings page id of a plugin (dots nest pages, so they become dashes). */
|
||
export const pluginSettingsPageId = (pluginId: string) => `plugins.${pluginId.replace(/\./g, "-")}`;
|
||
|
||
// --- views and custom editors -----------------------------------------------------
|
||
|
||
interface ViewDecl {
|
||
id: string;
|
||
title: string;
|
||
icon?: string | null;
|
||
location: ToolWindowAnchor | "services";
|
||
web?: string;
|
||
}
|
||
|
||
interface FileIconDecl {
|
||
id: string;
|
||
patterns: string[];
|
||
/** A dark-theme variant was given. */
|
||
dark: boolean;
|
||
}
|
||
|
||
interface FileTemplateDecl {
|
||
id: string;
|
||
name: string;
|
||
extension: string;
|
||
body: string;
|
||
}
|
||
|
||
/** A new file's icon: the plugin's own file icon for it, else the built-in one. */
|
||
function templateIcon(pluginId: string, extension: string): string {
|
||
const file = `untitled.${extension}`;
|
||
const own = usePlugins
|
||
.getState()
|
||
.ui.find(
|
||
(c) =>
|
||
c.pluginId === pluginId &&
|
||
c.point === "fileIcons" &&
|
||
(c.decl as FileIconDecl).patterns.some((p) => matchesPattern(p, file)),
|
||
);
|
||
return own ? `ext:fileIcon/${pluginId}/${(own.decl as FileIconDecl).id}` : fileIconName(file);
|
||
}
|
||
|
||
/** Registers a plugin's file icon (fetched first) and resolves matching files to it. */
|
||
function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void {
|
||
let disposed = false;
|
||
let undo = () => {};
|
||
const name = `ext:fileIcon/${pluginId}/${decl.id}`;
|
||
void pluginsBackend()
|
||
.assets(pluginId, `fileIcon/${decl.id}/`)
|
||
.then((assets) => {
|
||
if (disposed) return;
|
||
const svg = (variant: string) => assets.find(([id]) => id === `fileIcon/${decl.id}/${variant}`)?.[1];
|
||
const light = svg("light");
|
||
if (!light) return;
|
||
const dark = svg("dark");
|
||
const unregister = registerExternalIcons(
|
||
dark
|
||
? [
|
||
[name, light],
|
||
[`${name}_dark`, dark],
|
||
]
|
||
: [[name, light]],
|
||
);
|
||
const remove = addFileIconProvider((file) => (decl.patterns.some((p) => matchesPattern(p, file)) ? name : null));
|
||
undo = () => {
|
||
remove();
|
||
unregister();
|
||
};
|
||
})
|
||
.catch((e: unknown) => console.warn(`plugin ${pluginId}: file icon ${decl.id}`, e));
|
||
return () => {
|
||
disposed = true;
|
||
undo();
|
||
};
|
||
}
|
||
|
||
function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void {
|
||
if (decl.location === "services") {
|
||
const itemMenu = (key: string) => pluginViewItemMenuItems(pluginId, decl.id, key);
|
||
return registerServiceProvider(pluginServiceProvider(pluginId, decl, icon, itemMenu));
|
||
}
|
||
return registerToolWindow({
|
||
id: pluginToolWindowId(pluginId, decl.id),
|
||
title: decl.title,
|
||
icon,
|
||
anchor: decl.location,
|
||
order: 900,
|
||
component: () =>
|
||
decl.web ? (
|
||
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||
) : (
|
||
<PluginView
|
||
pluginId={pluginId}
|
||
view={decl.id}
|
||
label={decl.title}
|
||
itemMenu={(key) => pluginViewItemMenuItems(pluginId, decl.id, key)}
|
||
/>
|
||
),
|
||
});
|
||
}
|
||
|
||
function projectView(pluginId: string, decl: ViewDecl): () => void {
|
||
if (!decl.icon?.endsWith(".svg")) return registerView(pluginId, decl, decl.icon ?? "plugin");
|
||
let disposed = false;
|
||
let undo = () => {};
|
||
void pluginsBackend()
|
||
.assets(pluginId, `viewIcon/${decl.id}`)
|
||
.then((assets) => {
|
||
if (disposed) return;
|
||
const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1];
|
||
const name = `ext:${pluginId}/view-${decl.id}`;
|
||
const unregister = svg ? registerExternalIcons([[name, svg]]) : () => {};
|
||
const remove = registerView(pluginId, decl, svg ? name : "plugin");
|
||
undo = () => {
|
||
remove();
|
||
unregister();
|
||
};
|
||
})
|
||
.catch((e: unknown) => {
|
||
console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e);
|
||
if (!disposed) undo = registerView(pluginId, decl, "plugin");
|
||
});
|
||
return () => {
|
||
disposed = true;
|
||
undo();
|
||
};
|
||
}
|
||
|
||
/** Registers the client side of the P7 points; returns their disposer. */
|
||
export function defineViewUiPoints(): () => void {
|
||
const disposers = [
|
||
defineUiPoint<MenuDecl>("menus", {
|
||
title: "Menus",
|
||
project: ({ pluginId, decl }, { plugin }) => projectMenu(pluginId, decl, plugin),
|
||
}),
|
||
defineUiPoint<KeymapDecl>("keymaps", {
|
||
title: "Shortcuts",
|
||
project: ({ pluginId, decl }) =>
|
||
registerExtensionBindings(pluginActionId(pluginId, decl.command), [
|
||
isMacHost() ? (decl.mac ?? decl.key) : decl.key,
|
||
]),
|
||
}),
|
||
defineUiPoint<StatusDecl>("statusBar", {
|
||
title: "Status Bar Items",
|
||
project: ({ pluginId, decl }) =>
|
||
declareStatusItem(pluginId, decl.id, {
|
||
item: { text: decl.text, tooltip: decl.tooltip ?? null, command: decl.command ?? null },
|
||
align: decl.align,
|
||
priority: decl.priority,
|
||
}),
|
||
}),
|
||
defineUiPoint<SettingsDecl>("settings", {
|
||
title: "Settings",
|
||
project: ({ pluginId, decl }) =>
|
||
registerSettingsPage({
|
||
id: pluginSettingsPageId(pluginId),
|
||
title: decl.title,
|
||
projectScope: true,
|
||
keywords: ["plugin", pluginId, ...decl.properties.flatMap((p) => [p.key, p.title])],
|
||
component: settingsPage(pluginId, decl),
|
||
}),
|
||
}),
|
||
defineUiPoint<ViewDecl>("views", {
|
||
title: "Tool Windows",
|
||
project: ({ pluginId, decl }) => projectView(pluginId, decl),
|
||
}),
|
||
defineUiPoint<CustomEditorDecl>("customEditors", {
|
||
title: "Editors",
|
||
project: ({ pluginId, decl }) => {
|
||
const matches = (path: string) => decl.patterns.some((p) => matchesPattern(p, basename(path)));
|
||
const beside = decl.layout === "split" || decl.layout === "preview";
|
||
// A side-by-side editor wraps the text editor the file opens in; the others open their own tab.
|
||
const provider =
|
||
decl.priority !== "default"
|
||
? () => {}
|
||
: beside
|
||
? registerTextEditorWrapper({
|
||
id: `plugin.${pluginId}.${decl.id}`,
|
||
matches,
|
||
component: layoutWrapper(pluginId, decl),
|
||
tabActions: layoutActions(pluginId, decl),
|
||
})
|
||
: registerFileEditorProvider({
|
||
id: `plugin.${pluginId}.${decl.id}`,
|
||
order: 40,
|
||
resolve: (path) => (matches(path) ? customEditorInput(pluginId, decl, path) : null),
|
||
});
|
||
openWith.set(`${pluginId}/${decl.id}`, { pluginId, decl, matches });
|
||
return () => {
|
||
provider();
|
||
openWith.delete(`${pluginId}/${decl.id}`);
|
||
};
|
||
},
|
||
}),
|
||
defineUiPoint<FileIconDecl>("fileIcons", {
|
||
title: "File Icons",
|
||
project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl),
|
||
}),
|
||
defineUiPoint<CommitCheckDecl>("commitChecks", {
|
||
title: "Commit Checks",
|
||
project: ({ pluginId, decl }) => projectCommitCheck(pluginId, decl),
|
||
}),
|
||
defineUiPoint<FileTemplateDecl>("fileTemplates", {
|
||
title: "File Templates",
|
||
project: ({ pluginId, decl }) =>
|
||
addContributedFileTemplate({
|
||
id: `plugin.${pluginId}.${decl.id}`,
|
||
name: decl.name,
|
||
icon: templateIcon(pluginId, decl.extension),
|
||
extension: decl.extension,
|
||
nameStyle: "asis",
|
||
kinds: [{ id: "file", label: decl.name, body: decl.body }],
|
||
}),
|
||
}),
|
||
];
|
||
return () => disposers.reverse().forEach((d) => d());
|
||
}
|
||
|
||
/** Custom editors by `plugin/id`, for Open With. */
|
||
const openWith = new Map<string, { pluginId: string; decl: CustomEditorDecl; matches: (path: string) => boolean }>();
|
||
|
||
/** One-time registrations behind the points: the editor kind and the context menu group. */
|
||
export function startViewUi(openInput: (input: ReturnType<typeof customEditorInput>) => void): () => void {
|
||
const disposers = [
|
||
registerEditorKind({
|
||
kind: CUSTOM_EDITOR_KIND,
|
||
component: CustomEditor,
|
||
title: (input) => basename(input.path ?? input.key),
|
||
icon: () => "db-table",
|
||
restorable: true,
|
||
document: customEditorDocument,
|
||
}),
|
||
registerMenuGroup({
|
||
id: "plugins",
|
||
menus: ["editor", "projectView", "editorTab"],
|
||
order: 90,
|
||
items: (menu, ctx) => {
|
||
const items = pluginMenuItems(menu, ctx.paths);
|
||
const path = ctx.paths[0];
|
||
if (menu !== "editor" && path && !ctx.dirs?.[0]) {
|
||
const editors = [...openWith.values()].filter((e) => e.matches(path));
|
||
if (editors.length)
|
||
items.push({
|
||
label: tx("Open With"),
|
||
submenu: editors.map((e) => ({
|
||
label: e.decl.title,
|
||
run: () => openInput(customEditorInput(e.pluginId, e.decl, path)),
|
||
})),
|
||
});
|
||
}
|
||
return items;
|
||
},
|
||
}),
|
||
];
|
||
return () => disposers.reverse().forEach((d) => d());
|
||
}
|