дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(plugins): plugin contributions in the IDE's own settings and menus; official plugins published with releases
- Themes: a plugin theme is an `appearance.theme` value (`plugin:<id>/<theme>`), 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.
Цей коміт міститься в:
1 джерело
ac71b7008e
коміт
9cc83c77b8
36 files changed
+847
-138
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:<plugin-id>/<theme-id>`.
|
||||
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 (`<plugin-id>/<theme-id>`); 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(),
|
||||
|
||||
@@ -152,6 +152,8 @@ pub(super) struct Shared {
|
||||
status: Mutex<BTreeMap<(String, String), StatusItem>>,
|
||||
/// plugin → kinds subscribed at run time (kept across idle reclaim).
|
||||
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
|
||||
/// plugin → files it shows problems for, withdrawn when it stops.
|
||||
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
|
||||
pub secrets: RwLock<Arc<dyn SecretStore>>,
|
||||
/// (plugin, `process:` capability) → the approved program.
|
||||
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
@@ -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<ide_core::Diagnostic>,
|
||||
) -> 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<ide_core::Diagnostic>) -> 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<HttpResponse, HostError> {
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>) {
|
||||
*self.default_market_url.write() = url.filter(|u| !u.trim().is_empty());
|
||||
}
|
||||
|
||||
pub fn set_market_url(&self, url: Option<String>) {
|
||||
|
||||
@@ -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<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// The last marketplace index read: trust root for signatures, revocations.
|
||||
market: RwLock<Option<ide_extension::package::Index>>,
|
||||
/// The marketplace used while the user has set none (the app's official one).
|
||||
default_market_url: RwLock<Option<String>>,
|
||||
/// Updates not confirmed yet (the new version hasn't started once): what
|
||||
/// to roll back to.
|
||||
updates: Mutex<HashMap<String, PendingUpdate>>,
|
||||
@@ -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<String> = 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 (`<id>/<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() })?;
|
||||
|
||||
@@ -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<std::collections::HashMap<(String, String), String>>);
|
||||
impl SecretStore for Keychain {
|
||||
fn get(&self, plugin: &str, name: &str) -> Option<String> {
|
||||
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::<Vec<_>>());
|
||||
}
|
||||
@@ -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:[email protected]`) 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/[email protected]` 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -107,6 +107,10 @@ pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc<ide_host
|
||||
ide_language::set_external_syntax(Some(host.syntax_provider()));
|
||||
ide_run::set_external_detection(Some(host.run_detection()));
|
||||
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(project_host)))));
|
||||
// Released builds list the official plugins (published with each release) until the user picks another index.
|
||||
if !cfg!(debug_assertions) {
|
||||
host.set_default_market_url(Some(ide_host::plugins::OFFICIAL_MARKET_INDEX.into()));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
host.set_secret_store(Arc::new(KeychainSecrets));
|
||||
ide_host::plugins::set_editor_providers(Some(&host));
|
||||
@@ -485,7 +489,7 @@ pub fn plugin_market_url(state: State<'_, AppState>) -> Option<String> {
|
||||
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<String>) {
|
||||
|
||||
@@ -734,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} />
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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:<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 (`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<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleRefresh() {
|
||||
@@ -231,6 +304,7 @@ export function startPluginBridge(): () => void {
|
||||
pluginsBackend().onEvent(handlePluginEvent),
|
||||
syncProjection(),
|
||||
trackPluginTheme(),
|
||||
followThemeSetting(),
|
||||
trackIconTheme(),
|
||||
trackActiveFile(),
|
||||
trackActivationEvents(),
|
||||
|
||||
@@ -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 {
|
||||
/** `<pluginId>/<themeId>` → theme. */
|
||||
available: ReadonlyMap<string, IconThemeRef>;
|
||||
/** `<pluginId>/<themeId>` 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<IconThemesState>(() => ({
|
||||
available: new Map(),
|
||||
selected: readSelection(),
|
||||
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) }));
|
||||
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 });
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<ThemeDef>[] }) {
|
||||
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 (
|
||||
<ul className={styles.items}>
|
||||
{items.map(({ decl: t }) => {
|
||||
const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id;
|
||||
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 selectPluginTheme(null, null)}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => void setTheme(t.kind === "light" ? "light" : "dark")}
|
||||
>
|
||||
<T>Use Default Theme</T>
|
||||
</Button>
|
||||
) : (
|
||||
@@ -73,7 +83,7 @@ function ThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<Th
|
||||
size="small"
|
||||
variant="ghost"
|
||||
disabled={!live}
|
||||
onClick={() => void selectPluginTheme(plugin.id, t.id)}
|
||||
onClick={() => void setTheme(pluginThemeRef(plugin.id, t.id))}
|
||||
aria-label={tx`Apply theme ${t.name}`}
|
||||
>
|
||||
<T>Apply</T>
|
||||
@@ -93,7 +103,7 @@ interface IconThemeDecl {
|
||||
}
|
||||
|
||||
function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<IconThemeDecl>[] }) {
|
||||
const selected = useIconThemes((s) => s.selected);
|
||||
const selected = useSelectedIconTheme();
|
||||
return (
|
||||
<ul className={styles.items}>
|
||||
{items.map(({ decl: t }) => {
|
||||
@@ -103,14 +113,14 @@ function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiIte
|
||||
<span className={styles.itemMain}>{t.label}</span>
|
||||
<span className={styles.muted}>{tx`${t.icons} icons`}</span>
|
||||
{selected === key ? (
|
||||
<Button size="small" variant="secondary" onClick={() => selectIconTheme(null)}>
|
||||
<Button size="small" variant="secondary" onClick={() => void selectIconTheme(null)}>
|
||||
<T>Use Default Icons</T>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => selectIconTheme(key)}
|
||||
onClick={() => void selectIconTheme(key)}
|
||||
aria-label={tx`Apply icon theme ${t.label}`}
|
||||
>
|
||||
<T>Apply</T>
|
||||
@@ -143,11 +153,22 @@ export function defineBuiltinUiPoints(): () => void {
|
||||
}),
|
||||
Details: CommandsDetails,
|
||||
}),
|
||||
// Applied through `activeTheme` (the backend owns the selection); listed here.
|
||||
defineUiPoint<ThemeDef>("themes", { title: "Themes", Details: ThemesDetails }),
|
||||
// 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 }) => addIconTheme({ pluginId, id: decl.id, label: decl.label }),
|
||||
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.
|
||||
|
||||
@@ -16,7 +16,7 @@ import { registerStatusItem } from "@/workbench/contributions";
|
||||
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { bumpView, deliverViewMessage } from "./views/viewStore";
|
||||
import { runPluginCommand } from "./store";
|
||||
import { runPluginCommand, usePlugins } from "./store";
|
||||
|
||||
// --- status bar items ---------------------------------------------------------
|
||||
|
||||
@@ -77,6 +77,14 @@ function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 }>();
|
||||
@@ -98,6 +106,8 @@ function projectStatusItems(): () => void {
|
||||
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,
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -5774,5 +5774,9 @@
|
||||
"Cannot show {label}": "无法显示 {label}",
|
||||
"{title} layout": "{title} 布局",
|
||||
"Editor and {title}": "编辑器和{title}",
|
||||
"{title} Only": "仅{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 和插件都可能还会变化。"
|
||||
}
|
||||
@@ -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) }));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Заблокувати користувача