From 9cc83c77b818068ca9cb61749cda90941b91e122 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:29:59 +0800 Subject: [PATCH] feat(plugins): plugin contributions in the IDE's own settings and menus; official plugins published with releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Themes: a plugin theme is an `appearance.theme` value (`plugin:/`), offered by Settings › Appearance (and the preferred themes of Sync with OS), Quick Switch, the toolbar gear menu and Welcome › Customize. The project window selects it in the plugin host; picking a built-in theme clears it. While its plugin is off, the built-in theme of its kind shows and the choice is kept. - File icon themes: chosen in Settings › Appearance › File icon theme and kept in `appearance.fileIconTheme`, so every window follows. - Keymap: plugins' default shortcuts show in Settings › Keymap as the defaults of their actions: removable, changeable, resettable and part of conflict checks. - Problems: a plugin's problems are withdrawn when it stops, fails, is disabled or uninstalled. Settings › Inspections lists them under Plugins by plugin name; stored overrides of `plugin:` producers parse correctly. - Status bar items of plugins are named in the status bar menu and can be hidden. - Languages added or removed by plugins refresh Override File Type, the Code Style pages and Inspections without a restart. - Uninstalling a plugin deletes the secrets it declared. - The Settings window follows plugin changes made elsewhere. - The plugin system is marked Preview (Settings › Plugins, README); Harbor Night is versioned 0.1.0 like the other official plugins. - Release: the official plugins are packed, signed (secret PLUGIN_SIGNING_KEY) and indexed into the release as `plugins-index.json`; released builds use it as the default marketplace. Without the key the step is skipped. --- .github/workflows/release.yml | 65 +++++++++++- crates/ide-core/src/settings.rs | 6 +- crates/ide-host/src/plugins/api.rs | 38 ++++++- crates/ide-host/src/plugins/market.rs | 13 ++- crates/ide-host/src/plugins/mod.rs | 23 ++++- crates/ide-host/tests/plugins.rs | 33 +++++++ plugins/README.md | 8 +- plugins/official/harbor-night/plugin.toml | 2 +- src-tauri/src/commands/plugin.rs | 6 +- src/features/plugins/PluginsPage.tsx | 5 + .../plugins/__tests__/plugins.test.tsx | 20 ++++ src/features/plugins/bridge.ts | 78 ++++++++++++++- src/features/plugins/iconThemes.ts | 92 ++++++++++------- src/features/plugins/index.ts | 3 +- src/features/plugins/points.tsx | 45 ++++++--- src/features/plugins/runtimeUi.tsx | 12 ++- .../settings/__tests__/inspections.test.ts | 22 +++++ .../settings/__tests__/keymap.test.ts | 25 +++++ src/features/settings/fileTypesRuntime.ts | 6 ++ src/features/settings/keymap/model.ts | 19 ++-- src/features/settings/model/behaviorSchema.ts | 15 ++- src/features/settings/model/schema.ts | 9 +- src/features/settings/pages/behavior.tsx | 54 ++++++++-- .../settings/pages/editor/inspections.tsx | 11 ++- .../settings/pages/editor/inspectionsModel.ts | 42 +++++--- src/features/settings/pages/registry.tsx | 31 ++++-- src/features/welcome/CustomizePage.tsx | 13 ++- src/platform/inspections.ts | 7 ++ src/platform/languageList.ts | 20 ++++ src/platform/locales/text/zh.json | 6 +- src/platform/themes.test.ts | 56 ++++++++++- src/platform/themes.ts | 99 +++++++++++++++++++ src/workbench/TitleBar.tsx | 6 +- src/workbench/quickSwitch.ts | 9 +- src/workbench/statusBarWidgets.ts | 9 +- src/workbench/theme.ts | 77 ++++++++++++--- 36 files changed, 847 insertions(+), 138 deletions(-) create mode 100644 src/platform/languageList.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1527d20fa..3874f17e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,6 +180,69 @@ jobs: if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256" gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber + # The official plugins, signed, and the marketplace index released apps read + # (releases/latest/download/plugins-index.json). They are not in the + # installers: users install them from Settings › Plugins. Without the + # signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen` + # writes) nothing is published and the release goes on. + plugins: + needs: create-release + name: Official plugins + runs-on: ubuntu-22.04 + env: + SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }} + steps: + - id: key + run: | + if [ -z "$SIGNING_KEY" ]; then + echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published" + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v4 + if: steps.key.outputs.skip != 'true' + with: { ref: "${{ env.TAG }}" } + - uses: dtolnay/rust-toolchain@stable + if: steps.key.outputs.skip != 'true' + with: { targets: wasm32-wasip2 } + - uses: Swatinem/rust-cache@v2 + if: steps.key.outputs.skip != 'true' + with: { key: release-plugins } + - uses: pnpm/action-setup@v4 + if: steps.key.outputs.skip != 'true' + with: { version: 11 } + - uses: actions/setup-node@v4 + if: steps.key.outputs.skip != 'true' + with: { node-version: 22 } + # Builds the web pages; fails when a checked-in component isn't built from these sources. + - name: Build + if: steps.key.outputs.skip != 'true' + run: | + plugins/build.sh --check + cargo build --release --locked -p ide-host --bin openidea-plugin + - name: Pack, index and upload + if: steps.key.outputs.skip != 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tool="$PWD/target/release/openidea-plugin" + key="$RUNNER_TEMP/openidea.key" + out="$RUNNER_TEMP/market" + (umask 077 && printf '%s' "$SIGNING_KEY" > "$key") + trap 'rm -f "$key"' EXIT + mkdir -p "$out" + for dir in plugins/official/*/; do + id="$(sed -n 's/^id *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)" + version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)" + "$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin" + done + # The published index's revocations and earlier keys carry over. + curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \ + -o "$out/plugins-index.json" || rm -f "$out/plugins-index.json" + "$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" + gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber + # The updater manifest (latest.json), built once from the signed bundles every build uploaded. # Fails when a platform is missing, so a release never ships a manifest without it. updater-manifest: @@ -243,7 +306,7 @@ jobs: core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`); publish: - needs: [create-release, build, server, updater-manifest] + needs: [create-release, build, server, plugins, updater-manifest] runs-on: ubuntu-latest steps: - uses: actions/github-script@v7 diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index f0e942945..ebb978353 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -327,7 +327,8 @@ impl Default for NotificationGroupSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct 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:/`. pub theme: String, pub accent: String, pub ui_font_size: f32, @@ -336,6 +337,8 @@ pub struct AppearanceSettings { pub sync_with_os: bool, pub preferred_light_theme: String, pub preferred_dark_theme: String, + /// A plugin's file icon theme (`/`); empty = the built-in icons. + pub file_icon_theme: String, /// IDE zoom in percent (50–300). pub zoom: u32, /// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). @@ -390,6 +393,7 @@ impl Default for AppearanceSettings { sync_with_os: false, preferred_light_theme: "light".into(), preferred_dark_theme: "dark".into(), + file_icon_theme: String::new(), zoom: 100, use_custom_font: false, ui_font_family: "Inter".into(), diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 22726195d..d1f8f1a1a 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -152,6 +152,8 @@ pub(super) struct Shared { status: Mutex>, /// plugin → kinds subscribed at run time (kept across idle reclaim). pub subscriptions: Mutex>>, + /// plugin → files it shows problems for, withdrawn when it stops. + diagnostics: Mutex>>, pub secrets: RwLock>, /// (plugin, `process:` capability) → the approved program. pub pins: Arc>>, @@ -177,6 +179,7 @@ impl Shared { next_task: AtomicU64::new(1), status: Mutex::default(), subscriptions: Mutex::default(), + diagnostics: Mutex::default(), }) } @@ -188,6 +191,31 @@ impl Shared { self.workspace()?.project_of(window) } + /// Shows `plugin`'s problems of `path`, remembering the file until they are + /// cleared (by the plugin, or when it stops). + fn publish_diagnostics( + &self, + plugin: &str, + path: &Path, + items: Vec, + ) -> Result<(), HostError> { + let workspace = self.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?; + let empty = items.is_empty(); + workspace.publish_diagnostics(plugin, path, items)?; + let mut published = self.diagnostics.lock(); + if empty { + if let Some(paths) = published.get_mut(plugin) { + paths.remove(path); + if paths.is_empty() { + published.remove(plugin); + } + } + } else { + published.entry(plugin.to_owned()).or_default().insert(path.to_path_buf()); + } + Ok(()) + } + pub fn status_items(&self) -> Vec<(String, String, StatusItem)> { self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect() } @@ -210,6 +238,13 @@ impl Shared { for id in tasks { self.end_task(id); } + // Its problems go with it (an idle reclaim keeps them: it isn't cleared then). + let paths = self.diagnostics.lock().remove(plugin).unwrap_or_default(); + if let Some(workspace) = self.workspace() { + for path in paths { + let _ = workspace.publish_diagnostics(plugin, &path, Vec::new()); + } + } if subscriptions && self.subscriptions.lock().remove(plugin).is_some() { self.sink.emit(None, PluginEvent::SubscriptionsChanged); } @@ -422,8 +457,7 @@ impl HostApi for PluginApi { } fn publish_diagnostics(&self, path: &Path, items: Vec) -> Result<(), HostError> { - let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?; - workspace.publish_diagnostics(self.id(), path, items) + self.shared.publish_diagnostics(self.id(), path, items) } fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result { diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index b7b9c3dfd..b3112f4fb 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -11,6 +11,10 @@ use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATUR use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +/// The official marketplace: the index the release workflow publishes with +/// the app, next to the signed packages of the official plugins. +pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json"; + /// The last index read, in the plugins directory. pub const INDEX_FILE: &str = "market-index.json"; const DOWNLOADS: &str = ".downloads"; @@ -69,9 +73,14 @@ impl PluginHost { Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok() } - /// The marketplace index URL (none: no marketplace). + /// The marketplace index URL: the user's, else the default (none: no marketplace). pub fn market_url(&self) -> Option { - self.state.lock().market_url.clone() + self.state.lock().market_url.clone().or_else(|| self.default_market_url.read().clone()) + } + + /// The marketplace used while the user has set none. + pub fn set_default_market_url(&self, url: Option) { + *self.default_market_url.write() = url.filter(|u| !u.trim().is_empty()); } pub fn set_market_url(&self, url: Option) { diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 34c625df2..bc0beba14 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -32,7 +32,7 @@ pub mod web; pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix}; -pub use market::{INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin}; +pub use market::{INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX}; pub use points::{COMMANDS, CommandDecl, register_builtin_points}; pub(crate) use providers::editor_providers as editor_providers_for; pub use providers::{ProviderKind, set_editor_providers}; @@ -316,6 +316,8 @@ pub struct PluginHost { pins: Arc>>, /// The last marketplace index read: trust root for signatures, revocations. market: RwLock>, + /// The marketplace used while the user has set none (the app's official one). + default_market_url: RwLock>, /// Updates not confirmed yet (the new version hasn't started once): what /// to roll back to. updates: Mutex>, @@ -355,6 +357,7 @@ impl PluginHost { let market = RwLock::new(Self::load_market_index(&plugins_dir)); let host = Self { market, + default_market_url: RwLock::new(None), updates: Mutex::default(), project_policy: RwLock::new(Arc::new(|_| false)), engine: PluginEngine::new().map_err(IdeError::internal)?, @@ -933,7 +936,25 @@ impl PluginHost { "development plugins are removed by deleting them from .openidea/plugins or unlinking them", )); } + // Its secrets (kept outside its directory, in the OS keychain on the desktop) go with it. + let secrets: Vec = plugin + .manifest() + .contributes + .get(providers::SECRETS_POINT) + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter_map(|s| s.get("name")?.as_str().map(str::to_owned)) + .collect(); self.unload(id); + { + let store = self.shared.secrets.read().clone(); + for name in &secrets { + if let Err(e) = store.set(id, name, None) { + tracing::warn!(plugin = %id, "cannot delete secret {name:?}: {e}"); + } + } + } // Every version (`/`), or the flat directory of an older install. let root = self.plugins_dir.join(id); std::fs::remove_dir_all(if root.is_dir() { root } else { plugin.dir().to_path_buf() })?; diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index ae2c09ef6..2e33973b9 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -1781,6 +1781,10 @@ fn official_license_header_reports_fixes_and_scans() { f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); assert!(f.host.wait_events(Duration::from_secs(20))); assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there"); + + // Disabling the plugin withdraws the problems it still shows. + f.host.set_enabled("openidea.license-header", false).unwrap(); + assert_eq!(diagnostics(&b).map(|d| d.len()), Some(0), "withdrawn with the plugin"); } /// MyBatis Navigator: interface ↔ XML through the providers, on a @@ -2276,3 +2280,32 @@ fn a_zipped_plugin_folder_installs_like_the_folder() { let err = f.host.unpack_package(&other).unwrap_err().message; assert!(err.contains("is not an OpenIDEA plugin"), "{err}"); } + +/// Secrets live outside the plugin's directory (the OS keychain on the +/// desktop): uninstalling the plugin deletes the ones it declares. +#[test] +fn uninstalling_deletes_the_plugins_secrets() { + #[derive(Default)] + struct Keychain(Mutex>); + impl SecretStore for Keychain { + fn get(&self, plugin: &str, name: &str) -> Option { + self.0.lock().get(&(plugin.into(), name.into())).cloned() + } + fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> { + let key = (plugin.to_owned(), name.to_owned()); + match value { + Some(v) => self.0.lock().insert(key, v.into()), + None => self.0.lock().remove(&key), + }; + Ok(()) + } + } + let f = fixture(Limits::default()); + let keychain = Arc::new(Keychain::default()); + f.host.set_secret_store(keychain.clone()); + f.install(&official("ai-completion")); + f.host.set_secret("openidea.ai-completion", "api-key", Some("sk-test")).unwrap(); + assert!(keychain.get("openidea.ai-completion", "api-key").is_some()); + f.host.uninstall("openidea.ai-completion").unwrap(); + assert!(keychain.0.lock().is_empty(), "{:?}", keychain.0.lock().keys().collect::>()); +} diff --git a/plugins/README.md b/plugins/README.md index 494e5bbd2..4e994f5ec 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -11,6 +11,10 @@ editors, and contribute menus, shortcuts, settings, themes, file icon themes and languages. Plugins that only contribute data (themes, icons, languages, menus, settings) need no code at all. +> **Preview.** The plugin system ships as a preview: until it is declared +> stable the API (`openidea:plugin@1.0`) may still change, and the official +> plugins are versioned `0.x`. See _Evolving the plugin API_ below. + ``` plugins/ └─ official/ @@ -716,8 +720,8 @@ the API itself follows _Evolving the plugin API_ below. ## 10. Evolving the plugin API -Until the plugin system's first release the API may still change freely. From -then on wasmtime decides what stays compatible: it resolves interface names +While the plugin system is a preview the API may still change freely. Once it +is declared stable wasmtime decides what stays compatible: it resolves interface names by semver (a component importing `openidea:plugin/x@1.0.0` links against a host defining `@1.1.0`, and the host finds its exports the same way), but function types must match exactly — a record with another field count, or a diff --git a/plugins/official/harbor-night/plugin.toml b/plugins/official/harbor-night/plugin.toml index 495ed17a3..1fdc1d7e5 100644 --- a/plugins/official/harbor-night/plugin.toml +++ b/plugins/official/harbor-night/plugin.toml @@ -2,7 +2,7 @@ # `openidea-plugin new --template theme` starts a new theme from this one. id = "openidea.harbor-night" name = "Harbor Night" -version = "1.0.0" +version = "0.1.0" description = "A dark theme: slate surfaces, a teal accent and a warm syntax palette." authors = ["OpenIDEA contributors"] license = "Apache-2.0" diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 15a6343f4..3698057b0 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -107,6 +107,10 @@ pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc) -> Option { state.plugins.market_url() } -/// Sets the marketplace index URL (an `https://…/index.json` or a local path; `None` for none). +/// Sets the marketplace index URL (an `https://…/index.json` or a local path; `None` for the default). #[tauri::command] #[specta::specta] pub fn plugin_set_market_url(state: State<'_, AppState>, url: Option) { diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 1bdddb992..662e7dc0c 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -734,6 +734,11 @@ export function PluginsPage() { }, ]} /> + + + Preview + + {(loading || installing) && } diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index fa1686657..4ba687180 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -12,10 +12,12 @@ import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme"; import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend"; import { applyPluginTheme, + followThemeSetting, PLUGIN_THEME_STYLE_ID, handlePluginEvent, pluginActionId, startPluginBridge, + syncProjection, } from "../bridge"; import { iconResolver } from "../iconThemes"; import { markerIcon, placeMarkers } from "../lineMarkerGutter"; @@ -272,6 +274,24 @@ describe("store", () => { expect(usePlugins.getState().activeTheme).toBeNull(); }); + 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 [ diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index 46b11b079..9e4d06af7 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -10,14 +10,25 @@ 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 { trackIconTheme } from "./iconThemes"; import { defineBuiltinUiPoints } from "./points"; import { clearUiContributions, syncUiContributions } from "./projection"; import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi"; -import { grantPlugin, refreshPlugins, usePlugins } from "./store"; +import { grantPlugin, refreshPlugins, selectPluginTheme, usePlugins } from "./store"; export { pluginActionId } from "./points"; @@ -26,6 +37,8 @@ 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(); const unsubscribe = usePlugins.subscribe((s, prev) => { @@ -147,6 +160,53 @@ export function trackPluginTheme(): () => void { }; } +/** + * Makes the plugin host's theme the one the Appearance settings select: a + * plugin theme (`plugin:/`, 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 (`editor-focused` events). */ function trackActiveFile(): () => void { let last: string | null | undefined; @@ -181,12 +241,25 @@ export function handlePluginEvent(e: PluginEvent) { 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 | null = null; function scheduleRefresh() { @@ -231,6 +304,7 @@ export function startPluginBridge(): () => void { pluginsBackend().onEvent(handlePluginEvent), syncProjection(), trackPluginTheme(), + followThemeSetting(), trackIconTheme(), trackActiveFile(), trackActivationEvents(), diff --git a/src/features/plugins/iconThemes.ts b/src/features/plugins/iconThemes.ts index 198297f02..6f3cf9f4e 100644 --- a/src/features/plugins/iconThemes.ts +++ b/src/features/plugins/iconThemes.ts @@ -1,19 +1,23 @@ /** - * File icon themes contributed by plugins (`iconThemes`). The selection is a - * per-user preference kept in this browser profile; 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. + * 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 const ICON_THEME_KEY = "openidea.fileIconTheme"; - export interface IconThemeRef { pluginId: string; id: string; label: string; + /** The plugin's name. */ + origin?: string; } export interface IconThemeMap { @@ -28,48 +32,64 @@ export interface IconThemeMap { interface IconThemesState { /** `/` → theme. */ available: ReadonlyMap; - /** `/` or null for the built-in icons. */ - selected: string | null; + /** Without a backend (tests, browser preview): the selection for this session. */ + override: string | null | undefined; /** The theme whose icons are registered. */ applied: string | null; } -const readSelection = (): string | null => { - try { - return localStorage.getItem(ICON_THEME_KEY); - } catch { - return null; - } -}; - export const useIconThemes = create(() => ({ available: new Map(), - selected: readSelection(), + override: undefined, applied: null, })); +const fromSettings = (s: ReturnType) => + (s.settings?.appearance as { fileIconTheme?: unknown } | undefined)?.fileIconTheme; + +/** The selected icon theme (`/`), 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) })); - return () => + 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 function selectIconTheme(key: string | null) { +export async function selectIconTheme(key: string | null) { + if (!isTauri()) { + useIconThemes.setState({ override: key }); + return; + } try { - if (key) localStorage.setItem(ICON_THEME_KEY, key); - else localStorage.removeItem(ICON_THEME_KEY); - } catch { - /* storage unavailable: the choice lasts for this window */ + useProject.setState({ + settings: await call(commands.settingsSet("global", "appearance.fileIconTheme", JSON.stringify(key ?? ""))), + }); + } catch (e) { + console.warn("cannot select the file icon theme", e); } - useIconThemes.setState({ selected: key }); } /** @@ -148,21 +168,21 @@ async function apply(key: string | null) { /** Keeps the registered icons in line with the selection and the available themes. */ export function trackIconTheme(): () => void { const sync = () => { - const { selected, available } = useIconThemes.getState(); - void apply(selected && available.has(selected) ? selected : null); + const selected = selectedIconTheme(); + void apply(selected && useIconThemes.getState().available.has(selected) ? selected : null); }; sync(); - const unsubscribe = useIconThemes.subscribe((s, prev) => { - if (s.selected !== prev.selected || s.available !== prev.available) sync(); - }); - // Another window (Settings) changed the selection. - const onStorage = (e: StorageEvent) => { - if (e.key === ICON_THEME_KEY) useIconThemes.setState({ selected: e.newValue }); - }; - window.addEventListener("storage", onStorage); + 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(); - window.removeEventListener("storage", onStorage); + unsubscribe.forEach((u) => u()); undo?.(); undo = null; useIconThemes.setState({ applied: null }); diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index 4a9fa4e19..3991847bf 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -17,7 +17,7 @@ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions"; -import { startPluginBridge, syncProjection, trackPluginTheme } from "./bridge"; +import { followPluginChanges, startPluginBridge, syncProjection, trackPluginTheme } from "./bridge"; import { ConsentDialogHost, provideConsentDialog } from "./consent"; import { trackIconTheme } from "./iconThemes"; import { pluginLineMarkerGutter } from "./lineMarkerGutter"; @@ -43,6 +43,7 @@ export function activateSettingsPages(): () => void { registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }), provideConsentDialog(), defineBuiltinUiPoints(), + followPluginChanges(), syncProjection(), trackPluginTheme(), trackIconTheme(), diff --git a/src/features/plugins/points.tsx b/src/features/plugins/points.tsx index 54e805ec4..5974502c6 100644 --- a/src/features/plugins/points.tsx +++ b/src/features/plugins/points.tsx @@ -6,12 +6,15 @@ 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, useIconThemes } from "./iconThemes"; +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, selectPluginTheme, usePlugins } from "./store"; +import { isLive, runPluginCommand } from "./store"; import styles from "./PluginsPage.module.css"; export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`; @@ -54,18 +57,25 @@ function CommandsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem< } function ThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem[] }) { - const activeTheme = usePlugins((s) => s.activeTheme); + // 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 (
    {items.map(({ decl: t }) => { - const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id; + const isActive = selected === pluginThemeRef(plugin.id, t.id); return (
  • {t.name} {t.kind === "dark" ? tx("Dark") : tx("Light")} {isActive ? ( - ) : ( @@ -73,7 +83,7 @@ function ThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem void selectPluginTheme(plugin.id, t.id)} + onClick={() => void setTheme(pluginThemeRef(plugin.id, t.id))} aria-label={tx`Apply theme ${t.name}`} > Apply @@ -93,7 +103,7 @@ interface IconThemeDecl { } function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem[] }) { - const selected = useIconThemes((s) => s.selected); + const selected = useSelectedIconTheme(); return (
      {items.map(({ decl: t }) => { @@ -103,14 +113,14 @@ function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiIte {t.label} {tx`${t.icons} icons`} {selected === key ? ( - ) : (