дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +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
+381
-47
@@ -861,25 +861,99 @@ export const commands = {
|
||||
/** Routes this window's plugin events to `on_event` (replaces an earlier subscription). */
|
||||
pluginSubscribe: (onEvent: Channel<PluginEvent>) => __TAURI_INVOKE<void>("plugin_subscribe", { onEvent }),
|
||||
/**
|
||||
* Installed plugins plus the dev plugins of this window's project
|
||||
* (`<project>/.openidea/plugins/*`, rescanned on every call).
|
||||
* Installed and linked plugins plus the project plugins of this window's
|
||||
* project (`<project>/.openidea/plugins/*`, rescanned on every call), with
|
||||
* the UI contributions to project.
|
||||
*/
|
||||
pluginList: () => typedError<PluginsSnapshot, IdeError>(__TAURI_INVOKE("plugin_list")),
|
||||
pluginSetEnabled: (id: string, enabled: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_set_enabled", { id, enabled })),
|
||||
/** Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. */
|
||||
pluginInstallDir: (path: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_install_dir", { path })),
|
||||
/** Validates the package in `path` for the install page (nothing is copied). */
|
||||
pluginPreviewInstall: (path: string) => typedError<InstallPreview, IdeError>(__TAURI_INVOKE("plugin_preview_install", { path })),
|
||||
/**
|
||||
* Installs the plugin package in `path` (a folder with `plugin.toml`) with
|
||||
* the capabilities the user accepted on the install page.
|
||||
*/
|
||||
pluginInstallDir: (path: string, consent: Consent) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_install_dir", { path, consent })),
|
||||
/** Confirms a plugin waiting for consent (project plugins, new capabilities). */
|
||||
pluginGrant: (id: string, consent: Consent) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_grant", { id, consent })),
|
||||
/** Grants or revokes one declared capability. */
|
||||
pluginSetCapability: (id: string, capability: string, granted: boolean) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_set_capability", { id, capability, granted })),
|
||||
/** Links a development plugin folder (`openidea-plugin link` does the same from a terminal). */
|
||||
pluginLink: (path: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_link", { path })),
|
||||
pluginUnlink: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_unlink", { id })),
|
||||
/** Validated assets of a plugin whose id starts with `prefix` (icon theme mapping and SVGs). */
|
||||
pluginAssets: (pluginId: string, prefix: string) => typedError<([string, string])[], IdeError>(__TAURI_INVOKE("plugin_assets", { pluginId, prefix })),
|
||||
/** An activation event from the UI: `kind` is `language`, `file` (project-relative path) or `view`. */
|
||||
pluginActivationEvent: (kind: string, value: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_activation_event", { kind, value })),
|
||||
/**
|
||||
* Plugin packages available locally for installation: `<resources>/plugins`
|
||||
* when bundled, plus the repository's sample `plugins/` in debug builds.
|
||||
* There is no network marketplace.
|
||||
* when bundled, plus the repository's official plugins in debug builds.
|
||||
*/
|
||||
pluginCatalog: () => typedError<CatalogPlugin[], IdeError>(__TAURI_INVOKE("plugin_catalog")),
|
||||
pluginUninstall: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_uninstall", { id })),
|
||||
/** Re-reads a plugin from disk (after rebuilding a dev plugin). */
|
||||
pluginReload: (id: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_reload", { id })),
|
||||
/** Runs a plugin command in the context of this window's project. */
|
||||
pluginRunCommand: (pluginId: string, commandId: string, arg: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg })),
|
||||
/** The UI reports the focused editor's file (`active-file` for plugins). */
|
||||
/** Runs a plugin command in the context of this window's project and focused editor. */
|
||||
pluginRunCommand: (pluginId: string, commandId: string, arg: string | null, editor: {
|
||||
path: string,
|
||||
language: string,
|
||||
selections: Range[],
|
||||
} | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor })),
|
||||
/** Workbench events from this window (only the kinds of `plugin_event_kinds`). */
|
||||
pluginPostEvents: (events: WorkbenchEvent[]) => __TAURI_INVOKE<void>("plugin_post_events", { events }),
|
||||
/** Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). */
|
||||
pluginEventKinds: () => __TAURI_INVOKE<EventKind[]>("plugin_event_kinds"),
|
||||
/** Every plugin status bar item (later changes arrive as `statusItemChanged`). */
|
||||
pluginStatusItems: () => __TAURI_INVOKE<PluginStatusItem[]>("plugin_status_items"),
|
||||
/** Cancels a plugin task (`taskStarted` with `cancellable`). */
|
||||
pluginCancelTask: (taskId: string) => __TAURI_INVOKE<boolean>("plugin_cancel_task", { taskId }),
|
||||
/**
|
||||
* Content of a plugin view (`doc`: the document of a custom editor; `editor`: for
|
||||
* a tool window, the window's active editor, as commands get it).
|
||||
*/
|
||||
pluginViewRender: (pluginId: string, view: string, viewport: {
|
||||
firstRow: number,
|
||||
rows: number,
|
||||
} | null, doc: number | null, editor: {
|
||||
path: string,
|
||||
language: string,
|
||||
selections: Range[],
|
||||
} | null) => typedError<View, IdeError>(__TAURI_INVOKE("plugin_view_render", { pluginId, view, viewport, doc, editor })),
|
||||
/** A user action in a plugin view or custom editor (`doc`, `editor`: as for rendering). */
|
||||
pluginViewEvent: (pluginId: string, view: string, event: ViewEvent, doc: number | null, editor: {
|
||||
path: string,
|
||||
language: string,
|
||||
selections: Range[],
|
||||
} | null) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_view_event", { pluginId, view, event, doc, editor })),
|
||||
/** The page of a plugin's web view or custom editor (`web = …`). */
|
||||
pluginWebPage: (pluginId: string, view: string) => typedError<WebPage, IdeError>(__TAURI_INVOKE("plugin_web_page", { pluginId, view })),
|
||||
/**
|
||||
* Writes what a web view saves (`openidea.saveFile`) to the path the user
|
||||
* chose in the save dialog, on this machine.
|
||||
*/
|
||||
pluginWebSaveFile: (path: string, base64: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_web_save_file", { path, base64 })),
|
||||
/**
|
||||
* Reads the file the user picked in an open dialog for a web view
|
||||
* (`openidea.openFile`), on this machine; base64.
|
||||
*/
|
||||
pluginWebReadFile: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_web_read_file", { path })),
|
||||
/** Gutter markers of plugin `line-markers` providers for an open document. */
|
||||
pluginLineMarkers: (doc: DocId) => typedError<LineMarkerInfo[], IdeError>(__TAURI_INVOKE("plugin_line_markers", { doc })),
|
||||
/** The marketplace's plugins: read again when `refresh`, else the last index read. */
|
||||
pluginMarket: (refresh: boolean) => typedError<MarketPlugin[], IdeError>(__TAURI_INVOKE("plugin_market", { refresh })),
|
||||
pluginMarketUrl: () => __TAURI_INVOKE<string | null>("plugin_market_url"),
|
||||
/** Sets the marketplace index URL (an `https://…/index.json` or a local path; `None` for the default). */
|
||||
pluginSetMarketUrl: (url: string | null) => __TAURI_INVOKE<void>("plugin_set_market_url", { url }),
|
||||
/** Downloads and checks a marketplace plugin; returns the folder for the install page. */
|
||||
pluginMarketDownload: (id: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_market_download", { id })),
|
||||
/** Unpacks a package file (`.oiplugin` or `.zip`); returns the folder for the install page. */
|
||||
pluginUnpackPackage: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_unpack_package", { path })),
|
||||
/** Goes back to the version the last update of `id` replaced. */
|
||||
pluginRollBack: (id: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_roll_back", { id })),
|
||||
pluginSecrets: (pluginId: string) => typedError<PluginSecretState[], IdeError>(__TAURI_INVOKE("plugin_secrets", { pluginId })),
|
||||
/** Stores a plugin secret (`None` or empty deletes it). The value never goes back to the UI or the plugin. */
|
||||
pluginSetSecret: (pluginId: string, name: string, value: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_set_secret", { pluginId, name, value })),
|
||||
/** The UI reports the focused editor's file (`editor-focused` events). */
|
||||
pluginSetActiveFile: (path: string | null) => __TAURI_INVOKE<void>("plugin_set_active_file", { path }),
|
||||
/** Selects a plugin theme; both `None` restores the built-in theme. */
|
||||
pluginSetTheme: (pluginId: string | null, themeId: string | null) => typedError<{
|
||||
@@ -1080,6 +1154,16 @@ export const commands = {
|
||||
};
|
||||
|
||||
/* Types */
|
||||
export type ActionPerformed = {
|
||||
action: string,
|
||||
title: string,
|
||||
source: ActionSource,
|
||||
shortcut: string | null,
|
||||
};
|
||||
|
||||
/** How the user started an action. */
|
||||
export type ActionSource = "keyboard" | "menu" | "toolbar" | "contextMenu" | "search" | "other";
|
||||
|
||||
/** Tools › Actions on Save (Reformat code is `editor.formatOnSave`). */
|
||||
export type ActionsOnSaveSettings = {
|
||||
optimizeImports?: boolean,
|
||||
@@ -1450,7 +1534,10 @@ export type AppMetrics = {
|
||||
};
|
||||
|
||||
export type AppearanceSettings = {
|
||||
/** `dark` | `light` | `system` | `dark-hc` (high-contrast dark) */
|
||||
/**
|
||||
* A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
|
||||
* "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
|
||||
*/
|
||||
theme?: string,
|
||||
accent?: string,
|
||||
uiFontSize?: number | null,
|
||||
@@ -1459,6 +1546,8 @@ export type AppearanceSettings = {
|
||||
syncWithOs?: boolean,
|
||||
preferredLightTheme?: string,
|
||||
preferredDarkTheme?: string,
|
||||
/** A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons. */
|
||||
fileIconTheme?: string,
|
||||
/** IDE zoom in percent (50–300). */
|
||||
zoom?: number,
|
||||
/** Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). */
|
||||
@@ -1710,6 +1799,18 @@ export type CallHierarchy = {
|
||||
incomplete: ReferencesIncomplete | null,
|
||||
};
|
||||
|
||||
/** A capability as the UI shows it. */
|
||||
export type CapabilityInfo = {
|
||||
/** `project.read`, `net:crates.io`, … */
|
||||
id: string,
|
||||
risk: Risk,
|
||||
description: string,
|
||||
/** Declared under `optional`: the plugin runs without it. */
|
||||
optional: boolean,
|
||||
/** Currently granted. */
|
||||
granted: boolean,
|
||||
};
|
||||
|
||||
/**
|
||||
* A plugin package available for installation from a local catalog
|
||||
* directory (the samples shipped with the IDE). Not installed by listing it.
|
||||
@@ -1722,13 +1823,11 @@ export type CatalogPlugin = {
|
||||
authors: string[],
|
||||
homepage: string | null,
|
||||
changeNotes: string | null,
|
||||
/** Package directory; install with `install_from_path(path)`. */
|
||||
/** Package directory; install with `install_from_dir(path)`. */
|
||||
path: IpcPath,
|
||||
hasCode: boolean,
|
||||
permissions: Permission[],
|
||||
commands: CommandDecl[],
|
||||
languageCount: number,
|
||||
themeCount: number,
|
||||
capabilities: CapabilityInfo[],
|
||||
contributions: ContributionCount[],
|
||||
/** Why the package cannot be installed as is (e.g. its WASM is not built). */
|
||||
problem: string | null,
|
||||
};
|
||||
@@ -1919,12 +2018,6 @@ export type Command = {
|
||||
arguments: string[],
|
||||
};
|
||||
|
||||
export type CommandDecl = {
|
||||
id: string,
|
||||
title: string,
|
||||
category?: string | null,
|
||||
};
|
||||
|
||||
export type CommandForm = {
|
||||
command: Command,
|
||||
formFields: FormField[],
|
||||
@@ -2055,6 +2148,15 @@ export type ComposeOp = "start" | "stop" | "restart" |
|
||||
/** Stops and removes the project's containers and networks. */
|
||||
"down";
|
||||
|
||||
/** The user's answer on an install or consent page. */
|
||||
export type Consent = {
|
||||
/**
|
||||
* Optional capabilities the user accepted (`net:crates.io`, …). Required
|
||||
* ones are implied by consenting at all.
|
||||
*/
|
||||
optional: string[],
|
||||
};
|
||||
|
||||
/** Build, Execution, Deployment › Console. */
|
||||
export type ConsoleSettings = {
|
||||
softWrap?: boolean,
|
||||
@@ -2068,6 +2170,18 @@ export type ConsoleSettings = {
|
||||
|
||||
export type ContainerOp = "start" | "stop" | "restart" | "pause" | "unpause" | "kill";
|
||||
|
||||
/** Number of declarations of one extension point. */
|
||||
export type ContributionCount = {
|
||||
point: string,
|
||||
count: number,
|
||||
};
|
||||
|
||||
/** A contribution as the plugin details page lists it. */
|
||||
export type ContributionInfo = {
|
||||
point: string,
|
||||
label: string,
|
||||
};
|
||||
|
||||
export type CoveragePrepared = CoveragePrepared_Serialize | CoveragePrepared_Deserialize;
|
||||
|
||||
export type CoveragePrepared_Deserialize = ({ status: "ready"; runner: string;
|
||||
@@ -3067,6 +3181,9 @@ export type ErrorKind = "notFound" | "alreadyExists" | "permissionDenied" | "inv
|
||||
/** A required external tool (language server, git, debugger) is unavailable. */
|
||||
"unavailable" | "io" | "protocol" | "internal";
|
||||
|
||||
/** Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`). */
|
||||
export type EventKind = "file-opened" | "file-saved" | "editor-focused" | "action-performed" | "vcs-changed" | "scheduled";
|
||||
|
||||
/** An exception breakpoint filter offered by an adapter (`exceptionBreakpointFilters`). */
|
||||
export type ExceptionFilter = {
|
||||
filter: string,
|
||||
@@ -3135,6 +3252,11 @@ export type ExternalTool = {
|
||||
openConsole?: boolean,
|
||||
};
|
||||
|
||||
export type FieldValue = { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: {
|
||||
options: string[],
|
||||
selected: string,
|
||||
} };
|
||||
|
||||
export type FileCoverage = {
|
||||
path: IpcPath,
|
||||
/** Sorted by line. */
|
||||
@@ -3783,6 +3905,21 @@ export type IndentConfig_Serialize = {
|
||||
|
||||
export type IndentStyle = "space" | "tab";
|
||||
|
||||
/** A published plugin version. */
|
||||
export type IndexEntry = {
|
||||
id: string,
|
||||
name: string,
|
||||
version: string,
|
||||
description?: string,
|
||||
authors?: string[],
|
||||
publisher: string,
|
||||
/** The package, relative to the index (or an absolute URL). */
|
||||
download: string,
|
||||
/** [`package_digest`] of its contents. */
|
||||
digest: string,
|
||||
capabilities?: string[],
|
||||
};
|
||||
|
||||
export type IndexStatus = {
|
||||
indexing: boolean,
|
||||
files: number,
|
||||
@@ -3921,6 +4058,25 @@ export type InstallEvent =
|
||||
/** Download progress; `fraction` is 0..1 when the size is known. */
|
||||
{ type: "progress"; fraction: number | null; message: string };
|
||||
|
||||
/** What the install page shows before anything is copied. */
|
||||
export type InstallPreview = {
|
||||
id: string,
|
||||
name: string,
|
||||
version: string,
|
||||
description: string,
|
||||
authors: string[],
|
||||
hasCode: boolean,
|
||||
capabilities: CapabilityInfo[],
|
||||
contributions: ContributionCount[],
|
||||
warnings: string[],
|
||||
/** Version currently installed under the same id. */
|
||||
replaces: string | null,
|
||||
/** Not signed by a publisher the marketplace index trusts: warn the user. */
|
||||
unsigned: boolean,
|
||||
/** The verified publisher. */
|
||||
publisher: string | null,
|
||||
};
|
||||
|
||||
/** Optional parts of the invalidation; the symbol index is always rebuilt. */
|
||||
export type InvalidateCachesOptions = {
|
||||
/** Local History of every project. */
|
||||
@@ -4270,6 +4426,18 @@ export type LineHunk = {
|
||||
oldText: string,
|
||||
};
|
||||
|
||||
/** A gutter marker from a plugin's `line-markers` provider. */
|
||||
export type LineMarkerInfo = {
|
||||
pluginId: string,
|
||||
/** 0-based. */
|
||||
line: number,
|
||||
/** `navigate` (default), `implemented`, `implementing`, `run`. */
|
||||
icon: string | null,
|
||||
tooltip: string,
|
||||
/** Opened on click. */
|
||||
target: Location | null,
|
||||
};
|
||||
|
||||
export type LineMatch = {
|
||||
/** 0-based line. */
|
||||
line: number,
|
||||
@@ -4423,6 +4591,12 @@ export type MarkdownSettings = {
|
||||
previewFontSize?: number | null,
|
||||
};
|
||||
|
||||
/** A marketplace entry with what is installed. */
|
||||
export type MarketPlugin = {
|
||||
/** Installed version, if any. */
|
||||
installed: string | null,
|
||||
} & IndexEntry;
|
||||
|
||||
export type Markup = {
|
||||
/** `markdown` or `plaintext`. */
|
||||
kind: string,
|
||||
@@ -4773,24 +4947,47 @@ export type PathCompletion = {
|
||||
dir: boolean,
|
||||
};
|
||||
|
||||
/** Capabilities a plugin must declare to use the gated host functions. */
|
||||
export type Permission =
|
||||
/** `workspace-root`, `read-file` (inside the project root only). */
|
||||
"project-read" |
|
||||
/** `active-file`. */
|
||||
"editor-read" |
|
||||
/** `insert-text`, `replace-selection`. */
|
||||
"editor-write";
|
||||
|
||||
export type PhpSettings = {
|
||||
/** `php` CLI executable; empty = the one on `PATH`. */
|
||||
interpreter?: string,
|
||||
};
|
||||
|
||||
/** The focused editor when a plugin command is invoked. */
|
||||
export type PluginEditor = {
|
||||
path: string,
|
||||
language: string,
|
||||
selections: Range[],
|
||||
};
|
||||
|
||||
/** Backend → UI notifications (Tauri event `ide://plugin`). */
|
||||
export type PluginEvent =
|
||||
/** Plugins, their status or their commands changed: re-fetch `plugin_list`. */
|
||||
{ type: "commandsChanged" } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } | { type: "insertText"; pluginId: string; text: string } | { type: "replaceSelection"; pluginId: string; text: string } | { type: "themeChanged"; theme: ActiveTheme | null };
|
||||
/** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */
|
||||
{ type: "commandsChanged" } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } |
|
||||
/** A plugin's status bar item was set (`item`) or removed. */
|
||||
{ type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } |
|
||||
/** A plugin began a task: show it with the background tasks. */
|
||||
{ type: "taskStarted"; pluginId: string; taskId: string; title: string; cancellable: boolean } | { type: "taskProgress"; pluginId: string; taskId: string; done: number; total: number | null; message: string | null } | { type: "taskEnded"; pluginId: string; taskId: string } |
|
||||
/**
|
||||
* The kinds of workbench events plugins want changed: re-fetch
|
||||
* `plugin_event_kinds` (the UI reports only those).
|
||||
*/
|
||||
{ type: "subscriptionsChanged" } | { type: "themeChanged"; theme: ActiveTheme | null } |
|
||||
/**
|
||||
* A plugin needs capabilities the user has not granted yet (first use
|
||||
* after migrating from 0.1): show its consent dialog.
|
||||
*/
|
||||
{ type: "consentRequested"; pluginId: string; capabilities: CapabilityInfo[] } |
|
||||
/** A view (or the custom editors) of a plugin must be rendered again. */
|
||||
{ type: "viewChanged"; pluginId: string; view: string } |
|
||||
/** For the script of a view's `html` body. */
|
||||
{ type: "viewMessage"; pluginId: string; view: string; message: string } |
|
||||
/** Open a terminal running `program` (already approved and verified). */
|
||||
{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } |
|
||||
/**
|
||||
* Plugin languages, or the servers they may start, changed: re-fetch
|
||||
* `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
*/
|
||||
{ type: "languagesChanged" };
|
||||
|
||||
export type PluginInfo = {
|
||||
id: string,
|
||||
@@ -4807,42 +5004,78 @@ export type PluginInfo = {
|
||||
status: PluginStatus,
|
||||
error: string | null,
|
||||
hasCode: boolean,
|
||||
permissions: Permission[],
|
||||
/** Commands contributed while enabled (manifest + registered at runtime). */
|
||||
commands: CommandDecl[],
|
||||
languages: PluginLanguage[],
|
||||
themes: PluginTheme[],
|
||||
/** Declared capabilities with risk and grant state. */
|
||||
capabilities: CapabilityInfo[],
|
||||
/** What it contributes while live (point + label). */
|
||||
contributions: ContributionInfo[],
|
||||
/** `activation` events of the manifest. */
|
||||
activation: string[],
|
||||
/** Non-fatal problems (unsupported contribution points). */
|
||||
warnings: string[],
|
||||
/** Statistics of this session. */
|
||||
stats: PluginStats,
|
||||
/** The marketplace publisher whose signature the package carries (none: unsigned or untrusted). */
|
||||
publisher: string | null,
|
||||
/** The version an update replaced, to roll back to. */
|
||||
previousVersion: string | null,
|
||||
};
|
||||
|
||||
export type PluginLanguage = {
|
||||
id: string,
|
||||
/** A secret a plugin declares, and whether the user stored it. */
|
||||
export type PluginSecretState = {
|
||||
name: string,
|
||||
extensions: string[],
|
||||
set: boolean,
|
||||
};
|
||||
|
||||
export type PluginSource =
|
||||
/** Installed under `<app-data>/plugins/<id>/`. */
|
||||
"installed" |
|
||||
/** Loaded from `<project>/.openidea/plugins/<dir>/` while that project is open. */
|
||||
"dev";
|
||||
"dev" |
|
||||
/** A development plugin linked from any folder (`openidea-plugin link`). */
|
||||
"linked";
|
||||
|
||||
export type PluginStats = {
|
||||
activations: number,
|
||||
/** Last activation (instantiate + `activate`), ms. */
|
||||
activationMs: number | null,
|
||||
calls: number,
|
||||
errors: number,
|
||||
/** Slowest call, ms. */
|
||||
maxCallMs: number | null,
|
||||
/** 95th percentile of the recent calls, ms. */
|
||||
p95CallMs: number | null,
|
||||
/** Edit-path calls whose result came too late and was dropped. */
|
||||
slowCalls: number,
|
||||
/** Traps (panics, budgets, permission violations); each drops the instance. */
|
||||
crashes: number,
|
||||
/** Instances reclaimed after being idle. */
|
||||
reclaims: number,
|
||||
};
|
||||
|
||||
export type PluginStatus =
|
||||
/** Code loaded and activated. */
|
||||
/** Code instantiated and activated. */
|
||||
"active" |
|
||||
/** No code; static contributions only. */
|
||||
/** Has code that has not been needed yet: contributions are live, no Wasm instance. */
|
||||
"inactive" |
|
||||
/** No code; contributions only. */
|
||||
"static" | "disabled" |
|
||||
/** Failed to load, or trapped and was disabled for this session (see `error`). */
|
||||
/** Waiting for the user to confirm its permissions (project and new plugins). */
|
||||
"needsConsent" |
|
||||
/** Failed to load, or failed repeatedly and was stopped for this session (see `error`). */
|
||||
"failed";
|
||||
|
||||
export type PluginTheme = {
|
||||
/** A plugin's status bar item. */
|
||||
export type PluginStatusItem = {
|
||||
pluginId: string,
|
||||
id: string,
|
||||
name: string,
|
||||
kind: ThemeKind,
|
||||
item: StatusItem,
|
||||
};
|
||||
|
||||
export type PluginsSnapshot = {
|
||||
plugins: PluginInfo[],
|
||||
activeTheme: ActiveTheme | null,
|
||||
/** UI contributions of live plugins, projected by the client. */
|
||||
ui: UiContribution[],
|
||||
};
|
||||
|
||||
/** A 0-based line / UTF-16 column position. */
|
||||
@@ -5420,6 +5653,15 @@ export type RevisionChange = {
|
||||
after: string | null,
|
||||
};
|
||||
|
||||
/** How a grant is presented (plan 4.2). */
|
||||
export type Risk =
|
||||
/** Listed on the install page, one confirmation. */
|
||||
"low" |
|
||||
/** Listed separately with its purpose. */
|
||||
"medium" |
|
||||
/** Separate dialog: "equivalent to running arbitrary code on this machine". */
|
||||
"high";
|
||||
|
||||
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
|
||||
|
||||
export type RouteSource = {
|
||||
@@ -5836,6 +6078,8 @@ export type Settings_Deserialize = {
|
||||
cmake?: CMakeSettings_Deserialize,
|
||||
/** Tools › Agents › Chat. */
|
||||
agents?: AgentSettings,
|
||||
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
|
||||
plugins?: { [key in string]: { [key in string]: unknown } },
|
||||
};
|
||||
|
||||
export type Settings_Serialize = {
|
||||
@@ -5894,6 +6138,8 @@ export type Settings_Serialize = {
|
||||
cmake: CMakeSettings_Serialize,
|
||||
/** Tools › Agents › Chat. */
|
||||
agents: AgentSettings,
|
||||
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
|
||||
plugins: { [key in string]: { [key in string]: unknown } },
|
||||
};
|
||||
|
||||
export type Severity = "error" | "warning" | "info" | "hint";
|
||||
@@ -6079,6 +6325,14 @@ export type Stash = {
|
||||
timeMs: number | null,
|
||||
};
|
||||
|
||||
/** A plugin's status bar item. */
|
||||
export type StatusItem = {
|
||||
text: string,
|
||||
tooltip: string | null,
|
||||
/** One of the plugin's commands, run on click. */
|
||||
command: string | null,
|
||||
};
|
||||
|
||||
/** A call on the current line that Smart Step Into can enter (`stepInTargets`). */
|
||||
export type StepInTarget = {
|
||||
id: number,
|
||||
@@ -6499,6 +6753,17 @@ export type Typo = {
|
||||
*/
|
||||
export type TypoContext = "code" | "literal" | "comment" | "prose";
|
||||
|
||||
/** One projected declaration. */
|
||||
export type UiContribution = {
|
||||
pluginId: string,
|
||||
/** Extension point id (`commands`, `iconThemes`, …). */
|
||||
point: string,
|
||||
/** Stable within (plugin, point): the client keys its registrations on it. */
|
||||
key: string,
|
||||
/** The validated declaration, shaped by the point. */
|
||||
decl: unknown,
|
||||
};
|
||||
|
||||
/** What [`FileMoves::undo`] did. */
|
||||
export type UndoOutcome = {
|
||||
/** Moves reverted (each `from` is the original location again). */
|
||||
@@ -6575,6 +6840,62 @@ export type VcsSettings = {
|
||||
issueNavigation?: IssueLink[],
|
||||
};
|
||||
|
||||
/** What a view or custom editor shows. */
|
||||
export type View = {
|
||||
toolbar: ViewButton[],
|
||||
body: ViewBody,
|
||||
status: string | null,
|
||||
};
|
||||
|
||||
export type ViewBody = { kind: "message"; value: string } | { kind: "tree"; value: ViewTreeItem[] } | { kind: "list"; value: ViewListItem[] } | { kind: "form"; value: ViewField[] } | { kind: "table"; value: ViewTable } | { kind: "html"; value: string };
|
||||
|
||||
export type ViewButton = {
|
||||
id: string,
|
||||
label: string,
|
||||
icon: string | null,
|
||||
enabled: boolean,
|
||||
};
|
||||
|
||||
/** A user action in a view. */
|
||||
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string };
|
||||
|
||||
export type ViewField = {
|
||||
key: string,
|
||||
label: string,
|
||||
value: FieldValue,
|
||||
};
|
||||
|
||||
export type ViewListItem = {
|
||||
key: string,
|
||||
label: string,
|
||||
icon: string | null,
|
||||
detail: string | null,
|
||||
};
|
||||
|
||||
export type ViewTable = {
|
||||
columns: string[],
|
||||
totalRows: number,
|
||||
firstRow: number,
|
||||
rows: string[][],
|
||||
editable: boolean,
|
||||
};
|
||||
|
||||
export type ViewTreeItem = {
|
||||
key: string,
|
||||
parent: string | null,
|
||||
label: string,
|
||||
icon: string | null,
|
||||
detail: string | null,
|
||||
expandable: boolean,
|
||||
expanded: boolean,
|
||||
};
|
||||
|
||||
/** The table rows the UI shows. */
|
||||
export type Viewport = {
|
||||
firstRow: number,
|
||||
rows: number,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only contents a language server provides for a non-`file:` URI
|
||||
* (library class source or decompiled code from `jdt://`, …).
|
||||
@@ -6613,6 +6934,16 @@ export type WebGoto = {
|
||||
targets: Location[],
|
||||
};
|
||||
|
||||
/** The page of a web view, for the client to load in its frame. */
|
||||
export type WebPage = {
|
||||
/**
|
||||
* `<plugin id>/<revision>/<entry>` under the `openidea-plugin` scheme. The
|
||||
* revision changes with the installed package, so a cached file is never
|
||||
* stale; relative URLs in the page resolve to the package's files.
|
||||
*/
|
||||
path: string,
|
||||
};
|
||||
|
||||
export type WebReference = {
|
||||
kind: WebReferenceKind,
|
||||
/** Without sigil: `card`, `main`, `--accent`. */
|
||||
@@ -6637,6 +6968,9 @@ export type WindowTab = {
|
||||
current: boolean,
|
||||
};
|
||||
|
||||
/** A workbench event delivered to plugins. */
|
||||
export type WorkbenchEvent = { kind: "fileOpened"; value: string } | { kind: "fileSaved"; value: string } | { kind: "editorFocused"; value: string | null } | { kind: "actionPerformed"; value: ActionPerformed } | { kind: "vcsChanged"; value: string } | { kind: "scheduled" };
|
||||
|
||||
export type WorkingTreeChange = {
|
||||
path: string,
|
||||
kind: ChangeKind,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Annotation,
|
||||
Compartment,
|
||||
Facet,
|
||||
type ChangeSpec,
|
||||
StateEffect,
|
||||
type EditorState,
|
||||
type Extension,
|
||||
@@ -585,12 +586,32 @@ export function isLibraryPath(path: string): boolean {
|
||||
*/
|
||||
export interface DocumentAttachment {
|
||||
path: string;
|
||||
/** The backend document (for document-scoped commands). */
|
||||
doc: number;
|
||||
/** Current document text; build the view's state from it. */
|
||||
text(): string;
|
||||
readOnly: boolean;
|
||||
/** Add to the view (one view at a time). */
|
||||
extension: Extension;
|
||||
save(options?: { exact?: boolean }): Promise<void>;
|
||||
/** Resolves once local edits have reached the backend. */
|
||||
synced(): Promise<void>;
|
||||
/** Unsaved changes. */
|
||||
dirty(): boolean;
|
||||
/**
|
||||
* Undo / redo of the document's history: the text editor tab's when one
|
||||
* shows the file (backend edits are recorded there), else this view's.
|
||||
* Returns whether anything changed.
|
||||
*/
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
/**
|
||||
* Applies a change made by this view's owner as one edit of the document's
|
||||
* history (where `undo` works): the text editor tab's when one shows the
|
||||
* file, so Undo there reverts it too; else this view's. The view receives
|
||||
* it like any other change of the document, before this returns.
|
||||
*/
|
||||
edit(changes: ChangeSpec, userEvent?: string): void;
|
||||
/** Detach; the document closes when nothing else shows it. */
|
||||
release(): void;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ import { _languageMode } from "@/editor/api";
|
||||
|
||||
let infos: Map<string, LanguageInfo> | null = null;
|
||||
let infosPromise: Promise<Map<string, LanguageInfo>> | null = null;
|
||||
let infosBackend: DocBackend | null = null;
|
||||
|
||||
export function loadLanguageInfos(backend: DocBackend): Promise<Map<string, LanguageInfo>> {
|
||||
infosBackend = backend;
|
||||
return (infosPromise ??= backend
|
||||
.languages()
|
||||
.then((list) => (infos = new Map(list.map((l) => [l.id, l]))))
|
||||
@@ -37,6 +39,15 @@ export function languageInfo(id: string): LanguageInfo | undefined {
|
||||
return infos?.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The language list changed (a plugin added or removed languages): fetch it
|
||||
* again. Open documents follow through their `docLanguage` events.
|
||||
*/
|
||||
export function refreshLanguageInfos(): Promise<unknown> {
|
||||
infosPromise = null;
|
||||
return infosBackend ? loadLanguageInfos(infosBackend) : Promise.resolve();
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
export function _resetLanguages() {
|
||||
infos = null;
|
||||
|
||||
@@ -110,4 +110,61 @@ describe("attached document views (editable diff side)", () => {
|
||||
att.release();
|
||||
tab.dispose();
|
||||
});
|
||||
|
||||
it("undoes backend edits in the document's history: the text tab's when one is open (custom editors)", async () => {
|
||||
const { backend, manager, frame } = setup({ "/p/c.csv": "a,b\n" });
|
||||
const att = await attachDocument(manager, "/p/c.csv");
|
||||
const view = new EditorView({ doc: att.text(), extensions: [att.extension, history()] });
|
||||
const model = manager.get("/p/c.csv")!;
|
||||
expect(att.doc).toBe(model.doc);
|
||||
|
||||
// No tab: the attachment holds the history.
|
||||
backend.patch(model.doc, [{ from: 0, to: 1, insert: "X" }]);
|
||||
expect(att.undo()).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("a,b\n");
|
||||
expect(att.redo()).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("X,b\n");
|
||||
await frame();
|
||||
await att.synced();
|
||||
expect(backend.docs.get(model.doc)?.text).toBe("X,b\n");
|
||||
|
||||
// With a text tab, backend edits are recorded there: undo from the custom editor reaches it.
|
||||
const tab = new EditorSlot(model, "t1", host);
|
||||
backend.patch(model.doc, [{ from: 2, to: 3, insert: "Y" }]);
|
||||
expect(tab.currentState().doc.toString()).toBe("X,Y\n");
|
||||
expect(att.undo()).toBe(true);
|
||||
expect(tab.currentState().doc.toString()).toBe("X,b\n");
|
||||
expect(view.state.doc.toString()).toBe("X,b\n");
|
||||
view.destroy();
|
||||
att.release();
|
||||
tab.dispose();
|
||||
});
|
||||
|
||||
it("records an attachment's own edits in the document's history: the text tab's when one is open", async () => {
|
||||
const { manager } = setup({ "/p/d.excalidraw": "{}" });
|
||||
const att = await attachDocument(manager, "/p/d.excalidraw");
|
||||
const view = new EditorView({ doc: att.text(), extensions: [att.extension, history()] });
|
||||
const model = manager.get("/p/d.excalidraw")!;
|
||||
|
||||
// No tab: the view's own history.
|
||||
att.edit({ from: 1, insert: "a" });
|
||||
expect(view.state.doc.toString()).toBe("{a}");
|
||||
expect(att.undo()).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("{}");
|
||||
|
||||
// With a text tab: the edit is the tab's, so Undo there reverts it, and the view follows.
|
||||
const tab = new EditorSlot(model, "t1", host);
|
||||
att.edit({ from: 1, insert: "b" }, "input.plugin");
|
||||
expect(tab.currentState().doc.toString()).toBe("{b}");
|
||||
expect(view.state.doc.toString()).toBe("{b}");
|
||||
// `undo` runs where the history is: the tab's (what ⌘Z in the text editor runs).
|
||||
expect(att.undo()).toBe(true);
|
||||
expect(tab.currentState().doc.toString()).toBe("{}");
|
||||
expect(view.state.doc.toString()).toBe("{}");
|
||||
// The view's own history never had it.
|
||||
expect(undo(view)).toBe(false);
|
||||
view.destroy();
|
||||
att.release();
|
||||
tab.dispose();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
* diff): local edits flow view → model → backend and other editors; edits
|
||||
* from elsewhere are mirrored into the view. See `attachDocument` in editor/api.
|
||||
*/
|
||||
import { Transaction, type Annotation, type ChangeSet } from "@codemirror/state";
|
||||
import { redo, undo } from "@codemirror/commands";
|
||||
import { Transaction, type Annotation, type ChangeSet, type StateCommand } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin } from "@codemirror/view";
|
||||
import {
|
||||
_addEditor,
|
||||
@@ -65,8 +66,24 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr
|
||||
};
|
||||
});
|
||||
|
||||
/** Runs a history command where the document's history is (see `DocumentAttachment.undo`). */
|
||||
const historyCommand = (command: StateCommand): boolean => {
|
||||
const slot = model.primarySlot();
|
||||
if (slot?.view) return command(slot.view);
|
||||
if (slot)
|
||||
return command({
|
||||
state: slot.state,
|
||||
dispatch: (tr) => {
|
||||
slot.state = tr.state;
|
||||
if (tr.docChanged) model.onLocal(tr.changes, slot);
|
||||
},
|
||||
});
|
||||
return target.view ? command(target.view) : false;
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
doc: model.doc,
|
||||
text: () => model.sync.localText.toString(),
|
||||
readOnly: model.getMeta().readOnly || model.virtual,
|
||||
extension: [
|
||||
@@ -81,6 +98,16 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr
|
||||
}),
|
||||
],
|
||||
save: (options) => model.save(options?.exact ? "exact" : "explicit"),
|
||||
synced: () => model.sync.whenIdle(),
|
||||
dirty: () => model.getMeta().dirty,
|
||||
undo: () => historyCommand(undo),
|
||||
redo: () => historyCommand(redo),
|
||||
edit: (changes, userEvent = "input") => {
|
||||
// Into the document's history, like a typed edit: the text tab's when there is one.
|
||||
const slot = model.primarySlot();
|
||||
if (slot) slot.dispatchLocal({ changes, userEvent });
|
||||
else target.view?.dispatch({ changes, userEvent });
|
||||
},
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
* shortcut text of every keymap binding, so the menu shows the JS keymap's
|
||||
* shortcuts without registering native accelerators (see src-tauri/src/menu.rs).
|
||||
*/
|
||||
import { executeAction, getAction, isActionEnabled, useActions } from "@/platform/actions";
|
||||
import { executeActionFrom, getAction, isActionEnabled, useActions } from "@/platform/actions";
|
||||
import { useContextKeys } from "@/platform/context";
|
||||
import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n";
|
||||
import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc";
|
||||
import { effectiveBindings, formatBinding, primaryKeyBinding, useKeymap } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useContributions } from "@/workbench/contributions";
|
||||
import { useContributions, useMenuSlotItems } from "@/workbench/contributions";
|
||||
import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { useToolWindowLayouts } from "@/workbench/toolwindows/layouts";
|
||||
import { hostOs, type HostOs } from "./host";
|
||||
@@ -30,7 +30,10 @@ export function isMenuRoute(v: unknown): v is MenuRoute {
|
||||
}
|
||||
|
||||
/** Runs the action a menu item stands for. Returns whether it ran. */
|
||||
export function routeMenuEvent(payload: unknown, run = executeAction): Promise<boolean> {
|
||||
export function routeMenuEvent(
|
||||
payload: unknown,
|
||||
run = (id: string, arg?: unknown) => executeActionFrom("menu", id, arg),
|
||||
): Promise<boolean> {
|
||||
if (!isMenuRoute(payload) || !payload.action) return Promise.resolve(false);
|
||||
return run(payload.action, payload.arg ?? undefined);
|
||||
}
|
||||
@@ -62,7 +65,15 @@ export function dynamicMenuItems(): Record<string, DynamicItem[]> {
|
||||
const layouts = useToolWindowLayouts
|
||||
.getState()
|
||||
.layouts.map((l) => ({ action: "layout.restore", arg: l.name, label: l.name }));
|
||||
return { toolWindows, toolWindowLayouts: layouts };
|
||||
const slots = Object.fromEntries(
|
||||
Object.entries(useMenuSlotItems.getState().slots).map(([slot, items]) => [
|
||||
slot,
|
||||
[...items]
|
||||
.map((i) => ({ action: i.action, arg: i.arg ?? null, label: tx(i.label) ?? i.label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
]),
|
||||
);
|
||||
return { ...slots, toolWindows, toolWindowLayouts: layouts };
|
||||
}
|
||||
|
||||
/** Action ids of every IDE item in a menu model. */
|
||||
@@ -150,6 +161,7 @@ export function installMenuBridge(): () => void {
|
||||
if (s.toolWindows !== prev.toolWindows) push();
|
||||
}),
|
||||
useToolWindowLayouts.subscribe(push),
|
||||
useMenuSlotItems.subscribe(push),
|
||||
useContextKeys.subscribe(() => pushEnablement()),
|
||||
useActions.subscribe(() => pushEnablement()),
|
||||
// Opening / closing a project changes the Open Recent list.
|
||||
|
||||
@@ -198,11 +198,13 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* In a narrow window the actions go below rather than over the name. */
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex: 1 1 160px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
@@ -222,6 +224,11 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
/* Badges are counter pills (`min-width: 16px`): in a narrow window they wrap instead of shrinking under their text. */
|
||||
.titleLine > * {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
color: var(--fg-strong);
|
||||
@@ -313,3 +320,67 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Permission dialog and notices */
|
||||
.consent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.consentWarning {
|
||||
margin: 0;
|
||||
color: var(--fg-warning, var(--fg));
|
||||
}
|
||||
|
||||
.consentRequired {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.consentNotice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settingsPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.settingRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.marketItem {
|
||||
list-style: none;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.marketBar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.marketBar > :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.marketOverview {
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { CatalogPlugin, Permission, PluginInfo, PluginStatus } from "@/bindings";
|
||||
import type { CapabilityInfo, CatalogPlugin, MarketPlugin, PluginInfo, PluginStatus } from "@/bindings";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Kbd,
|
||||
Input,
|
||||
SearchField,
|
||||
Spinner,
|
||||
Tabs,
|
||||
@@ -24,16 +24,21 @@ import {
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { pluginActionId } from "./bridge";
|
||||
import { RISK_LABEL, sortByRisk } from "./consent";
|
||||
import { itemsOf, uiPoint } from "./projection";
|
||||
import {
|
||||
compareVersions,
|
||||
grantPlugin,
|
||||
installFromDisk,
|
||||
installFromMarket,
|
||||
refreshMarket,
|
||||
rollBackPlugin,
|
||||
setMarketUrl,
|
||||
refreshCatalog,
|
||||
refreshPlugins,
|
||||
reloadPlugin,
|
||||
runPluginCommand,
|
||||
selectPluginTheme,
|
||||
setAllInstalledEnabled,
|
||||
setPluginCapability,
|
||||
setPluginEnabled,
|
||||
uninstallPlugin,
|
||||
usePlugins,
|
||||
@@ -41,19 +46,26 @@ import {
|
||||
import styles from "./PluginsPage.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" }> = {
|
||||
const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" | "warning" }> = {
|
||||
active: { label: "Running", tone: "success" },
|
||||
inactive: { label: "Enabled", tone: "accent" },
|
||||
static: { label: "Enabled", tone: "accent" },
|
||||
disabled: { label: "Disabled", tone: "neutral" },
|
||||
needsConsent: { label: "Needs permission", tone: "warning" },
|
||||
failed: { label: "Failed", tone: "danger" },
|
||||
};
|
||||
|
||||
export const PERMISSION_TEXT: Record<Permission, string> = {
|
||||
"project-read": "Read files inside the open project",
|
||||
"editor-read": "See which file is open in the editor",
|
||||
"editor-write": "Insert text into the active editor",
|
||||
/** Human names of the built-in contribution points (others show their id). */
|
||||
const POINT_TITLES: Record<string, string> = {
|
||||
commands: "Commands",
|
||||
themes: "Themes",
|
||||
iconThemes: "File Icon Themes",
|
||||
languages: "Languages",
|
||||
providers: "Editor Providers",
|
||||
};
|
||||
|
||||
const pointTitle = (point: string) => uiPoint(point)?.title ?? POINT_TITLES[point] ?? point;
|
||||
|
||||
export const NO_REPOSITORY = "There is no online plugin repository";
|
||||
|
||||
type Tab = "marketplace" | "installed";
|
||||
@@ -65,16 +77,14 @@ export function filterPlugins(plugins: readonly PluginInfo[], query: string): Pl
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [...plugins];
|
||||
return plugins.filter((p) =>
|
||||
matches([p.name, p.id, p.description, ...p.authors, ...p.commands.map((c) => c.title)], q),
|
||||
matches([p.name, p.id, p.description, ...p.authors, ...p.contributions.map((c) => c.label)], q),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterCatalog(catalog: readonly CatalogPlugin[], query: string): CatalogPlugin[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [...catalog];
|
||||
return catalog.filter((p) =>
|
||||
matches([p.name, p.id, p.description, ...p.authors, ...p.commands.map((c) => c.title)], q),
|
||||
);
|
||||
return catalog.filter((p) => matches([p.name, p.id, p.description, ...p.authors], q));
|
||||
}
|
||||
|
||||
/** What the Available tab offers for a catalog package given what is installed. */
|
||||
@@ -84,7 +94,7 @@ export function catalogState(
|
||||
): "install" | "update" | "installed" | "dev" {
|
||||
const have = installed.find((p) => p.id === entry.id);
|
||||
if (!have) return "install";
|
||||
if (have.source === "dev") return "dev";
|
||||
if (have.source !== "installed") return "dev";
|
||||
return compareVersions(entry.version, have.version) > 0 ? "update" : "installed";
|
||||
}
|
||||
|
||||
@@ -124,16 +134,19 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
|
||||
<span className={styles.rowMeta}>
|
||||
<span className={styles.rowVendor}>{vendorOf(plugin.authors) || plugin.id}</span>
|
||||
<span>{plugin.version}</span>
|
||||
{plugin.source === "dev" && (
|
||||
<span className={styles.devTag}>
|
||||
<T>dev</T>
|
||||
</span>
|
||||
{plugin.source !== "installed" && (
|
||||
<span className={styles.devTag}>{plugin.source === "linked" ? tx("linked") : tx("dev")}</span>
|
||||
)}
|
||||
{plugin.status === "failed" && (
|
||||
<Badge dot tone="danger">
|
||||
<T>Failed</T>
|
||||
</Badge>
|
||||
)}
|
||||
{plugin.status === "needsConsent" && (
|
||||
<Badge dot tone="warning">
|
||||
<T>Needs permission</T>
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -234,12 +247,73 @@ function DetailTabs({ notes, overview }: { notes: string | null; overview: React
|
||||
);
|
||||
}
|
||||
|
||||
/** Contribution points of a plugin, in the order they were committed. */
|
||||
function pointsOf(plugin: PluginInfo): string[] {
|
||||
return [...new Set(plugin.contributions.map((c) => c.point))];
|
||||
}
|
||||
|
||||
function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) {
|
||||
const ui = usePlugins((s) => s.ui);
|
||||
const Details = uiPoint(point)?.Details;
|
||||
const items = itemsOf(ui, point, plugin.id);
|
||||
return (
|
||||
<Section title={pointTitle(point)}>
|
||||
{Details && items.length > 0 ? (
|
||||
<Details plugin={plugin} items={items as never} />
|
||||
) : (
|
||||
<ul className={styles.items}>
|
||||
{plugin.contributions
|
||||
.filter((c) => c.point === point)
|
||||
.map((c, i) => (
|
||||
<li key={`${c.label}-${i}`} className={styles.item}>
|
||||
<span className={styles.itemMain}>{c.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityRow({ cap, children }: { cap: CapabilityInfo; children?: ReactNode }) {
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
{children}
|
||||
<span className={styles.itemMain}>{cap.description}</span>
|
||||
<span className={styles.mono}>{cap.id}</span>
|
||||
<Badge tone={RISK_LABEL[cap.risk].tone}>{RISK_LABEL[cap.risk].label}</Badge>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) {
|
||||
if (plugin.capabilities.length === 0) {
|
||||
return (
|
||||
<div className={styles.muted}>
|
||||
<T>No special permissions.</T>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const confirmed = plugin.status !== "needsConsent";
|
||||
return (
|
||||
<ul className={styles.items} aria-label={tx("Permissions")}>
|
||||
{sortByRisk(plugin.capabilities).map((c) => (
|
||||
<CapabilityRow key={c.id} cap={c}>
|
||||
<Checkbox
|
||||
checked={c.granted}
|
||||
disabled={busy || !confirmed}
|
||||
onChange={(on) => void setPluginCapability(plugin.id, c.id, on)}
|
||||
aria-label={c.granted ? tx`Revoke ${c.id}` : tx`Allow ${c.id}`}
|
||||
/>
|
||||
</CapabilityRow>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
const busy = usePlugins((s) => !!s.busy[plugin.id]);
|
||||
const activeTheme = usePlugins((s) => s.activeTheme);
|
||||
const status = STATUS[plugin.status];
|
||||
const running = plugin.status === "active";
|
||||
const live = running || plugin.status === "static";
|
||||
const [confirmUninstall, setConfirmUninstall] = useState(false);
|
||||
|
||||
const overview = (
|
||||
@@ -276,95 +350,41 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{plugin.hasCode && (
|
||||
<Section title="Permissions">
|
||||
{plugin.permissions.length === 0 ? (
|
||||
<div className={styles.muted}>
|
||||
<T>No special permissions.</T>
|
||||
</div>
|
||||
) : (
|
||||
<ul className={styles.items}>
|
||||
{plugin.permissions.map((p) => (
|
||||
<li key={p} className={styles.item}>
|
||||
<span className={styles.itemMain}>{PERMISSION_TEXT[p] ?? p}</span>
|
||||
<span className={styles.mono}>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
{plugin.status === "needsConsent" && (
|
||||
<div className={styles.consentNotice} role="note">
|
||||
<span>
|
||||
{plugin.source === "installed"
|
||||
? tx("This version asks for new permissions. It stays off until you allow them.")
|
||||
: tx("Plugins from a project folder run only after you allow them.")}
|
||||
</span>
|
||||
<Button size="small" variant="primary" disabled={busy} onClick={() => void grantPlugin(plugin.id)}>
|
||||
<T>Review Permissions…</T>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plugin.commands.length > 0 && (
|
||||
<Section title="Commands">
|
||||
<ul className={styles.items}>
|
||||
{plugin.commands.map((c) => {
|
||||
const actionId = pluginActionId(plugin.id, c.id);
|
||||
return (
|
||||
<li key={c.id} className={styles.item}>
|
||||
<span className={styles.itemMain}>{c.title}</span>
|
||||
<Kbd action={actionId} />
|
||||
<span className={styles.mono} title={tx("Action id (bind it in Keymap)")}>
|
||||
{actionId}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
icon="run"
|
||||
disabled={!running}
|
||||
onClick={() => void runPluginCommand(plugin.id, c.id)}
|
||||
aria-label={tx`Run ${c.title}`}
|
||||
>
|
||||
<T>Run</T>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{plugin.warnings.map((w) => (
|
||||
<div key={w} className={styles.muted}>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{plugin.themes.length > 0 && (
|
||||
<Section title="Themes">
|
||||
<ul className={styles.items}>
|
||||
{plugin.themes.map((t) => {
|
||||
const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id;
|
||||
return (
|
||||
<li key={t.id} className={styles.item}>
|
||||
<span className={styles.itemMain}>{t.name}</span>
|
||||
<span className={styles.muted}>{t.kind === "dark" ? tx("Dark") : tx("Light")}</span>
|
||||
{isActive ? (
|
||||
<Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}>
|
||||
<T>Use Default Theme</T>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
disabled={!live}
|
||||
onClick={() => void selectPluginTheme(plugin.id, t.id)}
|
||||
aria-label={tx`Apply theme ${t.name}`}
|
||||
>
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
<Section title="Permissions">
|
||||
<CapabilityList plugin={plugin} busy={busy} />
|
||||
</Section>
|
||||
|
||||
{plugin.languages.length > 0 && (
|
||||
<Section title="Languages">
|
||||
<ul className={styles.items}>
|
||||
{plugin.languages.map((l) => (
|
||||
<li key={l.id} className={styles.item}>
|
||||
<span className={styles.itemMain}>{l.name}</span>
|
||||
<span className={styles.mono}>{l.extensions.map((e) => `.${e}`).join(" ") || l.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{pointsOf(plugin).map((point) => (
|
||||
<PointSection key={point} plugin={plugin} point={point} />
|
||||
))}
|
||||
|
||||
{plugin.hasCode && plugin.stats.activations > 0 && (
|
||||
<Section title="Statistics">
|
||||
<div className={styles.muted}>
|
||||
{tx`Activated ${plugin.stats.activations}× (last in ${(plugin.stats.activationMs ?? 0).toFixed(1)} ms), ${plugin.stats.calls} calls, ${plugin.stats.errors} errors, slowest ${(plugin.stats.maxCallMs ?? 0).toFixed(1)} ms`}
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{tx`Recent calls p95 ${(plugin.stats.p95CallMs ?? 0).toFixed(1)} ms; ${plugin.stats.crashes} crashes, ${plugin.stats.slowCalls} editor results too late, reclaimed ${plugin.stats.reclaims}× when idle`}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
@@ -380,11 +400,15 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
<div className={styles.titleLine}>
|
||||
<span className={styles.version}>{plugin.version}</span>
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
{plugin.source === "dev" && (
|
||||
<Badge tone="warning">
|
||||
<T>Dev plugin</T>
|
||||
</Badge>
|
||||
{plugin.source !== "installed" && (
|
||||
<Badge tone="warning">{plugin.source === "linked" ? tx("Linked plugin") : tx("Project plugin")}</Badge>
|
||||
)}
|
||||
{plugin.source === "installed" &&
|
||||
(plugin.publisher ? (
|
||||
<Badge tone="success">{tx`Signed by ${plugin.publisher}`}</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{tx("Unsigned")}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
@@ -404,6 +428,11 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
disabled={busy}
|
||||
onClick={() => void reloadPlugin(plugin.id)}
|
||||
/>
|
||||
{plugin.previousVersion && (
|
||||
<Button size="small" disabled={busy} onClick={() => void rollBackPlugin(plugin.id)}>
|
||||
{tx`Roll Back to ${plugin.previousVersion}`}
|
||||
</Button>
|
||||
)}
|
||||
{plugin.source === "installed" &&
|
||||
(confirmUninstall ? (
|
||||
<>
|
||||
@@ -433,11 +462,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
|
||||
}
|
||||
|
||||
function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
|
||||
const contributes = [
|
||||
entry.commands.length && `${entry.commands.length} ${entry.commands.length === 1 ? "command" : "commands"}`,
|
||||
entry.themeCount && `${entry.themeCount} ${entry.themeCount === 1 ? "theme" : "themes"}`,
|
||||
entry.languageCount && `${entry.languageCount} ${entry.languageCount === 1 ? "language" : "languages"}`,
|
||||
].filter(Boolean);
|
||||
const contributes = entry.contributions.map((c) => `${pointTitle(c.point)}: ${c.count}`);
|
||||
const overview = (
|
||||
<>
|
||||
{entry.problem && (
|
||||
@@ -470,14 +495,11 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{entry.permissions.length > 0 && (
|
||||
{entry.capabilities.length > 0 && (
|
||||
<Section title="Permissions">
|
||||
<ul className={styles.items}>
|
||||
{entry.permissions.map((p) => (
|
||||
<li key={p} className={styles.item}>
|
||||
<span className={styles.itemMain}>{PERMISSION_TEXT[p] ?? p}</span>
|
||||
<span className={styles.mono}>{p}</span>
|
||||
</li>
|
||||
{sortByRisk(entry.capabilities).map((c) => (
|
||||
<CapabilityRow key={c.id} cap={c} />
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
@@ -504,10 +526,11 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
|
||||
);
|
||||
}
|
||||
|
||||
function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem[] {
|
||||
function gearMenu(plugins: readonly PluginInfo[], install: (packageFile: boolean) => void): MenuItem[] {
|
||||
const installed = plugins.filter((p) => p.source === "installed");
|
||||
return [
|
||||
{ label: "Install Plugin from Disk…", icon: "package", run: install },
|
||||
{ label: "Install Plugin from Disk…", icon: "package", run: () => install(false) },
|
||||
{ label: "Install Plugin from Package File…", run: () => install(true) },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
label: "Disable All Downloaded Plugins",
|
||||
@@ -526,13 +549,127 @@ function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem
|
||||
run: () => {
|
||||
void refreshPlugins();
|
||||
void refreshCatalog();
|
||||
void refreshMarket(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The marketplace index URL, editable in place. */
|
||||
function MarketUrlBar() {
|
||||
const current = usePlugins((s) => s.marketUrl);
|
||||
const [value, setValue] = useState(current ?? "");
|
||||
useEffect(() => setValue(current ?? ""), [current]);
|
||||
return (
|
||||
<form
|
||||
className={styles.marketBar}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void setMarketUrl(value);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Marketplace index URL (https://…/index.json or a folder path)"
|
||||
aria-label="Marketplace index URL"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
<IconButton icon="refresh" title="Refresh Marketplace" type="submit" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) {
|
||||
const update = entry.installed && compareVersions(entry.version, entry.installed) > 0;
|
||||
return (
|
||||
<li className={clsx(styles.row, selected && styles.rowSelected)}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.rowMain}
|
||||
aria-current={selected || undefined}
|
||||
onClick={onSelect}
|
||||
title={entry.description || entry.name}
|
||||
>
|
||||
<PluginIcon />
|
||||
<span className={styles.rowText}>
|
||||
<span className={styles.rowName}>{entry.name}</span>
|
||||
<span className={styles.rowMeta}>
|
||||
<span className={styles.rowVendor}>{entry.publisher}</span>
|
||||
<span>{entry.version}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{update ? (
|
||||
<Badge tone="accent">{tx("Update")}</Badge>
|
||||
) : entry.installed ? (
|
||||
<Badge tone="neutral">{tx("Installed")}</Badge>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalled: (id: string) => void }) {
|
||||
const installing = usePlugins((s) => s.installing);
|
||||
const update = entry.installed && compareVersions(entry.version, entry.installed) > 0;
|
||||
const install = async () => {
|
||||
const id = await installFromMarket(entry.id);
|
||||
if (id) onInstalled(id);
|
||||
};
|
||||
return (
|
||||
<div className={styles.details} aria-label={`${entry.name} details`}>
|
||||
<header className={styles.header}>
|
||||
<PluginIcon large />
|
||||
<div className={styles.headerText}>
|
||||
<h2 className={styles.title}>{entry.name}</h2>
|
||||
<div className={styles.authors}>{entry.authors?.length ? vendorOf(entry.authors) : entry.publisher}</div>
|
||||
<div className={styles.titleLine}>
|
||||
<span className={styles.version}>{entry.version}</span>
|
||||
<Badge tone="success">{tx`Signed by ${entry.publisher}`}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
{(!entry.installed || update) && (
|
||||
<Button size="small" variant="primary" disabled={installing} onClick={() => void install()}>
|
||||
{update ? tx`Update to ${entry.version}` : tx("Install")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className={styles.marketOverview}>
|
||||
{entry.description && <p className={styles.description}>{entry.description}</p>}
|
||||
<dl className={styles.facts}>
|
||||
<dt>
|
||||
<T>Id</T>
|
||||
</dt>
|
||||
<dd className={styles.mono}>{entry.id}</dd>
|
||||
{entry.installed && (
|
||||
<>
|
||||
<dt>
|
||||
<T>Installed</T>
|
||||
</dt>
|
||||
<dd>{entry.installed}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{(entry.capabilities?.length ?? 0) > 0 && (
|
||||
<Section title="Permissions">
|
||||
<ul className={styles.items}>
|
||||
{(entry.capabilities ?? []).map((c) => (
|
||||
<li key={c} className={clsx(styles.item, styles.mono)}>
|
||||
{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginsPage() {
|
||||
const { plugins, loaded, loading, error, installing, catalog, catalogError } = usePlugins();
|
||||
const { plugins, loaded, loading, error, installing, catalog, catalogError, market, marketUrl, marketError } =
|
||||
usePlugins();
|
||||
const [tab, setTab] = useState<Tab>("installed");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -541,18 +678,30 @@ export function PluginsPage() {
|
||||
useEffect(() => {
|
||||
void refreshPlugins();
|
||||
void refreshCatalog();
|
||||
void refreshMarket(false);
|
||||
}, []);
|
||||
const [selectedMarketId, setSelectedMarketId] = useState<string | null>(null);
|
||||
const marketShown = useMemo(
|
||||
() =>
|
||||
(market ?? []).filter((m) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return !q || matches([m.name, m.id, m.description, m.publisher, ...(m.authors ?? [])], q);
|
||||
}),
|
||||
[market, query],
|
||||
);
|
||||
const selectedMarket = marketShown.find((m) => m.id === selectedMarketId) ?? null;
|
||||
|
||||
const shown = useMemo(() => filterPlugins(plugins, query), [plugins, query]);
|
||||
const downloaded = shown.filter((p) => p.source === "installed");
|
||||
const dev = shown.filter((p) => p.source === "dev");
|
||||
const linked = shown.filter((p) => p.source === "linked");
|
||||
const selected = shown.find((p) => p.id === selectedId) ?? shown[0] ?? null;
|
||||
|
||||
const catalogShown = useMemo(() => filterCatalog(catalog ?? [], query), [catalog, query]);
|
||||
const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null;
|
||||
|
||||
const install = async () => {
|
||||
const id = await installFromDisk();
|
||||
const install = async (packageFile = false) => {
|
||||
const id = await installFromDisk(undefined, packageFile);
|
||||
if (id) {
|
||||
setTab("installed");
|
||||
setSelectedId(id);
|
||||
@@ -563,7 +712,7 @@ export function PluginsPage() {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu(
|
||||
{ x: r.left, y: r.bottom + 2 },
|
||||
gearMenu(plugins, () => void install()),
|
||||
gearMenu(plugins, (packageFile) => void install(packageFile)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -585,6 +734,11 @@ export function PluginsPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<span title={tx("The plugin system is a preview: its API and the plugins may still change.")}>
|
||||
<Badge tone="accent">
|
||||
<T>Preview</T>
|
||||
</Badge>
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
{(loading || installing) && <Spinner size={14} label={installing ? "Installing plugin" : "Loading plugins"} />}
|
||||
<IconButton icon="settings" title="Manage Plugins" aria-haspopup="menu" onClick={openGear} />
|
||||
@@ -616,6 +770,15 @@ export function PluginsPage() {
|
||||
onSelect={() => setSelectedId(p.id)}
|
||||
/>
|
||||
))}
|
||||
{linked.length > 0 && <ListHeader title="Linked for development" count={linked.length} />}
|
||||
{linked.map((p) => (
|
||||
<InstalledRow
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
selected={p.id === selected?.id}
|
||||
onSelect={() => setSelectedId(p.id)}
|
||||
/>
|
||||
))}
|
||||
{dev.length > 0 && <ListHeader title="Project (.openidea/plugins)" count={dev.length} />}
|
||||
{dev.map((p) => (
|
||||
<InstalledRow
|
||||
@@ -633,17 +796,35 @@ export function PluginsPage() {
|
||||
</ul>
|
||||
) : (
|
||||
<ul className={styles.list} aria-label={tx("Available plugins")}>
|
||||
<li className={styles.notice} role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span>
|
||||
<li className={styles.marketItem}>
|
||||
<MarketUrlBar />
|
||||
</li>
|
||||
{!marketUrl && (
|
||||
<li className={styles.notice} role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span>
|
||||
</li>
|
||||
)}
|
||||
{marketError && <li className={clsx(styles.listEmpty, styles.errorText)}>{marketError}</li>}
|
||||
{marketShown.length > 0 && <ListHeader title="Marketplace" count={marketShown.length} />}
|
||||
{marketShown.map((m) => (
|
||||
<MarketRow
|
||||
key={`market:${m.id}`}
|
||||
entry={m}
|
||||
selected={m.id === selectedMarket?.id}
|
||||
onSelect={() => setSelectedMarketId(m.id)}
|
||||
/>
|
||||
))}
|
||||
{catalogShown.length > 0 && <ListHeader title="Local" count={catalogShown.length} />}
|
||||
{catalogShown.map((c) => (
|
||||
<CatalogRow
|
||||
key={c.id}
|
||||
entry={c}
|
||||
selected={c.id === selectedEntry?.id}
|
||||
onSelect={() => setSelectedCatalogId(c.id)}
|
||||
onSelect={() => {
|
||||
setSelectedMarketId(null);
|
||||
setSelectedCatalogId(c.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{catalog && catalogShown.length === 0 && (
|
||||
@@ -666,6 +847,15 @@ export function PluginsPage() {
|
||||
<T>inside your project.</T>
|
||||
</EmptyState>
|
||||
)
|
||||
) : selectedMarket ? (
|
||||
<MarketDetails
|
||||
key={`market:${selectedMarket.id}`}
|
||||
entry={selectedMarket}
|
||||
onInstalled={(id) => {
|
||||
setTab("installed");
|
||||
setSelectedId(id);
|
||||
}}
|
||||
/>
|
||||
) : selectedEntry ? (
|
||||
<CatalogDetails key={selectedEntry.id} entry={selectedEntry} />
|
||||
) : (
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { PluginInfo } from "@/bindings";
|
||||
import { _setActiveEditor } from "@/editor/api";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
|
||||
import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme";
|
||||
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
|
||||
import {
|
||||
applyPluginTheme,
|
||||
applyTextToActiveEditor,
|
||||
followThemeSetting,
|
||||
PLUGIN_THEME_STYLE_ID,
|
||||
handlePluginEvent,
|
||||
pluginActionId,
|
||||
pluginActions,
|
||||
startPluginBridge,
|
||||
syncProjection,
|
||||
} from "../bridge";
|
||||
import { iconResolver } from "../iconThemes";
|
||||
import { markerIcon, placeMarkers } from "../lineMarkerGutter";
|
||||
import { SecretsDetails } from "../secrets";
|
||||
import { usePluginStatusItems } from "../runtimeUi";
|
||||
import { useTasks } from "@/workbench/tasks";
|
||||
import { defineBuiltinUiPoints } from "../points";
|
||||
import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection";
|
||||
import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage";
|
||||
import {
|
||||
compareVersions,
|
||||
installFromDisk,
|
||||
refreshPlugins,
|
||||
runPluginCommand,
|
||||
selectPluginTheme,
|
||||
setAllInstalledEnabled,
|
||||
setConsentPrompt,
|
||||
setPluginEnabled,
|
||||
usePlugins,
|
||||
type ConsentRequest,
|
||||
} from "../store";
|
||||
|
||||
type Note = Parameters<NotificationService["notify"]>[0];
|
||||
@@ -48,10 +59,23 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginIn
|
||||
status: "active",
|
||||
error: null,
|
||||
hasCode: true,
|
||||
permissions: [],
|
||||
commands: [],
|
||||
languages: [],
|
||||
themes: [],
|
||||
capabilities: [],
|
||||
contributions: [],
|
||||
activation: [],
|
||||
warnings: [],
|
||||
stats: {
|
||||
activations: 0,
|
||||
activationMs: 0,
|
||||
calls: 0,
|
||||
errors: 0,
|
||||
maxCallMs: 0,
|
||||
p95CallMs: 0,
|
||||
slowCalls: 0,
|
||||
crashes: 0,
|
||||
reclaims: 0,
|
||||
},
|
||||
publisher: null,
|
||||
previousVersion: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
@@ -63,13 +87,17 @@ function editor(doc: string, anchor: number, head = anchor, readOnly = false) {
|
||||
return view;
|
||||
}
|
||||
|
||||
let undefinePoints: () => void = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
undefinePoints = defineBuiltinUiPoints();
|
||||
notes = [];
|
||||
provideNotificationService({ notify: (n) => void notes.push(n) });
|
||||
backend = createMemoryPluginsBackend();
|
||||
setPluginsBackend(backend);
|
||||
usePlugins.setState({
|
||||
plugins: [],
|
||||
ui: [],
|
||||
activeTheme: null,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
@@ -82,6 +110,9 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearUiContributions();
|
||||
undefinePoints();
|
||||
setConsentPrompt(null);
|
||||
setPluginsBackend(null);
|
||||
_setActiveEditor(null);
|
||||
view?.destroy();
|
||||
@@ -90,31 +121,124 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("plugin actions", () => {
|
||||
it("maps commands of running plugins to plugin.<plugin>.<command> actions", () => {
|
||||
const actions = pluginActions([
|
||||
plugin({ id: "a", name: "Alpha", commands: [{ id: "go", title: "Alpha: Go", category: null }] }),
|
||||
plugin({ id: "b", status: "disabled", commands: [{ id: "x", title: "X", category: "Cat" }] }),
|
||||
plugin({ id: "c", status: "failed", commands: [{ id: "y", title: "Y", category: null }] }),
|
||||
]);
|
||||
expect(actions.map((a) => a.id)).toEqual(["plugin.a.go"]);
|
||||
expect(actions[0]!.category).toBe("Alpha");
|
||||
it("projects command contributions to plugin.<plugin>.<command> actions", () => {
|
||||
const cmd = (pluginId: string, id: string, extra: Record<string, unknown> = {}) => ({
|
||||
pluginId,
|
||||
point: "commands",
|
||||
key: id,
|
||||
decl: { id, title: `T ${id}`, ...extra },
|
||||
});
|
||||
syncUiContributions(
|
||||
[cmd("a", "go"), cmd("a", "when", { when: "editorFocus", category: "Cat" }), cmd("gone", "x")],
|
||||
[plugin({ id: "a", name: "Alpha" })],
|
||||
);
|
||||
expect(getAction("plugin.a.go")).toMatchObject({ title: "T go", category: "Alpha" });
|
||||
expect(getAction("plugin.a.when")).toMatchObject({ when: "editorFocus", category: "Cat" });
|
||||
expect(getAction("plugin.gone.x")).toBeUndefined();
|
||||
expect(pluginActionId("p", "c.d")).toBe("plugin.p.c.d");
|
||||
// Changed declarations are re-projected; removed ones disposed.
|
||||
syncUiContributions([cmd("a", "go", { title: "Renamed" })], [plugin({ id: "a", name: "Alpha" })]);
|
||||
expect(getAction("plugin.a.go")?.title).toBe("Renamed");
|
||||
expect(getAction("plugin.a.when")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isolates a failing projection (a defect) from the other items", () => {
|
||||
const errors: unknown[] = [];
|
||||
const original = console.error;
|
||||
console.error = (...args: unknown[]) => void errors.push(args);
|
||||
const live: string[] = [];
|
||||
const undefine = defineUiPoint<{ n: number }>("test.items", {
|
||||
project: ({ decl }) => {
|
||||
if (decl.n === 2) throw new Error("bug");
|
||||
live.push(String(decl.n));
|
||||
return () => live.splice(live.indexOf(String(decl.n)), 1);
|
||||
},
|
||||
});
|
||||
const item = (n: number) => ({ pluginId: "a", point: "test.items", key: String(n), decl: { n } });
|
||||
syncUiContributions([item(1), item(2), item(3)], [plugin({ id: "a" })]);
|
||||
expect(live).toEqual(["1", "3"]);
|
||||
expect(errors).toHaveLength(1);
|
||||
undefine();
|
||||
expect(live).toEqual([]);
|
||||
console.error = original;
|
||||
});
|
||||
|
||||
it("keeps the registry in sync and runs commands through the backend", async () => {
|
||||
// The bridge defines the points itself (main window).
|
||||
undefinePoints();
|
||||
undefinePoints = () => {};
|
||||
const stop = startPluginBridge();
|
||||
await refreshPlugins();
|
||||
expect(getAction("plugin.hello.insert-date")?.title).toBe("Hello: Insert Date");
|
||||
expect(getAction("plugin.hello.word-count")).toBeDefined();
|
||||
|
||||
const v = editor("ab", 1);
|
||||
// Edits come back as document patches; here the command's result shows it ran.
|
||||
const ran: unknown[] = [];
|
||||
const run = backend.runCommand;
|
||||
backend.runCommand = async (...args) => {
|
||||
const out = await run(...args);
|
||||
ran.push(out);
|
||||
return out;
|
||||
};
|
||||
editor("ab", 1);
|
||||
await act(() => executeAction("plugin.hello.insert-date"));
|
||||
expect(v.state.doc.toString()).toMatch(/^a\d{4}-\d{2}-\d{2}b$/);
|
||||
expect(ran).toEqual([expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)]);
|
||||
|
||||
await setPluginEnabled("hello", false);
|
||||
expect(getAction("plugin.hello.insert-date")).toBeUndefined();
|
||||
stop();
|
||||
});
|
||||
|
||||
it("reports actions to on-event:action-performed plugins once the project opens and when plugins change", async () => {
|
||||
// Like `authorize_command`: refused until the window's project is open and trusted.
|
||||
let kinds: Awaited<ReturnType<PluginsBackend["eventKinds"]>> = [];
|
||||
const posted: unknown[] = [];
|
||||
setPluginsBackend({
|
||||
...backend,
|
||||
eventKinds: async () => {
|
||||
if (!useProject.getState().project?.trusted) throw new Error("no project is open in this window");
|
||||
return kinds;
|
||||
},
|
||||
postEvents: async (events) => void posted.push(...events),
|
||||
});
|
||||
const offAction = registerAction({ id: "test.columnMode", title: "Column Mode", run: () => {} });
|
||||
const performed = (n: number) =>
|
||||
Array.from({ length: n }, () => ({
|
||||
kind: "actionPerformed",
|
||||
value: { action: "test.columnMode", title: "Column Mode", source: "menu", shortcut: null },
|
||||
}));
|
||||
undefinePoints();
|
||||
undefinePoints = () => {};
|
||||
kinds = ["action-performed"];
|
||||
const stop = startPluginBridge();
|
||||
try {
|
||||
// Asked at startup, before the project is open: refused.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await executeActionFrom("menu", "test.columnMode");
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
expect(posted).toEqual([]);
|
||||
|
||||
useProject.setState({ project: { root: "/p", trusted: true } as never });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await executeActionFrom("menu", "test.columnMode");
|
||||
await waitFor(() => expect(posted).toEqual(performed(1)));
|
||||
|
||||
// Declared kinds change with the installed plugins (`commandsChanged`).
|
||||
kinds = [];
|
||||
handlePluginEvent({ type: "commandsChanged" });
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await executeActionFrom("menu", "test.columnMode");
|
||||
kinds = ["action-performed"];
|
||||
handlePluginEvent({ type: "commandsChanged" });
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await executeActionFrom("menu", "test.columnMode");
|
||||
await waitFor(() => expect(posted).toEqual(performed(2)));
|
||||
} finally {
|
||||
stop();
|
||||
offAction();
|
||||
useProject.setState({ project: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("store", () => {
|
||||
@@ -150,9 +274,73 @@ describe("store", () => {
|
||||
expect(usePlugins.getState().activeTheme).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by name, id, author and command title", () => {
|
||||
it("applies the plugin theme the Appearance setting selects, and clears it for a built-in one", async () => {
|
||||
const stop = [syncProjection(), followThemeSetting()];
|
||||
try {
|
||||
useProject.setState({ settings: { appearance: { theme: "plugin:theme-harbor-night/harbor-night" } } as never });
|
||||
await refreshPlugins();
|
||||
await waitFor(() => expect(usePlugins.getState().activeTheme?.theme.id).toBe("harbor-night"));
|
||||
// A built-in theme picked anywhere (here the session override) wins over it.
|
||||
act(() => useThemeOverride.setState({ theme: "solarized-dark" }));
|
||||
await waitFor(() => expect(usePlugins.getState().activeTheme).toBeNull());
|
||||
act(() => useThemeOverride.setState({ theme: null }));
|
||||
await waitFor(() => expect(usePlugins.getState().activeTheme?.theme.id).toBe("harbor-night"));
|
||||
} finally {
|
||||
stop.forEach((s) => s());
|
||||
useThemeOverride.setState({ theme: null });
|
||||
useProject.setState({ settings: null as never });
|
||||
}
|
||||
});
|
||||
|
||||
it("installs from a folder, its plugin.toml, or a package file unpacked first", async () => {
|
||||
setConsentPrompt(async () => ({ optional: [] }));
|
||||
for (const [picked, unpacked] of [
|
||||
["plugins/hello", null],
|
||||
["plugins/hello/plugin.toml", null],
|
||||
["C:\\plugins\\hello\\Plugin.TOML", null],
|
||||
["/downloads/hello.zip", "/downloads/hello.zip"],
|
||||
["/downloads/hello-1.0.0.oiplugin", "/downloads/hello-1.0.0.oiplugin"],
|
||||
] as const) {
|
||||
const unpack = vi.fn(async () => "plugins/hello");
|
||||
const preview = vi.fn();
|
||||
const backend = createMemoryPluginsBackend([], sampleCatalog());
|
||||
setPluginsBackend({
|
||||
...backend,
|
||||
unpackPackage: unpack,
|
||||
previewInstall: (path) => {
|
||||
preview(path);
|
||||
return backend.previewInstall("plugins/hello");
|
||||
},
|
||||
installDir: (_path, consent) => backend.installDir("plugins/hello", consent),
|
||||
});
|
||||
expect(await installFromDisk(picked)).toBe("hello");
|
||||
expect(unpack.mock.calls).toEqual(unpacked ? [[unpacked]] : []);
|
||||
const folder = unpacked ? "plugins/hello" : picked.replace(/[\\/]plugin\.toml$/i, "");
|
||||
expect(preview).toHaveBeenCalledWith(folder);
|
||||
usePlugins.setState({ plugins: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for consent before installing and passes the answer on", async () => {
|
||||
const asked: ConsentRequest[] = [];
|
||||
setConsentPrompt(async (req) => {
|
||||
asked.push(req);
|
||||
return { optional: [] };
|
||||
});
|
||||
setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog()));
|
||||
expect(await installFromDisk("plugins/hello")).toBe("hello");
|
||||
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install", unsigned: true });
|
||||
expect(asked[0]!.capabilities.map((c) => c.id)).toContain("editor.write");
|
||||
|
||||
setConsentPrompt(async () => null);
|
||||
setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog()));
|
||||
expect(await installFromDisk("plugins/hello")).toBeNull();
|
||||
expect(usePlugins.getState().plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters by name, id, author and contribution", () => {
|
||||
const list = [
|
||||
plugin({ id: "a", name: "Alpha", commands: [{ id: "c", title: "Count Words", category: null }] }),
|
||||
plugin({ id: "a", name: "Alpha", contributions: [{ point: "commands", label: "Count Words" }] }),
|
||||
plugin({ id: "b", name: "Beta", authors: ["Zed"] }),
|
||||
];
|
||||
expect(filterPlugins(list, "count").map((p) => p.id)).toEqual(["a"]);
|
||||
@@ -199,22 +387,44 @@ describe("bridge events", () => {
|
||||
useThemeOverride.setState({ theme: null });
|
||||
});
|
||||
|
||||
it("inserts at the cursor and replaces selections", () => {
|
||||
const v = editor("hello world", 6, 11);
|
||||
handlePluginEvent({ type: "replaceSelection", pluginId: "p", text: "there" });
|
||||
expect(v.state.doc.toString()).toBe("hello there");
|
||||
expect(v.state.selection.main).toMatchObject({ from: 6, to: 11 });
|
||||
handlePluginEvent({ type: "insertText", pluginId: "p", text: "!" });
|
||||
expect(v.state.doc.toString()).toBe("hello !");
|
||||
expect(v.state.selection.main.head).toBe(7);
|
||||
it("runs commands with the focused editor's path, language and UTF-16 selections", async () => {
|
||||
const calls: unknown[] = [];
|
||||
setPluginsBackend({
|
||||
...backend,
|
||||
runCommand: async (...args) => {
|
||||
calls.push(args);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
editor("ab\n😀cd", 3, 7);
|
||||
await runPluginCommand("p", "c");
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
"p",
|
||||
"c",
|
||||
null,
|
||||
{
|
||||
path: "/p/a.txt",
|
||||
language: "plaintext",
|
||||
selections: [{ start: { line: 1, col: 0 }, end: { line: 1, col: 4 } }],
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns instead of editing read-only or missing editors", () => {
|
||||
expect(applyTextToActiveEditor("x", false, "p")).toBe(false);
|
||||
const v = editor("abc", 0, 0, true);
|
||||
expect(applyTextToActiveEditor("x", false, "p")).toBe(false);
|
||||
expect(v.state.doc.toString()).toBe("abc");
|
||||
expect(notes.map((n) => n.level)).toEqual(["warning", "warning"]);
|
||||
it("shows status items and tasks of running plugin code", () => {
|
||||
const item = { text: "main ↑1", tooltip: "Branch", command: "refresh" };
|
||||
handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item });
|
||||
expect(usePluginStatusItems.getState().items.get("git/branch")).toEqual({ pluginId: "git", item });
|
||||
handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item: null });
|
||||
expect(usePluginStatusItems.getState().items.size).toBe(0);
|
||||
|
||||
handlePluginEvent({ type: "taskStarted", pluginId: "git", taskId: "7", title: "Fetching", cancellable: true });
|
||||
handlePluginEvent({ type: "taskProgress", pluginId: "git", taskId: "7", done: 1, total: 4, message: "origin" });
|
||||
const task = useTasks.getState().tasks.find((t) => t.id === "plugin:7")!;
|
||||
expect([task.title, task.fraction, task.message, !!task.cancel]).toEqual(["Fetching", 0.25, "origin", true]);
|
||||
handlePluginEvent({ type: "taskEnded", pluginId: "git", taskId: "7" });
|
||||
expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true);
|
||||
});
|
||||
|
||||
it("shows plugin notifications and tracks theme changes", () => {
|
||||
@@ -235,11 +445,12 @@ describe("PluginsPage", () => {
|
||||
// Sorted as returned; first one selected by default.
|
||||
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Hello");
|
||||
expect(screen.getByText("plugin.hello.insert-date")).toBeInTheDocument();
|
||||
expect(screen.getByText("Insert text into the active editor")).toBeInTheDocument();
|
||||
expect(screen.getByText("Change text in the editor")).toBeInTheDocument();
|
||||
expect(screen.getByText("Medium")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: "Disable Hello" }));
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument());
|
||||
expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "Run Hello: Insert Date" })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(list).getByText("Harbor Night Theme"));
|
||||
await user.click(screen.getByRole("button", { name: "Apply theme Harbor Night" }));
|
||||
@@ -248,7 +459,7 @@ describe("PluginsPage", () => {
|
||||
await user.type(screen.getByLabelText("Filter plugins"), "toy");
|
||||
await waitFor(() => expect(within(list).getAllByRole("listitem")).toHaveLength(1));
|
||||
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Toy Language");
|
||||
expect(screen.getByText(".toy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Toy (.toy)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restarts a failed plugin instead of disabling it", async () => {
|
||||
@@ -256,7 +467,7 @@ describe("PluginsPage", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PluginsPage />);
|
||||
await user.click(await screen.findByRole("button", { name: "Restart" }));
|
||||
await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "active" }));
|
||||
await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "inactive" }));
|
||||
});
|
||||
|
||||
it("uninstalls after confirmation", async () => {
|
||||
@@ -299,6 +510,140 @@ describe("PluginsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("secrets", () => {
|
||||
it("stores and removes a declared secret without showing its value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const items = [{ pluginId: "ai", point: "secrets", key: "api-key", decl: { name: "api-key", label: "API key" } }];
|
||||
render(<SecretsDetails plugin={plugin({ id: "ai" })} items={items} />);
|
||||
expect(await screen.findByText("Not set")).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Value of API key"), "sk-1{Enter}");
|
||||
await waitFor(() => expect(screen.getByText("Stored")).toBeInTheDocument());
|
||||
expect(screen.getByLabelText("Value of API key")).toHaveValue("");
|
||||
expect(await backend.secrets("ai")).toEqual([{ name: "api-key", set: true }]);
|
||||
await user.click(screen.getByRole("button", { name: "Remove API key" }));
|
||||
await waitFor(() => expect(screen.getByText("Not set")).toBeInTheDocument());
|
||||
expect(await backend.secrets("ai")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin line markers", () => {
|
||||
it("places markers on existing lines and maps their icons", () => {
|
||||
const doc = EditorState.create({ doc: "a\nbb\nccc" }).doc;
|
||||
const marker = (line: number, icon: string | null) => ({ pluginId: "m", line, icon, tooltip: "t", target: null });
|
||||
expect(placeMarkers(doc, [marker(1, "run"), marker(7, null)]).map((m) => m.pos)).toEqual([2]);
|
||||
expect(markerIcon("run")).toBe("gutter-run");
|
||||
expect(markerIcon("implemented")).toBe("gutter-implemented");
|
||||
expect(markerIcon(null)).toBe("gutter-implementing");
|
||||
expect(markerIcon("unknown")).toBe("gutter-implementing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("marketplace", () => {
|
||||
it("lists the index's plugins and installs one through the install page", async () => {
|
||||
const asked: ConsentRequest[] = [];
|
||||
setConsentPrompt(async (req) => {
|
||||
asked.push(req);
|
||||
return { optional: [] };
|
||||
});
|
||||
const base = createMemoryPluginsBackend([], sampleCatalog());
|
||||
const downloads: string[] = [];
|
||||
setPluginsBackend({
|
||||
...base,
|
||||
marketUrl: async () => "https://plugins.example/index.json",
|
||||
market: async () => [
|
||||
{
|
||||
id: "hello",
|
||||
name: "Hello",
|
||||
version: "1.0.0",
|
||||
description: "From the marketplace",
|
||||
authors: [],
|
||||
publisher: "acme",
|
||||
download: "hello.oiplugin",
|
||||
digest: "x",
|
||||
capabilities: ["editor.write"],
|
||||
installed: null,
|
||||
},
|
||||
],
|
||||
// The memory backend installs its sample packages by path.
|
||||
marketDownload: async (id) => {
|
||||
downloads.push(id);
|
||||
return "plugins/hello";
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<PluginsPage />);
|
||||
await user.click(screen.getByRole("tab", { name: /Available/ }));
|
||||
const list = await screen.findByRole("list", { name: "Available plugins" });
|
||||
await waitFor(() => expect(within(list).getByText("Marketplace")).toBeInTheDocument());
|
||||
expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument();
|
||||
await user.click(within(list).getAllByText("Hello")[0]!);
|
||||
expect(screen.getByText("Signed by acme")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
await waitFor(() => expect(usePlugins.getState().plugins.map((p) => p.id)).toEqual(["hello"]));
|
||||
expect(downloads).toEqual(["hello"]);
|
||||
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" });
|
||||
});
|
||||
|
||||
it("offers to roll back an update", async () => {
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }),
|
||||
]);
|
||||
const rolled: string[] = [];
|
||||
setPluginsBackend({
|
||||
...base,
|
||||
rollBack: async (id) => {
|
||||
rolled.push(id);
|
||||
return plugin({ id, name: "P", version: "1.0.0" });
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<PluginsPage />);
|
||||
expect(await screen.findByText("Unsigned")).toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("button", { name: "Roll Back to 1.0.0" }));
|
||||
await waitFor(() => expect(rolled).toEqual(["p"]));
|
||||
expect(notes.at(-1)).toMatchObject({ title: "Rolled back P to 1.0.0" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("icon themes", () => {
|
||||
it("resolves file names, then the longest extension, then the default icon", () => {
|
||||
const theme = iconResolver(
|
||||
{
|
||||
file: "file",
|
||||
fileNames: { "cargo.toml": "cargo" },
|
||||
fileExtensions: { "d.ts": "dts", ts: "ts", toml: "toml" },
|
||||
},
|
||||
(icon) => `ext:p/t/${icon}`,
|
||||
(name) => name !== "ext:p/t/toml",
|
||||
);
|
||||
expect(theme.file("Cargo.toml")).toBe("ext:p/t/cargo");
|
||||
expect(theme.file("a.d.ts")).toBe("ext:p/t/dts");
|
||||
expect(theme.file("a.ts")).toBe("ext:p/t/ts");
|
||||
expect(theme.file("x.toml")).toBe("ext:p/t/file");
|
||||
// No folder icons in this theme: folders keep the built-in ones.
|
||||
expect(theme.folder("src", false)).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves folders by name, then the default (expanded) folder icon", () => {
|
||||
const theme = iconResolver(
|
||||
{
|
||||
file: null,
|
||||
folder: "folder",
|
||||
folderExpanded: "folder-open",
|
||||
fileNames: {},
|
||||
fileExtensions: {},
|
||||
folderNames: { src: "folder-src" },
|
||||
},
|
||||
(icon) => `ext:p/t/${icon}`,
|
||||
() => true,
|
||||
);
|
||||
expect(theme.folder("SRC", true)).toBe("ext:p/t/folder-src");
|
||||
expect(theme.folder("lib", false)).toBe("ext:p/t/folder");
|
||||
expect(theme.folder("lib", true)).toBe("ext:p/t/folder-open");
|
||||
expect(theme.file("a.txt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog helpers", () => {
|
||||
it("compares versions numerically", () => {
|
||||
expect(compareVersions("1.10.0", "1.9.2")).toBe(1);
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { View, ViewEvent } from "@/bindings";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import { bindingsFor, effectiveBindings, registerExtensionBindings, useKeymap } from "@/platform/keymap";
|
||||
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
|
||||
import { defineBuiltinUiPoints } from "../points";
|
||||
import { clearUiContributions, syncUiContributions } from "../projection";
|
||||
import { pluginMenuItems, withPluginSetting } from "../uiPoints";
|
||||
import { matchesPattern } from "../views/customEditor";
|
||||
import { FRAME_CSP, FRAME_TAG, frameDocument, frameTheme, type FrameTheme } from "../views/frame";
|
||||
import { PluginView, treeNodes, wantedViewport } from "../views/PluginView";
|
||||
import { bumpView } from "../views/viewStore";
|
||||
|
||||
let undefinePoints: () => void = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
undefinePoints = defineBuiltinUiPoints();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearUiContributions();
|
||||
undefinePoints();
|
||||
setPluginsBackend(null);
|
||||
});
|
||||
|
||||
function backendWith(render: (view: string) => View, events: ViewEvent[] = []): PluginsBackend {
|
||||
const base = createMemoryPluginsBackend([]);
|
||||
return {
|
||||
...base,
|
||||
renderView: vi.fn(async (_p: string, view: string) => render(view)),
|
||||
viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("view helpers", () => {
|
||||
it("builds the tree from the flat item list", () => {
|
||||
const item = (key: string, parent: string | null = null) => ({
|
||||
key,
|
||||
parent,
|
||||
label: key,
|
||||
icon: null,
|
||||
detail: null,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
});
|
||||
const roots = treeNodes([item("a"), item("a/1", "a"), item("b"), item("x", "missing")]);
|
||||
expect(roots.map((n) => n.item.key)).toEqual(["a", "b", "x"]);
|
||||
expect(roots[0]!.children.map((n) => n.item.key)).toEqual(["a/1"]);
|
||||
});
|
||||
|
||||
it("asks for the visible table rows plus some around them", () => {
|
||||
expect(wantedViewport(0, 20, 10)).toEqual({ firstRow: 0, rows: 10 });
|
||||
expect(wantedViewport(1000, 20, 5000)).toEqual({ firstRow: 950, rows: 120 });
|
||||
});
|
||||
|
||||
it("confines HTML bodies: CSP without network, a message bridge", () => {
|
||||
const doc = frameDocument("<p>hi</p>", { dark: true, vars: {} });
|
||||
expect(doc).toContain(`content="${FRAME_CSP}"`);
|
||||
expect(FRAME_CSP).toContain("default-src 'none'");
|
||||
expect(FRAME_CSP).not.toMatch(/https?:|connect-src/);
|
||||
expect(doc).toContain("window.openidea = Object.freeze");
|
||||
expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(doc.indexOf("<p>hi</p>"));
|
||||
});
|
||||
|
||||
it("reads the frame theme from the IDE tokens, resolved and sanitised", () => {
|
||||
const root = document.documentElement;
|
||||
const before = root.dataset.theme;
|
||||
root.style.setProperty("--fg", "#112233");
|
||||
root.style.setProperty("--font-ui", "Inter, sans-serif");
|
||||
root.style.setProperty("--font-mono", "x}</style><script>alert(1)</script>");
|
||||
try {
|
||||
root.dataset.theme = "light";
|
||||
const light = frameTheme();
|
||||
expect(light.dark).toBe(false);
|
||||
expect(light.vars["--openidea-fg"]).toBe("rgb(17, 34, 51)");
|
||||
expect(light.vars["--openidea-font-ui"]).toBe("Inter, sans-serif");
|
||||
expect(light.vars).not.toHaveProperty("--openidea-font-mono");
|
||||
root.dataset.theme = "dark";
|
||||
root.dataset.contrast = "high";
|
||||
expect(frameTheme().dark).toBe(true);
|
||||
} finally {
|
||||
for (const p of ["--fg", "--font-ui", "--font-mono"]) root.style.removeProperty(p);
|
||||
delete root.dataset.contrast;
|
||||
if (before === undefined) delete root.dataset.theme;
|
||||
else root.dataset.theme = before;
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the theme into the frame document without letting a value end the script", () => {
|
||||
const doc = frameDocument("<p>hi</p>", { dark: true, vars: { "--openidea-fg": "</script><b>" } });
|
||||
expect(doc).toContain('<html data-theme="dark">');
|
||||
expect(doc).not.toContain("</script><b>");
|
||||
expect(doc).toContain("\\u003c/script>");
|
||||
});
|
||||
|
||||
it("applies posted themes inside the frame, replacing the previous variables", () => {
|
||||
const doc = frameDocument("", {
|
||||
dark: false,
|
||||
vars: { "--openidea-fg": "rgb(1, 2, 3)", "--openidea-accent": "red" },
|
||||
});
|
||||
const script = /<script>([\s\S]*)<\/script>/.exec(doc)![1]!;
|
||||
const root = document.documentElement;
|
||||
const before = root.dataset.theme;
|
||||
const seen: unknown[] = [];
|
||||
try {
|
||||
new Function(script)();
|
||||
const api = (
|
||||
window as unknown as { openidea: { theme: FrameTheme; onThemeChange(fn: (t: FrameTheme) => void): void } }
|
||||
).openidea;
|
||||
expect(root.style.getPropertyValue("--openidea-fg")).toBe("rgb(1, 2, 3)");
|
||||
expect(api.theme.dark).toBe(false);
|
||||
api.onThemeChange((t) => seen.push(t));
|
||||
// jsdom's postMessage has no `source`, so dispatch the event as the parent would.
|
||||
const send = (t: unknown) =>
|
||||
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, theme: t }, source: window }));
|
||||
send({ dark: true, vars: { "--openidea-fg": "rgb(9, 9, 9)", "--not-ours": "x" } });
|
||||
expect(root.dataset.theme).toBe("dark");
|
||||
expect(root.style.getPropertyValue("--openidea-fg")).toBe("rgb(9, 9, 9)");
|
||||
expect(root.style.getPropertyValue("--openidea-accent")).toBe("");
|
||||
expect(root.style.getPropertyValue("--not-ours")).toBe("");
|
||||
expect(seen).toHaveLength(1);
|
||||
} finally {
|
||||
for (const p of ["--openidea-fg", "--openidea-accent"]) root.style.removeProperty(p);
|
||||
delete (window as unknown as { openidea?: unknown }).openidea;
|
||||
if (before === undefined) delete root.dataset.theme;
|
||||
else root.dataset.theme = before;
|
||||
}
|
||||
});
|
||||
|
||||
it("matches custom editor file name patterns", () => {
|
||||
expect(matchesPattern("*.csv", "People.CSV")).toBe(true);
|
||||
expect(matchesPattern("*.{csv,tsv}", "a.tsv")).toBe(true);
|
||||
expect(matchesPattern("data?.csv", "data1.csv")).toBe(true);
|
||||
expect(matchesPattern("*.csv", "a.csv.bak")).toBe(false);
|
||||
});
|
||||
|
||||
it("stores plugin settings under the plugin's id, dropping defaults", () => {
|
||||
const one = withPluginSetting(undefined, "p.x", "watch", false);
|
||||
expect(one).toEqual({ "p.x": { watch: false } });
|
||||
expect(withPluginSetting(one, "p.x", "watch", null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin keymaps", () => {
|
||||
afterEach(() => useKeymap.setState({ extensions: {}, overrides: {} }));
|
||||
|
||||
it("adds a plugin's shortcut below the IDE's, never taking a used keystroke", () => {
|
||||
const taken = effectiveBindings()[0]!;
|
||||
const offFree = registerExtensionBindings("plugin.p.free", ["Ctrl+Alt+Shift+F11"]);
|
||||
const offTaken = registerExtensionBindings("plugin.p.taken", [taken[0]]);
|
||||
expect(bindingsFor("plugin.p.free")).toEqual(["Ctrl+Alt+Shift+F11"]);
|
||||
expect(bindingsFor("plugin.p.taken")).toEqual([]);
|
||||
useKeymap.setState({ overrides: { "plugin.p.free": [] } });
|
||||
expect(bindingsFor("plugin.p.free")).toEqual([]);
|
||||
offFree();
|
||||
offTaken();
|
||||
expect(useKeymap.getState().extensions).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin menus", () => {
|
||||
it("projects context menu entries for their command's action", () => {
|
||||
const off = registerAction({ id: "plugin.p.check", title: "Check", run: () => {} });
|
||||
const plugin = {
|
||||
id: "p",
|
||||
name: "P",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
} as unknown as Parameters<typeof syncUiContributions>[1][number];
|
||||
syncUiContributions(
|
||||
[
|
||||
{
|
||||
pluginId: "p",
|
||||
point: "menus",
|
||||
key: "projectView.context/check",
|
||||
decl: { menu: "projectView.context", command: "check" },
|
||||
},
|
||||
{
|
||||
pluginId: "p",
|
||||
point: "menus",
|
||||
key: "editor.context/check",
|
||||
decl: { menu: "editor.context", command: "check", when: "false" },
|
||||
},
|
||||
],
|
||||
[plugin],
|
||||
);
|
||||
expect(pluginMenuItems("projectView", ["/p/a.java"])).toEqual([
|
||||
{ kind: "action", action: "plugin.p.check", arg: "/p/a.java" },
|
||||
]);
|
||||
expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([]);
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginView", () => {
|
||||
it("renders a tree and sends expand / activate", async () => {
|
||||
const events: ViewEvent[] = [];
|
||||
const tree: View = {
|
||||
toolbar: [{ id: "refresh", label: "Refresh", icon: "refresh", enabled: true }],
|
||||
body: {
|
||||
kind: "tree",
|
||||
value: [
|
||||
{
|
||||
key: "ns",
|
||||
parent: null,
|
||||
label: "default",
|
||||
icon: null,
|
||||
detail: "3 pods",
|
||||
expandable: true,
|
||||
expanded: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "1 namespace",
|
||||
};
|
||||
setPluginsBackend(backendWith(() => tree, events));
|
||||
const user = userEvent.setup();
|
||||
render(<PluginView pluginId="jobs" view="jobs" label="Jobs" />);
|
||||
expect(await screen.findByText("default")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 namespace")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await waitFor(() => expect(events).toContainEqual({ kind: "button", id: "refresh" }));
|
||||
});
|
||||
|
||||
it("edits a table cell and renders again when the plugin refreshes", async () => {
|
||||
const events: ViewEvent[] = [];
|
||||
let city = "London";
|
||||
const table = (): View => ({
|
||||
toolbar: [],
|
||||
body: {
|
||||
kind: "table",
|
||||
value: { columns: ["name", "city"], totalRows: 1, firstRow: 0, rows: [["Ada", city]], editable: true },
|
||||
},
|
||||
status: null,
|
||||
});
|
||||
const backend = backendWith(table, events);
|
||||
setPluginsBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
render(<PluginView pluginId="csv" view="table" doc={7} />);
|
||||
await user.dblClick(await screen.findByText("London"));
|
||||
const input = screen.getByLabelText("Cell value");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Paris{Enter}");
|
||||
await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" }));
|
||||
expect(backend.viewEvent).toHaveBeenCalledWith("csv", "table", expect.anything(), 7);
|
||||
city = "Paris";
|
||||
bumpView("csv", "table");
|
||||
expect(await screen.findByText("Paris")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps an HTML body loaded across theme switches and posts the new theme to it", async () => {
|
||||
const html: View = { toolbar: [], body: { kind: "html", value: "<p>chart</p>" }, status: null };
|
||||
setPluginsBackend(backendWith(() => html, []));
|
||||
const root = document.documentElement;
|
||||
const before = root.dataset.theme;
|
||||
root.dataset.theme = "light";
|
||||
try {
|
||||
const { container } = render(<PluginView pluginId="charts" view="chart" />);
|
||||
await waitFor(() => expect(container.querySelector("iframe")).not.toBeNull());
|
||||
const frame = container.querySelector("iframe")!;
|
||||
const srcDoc = frame.getAttribute("srcdoc");
|
||||
expect(srcDoc).toContain('<html data-theme="light">');
|
||||
const post = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
root.dataset.theme = "dark";
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith({ tag: FRAME_TAG, theme: expect.objectContaining({ dark: true }) }, "*"),
|
||||
);
|
||||
expect(frame.getAttribute("srcdoc")).toBe(srcDoc);
|
||||
} finally {
|
||||
if (before === undefined) delete root.dataset.theme;
|
||||
else root.dataset.theme = before;
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a message when the plugin fails", async () => {
|
||||
const base = createMemoryPluginsBackend([]);
|
||||
setPluginsBackend({ ...base, renderView: () => Promise.reject(new Error("boom")) });
|
||||
render(<PluginView pluginId="x" view="v" />);
|
||||
expect(await screen.findByText("boom")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import type { PluginInfo, ViewEvent } from "@/bindings";
|
||||
import { addFileIconProvider, fileIconName, registerExternalIcons } from "@/ui";
|
||||
import { addContributedFileTemplate, fileTemplates, isBuiltinFileTemplate } from "@/features/templates/files/store";
|
||||
import { createMemoryPluginsBackend, setPluginsBackend, webSchemeUrl } from "../backend";
|
||||
import { usePlugins } from "../store";
|
||||
import { FRAME_TAG, frameDocument } from "../views/frame";
|
||||
import {
|
||||
WebView,
|
||||
minimalChange,
|
||||
validChanges,
|
||||
type DocumentState,
|
||||
type FrameDocument,
|
||||
type TextChange,
|
||||
} from "../views/WebView";
|
||||
|
||||
afterEach(() => {
|
||||
setPluginsBackend(null);
|
||||
usePlugins.setState({ plugins: [] });
|
||||
});
|
||||
|
||||
/** An in-memory document with the custom editor session's versioning. */
|
||||
function memoryDocument(text: string) {
|
||||
let version = 0;
|
||||
const listeners = new Set<(s: DocumentState) => void>();
|
||||
const apply = (changes: readonly TextChange[]) => {
|
||||
for (const c of [...changes].reverse()) text = text.slice(0, c.from) + c.insert + text.slice(c.to);
|
||||
version++;
|
||||
};
|
||||
const doc: FrameDocument & { external(changes: TextChange[]): void; text(): string } = {
|
||||
read: () => ({ version, text, readOnly: false, name: "a.draw" }),
|
||||
edit: (at, changes) => {
|
||||
if (at !== version) throw new Error("stale");
|
||||
apply(changes);
|
||||
return version;
|
||||
},
|
||||
subscribe: (l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
external: (changes) => {
|
||||
apply(changes);
|
||||
for (const l of listeners) l({ version, text });
|
||||
},
|
||||
text: () => text,
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe("web view helpers", () => {
|
||||
it("turns a replacement into the smallest change", () => {
|
||||
expect(minimalChange("abcdef", "abXYef")).toEqual({ from: 2, to: 4, insert: "XY" });
|
||||
expect(minimalChange("aaa", "aaaa")).toEqual({ from: 3, to: 3, insert: "a" });
|
||||
expect(minimalChange("abc", "")).toEqual({ from: 0, to: 3, insert: "" });
|
||||
expect(minimalChange("same", "same")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts only sorted, in-range, non-overlapping edits", () => {
|
||||
expect(
|
||||
validChanges(
|
||||
[
|
||||
{ from: 0, to: 1, insert: "x" },
|
||||
{ from: 2, to: 2, insert: "y" },
|
||||
],
|
||||
3,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(() =>
|
||||
validChanges(
|
||||
[
|
||||
{ from: 2, to: 2, insert: "" },
|
||||
{ from: 0, to: 1, insert: "" },
|
||||
],
|
||||
3,
|
||||
),
|
||||
).toThrow();
|
||||
expect(() => validChanges([{ from: 0, to: 4, insert: "" }], 3)).toThrow();
|
||||
expect(() => validChanges([{ from: 0.5, to: 1, insert: "" }], 3)).toThrow();
|
||||
expect(() => validChanges("nope", 3)).toThrow();
|
||||
});
|
||||
|
||||
it("spells scheme URLs the way each webview expects", () => {
|
||||
expect(webSchemeUrl("acme.draw/abc/web/index.html", false)).toBe(
|
||||
"openidea-plugin://localhost/acme.draw/abc/web/index.html",
|
||||
);
|
||||
expect(webSchemeUrl("p/r/web/a b.html", true)).toBe("http://openidea-plugin.localhost/p/r/web/a%20b.html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame bridge", () => {
|
||||
it("asks the workbench for the document and edits it, and hears of changes made elsewhere", async () => {
|
||||
const script = /<script>([\s\S]*)<\/script>/.exec(frameDocument("", { dark: true, vars: {} }))![1]!;
|
||||
const sent: Array<{ call: number; op: string; args: unknown }> = [];
|
||||
// In jsdom the frame is its own parent: catch what the bridge posts "up".
|
||||
const post = vi.spyOn(window, "postMessage").mockImplementation((m: unknown) => {
|
||||
const msg = m as { call?: number; op?: string; args?: unknown };
|
||||
if (typeof msg.call === "number") sent.push(msg as (typeof sent)[number]);
|
||||
});
|
||||
const reply = (data: object) =>
|
||||
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, ...data }, source: window }));
|
||||
try {
|
||||
new Function(script)();
|
||||
const api = (window as unknown as { openidea: Record<string, never> }).openidea as unknown as {
|
||||
document: {
|
||||
get(): Promise<unknown>;
|
||||
edit(v: number, e: TextChange[]): Promise<{ version: number }>;
|
||||
replace(v: number, t: string): Promise<unknown>;
|
||||
onChange(fn: (s: DocumentState) => void): void;
|
||||
};
|
||||
saveFile(o: { name: string; data: string }): Promise<string | null>;
|
||||
};
|
||||
const got = api.document.get();
|
||||
expect(sent.at(-1)).toMatchObject({ op: "document.get" });
|
||||
reply({ reply: sent.at(-1)!.call, value: { version: 3, text: "abc", readOnly: false } });
|
||||
await expect(got).resolves.toEqual({ version: 3, text: "abc", readOnly: false });
|
||||
|
||||
const edited = api.document.edit(3, [{ from: 0, to: 1, insert: "X" }]);
|
||||
expect(sent.at(-1)).toMatchObject({ op: "document.edit", args: { version: 3 } });
|
||||
reply({ reply: sent.at(-1)!.call, error: "stale" });
|
||||
await expect(edited).rejects.toThrow("stale");
|
||||
|
||||
const saved = api.saveFile({ name: "a.svg", data: "<svg/>" });
|
||||
await waitFor(() => expect(sent.at(-1)).toMatchObject({ op: "saveFile", args: { name: "a.svg" } }));
|
||||
expect(new TextDecoder().decode((sent.at(-1)!.args as { bytes: Uint8Array }).bytes)).toBe("<svg/>");
|
||||
reply({ reply: sent.at(-1)!.call, value: "/tmp/a.svg" });
|
||||
await expect(saved).resolves.toBe("/tmp/a.svg");
|
||||
|
||||
const changes: DocumentState[] = [];
|
||||
api.document.onChange((s) => changes.push(s));
|
||||
reply({ document: { version: 4, text: "xyz" } });
|
||||
expect(changes).toEqual([{ version: 4, text: "xyz" }]);
|
||||
} finally {
|
||||
post.mockRestore();
|
||||
delete (window as unknown as { openidea?: unknown }).openidea;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebView", () => {
|
||||
function setup(hasCode: boolean) {
|
||||
const events: ViewEvent[] = [];
|
||||
setPluginsBackend({
|
||||
...createMemoryPluginsBackend([]),
|
||||
webPageUrl: async () => "about:blank",
|
||||
viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)),
|
||||
});
|
||||
usePlugins.setState({
|
||||
plugins: [{ id: "acme.draw", hasCode, capabilities: [] } as unknown as PluginInfo],
|
||||
});
|
||||
const doc = memoryDocument("hello world");
|
||||
const view = render(<WebView pluginId="acme.draw" view="draw" label="Draw" document={doc} doc={7} />);
|
||||
return { events, doc, view };
|
||||
}
|
||||
|
||||
async function frameOf(container: HTMLElement) {
|
||||
const frame = await waitFor(() => {
|
||||
const f = container.querySelector("iframe");
|
||||
expect(f).not.toBeNull();
|
||||
return f!;
|
||||
});
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
vi.spyOn(frame.contentWindow!, "postMessage").mockImplementation((m: unknown) => void posted.push(m as never));
|
||||
const send = (data: object) =>
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", { data: { tag: FRAME_TAG, ...data }, source: frame.contentWindow }),
|
||||
);
|
||||
return { frame, posted, send };
|
||||
}
|
||||
|
||||
it("loads the page sandboxed, from its URL, without clipboard access unless granted", async () => {
|
||||
const { view } = setup(false);
|
||||
const { frame } = await frameOf(view.container);
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame.getAttribute("src")).toBe("about:blank");
|
||||
expect(frame.getAttribute("allow")).toBeNull();
|
||||
});
|
||||
|
||||
it("serves the document to the page: versioned edits, replace, stale versions, outside changes", async () => {
|
||||
const { view, doc } = setup(false);
|
||||
const { posted, send } = await frameOf(view.container);
|
||||
const replyTo = async (id: number) =>
|
||||
waitFor(() => {
|
||||
const r = posted.find((m) => m.reply === id);
|
||||
expect(r).toBeDefined();
|
||||
return r!;
|
||||
});
|
||||
|
||||
send({ call: 1, op: "document.get" });
|
||||
expect((await replyTo(1)).value).toEqual({ version: 0, text: "hello world", readOnly: false, name: "a.draw" });
|
||||
|
||||
send({ call: 2, op: "document.edit", args: { version: 0, edits: [{ from: 0, to: 5, insert: "HELLO" }] } });
|
||||
expect((await replyTo(2)).value).toEqual({ version: 1 });
|
||||
expect(doc.text()).toBe("HELLO world");
|
||||
|
||||
send({ call: 3, op: "document.edit", args: { version: 0, edits: [] } });
|
||||
expect((await replyTo(3)).error).toBe("stale");
|
||||
|
||||
send({ call: 4, op: "document.replace", args: { version: 1, text: "HELLO there" } });
|
||||
expect((await replyTo(4)).value).toEqual({ version: 2 });
|
||||
expect(doc.text()).toBe("HELLO there");
|
||||
|
||||
send({ call: 5, op: "document.edit", args: { version: 2, edits: [{ from: 0, to: 99, insert: "" }] } });
|
||||
expect(String((await replyTo(5)).error)).toContain("in range");
|
||||
|
||||
// Changes made elsewhere reach the page; its own are not echoed back.
|
||||
const before = posted.filter((m) => m.document).length;
|
||||
doc.external([{ from: 0, to: 0, insert: "> " }]);
|
||||
await waitFor(() => expect(posted.filter((m) => m.document)).toHaveLength(before + 1));
|
||||
expect(posted.at(-1)).toMatchObject({ document: { version: 3, text: "> HELLO there" } });
|
||||
|
||||
send({ call: 6, op: "nope" });
|
||||
expect(String((await replyTo(6)).error)).toContain("unknown request");
|
||||
});
|
||||
|
||||
it("follows the installed package: a new revision reloads the page, an unchanged one keeps it", async () => {
|
||||
let url = "openidea-plugin://localhost/acme.draw/r1/web/index.html";
|
||||
setPluginsBackend({ ...createMemoryPluginsBackend([]), webPageUrl: async () => url });
|
||||
const info = (version: string) =>
|
||||
({ id: "acme.draw", version, hasCode: false, capabilities: [] }) as unknown as PluginInfo;
|
||||
usePlugins.setState({ plugins: [info("1.0.0")] });
|
||||
const view = render(<WebView pluginId="acme.draw" view="draw" label="Draw" />);
|
||||
const { frame } = await frameOf(view.container);
|
||||
expect(frame.getAttribute("src")).toContain("/r1/");
|
||||
|
||||
// Plugins refreshed, same package: the same frame, not reloaded.
|
||||
usePlugins.setState({ plugins: [info("1.0.0")] });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(view.container.querySelector("iframe")).toBe(frame);
|
||||
expect(frame.getAttribute("src")).toContain("/r1/");
|
||||
|
||||
url = "openidea-plugin://localhost/acme.draw/r2/web/index.html";
|
||||
usePlugins.setState({ plugins: [info("1.0.1")] });
|
||||
await waitFor(() => expect(view.container.querySelector("iframe")?.getAttribute("src")).toContain("/r2/"));
|
||||
});
|
||||
|
||||
it("forwards the page's messages to the plugin's code only when it has code", async () => {
|
||||
for (const hasCode of [false, true]) {
|
||||
const { view, events } = setup(hasCode);
|
||||
const { send } = await frameOf(view.container);
|
||||
send({ text: "hi" });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(events).toEqual(hasCode ? [{ kind: "message", text: "hi" }] : []);
|
||||
view.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributed file icons and templates", () => {
|
||||
it("resolves a plugin's file icon before the built-in one, and stops when removed", () => {
|
||||
const builtin = fileIconName("a.excalidraw");
|
||||
const unregister = registerExternalIcons([["ext:fileIcon/acme/draw", "<svg viewBox='0 0 16 16'/>"]]);
|
||||
const remove = addFileIconProvider((name) => (name.endsWith(".excalidraw") ? "ext:fileIcon/acme/draw" : null));
|
||||
try {
|
||||
expect(fileIconName("a.excalidraw")).toBe("ext:fileIcon/acme/draw");
|
||||
expect(fileIconName("a.txt")).not.toBe("ext:fileIcon/acme/draw");
|
||||
} finally {
|
||||
remove();
|
||||
unregister();
|
||||
}
|
||||
expect(fileIconName("a.excalidraw")).toBe(builtin);
|
||||
});
|
||||
|
||||
it("adds a plugin's template to the New menu like a built-in", () => {
|
||||
const remove = addContributedFileTemplate({
|
||||
id: "plugin.acme.draw",
|
||||
name: "Drawing",
|
||||
icon: "file-text",
|
||||
extension: "draw",
|
||||
nameStyle: "asis",
|
||||
kinds: [{ id: "file", label: "Drawing", body: "{}" }],
|
||||
});
|
||||
expect(fileTemplates().some((t) => t.id === "plugin.acme.draw")).toBe(true);
|
||||
expect(isBuiltinFileTemplate("plugin.acme.draw")).toBe(true);
|
||||
remove();
|
||||
expect(fileTemplates().some((t) => t.id === "plugin.acme.draw")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,42 +4,197 @@
|
||||
* development and tests.
|
||||
*/
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } from "@/bindings";
|
||||
import type {
|
||||
ActiveTheme,
|
||||
CapabilityInfo,
|
||||
CatalogPlugin,
|
||||
Consent,
|
||||
EventKind,
|
||||
InstallPreview,
|
||||
MarketPlugin,
|
||||
PluginEditor,
|
||||
PluginEvent,
|
||||
PluginInfo,
|
||||
PluginsSnapshot,
|
||||
PluginSecretState,
|
||||
PluginStatusItem,
|
||||
UiContribution,
|
||||
View,
|
||||
ViewEvent,
|
||||
Viewport,
|
||||
WorkbenchEvent,
|
||||
} from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
|
||||
import { editorContext } from "./editorContext";
|
||||
|
||||
export interface PluginsBackend {
|
||||
list(): Promise<PluginsSnapshot>;
|
||||
setEnabled(id: string, enabled: boolean): Promise<void>;
|
||||
installDir(path: string): Promise<PluginInfo>;
|
||||
/** Validates a package for the install page; nothing is copied. */
|
||||
previewInstall(path: string): Promise<InstallPreview>;
|
||||
installDir(path: string, consent: Consent): Promise<PluginInfo>;
|
||||
/** Confirms a plugin waiting for consent. */
|
||||
grant(id: string, consent: Consent): Promise<PluginInfo>;
|
||||
setCapability(id: string, capability: string, granted: boolean): Promise<PluginInfo>;
|
||||
/** Validated assets (icon theme mapping and SVGs) whose id starts with `prefix`. */
|
||||
assets(pluginId: string, prefix: string): Promise<[string, string][]>;
|
||||
/** `language` / `file` / `view` activation events. */
|
||||
activationEvent(kind: "language" | "file" | "view", value: string): Promise<void>;
|
||||
/** Plugin packages available locally (no network marketplace). */
|
||||
catalog(): Promise<CatalogPlugin[]>;
|
||||
uninstall(id: string): Promise<void>;
|
||||
reload(id: string): Promise<PluginInfo>;
|
||||
runCommand(pluginId: string, commandId: string, arg: string | null): Promise<string | null>;
|
||||
/** Runs a command with the focused editor (path, language, selections) as its context. */
|
||||
runCommand(
|
||||
pluginId: string,
|
||||
commandId: string,
|
||||
arg: string | null,
|
||||
editor: PluginEditor | null,
|
||||
): Promise<string | null>;
|
||||
/** Workbench events plugins subscribed to (see `eventKinds`). */
|
||||
postEvents(events: WorkbenchEvent[]): Promise<void>;
|
||||
/** Kinds of workbench events plugins want; re-fetch on `subscriptionsChanged`. */
|
||||
eventKinds(): Promise<EventKind[]>;
|
||||
/** Plugin status bar items (later changes arrive as `statusItemChanged`). */
|
||||
statusItems(): Promise<PluginStatusItem[]>;
|
||||
cancelTask(taskId: string): Promise<void>;
|
||||
/** Marketplace plugins: the index read again (`refresh`) or the last one read. */
|
||||
market(refresh: boolean): Promise<MarketPlugin[]>;
|
||||
marketUrl(): Promise<string | null>;
|
||||
setMarketUrl(url: string | null): Promise<void>;
|
||||
/** Downloads and checks a marketplace plugin; the folder to install from. */
|
||||
marketDownload(id: string): Promise<string>;
|
||||
/** Unpacks a package file (`.oiplugin` or `.zip`); the folder to install from. */
|
||||
unpackPackage(path: string): Promise<string>;
|
||||
/** Goes back to the version the last update replaced. */
|
||||
rollBack(id: string): Promise<PluginInfo>;
|
||||
/** File picker for a package: an `.oiplugin`, a `.zip` or a folder's `plugin.toml`; `null` when cancelled. */
|
||||
pickPackage(): Promise<string | null>;
|
||||
/** Content of a plugin view (`doc`: the document of a custom editor). */
|
||||
renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>;
|
||||
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
|
||||
/** The URL of a web view's page (`web = …`), under the `openidea-plugin` scheme. */
|
||||
webPageUrl(pluginId: string, view: string): Promise<string>;
|
||||
/** Save dialog for a web view's `openidea.saveFile`; the path written, or `null` when cancelled. */
|
||||
saveWebFile(name: string, filters: FileFilter[] | undefined, bytes: Uint8Array): Promise<string | null>;
|
||||
/** Open dialog for a web view's `openidea.openFile`; `null` when cancelled. */
|
||||
openWebFile(filters: FileFilter[] | undefined): Promise<{ name: string; data: Uint8Array } | null>;
|
||||
/** Which of a plugin's declared secrets are stored (values never come back). */
|
||||
secrets(pluginId: string): Promise<PluginSecretState[]>;
|
||||
/** Stores a secret (`null` removes it). */
|
||||
setSecret(pluginId: string, name: string, value: string | null): Promise<void>;
|
||||
setActiveFile(path: string | null): Promise<void>;
|
||||
setTheme(pluginId: string | null, themeId: string | null): Promise<ActiveTheme | null>;
|
||||
/** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */
|
||||
setThemeFrame(color: string | null): Promise<void>;
|
||||
/** Folder picker; `null` when cancelled. */
|
||||
/** A package to install: a `.zip`, or the `plugin.toml` of a folder. */
|
||||
pickPackage(): Promise<string | null>;
|
||||
pickFolder(): Promise<string | null>;
|
||||
/** Subscribes to backend plugin events (this window only). */
|
||||
onEvent(handler: (e: PluginEvent) => void): () => void;
|
||||
}
|
||||
|
||||
/** A file type choice of the save / open dialogs. */
|
||||
export interface FileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `path` under the `openidea-plugin` scheme, as the platform's webview spells
|
||||
* custom schemes (WebView2 maps them onto `http://<scheme>.localhost`).
|
||||
*/
|
||||
export function webSchemeUrl(path: string, windows = /windows/i.test(navigator.userAgent)): string {
|
||||
const encoded = path.split("/").map(encodeURIComponent).join("/");
|
||||
return windows ? `http://openidea-plugin.localhost/${encoded}` : `openidea-plugin://localhost/${encoded}`;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const tauriPluginsBackend: PluginsBackend = {
|
||||
list: () => call(commands.pluginList()),
|
||||
setEnabled: async (id, enabled) => {
|
||||
await call(commands.pluginSetEnabled(id, enabled));
|
||||
},
|
||||
installDir: (path) => call(commands.pluginInstallDir(path)),
|
||||
previewInstall: (path) => call(commands.pluginPreviewInstall(path)),
|
||||
installDir: (path, consent) => call(commands.pluginInstallDir(path, consent)),
|
||||
grant: (id, consent) => call(commands.pluginGrant(id, consent)),
|
||||
setCapability: (id, capability, granted) => call(commands.pluginSetCapability(id, capability, granted)),
|
||||
assets: (pluginId, prefix) => call(commands.pluginAssets(pluginId, prefix)),
|
||||
activationEvent: async (kind, value) => {
|
||||
await call(commands.pluginActivationEvent(kind, value));
|
||||
},
|
||||
catalog: () => call(commands.pluginCatalog()),
|
||||
uninstall: async (id) => {
|
||||
await call(commands.pluginUninstall(id));
|
||||
},
|
||||
reload: (id) => call(commands.pluginReload(id)),
|
||||
runCommand: (pluginId, commandId, arg) => call(commands.pluginRunCommand(pluginId, commandId, arg)),
|
||||
runCommand: (pluginId, commandId, arg, editor) => call(commands.pluginRunCommand(pluginId, commandId, arg, editor)),
|
||||
postEvents: async (events) => {
|
||||
await commands.pluginPostEvents(events);
|
||||
},
|
||||
eventKinds: () => commands.pluginEventKinds(),
|
||||
statusItems: () => commands.pluginStatusItems(),
|
||||
cancelTask: async (taskId) => {
|
||||
await commands.pluginCancelTask(taskId);
|
||||
},
|
||||
market: (refresh) => call(commands.pluginMarket(refresh)),
|
||||
marketUrl: () => commands.pluginMarketUrl(),
|
||||
setMarketUrl: async (url) => {
|
||||
await commands.pluginSetMarketUrl(url);
|
||||
},
|
||||
marketDownload: (id) => call(commands.pluginMarketDownload(id)),
|
||||
unpackPackage: (path) => call(commands.pluginUnpackPackage(path)),
|
||||
rollBack: (id) => call(commands.pluginRollBack(id)),
|
||||
pickPackage: async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await open({
|
||||
multiple: false,
|
||||
title: tx("Install Plugin from Package File"),
|
||||
filters: [{ name: tx("Plugin package"), extensions: ["oiplugin", "zip", "toml"] }],
|
||||
});
|
||||
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
|
||||
},
|
||||
// A tool window's calls (no `doc`) carry the window's active editor.
|
||||
renderView: (pluginId, view, viewport, doc) =>
|
||||
call(commands.pluginViewRender(pluginId, view, viewport, doc, doc == null ? editorContext() : null)),
|
||||
viewEvent: async (pluginId, view, event, doc) => {
|
||||
await call(commands.pluginViewEvent(pluginId, view, event, doc, doc == null ? editorContext() : null));
|
||||
},
|
||||
webPageUrl: async (pluginId, view) => {
|
||||
const page = await call(commands.pluginWebPage(pluginId, view));
|
||||
return webSchemeUrl(page.path);
|
||||
},
|
||||
saveWebFile: async (name, filters, bytes) => {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({ defaultPath: name, filters });
|
||||
if (!path) return null;
|
||||
await call(commands.pluginWebSaveFile(path, toBase64(bytes)));
|
||||
return path;
|
||||
},
|
||||
openWebFile: async (filters) => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await open({ multiple: false, filters });
|
||||
const path = (Array.isArray(picked) ? picked[0] : picked) ?? null;
|
||||
if (!path) return null;
|
||||
const data = fromBase64(await call(commands.pluginWebReadFile(path)));
|
||||
return { name: path.split(/[\\/]/).pop() ?? path, data };
|
||||
},
|
||||
secrets: (pluginId) => call(commands.pluginSecrets(pluginId)),
|
||||
setSecret: async (pluginId, name, value) => {
|
||||
await call(commands.pluginSetSecret(pluginId, name, value));
|
||||
},
|
||||
setActiveFile: async (path) => {
|
||||
await commands.pluginSetActiveFile(path);
|
||||
},
|
||||
@@ -47,13 +202,9 @@ export const tauriPluginsBackend: PluginsBackend = {
|
||||
setThemeFrame: async (color) => {
|
||||
await call(commands.pluginSetThemeFrame(color));
|
||||
},
|
||||
pickPackage: async () => {
|
||||
pickFolder: async () => {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await open({
|
||||
multiple: false,
|
||||
title: tx("Install Plugin from Disk"),
|
||||
filters: [{ name: tx("Plugin package"), extensions: ["zip", "toml"] }],
|
||||
});
|
||||
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
|
||||
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
|
||||
},
|
||||
onEvent: (handler) => {
|
||||
@@ -66,7 +217,7 @@ export const tauriPluginsBackend: PluginsBackend = {
|
||||
const tauriListeners = new Set<(e: PluginEvent) => void>();
|
||||
let subscribed = false;
|
||||
|
||||
/** One channel per window; the backend addresses insert-text requests to the invoking window. */
|
||||
/** One channel per window; the backend addresses notifications and consent requests to the invoking window. */
|
||||
function ensureSubscribed() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
@@ -82,7 +233,7 @@ function ensureSubscribed() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browser simulation: the bundled sample plugins, with `hello` implemented in JS.
|
||||
|
||||
// templates/theme-plugin, abridged.
|
||||
// plugins/official/harbor-night, abridged.
|
||||
const SAMPLE_THEMES: ActiveTheme["theme"][] = [
|
||||
{
|
||||
id: "harbor-night",
|
||||
@@ -101,6 +252,27 @@ const SAMPLE_THEMES: ActiveTheme["theme"][] = [
|
||||
},
|
||||
];
|
||||
|
||||
const cap = (id: string, risk: CapabilityInfo["risk"], description: string): CapabilityInfo => ({
|
||||
id,
|
||||
risk,
|
||||
description,
|
||||
optional: false,
|
||||
granted: true,
|
||||
});
|
||||
|
||||
const HELLO_COMMANDS = [
|
||||
{ id: "insert-date", title: "Hello: Insert Date", category: "Hello" },
|
||||
{ id: "word-count", title: "Hello: Word Count of Active File", category: "Hello" },
|
||||
];
|
||||
|
||||
/** UI contributions of the sample plugins (what the backend would project). */
|
||||
function sampleUi(id: string): UiContribution[] {
|
||||
if (id === "hello") return HELLO_COMMANDS.map((c) => ({ pluginId: id, point: "commands", key: c.id, decl: c }));
|
||||
if (id === "theme-harbor-night")
|
||||
return SAMPLE_THEMES.map((t) => ({ pluginId: id, point: "themes", key: t.id, decl: t }));
|
||||
return [];
|
||||
}
|
||||
|
||||
function samplePlugins(): PluginInfo[] {
|
||||
const base = {
|
||||
authors: ["OpenIDEA contributors"],
|
||||
@@ -109,10 +281,23 @@ function samplePlugins(): PluginInfo[] {
|
||||
source: "installed" as const,
|
||||
enabled: true,
|
||||
error: null,
|
||||
languages: [],
|
||||
themes: [],
|
||||
commands: [],
|
||||
permissions: [],
|
||||
capabilities: [],
|
||||
contributions: [],
|
||||
activation: [],
|
||||
warnings: [],
|
||||
stats: {
|
||||
activations: 0,
|
||||
activationMs: 0,
|
||||
calls: 0,
|
||||
errors: 0,
|
||||
maxCallMs: 0,
|
||||
p95CallMs: 0,
|
||||
slowCalls: 0,
|
||||
crashes: 0,
|
||||
reclaims: 0,
|
||||
},
|
||||
publisher: null,
|
||||
previousVersion: null,
|
||||
};
|
||||
return [
|
||||
{
|
||||
@@ -122,13 +307,15 @@ function samplePlugins(): PluginInfo[] {
|
||||
version: "0.1.0",
|
||||
description: "Sample plugin: inserts today's date and counts words in the active file.",
|
||||
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/hello",
|
||||
status: "active",
|
||||
status: "inactive",
|
||||
hasCode: true,
|
||||
permissions: ["project-read", "editor-read", "editor-write"],
|
||||
commands: [
|
||||
{ id: "insert-date", title: "Hello: Insert Date", category: "Hello" },
|
||||
{ id: "word-count", title: "Hello: Word Count of Active File", category: "Hello" },
|
||||
activation: ["on-startup-finished"],
|
||||
capabilities: [
|
||||
cap("editor.write", "medium", "Change text in the editor"),
|
||||
cap("project.read", "low", "Read files in the open project"),
|
||||
cap("editor.read", "low", "Read the focused editor's file and selection"),
|
||||
],
|
||||
contributions: HELLO_COMMANDS.map((c) => ({ point: "commands", label: c.title })),
|
||||
},
|
||||
{
|
||||
...base,
|
||||
@@ -139,7 +326,7 @@ function samplePlugins(): PluginInfo[] {
|
||||
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-harbor-night",
|
||||
status: "static",
|
||||
hasCode: false,
|
||||
themes: SAMPLE_THEMES.map(({ id, name, kind }) => ({ id, name, kind })),
|
||||
contributions: SAMPLE_THEMES.map((t) => ({ point: "themes", label: t.name })),
|
||||
},
|
||||
{
|
||||
...base,
|
||||
@@ -150,31 +337,35 @@ function samplePlugins(): PluginInfo[] {
|
||||
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/language-toy",
|
||||
status: "static",
|
||||
hasCode: false,
|
||||
languages: [{ id: "toy", name: "Toy", extensions: ["toy"] }],
|
||||
contributions: [{ point: "languages", label: "Toy (.toy)" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The sample packages as a local catalog (the desktop app scans `plugins/`). */
|
||||
export function sampleCatalog(): CatalogPlugin[] {
|
||||
return samplePlugins().map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
description: p.description,
|
||||
authors: p.authors,
|
||||
homepage: p.homepage,
|
||||
changeNotes: `${p.version} — first release.`,
|
||||
path: `plugins/${p.id}`,
|
||||
hasCode: p.hasCode,
|
||||
permissions: p.permissions,
|
||||
commands: p.commands,
|
||||
languageCount: p.languages.length,
|
||||
themeCount: p.themes.length,
|
||||
problem: null,
|
||||
}));
|
||||
return samplePlugins().map((p) => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const c of p.contributions) counts.set(c.point, (counts.get(c.point) ?? 0) + 1);
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
description: p.description,
|
||||
authors: p.authors,
|
||||
homepage: p.homepage,
|
||||
changeNotes: `${p.version} — first release.`,
|
||||
path: `plugins/${p.id}`,
|
||||
hasCode: p.hasCode,
|
||||
capabilities: p.capabilities.map((c) => ({ ...c, granted: false })),
|
||||
contributions: [...counts].map(([point, count]) => ({ point, count })),
|
||||
problem: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const live = (p: PluginInfo) => p.status === "active" || p.status === "inactive" || p.status === "static";
|
||||
|
||||
export function createMemoryPluginsBackend(
|
||||
initial: PluginInfo[] = samplePlugins(),
|
||||
catalog: CatalogPlugin[] = sampleCatalog(),
|
||||
@@ -183,6 +374,8 @@ export function createMemoryPluginsBackend(
|
||||
const templates = samplePlugins();
|
||||
let theme: { pluginId: string; themeId: string } | null = null;
|
||||
let activeFile: string | null = null;
|
||||
const secrets = new Map<string, string>();
|
||||
let marketUrl: string | null = null;
|
||||
const listeners = new Set<(e: PluginEvent) => void>();
|
||||
const emit = (e: PluginEvent) => listeners.forEach((l) => l(e));
|
||||
const find = (id: string) => {
|
||||
@@ -197,16 +390,36 @@ export function createMemoryPluginsBackend(
|
||||
if (!p?.enabled || !t) return null;
|
||||
return { pluginId: p.id, theme: t };
|
||||
};
|
||||
const ui = () => plugins.filter(live).flatMap((p) => sampleUi(p.id));
|
||||
const preview = (path: string): InstallPreview => {
|
||||
const entry = catalog.find((c) => c.path === path);
|
||||
if (!entry) throw new IpcError({ kind: "unavailable", message: `installing ${path} requires the desktop app` });
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
version: entry.version,
|
||||
description: entry.description,
|
||||
authors: entry.authors,
|
||||
hasCode: entry.hasCode,
|
||||
capabilities: entry.capabilities,
|
||||
contributions: entry.contributions,
|
||||
warnings: [],
|
||||
replaces: plugins.find((p) => p.id === entry.id)?.version ?? null,
|
||||
unsigned: true,
|
||||
publisher: null,
|
||||
};
|
||||
};
|
||||
return {
|
||||
list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme() }),
|
||||
list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme(), ui: ui() }),
|
||||
setEnabled: async (id, enabled) => {
|
||||
const p = find(id);
|
||||
p.enabled = enabled;
|
||||
p.status = enabled ? (p.hasCode ? "active" : "static") : "disabled";
|
||||
p.status = enabled ? (p.hasCode ? "inactive" : "static") : "disabled";
|
||||
p.error = null;
|
||||
emit({ type: "commandsChanged" });
|
||||
if (theme?.pluginId === id) emit({ type: "themeChanged", theme: activeTheme() });
|
||||
},
|
||||
previewInstall: async (path) => preview(path),
|
||||
installDir: async (path) => {
|
||||
// Only catalog packages can be "installed" here; there are no files in the browser.
|
||||
const entry = catalog.find((c) => c.path === path);
|
||||
@@ -219,6 +432,22 @@ export function createMemoryPluginsBackend(
|
||||
emit({ type: "commandsChanged" });
|
||||
return { ...info };
|
||||
},
|
||||
grant: async (id) => {
|
||||
const p = find(id);
|
||||
p.status = p.hasCode ? "inactive" : "static";
|
||||
p.capabilities = p.capabilities.map((c) => ({ ...c, granted: true }));
|
||||
emit({ type: "commandsChanged" });
|
||||
return { ...p };
|
||||
},
|
||||
setCapability: async (id, capability, granted) => {
|
||||
const p = find(id);
|
||||
p.capabilities = p.capabilities.map((c) => (c.id === capability ? { ...c, granted } : c));
|
||||
if (!granted && p.capabilities.some((c) => c.id === capability && !c.optional)) p.status = "needsConsent";
|
||||
emit({ type: "commandsChanged" });
|
||||
return { ...p };
|
||||
},
|
||||
assets: async () => [],
|
||||
activationEvent: async () => {},
|
||||
catalog: async () => catalog.map((c) => ({ ...c })),
|
||||
uninstall: async (id) => {
|
||||
find(id);
|
||||
@@ -228,13 +457,10 @@ export function createMemoryPluginsBackend(
|
||||
reload: async (id) => find(id),
|
||||
runCommand: async (pluginId, commandId) => {
|
||||
const p = find(pluginId);
|
||||
if (p.status !== "active")
|
||||
if (p.status !== "active" && p.status !== "inactive")
|
||||
throw new IpcError({ kind: "unavailable", message: `plugin "${pluginId}" is not running` });
|
||||
if (commandId === "insert-date") {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
emit({ type: "insertText", pluginId, text: date });
|
||||
return date;
|
||||
}
|
||||
p.status = "active";
|
||||
if (commandId === "insert-date") return new Date().toISOString().slice(0, 10);
|
||||
if (commandId === "word-count") {
|
||||
if (!activeFile) throw new IpcError({ kind: "internal", message: "no file is open in the editor" });
|
||||
const name = activeFile.split("/").pop() ?? activeFile;
|
||||
@@ -243,6 +469,44 @@ export function createMemoryPluginsBackend(
|
||||
}
|
||||
throw new IpcError({ kind: "internal", message: `unknown command: ${commandId}` });
|
||||
},
|
||||
postEvents: async () => {},
|
||||
eventKinds: async () => [],
|
||||
statusItems: async () => [],
|
||||
cancelTask: async () => {},
|
||||
market: async () => [],
|
||||
marketUrl: async () => marketUrl,
|
||||
setMarketUrl: async (url) => {
|
||||
marketUrl = url;
|
||||
},
|
||||
marketDownload: async (id) => {
|
||||
throw new IpcError({ kind: "unavailable", message: `installing ${id} requires the desktop app` });
|
||||
},
|
||||
unpackPackage: async (path) => {
|
||||
throw new IpcError({ kind: "unavailable", message: `unpacking ${path} requires the desktop app` });
|
||||
},
|
||||
rollBack: async (id) => {
|
||||
throw new IpcError({ kind: "notFound", message: `${id} has no earlier version to roll back to` });
|
||||
},
|
||||
pickPackage: async () => null,
|
||||
renderView: async (pluginId, view) => ({
|
||||
toolbar: [],
|
||||
body: { kind: "message", value: `${pluginId} · ${view} (plugin views need the desktop app)` },
|
||||
status: null,
|
||||
}),
|
||||
viewEvent: async () => {},
|
||||
webPageUrl: async (pluginId, view) => {
|
||||
throw new IpcError({ kind: "unavailable", message: `${pluginId} · ${view}: web views need the desktop app` });
|
||||
},
|
||||
saveWebFile: async () => null,
|
||||
openWebFile: async () => null,
|
||||
secrets: async (pluginId) =>
|
||||
[...secrets.entries()]
|
||||
.filter(([k]) => k.startsWith(`${pluginId}/`))
|
||||
.map(([k]) => ({ name: k.slice(pluginId.length + 1), set: true })),
|
||||
setSecret: async (pluginId, name, value) => {
|
||||
if (value === null) secrets.delete(`${pluginId}/${name}`);
|
||||
else secrets.set(`${pluginId}/${name}`, value);
|
||||
},
|
||||
setActiveFile: async (path) => {
|
||||
activeFile = path;
|
||||
},
|
||||
@@ -253,7 +517,7 @@ export function createMemoryPluginsBackend(
|
||||
return t;
|
||||
},
|
||||
setThemeFrame: async () => {},
|
||||
pickPackage: async () => null,
|
||||
pickFolder: async () => null,
|
||||
onEvent: (handler) => {
|
||||
listeners.add(handler);
|
||||
return () => void listeners.delete(handler);
|
||||
|
||||
@@ -1,66 +1,52 @@
|
||||
/**
|
||||
* Glue between backend plugins and the workbench: plugin commands become
|
||||
* actions, editor requests are applied to the active editor, the selected
|
||||
* theme becomes a stylesheet of CSS variables, notifications become balloons.
|
||||
* Glue between backend plugins and the workbench: UI contributions are
|
||||
* projected (`projection.ts` + `points.tsx`), status items, tasks and event
|
||||
* subscriptions of running code go through `runtimeUi.tsx`, the selected
|
||||
* theme becomes a stylesheet of CSS
|
||||
* variables, notifications become balloons, consent requests open the
|
||||
* permission dialog, and opened files fire `on-language` / `on-file`.
|
||||
*/
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings";
|
||||
import { activeEditor, useEditorRegistry } from "@/editor/api";
|
||||
import { registerAction, type Action } from "@/platform/actions";
|
||||
import type { ActiveTheme, PluginEvent } from "@/bindings";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { applyThemeAttributes, currentThemeId, syncWindowBackground, windowBackgroundHex } from "@/workbench/theme";
|
||||
import { useProducerNames } from "@/platform/inspections";
|
||||
import { languageListChanged } from "@/platform/languageList";
|
||||
import { isThemeAvailable, parsePluginThemeRef, useContributedThemes } from "@/platform/themes";
|
||||
import {
|
||||
applyThemeAttributes,
|
||||
currentThemeChoice,
|
||||
currentThemeId,
|
||||
subscribeScheme,
|
||||
syncWindowBackground,
|
||||
useThemeOverride,
|
||||
windowBackgroundHex,
|
||||
} from "@/workbench/theme";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { refreshPlugins, runPluginCommand, usePlugins } from "./store";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { trackIconTheme } from "./iconThemes";
|
||||
import { defineBuiltinUiPoints } from "./points";
|
||||
import { clearUiContributions, syncUiContributions } from "./projection";
|
||||
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, usePlugins } from "./store";
|
||||
|
||||
export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`;
|
||||
export { pluginActionId } from "./points";
|
||||
|
||||
/** Actions for every command of every running plugin. */
|
||||
export function pluginActions(plugins: readonly PluginInfo[]): Action[] {
|
||||
return plugins
|
||||
.filter((p) => p.status === "active")
|
||||
.flatMap((p) =>
|
||||
p.commands.map((c): Action => ({
|
||||
id: pluginActionId(p.id, c.id),
|
||||
title: c.title,
|
||||
category: c.category ?? p.name,
|
||||
icon: "package",
|
||||
keywords: ["plugin", p.name, p.id],
|
||||
description: tx`From plugin ${p.name}`,
|
||||
run: async ({ arg }) => {
|
||||
await runPluginCommand(p.id, c.id, typeof arg === "string" ? arg : null);
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps the action registry in sync with the plugin list; re-registers only changed actions. */
|
||||
export function syncPluginActions(): () => void {
|
||||
const registered = new Map<string, { key: string; dispose: () => void }>();
|
||||
const apply = (plugins: readonly PluginInfo[]) => {
|
||||
const next = new Map(pluginActions(plugins).map((a) => [a.id, a]));
|
||||
for (const [id, entry] of registered) {
|
||||
if (!next.has(id)) {
|
||||
entry.dispose();
|
||||
registered.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, action] of next) {
|
||||
const key = `${action.title}\u0000${action.category}`;
|
||||
const prev = registered.get(id);
|
||||
if (prev?.key === key) continue;
|
||||
prev?.dispose();
|
||||
registered.set(id, { key, dispose: registerAction(action) });
|
||||
}
|
||||
/** Keeps the projected UI contributions in line with the plugin list. */
|
||||
export function syncProjection(): () => void {
|
||||
const apply = () => {
|
||||
const s = usePlugins.getState();
|
||||
syncUiContributions(s.ui, s.plugins);
|
||||
// Their problems are listed by name in Settings › Inspections.
|
||||
useProducerNames.setState({ names: new Map(s.plugins.map((p) => [`plugin:${p.id}`, p.name])) });
|
||||
};
|
||||
apply(usePlugins.getState().plugins);
|
||||
apply();
|
||||
const unsubscribe = usePlugins.subscribe((s, prev) => {
|
||||
if (s.plugins !== prev.plugins) apply(s.plugins);
|
||||
if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
registered.forEach((e) => e.dispose());
|
||||
registered.clear();
|
||||
clearUiContributions();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,35 +160,54 @@ export function trackPluginTheme(): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
// --- editor -----------------------------------------------------------------
|
||||
|
||||
/** Inserts (`select: false`) or replaces-and-selects (`select: true`) at every selection range. */
|
||||
export function applyTextToActiveEditor(text: string, select: boolean, pluginId: string): boolean {
|
||||
const editor = activeEditor();
|
||||
const name = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId;
|
||||
if (!editor || editor.ctx.readOnly) {
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: "warning",
|
||||
title: `${name}: ${editor ? "the editor is read-only" : "no editor to insert into"}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const { view } = editor;
|
||||
view.dispatch(
|
||||
view.state.changeByRange((range) => ({
|
||||
changes: { from: range.from, to: range.to, insert: text },
|
||||
range: select
|
||||
? EditorSelection.range(range.from, range.from + text.length)
|
||||
: EditorSelection.cursor(range.from + text.length),
|
||||
})),
|
||||
{ scrollIntoView: true, userEvent: "input.plugin" },
|
||||
);
|
||||
view.focus();
|
||||
return true;
|
||||
/**
|
||||
* Makes the plugin host's theme the one the Appearance settings select: a
|
||||
* plugin theme (`plugin:<id>/<theme>`, from any theme picker, the OS mode with
|
||||
* "Sync with OS", or the plugin's details page) is selected there once its
|
||||
* plugin is live; a built-in one clears it. While the selected plugin is off,
|
||||
* its built-in base shows and the selection is kept for when it is back.
|
||||
*/
|
||||
export function followThemeSetting(): () => void {
|
||||
let requested: string | undefined;
|
||||
const reconcile = () => {
|
||||
const plugins = usePlugins.getState();
|
||||
if (!plugins.loaded || plugins.error || !useProject.getState().settings?.appearance) return;
|
||||
const choice = currentThemeChoice();
|
||||
const want = parsePluginThemeRef(choice);
|
||||
const target = want && isThemeAvailable(choice) ? choice : null;
|
||||
const active = plugins.activeTheme;
|
||||
const current = active ? `plugin:${active.pluginId}/${active.theme.id}` : null;
|
||||
if (target === current || (want && !target && !current)) {
|
||||
requested = undefined;
|
||||
return;
|
||||
}
|
||||
// A selected plugin that is off: leave the host's selection for when it is back.
|
||||
if (want && !target) {
|
||||
if (requested === "") return;
|
||||
requested = "";
|
||||
void selectPluginTheme(null, null);
|
||||
return;
|
||||
}
|
||||
if (requested === (target ?? "")) return;
|
||||
requested = target ?? "";
|
||||
void selectPluginTheme(want && target ? want.pluginId : null, want && target ? want.themeId : null);
|
||||
};
|
||||
reconcile();
|
||||
const disposers = [
|
||||
usePlugins.subscribe((s, prev) => {
|
||||
if (s.loaded !== prev.loaded || s.activeTheme !== prev.activeTheme) reconcile();
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.settings?.appearance !== prev.settings?.appearance) reconcile();
|
||||
}),
|
||||
useThemeOverride.subscribe(reconcile),
|
||||
useContributedThemes.subscribe(reconcile),
|
||||
subscribeScheme(reconcile),
|
||||
];
|
||||
return () => disposers.forEach((d) => d());
|
||||
}
|
||||
|
||||
/** Reports the focused editor's file to the backend (`active-file` for plugins). */
|
||||
/** Reports the focused editor's file to the backend (`editor-focused` events). */
|
||||
function trackActiveFile(): () => void {
|
||||
let last: string | null | undefined;
|
||||
const report = (path: string | null) => {
|
||||
@@ -228,18 +233,33 @@ export function handlePluginEvent(e: PluginEvent) {
|
||||
notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined });
|
||||
break;
|
||||
}
|
||||
case "insertText":
|
||||
applyTextToActiveEditor(e.text, false, e.pluginId);
|
||||
break;
|
||||
case "replaceSelection":
|
||||
applyTextToActiveEditor(e.text, true, e.pluginId);
|
||||
break;
|
||||
case "themeChanged":
|
||||
usePlugins.setState({ activeTheme: e.theme });
|
||||
break;
|
||||
case "consentRequested":
|
||||
void refreshPlugins().then(() => grantPlugin(e.pluginId, e.capabilities));
|
||||
break;
|
||||
case "languagesChanged":
|
||||
void refreshLanguageInfos();
|
||||
languageListChanged();
|
||||
break;
|
||||
default:
|
||||
handleRuntimeEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For windows that only show plugins (Settings): stays in line with what
|
||||
* changes elsewhere — installs, enable / disable, the theme, languages —
|
||||
* while notifications and permission prompts stay with the project windows.
|
||||
*/
|
||||
export function followPluginChanges(): () => void {
|
||||
return pluginsBackend().onEvent((e) => {
|
||||
if (e.type === "commandsChanged" || e.type === "themeChanged" || e.type === "languagesChanged")
|
||||
handlePluginEvent(e);
|
||||
});
|
||||
}
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleRefresh() {
|
||||
@@ -247,15 +267,48 @@ function scheduleRefresh() {
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
void refreshPlugins();
|
||||
refreshEventKinds();
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires `on-language:<id>` and `on-file:<project-relative path>` for files
|
||||
* opened in the editor; the backend activates the plugins waiting for them.
|
||||
*/
|
||||
function trackActivationEvents(): () => void {
|
||||
const seenLanguages = new Set<string>();
|
||||
const report = (ctx: { path: string | null; language: string } | undefined) => {
|
||||
if (!ctx?.path) return;
|
||||
if (!seenLanguages.has(ctx.language)) {
|
||||
seenLanguages.add(ctx.language);
|
||||
void pluginsBackend()
|
||||
.activationEvent("language", ctx.language)
|
||||
.catch(() => seenLanguages.delete(ctx.language));
|
||||
}
|
||||
const root = useProject.getState().project?.root;
|
||||
if (root && ctx.path.startsWith(`${root}/`)) {
|
||||
void pluginsBackend()
|
||||
.activationEvent("file", ctx.path.slice(root.length + 1))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
report(useEditorRegistry.getState().active?.ctx);
|
||||
return useEditorRegistry.subscribe((s, prev) => {
|
||||
if (s.active?.ctx.path !== prev.active?.ctx.path) report(s.active?.ctx);
|
||||
});
|
||||
}
|
||||
|
||||
export function startPluginBridge(): () => void {
|
||||
const disposers = [
|
||||
defineBuiltinUiPoints(),
|
||||
pluginsBackend().onEvent(handlePluginEvent),
|
||||
syncPluginActions(),
|
||||
syncProjection(),
|
||||
trackPluginTheme(),
|
||||
followThemeSetting(),
|
||||
trackIconTheme(),
|
||||
trackActiveFile(),
|
||||
trackActivationEvents(),
|
||||
startRuntimeUi(),
|
||||
];
|
||||
return () => {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The install / permission dialog: every
|
||||
* capability with its risk; optional ones can be refused; high-risk ones are
|
||||
* called out as running code on this machine.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { CapabilityInfo, Consent } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Badge, Checkbox, Dialog, T } from "@/ui";
|
||||
import { setConsentPrompt, type ConsentRequest } from "./store";
|
||||
import styles from "./PluginsPage.module.css";
|
||||
|
||||
interface Pending {
|
||||
req: ConsentRequest;
|
||||
resolve: (c: Consent | null) => void;
|
||||
}
|
||||
|
||||
const usePending = create<{ queue: Pending[] }>(() => ({ queue: [] }));
|
||||
|
||||
export const RISK_LABEL: Record<CapabilityInfo["risk"], { label: string; tone: "neutral" | "warning" | "danger" }> = {
|
||||
low: { label: "Low", tone: "neutral" },
|
||||
medium: { label: "Medium", tone: "warning" },
|
||||
high: { label: "High", tone: "danger" },
|
||||
};
|
||||
|
||||
const RISK_ORDER = { high: 0, medium: 1, low: 2 } as const;
|
||||
|
||||
export function sortByRisk(caps: readonly CapabilityInfo[]): CapabilityInfo[] {
|
||||
return [...caps].sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk] || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
function ask(req: ConsentRequest): Promise<Consent | null> {
|
||||
return new Promise((resolve) => usePending.setState((s) => ({ queue: [...s.queue, { req, resolve }] })));
|
||||
}
|
||||
|
||||
function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => void }) {
|
||||
const { req } = pending;
|
||||
const caps = sortByRisk(req.capabilities);
|
||||
const optional = caps.filter((c) => c.optional);
|
||||
const [accepted, setAccepted] = useState<Set<string>>(
|
||||
() => new Set(optional.filter((c) => c.granted || c.risk === "low").map((c) => c.id)),
|
||||
);
|
||||
const high = caps.some((c) => c.risk === "high" && (!c.optional || accepted.has(c.id)));
|
||||
const finish = (consent: Consent | null) => {
|
||||
pending.resolve(consent);
|
||||
onDone();
|
||||
};
|
||||
const title = req.kind === "install" ? tx`Install ${req.name} ${req.version}?` : tx`Allow ${req.name} to run?`;
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={title}
|
||||
icon={high ? "warning" : "question"}
|
||||
width={520}
|
||||
onClose={() => finish(null)}
|
||||
buttons={[
|
||||
{ label: tx("Cancel"), role: "cancel", onClick: () => finish(null) },
|
||||
{
|
||||
label: req.kind === "install" ? tx("Install") : tx("Allow"),
|
||||
role: "default",
|
||||
onClick: () => finish({ optional: [...accepted] }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className={styles.consent}>
|
||||
{req.unsigned && (
|
||||
<p className={styles.consentWarning} role="note">
|
||||
<T>This package is not signed. Only continue if you trust where it came from.</T>
|
||||
</p>
|
||||
)}
|
||||
{caps.length === 0 ? (
|
||||
<p>
|
||||
<T>This plugin needs no special permissions.</T>
|
||||
</p>
|
||||
) : (
|
||||
<ul className={styles.items} aria-label={tx("Requested permissions")}>
|
||||
{caps.map((c) => (
|
||||
<li key={c.id} className={styles.item}>
|
||||
{c.optional ? (
|
||||
<Checkbox
|
||||
checked={accepted.has(c.id)}
|
||||
onChange={(on) =>
|
||||
setAccepted((s) => {
|
||||
const next = new Set(s);
|
||||
if (on) next.add(c.id);
|
||||
else next.delete(c.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
aria-label={tx`Allow ${c.id}`}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.consentRequired} title={tx("Required")}>
|
||||
•
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.itemMain}>{c.description}</span>
|
||||
<span className={styles.mono}>{c.id}</span>
|
||||
<Badge tone={RISK_LABEL[c.risk].tone}>{RISK_LABEL[c.risk].label}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{high && (
|
||||
<p className={styles.consentWarning} role="alert">
|
||||
<T>High-risk permissions are equivalent to running arbitrary code on this machine.</T>
|
||||
</p>
|
||||
)}
|
||||
{req.warnings.map((w) => (
|
||||
<p key={w} className={styles.muted}>
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Overlay rendering the queued consent dialogs one at a time. */
|
||||
export function ConsentDialogHost() {
|
||||
const first = usePending((s) => s.queue[0]);
|
||||
if (!first) return null;
|
||||
return (
|
||||
<ConsentDialog
|
||||
key={first.req.pluginId + first.req.kind}
|
||||
pending={first}
|
||||
onDone={() => usePending.setState((s) => ({ queue: s.queue.slice(1) }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Routes the store's consent requests to the dialog. */
|
||||
export function provideConsentDialog(): () => void {
|
||||
setConsentPrompt(ask);
|
||||
return () => {
|
||||
setConsentPrompt(null);
|
||||
for (const p of usePending.getState().queue) p.resolve(null);
|
||||
usePending.setState({ queue: [] });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The editor a plugin call is about: the window's active text editor, as
|
||||
* commands get it (path, language, selections in line / UTF-16 column). Tool
|
||||
* windows' renders and events carry it too, so their buttons act on what the
|
||||
* user is editing; a custom editor's calls carry its own document instead.
|
||||
*/
|
||||
import type { PluginEditor } from "@/bindings";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
|
||||
export function editorContext(): PluginEditor | null {
|
||||
const editor = activeEditor();
|
||||
if (!editor?.ctx.path) return null;
|
||||
const { doc, selection } = editor.view.state;
|
||||
const pos = (offset: number) => {
|
||||
const line = doc.lineAt(offset);
|
||||
return { line: line.number - 1, col: offset - line.from };
|
||||
};
|
||||
return {
|
||||
path: editor.ctx.path,
|
||||
language: editor.ctx.language,
|
||||
selections: selection.ranges.map((r) => ({ start: pos(r.from), end: pos(r.to) })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* File icon themes contributed by plugins (`iconThemes`). The selection is the
|
||||
* `appearance.fileIconTheme` setting (Settings › Appearance, or the plugin's
|
||||
* details page); the mapping and the sanitised SVGs are fetched from the
|
||||
* backend only when a theme is selected, so installed icon themes cost
|
||||
* nothing at start-up.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { addContributedIconTheme } from "@/platform/themes";
|
||||
import { registerExternalIcons, setFileIconTheme, type FileIconTheme } from "@/ui";
|
||||
import { pluginsBackend } from "./backend";
|
||||
|
||||
export interface IconThemeRef {
|
||||
pluginId: string;
|
||||
id: string;
|
||||
label: string;
|
||||
/** The plugin's name. */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface IconThemeMap {
|
||||
file: string | null;
|
||||
folder?: string | null;
|
||||
folderExpanded?: string | null;
|
||||
fileExtensions: Record<string, string>;
|
||||
fileNames: Record<string, string>;
|
||||
folderNames?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface IconThemesState {
|
||||
/** `<pluginId>/<themeId>` → theme. */
|
||||
available: ReadonlyMap<string, IconThemeRef>;
|
||||
/** Without a backend (tests, browser preview): the selection for this session. */
|
||||
override: string | null | undefined;
|
||||
/** The theme whose icons are registered. */
|
||||
applied: string | null;
|
||||
}
|
||||
|
||||
export const useIconThemes = create<IconThemesState>(() => ({
|
||||
available: new Map(),
|
||||
override: undefined,
|
||||
applied: null,
|
||||
}));
|
||||
|
||||
const fromSettings = (s: ReturnType<typeof useProject.getState>) =>
|
||||
(s.settings?.appearance as { fileIconTheme?: unknown } | undefined)?.fileIconTheme;
|
||||
|
||||
/** The selected icon theme (`<pluginId>/<themeId>`), or null for the built-in icons. */
|
||||
export function selectedIconTheme(): string | null {
|
||||
const { override } = useIconThemes.getState();
|
||||
if (override !== undefined) return override;
|
||||
const v = fromSettings(useProject.getState());
|
||||
return typeof v === "string" && v ? v : null;
|
||||
}
|
||||
|
||||
export function useSelectedIconTheme(): string | null {
|
||||
useIconThemes((s) => s.override);
|
||||
useProject((s) => fromSettings(s));
|
||||
return selectedIconTheme();
|
||||
}
|
||||
|
||||
export const iconThemeKey = (pluginId: string, id: string) => `${pluginId}/${id}`;
|
||||
|
||||
/** Called by the `iconThemes` projection: the theme can be selected while its plugin is live. */
|
||||
export function addIconTheme(theme: IconThemeRef): () => void {
|
||||
const key = iconThemeKey(theme.pluginId, theme.id);
|
||||
useIconThemes.setState((s) => ({ available: new Map(s.available).set(key, theme) }));
|
||||
const unlist = addContributedIconTheme({ id: key, label: theme.label, origin: theme.origin });
|
||||
return () => {
|
||||
unlist();
|
||||
useIconThemes.setState((s) => {
|
||||
const available = new Map(s.available);
|
||||
available.delete(key);
|
||||
return { available };
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function selectIconTheme(key: string | null) {
|
||||
if (!isTauri()) {
|
||||
useIconThemes.setState({ override: key });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
useProject.setState({
|
||||
settings: await call(commands.settingsSet("global", "appearance.fileIconTheme", JSON.stringify(key ?? ""))),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("cannot select the file icon theme", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme's resolver. Files: exact name, then the longest matching
|
||||
* extension, then its default file icon. Folders: exact name, then its
|
||||
* default (expanded) folder icon.
|
||||
*/
|
||||
export function iconResolver(
|
||||
map: IconThemeMap,
|
||||
iconName: (icon: string) => string,
|
||||
has: (name: string) => boolean,
|
||||
): FileIconTheme {
|
||||
const pick = (icon: string | undefined | null) => (icon && has(iconName(icon)) ? iconName(icon) : null);
|
||||
return {
|
||||
file(fileName) {
|
||||
const lower = fileName.toLowerCase();
|
||||
const named = pick(map.fileNames[lower]);
|
||||
if (named) return named;
|
||||
let rest = lower;
|
||||
for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) {
|
||||
rest = rest.slice(dot + 1);
|
||||
const byExt = pick(map.fileExtensions[rest]);
|
||||
if (byExt) return byExt;
|
||||
}
|
||||
return pick(map.file);
|
||||
},
|
||||
folder(folderName, expanded) {
|
||||
const named = pick(map.folderNames?.[folderName.toLowerCase()]);
|
||||
if (named) return named;
|
||||
return (expanded && pick(map.folderExpanded)) || pick(map.folder);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let undo: (() => void) | null = null;
|
||||
let loading: string | null = null;
|
||||
|
||||
async function apply(key: string | null) {
|
||||
const theme = key ? useIconThemes.getState().available.get(key) : undefined;
|
||||
if (!theme) {
|
||||
undo?.();
|
||||
undo = null;
|
||||
useIconThemes.setState({ applied: null });
|
||||
return;
|
||||
}
|
||||
if (useIconThemes.getState().applied === key || loading === key) return;
|
||||
loading = key;
|
||||
try {
|
||||
const prefix = `iconTheme/${theme.id}/`;
|
||||
const assets = await pluginsBackend().assets(theme.pluginId, prefix);
|
||||
if (loading !== key) return;
|
||||
const mapJson = assets.find(([id]) => id === `${prefix}map`)?.[1];
|
||||
if (!mapJson) throw new Error(`icon theme ${key} has no mapping`);
|
||||
const map = JSON.parse(mapJson) as IconThemeMap;
|
||||
const svgPrefix = `${prefix}svg/`;
|
||||
const iconName = (icon: string) => `ext:${key}/${icon}`;
|
||||
const icons = assets
|
||||
.filter(([id]) => id.startsWith(svgPrefix))
|
||||
.map(([id, svg]) => [iconName(id.slice(svgPrefix.length)), svg] as const);
|
||||
const names = new Set(icons.map(([n]) => n));
|
||||
undo?.();
|
||||
const unregister = registerExternalIcons(icons);
|
||||
setFileIconTheme(iconResolver(map, iconName, (n) => names.has(n)));
|
||||
undo = () => {
|
||||
setFileIconTheme(null);
|
||||
unregister();
|
||||
};
|
||||
useIconThemes.setState({ applied: key });
|
||||
} catch (e) {
|
||||
console.warn("could not load the file icon theme", e);
|
||||
} finally {
|
||||
if (loading === key) loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the registered icons in line with the selection and the available themes. */
|
||||
export function trackIconTheme(): () => void {
|
||||
const sync = () => {
|
||||
const selected = selectedIconTheme();
|
||||
void apply(selected && useIconThemes.getState().available.has(selected) ? selected : null);
|
||||
};
|
||||
sync();
|
||||
const unsubscribe = [
|
||||
useIconThemes.subscribe((s, prev) => {
|
||||
if (s.override !== prev.override || s.available !== prev.available) sync();
|
||||
}),
|
||||
// Settings are shared by every window: a choice made in one shows in all.
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (fromSettings(s) !== fromSettings(prev)) sync();
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
unsubscribe.forEach((u) => u());
|
||||
undo?.();
|
||||
undo = null;
|
||||
useIconThemes.setState({ applied: null });
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
/**
|
||||
* Plugins: Settings › Plugins (installed list, enable/disable, install from
|
||||
* disk, uninstall, theme selection) and the runtime bridge to the WASM plugin
|
||||
* host (`ide-plugin`).
|
||||
* disk with a permission page, uninstall, theme selection) and the runtime
|
||||
* bridge to the plugin host (`ide_host::plugins`): UI contributions are
|
||||
* projected by `projection.ts` / `points.tsx`.
|
||||
*
|
||||
* Actions: `plugins.settings`, `plugins.installFromDisk`, `plugins.reloadAll`,
|
||||
* plus one action per plugin command: `plugin.<pluginId>.<commandId>`
|
||||
* (searchable in Search Everywhere › Actions and bindable in the keymap editor;
|
||||
* a string arg is forwarded to the plugin).
|
||||
*
|
||||
* Editor contribution `plugins.lineMarkers`: gutter icons from plugin
|
||||
* `line-markers` providers (`lineMarkerGutter.ts`).
|
||||
*/
|
||||
import { registerEditorContribution } from "@/editor/api";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerSettingsPage } from "@/workbench/contributions";
|
||||
import { startPluginBridge, trackPluginTheme } from "./bridge";
|
||||
import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions";
|
||||
import { followPluginChanges, startPluginBridge, syncProjection, trackPluginTheme } from "./bridge";
|
||||
import { ConsentDialogHost, provideConsentDialog } from "./consent";
|
||||
import { trackIconTheme } from "./iconThemes";
|
||||
import { pluginLineMarkerGutter } from "./lineMarkerGutter";
|
||||
import { defineBuiltinUiPoints } from "./points";
|
||||
import { startViewUi } from "./uiPoints";
|
||||
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
|
||||
|
||||
function registerPluginsPage(): () => void {
|
||||
@@ -28,7 +38,16 @@ function registerPluginsPage(): () => void {
|
||||
|
||||
/** Settings › Plugins for the Settings window: the page plus the plugin theme. */
|
||||
export function activateSettingsPages(): () => void {
|
||||
const disposers = [registerPluginsPage(), trackPluginTheme()];
|
||||
const disposers = [
|
||||
registerPluginsPage(),
|
||||
registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }),
|
||||
provideConsentDialog(),
|
||||
defineBuiltinUiPoints(),
|
||||
followPluginChanges(),
|
||||
syncProjection(),
|
||||
trackPluginTheme(),
|
||||
trackIconTheme(),
|
||||
];
|
||||
void refreshPlugins();
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -36,6 +55,8 @@ export function activateSettingsPages(): () => void {
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
registerPluginsPage(),
|
||||
registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }),
|
||||
provideConsentDialog(),
|
||||
registerActions([
|
||||
{
|
||||
id: "plugins.settings",
|
||||
@@ -68,6 +89,10 @@ export function activate(): () => void {
|
||||
},
|
||||
]),
|
||||
startPluginBridge(),
|
||||
// Plugin custom editors (tab kind) and context menu entries.
|
||||
startViewUi((input) => void editorService().openInput(input)),
|
||||
// Gutter icons from plugins' `line-markers` providers (next to the LSP ones).
|
||||
registerEditorContribution({ id: "plugins.lineMarkers", order: 57, extensions: pluginLineMarkerGutter }),
|
||||
// Dev plugins (`<project>/.openidea/plugins`) depend on the open project.
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) void refreshPlugins();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Gutter icons from plugins' `line-markers` providers (MyBatis' arrows
|
||||
* between a mapper interface and its XML). Asked once per document version
|
||||
* (debounced); markers follow their lines in between; a click opens the
|
||||
* marker's target. The gutter column exists only while there are markers,
|
||||
* so editors without plugin providers keep their usual width.
|
||||
*/
|
||||
import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { navigateTo } from "@/editor/lsp/navigation";
|
||||
import { commands, type LineMarkerInfo } from "@/platform/ipc";
|
||||
import { gutterIconMarkup } from "@/ui";
|
||||
import styles from "@/editor/navigation/LineMarkers.module.css";
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
implemented: "gutter-implemented",
|
||||
implementing: "gutter-implementing",
|
||||
overridden: "gutter-overridden",
|
||||
overriding: "gutter-overriding",
|
||||
run: "gutter-run",
|
||||
navigate: "gutter-implementing",
|
||||
};
|
||||
|
||||
/** Icon name for a plugin marker (`navigate` when unknown). */
|
||||
export function markerIcon(icon: string | null): string {
|
||||
return ICONS[icon ?? "navigate"] ?? ICONS.navigate!;
|
||||
}
|
||||
|
||||
interface Placed {
|
||||
marker: LineMarkerInfo;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
const setMarkers = StateEffect.define<Placed[]>();
|
||||
|
||||
const markersField = StateField.define<Placed[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setMarkers)) return e.value;
|
||||
if (!tr.docChanged || !value.length) return value;
|
||||
return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from }));
|
||||
},
|
||||
});
|
||||
|
||||
/** Markers on existing lines, at their line starts. */
|
||||
export function placeMarkers(doc: Text, markers: readonly LineMarkerInfo[]): Placed[] {
|
||||
return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from }));
|
||||
}
|
||||
|
||||
class PluginMarker extends GutterMarker {
|
||||
constructor(readonly marker: LineMarkerInfo) {
|
||||
super();
|
||||
}
|
||||
override eq(other: GutterMarker): boolean {
|
||||
return (
|
||||
other instanceof PluginMarker &&
|
||||
other.marker.tooltip === this.marker.tooltip &&
|
||||
other.marker.icon === this.marker.icon
|
||||
);
|
||||
}
|
||||
override toDOM(): Node {
|
||||
const el = document.createElement("div");
|
||||
el.className = styles.marker!;
|
||||
el.title = this.marker.tooltip;
|
||||
el.setAttribute("role", "button");
|
||||
el.setAttribute("aria-label", this.marker.tooltip);
|
||||
el.innerHTML = gutterIconMarkup(markerIcon(this.marker.icon)) ?? "";
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
class MarkersPlugin {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private generation = 0;
|
||||
private shown = false;
|
||||
|
||||
constructor(
|
||||
private view: EditorView,
|
||||
private doc: number,
|
||||
private column: Compartment,
|
||||
) {
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged) this.schedule();
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.run(), DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async run() {
|
||||
this.timer = null;
|
||||
const gen = ++this.generation;
|
||||
const text = this.view.state.doc;
|
||||
try {
|
||||
const markers = await commands.pluginLineMarkers(this.doc);
|
||||
if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return;
|
||||
if (markers.status !== "ok") return;
|
||||
const placed = placeMarkers(text, markers.data);
|
||||
if (!placed.length && !this.view.state.field(markersField).length) return;
|
||||
const effects: StateEffect<unknown>[] = [setMarkers.of(placed)];
|
||||
if (placed.length > 0 !== this.shown) {
|
||||
this.shown = placed.length > 0;
|
||||
effects.push(this.column.reconfigure(this.shown ? markerColumn : []));
|
||||
}
|
||||
this.view.dispatch({ effects });
|
||||
} catch (e) {
|
||||
console.debug("[plugin line markers]", e);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.generation++;
|
||||
}
|
||||
}
|
||||
|
||||
const markerColumn: Extension = gutter({
|
||||
class: `${styles.gutter} cm-openidea-gutter-pluginMarkers`,
|
||||
markers: (view) =>
|
||||
RangeSet.of(
|
||||
view.state.field(markersField).map((m) => new PluginMarker(m.marker).range(m.pos)),
|
||||
true,
|
||||
),
|
||||
domEventHandlers: {
|
||||
mousedown: (view, block, event) => {
|
||||
const e = event as MouseEvent;
|
||||
if (e.button !== 0) return false;
|
||||
const target = view.state.field(markersField).find((m) => m.pos === block.from && m.marker.target)?.marker.target;
|
||||
if (!target) return false;
|
||||
e.preventDefault();
|
||||
void navigateTo(target);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function pluginLineMarkerGutter(ctx: EditorDocContext): Extension {
|
||||
if (ctx.doc < 0) return [];
|
||||
const doc = ctx.doc;
|
||||
const column = new Compartment();
|
||||
return [markersField, column.of([]), ViewPlugin.define((view) => new MarkersPlugin(view, doc, column))];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Client side of the first-party UI extension points: how each validated
|
||||
* declaration is projected onto the workbench, and how its items appear on
|
||||
* the plugin details page. One `defineUiPoint` per contribution type.
|
||||
*/
|
||||
import type { PluginInfo, ThemeDef } from "@/bindings";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { addContributedTheme, pluginThemeRef } from "@/platform/themes";
|
||||
import { currentThemeChoice, setTheme, useThemeOverride } from "@/workbench/theme";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Button, Kbd, T } from "@/ui";
|
||||
import { addIconTheme, iconThemeKey, selectIconTheme, useSelectedIconTheme } from "./iconThemes";
|
||||
import { defineUiPoint, type UiItem } from "./projection";
|
||||
import { SecretsDetails, type SecretDecl } from "./secrets";
|
||||
import { defineViewUiPoints } from "./uiPoints";
|
||||
import { isLive, runPluginCommand } from "./store";
|
||||
import styles from "./PluginsPage.module.css";
|
||||
|
||||
export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`;
|
||||
|
||||
interface CommandDecl {
|
||||
id: string;
|
||||
title: string;
|
||||
category?: string | null;
|
||||
when?: string | null;
|
||||
}
|
||||
|
||||
function CommandsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<CommandDecl>[] }) {
|
||||
const runnable = plugin.status === "active" || plugin.status === "inactive";
|
||||
return (
|
||||
<ul className={styles.items}>
|
||||
{items.map(({ decl: c }) => {
|
||||
const actionId = pluginActionId(plugin.id, c.id);
|
||||
return (
|
||||
<li key={c.id} className={styles.item}>
|
||||
<span className={styles.itemMain}>{c.title}</span>
|
||||
<Kbd action={actionId} />
|
||||
<span className={styles.mono} title={tx("Action id (bind it in Keymap)")}>
|
||||
{actionId}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
icon="run"
|
||||
disabled={!runnable}
|
||||
onClick={() => void runPluginCommand(plugin.id, c.id)}
|
||||
aria-label={tx`Run ${c.title}`}
|
||||
>
|
||||
<T>Run</T>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<ThemeDef>[] }) {
|
||||
// The selection is the Appearance setting; these buttons write it as the theme pickers do.
|
||||
useProject((s) => s.settings?.appearance);
|
||||
useThemeOverride((s) => s.theme);
|
||||
const selected = currentThemeChoice();
|
||||
const live = isLive(plugin.status);
|
||||
return (
|
||||
<ul className={styles.items}>
|
||||
{items.map(({ decl: t }) => {
|
||||
const isActive = selected === pluginThemeRef(plugin.id, t.id);
|
||||
return (
|
||||
<li key={t.id} className={styles.item}>
|
||||
<span className={styles.itemMain}>{t.name}</span>
|
||||
<span className={styles.muted}>{t.kind === "dark" ? tx("Dark") : tx("Light")}</span>
|
||||
{isActive ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => void setTheme(t.kind === "light" ? "light" : "dark")}
|
||||
>
|
||||
<T>Use Default Theme</T>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
disabled={!live}
|
||||
onClick={() => void setTheme(pluginThemeRef(plugin.id, t.id))}
|
||||
aria-label={tx`Apply theme ${t.name}`}
|
||||
>
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface IconThemeDecl {
|
||||
id: string;
|
||||
label: string;
|
||||
icons: number;
|
||||
}
|
||||
|
||||
function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<IconThemeDecl>[] }) {
|
||||
const selected = useSelectedIconTheme();
|
||||
return (
|
||||
<ul className={styles.items}>
|
||||
{items.map(({ decl: t }) => {
|
||||
const key = iconThemeKey(plugin.id, t.id);
|
||||
return (
|
||||
<li key={t.id} className={styles.item}>
|
||||
<span className={styles.itemMain}>{t.label}</span>
|
||||
<span className={styles.muted}>{tx`${t.icons} icons`}</span>
|
||||
{selected === key ? (
|
||||
<Button size="small" variant="secondary" onClick={() => void selectIconTheme(null)}>
|
||||
<T>Use Default Icons</T>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => void selectIconTheme(key)}
|
||||
aria-label={tx`Apply icon theme ${t.label}`}
|
||||
>
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Registers the client side of every first-party UI point. */
|
||||
export function defineBuiltinUiPoints(): () => void {
|
||||
const disposers = [
|
||||
defineUiPoint<CommandDecl>("commands", {
|
||||
title: "Commands",
|
||||
project: ({ pluginId, decl }, { plugin }) =>
|
||||
registerAction({
|
||||
id: pluginActionId(pluginId, decl.id),
|
||||
title: decl.title,
|
||||
category: decl.category ?? plugin.name,
|
||||
icon: "package",
|
||||
when: decl.when ?? undefined,
|
||||
keywords: ["plugin", plugin.name, pluginId],
|
||||
description: tx`From plugin ${plugin.name}`,
|
||||
run: async ({ arg }) => {
|
||||
await runPluginCommand(pluginId, decl.id, typeof arg === "string" ? arg : null);
|
||||
},
|
||||
}),
|
||||
Details: CommandsDetails,
|
||||
}),
|
||||
// Offered by every theme picker while the plugin is live; `followThemeSetting` applies the selected one.
|
||||
defineUiPoint<ThemeDef>("themes", {
|
||||
title: "Themes",
|
||||
project: ({ pluginId, decl }, { plugin }) =>
|
||||
addContributedTheme({
|
||||
id: pluginThemeRef(pluginId, decl.id),
|
||||
label: decl.name,
|
||||
kind: decl.kind === "light" ? "light" : "dark",
|
||||
origin: plugin.name,
|
||||
}),
|
||||
Details: ThemesDetails,
|
||||
}),
|
||||
defineUiPoint<IconThemeDecl>("iconThemes", {
|
||||
title: "File Icon Themes",
|
||||
project: ({ pluginId, decl }, { plugin }) =>
|
||||
addIconTheme({ pluginId, id: decl.id, label: decl.label, origin: plugin.name }),
|
||||
Details: IconThemesDetails,
|
||||
}),
|
||||
// Stored by the backend; listed here with a field per secret.
|
||||
defineUiPoint<SecretDecl>("secrets", { title: "Secrets", Details: SecretsDetails }),
|
||||
defineViewUiPoints(),
|
||||
];
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Projection of plugin UI contributions onto the workbench's own `register*`
|
||||
* functions.
|
||||
*
|
||||
* The backend validated every declaration completely (Rust extension points);
|
||||
* a projection maps one onto an existing registration and returns its
|
||||
* disposer. Projections must not fail: an exception is a defect, logged, and
|
||||
* that one item is skipped — nothing is half-registered.
|
||||
*
|
||||
* Adding a UI contribution type = a Rust extension point + one
|
||||
* `defineUiPoint` here; the kernel (this file, the store, the bridge) is not
|
||||
* touched.
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginInfo, UiContribution } from "@/bindings";
|
||||
|
||||
export interface UiItem<T = Record<string, unknown>> {
|
||||
pluginId: string;
|
||||
point: string;
|
||||
key: string;
|
||||
decl: T;
|
||||
}
|
||||
|
||||
export interface ProjectionContext {
|
||||
plugin: PluginInfo;
|
||||
}
|
||||
|
||||
export interface UiPointDefinition<T = Record<string, unknown>> {
|
||||
/** Makes one item take effect; returns its disposer. */
|
||||
project?: (item: UiItem<T>, ctx: ProjectionContext) => (() => void) | void;
|
||||
/** Optional section in Settings › Plugins › details for this point's items. */
|
||||
Details?: ComponentType<{ plugin: PluginInfo; items: UiItem<T>[] }>;
|
||||
/** Section title for the details page (defaults to the point id). */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const points = new Map<string, UiPointDefinition<never>>();
|
||||
const live = new Map<string, { sig: string; dispose: () => void }>();
|
||||
const warned = new Set<string>();
|
||||
let lastItems: readonly UiContribution[] = [];
|
||||
let lastPlugins: readonly PluginInfo[] = [];
|
||||
|
||||
const itemKey = (c: UiContribution) => `${c.pluginId}\u0000${c.point}\u0000${c.key}`;
|
||||
|
||||
/** Registers the client side of an extension point. */
|
||||
export function defineUiPoint<T>(point: string, def: UiPointDefinition<T>): () => void {
|
||||
if (points.has(point)) throw new Error(`UI point "${point}" is already defined`);
|
||||
points.set(point, def as UiPointDefinition<never>);
|
||||
// Items that arrived before the definition (lazy features) are projected now.
|
||||
syncUiContributions(lastItems, lastPlugins);
|
||||
return () => {
|
||||
for (const [key, entry] of live) {
|
||||
if (key.split("\u0000")[1] === point) {
|
||||
safeDispose(entry.dispose);
|
||||
live.delete(key);
|
||||
}
|
||||
}
|
||||
points.delete(point);
|
||||
};
|
||||
}
|
||||
|
||||
export function uiPoint(point: string): UiPointDefinition<never> | undefined {
|
||||
return points.get(point);
|
||||
}
|
||||
|
||||
function safeDispose(dispose: () => void) {
|
||||
try {
|
||||
dispose();
|
||||
} catch (e) {
|
||||
console.error("plugin contribution disposer failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the registrations in line with `items` (the backend's snapshot):
|
||||
* removed or changed items are disposed, new or changed ones projected.
|
||||
*/
|
||||
export function syncUiContributions(items: readonly UiContribution[], plugins: readonly PluginInfo[]) {
|
||||
lastItems = items;
|
||||
lastPlugins = plugins;
|
||||
const byId = new Map(plugins.map((p) => [p.id, p]));
|
||||
const next = new Map<string, { item: UiContribution; sig: string }>();
|
||||
for (const item of items) {
|
||||
if (!points.get(item.point)?.project) {
|
||||
if (!points.has(item.point) && !warned.has(item.point)) {
|
||||
warned.add(item.point);
|
||||
console.warn(`plugin UI point "${item.point}" has no client projection`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!byId.has(item.pluginId)) continue;
|
||||
next.set(itemKey(item), { item, sig: JSON.stringify(item.decl) });
|
||||
}
|
||||
for (const [key, entry] of live) {
|
||||
if (next.get(key)?.sig !== entry.sig) {
|
||||
safeDispose(entry.dispose);
|
||||
live.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, { item, sig }] of next) {
|
||||
if (live.has(key)) continue;
|
||||
const def = points.get(item.point)!;
|
||||
const plugin = byId.get(item.pluginId)!;
|
||||
try {
|
||||
const dispose = def.project!(item as UiItem<never>, { plugin }) ?? (() => {});
|
||||
live.set(key, { sig, dispose });
|
||||
} catch (e) {
|
||||
// The backend validated the declaration: a failing projection is a defect.
|
||||
console.error(`projecting ${item.point} "${item.key}" of plugin ${item.pluginId} failed`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Disposes every projected registration (shutdown, tests). */
|
||||
export function clearUiContributions() {
|
||||
for (const entry of live.values()) safeDispose(entry.dispose);
|
||||
live.clear();
|
||||
lastItems = [];
|
||||
lastPlugins = [];
|
||||
}
|
||||
|
||||
/** Items of one point, optionally of one plugin. */
|
||||
export function itemsOf<T = Record<string, unknown>>(
|
||||
items: readonly UiContribution[],
|
||||
point: string,
|
||||
pluginId?: string,
|
||||
): UiItem<T>[] {
|
||||
return items.filter((c) => c.point === point && (!pluginId || c.pluginId === pluginId)) as UiItem<T>[];
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* What running plugin code shows outside its declarations: status bar items
|
||||
* (`ui.set-status-item`), background tasks (`tasks.begin`), and the
|
||||
* workbench events it subscribed to (`events.subscribe`), reported in
|
||||
* batches.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import type { EventKind, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings";
|
||||
import { onEditorEvent } from "@/editor/api";
|
||||
import { executeAction, getAction, onActionExecuted } from "@/platform/actions";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { BarButton } from "@/ui/BarButton";
|
||||
import { registerStatusItem } from "@/workbench/contributions";
|
||||
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { bumpView, deliverViewMessage } from "./views/viewStore";
|
||||
import { runPluginCommand, usePlugins } from "./store";
|
||||
|
||||
// --- status bar items ---------------------------------------------------------
|
||||
|
||||
type ItemKey = `${string}/${string}`;
|
||||
|
||||
export const usePluginStatusItems = create<{ items: Map<ItemKey, { pluginId: string; item: StatusItem }> }>(() => ({
|
||||
items: new Map(),
|
||||
}));
|
||||
|
||||
function setStatusItem(pluginId: string, id: string, item: StatusItem | null) {
|
||||
usePluginStatusItems.setState((s) => {
|
||||
const items = new Map(s.items);
|
||||
if (item) items.set(`${pluginId}/${id}`, { pluginId, item });
|
||||
else items.delete(`${pluginId}/${id}`);
|
||||
return { items };
|
||||
});
|
||||
}
|
||||
|
||||
/** Items declared under `[[contributes.statusBar]]`: shown until (and unless) the code sets its own. */
|
||||
interface Declared {
|
||||
item: StatusItem;
|
||||
align: "left" | "right";
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export const useDeclaredStatusItems = create<{ items: Map<ItemKey, { pluginId: string } & Declared> }>(() => ({
|
||||
items: new Map(),
|
||||
}));
|
||||
|
||||
export function declareStatusItem(pluginId: string, id: string, declared: Declared): () => void {
|
||||
const key: ItemKey = `${pluginId}/${id}`;
|
||||
const entry = { pluginId, ...declared };
|
||||
useDeclaredStatusItems.setState((s) => ({ items: new Map(s.items).set(key, entry) }));
|
||||
return () =>
|
||||
useDeclaredStatusItems.setState((s) => {
|
||||
if (s.items.get(key) !== entry) return s;
|
||||
const items = new Map(s.items);
|
||||
items.delete(key);
|
||||
return { items };
|
||||
});
|
||||
}
|
||||
|
||||
function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) {
|
||||
const running = usePluginStatusItems((s) => s.items.get(itemKey));
|
||||
const declared = useDeclaredStatusItems((s) => s.items.get(itemKey));
|
||||
const entry = running ?? declared;
|
||||
if (!entry) return null;
|
||||
const { pluginId, item } = entry;
|
||||
const command = item.command;
|
||||
return (
|
||||
<BarButton
|
||||
tooltip={item.tooltip ?? undefined}
|
||||
aria-label={item.tooltip ?? item.text}
|
||||
onClick={command ? () => void runPluginCommand(pluginId, command) : undefined}
|
||||
>
|
||||
{item.text}
|
||||
</BarButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** The item's name in the status bar menu: its plugin's, plus what it declares (tooltip or text). */
|
||||
function statusItemTitle(key: ItemKey, declared: Declared | undefined): string {
|
||||
const pluginId = key.slice(0, key.indexOf("/"));
|
||||
const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId;
|
||||
const what = declared?.item.tooltip || declared?.item.text;
|
||||
return what ? `${plugin}: ${what}` : plugin;
|
||||
}
|
||||
|
||||
/** One status bar contribution per plugin item (declared or set by code), registered while it exists. */
|
||||
function projectStatusItems(): () => void {
|
||||
const registered = new Map<ItemKey, { dispose: () => void; sig: string }>();
|
||||
const sync = () => {
|
||||
const running = usePluginStatusItems.getState().items;
|
||||
const declared = useDeclaredStatusItems.getState().items;
|
||||
const live = new Set<ItemKey>([...running.keys(), ...declared.keys()]);
|
||||
for (const [key, r] of registered) {
|
||||
const d = declared.get(key);
|
||||
const sig = d ? `${d.align}/${d.priority}` : "";
|
||||
if (!live.has(key) || r.sig !== sig) {
|
||||
r.dispose();
|
||||
registered.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of live)
|
||||
if (!registered.has(key)) {
|
||||
const d = declared.get(key);
|
||||
const View = () => <PluginStatusItemView itemKey={key} />;
|
||||
const dispose = registerStatusItem({
|
||||
id: `plugin.${key}`,
|
||||
// Named in the status bar's context menu, so it can be hidden like the built-in widgets.
|
||||
title: statusItemTitle(key, d),
|
||||
align: d?.align ?? "right",
|
||||
priority: d?.priority ?? 2,
|
||||
component: View,
|
||||
});
|
||||
registered.set(key, { dispose, sig: d ? `${d.align}/${d.priority}` : "" });
|
||||
}
|
||||
};
|
||||
sync();
|
||||
const unsubscribe = [
|
||||
usePluginStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()),
|
||||
useDeclaredStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()),
|
||||
];
|
||||
refreshStatusItems();
|
||||
return () => {
|
||||
unsubscribe.forEach((u) => u());
|
||||
for (const r of registered.values()) r.dispose();
|
||||
registered.clear();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the status items of running plugin code (later changes arrive as
|
||||
* `statusItemChanged`). Like the event kinds, the backend answers once this
|
||||
* window's project is open and trusted, so call it again when that happens.
|
||||
*/
|
||||
function refreshStatusItems() {
|
||||
void pluginsBackend()
|
||||
.statusItems()
|
||||
.then((items) => items.forEach((i) => setStatusItem(i.pluginId, i.id, i.item)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// --- tasks --------------------------------------------------------------------
|
||||
|
||||
const taskId = (id: string) => `plugin:${id}`;
|
||||
|
||||
function onTaskEvent(e: Extract<PluginEvent, { type: "taskStarted" | "taskProgress" | "taskEnded" }>) {
|
||||
const id = taskId(e.taskId);
|
||||
const current = useTasks.getState().tasks.find((t) => t.id === id);
|
||||
switch (e.type) {
|
||||
case "taskStarted":
|
||||
upsertTask({
|
||||
id,
|
||||
title: e.title,
|
||||
message: null,
|
||||
fraction: null,
|
||||
done: false,
|
||||
cancel: e.cancellable ? () => void pluginsBackend().cancelTask(e.taskId) : undefined,
|
||||
});
|
||||
break;
|
||||
case "taskProgress":
|
||||
if (current)
|
||||
upsertTask({
|
||||
...current,
|
||||
message: e.message,
|
||||
fraction: e.total ? Math.min(1, e.done / e.total) : null,
|
||||
});
|
||||
break;
|
||||
case "taskEnded":
|
||||
if (current) upsertTask({ ...current, done: true });
|
||||
else removeTask(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- workbench events -----------------------------------------------------------
|
||||
|
||||
/** Batches are sent at most this often; the backend batches again per plugin. */
|
||||
const POST_INTERVAL_MS = 500;
|
||||
|
||||
let wanted = new Set<EventKind>();
|
||||
let queue: WorkbenchEvent[] = [];
|
||||
let postTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Re-reads the event kinds plugins want. Besides `subscriptionsChanged`
|
||||
* (run-time subscriptions), declared `on-event:` kinds change whenever plugins
|
||||
* load, install, toggle or get grants, which the backend reports as `commandsChanged`.
|
||||
*/
|
||||
export function refreshEventKinds() {
|
||||
void pluginsBackend()
|
||||
.eventKinds()
|
||||
.then((kinds) => (wanted = new Set(kinds)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function post(event: WorkbenchEvent) {
|
||||
queue.push(event);
|
||||
if (postTimer) return;
|
||||
postTimer = setTimeout(() => {
|
||||
postTimer = null;
|
||||
const batch = queue;
|
||||
queue = [];
|
||||
void pluginsBackend()
|
||||
.postEvents(batch)
|
||||
.catch(() => {});
|
||||
}, POST_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Reports the events plugins subscribed to: files opened and saved, actions run, repositories changed. */
|
||||
function trackWorkbenchEvents(): () => void {
|
||||
refreshEventKinds();
|
||||
const offEditor = onEditorEvent((e) => {
|
||||
if (e.type === "docOpened" && wanted.has("file-opened")) post({ kind: "fileOpened", value: e.path });
|
||||
if (e.type === "docSaved" && wanted.has("file-saved")) post({ kind: "fileSaved", value: e.path });
|
||||
});
|
||||
const offActions = onActionExecuted((id, _arg, source) => {
|
||||
if (!wanted.has("action-performed")) return;
|
||||
const action = getAction(id);
|
||||
post({
|
||||
kind: "actionPerformed",
|
||||
value: { action: id, title: action?.title ?? id, source, shortcut: shortcutLabel(id) ?? null },
|
||||
});
|
||||
});
|
||||
const offVcs = onIdeEvent("vcsChanged", (e) => {
|
||||
if (wanted.has("vcs-changed")) post({ kind: "vcsChanged", value: e.root });
|
||||
});
|
||||
return () => {
|
||||
offEditor();
|
||||
offActions();
|
||||
offVcs();
|
||||
if (postTimer) clearTimeout(postTimer);
|
||||
postTimer = null;
|
||||
queue = [];
|
||||
};
|
||||
}
|
||||
|
||||
/** Handles the runtime UI events; returns whether `e` was one. */
|
||||
export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
switch (e.type) {
|
||||
case "statusItemChanged":
|
||||
setStatusItem(e.pluginId, e.id, e.item);
|
||||
return true;
|
||||
case "taskStarted":
|
||||
case "taskProgress":
|
||||
case "taskEnded":
|
||||
onTaskEvent(e);
|
||||
return true;
|
||||
case "subscriptionsChanged":
|
||||
refreshEventKinds();
|
||||
return true;
|
||||
case "viewChanged":
|
||||
bumpView(e.pluginId, e.view);
|
||||
return true;
|
||||
case "viewMessage":
|
||||
deliverViewMessage(e.pluginId, e.view, e.message);
|
||||
return true;
|
||||
case "openTerminal":
|
||||
// The program was approved (`process:`) and verified by the host.
|
||||
void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd });
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend refuses the event kinds and status items until this window's
|
||||
* project is open and trusted (`authorize_command`), but startup asks before
|
||||
* that: ask again when it changes.
|
||||
*/
|
||||
function refreshOnProjectChange(): () => void {
|
||||
return useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
|
||||
refreshEventKinds();
|
||||
refreshStatusItems();
|
||||
});
|
||||
}
|
||||
|
||||
export function startRuntimeUi(): () => void {
|
||||
const disposers = [projectStatusItems(), trackWorkbenchEvents(), refreshOnProjectChange()];
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* `[[contributes.secrets]]` on the plugin details page: a password field per
|
||||
* declared secret. Values go to the backend's secret store (the macOS
|
||||
* Keychain in the desktop app) and never come back to the UI; plugins use
|
||||
* them only as `{{secret:<name>}}` placeholders in `net` request headers.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { PluginInfo, PluginSecretState } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Badge, Button, Input, T } from "@/ui";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import type { UiItem } from "./projection";
|
||||
import styles from "./PluginsPage.module.css";
|
||||
|
||||
export interface SecretDecl {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
function SecretRow({
|
||||
plugin,
|
||||
decl,
|
||||
isSet,
|
||||
onChanged,
|
||||
}: {
|
||||
plugin: PluginInfo;
|
||||
decl: SecretDecl;
|
||||
isSet: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const save = async (v: string | null) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await pluginsBackend().setSecret(plugin.id, decl.name, v);
|
||||
setValue("");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
<span className={styles.itemMain} title={decl.description ?? undefined}>
|
||||
{decl.label}
|
||||
</span>
|
||||
{isSet ? <Badge tone="success">{tx("Stored")}</Badge> : <Badge tone="neutral">{tx("Not set")}</Badge>}
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
disabled={busy}
|
||||
placeholder={isSet ? "Replace…" : "Value"}
|
||||
aria-label={tx`Value of ${decl.label}`}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && value) void save(value);
|
||||
}}
|
||||
/>
|
||||
<Button size="small" variant="ghost" disabled={busy || !value} onClick={() => void save(value)}>
|
||||
<T>Save</T>
|
||||
</Button>
|
||||
{isSet && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void save(null)}
|
||||
aria-label={tx`Remove ${decl.label}`}
|
||||
>
|
||||
<T>Remove</T>
|
||||
</Button>
|
||||
)}
|
||||
{error && (
|
||||
<span className={styles.error} role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecretsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<SecretDecl>[] }) {
|
||||
const [state, setState] = useState<PluginSecretState[]>([]);
|
||||
const refresh = useCallback(() => {
|
||||
pluginsBackend()
|
||||
.secrets(plugin.id)
|
||||
.then(setState, () => setState([]));
|
||||
}, [plugin.id]);
|
||||
useEffect(refresh, [refresh]);
|
||||
return (
|
||||
<>
|
||||
<ul className={styles.items} aria-label={tx("Secrets")}>
|
||||
{items.map(({ decl }) => (
|
||||
<SecretRow
|
||||
key={decl.name}
|
||||
plugin={plugin}
|
||||
decl={decl}
|
||||
isSet={state.some((s) => s.name === decl.name && s.set)}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.muted}>
|
||||
<T>The plugin can only send these with its network requests; it cannot read them.</T>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
/** Installed plugins, the selected plugin theme, and the operations on them. */
|
||||
/** Installed plugins, their UI contributions, the selected plugin theme, and the operations on them. */
|
||||
import { create } from "zustand";
|
||||
import type { ActiveTheme, CatalogPlugin, PluginInfo } from "@/bindings";
|
||||
import type {
|
||||
ActiveTheme,
|
||||
CapabilityInfo,
|
||||
CatalogPlugin,
|
||||
Consent,
|
||||
MarketPlugin,
|
||||
PluginInfo,
|
||||
PluginStatus,
|
||||
UiContribution,
|
||||
} from "@/bindings";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { pluginsBackend, type PluginsBackend } from "./backend";
|
||||
import { editorContext } from "./editorContext";
|
||||
|
||||
export const SETTINGS_PAGE_ID = "plugins";
|
||||
|
||||
interface PluginsState {
|
||||
plugins: PluginInfo[];
|
||||
/** UI contributions of live plugins, projected by `projection.ts`. */
|
||||
ui: UiContribution[];
|
||||
activeTheme: ActiveTheme | null;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
@@ -18,10 +30,16 @@ interface PluginsState {
|
||||
/** Locally available packages (Marketplace tab); `null` until loaded. */
|
||||
catalog: CatalogPlugin[] | null;
|
||||
catalogError: string | null;
|
||||
/** The marketplace index URL; `null` when none is configured. */
|
||||
marketUrl: string | null;
|
||||
/** Plugins of the marketplace index; `null` until read. */
|
||||
market: MarketPlugin[] | null;
|
||||
marketError: string | null;
|
||||
}
|
||||
|
||||
export const usePlugins = create<PluginsState>(() => ({
|
||||
plugins: [],
|
||||
ui: [],
|
||||
activeTheme: null,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
@@ -30,10 +48,48 @@ export const usePlugins = create<PluginsState>(() => ({
|
||||
installing: false,
|
||||
catalog: null,
|
||||
catalogError: null,
|
||||
marketUrl: null,
|
||||
market: null,
|
||||
marketError: null,
|
||||
}));
|
||||
|
||||
export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/** Statuses whose contributions are in effect. */
|
||||
export const isLive = (status: PluginStatus) => status === "active" || status === "inactive" || status === "static";
|
||||
|
||||
// --- consent ------------------------------------------------------------------
|
||||
|
||||
/** What a consent prompt shows: an install preview or a plugin waiting for permission. */
|
||||
export interface ConsentRequest {
|
||||
pluginId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
/** Install page (new or update) or a permission prompt for an installed/project plugin. */
|
||||
kind: "install" | "grant";
|
||||
capabilities: CapabilityInfo[];
|
||||
/** Unsigned package (from disk or a project folder). */
|
||||
unsigned: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type ConsentPrompt = (req: ConsentRequest) => Promise<Consent | null>;
|
||||
|
||||
let consentPrompt: ConsentPrompt | null = null;
|
||||
|
||||
/** The dialog that asks (registered by `consent.tsx`; tests substitute their own). */
|
||||
export function setConsentPrompt(prompt: ConsentPrompt | null) {
|
||||
consentPrompt = prompt;
|
||||
}
|
||||
|
||||
/** Without a prompt only plugins asking for nothing are accepted. */
|
||||
function askConsent(req: ConsentRequest): Promise<Consent | null> {
|
||||
if (consentPrompt) return consentPrompt(req);
|
||||
return Promise.resolve(req.capabilities.length === 0 ? { optional: [] } : null);
|
||||
}
|
||||
|
||||
// --- loading ------------------------------------------------------------------
|
||||
|
||||
let generation = 0;
|
||||
|
||||
export async function refreshPlugins(): Promise<void> {
|
||||
@@ -44,6 +100,7 @@ export async function refreshPlugins(): Promise<void> {
|
||||
if (gen !== generation) return;
|
||||
usePlugins.setState({
|
||||
plugins: snapshot.plugins,
|
||||
ui: snapshot.ui,
|
||||
activeTheme: snapshot.activeTheme,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
@@ -93,6 +150,29 @@ export function uninstallPlugin(id: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Asks for (and records) the permissions of a plugin waiting for consent. */
|
||||
export async function grantPlugin(id: string, capabilities?: CapabilityInfo[]): Promise<boolean> {
|
||||
const plugin = usePlugins.getState().plugins.find((p) => p.id === id);
|
||||
if (!plugin) return false;
|
||||
const consent = await askConsent({
|
||||
pluginId: id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
kind: "grant",
|
||||
capabilities: capabilities ?? plugin.capabilities,
|
||||
unsigned: plugin.source !== "installed",
|
||||
warnings: plugin.warnings,
|
||||
});
|
||||
if (!consent) return false;
|
||||
return withBusy(id, `Could not enable ${plugin.name}`, () => pluginsBackend().grant(id, consent));
|
||||
}
|
||||
|
||||
export function setPluginCapability(id: string, capability: string, granted: boolean): Promise<boolean> {
|
||||
return withBusy(id, `Could not change ${capability} of ${nameOf(id)}`, () =>
|
||||
pluginsBackend().setCapability(id, capability, granted),
|
||||
);
|
||||
}
|
||||
|
||||
export async function refreshCatalog(): Promise<void> {
|
||||
try {
|
||||
usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null });
|
||||
@@ -101,6 +181,57 @@ export async function refreshCatalog(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The marketplace's plugins: the index read again with `refresh`. */
|
||||
export async function refreshMarket(refresh = true): Promise<void> {
|
||||
const backend = pluginsBackend();
|
||||
try {
|
||||
const marketUrl = await backend.marketUrl();
|
||||
usePlugins.setState({ marketUrl });
|
||||
if (!marketUrl) return usePlugins.setState({ market: null, marketError: null });
|
||||
usePlugins.setState({ market: await backend.market(refresh), marketError: null });
|
||||
} catch (e) {
|
||||
usePlugins.setState({ marketError: errorText(e) });
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMarketUrl(url: string | null): Promise<void> {
|
||||
await pluginsBackend().setMarketUrl(url?.trim() || null);
|
||||
await refreshMarket(true);
|
||||
}
|
||||
|
||||
/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */
|
||||
export async function installFromMarket(id: string): Promise<string | null> {
|
||||
usePlugins.setState({ installing: true });
|
||||
let dir: string;
|
||||
try {
|
||||
dir = await pluginsBackend().marketDownload(id);
|
||||
} catch (e) {
|
||||
usePlugins.setState({ installing: false });
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: "error",
|
||||
title: "Could not download plugin",
|
||||
body: errorText(e),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const installed = await installFromDisk(dir);
|
||||
void refreshMarket(false);
|
||||
return installed;
|
||||
}
|
||||
|
||||
/** Settings › Plugins › Roll Back: the version before the last update. */
|
||||
export async function rollBackPlugin(id: string): Promise<void> {
|
||||
try {
|
||||
const info = await pluginsBackend().rollBack(id);
|
||||
notifications().notify({ group: "plugins", level: "info", title: `Rolled back ${info.name} to ${info.version}` });
|
||||
} catch (e) {
|
||||
notifications().notify({ group: "plugins", level: "error", title: "Could not roll back", body: errorText(e) });
|
||||
} finally {
|
||||
await refreshPlugins();
|
||||
}
|
||||
}
|
||||
|
||||
/** Enables or disables every installed (non-dev) plugin; returns how many changed. */
|
||||
export async function setAllInstalledEnabled(enabled: boolean): Promise<number> {
|
||||
const targets = usePlugins.getState().plugins.filter((p) => p.source === "installed" && p.enabled !== enabled);
|
||||
@@ -124,25 +255,51 @@ export function compareVersions(a: string, b: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Asks for a plugin package (a `.zip` or a `plugin.toml`) and installs it. Returns the new plugin id. */
|
||||
export async function installFromDisk(path?: string): Promise<string | null> {
|
||||
/** The folder to install from for what the user picked; `.oiplugin` / `.zip` files are unpacked. */
|
||||
async function packageFolder(backend: PluginsBackend, picked: string): Promise<string> {
|
||||
const lower = picked.toLowerCase();
|
||||
if (lower.endsWith(".oiplugin") || lower.endsWith(".zip")) return backend.unpackPackage(picked);
|
||||
// A folder's manifest stands for the folder.
|
||||
const manifest = /[\\/]plugin\.toml$/i.exec(picked);
|
||||
return manifest ? picked.slice(0, manifest.index) : picked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a plugin (a folder, its `plugin.toml`, or an `.oiplugin` / `.zip`
|
||||
* package file), shows what it may do, and installs it with the user's
|
||||
* consent. Returns the new plugin id.
|
||||
*/
|
||||
export async function installFromDisk(path?: string, packageFile = false): Promise<string | null> {
|
||||
const backend = pluginsBackend();
|
||||
let source: string | null = path ?? null;
|
||||
let dir: string | null = path ?? null;
|
||||
try {
|
||||
source ??= await backend.pickPackage();
|
||||
dir ??= packageFile ? await backend.pickPackage() : await backend.pickFolder();
|
||||
// Package files are unpacked first; signed ones are checked on the install page.
|
||||
if (dir) dir = await packageFolder(backend, dir);
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: "error",
|
||||
title: "Could not open the file picker",
|
||||
title: packageFile ? "Could not open the package" : "Could not open the file picker",
|
||||
body: errorText(e),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!source) return null;
|
||||
if (!dir) return null;
|
||||
usePlugins.setState({ installing: true });
|
||||
try {
|
||||
const info = await backend.installDir(source);
|
||||
const preview = await backend.previewInstall(dir);
|
||||
const consent = await askConsent({
|
||||
pluginId: preview.id,
|
||||
name: preview.name,
|
||||
version: preview.version,
|
||||
kind: "install",
|
||||
capabilities: preview.capabilities,
|
||||
unsigned: preview.unsigned,
|
||||
warnings: preview.warnings,
|
||||
});
|
||||
if (!consent) return null;
|
||||
const info = await backend.installDir(dir, consent);
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: info.status === "failed" ? "warning" : "info",
|
||||
@@ -168,17 +325,27 @@ export async function selectPluginTheme(pluginId: string | null, themeId: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Title of a plugin command from its UI contribution. */
|
||||
export function commandTitle(pluginId: string, commandId: string): string {
|
||||
const item = usePlugins
|
||||
.getState()
|
||||
.ui.find((c) => c.point === "commands" && c.pluginId === pluginId && c.key === commandId);
|
||||
const title = (item?.decl as { title?: unknown } | undefined)?.title;
|
||||
return typeof title === "string" ? title : commandId;
|
||||
}
|
||||
|
||||
/** Runs a plugin command from an action; failures become notifications. */
|
||||
export async function runPluginCommand(pluginId: string, commandId: string, arg: string | null = null) {
|
||||
const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId);
|
||||
const title = plugin?.commands.find((c) => c.id === commandId)?.title ?? commandId;
|
||||
const title = commandTitle(pluginId, commandId);
|
||||
try {
|
||||
return await pluginsBackend().runCommand(pluginId, commandId, arg);
|
||||
return await pluginsBackend().runCommand(pluginId, commandId, arg, editorContext());
|
||||
} catch (e) {
|
||||
await refreshPlugins();
|
||||
// A trap disables the plugin and the backend already announced why.
|
||||
const failed = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status === "failed";
|
||||
if (!failed)
|
||||
const status = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status;
|
||||
// A trap disables the plugin and the backend already announced why; a
|
||||
// consent request opens its own dialog.
|
||||
const permission = (e as { kind?: string }).kind === "permissionDenied";
|
||||
if (status !== "failed" && !permission)
|
||||
notifications().notify({ group: "plugins", level: "error", title: `${title} failed`, body: errorText(e) });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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, and Tools in the main menu;
|
||||
* - `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.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import type { PluginInfo } from "@/bindings";
|
||||
import { getAction } from "@/platform/actions";
|
||||
import { evaluateWhen } 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,
|
||||
registerMenuGroup,
|
||||
registerMenuSlotItem,
|
||||
registerSettingsPage,
|
||||
registerTextEditorWrapper,
|
||||
registerToolWindow,
|
||||
useSettingsForm,
|
||||
type ContextMenuId,
|
||||
type ToolWindowAnchor,
|
||||
} from "@/workbench/contributions";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { usePlugins } from "./store";
|
||||
import { pluginActionId } from "./points";
|
||||
import { defineUiPoint } from "./projection";
|
||||
import { declareStatusItem } from "./runtimeUi";
|
||||
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 { WebView } from "./views/WebView";
|
||||
import styles from "./PluginsPage.module.css";
|
||||
|
||||
// --- menus ------------------------------------------------------------------------
|
||||
|
||||
interface MenuDecl {
|
||||
menu: "editor.context" | "projectView.context" | "editorTab.context" | "tools";
|
||||
command: string;
|
||||
when?: string | null;
|
||||
}
|
||||
|
||||
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 : paths[0] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void {
|
||||
if (decl.menu === "tools") {
|
||||
const action = pluginActionId(pluginId, decl.command);
|
||||
const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
|
||||
return registerMenuSlotItem("pluginTools", { action, label: title });
|
||||
}
|
||||
const key = `${++menuSeq}`;
|
||||
contextItems.set(key, { pluginId, decl });
|
||||
return () => void contextItems.delete(key);
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`;
|
||||
|
||||
/** 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 }) =>
|
||||
registerToolWindow({
|
||||
id: pluginToolWindowId(pluginId, decl.id),
|
||||
title: decl.title,
|
||||
icon: decl.icon ?? "plugin",
|
||||
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} />
|
||||
),
|
||||
}),
|
||||
}),
|
||||
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<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());
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
.view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.textButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.textButton:hover:not(:disabled) {
|
||||
background: var(--bg-toolbar-hover);
|
||||
}
|
||||
|
||||
.textButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 2px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 4px 8px;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.listItem:hover,
|
||||
.listItem:focus-visible {
|
||||
background: var(--bg-toolbar-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.detail {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 80px;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono, monospace);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tableScroller {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tableCanvas {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: flex;
|
||||
height: 24px;
|
||||
min-width: 100%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tableRow[aria-selected="true"] {
|
||||
background: var(--bg-tree-selected);
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-island);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rowNumber {
|
||||
flex: 0 0 48px;
|
||||
padding: 0 6px;
|
||||
text-align: right;
|
||||
color: var(--fg-muted);
|
||||
line-height: 24px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cell {
|
||||
flex: 0 0 160px;
|
||||
padding: 0 6px;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: 32px 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.frame {
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* A custom editor beside the text editor (`layout = "split"` / `"preview"`). */
|
||||
.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
.splitEditor {
|
||||
flex: 1 1 50%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.splitPreview {
|
||||
flex: 1 1 50%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-editor, var(--bg-app));
|
||||
}
|
||||
.layout-preview .splitEditor {
|
||||
display: none;
|
||||
}
|
||||
.layout-split .splitEditor {
|
||||
border-right: 1px solid var(--border-editor-tabs, var(--border));
|
||||
}
|
||||
.layoutActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.layoutActions > button {
|
||||
width: var(--h-floating-toolbar-button);
|
||||
height: var(--h-floating-toolbar-button);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* Renders a plugin's declarative view: the
|
||||
* plugin returns data (`View`), this draws it with the workbench's own
|
||||
* components and sends the user's actions back (`ViewEvent`). Used by plugin
|
||||
* tool windows and custom editors. A refresh comes from the plugin
|
||||
* (`viewChanged`), from `revision` (a custom editor's document changed), or
|
||||
* from the table scrolling to rows it hasn't got.
|
||||
*/
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import {
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
Tree,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { pluginsBackend } from "../backend";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { onViewMessage, useViewRevision } from "./viewStore";
|
||||
import styles from "./PluginView.module.css";
|
||||
|
||||
export interface PluginViewProps {
|
||||
pluginId: string;
|
||||
view: string;
|
||||
/** The document of a custom editor. */
|
||||
doc?: number | null;
|
||||
/** Bumped by the owner when the view must render again (document edits). */
|
||||
revision?: number;
|
||||
/** Called after an event was handled (custom editors re-render on the document instead). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Table rows asked for beyond the visible ones (each side). */
|
||||
const OVERSCAN = 50;
|
||||
const ROW_HEIGHT = 24;
|
||||
|
||||
export function PluginView({ pluginId, view, doc = null, revision = 0, label }: PluginViewProps) {
|
||||
const [content, setContent] = useState<View | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewport, setViewport] = useState<Viewport | null>(null);
|
||||
const pluginRevision = useViewRevision(pluginId, view);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mine = ++seq.current;
|
||||
pluginsBackend()
|
||||
.renderView(pluginId, view, viewport, doc)
|
||||
.then(
|
||||
(v) => {
|
||||
if (mine !== seq.current) return;
|
||||
setContent(v);
|
||||
setError(null);
|
||||
},
|
||||
(e: unknown) => {
|
||||
if (mine !== seq.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
},
|
||||
);
|
||||
}, [pluginId, view, doc, viewport, revision, pluginRevision]);
|
||||
|
||||
const send = useCallback(
|
||||
(event: ViewEvent) => {
|
||||
pluginsBackend()
|
||||
.viewEvent(pluginId, view, event, doc)
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
|
||||
},
|
||||
[pluginId, view, doc],
|
||||
);
|
||||
|
||||
if (!content)
|
||||
return error ? (
|
||||
<EmptyState className={styles.empty} title={tx("The plugin could not show this view")}>
|
||||
{error}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
<Spinner label={tx("Loading")} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.view} aria-label={label}>
|
||||
{content.toolbar.length > 0 && (
|
||||
<div className={styles.toolbar} role="toolbar">
|
||||
{content.toolbar.map((b) =>
|
||||
b.icon ? (
|
||||
<IconButton
|
||||
key={b.id}
|
||||
icon={b.icon}
|
||||
title={b.label}
|
||||
disabled={!b.enabled}
|
||||
onClick={() => send({ kind: "button", id: b.id })}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
className={styles.textButton}
|
||||
disabled={!b.enabled}
|
||||
onClick={() => send({ kind: "button", id: b.id })}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<Body content={content} send={send} pluginId={pluginId} view={view} onViewport={setViewport} />
|
||||
</div>
|
||||
{content.status && <div className={styles.status}>{content.status}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BodyProps {
|
||||
content: View;
|
||||
send: (e: ViewEvent) => void;
|
||||
pluginId: string;
|
||||
view: string;
|
||||
onViewport: (v: Viewport) => void;
|
||||
}
|
||||
|
||||
function Body({ content, send, pluginId, view, onViewport }: BodyProps) {
|
||||
const body = content.body;
|
||||
switch (body.kind) {
|
||||
case "message":
|
||||
return <EmptyState className={styles.empty} title={body.value} />;
|
||||
case "tree":
|
||||
return <TreeBody items={body.value} send={send} />;
|
||||
case "list":
|
||||
return (
|
||||
<ul className={styles.list} role="listbox">
|
||||
{body.value.map((i) => (
|
||||
<li
|
||||
key={i.key}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
tabIndex={0}
|
||||
className={styles.listItem}
|
||||
onClick={() => send({ kind: "select", key: i.key })}
|
||||
onDoubleClick={() => send({ kind: "activate", key: i.key })}
|
||||
onKeyDown={(e) => e.key === "Enter" && send({ kind: "activate", key: i.key })}
|
||||
>
|
||||
{i.icon && <Icon name={i.icon} size={14} />}
|
||||
<span>{i.label}</span>
|
||||
{i.detail && <span className={styles.detail}>{i.detail}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "form":
|
||||
return (
|
||||
<div className={styles.form}>
|
||||
{body.value.map((f) => (
|
||||
<FormField key={f.key} field={f} send={send} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "table":
|
||||
return <TableBody table={body.value} send={send} onViewport={onViewport} />;
|
||||
case "html":
|
||||
return <HtmlBody html={body.value} send={send} pluginId={pluginId} view={view} />;
|
||||
}
|
||||
}
|
||||
|
||||
// --- tree -------------------------------------------------------------------------
|
||||
|
||||
interface Node {
|
||||
item: ViewTreeItem;
|
||||
children: Node[];
|
||||
}
|
||||
|
||||
/** The flat item list as nodes (items naming an unknown parent are roots). */
|
||||
export function treeNodes(items: readonly ViewTreeItem[]): Node[] {
|
||||
const byKey = new Map<string, Node>();
|
||||
const roots: Node[] = [];
|
||||
for (const item of items) {
|
||||
const node: Node = { item, children: [] };
|
||||
if (!byKey.has(item.key)) byKey.set(item.key, node);
|
||||
const parent = item.parent != null ? byKey.get(item.parent) : undefined;
|
||||
(parent ? parent.children : roots).push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function TreeBody({ items, send }: { items: ViewTreeItem[]; send: (e: ViewEvent) => void }) {
|
||||
const roots = useMemo(() => treeNodes(items), [items]);
|
||||
const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]);
|
||||
return (
|
||||
<Tree<Node>
|
||||
roots={roots}
|
||||
getKey={(n) => n.item.key}
|
||||
getLabel={(n) => n.item.label}
|
||||
getChildren={(n) => (n.children.length ? n.children : n.item.expandable ? undefined : [])}
|
||||
isExpandable={(n) => n.item.expandable || n.children.length > 0}
|
||||
getIcon={(n) => n.item.icon ?? undefined}
|
||||
getDetail={(n) => n.item.detail ?? undefined}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(_, node, isExpanded) =>
|
||||
send(isExpanded ? { kind: "expand", key: node.item.key } : { kind: "collapse", key: node.item.key })
|
||||
}
|
||||
onSelectionChange={(_, lead) => lead && send({ kind: "select", key: lead.item.key })}
|
||||
onActivate={(n) => send({ kind: "activate", key: n.item.key })}
|
||||
multiSelect={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- form -------------------------------------------------------------------------
|
||||
|
||||
function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) => void }) {
|
||||
const commit = (value: string) => send({ kind: "field", key: field.key, value });
|
||||
const v = field.value;
|
||||
const id = `plugin-field-${field.key}`;
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
{v.kind !== "checkbox" && (
|
||||
<label className={styles.fieldLabel} htmlFor={id}>
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
{v.kind === "text" && <TextField id={id} initial={v.value} onCommit={commit} />}
|
||||
{v.kind === "multiline" && <TextField id={id} initial={v.value} onCommit={commit} multiline />}
|
||||
{v.kind === "checkbox" && (
|
||||
<Checkbox checked={v.value} label={field.label} onChange={(on) => commit(on ? "true" : "false")} />
|
||||
)}
|
||||
{v.kind === "choice" && (
|
||||
<Select
|
||||
aria-label={field.label}
|
||||
value={v.value.selected}
|
||||
options={v.value.options.map((o) => ({ value: o, label: o }))}
|
||||
onChange={commit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
id,
|
||||
initial,
|
||||
onCommit,
|
||||
multiline,
|
||||
}: {
|
||||
id: string;
|
||||
initial: string;
|
||||
onCommit: (v: string) => void;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
useEffect(() => setValue(initial), [initial]);
|
||||
const commit = () => value !== initial && onCommit(value);
|
||||
return multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
className={styles.textarea}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- table ------------------------------------------------------------------------
|
||||
|
||||
/** The rows to ask for when `first..first + visible` is shown. */
|
||||
export function wantedViewport(first: number, visible: number, total: number): Viewport {
|
||||
const start = Math.max(0, first - OVERSCAN);
|
||||
const end = Math.min(total, first + visible + OVERSCAN);
|
||||
return { firstRow: start, rows: Math.max(0, end - start) };
|
||||
}
|
||||
|
||||
function TableBody({
|
||||
table,
|
||||
send,
|
||||
onViewport,
|
||||
}: {
|
||||
table: ViewTable;
|
||||
send: (e: ViewEvent) => void;
|
||||
onViewport: (v: Viewport) => void;
|
||||
}) {
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [height, setHeight] = useState(400);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [editing, setEditing] = useState<{ row: number; column: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 400));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const first = Math.floor(scrollTop / ROW_HEIGHT);
|
||||
const visible = Math.ceil(height / ROW_HEIGHT) + 1;
|
||||
const loadedEnd = table.firstRow + table.rows.length;
|
||||
const shownEnd = Math.min(table.totalRows, first + visible);
|
||||
// Ask for rows the scroll position shows but the last render didn't include.
|
||||
useEffect(() => {
|
||||
if (first < table.firstRow || shownEnd > loadedEnd) onViewport(wantedViewport(first, visible, table.totalRows));
|
||||
}, [first, visible, shownEnd, loadedEnd, table.firstRow, table.totalRows, onViewport]);
|
||||
|
||||
const rowMenu = (row: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const rows = selected.has(row) ? [...selected].sort((a, b) => a - b) : [row];
|
||||
if (!selected.has(row)) setSelected(new Set([row]));
|
||||
const items: MenuItem[] = [
|
||||
{ label: tx("Insert Row Above"), icon: "plus", run: () => send({ kind: "insertRow", before: row }) },
|
||||
{ label: tx("Insert Row Below"), run: () => send({ kind: "insertRow", before: row + 1 }) },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
label: rows.length > 1 ? tx`Delete ${rows.length} Rows` : tx("Delete Row"),
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
run: () => send({ kind: "deleteRows", rows }),
|
||||
},
|
||||
];
|
||||
if (table.editable) showContextMenu(e, items);
|
||||
};
|
||||
|
||||
const rows: React.ReactNode[] = [];
|
||||
for (let r = Math.max(first, table.firstRow); r < Math.min(shownEnd, loadedEnd); r++) {
|
||||
const cells = table.rows[r - table.firstRow] ?? [];
|
||||
rows.push(
|
||||
<div
|
||||
key={r}
|
||||
role="row"
|
||||
aria-rowindex={r + 2}
|
||||
aria-selected={selected.has(r)}
|
||||
className={styles.tableRow}
|
||||
style={{ top: (r + 1) * ROW_HEIGHT }}
|
||||
onClick={(e) =>
|
||||
setSelected((s) => {
|
||||
if (!(e.metaKey || e.ctrlKey)) return new Set([r]);
|
||||
const next = new Set(s);
|
||||
if (next.has(r)) next.delete(r);
|
||||
else next.add(r);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onContextMenu={(e) => rowMenu(r, e)}
|
||||
>
|
||||
<div className={styles.rowNumber} role="rowheader">
|
||||
{r + 1}
|
||||
</div>
|
||||
{table.columns.map((_, c) => (
|
||||
<div
|
||||
key={c}
|
||||
role="gridcell"
|
||||
className={styles.cell}
|
||||
title={cells[c]}
|
||||
onDoubleClick={() => table.editable && setEditing({ row: r, column: c })}
|
||||
>
|
||||
{editing?.row === r && editing.column === c ? (
|
||||
<CellEditor
|
||||
initial={cells[c] ?? ""}
|
||||
onDone={(value) => {
|
||||
setEditing(null);
|
||||
if (value !== null && value !== (cells[c] ?? "")) send({ kind: "cell", row: r, column: c, value });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
cells[c]
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scroller}
|
||||
className={styles.tableScroller}
|
||||
role="grid"
|
||||
aria-rowcount={table.totalRows + 1}
|
||||
aria-colcount={table.columns.length}
|
||||
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||
>
|
||||
<div className={styles.tableCanvas} style={{ height: (table.totalRows + 1) * ROW_HEIGHT }}>
|
||||
<div className={`${styles.tableRow} ${styles.tableHeader}`} role="row" aria-rowindex={1}>
|
||||
<div className={styles.rowNumber} />
|
||||
{table.columns.map((c, i) => (
|
||||
<div key={i} role="columnheader" className={styles.cell} title={c}>
|
||||
{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows}
|
||||
</div>
|
||||
{table.totalRows === 0 && <div className={styles.tableEmpty}>{tx("No rows")}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CellEditor({ initial, onDone }: { initial: string; onDone: (value: string | null) => void }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const done = useRef(false);
|
||||
const finish = (v: string | null) => {
|
||||
if (done.current) return;
|
||||
done.current = true;
|
||||
onDone(v);
|
||||
};
|
||||
const onKeyDown = (e: ReactKeyboardEvent) => {
|
||||
if (e.key === "Enter") finish(value);
|
||||
else if (e.key === "Escape") finish(null);
|
||||
};
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
variant="bare"
|
||||
aria-label={tx("Cell value")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => finish(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- html -------------------------------------------------------------------------
|
||||
|
||||
function HtmlBody({
|
||||
html,
|
||||
send,
|
||||
pluginId,
|
||||
view,
|
||||
}: {
|
||||
html: string;
|
||||
send: (e: ViewEvent) => void;
|
||||
pluginId: string;
|
||||
view: string;
|
||||
}) {
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
// The theme at load time is in the document; later changes are posted, so a
|
||||
// theme switch doesn't reload the frame and lose its state.
|
||||
const srcDoc = useMemo(() => frameDocument(html, frameTheme()), [html]);
|
||||
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
|
||||
const postTheme = useCallback(
|
||||
() => frame.current?.contentWindow?.postMessage(frameThemeMessage(frameTheme()), "*"),
|
||||
[],
|
||||
);
|
||||
useEffect(postTheme, [theme, postTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.source !== frame.current?.contentWindow) return;
|
||||
const data = e.data as { tag?: unknown; text?: unknown } | null;
|
||||
if (data?.tag === FRAME_TAG && typeof data.text === "string") send({ kind: "message", text: data.text });
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
const off = onViewMessage(pluginId, view, (text) =>
|
||||
frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, text }, "*"),
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
off();
|
||||
};
|
||||
}, [pluginId, view, send]);
|
||||
|
||||
// No `allow-same-origin`: an opaque origin without access to the IDE.
|
||||
// `onLoad` re-sends the theme in case it changed between building `srcDoc` and the frame loading.
|
||||
return (
|
||||
<iframe
|
||||
ref={frame}
|
||||
className={styles.frame}
|
||||
title={view}
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={srcDoc}
|
||||
onLoad={postTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* A plugin's web view (`web = "web/index.html"` on a view or custom editor):
|
||||
* a page shipped in the plugin package, loaded from the `openidea-plugin`
|
||||
* scheme into an `<iframe sandbox="allow-scripts">`. The frame is an opaque
|
||||
* origin with no network and no IDE API; the desktop serves only the files
|
||||
* under the page's directory, with a CSP of their own, and injects the bridge
|
||||
* (`frame-bridge.js`). Through it, and only it, the page:
|
||||
*
|
||||
* - follows the IDE theme;
|
||||
* - exchanges messages with the plugin's code, when it has any;
|
||||
* - reads and edits the document of the custom editor it is (versioned edits
|
||||
* that join the document's undo history, Save and dirty state);
|
||||
* - asks the user to save or open a file (the IDE's dialogs: the user picks
|
||||
* the path, the page never names one);
|
||||
* - uses the clipboard, when the plugin was granted `clipboard`;
|
||||
* - passes on IDE shortcuts it doesn't handle itself (Save, Close…).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { EmptyState, Spinner } from "@/ui";
|
||||
import { pluginsBackend, type FileFilter } from "../backend";
|
||||
import { errorText, usePlugins } from "../store";
|
||||
import { FRAME_TAG, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { onViewMessage } from "./viewStore";
|
||||
import styles from "./PluginView.module.css";
|
||||
|
||||
/** One change of a document, in UTF-16 offsets of the text it applies to. */
|
||||
export interface TextChange {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
export interface DocumentState {
|
||||
version: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The document a web custom editor shows, as its frame sees it. */
|
||||
export interface FrameDocument {
|
||||
/** `name` is the file's name, without its directory. */
|
||||
read(): DocumentState & { readOnly: boolean; name: string };
|
||||
/** Applies the frame's edits as one change; throws `stale` when not at `version`. Returns the new version. */
|
||||
edit(version: number, changes: readonly TextChange[]): number;
|
||||
/** Changes made other than through `edit` (the text editor, Undo, other windows). */
|
||||
subscribe(listener: (state: DocumentState) => void): () => void;
|
||||
}
|
||||
|
||||
/** Largest text a frame may write in one edit. */
|
||||
const MAX_EDIT = 64 << 20;
|
||||
|
||||
/** The smallest change turning `from` into `to`: their common prefix and suffix stay. */
|
||||
export function minimalChange(from: string, to: string): TextChange | null {
|
||||
if (from === to) return null;
|
||||
let start = 0;
|
||||
const max = Math.min(from.length, to.length);
|
||||
while (start < max && from.charCodeAt(start) === to.charCodeAt(start)) start++;
|
||||
let end = 0;
|
||||
while (end < max - start && from.charCodeAt(from.length - 1 - end) === to.charCodeAt(to.length - 1 - end)) end++;
|
||||
return { from: start, to: from.length - end, insert: to.slice(start, to.length - end) };
|
||||
}
|
||||
|
||||
/** Checks a frame's edits against the text they apply to: in range, sorted, not overlapping. */
|
||||
export function validChanges(value: unknown, length: number): TextChange[] {
|
||||
if (!Array.isArray(value) || value.length > 100_000) throw new Error("edits must be an array");
|
||||
let at = 0;
|
||||
let size = 0;
|
||||
return value.map((c: unknown) => {
|
||||
const { from, to, insert } = (c ?? {}) as Partial<TextChange>;
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) || typeof insert !== "string")
|
||||
throw new Error("an edit is { from, to, insert }");
|
||||
if (from! < at || to! < from! || to! > length) throw new Error("edits must be sorted, in range and not overlap");
|
||||
size += insert.length;
|
||||
if (size > MAX_EDIT) throw new Error("the edit is too large");
|
||||
at = to!;
|
||||
return { from: from!, to: to!, insert };
|
||||
});
|
||||
}
|
||||
|
||||
function validFilters(value: unknown): FileFilter[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value
|
||||
.slice(0, 10)
|
||||
.filter(
|
||||
(f): f is FileFilter =>
|
||||
!!f &&
|
||||
typeof f.name === "string" &&
|
||||
Array.isArray(f.extensions) &&
|
||||
f.extensions.every((e: unknown) => typeof e === "string" && /^[\w.+-]{1,32}$/.test(e)),
|
||||
)
|
||||
.map((f) => ({ name: f.name.slice(0, 64), extensions: f.extensions.slice(0, 20) }));
|
||||
}
|
||||
|
||||
type PageState = { url: string } | { error: string };
|
||||
|
||||
/**
|
||||
* The `openidea-plugin` URL of the page, or why there is none. Asked again
|
||||
* whenever the plugin changes (`plugin`: its info, a new object then): the URL
|
||||
* names the installed package's revision, so an update, a reinstall or
|
||||
* disabling it reach the frame, and the frame only reloads when the URL is new.
|
||||
*/
|
||||
function usePageUrl(pluginId: string, view: string, plugin: unknown) {
|
||||
const page = `${pluginId}/${view}`;
|
||||
const [state, setState] = useState<{ page: string; value: PageState } | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const set = (value: PageState) =>
|
||||
live &&
|
||||
setState((s) =>
|
||||
s?.page === page && "url" in s.value && "url" in value && s.value.url === value.url ? s : { page, value },
|
||||
);
|
||||
pluginsBackend()
|
||||
.webPageUrl(pluginId, view)
|
||||
.then(
|
||||
(url) => set({ url }),
|
||||
(e: unknown) => set({ error: errorText(e) }),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [pluginId, view, page, plugin]);
|
||||
return state?.page === page ? state.value : null;
|
||||
}
|
||||
|
||||
export interface WebViewProps {
|
||||
pluginId: string;
|
||||
view: string;
|
||||
label: string;
|
||||
/** The custom editor's document; none in a tool window. */
|
||||
document?: FrameDocument | null;
|
||||
/** The backend document, for messages to the plugin's code. */
|
||||
doc?: number | null;
|
||||
}
|
||||
|
||||
export function WebView({ pluginId, view, label, document = null, doc = null }: WebViewProps) {
|
||||
const plugin = usePlugins((s) => s.plugins.find((p) => p.id === pluginId));
|
||||
const page = usePageUrl(pluginId, view, plugin);
|
||||
const hasCode = plugin?.hasCode ?? false;
|
||||
const clipboard = plugin?.capabilities.some((c) => c.id === "clipboard" && c.granted) ?? false;
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const post = useCallback((message: object, transfer: Transferable[] = []) => {
|
||||
frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, ...message }, "*", transfer);
|
||||
}, []);
|
||||
const postTheme = useCallback(() => post(frameThemeMessage(frameTheme())), [post]);
|
||||
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
|
||||
useEffect(postTheme, [theme, postTheme]);
|
||||
|
||||
// The document's changes made elsewhere.
|
||||
useEffect(() => document?.subscribe((state) => post({ document: state })), [document, post]);
|
||||
|
||||
useEffect(() => {
|
||||
let dialog = false;
|
||||
const run = async (op: string, args: Record<string, unknown>): Promise<[unknown, Transferable[]?]> => {
|
||||
switch (op) {
|
||||
case "document.get": {
|
||||
if (!document) throw new Error("this view shows no document");
|
||||
return [document.read()];
|
||||
}
|
||||
case "document.edit":
|
||||
case "document.replace": {
|
||||
if (!document) throw new Error("this view shows no document");
|
||||
const current = document.read();
|
||||
if (current.readOnly) throw new Error("the document is read-only");
|
||||
if (args.version !== current.version) throw new Error("stale");
|
||||
let changes: TextChange[];
|
||||
if (op === "document.replace") {
|
||||
if (typeof args.text !== "string" || args.text.length > MAX_EDIT) throw new Error("text must be a string");
|
||||
const change = minimalChange(current.text, args.text);
|
||||
changes = change ? [change] : [];
|
||||
} else changes = validChanges(args.edits, current.text.length);
|
||||
return [{ version: changes.length ? document.edit(current.version, changes) : current.version }];
|
||||
}
|
||||
case "saveFile":
|
||||
case "openFile": {
|
||||
// One dialog at a time per frame.
|
||||
if (dialog) throw new Error("a file dialog is already open");
|
||||
dialog = true;
|
||||
try {
|
||||
const filters = validFilters(args.filters);
|
||||
if (op === "openFile") {
|
||||
const file = await pluginsBackend().openWebFile(filters);
|
||||
return file ? [file, [file.data.buffer]] : [null];
|
||||
}
|
||||
if (!(args.bytes instanceof Uint8Array)) throw new Error("data must be bytes");
|
||||
const name = typeof args.name === "string" ? args.name.slice(0, 255) : "untitled";
|
||||
return [await pluginsBackend().saveWebFile(name, filters, args.bytes)];
|
||||
} finally {
|
||||
dialog = false;
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown request ${op}`);
|
||||
}
|
||||
};
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (!frame.current || e.source !== frame.current.contentWindow) return;
|
||||
const m = e.data as {
|
||||
tag?: unknown;
|
||||
text?: unknown;
|
||||
call?: unknown;
|
||||
op?: unknown;
|
||||
args?: unknown;
|
||||
key?: unknown;
|
||||
} | null;
|
||||
if (m?.tag !== FRAME_TAG) return;
|
||||
if (m.key && typeof m.key === "object") {
|
||||
// A shortcut the page didn't handle: as if pressed on the frame, for the IDE's keymap.
|
||||
const k = m.key as KeyboardEventInit;
|
||||
const init: KeyboardEventInit = { bubbles: true, cancelable: true };
|
||||
for (const f of ["key", "code"] as const) if (typeof k[f] === "string") init[f] = k[f];
|
||||
for (const f of ["metaKey", "ctrlKey", "altKey", "shiftKey", "repeat"] as const) init[f] = k[f] === true;
|
||||
frame.current.dispatchEvent(new KeyboardEvent("keydown", init));
|
||||
} else if (typeof m.call === "number" && typeof m.op === "string") {
|
||||
const id = m.call;
|
||||
run(m.op, (m.args ?? {}) as Record<string, unknown>).then(
|
||||
([value, transfer]) => post({ reply: id, value }, transfer),
|
||||
(err: unknown) => post({ reply: id, error: errorText(err) }),
|
||||
);
|
||||
} else if (typeof m.text === "string" && hasCode) {
|
||||
pluginsBackend()
|
||||
.viewEvent(pluginId, view, { kind: "message", text: m.text }, doc)
|
||||
.catch((err: unknown) => console.warn(`plugin ${pluginId}: ${errorText(err)}`));
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
const off = onViewMessage(pluginId, view, (text) => post({ text }));
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
off();
|
||||
};
|
||||
}, [pluginId, view, document, doc, hasCode, post]);
|
||||
|
||||
if (!page)
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<Spinner label={tx("Loading")} />
|
||||
</div>
|
||||
);
|
||||
if ("error" in page)
|
||||
return (
|
||||
<EmptyState className={styles.empty} title={tx`Cannot show ${label}`}>
|
||||
{page.error}
|
||||
</EmptyState>
|
||||
);
|
||||
// No `allow-same-origin`: an opaque origin without access to the IDE. `onLoad` sends the theme.
|
||||
return (
|
||||
<iframe
|
||||
ref={frame}
|
||||
className={styles.frame}
|
||||
title={label}
|
||||
sandbox="allow-scripts"
|
||||
allow={clipboard ? "clipboard-read; clipboard-write" : undefined}
|
||||
src={page.url}
|
||||
onLoad={postTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Plugin custom editors (`[[contributes.customEditors]]`): a file matching
|
||||
* the declared patterns opens in a tab whose content the plugin renders from
|
||||
* the file's document. The tab joins the document shared by every editor of
|
||||
* the file (`attachDocument`), so:
|
||||
*
|
||||
* - the plugin edits the document (versioned `apply-edits`), and a text tab
|
||||
* of the same file shows the change at once — and the other way round;
|
||||
* - Undo / Redo work on the document's one history: the text tab's when one
|
||||
* is open, else this tab's own;
|
||||
* - unsaved changes, Save, and the close prompts are the document's.
|
||||
*/
|
||||
import { history } from "@codemirror/commands";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { attachDocument, type DocumentAttachment } from "@/editor/api";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { EmptyState, Spinner } from "@/ui";
|
||||
import {
|
||||
editorService,
|
||||
notifyEditorDocumentChanged,
|
||||
type EditorDocumentLifecycle,
|
||||
type EditorInput,
|
||||
} from "@/workbench/contributions";
|
||||
import { PluginView } from "./PluginView";
|
||||
import { WebView, type DocumentState, type FrameDocument } from "./WebView";
|
||||
import styles from "./PluginView.module.css";
|
||||
|
||||
export const CUSTOM_EDITOR_KIND = "pluginEditor";
|
||||
|
||||
export interface CustomEditorData {
|
||||
pluginId: string;
|
||||
editorId: string;
|
||||
title: string;
|
||||
/** A web page of the package (`web`) rather than content the code renders. */
|
||||
web?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomEditorDecl {
|
||||
id: string;
|
||||
title: string;
|
||||
patterns: string[];
|
||||
priority: "default" | "option";
|
||||
/** `tab`: its own tab; `split` / `preview`: beside the text editor, starting in that layout. */
|
||||
layout?: "tab" | "split" | "preview";
|
||||
web?: string;
|
||||
}
|
||||
|
||||
export function customEditorInput(pluginId: string, decl: CustomEditorDecl, path: string): EditorInput {
|
||||
const data: CustomEditorData = { pluginId, editorId: decl.id, title: decl.title, web: !!decl.web };
|
||||
return { kind: CUSTOM_EDITOR_KIND, key: `${CUSTOM_EDITOR_KIND}:${pluginId}/${decl.id}:${path}`, path, data };
|
||||
}
|
||||
|
||||
/** File name glob (`*`, `?`, `[abc]`, `{a,b}`), case-insensitive. */
|
||||
export function matchesPattern(pattern: string, name: string): boolean {
|
||||
let re = "";
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
const c = pattern[i]!;
|
||||
if (c === "*") re += "[^/]*";
|
||||
else if (c === "?") re += "[^/]";
|
||||
else if (c === "{") {
|
||||
const end = pattern.indexOf("}", i);
|
||||
if (end < 0) re += "\\{";
|
||||
else {
|
||||
re += `(?:${pattern
|
||||
.slice(i + 1, end)
|
||||
.split(",")
|
||||
.map((p) => p.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"))
|
||||
.join("|")})`;
|
||||
i = end;
|
||||
}
|
||||
} else if (c === "[") {
|
||||
const end = pattern.indexOf("]", i);
|
||||
if (end < 0) re += "\\[";
|
||||
else {
|
||||
re += pattern.slice(i, end + 1);
|
||||
i = end;
|
||||
}
|
||||
} else re += c.replace(/[.+^$()|\\]/g, "\\$&");
|
||||
}
|
||||
return new RegExp(`^${re}$`, "i").test(name);
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path;
|
||||
}
|
||||
|
||||
// --- sessions: one per document shown by a custom editor (splits share it) -----------
|
||||
|
||||
interface Session {
|
||||
attachment: DocumentAttachment;
|
||||
/** Holds this tab's undo history and receives the document's changes. */
|
||||
view: EditorView;
|
||||
revision: number;
|
||||
listeners: Set<(revision: number) => void>;
|
||||
/** The document as web frames see it. */
|
||||
frameDocument: FrameDocument;
|
||||
cancelTimer: () => void;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Promise<Session>>();
|
||||
|
||||
/** Edits settle in the backend before the plugin renders the table again. */
|
||||
const RENDER_DELAY_MS = 120;
|
||||
|
||||
function session(key: string, path: string): Promise<Session> {
|
||||
let s = sessions.get(key);
|
||||
if (!s) {
|
||||
s = attachDocument(path).then((attachment) => {
|
||||
const listeners = new Set<(revision: number) => void>();
|
||||
const frameListeners = new Set<(state: DocumentState) => void>();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Bumped by every change: what frames' versioned edits are checked against.
|
||||
let version = 0;
|
||||
// Set while a frame's edit is applied: its changes (arriving synchronously) aren't echoed back.
|
||||
let framing = false;
|
||||
const changed = (byFrame: boolean) => {
|
||||
version++;
|
||||
notifyEditorDocumentChanged();
|
||||
if (!byFrame && frameListeners.size) {
|
||||
const state = { version, text: view.state.doc.toString() };
|
||||
for (const l of frameListeners) l(state);
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void attachment.synced().then(() => {
|
||||
state.revision++;
|
||||
for (const l of listeners) l(state.revision);
|
||||
});
|
||||
}, RENDER_DELAY_MS);
|
||||
};
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: attachment.text(),
|
||||
extensions: [
|
||||
attachment.extension,
|
||||
history(),
|
||||
EditorView.updateListener.of((u) => u.docChanged && changed(framing)),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const frameDocument: FrameDocument = {
|
||||
read: () => ({ version, text: view.state.doc.toString(), readOnly: attachment.readOnly, name: basename(path) }),
|
||||
edit: (at, changes) => {
|
||||
if (at !== version) throw new Error("stale");
|
||||
// One edit of the document's history: Undo in the text editor (or this tab) reverts it.
|
||||
framing = true;
|
||||
try {
|
||||
attachment.edit([...changes], "input.plugin");
|
||||
} finally {
|
||||
framing = false;
|
||||
}
|
||||
return version;
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
frameListeners.add(listener);
|
||||
return () => frameListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
const state: Session = {
|
||||
attachment,
|
||||
view,
|
||||
revision: 0,
|
||||
listeners,
|
||||
frameDocument,
|
||||
cancelTimer: () => timer && clearTimeout(timer),
|
||||
};
|
||||
return state;
|
||||
});
|
||||
sessions.set(key, s);
|
||||
s.catch(() => sessions.delete(key));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
async function closeSession(key: string) {
|
||||
const s = sessions.get(key);
|
||||
sessions.delete(key);
|
||||
if (!s) return;
|
||||
const state = await s;
|
||||
settled.delete(key);
|
||||
state.cancelTimer();
|
||||
state.attachment.release();
|
||||
state.view.destroy();
|
||||
}
|
||||
|
||||
async function withSession<T>(input: EditorInput, f: (s: Session) => T | Promise<T>): Promise<T | undefined> {
|
||||
const s = sessions.get(input.key);
|
||||
return s ? f(await s) : undefined;
|
||||
}
|
||||
|
||||
const settled = new Map<string, Session>();
|
||||
|
||||
/** Lifecycle of the tabs' document (dirty mark, Save, close prompts, Undo). */
|
||||
export const customEditorDocument: EditorDocumentLifecycle = {
|
||||
isDirty: (input) => settled.get(input.key)?.attachment.dirty() ?? false,
|
||||
flush: async (input) => {
|
||||
await withSession(input, (s) => s.attachment.synced());
|
||||
},
|
||||
save: async (input) => {
|
||||
await withSession(input, (s) => s.attachment.save());
|
||||
},
|
||||
prepareClose: async (input) => {
|
||||
await withSession(input, (s) => s.attachment.synced());
|
||||
},
|
||||
cancelClose: () => {},
|
||||
discard: async () => {
|
||||
// The document's unsaved changes go when it closes (`close` releases it).
|
||||
},
|
||||
close: (input) => closeSession(input.key),
|
||||
undo: async (input) => {
|
||||
await withSession(input, (s) => void s.attachment.undo());
|
||||
},
|
||||
redo: async (input) => {
|
||||
await withSession(input, (s) => void s.attachment.redo());
|
||||
},
|
||||
};
|
||||
|
||||
type SessionState = { session: Session; revision: number } | { error: string } | null;
|
||||
|
||||
/** The session `key` of `path`, kept for as long as the component shows it. */
|
||||
function useSession(key: string, path: string, release: boolean): SessionState {
|
||||
const [state, setState] = useState<SessionState>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
let off = () => {};
|
||||
session(key, path).then(
|
||||
(s) => {
|
||||
if (!live) return;
|
||||
settled.set(key, s);
|
||||
const listener = (revision: number) => setState({ session: s, revision });
|
||||
s.listeners.add(listener);
|
||||
off = () => s.listeners.delete(listener);
|
||||
setState({ session: s, revision: s.revision });
|
||||
},
|
||||
(e: unknown) => live && setState({ error: e instanceof Error ? e.message : String(e) }),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
off();
|
||||
// A tab's session lives until the tab closes (its lifecycle); a preview's until it unmounts.
|
||||
if (release) void closeSession(key);
|
||||
};
|
||||
}, [key, path, release]);
|
||||
return state;
|
||||
}
|
||||
|
||||
function SessionBody({
|
||||
state,
|
||||
pluginId,
|
||||
editorId,
|
||||
web,
|
||||
label,
|
||||
path,
|
||||
}: {
|
||||
state: Exclude<SessionState, null | { error: string }>;
|
||||
pluginId: string;
|
||||
editorId: string;
|
||||
web: boolean;
|
||||
label: string;
|
||||
path: string;
|
||||
}) {
|
||||
const { session: s, revision } = state;
|
||||
if (web)
|
||||
return (
|
||||
<WebView pluginId={pluginId} view={editorId} label={label} document={s.frameDocument} doc={s.attachment.doc} />
|
||||
);
|
||||
return (
|
||||
<PluginView
|
||||
pluginId={pluginId}
|
||||
view={editorId}
|
||||
doc={s.attachment.doc}
|
||||
revision={revision}
|
||||
label={label}
|
||||
key={path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomEditor({ input }: { input: EditorInput }) {
|
||||
const data = input.data as CustomEditorData;
|
||||
const state = useSession(input.key, input.path ?? "", false);
|
||||
|
||||
if (!state)
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<Spinner label={tx("Loading")} />
|
||||
</div>
|
||||
);
|
||||
if ("error" in state)
|
||||
return (
|
||||
<EmptyState className={styles.empty} title={tx`Cannot open ${basename(input.path ?? "")}`}>
|
||||
{state.error}
|
||||
</EmptyState>
|
||||
);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<SessionBody
|
||||
state={state}
|
||||
pluginId={data.pluginId}
|
||||
editorId={data.editorId}
|
||||
web={!!data.web}
|
||||
label={tx`${data.title}: ${basename(input.path ?? "")}`}
|
||||
path={input.path ?? ""}
|
||||
/>
|
||||
<div className={styles.status}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.textButton}
|
||||
onClick={() => input.path && void editorService().openFile(input.path, { asText: true })}
|
||||
>
|
||||
{tx("Open as Text")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom editor beside the text editor (`layout = "split"` / `"preview"`):
|
||||
* the same document, so each follows the other's edits.
|
||||
*/
|
||||
export function CustomEditorPreview({
|
||||
pluginId,
|
||||
decl,
|
||||
path,
|
||||
}: {
|
||||
pluginId: string;
|
||||
decl: CustomEditorDecl;
|
||||
path: string;
|
||||
}) {
|
||||
const key = useMemo(() => `pluginPreview:${pluginId}/${decl.id}:${path}`, [pluginId, decl.id, path]);
|
||||
const state = useSession(key, path, true);
|
||||
const label = tx`${decl.title}: ${basename(path)}`;
|
||||
if (!state)
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<Spinner label={tx("Loading")} />
|
||||
</div>
|
||||
);
|
||||
if ("error" in state)
|
||||
return (
|
||||
<EmptyState className={styles.empty} title={tx`Cannot open ${basename(path)}`}>
|
||||
{state.error}
|
||||
</EmptyState>
|
||||
);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<SessionBody state={state} pluginId={pluginId} editorId={decl.id} web={!!decl.web} label={label} path={path} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Custom editors that sit beside the text editor (`layout = "split"` or
|
||||
* `"preview"`): the file opens in the text editor, wrapped with the plugin's
|
||||
* editor and the Editor / Editor and Preview / Preview switch (in the tab
|
||||
* strip, or floating when tabs are hidden), as Markdown's preview. The
|
||||
* layout is kept per file; files not switched yet start in the declared one.
|
||||
*/
|
||||
import clsx from "clsx";
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { FloatingToolbar, IconButton } from "@/ui";
|
||||
import { CustomEditorPreview, type CustomEditorDecl } from "./customEditor";
|
||||
import styles from "./PluginView.module.css";
|
||||
|
||||
export type EditorLayout = "editor" | "split" | "preview";
|
||||
|
||||
/** `<plugin>/<editor>` → path → layout. */
|
||||
export const useEditorLayouts = create<{ byPath: Record<string, EditorLayout> }>(() => ({ byPath: {} }));
|
||||
|
||||
const layoutKey = (pluginId: string, editorId: string, path: string) => `${pluginId}/${editorId}:${path}`;
|
||||
|
||||
export function layoutOf(pluginId: string, decl: CustomEditorDecl, path: string, byPath: Record<string, EditorLayout>) {
|
||||
return byPath[layoutKey(pluginId, decl.id, path)] ?? (decl.layout === "preview" ? "preview" : "split");
|
||||
}
|
||||
|
||||
export function setEditorLayout(pluginId: string, editorId: string, path: string, layout: EditorLayout) {
|
||||
useEditorLayouts.setState((s) => ({ byPath: { ...s.byPath, [layoutKey(pluginId, editorId, path)]: layout } }));
|
||||
}
|
||||
|
||||
function LayoutActions({ pluginId, decl, path }: { pluginId: string; decl: CustomEditorDecl; path: string }) {
|
||||
const layout = useEditorLayouts((s) => layoutOf(pluginId, decl, path, s.byPath));
|
||||
const button = (value: EditorLayout, icon: string, title: string) => (
|
||||
<IconButton
|
||||
icon={icon}
|
||||
iconSize={16}
|
||||
size="small"
|
||||
title={title}
|
||||
pressed={layout === value}
|
||||
onClick={() => setEditorLayout(pluginId, decl.id, path, value)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className={styles.layoutActions} role="toolbar" aria-label={tx`${decl.title} layout`}>
|
||||
{button("editor", "editor-only", tx("Editor Only"))}
|
||||
{button("split", "editor-preview", tx`Editor and ${decl.title}`)}
|
||||
{button("preview", "preview-only", tx`${decl.title} Only`)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The `tabActions` of a wrapper: the layout switch in the editor's tab strip. */
|
||||
export function layoutActions(pluginId: string, decl: CustomEditorDecl) {
|
||||
return function PluginLayoutActions({ path }: { path: string; tabId: string }) {
|
||||
return <LayoutActions pluginId={pluginId} decl={decl} path={path} />;
|
||||
};
|
||||
}
|
||||
|
||||
/** The text editor wrapper of a custom editor with a side-by-side layout. */
|
||||
export function layoutWrapper(pluginId: string, decl: CustomEditorDecl) {
|
||||
return function PluginEditorLayout({
|
||||
path,
|
||||
children,
|
||||
actionsInTabs = false,
|
||||
}: {
|
||||
path: string;
|
||||
tabId: string;
|
||||
children: ReactNode;
|
||||
actionsInTabs?: boolean;
|
||||
}) {
|
||||
const layout = useEditorLayouts((s) => layoutOf(pluginId, decl, path, s.byPath));
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<div ref={ref} className={clsx(styles.split, styles[`layout-${layout}`])}>
|
||||
{/* Always rendered: the text editor's view stays alive in every layout. */}
|
||||
<div className={styles.splitEditor}>{children}</div>
|
||||
{layout !== "editor" && (
|
||||
<div className={styles.splitPreview}>
|
||||
<CustomEditorPreview pluginId={pluginId} decl={decl} path={path} />
|
||||
</div>
|
||||
)}
|
||||
{!actionsInTabs && (
|
||||
<FloatingToolbar targetRef={ref}>
|
||||
<LayoutActions pluginId={pluginId} decl={decl} path={path} />
|
||||
</FloatingToolbar>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* `window.openidea` inside a plugin view's sandboxed frame: the frame's only
|
||||
* way to the IDE. Shared by both kinds of frames: inlined into the `html`
|
||||
* bodies plugin code renders (src/features/plugins/views/frame.ts) and
|
||||
* injected by the desktop into the pages of web views
|
||||
* (src-tauri/src/plugin_web.rs). Part of the plugin API: members are only
|
||||
* ever added.
|
||||
*
|
||||
* The file is one function expression, called with the IDE theme when it is
|
||||
* known up front (null otherwise: the workbench posts it once the frame has
|
||||
* loaded).
|
||||
*/
|
||||
(initialTheme) => {
|
||||
const TAG = "openidea-view";
|
||||
const listeners = [];
|
||||
const themeListeners = [];
|
||||
const documentListeners = [];
|
||||
const pending = new Map();
|
||||
const root = document.documentElement;
|
||||
let theme = null;
|
||||
let applied = [];
|
||||
let nextCall = 1;
|
||||
|
||||
const safely = (fn, value) => {
|
||||
try {
|
||||
fn(value);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const applyTheme = (t) => {
|
||||
if (!t || typeof t !== "object" || !t.vars || typeof t.vars !== "object") return;
|
||||
const vars = {};
|
||||
for (const [k, v] of Object.entries(t.vars)) if (k.startsWith("--openidea-") && typeof v === "string") vars[k] = v;
|
||||
for (const k of applied) if (!(k in vars)) root.style.removeProperty(k);
|
||||
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
|
||||
applied = Object.keys(vars);
|
||||
root.dataset.theme = t.dark ? "dark" : "light";
|
||||
theme = Object.freeze({ dark: !!t.dark, vars: Object.freeze(vars) });
|
||||
for (const l of themeListeners) safely(l, theme);
|
||||
};
|
||||
|
||||
/** A request to the workbench; resolves with its reply. */
|
||||
const call = (op, args, transfer) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = nextCall++;
|
||||
pending.set(id, { resolve, reject });
|
||||
window.parent.postMessage({ tag: TAG, call: id, op, args }, "*", transfer || []);
|
||||
});
|
||||
|
||||
const bytesOf = async (data) => {
|
||||
if (typeof data === "string") return new TextEncoder().encode(data);
|
||||
if (data instanceof Uint8Array) return data;
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
|
||||
throw new TypeError("data must be a string, bytes or a Blob");
|
||||
};
|
||||
|
||||
if (initialTheme) applyTheme(initialTheme);
|
||||
|
||||
// IDE shortcuts (Save, Close, Find Action…) pressed while the page has focus:
|
||||
// the ones the page left alone go to the workbench. Editing keys stay here.
|
||||
const EDITING_KEYS = new Set(["a", "c", "v", "x", "z", "y"]);
|
||||
window.addEventListener("keydown", (e) => {
|
||||
const fn = /^F\d{1,2}$/.test(e.key);
|
||||
const mod = (e.metaKey || e.ctrlKey) && !EDITING_KEYS.has(e.key.toLowerCase());
|
||||
if (e.isComposing || (!fn && !mod)) return;
|
||||
// After every handler of the page has seen it.
|
||||
setTimeout(() => {
|
||||
if (e.defaultPrevented) return;
|
||||
const { key, code, metaKey, ctrlKey, altKey, shiftKey, repeat } = e;
|
||||
window.parent.postMessage({ tag: TAG, key: { key, code, metaKey, ctrlKey, altKey, shiftKey, repeat } }, "*");
|
||||
}, 0);
|
||||
});
|
||||
window.addEventListener("message", (e) => {
|
||||
const m = e.data;
|
||||
if (e.source !== window.parent || !m || m.tag !== TAG) return;
|
||||
if (m.theme) applyTheme(m.theme);
|
||||
else if (typeof m.reply === "number") {
|
||||
const p = pending.get(m.reply);
|
||||
pending.delete(m.reply);
|
||||
if (p) "error" in m ? p.reject(new Error(String(m.error))) : p.resolve(m.value);
|
||||
} else if (m.document) {
|
||||
for (const l of documentListeners) safely(l, m.document);
|
||||
} else if (typeof m.text === "string") {
|
||||
for (const l of listeners) safely(l, m.text);
|
||||
}
|
||||
});
|
||||
|
||||
const documentApi = Object.freeze({
|
||||
/** `{ version, text, readOnly, name }` of the document a custom editor shows (`name`: the file's, no directory). */
|
||||
get: () => call("document.get"),
|
||||
/** Called with `{ version, text }` when the document changes other than through this frame. */
|
||||
onChange: (fn) => {
|
||||
if (typeof fn === "function") documentListeners.push(fn);
|
||||
},
|
||||
/**
|
||||
* Applies `edits` (`{ from, to, insert }`, offsets in UTF-16 code units of
|
||||
* the text at `version`) as one change; rejects with "stale" when the
|
||||
* document is no longer at `version`. Resolves with the new version.
|
||||
*/
|
||||
edit: (version, edits) => call("document.edit", { version, edits }),
|
||||
/** Replaces the whole text (sent as the smallest edit). */
|
||||
replace: (version, text) => call("document.replace", { version, text: String(text) }),
|
||||
});
|
||||
|
||||
window.openidea = Object.freeze({
|
||||
/** To the plugin's code (`ViewEvent::Message`). */
|
||||
postMessage: (text) => window.parent.postMessage({ tag: TAG, text: String(text) }, "*"),
|
||||
/** From the plugin's code (`host::post_view_message`). */
|
||||
onMessage: (fn) => {
|
||||
if (typeof fn === "function") listeners.push(fn);
|
||||
},
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
onThemeChange: (fn) => {
|
||||
if (typeof fn === "function") themeListeners.push(fn);
|
||||
},
|
||||
document: documentApi,
|
||||
/**
|
||||
* Asks the user where to save `data` (a string, bytes or a Blob), starting
|
||||
* with `name`; resolves with the chosen path, or null when cancelled.
|
||||
*/
|
||||
saveFile: async ({ name, data, filters } = {}) => {
|
||||
// A copy: its buffer is handed over, the caller's data stays usable.
|
||||
const bytes = (await bytesOf(data)).slice();
|
||||
return call("saveFile", { name: String(name || "untitled"), filters, bytes }, [bytes.buffer]);
|
||||
},
|
||||
/** Asks the user for a file; resolves with `{ name, data }` (bytes), or null when cancelled. */
|
||||
openFile: ({ filters } = {}) => call("openFile", { filters }),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The document of a view's `html` body. It runs in an `<iframe sandbox=
|
||||
* "allow-scripts">` (an opaque origin: no cookies, storage, IDE API or parent
|
||||
* access) under a CSP that allows only its own inline scripts and styles and
|
||||
* `data:` images — no network. `openidea.postMessage` / `openidea.onMessage`
|
||||
* are its only way to the plugin.
|
||||
*
|
||||
* The IDE theme reaches the frame as `data-theme="light|dark"` on its root and
|
||||
* the `--openidea-*` variables of {@link FRAME_THEME_TOKENS}, kept current
|
||||
* without reloading the frame; `openidea.theme` / `openidea.onThemeChange` give
|
||||
* the same to scripts (canvas drawing, say).
|
||||
*/
|
||||
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
|
||||
import BRIDGE_SOURCE from "./frame-bridge.js?raw";
|
||||
|
||||
export const FRAME_CSP =
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:";
|
||||
|
||||
/** Marks frame ↔ workbench messages. */
|
||||
export const FRAME_TAG = "openidea-view";
|
||||
|
||||
/**
|
||||
* The theme variables a frame gets: public name → IDE token. Part of the
|
||||
* plugin API: names are only ever added, so the IDE's own tokens can change.
|
||||
*/
|
||||
export const FRAME_THEME_TOKENS = {
|
||||
"--openidea-bg": "--bg-island",
|
||||
"--openidea-bg-elevated": "--bg-elev",
|
||||
"--openidea-bg-hover": "--bg-hover",
|
||||
"--openidea-bg-selected": "--bg-tree-selected",
|
||||
"--openidea-bg-input": "--bg-input",
|
||||
"--openidea-fg": "--fg",
|
||||
"--openidea-fg-strong": "--fg-strong",
|
||||
"--openidea-fg-muted": "--fg-muted",
|
||||
"--openidea-border": "--border",
|
||||
"--openidea-accent": "--accent",
|
||||
"--openidea-fg-on-accent": "--fg-on-accent",
|
||||
"--openidea-link": "--link",
|
||||
"--openidea-focus-ring": "--focus-ring",
|
||||
"--openidea-danger": "--danger",
|
||||
"--openidea-warning": "--warning",
|
||||
"--openidea-success": "--success",
|
||||
"--openidea-info": "--info",
|
||||
} as const;
|
||||
|
||||
/** Non-colour variables: public name → IDE token. */
|
||||
const FRAME_FONT_TOKENS = {
|
||||
"--openidea-font-ui": "--font-ui",
|
||||
"--openidea-font-mono": "--font-mono",
|
||||
"--openidea-font-size": "--fs-ui",
|
||||
} as const;
|
||||
|
||||
export interface FrameTheme {
|
||||
dark: boolean;
|
||||
/** `--openidea-*` → a concrete value (colours resolved, no `var()`: the frame has no IDE stylesheet). */
|
||||
vars: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Font stacks and sizes only; anything that could leave its declaration is dropped. */
|
||||
const safeValue = (v: string) => v.length <= 400 && !/[<>{};\\]/.test(v);
|
||||
|
||||
/** The IDE theme as a frame sees it, read from the root element's tokens. */
|
||||
export function frameTheme(root: HTMLElement = document.documentElement): FrameTheme {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [name, token] of Object.entries(FRAME_THEME_TOKENS)) {
|
||||
const c = resolveTokenColor(token, root);
|
||||
if (c) vars[name] = toRgb(c);
|
||||
}
|
||||
if (typeof getComputedStyle !== "undefined") {
|
||||
const css = getComputedStyle(root);
|
||||
for (const [name, token] of Object.entries(FRAME_FONT_TOKENS)) {
|
||||
const v = css.getPropertyValue(token).trim();
|
||||
if (v && safeValue(v)) vars[name] = v;
|
||||
}
|
||||
}
|
||||
return { dark: root.dataset.theme !== "light", vars };
|
||||
}
|
||||
|
||||
/** The message that updates a loaded frame's theme. */
|
||||
export const frameThemeMessage = (theme: FrameTheme) => ({ tag: FRAME_TAG, theme });
|
||||
|
||||
/** `window.openidea`, a function expression taking the initial theme (shared with web view pages). */
|
||||
const bridge = (initial: FrameTheme | null) =>
|
||||
`(${BRIDGE_SOURCE.trim().replace(/;$/, "")})(${JSON.stringify(initial).replace(/</g, "\\u003c")});`;
|
||||
|
||||
const BASE_STYLE =
|
||||
"html[data-theme=light]{color-scheme:light}html[data-theme=dark]{color-scheme:dark}" +
|
||||
"body{margin:8px;color:var(--openidea-fg);font-family:var(--openidea-font-ui,system-ui,sans-serif);" +
|
||||
"font-size:var(--openidea-font-size,13px)}a{color:var(--openidea-link)}";
|
||||
|
||||
/** `html` wrapped with the CSP, the message bridge, the theme and base styles. */
|
||||
export function frameDocument(html: string, theme: FrameTheme): string {
|
||||
return [
|
||||
`<!doctype html><html data-theme="${theme.dark ? "dark" : "light"}"><head><meta charset="utf-8">`,
|
||||
`<meta http-equiv="Content-Security-Policy" content="${FRAME_CSP}">`,
|
||||
`<style>${BASE_STYLE}</style>`,
|
||||
`<script>${bridge(theme)}</script>`,
|
||||
"</head><body>",
|
||||
html,
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Client state of plugin views: a revision per (plugin, view) that bumps when
|
||||
* the plugin asks for a refresh (`viewChanged`), and the messages its code
|
||||
* sends to the script of an `html` body (`viewMessage`).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
|
||||
export const viewKey = (pluginId: string, view: string) => `${pluginId}/${view}`;
|
||||
|
||||
export const useViewRevisions = create<{ revisions: Record<string, number> }>(() => ({ revisions: {} }));
|
||||
|
||||
/** The plugin's view content changed: rendered views fetch it again. */
|
||||
export function bumpView(pluginId: string, view: string) {
|
||||
const key = viewKey(pluginId, view);
|
||||
useViewRevisions.setState((s) => ({ revisions: { ...s.revisions, [key]: (s.revisions[key] ?? 0) + 1 } }));
|
||||
}
|
||||
|
||||
export function useViewRevision(pluginId: string, view: string): number {
|
||||
return useViewRevisions((s) => s.revisions[viewKey(pluginId, view)] ?? 0);
|
||||
}
|
||||
|
||||
type MessageListener = (message: string) => void;
|
||||
const messageListeners = new Map<string, Set<MessageListener>>();
|
||||
|
||||
/** Delivers a plugin's message to the frames of its view. */
|
||||
export function deliverViewMessage(pluginId: string, view: string, message: string) {
|
||||
for (const l of messageListeners.get(viewKey(pluginId, view)) ?? []) l(message);
|
||||
}
|
||||
|
||||
export function onViewMessage(pluginId: string, view: string, listener: MessageListener): () => void {
|
||||
const key = viewKey(pluginId, view);
|
||||
const set = messageListeners.get(key) ?? new Set();
|
||||
set.add(listener);
|
||||
messageListeners.set(key, set);
|
||||
return () => {
|
||||
set.delete(listener);
|
||||
if (!set.size) messageListeners.delete(key);
|
||||
};
|
||||
}
|
||||
@@ -37,7 +37,9 @@ import {
|
||||
Tree,
|
||||
acquirePopup,
|
||||
fileIconName,
|
||||
useFileIconRevision,
|
||||
folderIconName,
|
||||
themedFolderIconName,
|
||||
showContextMenu,
|
||||
SymbolBadge,
|
||||
type DragSession,
|
||||
@@ -125,6 +127,7 @@ function iconFor(
|
||||
root: string | null,
|
||||
build: BuildPresentation,
|
||||
detected: readonly RootFolder[],
|
||||
expanded: boolean,
|
||||
): string {
|
||||
if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file";
|
||||
if (node.icon) return node.icon;
|
||||
@@ -136,7 +139,7 @@ function iconFor(
|
||||
const key = rel !== null && build.modules.has(rel) ? rel : node.path;
|
||||
const module = root ? moduleFolderIcon(key, node.root, build.modules) : null;
|
||||
if (module) return module;
|
||||
return node.root ? "module" : "folder";
|
||||
return node.root ? "module" : (themedFolderIconName(node.label, expanded) ?? "folder");
|
||||
}
|
||||
return fileIconName(node.label);
|
||||
}
|
||||
@@ -267,15 +270,18 @@ const Row = memo(function Row({
|
||||
onDoubleClick,
|
||||
onPointerDown,
|
||||
}: RowProps) {
|
||||
// Memoised: redraw this row itself when the file icon theme changes.
|
||||
useFileIconRevision();
|
||||
const deco = useFileDecoration(node.path);
|
||||
const roots = useEffectiveRoots();
|
||||
const root = useProjectTree((s) => s.root);
|
||||
const detected = useDetectedRoots();
|
||||
const build = useProjectPresentation();
|
||||
const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs);
|
||||
const expanded = useProjectTree((s) => node.dir && s.expanded.has(node.key));
|
||||
const icon =
|
||||
node.dir || node.placeholder || node.icon
|
||||
? iconFor(node, roots, root, build, detected)
|
||||
? iconFor(node, roots, root, build, detected, expanded)
|
||||
: presentationIcon(node.path, file, roots, root, build.spring);
|
||||
const moduleFolder =
|
||||
node.dir && !node.placeholder && !!root && isModuleFolder(relativePath(root, node.path), node.root, build.modules);
|
||||
@@ -396,6 +402,8 @@ function folderAt(container: HTMLElement | null, x: number, y: number): { key: s
|
||||
|
||||
export function ProjectView() {
|
||||
const txTemplate = useTxTemplate();
|
||||
// Rows compute their file icons while rendering: redraw when the icon theme changes.
|
||||
useFileIconRevision();
|
||||
const root = useProjectTree((s) => s.root);
|
||||
const model = useProjectTree(selectModel);
|
||||
const expanded = useProjectTree((s) => s.expanded);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useFilePresentation } from "@/platform/filePresentation";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { actionToggled, executeAction, getAction } from "@/platform/actions";
|
||||
import { actionToggled, executeAction, executeActionFrom, getAction } from "@/platform/actions";
|
||||
import type { ProjectFileSet } from "@/platform/ipc";
|
||||
import { tx as translate, useTx } from "@/platform/i18n";
|
||||
import { hasPrimaryModifier, shortcutLabel } from "@/platform/keymap";
|
||||
@@ -281,10 +281,10 @@ function SearchEverywhereDialog() {
|
||||
if (!row.enabled) return;
|
||||
pushRecentAction(row.id);
|
||||
if (getAction(row.id)?.toggled) {
|
||||
void executeAction(row.id).then(() => bumpToggles());
|
||||
void executeActionFrom("search", row.id).then(() => bumpToggles());
|
||||
return;
|
||||
}
|
||||
return closeThen(() => void executeAction(row.id));
|
||||
return closeThen(() => void executeActionFrom("search", row.id));
|
||||
case "setting":
|
||||
return closeThen(() => void executeAction("file.settings", row.pageId));
|
||||
}
|
||||
@@ -761,7 +761,7 @@ function ResultRow({
|
||||
aria-label={row.title}
|
||||
onChange={() => {
|
||||
pushRecentAction(row.id);
|
||||
void executeAction(row.id).then(onToggled);
|
||||
void executeActionFrom("search", row.id).then(onToggled);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -28,13 +28,33 @@ interface Customizations {
|
||||
|
||||
const EMPTY: Customizations = { templates: {}, deleted: [], includes: {}, deletedIncludes: [], code: {} };
|
||||
|
||||
/** Templates plugins contribute (`fileTemplates`): treated like built-ins (customised, reset, hidden). */
|
||||
let contributed: readonly FileTemplate[] = [];
|
||||
|
||||
const isBase = (id: string) => BUILTIN_IDS.has(id) || contributed.some((t) => t.id === id);
|
||||
|
||||
export function mergeFileTemplates(c: Pick<Customizations, "templates" | "deleted">): FileTemplate[] {
|
||||
const deleted = new Set(c.deleted);
|
||||
const out = BUILTIN_FILE_TEMPLATES.filter((t) => !deleted.has(t.id)).map((t) => c.templates[t.id] ?? t);
|
||||
for (const t of Object.values(c.templates)) if (!BUILTIN_IDS.has(t.id) && !deleted.has(t.id)) out.push(t);
|
||||
const base = [...BUILTIN_FILE_TEMPLATES, ...contributed];
|
||||
const out = base.filter((t) => !deleted.has(t.id)).map((t) => c.templates[t.id] ?? t);
|
||||
for (const t of Object.values(c.templates)) if (!isBase(t.id) && !deleted.has(t.id)) out.push(t);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Adds a plugin's template to the New menu until the returned function is called. */
|
||||
export function addContributedFileTemplate(t: FileTemplate): () => void {
|
||||
contributed = [...contributed.filter((x) => x.id !== t.id), t];
|
||||
refreshMerged();
|
||||
return () => {
|
||||
contributed = contributed.filter((x) => x !== t);
|
||||
refreshMerged();
|
||||
};
|
||||
}
|
||||
|
||||
function refreshMerged() {
|
||||
useFileTemplates.setState((s) => ({ templates: mergeFileTemplates(s.custom) }));
|
||||
}
|
||||
|
||||
export function mergeIncludes(c: Pick<Customizations, "includes" | "deletedIncludes">): TemplateFragment[] {
|
||||
const deleted = new Set(c.deletedIncludes);
|
||||
const out = BUILTIN_INCLUDES.filter((f) => !deleted.has(f.name)).map((f) => c.includes[f.name] ?? f);
|
||||
@@ -98,7 +118,7 @@ function commit(custom: Customizations) {
|
||||
const custom = () => useFileTemplates.getState().custom;
|
||||
|
||||
export const fileTemplates = () => useFileTemplates.getState().templates;
|
||||
export const isBuiltinFileTemplate = (id: string) => BUILTIN_IDS.has(id);
|
||||
export const isBuiltinFileTemplate = (id: string) => isBase(id);
|
||||
|
||||
/** `#parse` lookup over the current scheme's includes, then its code templates. */
|
||||
export function templateIncludes(): IncludeResolver {
|
||||
@@ -115,7 +135,7 @@ export function removeFileTemplate(id: string) {
|
||||
const c = custom();
|
||||
const templates = { ...c.templates };
|
||||
delete templates[id];
|
||||
commit({ ...c, templates, deleted: BUILTIN_IDS.has(id) ? [...new Set([...c.deleted, id])] : c.deleted });
|
||||
commit({ ...c, templates, deleted: isBase(id) ? [...new Set([...c.deleted, id])] : c.deleted });
|
||||
}
|
||||
|
||||
export function resetFileTemplate(id: string) {
|
||||
@@ -126,7 +146,7 @@ export function resetFileTemplate(id: string) {
|
||||
}
|
||||
|
||||
export function isModifiedFileTemplate(id: string): boolean {
|
||||
return BUILTIN_IDS.has(id) && id in custom().templates;
|
||||
return isBase(id) && id in custom().templates;
|
||||
}
|
||||
|
||||
/** Saves an include; `previous` is its name before a rename. */
|
||||
|
||||
@@ -25,7 +25,8 @@ import { setShownLine } from "@/editor/api";
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
import { FloatingToolbar, selectableText } from "@/ui";
|
||||
import { publishPreviewTop, useLiveMarkdown } from "./live";
|
||||
import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn, subscribeTheme, themeKey } from "./mermaid";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn } from "./mermaid";
|
||||
import { modeOf, useMarkdownModes } from "./modes";
|
||||
import { MarkdownLayoutActions } from "./MarkdownLayoutActions";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderMarkdown } from "./render";
|
||||
import { hasMermaid, renderMermaidIn, resetMermaidForTests, subscribeTheme, themeKey } from "./mermaid";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { hasMermaid, renderMermaidIn, resetMermaidForTests } from "./mermaid";
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
loads: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { fontsLoaded } from "@/platform/fonts";
|
||||
import type { Mermaid } from "mermaid";
|
||||
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
|
||||
import { themeKey } from "@/platform/themeWatch";
|
||||
import { attachPanZoom } from "../panZoom";
|
||||
|
||||
export const MERMAID_SELECTOR = "[data-mermaid]";
|
||||
@@ -29,26 +30,6 @@ function loadMermaid(): Promise<Mermaid> {
|
||||
return loading;
|
||||
}
|
||||
|
||||
/** Theme identity: anything that changes the colours read below. */
|
||||
export function themeKey(): string {
|
||||
const root = document.documentElement;
|
||||
const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset;
|
||||
const accent = root.style.getPropertyValue("--accent");
|
||||
const font = getComputedStyle(root).getPropertyValue("--font-ui");
|
||||
return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`;
|
||||
}
|
||||
|
||||
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
|
||||
export function subscribeTheme(cb: () => void): () => void {
|
||||
if (typeof MutationObserver === "undefined") return () => {};
|
||||
const observer = new MutationObserver(cb);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagram colours from the IDE tokens. Mermaid parses only hex and `rgb()`, so
|
||||
* tokens a theme writes as `hsl()`, `color-mix()` or `var()` are resolved to RGB.
|
||||
|
||||
@@ -10,15 +10,13 @@ import { defaultPreset, PRESET_TITLES } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Checkbox, Select, type SelectOption, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { ACCENT_PRESETS, DEFAULT_ACCENT } from "@/workbench/theme";
|
||||
import { ACCENT_PRESETS, DEFAULT_ACCENT, themeSelectOptions, useThemeChoices } from "@/workbench/theme";
|
||||
import { reopenEnabled, setReopenPreference, useSession } from "./session";
|
||||
import styles from "./Welcome.module.css";
|
||||
import { BUILTIN_THEMES, isBuiltinTheme, type BuiltinThemeId } from "@/platform/themes";
|
||||
import { isThemeChoiceId, type ThemeChoiceId } from "@/platform/themes";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
type Theme = BuiltinThemeId;
|
||||
|
||||
export const THEME_OPTIONS: SelectOption<Theme>[] = BUILTIN_THEMES.map((t) => ({ value: t.id, label: t.label }));
|
||||
type Theme = ThemeChoiceId;
|
||||
|
||||
const DEFAULT_UI_FONT_SIZE = 13;
|
||||
const DEFAULT_UI_FONT = "Inter";
|
||||
@@ -50,7 +48,7 @@ interface AppearanceView {
|
||||
/** The values Customize shows, from (possibly partial / legacy) settings. */
|
||||
export function customizeView(settings: ReturnType<typeof useProject.getState>["settings"]): AppearanceView {
|
||||
const a = (settings?.appearance ?? {}) as Record<string, unknown>;
|
||||
const theme = isBuiltinTheme(a.theme) ? a.theme : "light";
|
||||
const theme = isThemeChoiceId(a.theme) ? a.theme : "light";
|
||||
const custom = a.useCustomFont === true;
|
||||
const size = typeof a.uiFontSize === "number" ? a.uiFontSize : DEFAULT_UI_FONT_SIZE;
|
||||
return {
|
||||
@@ -98,6 +96,7 @@ export function CustomizePage() {
|
||||
);
|
||||
};
|
||||
|
||||
const themes = useThemeChoices();
|
||||
const keymaps = useMemo<SelectOption[]>(() => {
|
||||
const custom = (settings?.customKeymaps ?? {}) as Record<string, { name?: string }>;
|
||||
const list: SelectOption[] = Object.entries(PRESET_TITLES).map(([value, label]) => ({ value, label }));
|
||||
@@ -112,7 +111,7 @@ export function CustomizePage() {
|
||||
<Field label="Theme">
|
||||
<Select<Theme>
|
||||
value={shown.theme}
|
||||
options={THEME_OPTIONS}
|
||||
options={themeSelectOptions(themes, shown.theme)}
|
||||
disabled={shown.syncWithOs}
|
||||
aria-label="Color theme"
|
||||
onChange={(t) => update("theme", t, () => setGlobalSetting("appearance.theme", t))}
|
||||
|
||||
@@ -140,8 +140,13 @@ export function actionToggled(action: Action | undefined): boolean | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
type ActionListener = (id: string, arg: unknown) => void;
|
||||
/** How the user started an action (for listeners such as plugins' `action-performed`). */
|
||||
export type ActionSource = "keyboard" | "menu" | "toolbar" | "contextMenu" | "search" | "other";
|
||||
|
||||
type ActionListener = (id: string, arg: unknown, source: ActionSource) => void;
|
||||
const listeners = new Set<ActionListener>();
|
||||
/** Set by `executeActionFrom` for the duration of the synchronous dispatch. */
|
||||
let currentSource: ActionSource = "other";
|
||||
/** Actions running synchronously inside another action's `run`. */
|
||||
let nesting = 0;
|
||||
|
||||
@@ -166,7 +171,7 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean>
|
||||
if (nesting === 0)
|
||||
for (const l of listeners)
|
||||
try {
|
||||
l(id, arg);
|
||||
l(id, arg, currentSource);
|
||||
} catch (e) {
|
||||
console.error("action listener failed", e);
|
||||
}
|
||||
@@ -184,3 +189,14 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean>
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `executeAction`, telling listeners how the user started it. */
|
||||
export function executeActionFrom(source: ActionSource, id: string, arg?: unknown): Promise<boolean> {
|
||||
const previous = currentSource;
|
||||
currentSource = source;
|
||||
try {
|
||||
return executeAction(id, arg);
|
||||
} finally {
|
||||
currentSource = previous;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Visible-file JVM presentation: one bounded IPC batch, shared cache and watcher. */
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { create } from "zustand";
|
||||
import { fileIconName, type IconName } from "@/ui/icons";
|
||||
import { fileIconName, useFileIconRevision, type IconName } from "@/ui/icons";
|
||||
import { call, commands, isTauri, onIdeEvent, type JvmPresentation } from "./ipc";
|
||||
import { basename, relativePath, useProject } from "./project";
|
||||
import { rootFor, type RootFolder } from "./languageSettings";
|
||||
@@ -157,6 +157,7 @@ export function useJvmPresentation(path: string, mtime?: number | null) {
|
||||
}
|
||||
|
||||
export function useFilePresentation(path: string, mtime?: number | null) {
|
||||
useFileIconRevision();
|
||||
const info = useJvmPresentation(path, mtime);
|
||||
const roots = useEffectiveRoots();
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
@@ -173,6 +174,7 @@ export function useFilePresentationIcons(paths: readonly string[]) {
|
||||
return () => disposers.forEach((dispose) => dispose());
|
||||
}, [key]);
|
||||
const entries = cache((s) => s.entries);
|
||||
const iconRevision = useFileIconRevision();
|
||||
const roots = useEffectiveRoots();
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const build = useProjectPresentation();
|
||||
@@ -184,6 +186,7 @@ export function useFilePresentationIcons(paths: readonly string[]) {
|
||||
presentationIcon(path, entries.get(path), roots, root, build.spring),
|
||||
]),
|
||||
),
|
||||
[key, entries, roots, root, build.spring],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- iconRevision: the file icon theme changed
|
||||
[key, entries, roots, root, build.spring, iconRevision],
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* at either level (`ide:<key>` / `project:<key>`).
|
||||
* Mirrors `ide-core::inspection_settings`.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import type { Diagnostic, Severity } from "@/bindings";
|
||||
import { useProject } from "./project";
|
||||
|
||||
@@ -472,6 +473,12 @@ function loadSeen(): Map<string, SeenCode> {
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the producers that aren't language servers, for Settings ›
|
||||
* Inspections: plugins (`plugin:<id>`) by their names.
|
||||
*/
|
||||
export const useProducerNames = create<{ names: ReadonlyMap<string, string> }>(() => ({ names: new Map() }));
|
||||
|
||||
/** Records the codes of a producer's diagnostics (persisted, shared with the Settings window). */
|
||||
export function noteDiagnosticCodes(producer: string, items: readonly Diagnostic[]) {
|
||||
if (!producer) return;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* optionally with modifiers (`Alt+Button4`); they are single strokes.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { getAction, isActionKeyEnabled, executeAction } from "./actions";
|
||||
import { executeAction, executeActionFrom, getAction, isActionKeyEnabled } from "./actions";
|
||||
import { getContext } from "./context";
|
||||
import { intellijMacos } from "./keymaps/intellij-macos";
|
||||
import { intellijWindows } from "./keymaps/intellij-windows";
|
||||
@@ -70,9 +70,11 @@ interface KeymapState {
|
||||
preset: string;
|
||||
/** User overrides: action id → bindings (empty array removes defaults). */
|
||||
overrides: Keymap;
|
||||
/** Extensions' default bindings (plugins), below the preset. */
|
||||
extensions: Keymap;
|
||||
}
|
||||
|
||||
export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {} }));
|
||||
export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {}, extensions: {} }));
|
||||
|
||||
const MOUSE_KEY_RE = /^Button(\d+)$/i;
|
||||
|
||||
@@ -189,17 +191,61 @@ export function suspendKeyboardDispatch(): () => void {
|
||||
}
|
||||
|
||||
export function bindingsFor(actionId: string): string[] {
|
||||
const { preset, overrides } = useKeymap.getState();
|
||||
return overrides[actionId] ?? PRESETS[preset]?.[actionId] ?? [];
|
||||
const { preset, overrides, extensions } = useKeymap.getState();
|
||||
return overrides[actionId] ?? PRESETS[preset]?.[actionId] ?? usableExtensionBindings(extensions)[actionId] ?? [];
|
||||
}
|
||||
|
||||
/** Every (binding, actionId) pair of the effective keymap. */
|
||||
export function effectiveBindings(): Array<[string, string]> {
|
||||
const { preset, overrides } = useKeymap.getState();
|
||||
const merged: Keymap = { ...PRESETS[preset], ...overrides };
|
||||
const { preset, overrides, extensions } = useKeymap.getState();
|
||||
const merged: Keymap = { ...usableExtensionBindings(extensions), ...PRESETS[preset], ...overrides };
|
||||
return Object.entries(merged).flatMap(([id, keys]) => keys.map((k) => [normalizeBinding(k), id] as [string, string]));
|
||||
}
|
||||
|
||||
let extensionCache: { source: Keymap; preset: string; overrides: Keymap; usable: Keymap } | null = null;
|
||||
|
||||
/**
|
||||
* Plugin default bindings that stay: those of actions the preset and the
|
||||
* user leave unbound, on keystrokes nothing else uses (the IDE's own
|
||||
* shortcuts always win).
|
||||
*/
|
||||
function usableExtensionBindings(extensions: Keymap | undefined = {}): Keymap {
|
||||
const { preset, overrides } = useKeymap.getState();
|
||||
if (
|
||||
extensionCache?.source === extensions &&
|
||||
extensionCache.preset === preset &&
|
||||
extensionCache.overrides === overrides
|
||||
)
|
||||
return extensionCache.usable;
|
||||
const builtIn: Keymap = { ...PRESETS[preset], ...overrides };
|
||||
const taken = new Set(Object.values(builtIn).flatMap((keys) => keys.map(normalizeBinding)));
|
||||
const usable: Keymap = {};
|
||||
for (const [id, keys] of Object.entries(extensions)) {
|
||||
if (id in builtIn) continue;
|
||||
const free = keys.filter((k) => !taken.has(normalizeBinding(k)));
|
||||
for (const k of free) taken.add(normalizeBinding(k));
|
||||
if (free.length) usable[id] = free;
|
||||
}
|
||||
extensionCache = { source: extensions, preset, overrides, usable };
|
||||
return usable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default bindings contributed by an extension (a plugin's `keymaps`), below
|
||||
* the preset and the user's: dropped when the keystroke is already taken.
|
||||
* Returns a disposer.
|
||||
*/
|
||||
export function registerExtensionBindings(actionId: string, bindings: string[]): () => void {
|
||||
const valid = bindings.filter(isValidBinding);
|
||||
useKeymap.setState((s) => ({ extensions: { ...(s.extensions ?? {}), [actionId]: valid } }));
|
||||
return () =>
|
||||
useKeymap.setState((s) => {
|
||||
if (s.extensions?.[actionId] !== valid) return s;
|
||||
const { [actionId]: _, ...rest } = s.extensions ?? {};
|
||||
return { extensions: rest };
|
||||
});
|
||||
}
|
||||
|
||||
const MOD_ORDER = MODIFIERS;
|
||||
|
||||
/** Canonical form: modifiers in fixed order, so `Shift+Meta+o` == `Meta+Shift+O`. */
|
||||
@@ -402,7 +448,7 @@ export function isAltGrTyping(
|
||||
function runIfEnabled(id: string): boolean {
|
||||
const action = getAction(id);
|
||||
if (!action || !isActionKeyEnabled(action)) return false;
|
||||
void executeAction(id);
|
||||
void executeActionFrom("keyboard", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -477,7 +523,7 @@ export function installKeyboardDispatcher(
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
tap.keyUsed = true;
|
||||
void executeAction(id);
|
||||
void executeActionFrom("keyboard", id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -526,7 +572,7 @@ export function installKeyboardDispatcher(
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
reset();
|
||||
void executeAction(id);
|
||||
void executeActionFrom("keyboard", id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Changes of the backend's language list (plugins add and remove languages),
|
||||
* for the views that cache it: File Types, Override File Type, Code Style
|
||||
* pages, Inspections.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
|
||||
export const useLanguageListVersion = create<{ version: number }>(() => ({ version: 0 }));
|
||||
|
||||
/** The backend's language list changed. */
|
||||
export function languageListChanged() {
|
||||
useLanguageListVersion.setState((s) => ({ version: s.version + 1 }));
|
||||
}
|
||||
|
||||
/** Calls `cb` after each change; returns the unsubscribe function. */
|
||||
export function onLanguageListChanged(cb: () => void): () => void {
|
||||
return useLanguageListVersion.subscribe((s, prev) => {
|
||||
if (s.version !== prev.version) cb();
|
||||
});
|
||||
}
|
||||
@@ -670,7 +670,6 @@
|
||||
"Detected from the toolchain": "从工具链检测到",
|
||||
"Detecting installations": "正在检测安装",
|
||||
"Detecting…": "正在检测…",
|
||||
"Dev plugin": "开发插件",
|
||||
"Diff": "差异",
|
||||
"Different tool window background": "不同的工具窗口背景",
|
||||
"Direction": "方向",
|
||||
@@ -3731,8 +3730,6 @@
|
||||
"Macro '{name}' stopped": "宏 '{name}' 已停止",
|
||||
"Id": "ID",
|
||||
"Enable": "启用",
|
||||
"{name}: the editor is read-only": "{name}:编辑器为只读",
|
||||
"{name}: no editor to insert into": "{name}:没有可插入的编辑器",
|
||||
"The plugin failed to start.": "插件启动失败。",
|
||||
"Move Directory…": "移动目录…",
|
||||
"Cannot move {name}": "无法移动 {name}",
|
||||
@@ -5711,5 +5708,76 @@
|
||||
"What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)",
|
||||
"Build and run using": "构建和运行使用",
|
||||
"Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath.": "Gradle 以 JavaExec 任务在源集的运行时类路径上运行主类(包括 Gradle 的资源处理)。OpenIDEA 在 Java 语言服务器的类路径上启动它。",
|
||||
"Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径"
|
||||
"Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径",
|
||||
"Needs permission": "需要授权",
|
||||
"linked": "已链接",
|
||||
"Revoke {id}": "撤销 {id}",
|
||||
"Allow {id}": "允许 {id}",
|
||||
"This version asks for new permissions. It stays off until you allow them.": "此版本请求了新的权限。在你允许之前它保持停用。",
|
||||
"Plugins from a project folder run only after you allow them.": "来自项目文件夹的插件需要你允许后才会运行。",
|
||||
"Review Permissions…": "查看权限…",
|
||||
"Statistics": "统计",
|
||||
"Activated {activations}× (last in {toFixed} ms), {calls} calls, {errors} errors, slowest {toFixed} ms": "已激活 {activations} 次(最近一次 {toFixed} 毫秒),调用 {calls} 次,错误 {errors} 次,最慢 {toFixed} 毫秒",
|
||||
"Recent calls p95 {toFixed} ms; {crashes} crashes, {slowCalls} editor results too late, reclaimed {reclaims}× when idle": "近期调用 p95 {toFixed} 毫秒;崩溃 {crashes} 次,编辑器结果超时 {slowCalls} 次,空闲回收 {reclaims} 次",
|
||||
"Linked plugin": "链接的插件",
|
||||
"Project plugin": "项目插件",
|
||||
"Signed by {publisher}": "由 {publisher} 签名",
|
||||
"Unsigned": "未签名",
|
||||
"Roll Back to {previousVersion}": "回滚到 {previousVersion}",
|
||||
"Install Plugin from Package File…": "从插件包文件安装…",
|
||||
"Marketplace index URL": "插件市场索引 URL",
|
||||
"Refresh Marketplace": "刷新插件市场",
|
||||
"Marketplace": "插件市场",
|
||||
"Could not download plugin": "无法下载插件",
|
||||
"Rolled back {name} to {version}": "已将 {name} 回滚到 {version}",
|
||||
"Could not roll back": "无法回滚",
|
||||
"Could not open the package": "无法打开插件包",
|
||||
"Marketplace index URL (https://…/index.json or a folder path)": "插件市场索引 URL(https://…/index.json 或文件夹路径)",
|
||||
"Settings of the plugin; projects can override them.": "插件的设置;项目可以覆盖。",
|
||||
"Menus": "菜单",
|
||||
"Shortcuts": "快捷键",
|
||||
"Status Bar Items": "状态栏项",
|
||||
"Editors": "编辑器",
|
||||
"Open With": "打开方式",
|
||||
"The plugin could not show this view": "插件无法显示此视图",
|
||||
"Loading": "加载中",
|
||||
"Insert Row Above": "在上方插入行",
|
||||
"Insert Row Below": "在下方插入行",
|
||||
"Delete {length} Rows": "删除 {length} 行",
|
||||
"Delete Row": "删除行",
|
||||
"No rows": "没有行",
|
||||
"Cell value": "单元格的值",
|
||||
"Secrets": "密钥",
|
||||
"Stored": "已保存",
|
||||
"Not set": "未设置",
|
||||
"Replace…": "替换…",
|
||||
"Value of {label}": "{label} 的值",
|
||||
"The plugin can only send these with its network requests; it cannot read them.": "插件只能在网络请求中发送这些值,无法读取它们。",
|
||||
"Editor Providers": "编辑器提供者",
|
||||
"Linked for development": "为开发而链接",
|
||||
"Low": "低",
|
||||
"Medium": "中",
|
||||
"High": "高",
|
||||
"Install {name} {version}?": "安装 {name} {version}?",
|
||||
"Allow {name} to run?": "允许 {name} 运行?",
|
||||
"Allow": "允许",
|
||||
"This package is not signed. Only continue if you trust where it came from.": "此软件包未签名。只有在信任其来源时才继续。",
|
||||
"This plugin needs no special permissions.": "此插件不需要特殊权限。",
|
||||
"Requested permissions": "请求的权限",
|
||||
"Required": "必需",
|
||||
"High-risk permissions are equivalent to running arbitrary code on this machine.": "高风险权限相当于在这台计算机上运行任意代码。",
|
||||
"{icons} icons": "{icons} 个图标",
|
||||
"Use Default Icons": "使用默认图标",
|
||||
"Apply icon theme {label}": "应用图标主题 {label}",
|
||||
"File Icon Themes": "文件图标主题",
|
||||
"File Icons": "文件图标",
|
||||
"File Templates": "文件模板",
|
||||
"Cannot show {label}": "无法显示 {label}",
|
||||
"{title} layout": "{title} 布局",
|
||||
"Editor and {title}": "编辑器和{title}",
|
||||
"{title} Only": "仅{title}",
|
||||
"File icon theme": "文件图标主题",
|
||||
"Plugin not enabled": "插件未启用",
|
||||
"Problems the {plugin} plugin reports. Severity \"As reported\" keeps the level the plugin assigns; a code below can override it.": "{plugin} 插件报告的问题。严重性“按报告”保留插件分配的级别;下方的代码可以覆盖它。",
|
||||
"The plugin system is a preview: its API and the plugins may still change.": "插件系统目前是预览版:API 和插件都可能还会变化。"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Watching the active IDE theme: built-in theme, palette theme, plugin theme
|
||||
* and accent all live as attributes / inline style on the root element.
|
||||
*/
|
||||
|
||||
/** Theme identity: changes whenever the colours the theme tokens resolve to may change. */
|
||||
export function themeKey(): string {
|
||||
const root = document.documentElement;
|
||||
const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset;
|
||||
const accent = root.style.getPropertyValue("--accent");
|
||||
// The UI font (the custom one, or its fallback once loaded) is part of how a theme renders.
|
||||
const font = getComputedStyle(root).getPropertyValue("--font-ui");
|
||||
return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`;
|
||||
}
|
||||
|
||||
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
|
||||
export function subscribeTheme(cb: () => void): () => void {
|
||||
if (typeof MutationObserver === "undefined") return () => {};
|
||||
const observer = new MutationObserver(cb);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
@@ -1,8 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { BUILTIN_THEMES, isBuiltinTheme, themeKind } from "./themes";
|
||||
import {
|
||||
addContributedTheme,
|
||||
BUILTIN_THEMES,
|
||||
isBuiltinTheme,
|
||||
isThemeAvailable,
|
||||
parsePluginThemeRef,
|
||||
pluginThemeRef,
|
||||
themeChoices,
|
||||
themeKind,
|
||||
useContributedThemes,
|
||||
} from "./themes";
|
||||
import { _setSchemeSource, effectiveScheme, schemeCss, schemeTokens, themeScheme } from "./colorScheme";
|
||||
import { applyThemeAttributes, resolveTheme, resolveThemeId } from "@/workbench/theme";
|
||||
import {
|
||||
applyThemeAttributes,
|
||||
resolveTheme,
|
||||
resolveThemeChoice,
|
||||
resolveThemeId,
|
||||
themeSelectOptions,
|
||||
} from "@/workbench/theme";
|
||||
import { validateSettings } from "@/features/settings/model/schema";
|
||||
import { applyPluginTheme } from "@/features/plugins/bridge";
|
||||
|
||||
@@ -74,3 +90,39 @@ describe("built-in palettes", () => {
|
||||
expect(root.dataset.contrast).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin themes", () => {
|
||||
const ref = pluginThemeRef("acme.night", "night");
|
||||
afterEach(() => {
|
||||
useContributedThemes.setState({ themes: [] });
|
||||
localStorage.removeItem("openidea.themeKinds");
|
||||
});
|
||||
|
||||
it("are settings values like the built-in ones, in every picker while their plugin is live", () => {
|
||||
const a = { theme: ref, preferredLightTheme: "light", preferredDarkTheme: ref };
|
||||
expect(validateSettings({ appearance: a }).appearance).toMatchObject(a);
|
||||
expect(resolveThemeChoice(a, false)).toBe(ref);
|
||||
expect(resolveThemeChoice({ ...a, syncWithOs: true }, false)).toBe(ref);
|
||||
expect(resolveThemeChoice({ ...a, syncWithOs: true }, true)).toBe("light");
|
||||
|
||||
const remove = addContributedTheme({ id: ref, label: "Night", kind: "dark", origin: "Acme" });
|
||||
expect(isThemeAvailable(ref)).toBe(true);
|
||||
expect(themeChoices().at(-1)).toMatchObject({ id: ref, label: "Night" });
|
||||
expect(themeSelectOptions(themeChoices(), ref).at(-1)).toEqual({ value: ref, label: "Night", detail: "Acme" });
|
||||
// Its tokens apply over the default of its kind.
|
||||
expect(resolveThemeId(a, false)).toBe("dark");
|
||||
|
||||
remove();
|
||||
expect(isThemeAvailable(ref)).toBe(false);
|
||||
// Its plugin off: the base of the kind it had shows, and the pickers keep it listed.
|
||||
expect(resolveThemeId(a, false)).toBe("dark");
|
||||
expect(themeSelectOptions(themeChoices(), ref).at(-1)).toMatchObject({ value: ref, label: "night" });
|
||||
});
|
||||
|
||||
it("parse only well-formed references", () => {
|
||||
expect(parsePluginThemeRef(ref)).toEqual({ pluginId: "acme.night", themeId: "night" });
|
||||
for (const bad of ["plugin:", "plugin:/night", "plugin:acme/", "acme/night", 3])
|
||||
expect(parsePluginThemeRef(bad)).toBeNull();
|
||||
expect(validateSettings({ appearance: { theme: "plugin:/x" } }).appearance.theme).toBe("light");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/** Stable built-in ids shared by settings, quick switch and editor schemes. */
|
||||
export const BUILTIN_THEMES = [
|
||||
{ id: "light", label: "Islands Light", kind: "light" },
|
||||
@@ -17,3 +19,100 @@ export function isBuiltinTheme(value: unknown): value is BuiltinThemeId {
|
||||
export function themeKind(id: BuiltinThemeId): "light" | "dark" {
|
||||
return BUILTIN_THEMES.find((t) => t.id === id)!.kind;
|
||||
}
|
||||
|
||||
// --- themes contributed at runtime (plugins) ---------------------------------
|
||||
|
||||
/** A plugin theme as a theme setting value: `plugin:<pluginId>/<themeId>`. */
|
||||
export type PluginThemeRef = `plugin:${string}`;
|
||||
/** Any theme the settings can name: built-in or contributed. */
|
||||
export type ThemeChoiceId = BuiltinThemeId | PluginThemeRef;
|
||||
|
||||
export interface ThemeChoice {
|
||||
id: ThemeChoiceId;
|
||||
label: string;
|
||||
kind: "light" | "dark";
|
||||
/** Who contributes it (a plugin's name); built-in themes have none. */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export const pluginThemeRef = (pluginId: string, themeId: string): PluginThemeRef => `plugin:${pluginId}/${themeId}`;
|
||||
|
||||
export function parsePluginThemeRef(value: unknown): { pluginId: string; themeId: string } | null {
|
||||
if (typeof value !== "string" || !value.startsWith("plugin:")) return null;
|
||||
const rest = value.slice("plugin:".length);
|
||||
const slash = rest.lastIndexOf("/");
|
||||
if (slash <= 0 || slash === rest.length - 1) return null;
|
||||
return { pluginId: rest.slice(0, slash), themeId: rest.slice(slash + 1) };
|
||||
}
|
||||
|
||||
export function isThemeChoiceId(value: unknown): value is ThemeChoiceId {
|
||||
return isBuiltinTheme(value) || parsePluginThemeRef(value) !== null;
|
||||
}
|
||||
|
||||
/** Themes of live plugins, offered next to the built-in ones. */
|
||||
export const useContributedThemes = create<{ themes: readonly ThemeChoice[] }>(() => ({ themes: [] }));
|
||||
|
||||
const KINDS_KEY = "openidea.themeKinds";
|
||||
|
||||
function rememberedKinds(): Record<string, "light" | "dark"> {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(KINDS_KEY) ?? "{}");
|
||||
return v && typeof v === "object" ? (v as Record<string, "light" | "dark">) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Offers a contributed theme until the returned function is called. */
|
||||
export function addContributedTheme(theme: ThemeChoice): () => void {
|
||||
useContributedThemes.setState((s) => ({ themes: [...s.themes.filter((t) => t.id !== theme.id), theme] }));
|
||||
// Its kind outlives the plugin: a disabled plugin's theme falls back to the built-in of that kind.
|
||||
try {
|
||||
const kinds = rememberedKinds();
|
||||
if (kinds[theme.id] !== theme.kind)
|
||||
localStorage.setItem(KINDS_KEY, JSON.stringify({ ...kinds, [theme.id]: theme.kind }));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
return () => useContributedThemes.setState((s) => ({ themes: s.themes.filter((t) => t !== theme) }));
|
||||
}
|
||||
|
||||
/** Every theme that can be selected: the built-in ones, then the contributed ones by name. */
|
||||
export function themeChoices(
|
||||
contributed: readonly ThemeChoice[] = useContributedThemes.getState().themes,
|
||||
): ThemeChoice[] {
|
||||
return [...BUILTIN_THEMES, ...[...contributed].sort((a, b) => a.label.localeCompare(b.label))];
|
||||
}
|
||||
|
||||
/** Whether `id` can be applied now (a contributed theme's plugin may be off). */
|
||||
export function isThemeAvailable(id: ThemeChoiceId): boolean {
|
||||
return isBuiltinTheme(id) || useContributedThemes.getState().themes.some((t) => t.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in theme under `id`: itself, or for a contributed theme the
|
||||
* light / dark default of its kind (the base its tokens apply over, and what
|
||||
* shows while its plugin is off).
|
||||
*/
|
||||
export function baseTheme(id: ThemeChoiceId, fallback: BuiltinThemeId = "light"): BuiltinThemeId {
|
||||
if (isBuiltinTheme(id)) return id;
|
||||
const kind = useContributedThemes.getState().themes.find((t) => t.id === id)?.kind ?? rememberedKinds()[id];
|
||||
return kind === "dark" ? "dark" : kind === "light" ? "light" : fallback;
|
||||
}
|
||||
|
||||
// --- file icon themes (plugins) -------------------------------------------------
|
||||
|
||||
export interface IconThemeChoice {
|
||||
/** `<pluginId>/<themeId>`: the `appearance.fileIconTheme` value. */
|
||||
id: string;
|
||||
label: string;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
/** File icon themes of live plugins, for Settings › Appearance. */
|
||||
export const useContributedIconThemes = create<{ themes: readonly IconThemeChoice[] }>(() => ({ themes: [] }));
|
||||
|
||||
export function addContributedIconTheme(theme: IconThemeChoice): () => void {
|
||||
useContributedIconThemes.setState((s) => ({ themes: [...s.themes.filter((t) => t.id !== theme.id), theme] }));
|
||||
return () => useContributedIconThemes.setState((s) => ({ themes: s.themes.filter((t) => t !== theme) }));
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import { getAction, isActionEnabled, executeAction, actionToggled } from "@/platform/actions";
|
||||
import { getAction, isActionEnabled, executeActionFrom, actionToggled } from "@/platform/actions";
|
||||
import { useContextKeys } from "@/platform/context";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
@@ -84,7 +84,7 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] {
|
||||
shortcut: shortcutLabel(item.action),
|
||||
disabled: !!item.disabled || !isActionEnabled(a),
|
||||
checked: actionToggled(a),
|
||||
run: () => void executeAction(item.action, item.arg),
|
||||
run: () => void executeActionFrom("menu", item.action, item.arg),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
import { useId, useMemo, type CSSProperties } from "react";
|
||||
import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import styles from "./icons.module.css";
|
||||
|
||||
const RAW = import.meta.glob<string>(["./expui/**/*.svg", "./openidea/**/*.svg"], {
|
||||
@@ -537,10 +538,64 @@ export type IconName = BaseName | `symbol-${SymbolKind}`;
|
||||
export const ICON_NAMES = Object.keys(ICONS) as BaseName[];
|
||||
|
||||
function iconPath(name: string): string | undefined {
|
||||
if (name.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.has(name) ? name : undefined;
|
||||
if (name.startsWith("symbol-")) return SYMBOL_ICONS[name.slice(7) as SymbolKind];
|
||||
return (ICONS as Record<string, string>)[name];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icons supplied at runtime (plugin icon themes). The markup was sanitised by
|
||||
// the backend (`ide_host::plugins::sanitize_svg`); names are `ext:<source>/<icon>`.
|
||||
|
||||
const EXTERNAL_PREFIX = "ext:";
|
||||
const EXTERNAL = new Map<string, string>();
|
||||
|
||||
/** Registers icons by name (`ext:…`); the returned function removes them. */
|
||||
export function registerExternalIcons(icons: ReadonlyArray<readonly [name: string, svg: string]>): () => void {
|
||||
const names: string[] = [];
|
||||
for (const [name, svg] of icons) {
|
||||
if (!name.startsWith(EXTERNAL_PREFIX)) continue;
|
||||
EXTERNAL.set(name, svg);
|
||||
prepared.delete(`${name}|16`);
|
||||
prepared.delete(`${name}|20`);
|
||||
names.push(name);
|
||||
}
|
||||
return () => {
|
||||
for (const name of names) {
|
||||
EXTERNAL.delete(name);
|
||||
prepared.delete(`${name}|16`);
|
||||
prepared.delete(`${name}|20`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** A file icon theme (plugin `iconThemes`). Each method returns an icon name, or null for the built-in one. */
|
||||
export interface FileIconTheme {
|
||||
file(fileName: string): string | null;
|
||||
/** Plain folders only: folders with a role (source root, excluded, …) keep their built-in icons. */
|
||||
folder(folderName: string, expanded: boolean): string | null;
|
||||
}
|
||||
|
||||
let fileIconTheme: FileIconTheme | null = null;
|
||||
|
||||
/** Bumped when the file icon theme changes; file lists subscribe to redraw their icons. */
|
||||
export const useFileIconRevision = create<number>(() => 0);
|
||||
|
||||
/**
|
||||
* Sets the file icon theme: consulted after the user's own file type
|
||||
* associations and before the built-in icons.
|
||||
*/
|
||||
export function setFileIconTheme(theme: FileIconTheme | null) {
|
||||
if (fileIconTheme === theme) return;
|
||||
fileIconTheme = theme;
|
||||
useFileIconRevision.setState((n) => n + 1, true);
|
||||
}
|
||||
|
||||
/** The icon theme's icon for a plain folder, or null to keep the built-in one. */
|
||||
export function themedFolderIconName(folderName: string, expanded = false): IconName | null {
|
||||
return (fileIconTheme?.folder(folderName, expanded) ?? null) as IconName | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG processing
|
||||
|
||||
@@ -562,6 +617,7 @@ interface Prepared {
|
||||
}
|
||||
|
||||
function raw(path: string): string | undefined {
|
||||
if (path.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.get(path);
|
||||
return RAW[path.startsWith("openidea/") ? `./${path}.svg` : `./expui/${path}.svg`];
|
||||
}
|
||||
|
||||
@@ -930,10 +986,29 @@ export function setFileIconOverride(resolve: ((fileName: string) => IconName | n
|
||||
iconOverride = resolve;
|
||||
}
|
||||
|
||||
/** Icons for particular files (plugin `fileIcons`), newest first: after the user's, before the icon theme's. */
|
||||
let fileIconProviders: ReadonlyArray<(fileName: string) => string | null> = [];
|
||||
|
||||
/** Adds a file icon provider (an `ext:` icon name or null); the returned function removes it. */
|
||||
export function addFileIconProvider(resolve: (fileName: string) => string | null): () => void {
|
||||
fileIconProviders = [resolve, ...fileIconProviders];
|
||||
useFileIconRevision.setState((n) => n + 1, true);
|
||||
return () => {
|
||||
fileIconProviders = fileIconProviders.filter((p) => p !== resolve);
|
||||
useFileIconRevision.setState((n) => n + 1, true);
|
||||
};
|
||||
}
|
||||
|
||||
/** Picks a file-type icon from a file name. */
|
||||
export function fileIconName(fileName: string): IconName {
|
||||
const custom = iconOverride?.(fileName);
|
||||
if (custom) return custom;
|
||||
for (const provide of fileIconProviders) {
|
||||
const icon = provide(fileName);
|
||||
if (icon && iconPath(icon)) return icon as IconName;
|
||||
}
|
||||
const themed = fileIconTheme?.file(fileName);
|
||||
if (themed) return themed as IconName;
|
||||
const lower = fileName.toLowerCase();
|
||||
const named = FILE_NAMES[lower] ?? NAME_PATTERNS.find(([re]) => re.test(lower))?.[1];
|
||||
if (named) return named;
|
||||
|
||||
@@ -24,7 +24,8 @@ import { WindowControls } from "./WindowControls";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { projectInitials } from "./projectColor";
|
||||
import { keymapItems, themeItems, VIEW_MODE_ITEMS } from "./quickSwitch";
|
||||
import { currentThemeId, useThemeOverride } from "./theme";
|
||||
import { useContributedThemes } from "@/platform/themes";
|
||||
import { currentThemeChoice, useThemeOverride } from "./theme";
|
||||
import styles from "./TitleBar.module.css";
|
||||
import { useNativeTitlebar } from "./nativeTitlebar";
|
||||
|
||||
@@ -202,8 +203,9 @@ function GearButton({ customize }: { customize: MenuItem[] }) {
|
||||
useActions((s) => s.actions);
|
||||
const preset = useKeymap((s) => s.preset);
|
||||
const customs = settings?.customKeymaps;
|
||||
useContributedThemes((s) => s.themes);
|
||||
const items = gearMenuItems(
|
||||
currentThemeId(),
|
||||
currentThemeChoice(),
|
||||
typeof settings?.keymap === "string" ? settings.keymap : preset,
|
||||
customs && typeof customs === "object" ? Object.keys(customs) : [],
|
||||
customize,
|
||||
|
||||
@@ -941,6 +941,26 @@ export type ContextMenuId =
|
||||
| "selectIn"
|
||||
| "new";
|
||||
|
||||
/**
|
||||
* Entries of the native main menu's open slots (src-tauri/src/menu.rs
|
||||
* `Entry::Dynamic`), e.g. `pluginTools` at the end of Tools: each runs `action`.
|
||||
*/
|
||||
export interface MenuSlotItem {
|
||||
action: string;
|
||||
arg?: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const useMenuSlotItems = create<{ slots: Record<string, MenuSlotItem[]> }>(() => ({ slots: {} }));
|
||||
|
||||
export function registerMenuSlotItem(slot: string, item: MenuSlotItem): () => void {
|
||||
useMenuSlotItems.setState((s) => ({ slots: { ...s.slots, [slot]: [...(s.slots[slot] ?? []), item] } }));
|
||||
return () =>
|
||||
useMenuSlotItems.setState((s) => ({
|
||||
slots: { ...s.slots, [slot]: (s.slots[slot] ?? []).filter((i) => i !== item) },
|
||||
}));
|
||||
}
|
||||
|
||||
export interface MenuGroupContext {
|
||||
/** Files / directories the menu is for (absolute paths; a virtual URI for virtual documents). */
|
||||
paths: string[];
|
||||
|
||||
@@ -8,7 +8,8 @@ import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { PRESET_TITLES, useKeymap } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { currentThemeId, setTheme, THEME_CHOICES } from "./theme";
|
||||
import { themeChoices } from "@/platform/themes";
|
||||
import { currentThemeChoice, setTheme } from "./theme";
|
||||
|
||||
async function setGlobal(key: string, value: unknown, what: string) {
|
||||
if (!isTauri()) return;
|
||||
@@ -21,10 +22,12 @@ async function setGlobal(key: string, value: unknown, what: string) {
|
||||
|
||||
export const selectKeymap = (id: string) => setGlobal("keymap", id, "keymap");
|
||||
|
||||
export function themeItems(themeId: string = currentThemeId()): MenuItem[] {
|
||||
return THEME_CHOICES.map<MenuItem>((c) => ({
|
||||
export function themeItems(themeId: string = currentThemeChoice()): MenuItem[] {
|
||||
return themeChoices().map<MenuItem>((c) => ({
|
||||
id: `theme.${c.id}`,
|
||||
label: c.label,
|
||||
// Plugin themes keep their own names.
|
||||
verbatim: !!c.origin,
|
||||
checked: c.id === themeId,
|
||||
run: () => void setTheme(c.id),
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,14 @@ export function statusBarMenu(
|
||||
.filter((i) => i.title)
|
||||
.map((i) => {
|
||||
const visible = isStatusItemVisible(i, overrides);
|
||||
return { id: i.id, label: i.title!, checked: visible, run: () => set(i.id, !visible) };
|
||||
// Plugin widgets are named by their plugin, not translated.
|
||||
return {
|
||||
id: i.id,
|
||||
label: i.title!,
|
||||
verbatim: i.id.startsWith("plugin."),
|
||||
checked: visible,
|
||||
run: () => set(i.id, !visible),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,21 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { BUILTIN_THEMES, isBuiltinTheme, themeKind, type BuiltinThemeId } from "@/platform/themes";
|
||||
import {
|
||||
baseTheme,
|
||||
isThemeChoiceId,
|
||||
parsePluginThemeRef,
|
||||
themeChoices,
|
||||
themeKind,
|
||||
useContributedThemes,
|
||||
type BuiltinThemeId,
|
||||
type ThemeChoice,
|
||||
type ThemeChoiceId,
|
||||
} from "@/platform/themes";
|
||||
import { resolveTokenColor, toHex } from "@/platform/cssColor";
|
||||
import type { SelectOption } from "@/ui";
|
||||
|
||||
export type ThemeSetting = BuiltinThemeId | "system";
|
||||
export type ThemeSetting = ThemeChoiceId | "system";
|
||||
/** PyCharm's brand blue: the accent both Islands themes ship with. */
|
||||
export const DEFAULT_ACCENT = "#3871e1";
|
||||
/** Accent presets (Settings › Appearance, Welcome › Customize); the first is the Islands default. */
|
||||
@@ -26,13 +37,13 @@ export const useThemeOverride = create<{ theme: ThemeSetting | null }>(() => ({
|
||||
const media = () =>
|
||||
typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(prefers-color-scheme: light)") : null;
|
||||
|
||||
function subscribeScheme(cb: () => void) {
|
||||
export function subscribeScheme(cb: () => void) {
|
||||
const m = media();
|
||||
m?.addEventListener?.("change", cb);
|
||||
return () => m?.removeEventListener?.("change", cb);
|
||||
}
|
||||
|
||||
const prefersLight = () => !!media()?.matches;
|
||||
export const prefersLight = () => !!media()?.matches;
|
||||
|
||||
interface AppearanceLike {
|
||||
theme?: string;
|
||||
@@ -42,16 +53,26 @@ interface AppearanceLike {
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete theme id: the chosen theme, or with "Sync with OS" (legacy
|
||||
* `theme = "system"`) the preferred light / dark theme for the OS mode.
|
||||
* The theme in effect as the settings name it: the chosen theme, or with
|
||||
* "Sync with OS" (legacy `theme = "system"`) the preferred light / dark theme
|
||||
* for the OS mode. A plugin theme (`plugin:<id>/<theme>`) is returned as is.
|
||||
*/
|
||||
export function resolveThemeId(a: AppearanceLike | undefined, systemLight: boolean): BuiltinThemeId {
|
||||
const concrete = (v: string | undefined, d: "dark" | "light") => (isBuiltinTheme(v) ? v : d);
|
||||
export function resolveThemeChoice(a: AppearanceLike | undefined, systemLight: boolean): ThemeChoiceId {
|
||||
const concrete = (v: string | undefined, d: "dark" | "light") => (isThemeChoiceId(v) ? v : d);
|
||||
if (a?.syncWithOs || a?.theme === "system")
|
||||
return systemLight ? concrete(a?.preferredLightTheme, "light") : concrete(a?.preferredDarkTheme, "dark");
|
||||
return concrete(a?.theme, "light");
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete built-in theme id: the one chosen, or the base of a chosen plugin
|
||||
* theme (its light / dark default, which also shows while its plugin is off).
|
||||
*/
|
||||
export function resolveThemeId(a: AppearanceLike | undefined, systemLight: boolean): BuiltinThemeId {
|
||||
const sync = !!(a?.syncWithOs || a?.theme === "system");
|
||||
return baseTheme(resolveThemeChoice(a, systemLight), sync && !systemLight ? "dark" : "light");
|
||||
}
|
||||
|
||||
/** `dark-hc` resolves to `dark` plus `data-contrast="high"`. */
|
||||
export function resolveTheme(setting: string | undefined, systemLight: boolean): "dark" | "light" {
|
||||
return themeKind(resolveThemeId({ theme: setting }, systemLight));
|
||||
@@ -63,6 +84,8 @@ export function useApplyTheme() {
|
||||
const appearance = useProject((s) => s.settings?.appearance);
|
||||
const override = useThemeOverride((s) => s.theme);
|
||||
const systemLight = useSyncExternalStore(subscribeScheme, prefersLight, () => false);
|
||||
// A plugin theme's kind is known once its plugin is live.
|
||||
useContributedThemes((s) => s.themes);
|
||||
const id = override
|
||||
? resolveThemeId({ theme: override }, systemLight)
|
||||
: resolveThemeId(appearance as AppearanceLike | undefined, systemLight);
|
||||
@@ -171,10 +194,13 @@ export function currentTheme(): "dark" | "light" {
|
||||
return themeKind(currentThemeId());
|
||||
}
|
||||
|
||||
/** Themes offered by quick switch menus, in IDEA's order. */
|
||||
export const THEME_CHOICES = BUILTIN_THEMES;
|
||||
/** Themes offered by the theme pickers and quick switch menus: built-in, then plugins'. */
|
||||
export function useThemeChoices(): ThemeChoice[] {
|
||||
const contributed = useContributedThemes((s) => s.themes);
|
||||
return themeChoices(contributed);
|
||||
}
|
||||
|
||||
/** The concrete theme in effect (`dark-hc` kept distinct). */
|
||||
/** The concrete built-in theme in effect (`dark-hc` kept distinct; a plugin theme's base). */
|
||||
export function currentThemeId(): BuiltinThemeId {
|
||||
const override = useThemeOverride.getState().theme;
|
||||
return override
|
||||
@@ -182,6 +208,14 @@ export function currentThemeId(): BuiltinThemeId {
|
||||
: resolveThemeId(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight());
|
||||
}
|
||||
|
||||
/** The theme in effect as selected: built-in or a plugin's. */
|
||||
export function currentThemeChoice(): ThemeChoiceId {
|
||||
const override = useThemeOverride.getState().theme;
|
||||
return override
|
||||
? resolveThemeChoice({ theme: override }, prefersLight())
|
||||
: resolveThemeChoice(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight());
|
||||
}
|
||||
|
||||
export async function setTheme(next: ThemeSetting) {
|
||||
useThemeOverride.setState({ theme: next });
|
||||
if (!isTauri()) return;
|
||||
@@ -200,3 +234,24 @@ export async function setTheme(next: ThemeSetting) {
|
||||
export async function toggleTheme() {
|
||||
await setTheme(currentTheme() === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
/** The plugin named beside a contributed theme, unless the theme carries its name already. */
|
||||
export function originDetail(t: { label: string; origin?: string }): string | undefined {
|
||||
return t.origin && t.origin !== t.label ? t.origin : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for a theme picker: built-in themes, then plugins' (with the plugin
|
||||
* named beside them). A selected plugin theme whose plugin is off stays listed.
|
||||
*/
|
||||
export function themeSelectOptions(choices: readonly ThemeChoice[], selected?: string): SelectOption<ThemeChoiceId>[] {
|
||||
const options: SelectOption<ThemeChoiceId>[] = choices.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
detail: originDetail(c),
|
||||
}));
|
||||
const missing = parsePluginThemeRef(selected);
|
||||
if (missing && !options.some((o) => o.value === selected))
|
||||
options.push({ value: selected as ThemeChoiceId, label: missing.themeId, detail: "Plugin not enabled" });
|
||||
return options;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача