дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview. - Extension points declared in `plugin.toml` (commands, languages, themes, icon themes, menus, key bindings, status items, settings, views, custom editors, file icons and templates, run detectors, editor providers), validated before anything takes effect and projected onto the workbench's own registries: plugin themes and file icon themes are chosen in Settings › Appearance and Quick Switch, plugin shortcuts are edited in Settings › Keymap, plugin problems are grouped in Settings › Inspections. - Capabilities with risk levels and consent at install, pinned `process:` programs, secrets in the OS keychain. - Runtime: one thread per active plugin, fuel/time/memory budgets, soft and hard deadlines on the editing path, cancellation, crash backoff, idle reclaim, a precompiled-component cache; remote projects run code plugins on `openidea-server`. - Web views and custom editors with a sandboxed page, a document bridge joined to the text editor's undo history, and split or preview layouts. - Distribution: signed `.oiplugin` packages, a static marketplace index with revocation, versioned installs with rollback, install from a `.zip` or a folder. The release workflow signs and publishes the official plugins and their index, which released builds use as the default marketplace. - Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion, Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor Night, Excalidraw, Graphviz, Regex Tester, License Header. Closes #732 Co-authored-by: Deng Liu <[email protected]> Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
450 files changed
+47018
-3058
No files matched your search
@@ -40,6 +40,28 @@ const find = (nodes: readonly InspectionNode[], key: string): InspectionNode | u
|
||||
return undefined;
|
||||
};
|
||||
|
||||
describe("plugin inspections", () => {
|
||||
it("list each plugin by name under Plugins, from seen codes and stored overrides", () => {
|
||||
const tree = buildInspectionTree(
|
||||
[],
|
||||
[{ producer: "plugin:acme.license", code: "missing-header", source: "", sample: "No licence header" }],
|
||||
[{ "lsp:plugin:acme.license:old-code": { enabled: false }, "lsp:plugin:acme.other": { severity: "warning" } }],
|
||||
new Map([["plugin:acme.license", "License Header"]]),
|
||||
);
|
||||
const plugins = find(tree, "group:Plugins")!;
|
||||
expect(plugins.children.map((n) => [n.id, n.name])).toEqual([
|
||||
["lsp:plugin:acme.license", "License Header"],
|
||||
["lsp:plugin:acme.other", "acme.other"],
|
||||
]);
|
||||
expect(plugins.children[0]!.children.map((n) => n.id)).toEqual([
|
||||
"lsp:plugin:acme.license:missing-header",
|
||||
"lsp:plugin:acme.license:old-code",
|
||||
]);
|
||||
expect(plugins.children[0]!.description).toContain("License Header plugin");
|
||||
expect(find(tree, "group:Other")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspections tree", () => {
|
||||
const tree = buildInspectionTree(languages, seen, [{ "lsp:ruff:E501": { severity: "typo" } }]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isValidBinding,
|
||||
normalizeBinding,
|
||||
PRESETS,
|
||||
registerExtensionBindings,
|
||||
strokeOf,
|
||||
suspendKeyboardDispatch,
|
||||
useKeymap,
|
||||
@@ -125,6 +126,30 @@ describe("overrides", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin shortcuts", () => {
|
||||
it("show as the defaults, can be removed or changed, and take part in conflicts", () => {
|
||||
const preset = defaultPreset();
|
||||
const id = "plugin.acme.tidy";
|
||||
const unregister = registerExtensionBindings(id, ["Ctrl+Alt+Shift+F9"]);
|
||||
try {
|
||||
const draft = { preset, overrides: {} };
|
||||
expect(resolveBindings(draft, id)).toEqual([normalizeBinding("Ctrl+Alt+Shift+F9")]);
|
||||
expect(isModified(draft, id)).toBe(false);
|
||||
const removed = removeBinding(draft, id, "Ctrl+Alt+Shift+F9");
|
||||
expect(removed[id]).toEqual([]);
|
||||
expect(resolveBindings({ preset, overrides: removed }, id)).toEqual([]);
|
||||
expect(resetBindings({ preset, overrides: removed }, id)).toEqual({});
|
||||
const conflicts = findConflicts(draft, "Ctrl+Alt+Shift+F9", "acme.other", [
|
||||
{ id: "acme.other", title: "Other" },
|
||||
{ id, title: "Tidy" },
|
||||
]);
|
||||
expect(conflicts.map((c) => c.actionId)).toContain(id);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("conflicts", () => {
|
||||
const actions = [
|
||||
{ id: "code.duplicateLine", title: "Duplicate Line", category: "Code", when: "editorFocus" },
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
validateFileTypes,
|
||||
type FileTypeAssociations,
|
||||
} from "@/platform/fileTypes";
|
||||
import { onLanguageListChanged } from "@/platform/languageList";
|
||||
import { loadSettings, useProject } from "@/platform/project";
|
||||
import { fileIconName, setFileIconOverride, type IconName, type MenuItem } from "@/ui";
|
||||
import { registerMenuGroup } from "@/workbench/contributions";
|
||||
@@ -97,6 +98,11 @@ export function activateFileTypes(): () => void {
|
||||
if (s.settings !== prev.settings) syncIcons();
|
||||
});
|
||||
const disposers = [
|
||||
// Plugins add and remove languages: read the list again.
|
||||
onLanguageListChanged(() => {
|
||||
loading = null;
|
||||
void loadLanguages().then(syncIcons);
|
||||
}),
|
||||
unsub,
|
||||
() => setFileIconOverride(null),
|
||||
registerActions([
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
/**
|
||||
* Keymap editing logic (pure): effective bindings for a staged preset +
|
||||
* overrides, add / remove / reset, conflict detection with `when` overlap,
|
||||
* and shortcut search.
|
||||
* Keymap editing logic: effective bindings for a staged preset + overrides
|
||||
* (plugins' default shortcuts count as the preset's), add / remove / reset,
|
||||
* conflict detection with `when` overlap, and shortcut search.
|
||||
*/
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { evaluateWhen, type ContextKeys } from "@/platform/context";
|
||||
import { defaultPreset, normalizeBinding, PRESET_TITLES, PRESETS, type Keymap } from "@/platform/keymap";
|
||||
import { defaultPreset, normalizeBinding, PRESET_TITLES, PRESETS, useKeymap, type Keymap } from "@/platform/keymap";
|
||||
|
||||
export interface KeymapDraft {
|
||||
preset: string;
|
||||
overrides: Keymap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The defaults of `id`: the preset's, or for an action the preset leaves out,
|
||||
* the shortcut its plugin declares (`keymaps`). Removing or changing one is an
|
||||
* override like any other.
|
||||
*/
|
||||
export function presetBindings(preset: string, id: string): string[] {
|
||||
return (PRESETS[preset]?.[id] ?? []).map(normalizeBinding);
|
||||
const own = PRESETS[preset];
|
||||
const keys = own && id in own ? own[id] : useKeymap.getState().extensions?.[id];
|
||||
return (keys ?? []).map(normalizeBinding);
|
||||
}
|
||||
|
||||
export function resolveBindings(draft: KeymapDraft, id: string): string[] {
|
||||
return (draft.overrides[id] ?? PRESETS[draft.preset]?.[id] ?? []).map(normalizeBinding);
|
||||
return draft.overrides[id]?.map(normalizeBinding) ?? presetBindings(draft.preset, id);
|
||||
}
|
||||
|
||||
export function isModified(draft: KeymapDraft, id: string): boolean {
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
* `ide-core::settings` defaults; validation never throws.
|
||||
*/
|
||||
|
||||
import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes";
|
||||
import { isThemeChoiceId, type ThemeChoiceId } from "@/platform/themes";
|
||||
import { ZOOM_LIMITS } from "@/platform/zoom";
|
||||
|
||||
export type Antialiasing = "subpixel" | "greyscale" | "none";
|
||||
export type OpenProjectIn = "ask" | "new" | "same" | "attach";
|
||||
export type NotificationDisplay = "balloon" | "sticky" | "none";
|
||||
/** Themes selectable as "preferred" when syncing with the OS. */
|
||||
export type ConcreteTheme = BuiltinThemeId;
|
||||
export type ConcreteTheme = ThemeChoiceId;
|
||||
|
||||
export interface AppearanceExtras {
|
||||
syncWithOs: boolean;
|
||||
preferredLightTheme: ConcreteTheme;
|
||||
preferredDarkTheme: ConcreteTheme;
|
||||
/** A plugin's file icon theme (`<pluginId>/<themeId>`); empty for the built-in icons. */
|
||||
fileIconTheme: string;
|
||||
zoom: number;
|
||||
useCustomFont: boolean;
|
||||
uiFontFamily: string;
|
||||
@@ -70,6 +72,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
|
||||
syncWithOs: false,
|
||||
preferredLightTheme: "light",
|
||||
preferredDarkTheme: "dark",
|
||||
fileIconTheme: "",
|
||||
zoom: 100,
|
||||
useCustomFont: false,
|
||||
uiFontFamily: "Inter",
|
||||
@@ -110,7 +113,7 @@ const int = (v: unknown, d: number, { min, max }: { min: number; max: number })
|
||||
typeof v === "number" && Number.isFinite(v) ? Math.round(Math.min(max, Math.max(min, v))) : d;
|
||||
|
||||
const AA: readonly Antialiasing[] = ["subpixel", "greyscale", "none"];
|
||||
const CONCRETE = BUILTIN_THEME_IDS;
|
||||
const concrete = (v: unknown, d: ConcreteTheme): ConcreteTheme => (isThemeChoiceId(v) ? v : d);
|
||||
const DISPLAYS: readonly NotificationDisplay[] = ["balloon", "sticky", "none"];
|
||||
|
||||
export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
|
||||
@@ -120,8 +123,10 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
|
||||
return {
|
||||
// A legacy `theme = "system"` means syncing too.
|
||||
syncWithOs: bool(a.syncWithOs, d.syncWithOs) || a.theme === "system",
|
||||
preferredLightTheme: oneOf(a.preferredLightTheme, CONCRETE, d.preferredLightTheme),
|
||||
preferredDarkTheme: oneOf(a.preferredDarkTheme, CONCRETE, d.preferredDarkTheme),
|
||||
preferredLightTheme: concrete(a.preferredLightTheme, d.preferredLightTheme),
|
||||
preferredDarkTheme: concrete(a.preferredDarkTheme, d.preferredDarkTheme),
|
||||
fileIconTheme:
|
||||
typeof a.fileIconTheme === "string" && /^[^/\s]+\/[^/\s]+$/.test(a.fileIconTheme) ? a.fileIconTheme : "",
|
||||
zoom: int(a.zoom, d.zoom, ZOOM_LIMITS),
|
||||
useCustomFont: b("useCustomFont"),
|
||||
uiFontFamily: typeof a.uiFontFamily === "string" && a.uiFontFamily.trim() ? a.uiFontFamily.trim() : d.uiFontFamily,
|
||||
|
||||
@@ -31,9 +31,10 @@ import {
|
||||
type GeneralExtras,
|
||||
type NotificationsValid,
|
||||
} from "./behaviorSchema";
|
||||
import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes";
|
||||
import { isThemeChoiceId, type ThemeChoiceId } from "@/platform/themes";
|
||||
|
||||
export type ThemeId = BuiltinThemeId | "system";
|
||||
/** A built-in theme, a plugin's (`plugin:<id>/<theme>`) or the legacy `"system"`. */
|
||||
export type ThemeId = ThemeChoiceId | "system";
|
||||
export type LocaleId = "en" | "zh";
|
||||
|
||||
/** A user keymap (Keymap › Duplicate…): its own overrides on top of a bundled preset. */
|
||||
@@ -173,7 +174,7 @@ export const LIMITS = {
|
||||
localHistoryMaxMb: { min: 16, max: 16384 },
|
||||
} as const;
|
||||
|
||||
const THEMES: readonly ThemeId[] = [...BUILTIN_THEME_IDS, "system"];
|
||||
const theme = (v: unknown, d: ThemeId): ThemeId => (v === "system" || isThemeChoiceId(v) ? v : d);
|
||||
const LOCALES: readonly LocaleId[] = ["en", "zh"];
|
||||
export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
||||
|
||||
@@ -220,7 +221,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
},
|
||||
appearance: {
|
||||
...validateAppearanceExtras(a),
|
||||
theme: oneOf(a.theme, THEMES, d.appearance.theme),
|
||||
theme: theme(a.theme, d.appearance.theme),
|
||||
accent: typeof a.accent === "string" && HEX_COLOR.test(a.accent) ? a.accent.toLowerCase() : d.appearance.accent,
|
||||
uiFontSize: num(a.uiFontSize, d.appearance.uiFontSize, LIMITS.uiFontSize, true),
|
||||
locale: oneOf(a.locale, LOCALES, d.appearance.locale),
|
||||
|
||||
@@ -32,13 +32,13 @@ import type { PageSpec } from "./spec";
|
||||
import { HTTP_PROXY_PAGE } from "./httpProxy";
|
||||
import styles from "./Behavior.module.css";
|
||||
import { tx, useT } from "@/platform/i18n";
|
||||
import { BUILTIN_THEMES, useContributedIconThemes } from "@/platform/themes";
|
||||
import { originDetail, themeSelectOptions, useThemeChoices } from "@/workbench/theme";
|
||||
|
||||
import { BUILTIN_THEMES } from "@/platform/themes";
|
||||
const BUILTIN_THEME_LABELS = BUILTIN_THEMES.map((t) => t.label);
|
||||
|
||||
export const APPEARANCE_GROUP = "Appearance & Behavior";
|
||||
|
||||
const THEMES: SelectOption<ConcreteTheme>[] = BUILTIN_THEMES.map((t) => ({ value: t.id, label: t.label }));
|
||||
|
||||
/** Checkbox bound to a key, optionally disabled. */
|
||||
function BoundToggle({ k, label, hint, disabled }: { k: string; label: string; hint?: ReactNode; disabled?: boolean }) {
|
||||
const [value, set] = useSetting<boolean>(k);
|
||||
@@ -60,10 +60,17 @@ function ThemeRow() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const gear = useRef<HTMLButtonElement>(null);
|
||||
const matched = useLabelMatch("Sync with OS");
|
||||
const choices = useThemeChoices();
|
||||
const shown: ConcreteTheme = theme === "system" ? "light" : theme;
|
||||
return (
|
||||
<Row label="Theme">
|
||||
<Select<ConcreteTheme> value={shown} options={THEMES} onChange={setTheme} aria-label="Theme" disabled={sync} />
|
||||
<Select<ConcreteTheme>
|
||||
value={shown}
|
||||
options={themeSelectOptions(choices, shown)}
|
||||
onChange={setTheme}
|
||||
aria-label="Theme"
|
||||
disabled={sync}
|
||||
/>
|
||||
<span className={clsx(styles.inline, matched && styles.matched)}>
|
||||
<Checkbox
|
||||
checked={sync}
|
||||
@@ -97,13 +104,18 @@ function ThemeRow() {
|
||||
<Row label="Preferred theme for light OS mode">
|
||||
<Select<ConcreteTheme>
|
||||
value={light}
|
||||
options={THEMES}
|
||||
options={themeSelectOptions(choices, light)}
|
||||
onChange={setLight}
|
||||
aria-label="Preferred light theme"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Preferred theme for dark OS mode">
|
||||
<Select<ConcreteTheme> value={dark} options={THEMES} onChange={setDark} aria-label="Preferred dark theme" />
|
||||
<Select<ConcreteTheme>
|
||||
value={dark}
|
||||
options={themeSelectOptions(choices, dark)}
|
||||
onChange={setDark}
|
||||
aria-label="Preferred dark theme"
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
</Popup>
|
||||
@@ -129,6 +141,28 @@ function ColorSchemeRow() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Plugins' file icon themes; the row shows once one is installed (or still selected). */
|
||||
function FileIconThemeRow() {
|
||||
const [value, setValue] = useSetting<string>("appearance.fileIconTheme");
|
||||
const themes = useContributedIconThemes((s) => s.themes);
|
||||
if (!themes.length && !value) return null;
|
||||
const options: SelectOption[] = [
|
||||
{ value: "", label: "Default" },
|
||||
...[...themes]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map((t) => ({ value: t.id, label: t.label, detail: originDetail(t) })),
|
||||
];
|
||||
if (value && !themes.some((t) => t.id === value))
|
||||
options.push({ value, label: value.slice(value.indexOf("/") + 1), detail: "Plugin not enabled" });
|
||||
return (
|
||||
<Indented>
|
||||
<Row label="File icon theme">
|
||||
<Select value={value ?? ""} options={options} onChange={setValue} aria-label="File icon theme" />
|
||||
</Row>
|
||||
</Indented>
|
||||
);
|
||||
}
|
||||
|
||||
function AccentRow() {
|
||||
const [accent, setAccent] = useSetting<string>("appearance.accent");
|
||||
return (
|
||||
@@ -529,7 +563,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
{
|
||||
kind: "custom",
|
||||
label: "Theme",
|
||||
keywords: ["Sync with OS", ...BUILTIN_THEMES.map((t) => t.label), "preferred theme"],
|
||||
keywords: ["Sync with OS", ...BUILTIN_THEME_LABELS, "preferred theme"],
|
||||
component: ThemeRow,
|
||||
},
|
||||
{
|
||||
@@ -538,6 +572,12 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
keywords: ["Theme default", "scheme"],
|
||||
component: ColorSchemeRow,
|
||||
},
|
||||
{
|
||||
kind: "custom",
|
||||
label: "File icon theme",
|
||||
keywords: ["icons", "file icons", "icon theme"],
|
||||
component: FileIconThemeRow,
|
||||
},
|
||||
{ kind: "custom", label: "Accent colour", keywords: ["accent color", "highlight"], component: AccentRow },
|
||||
{ kind: "custom", label: "Different tool window background", component: ToolWindowBackgroundRow },
|
||||
],
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
profileLabel,
|
||||
profileLocation,
|
||||
seenDiagnosticCodes,
|
||||
useProducerNames,
|
||||
validateProfileName,
|
||||
type CustomProfiles,
|
||||
type InspectionLevel,
|
||||
@@ -65,8 +66,10 @@ import {
|
||||
} from "./inspectionsModel";
|
||||
import styles from "./Inspections.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { onLanguageListChanged, useLanguageListVersion } from "@/platform/languageList";
|
||||
|
||||
let languagesCache: LanguageServers[] | null = null;
|
||||
onLanguageListChanged(() => (languagesCache = null));
|
||||
|
||||
async function loadLanguages(): Promise<LanguageServers[]> {
|
||||
if (languagesCache) return languagesCache;
|
||||
@@ -78,6 +81,7 @@ async function loadLanguages(): Promise<LanguageServers[]> {
|
||||
|
||||
function useLanguages(): LanguageServers[] {
|
||||
const [langs, setLangs] = useState<LanguageServers[]>(() => languagesCache ?? []);
|
||||
const version = useLanguageListVersion((s) => s.version);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void loadLanguages().then(
|
||||
@@ -87,7 +91,7 @@ function useLanguages(): LanguageServers[] {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
}, [version]);
|
||||
return langs;
|
||||
}
|
||||
|
||||
@@ -132,9 +136,10 @@ function InspectionsPage() {
|
||||
|
||||
const languages = useLanguages();
|
||||
const seen = useMemo(() => seenDiagnosticCodes(), []);
|
||||
const producerNames = useProducerNames((s) => s.names);
|
||||
const tree = useMemo(
|
||||
() => buildInspectionTree(languages, seen, [ideOverrides, projectOverrides]),
|
||||
[languages, seen, ideOverrides, projectOverrides],
|
||||
() => buildInspectionTree(languages, seen, [ideOverrides, projectOverrides], producerNames),
|
||||
[languages, seen, ideOverrides, projectOverrides, producerNames],
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const roots = useMemo(() => filterInspectionTree(tree, query), [tree, query]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Editor › Inspections tree: language → language server → diagnostic codes
|
||||
* seen, plus the built-in checks; enabled / severity state against a
|
||||
* profile's overrides (`@/platform/inspections`).
|
||||
* seen, plugins' problems under Plugins, plus the built-in checks; enabled /
|
||||
* severity state against a profile's overrides (`@/platform/inspections`).
|
||||
*/
|
||||
import {
|
||||
BUILTIN_INSPECTIONS,
|
||||
@@ -39,23 +39,33 @@ export interface LanguageServers {
|
||||
}
|
||||
|
||||
const OTHER = "Other";
|
||||
const PLUGINS = "Plugins";
|
||||
const PLUGIN_PREFIX = "plugin:";
|
||||
|
||||
function serverNode(group: string, server: string, codes: readonly SeenCode[], languages: string): InspectionNode {
|
||||
function serverNode(
|
||||
group: string,
|
||||
server: string,
|
||||
codes: readonly SeenCode[],
|
||||
languages: string,
|
||||
plugin?: string,
|
||||
): InspectionNode {
|
||||
const id = producerInspectionId(server);
|
||||
return {
|
||||
key: `${group}/${id}`,
|
||||
id,
|
||||
name: server,
|
||||
description: languages
|
||||
? tx`Problems the ${server} language server reports in ${languages} files. Severity "As reported" keeps the level the server assigns; a code below can override it.`
|
||||
: tx`Problems the ${server} language server reports. Severity "As reported" keeps the level the server assigns; a code below can override it.`,
|
||||
name: plugin ?? server,
|
||||
description: plugin
|
||||
? tx`Problems the ${plugin} plugin reports. Severity "As reported" keeps the level the plugin assigns; a code below can override it.`
|
||||
: languages
|
||||
? tx`Problems the ${server} language server reports in ${languages} files. Severity "As reported" keeps the level the server assigns; a code below can override it.`
|
||||
: tx`Problems the ${server} language server reports. Severity "As reported" keeps the level the server assigns; a code below can override it.`,
|
||||
children: [...codes]
|
||||
.sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true }))
|
||||
.map((c) => ({
|
||||
key: `${group}/${codeInspectionId(id, c.code)}`,
|
||||
id: codeInspectionId(id, c.code),
|
||||
name: c.code,
|
||||
description: codeDescription(server, c),
|
||||
description: codeDescription(plugin ?? server, c),
|
||||
children: [],
|
||||
})),
|
||||
};
|
||||
@@ -64,12 +74,14 @@ function serverNode(group: string, server: string, codes: readonly SeenCode[], l
|
||||
/**
|
||||
* The inspection tree: one group per language that has language servers, the
|
||||
* built-in checks under their category, and servers known only from seen
|
||||
* diagnostics or stored overrides under "Other". Groups sort by name.
|
||||
* diagnostics or stored overrides under "Other", plugins (`plugin:<id>`,
|
||||
* named by `names`) under "Plugins". Groups sort by name.
|
||||
*/
|
||||
export function buildInspectionTree(
|
||||
languages: readonly LanguageServers[],
|
||||
seen: readonly SeenCode[],
|
||||
overrides: readonly ProfileOverrides[] = [],
|
||||
names: ReadonlyMap<string, string> = new Map(),
|
||||
): InspectionNode[] {
|
||||
const codes = new Map<string, Map<string, SeenCode>>();
|
||||
const addCode = (c: SeenCode) => {
|
||||
@@ -81,9 +93,10 @@ export function buildInspectionTree(
|
||||
// Codes with stored overrides stay listed even if not seen in this session.
|
||||
for (const profile of overrides) {
|
||||
for (const id of Object.keys(profile)) {
|
||||
const m = /^lsp:([^:]+):(.+)$/.exec(id);
|
||||
if (m) addCode({ producer: m[1]!, code: m[2]!, source: "", sample: "" });
|
||||
else if (id.startsWith("lsp:") && !codes.has(id.slice(4))) codes.set(id.slice(4), new Map());
|
||||
// `lsp:<producer>[:<code>]`; a plugin's producer is `plugin:<id>`.
|
||||
const m = /^lsp:(plugin:[^:]+|[^:]+)(?::(.+))?$/.exec(id);
|
||||
if (m?.[2]) addCode({ producer: m[1]!, code: m[2], source: "", sample: "" });
|
||||
else if (m && !codes.has(m[1]!)) codes.set(m[1]!, new Map());
|
||||
}
|
||||
}
|
||||
const servedBy = new Map<string, string[]>();
|
||||
@@ -116,7 +129,10 @@ export function buildInspectionTree(
|
||||
}
|
||||
for (const [producer, list] of codes) {
|
||||
if (servedBy.has(producer) || builtinProducers.has(producer)) continue;
|
||||
group(OTHER).children.push(serverNode(OTHER, producer, [...list.values()], ""));
|
||||
if (producer.startsWith(PLUGIN_PREFIX)) {
|
||||
const name = names.get(producer) ?? producer.slice(PLUGIN_PREFIX.length);
|
||||
group(PLUGINS).children.push(serverNode(PLUGINS, producer, [...list.values()], "", name));
|
||||
} else group(OTHER).children.push(serverNode(OTHER, producer, [...list.values()], ""));
|
||||
}
|
||||
for (const g of groups.values()) {
|
||||
g.description = `${g.children.length} inspection${g.children.length === 1 ? "" : "s"}.`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Registers the built-in pages as ordinary settings-page contributions and indexes their labels. */
|
||||
import { onLanguageListChanged } from "@/platform/languageList";
|
||||
import { registerSettingsPage, type SettingsPageContribution } from "@/workbench/contributions";
|
||||
import { BEHAVIOR_PAGES } from "./behavior";
|
||||
import { BUILTIN_PAGES as CORE_PAGES } from "./builtins";
|
||||
@@ -55,19 +56,29 @@ function toContribution(spec: PageSpec): SettingsPageContribution {
|
||||
|
||||
export function registerBuiltinPages(): () => void {
|
||||
const disposers = BUILTIN_PAGES.map((spec) => registerSettingsPage(toContribution(spec)));
|
||||
// Code Style › <Language> pages come from the backend's language list.
|
||||
// Code Style › <Language> pages come from the backend's language list, which plugins change.
|
||||
let live = true;
|
||||
void codeStyleLanguagePages()
|
||||
.then((pages) => {
|
||||
if (!live) return;
|
||||
for (const spec of pages) {
|
||||
specs.set(spec.id, spec);
|
||||
disposers.push(registerSettingsPage(toContribution(spec)));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
let languagePages: (() => void)[] = [];
|
||||
let generation = 0;
|
||||
const loadLanguagePages = () => {
|
||||
const mine = ++generation;
|
||||
void codeStyleLanguagePages()
|
||||
.then((pages) => {
|
||||
if (!live || mine !== generation) return;
|
||||
languagePages.forEach((d) => d());
|
||||
languagePages = pages.map((spec) => {
|
||||
specs.set(spec.id, spec);
|
||||
return registerSettingsPage(toContribution(spec));
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
loadLanguagePages();
|
||||
const unsubscribe = onLanguageListChanged(loadLanguagePages);
|
||||
return () => {
|
||||
live = false;
|
||||
unsubscribe();
|
||||
languagePages.forEach((d) => d());
|
||||
disposers.forEach((d) => d());
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача