feat(plugins): import IntelliJ theme plugins (#1184)

Settings › Plugins (gear menu) gets Import Theme from IntelliJ IDE… (theme plugins of the JetBrains IDEs on this machine, the one each IDE uses first) and Import IntelliJ Theme from File… (a Marketplace .jar / .zip or a .theme.json). The host reads the plugin with size limits; the UI converts it with the IntelliJ mapping (moved from scripts/theme to src/features/plugins/intellijTheme.ts, still used by pnpm theme:import-intellij) into one OpenIDEA theme plugin (engines.openidea ^1.0) with all of its themes, installed through the usual install page, and applies the chosen theme.

Theme pickers separate each plugin's themes, and Quick Switch Scheme › Theme previews the theme under the pointer or keyboard after 500 ms and restores the selected one when closed without a choice.

Co-authored-by: ldg030201 <100822592+ldg030201@users.noreply.github.com>
This commit is contained in:
Lee Dong Geon
2026-11-04 15:28:00 +08:00
committed by GitHub
parent a610982655
commit 6dec1252db
33 changed files with 2284 additions and 87 deletions
Generated
+1
View File
@@ -4272,6 +4272,7 @@ dependencies = [
"ide-workspace",
"ignore",
"parking_lot",
"quick-xml",
"regex-syntax",
"reqwest",
"rustix",
+2 -1
View File
@@ -59,12 +59,13 @@ dunce.workspace = true
dirs.workspace = true
uuid.workspace = true
tempfile.workspace = true
zip.workspace = true
quick-xml = "0.42"
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
criterion = { version = "0.5", default-features = false }
zip.workspace = true
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
+874
View File
@@ -0,0 +1,874 @@
//! IntelliJ theme plugins, read for import as OpenIDEA theme plugins: a
//! Marketplace download (a `.jar`, or a `.zip` holding the plugin's
//! `lib/*.jar`), an installed plugin's folder or a loose `.theme.json`; and the
//! theme plugins of the JetBrains IDEs on this machine, with the theme each IDE
//! is set to (`options/laf.xml`).
//!
//! Nothing is converted here: the UI maps IntelliJ keys onto design tokens
//! (`src/features/plugins/intellijTheme.ts`, shared with `pnpm
//! theme:import-intellij`) and [`super::PluginHost::stage_package`] writes the
//! result for the install page, which validates it like any package.
use ide_core::{IdeError, IdeResult, IpcPath};
use quick_xml::{Reader, XmlVersion, events::Event};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::ffi::OsString;
use std::io::{Cursor, Read, Seek};
use std::path::{Path, PathBuf};
/// A descriptor, theme or editor scheme larger than this is not one.
const MAX_FILE: u64 = 4 << 20;
/// A plugin jar read into memory from a Marketplace zip.
const MAX_JAR: u64 = 64 << 20;
/// Jars of one plugin looked into.
const MAX_JARS: usize = 64;
/// All the jars read into memory from one Marketplace zip.
const MAX_NESTED: u64 = 256 << 20;
/// Descriptors read for one plugin: `plugin.xml` and those it includes.
const MAX_DESCRIPTORS: usize = 64;
const PLUGIN_XML: &str = "META-INF/plugin.xml";
/// A theme plugin's themes, as read from its files.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct IntellijThemeSource {
/// The plugin's `<name>`, or the theme's for a loose file.
pub name: String,
pub plugin_id: Option<String>,
pub version: Option<String>,
pub vendor: Option<String>,
pub themes: Vec<IntellijThemeFile>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct IntellijThemeFile {
/// The `themeProvider` id (what `laf.xml` names), or the file name without `.theme.json`.
pub id: String,
/// The `.theme.json`, comments removed.
pub theme: String,
/// The editor scheme (`.icls` XML) its `editorScheme` names, when the plugin has it.
pub editor_scheme: Option<String>,
}
/// A theme of a plugin installed in a JetBrains IDE.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstalledIntellijTheme {
/// The IDE and its version: `IntelliJ IDEA 2025.2`.
pub ide: String,
/// The plugin's name.
pub plugin: String,
/// What to import: the plugin's folder or jar.
pub path: IpcPath,
pub theme_id: String,
pub theme_name: String,
pub dark: bool,
/// The IDE is set to this theme.
pub active: bool,
}
/// Reads the themes of an IntelliJ theme plugin (a `.jar`, a Marketplace
/// `.zip`, an installed plugin's folder) or of a `.theme.json`.
pub fn read_source(path: &Path) -> IdeResult<IntellijThemeSource> {
read(path).map_err(IdeError::invalid)
}
/// The theme plugins of the JetBrains IDEs (and Android Studio) of this user,
/// the themes their IDEs are set to first.
pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
let Some(home) = dirs::home_dir() else { return Vec::new() };
let layout = if cfg!(target_os = "macos") {
Layout::Mac
} else if cfg!(windows) {
Layout::Windows
} else {
Layout::Xdg
};
find_installed(&ide_dirs(layout, &home, |key| std::env::var_os(key)))
}
fn read(path: &Path) -> Result<IntellijThemeSource, String> {
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("json")) {
return loose_theme(path);
}
let mut jars = plugin_jars(path)?;
let plugin = descriptor(&mut jars)?;
let shown = || path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if plugin.themes.is_empty() {
return Err(format!("{} is not a theme plugin: its plugin.xml declares no themeProvider", shown()));
}
let mut themes = Vec::new();
let mut problem = None;
for (id, theme_path) in &plugin.themes {
match plugin_theme(&mut jars, id, theme_path) {
Ok(theme) => themes.push(theme),
Err(e) => problem = problem.or(Some(e)),
}
}
if themes.is_empty() {
return Err(problem.unwrap_or_else(|| format!("{} has no readable theme", shown())));
}
Ok(IntellijThemeSource {
name: plugin.name.unwrap_or_else(shown),
plugin_id: plugin.id,
version: plugin.version,
vendor: plugin.vendor,
themes,
})
}
fn plugin_theme(jars: &mut [Jar], id: &str, path: &str) -> Result<IntellijThemeFile, String> {
let bytes = find(jars, entry_name(path), MAX_FILE)?.ok_or_else(|| format!("{path} is missing from the plugin"))?;
let (theme, fields) = theme_json(&bytes).map_err(|e| format!("{path}: {e}"))?;
let editor_scheme = match string_field(&fields, "editorScheme") {
Some(scheme) => find(jars, entry_name(scheme), MAX_FILE)?.map(|b| text(&b)),
None => None,
};
Ok(IntellijThemeFile { id: id.to_owned(), theme, editor_scheme })
}
/// A `.theme.json` with the editor scheme beside it (its `editorScheme` names a path in the plugin's jar).
fn loose_theme(path: &Path) -> Result<IntellijThemeSource, String> {
let (theme, fields) = theme_json(&read_file(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
// Any JSON object parses: an IntelliJ theme has UI colours or an editor scheme.
let scheme_ref = string_field(&fields, "editorScheme");
if !fields.get("ui").is_some_and(serde_json::Value::is_object) && scheme_ref.is_none() {
return Err(format!("{} is not an IntelliJ theme: it has no \"ui\" or \"editorScheme\"", path.display()));
}
let editor_scheme = scheme_ref
.and_then(|p| Path::new(p).file_name().map(|n| path.with_file_name(n)))
.filter(|p| p.is_file())
.map(|p| read_file(&p).map(|b| text(&b)))
.transpose()?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id = file.strip_suffix(".theme.json").or_else(|| file.strip_suffix(".json")).unwrap_or(&file).to_owned();
Ok(IntellijThemeSource {
name: string_field(&fields, "name").map_or_else(|| id.clone(), str::to_owned),
plugin_id: None,
version: None,
vendor: string_field(&fields, "author").map(str::to_owned),
themes: vec![IntellijThemeFile { id, theme, editor_scheme }],
})
}
type JsonObject = serde_json::Map<String, serde_json::Value>;
/// The theme JSON, comments removed, and its fields, if it is an object.
fn theme_json(bytes: &[u8]) -> Result<(String, JsonObject), String> {
let json = super::theme::strip_json_comments(&text(bytes));
match serde_json::from_str::<serde_json::Value>(&json) {
Ok(serde_json::Value::Object(fields)) => Ok((json, fields)),
Ok(_) => Err("not a theme: expected a JSON object".into()),
Err(e) => Err(format!("invalid theme JSON: {e}")),
}
}
fn string_field<'a>(fields: &'a JsonObject, key: &str) -> Option<&'a str> {
fields.get(key)?.as_str().filter(|s| !s.trim().is_empty())
}
/// `/themes/x.theme.json` → `themes/x.theme.json`.
fn entry_name(path: &str) -> &str {
path.trim().trim_start_matches('/')
}
fn text(bytes: &[u8]) -> String {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
String::from_utf8_lossy(bytes).into_owned()
}
fn read_file(path: &Path) -> Result<Vec<u8>, String> {
let shown = |e: std::io::Error| format!("{}: {e}", path.display());
let file = std::fs::File::open(path).map_err(shown)?;
let mut buf = Vec::new();
file.take(MAX_FILE + 1).read_to_end(&mut buf).map_err(shown)?;
if buf.len() as u64 > MAX_FILE {
return Err(format!("{} is too large for a theme", path.display()));
}
Ok(buf)
}
trait ReadSeek: Read + Seek {}
impl<T: Read + Seek> ReadSeek for T {}
type Jar = zip::ZipArchive<Box<dyn ReadSeek>>;
fn open_jar(path: &Path) -> Result<Jar, String> {
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let reader: Box<dyn ReadSeek> = Box::new(std::io::BufReader::new(file));
zip::ZipArchive::new(reader).map_err(|e| format!("{} is not a jar or zip file ({e})", path.display()))
}
/// The jars of the plugin at `path`: a jar, a Marketplace zip (`<plugin>/lib/*.jar`)
/// or an installed plugin's folder (`lib/*.jar`).
fn plugin_jars(path: &Path) -> Result<Vec<Jar>, String> {
let not_plugin = || format!("{} is not an IntelliJ plugin: no {PLUGIN_XML} and no lib/*.jar", path.display());
if path.is_dir() {
let mut found: Vec<PathBuf> = [path.join("lib"), path.join("lib").join("modules")]
.iter()
.flat_map(|dir| std::fs::read_dir(dir).into_iter().flatten().flatten())
.map(|e| e.path())
.filter(|p| p.is_file() && is_jar(p))
.collect();
found.sort();
let jars: Vec<Jar> = found.iter().take(MAX_JARS).filter_map(|p| open_jar(p).ok()).collect();
return if jars.is_empty() { Err(not_plugin()) } else { Ok(jars) };
}
let mut archive = open_jar(path)?;
if archive.index_for_name(PLUGIN_XML).is_some() {
return Ok(vec![archive]);
}
let jars = nested_jars(&mut archive, MAX_JAR, MAX_NESTED);
if jars.is_empty() { Err(not_plugin()) } else { Ok(jars) }
}
/// The `lib` jars of a Marketplace zip, smallest first (a theme's own jar is small)
/// while they fit `max_total`; library jars too large or not jars at all are skipped.
fn nested_jars(archive: &mut Jar, max_jar: u64, max_total: u64) -> Vec<Jar> {
let mut nested: Vec<(u64, String)> = entry_names(archive)
.into_iter()
.filter(|n| is_lib_jar(n))
.filter_map(|n| Some((archive.by_name(&n).ok()?.size(), n)))
.filter(|(size, _)| *size <= max_jar)
.collect();
nested.sort();
let (mut jars, mut loaded) = (Vec::new(), 0u64);
for (size, name) in nested.into_iter().take(MAX_JARS) {
if loaded.saturating_add(size) > max_total {
break;
}
let Ok(Some(bytes)) = read_entry(archive, &name, max_jar) else { continue };
loaded += size;
let reader: Box<dyn ReadSeek> = Box::new(Cursor::new(bytes));
if let Ok(jar) = zip::ZipArchive::new(reader) {
jars.push(jar);
}
}
jars
}
fn entry_names(jar: &Jar) -> Vec<String> {
jar.file_names().filter_map(Result::ok).map(|n| n.into_owned()).collect()
}
fn is_jar(path: &Path) -> bool {
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("jar"))
}
/// `lib/x.jar`, `<plugin>/lib/x.jar` or `<plugin>/lib/modules/x.jar` in a Marketplace zip.
fn is_lib_jar(name: &str) -> bool {
let parts: Vec<&str> = name.split('/').collect();
let n = parts.len();
name.to_ascii_lowercase().ends_with(".jar")
&& (2..=4).contains(&n)
&& (parts[n - 2] == "lib" || (n >= 3 && parts[n - 2] == "modules" && parts[n - 3] == "lib"))
}
fn read_entry(jar: &mut Jar, name: &str, max: u64) -> Result<Option<Vec<u8>>, String> {
let Ok(file) = jar.by_name(name) else { return Ok(None) };
if file.is_dir() {
return Ok(None);
}
if file.size() > max {
return Err(format!("{name} is too large"));
}
let mut buf = Vec::with_capacity(file.size() as usize);
file.take(max.saturating_add(1)).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?;
if buf.len() as u64 > max {
return Err(format!("{name} is too large"));
}
Ok(Some(buf))
}
fn find(jars: &mut [Jar], name: &str, max: u64) -> Result<Option<Vec<u8>>, String> {
for jar in jars.iter_mut() {
if let Some(bytes) = read_entry(jar, name, max)? {
return Ok(Some(bytes));
}
}
Ok(None)
}
/// What the plugin's descriptors say: `<idea-plugin>`'s `id`, `name`, `version`,
/// `vendor`, and its `themeProvider`s (id, path in the jar).
#[derive(Debug, Default, PartialEq)]
struct Descriptor {
id: Option<String>,
name: Option<String>,
version: Option<String>,
vendor: Option<String>,
themes: Vec<(String, String)>,
}
/// Reads `META-INF/plugin.xml` and the descriptors it includes (`<xi:include href>`), in any jar.
fn descriptor(jars: &mut [Jar]) -> Result<Descriptor, String> {
let main = find(jars, PLUGIN_XML, MAX_FILE)
.ok()
.flatten()
.ok_or_else(|| format!("not an IntelliJ plugin: no {PLUGIN_XML}"))?;
let mut out = Descriptor::default();
let mut includes = Vec::new();
parse_descriptor(&text(&main), true, &mut out, &mut includes).map_err(|e| format!("{PLUGIN_XML}: {e}"))?;
let mut read = std::collections::HashSet::from([PLUGIN_XML.to_owned()]);
while let Some(href) = includes.pop() {
let name = include_entry(&href);
if read.len() >= MAX_DESCRIPTORS || !read.insert(name.clone()) {
continue;
}
let Ok(Some(bytes)) = find(jars, &name, MAX_FILE) else { continue };
let _ = parse_descriptor(&text(&bytes), false, &mut out, &mut includes);
}
let mut seen = std::collections::HashSet::new();
out.themes.retain(|(id, _)| seen.insert(id.clone()));
Ok(out)
}
/// An `<xi:include href>` as a jar entry: `/META-INF/x.xml` from the jar's root, `x.xml` beside plugin.xml.
fn include_entry(href: &str) -> String {
let href = href.trim();
href.strip_prefix('/').map_or_else(|| format!("META-INF/{href}"), str::to_owned)
}
fn parse_descriptor(xml: &str, main: bool, out: &mut Descriptor, includes: &mut Vec<String>) -> Result<(), String> {
let mut reader = Reader::from_str(xml);
let mut stack: Vec<String> = Vec::new();
let mut text = String::new();
loop {
let event = reader.read_event().map_err(|e| e.to_string())?;
match event {
Event::Start(ref tag) | Event::Empty(ref tag) => {
let name = tag.local_name().as_ref().to_owned();
if name == "include" {
let href = tag.attributes().flatten().find(|a| a.key.local_name().as_ref() == "href");
if let Some(href) = href.and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok()) {
includes.push(href.into_owned());
}
}
if name == "themeProvider" {
let (mut id, mut path) = (None, None);
for a in tag.attributes() {
let a = a.map_err(|e| e.to_string())?;
let value = a.normalized_value(XmlVersion::Implicit1_0).map_err(|e| e.to_string())?;
match a.key.local_name().as_ref() {
"id" => id = Some(value.trim().to_owned()),
"path" => path = Some(value.trim().to_owned()),
_ => {}
}
}
if let (Some(id), Some(path)) = (id, path)
&& !id.is_empty()
&& !path.is_empty()
{
out.themes.push((id, path));
}
}
if matches!(event, Event::Start(_)) {
stack.push(name);
text.clear();
}
}
Event::Text(t) => text.push_str(&t.xml_content(XmlVersion::Implicit1_0)),
Event::CData(t) => text.push_str(t.as_ref()),
Event::GeneralRef(r) => {
let name = r.as_ref();
let escaped = format!("&{name};");
text.push_str(&quick_xml::escape::unescape(&escaped).map_err(|e| e.to_string())?);
}
Event::End(_) => {
let name = stack.pop().ok_or("unbalanced element")?;
// Direct children of <idea-plugin>.
if main && stack.len() == 1 && stack[0] == "idea-plugin" {
let value = text.trim();
let slot = match name.as_str() {
"id" => Some(&mut out.id),
"name" => Some(&mut out.name),
"version" => Some(&mut out.version),
"vendor" => Some(&mut out.vendor),
_ => None,
};
if let Some(slot) = slot
&& slot.is_none()
&& !value.is_empty()
{
*slot = Some(value.to_owned());
}
}
text.clear();
}
Event::Eof => return if stack.is_empty() { Ok(()) } else { Err("unclosed element".into()) },
_ => {}
}
}
}
/// Where an OS keeps the IDEs' settings and plugins.
#[derive(Clone, Copy, Debug)]
enum Layout {
/// `~/Library/Application Support/<vendor>/<IDE>`, plugins in its `plugins/`.
Mac,
/// `%APPDATA%\<vendor>\<IDE>`, plugins in its `plugins\`.
Windows,
/// `$XDG_CONFIG_HOME/<vendor>/<IDE>`, plugins in `$XDG_DATA_HOME/<vendor>/<IDE>`.
Xdg,
}
#[derive(Debug, PartialEq)]
struct IdeDir {
label: String,
product: String,
version: (u32, u32),
config: PathBuf,
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
Layout::Mac => {
let root = home.join("Library").join("Application Support");
(root.clone(), root)
}
Layout::Windows => {
let root = var("APPDATA").unwrap_or_else(|| home.join("AppData").join("Roaming"));
(root.clone(), root)
}
Layout::Xdg => (
var("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config")),
var("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local").join("share")),
),
};
let mut out = Vec::new();
for vendor in ["JetBrains", "Google"] {
for entry in std::fs::read_dir(config_root.join(vendor)).into_iter().flatten().flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some((product, version, shown)) = product_dir(&name) else { continue };
let config = entry.path();
if !config.is_dir() {
continue;
}
let plugins = match layout {
Layout::Xdg => data_root.join(vendor).join(&name),
Layout::Mac | Layout::Windows => config.join("plugins"),
};
out.push(IdeDir {
label: format!("{} {shown}", product_label(product)),
product: product.to_owned(),
version,
config,
plugins,
});
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
out
}
/// `IntelliJIdea2025.2` → (`IntelliJIdea`, (2025, 2), `2025.2`).
fn product_dir(name: &str) -> Option<(&str, (u32, u32), &str)> {
let at = name.find(|c: char| c.is_ascii_digit())?;
let (product, version) = name.split_at(at);
if product.is_empty() || !product.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
let (year, minor) = version.split_once('.')?;
let ok = |s: &str| !s.is_empty() && s.len() <= 4 && s.chars().all(|c| c.is_ascii_digit());
if year.len() != 4 || !ok(year) || !ok(minor) {
return None;
}
Some((product, (year.parse().ok()?, minor.parse().ok()?), version))
}
fn product_label(product: &str) -> &str {
match product {
"IntelliJIdea" => "IntelliJ IDEA",
"IdeaIC" => "IntelliJ IDEA Community Edition",
"PyCharmCE" => "PyCharm Community Edition",
"AndroidStudio" => "Android Studio",
"AndroidStudioPreview" => "Android Studio Preview",
other => other,
}
}
/// The theme ids `options/laf.xml` selects: the current one, and with "Sync with OS" the preferred light and dark.
fn active_theme_ids(config: &Path) -> Vec<String> {
let Ok(bytes) = read_file(&config.join("options").join("laf.xml")) else { return Vec::new() };
let xml = text(&bytes);
let mut reader = Reader::from_str(&xml);
let (mut ids, mut preferred, mut autodetect) = (Vec::new(), Vec::new(), false);
loop {
match reader.read_event() {
Ok(Event::Start(tag) | Event::Empty(tag)) => {
let attr = |key: &str| {
tag.attributes()
.flatten()
.find(|a| a.key.as_ref() == key)
.and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok().map(|v| v.trim().to_owned()))
};
match tag.local_name().as_ref() {
"component" if attr("name").as_deref() == Some("LafManager") => {
autodetect = attr("autodetect").as_deref() == Some("true");
}
"laf" => ids.extend(attr("themeId")),
"preferred-light-laf" | "preferred-dark-laf" => preferred.extend(attr("themeId")),
_ => {}
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
}
if autodetect {
ids.extend(preferred);
}
ids.retain(|id| !id.is_empty());
ids
}
fn find_installed(ides: &[IdeDir]) -> Vec<InstalledIntellijTheme> {
let mut out = Vec::new();
for ide in ides {
let active = active_theme_ids(&ide.config);
let mut plugins: Vec<PathBuf> = std::fs::read_dir(&ide.plugins)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() || (p.is_file() && is_jar(p)))
.collect();
plugins.sort();
for plugin in plugins {
let Ok(mut jars) = plugin_jars(&plugin) else { continue };
let Ok(found) = descriptor(&mut jars) else { continue };
for (id, path) in &found.themes {
let Ok(Some(bytes)) = find(&mut jars, entry_name(path), MAX_FILE) else { continue };
let Ok((_, fields)) = theme_json(&bytes) else { continue };
let file = || plugin.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
out.push(InstalledIntellijTheme {
ide: ide.label.clone(),
plugin: found.name.clone().unwrap_or_else(file),
path: plugin.clone().into(),
theme_id: id.clone(),
theme_name: string_field(&fields, "name").unwrap_or(id).to_owned(),
dark: fields.get("dark").and_then(serde_json::Value::as_bool).unwrap_or(true),
active: active.contains(id),
});
}
}
}
// Stable: the IDEs' order (newest first) within each group.
out.sort_by_key(|t| !t.active);
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const PLUGIN: &str = r#"<idea-plugin>
<id>com.example.deepsea</id>
<name>Deep Sea &amp; Friends</name>
<version>2.3</version>
<vendor email="a@example.com">Someone</vendor>
<description><![CDATA[<b>Dark</b> themes]]></description>
<depends>com.intellij.modules.platform</depends>
<extensions defaultExtensionNs="com.intellij">
<themeProvider id="deep-sea-id" path="/themes/DeepSea.theme.json"/>
<themeProvider id="shallow-id" path="themes/Shallow.theme.json" />
</extensions>
</idea-plugin>"#;
const DEEP: &str = r##"{
// comment
"name": "Deep Sea",
"dark": true,
"editorScheme": "/themes/DeepSea.xml",
"ui": { "*": { "background": "#101820" } }
}"##;
const SHALLOW: &str = r#"{ "name": "Shallow", "dark": false, "ui": {} }"#;
const SCHEME: &str = r#"<scheme name="Deep Sea" version="142" parent_scheme="Darcula"/>"#;
fn jar_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, data) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn theme_jar() -> Vec<u8> {
jar_bytes(&[
("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\n"),
(PLUGIN_XML, PLUGIN.as_bytes()),
("themes/DeepSea.theme.json", DEEP.as_bytes()),
("themes/DeepSea.xml", SCHEME.as_bytes()),
("themes/Shallow.theme.json", SHALLOW.as_bytes()),
])
}
fn write(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn expected_source() -> IntellijThemeSource {
IntellijThemeSource {
name: "Deep Sea & Friends".into(),
plugin_id: Some("com.example.deepsea".into()),
version: Some("2.3".into()),
vendor: Some("Someone".into()),
themes: vec![
IntellijThemeFile {
id: "deep-sea-id".into(),
theme: super::super::theme::strip_json_comments(DEEP),
editor_scheme: Some(SCHEME.into()),
},
IntellijThemeFile { id: "shallow-id".into(), theme: SHALLOW.into(), editor_scheme: None },
],
}
}
#[test]
fn reads_a_theme_jar_with_its_editor_scheme() {
let tmp = tempfile::tempdir().unwrap();
let jar = tmp.path().join("deep-sea.jar");
write(&jar, &theme_jar());
let source = read_source(&jar).unwrap();
assert_eq!(source, expected_source());
assert!(!source.themes[0].theme.contains("comment"));
}
#[test]
fn reads_a_marketplace_zip_and_an_installed_folder() {
let tmp = tempfile::tempdir().unwrap();
let other = jar_bytes(&[("META-INF/MANIFEST.MF", b"x"), ("a/B.class", b"\xCA\xFE")]);
let zip = tmp.path().join("DeepSea-2.3.zip");
write(&zip, &jar_bytes(&[("DeepSea/lib/a-util.jar", &other), ("DeepSea/lib/deep-sea.jar", &theme_jar())]));
assert_eq!(read_source(&zip).unwrap(), expected_source());
let dir = tmp.path().join("plugins").join("DeepSea");
write(&dir.join("lib").join("deep-sea.jar"), &theme_jar());
write(&dir.join("lib").join("a-util.jar"), &other);
assert_eq!(read_source(&dir).unwrap(), expected_source());
}
#[test]
fn skips_library_jars_it_cannot_or_need_not_read() {
let tmp = tempfile::tempdir().unwrap();
// Stored, not deflated: as large in the zip as its content.
let mut big = zip::ZipWriter::new(Cursor::new(Vec::new()));
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
big.start_file("lib/Data.class", stored).unwrap();
big.write_all(&[b'x'; 8192]).unwrap();
let big = big.finish().unwrap().into_inner();
let zip = tmp.path().join("DeepSea.zip");
write(
&zip,
&jar_bytes(&[
("DeepSea/lib/a-broken.jar", b"not a jar"),
("DeepSea/lib/b-big.jar", &big),
("DeepSea/lib/deep-sea.jar", &theme_jar()),
]),
);
assert_eq!(read_source(&zip).unwrap(), expected_source());
// The broken jar is skipped; a jar over the size limit, or past the total
// (smallest first), is not read, while the theme's small jar still is.
let mut archive = open_jar(&zip).unwrap();
let theme_len = theme_jar().len() as u64;
assert_eq!(nested_jars(&mut archive, u64::MAX, u64::MAX).len(), 2);
let one = nested_jars(&mut archive, theme_len + 64, u64::MAX);
assert_eq!(one.len(), 1);
assert!(one[0].index_for_name(PLUGIN_XML).is_some());
assert_eq!(nested_jars(&mut archive, u64::MAX, theme_len + 64).len(), 1);
assert!(nested_jars(&mut archive, 8, u64::MAX).is_empty());
}
#[test]
fn reads_a_loose_theme_json_with_the_scheme_beside_it() {
let tmp = tempfile::tempdir().unwrap();
let theme = tmp.path().join("DeepSea.theme.json");
write(&theme, DEEP.as_bytes());
write(&tmp.path().join("DeepSea.xml"), SCHEME.as_bytes());
let source = read_source(&theme).unwrap();
assert_eq!(source.name, "Deep Sea");
assert_eq!(source.themes.len(), 1);
assert_eq!(source.themes[0].id, "DeepSea");
assert_eq!(source.themes[0].editor_scheme.as_deref(), Some(SCHEME));
std::fs::remove_file(tmp.path().join("DeepSea.xml")).unwrap();
assert_eq!(read_source(&theme).unwrap().themes[0].editor_scheme, None);
}
#[test]
fn says_what_is_wrong_with_other_files() {
let tmp = tempfile::tempdir().unwrap();
let not_zip = tmp.path().join("notes.jar");
write(&not_zip, b"hello");
assert!(read_source(&not_zip).unwrap_err().message.contains("not a jar or zip"));
let other_plugin = tmp.path().join("tools.jar");
let xml = "<idea-plugin><name>Tools</name><actions/></idea-plugin>";
write(&other_plugin, &jar_bytes(&[(PLUGIN_XML, xml.as_bytes())]));
assert!(read_source(&other_plugin).unwrap_err().message.contains("not a theme plugin"));
let package = tmp.path().join("package.zip");
write(&package, &jar_bytes(&[("plugin.toml", b"id = \"x\"")]));
assert!(read_source(&package).unwrap_err().message.contains("not an IntelliJ plugin"));
let other = tmp.path().join("package.json");
write(&other, br#"{ "name": "app", "version": "1.0.0" }"#);
assert!(read_source(&other).unwrap_err().message.contains("is not an IntelliJ theme"));
let broken = tmp.path().join("broken.theme.json");
write(&broken, b"{ \"name\": ");
assert!(read_source(&broken).unwrap_err().message.contains("invalid theme JSON"));
let missing = tmp.path().join("missing.jar");
let xml = r#"<idea-plugin><name>M</name><themeProvider id="m" path="/m.theme.json"/></idea-plugin>"#;
write(&missing, &jar_bytes(&[(PLUGIN_XML, xml.as_bytes())]));
assert!(read_source(&missing).unwrap_err().message.contains("/m.theme.json is missing"));
}
#[test]
fn finds_theme_providers_in_included_descriptors() {
let tmp = tempfile::tempdir().unwrap();
let jar = tmp.path().join("split.jar");
let main = r#"<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude"><name>Split</name>
<xi:include href="/META-INF/themes.xml"/></idea-plugin>"#;
let part = r#"<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude"><extensions defaultExtensionNs="com.intellij">
<themeProvider id="s" path="/s.theme.json"/></extensions><name>Not this</name>
<xi:include href="more.xml"/><xi:include href="/META-INF/themes.xml"/></idea-plugin>"#;
let more = r#"<idea-plugin><themeProvider id="m" path="/m.theme.json"/></idea-plugin>"#;
let unrelated = r#"<idea-plugin><themeProvider id="x" path="/x.theme.json"/></idea-plugin>"#;
write(
&jar,
&jar_bytes(&[
("META-INF/themes.xml", part.as_bytes()),
("META-INF/more.xml", more.as_bytes()),
("META-INF/unrelated.xml", unrelated.as_bytes()),
(PLUGIN_XML, main.as_bytes()),
("s.theme.json", br#"{"name":"S"}"#),
("m.theme.json", br#"{"name":"M"}"#),
("x.theme.json", br#"{"name":"X"}"#),
]),
);
let source = read_source(&jar).unwrap();
assert_eq!(source.name, "Split");
// Only what plugin.xml includes (relative or from the root, once each) counts.
assert_eq!(source.themes.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["s", "m"]);
}
#[test]
fn parses_ide_folder_names() {
assert_eq!(product_dir("IntelliJIdea2025.2"), Some(("IntelliJIdea", (2025, 2), "2025.2")));
assert_eq!(product_dir("PyCharmCE2023.10"), Some(("PyCharmCE", (2023, 10), "2023.10")));
assert_eq!(product_dir("AndroidStudio2024.3"), Some(("AndroidStudio", (2024, 3), "2024.3")));
for name in
["Toolbox", "consentOptions", "IntelliJIdea2025", "Idea-2025.1", "2025.1", "Rider25.1", "Go Land2025.1"]
{
assert_eq!(product_dir(name), None, "{name}");
}
}
#[test]
fn locates_the_settings_and_plugins_of_each_os() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let mk = |p: PathBuf| {
std::fs::create_dir_all(&p).unwrap();
p
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
assert_eq!((dirs[0].config.clone(), dirs[0].plugins.clone()), (mac.clone(), mac.join("plugins")));
let appdata = mk(home.join("Roaming"));
let win = mk(appdata.join("JetBrains/PyCharm2024.3"));
let studio = mk(appdata.join("Google/AndroidStudio2025.1"));
let env = |k: &str| (k == "APPDATA").then(|| appdata.clone().into_os_string());
let dirs = ide_dirs(Layout::Windows, home, env);
assert_eq!(
dirs.iter().map(|d| d.label.as_str()).collect::<Vec<_>>(),
["Android Studio 2025.1", "PyCharm 2024.3"]
);
assert_eq!(dirs[1].plugins, win.join("plugins"));
assert_eq!(dirs[0].config, studio);
let config = mk(home.join(".config/JetBrains/IdeaIC2024.1"));
mk(home.join(".config/JetBrains/GoLand2025.2"));
let dirs = ide_dirs(Layout::Xdg, home, |_| None);
assert_eq!(dirs.iter().map(|d| d.product.as_str()).collect::<Vec<_>>(), ["GoLand", "IdeaIC"]);
assert_eq!(dirs[1].label, "IntelliJ IDEA Community Edition 2024.1");
assert_eq!(dirs[1].config, config);
assert_eq!(dirs[1].plugins, home.join(".local/share/JetBrains/IdeaIC2024.1"));
let xdg_data = home.join("data");
let env = |k: &str| (k == "XDG_DATA_HOME").then(|| xdg_data.clone().into_os_string());
assert_eq!(ide_dirs(Layout::Xdg, home, env)[1].plugins, xdg_data.join("JetBrains/IdeaIC2024.1"));
}
#[test]
fn reads_the_selected_theme_from_laf_xml() {
let tmp = tempfile::tempdir().unwrap();
let laf = tmp.path().join("options/laf.xml");
write(
&laf,
br#"<application>
<component name="LafManager">
<laf class-name="com.intellij.ide.ui.laf.UIThemeBasedLookAndFeelInfo" themeId="deep-sea-id" />
<preferred-light-laf themeId="shallow-id" />
</component>
</application>"#,
);
assert_eq!(active_theme_ids(tmp.path()), ["deep-sea-id"]);
let synced = std::fs::read_to_string(&laf)
.unwrap()
.replace(r#"name="LafManager""#, r#"name="LafManager" autodetect="true""#);
write(&laf, synced.as_bytes());
assert_eq!(active_theme_ids(tmp.path()), ["deep-sea-id", "shallow-id"]);
assert!(active_theme_ids(&tmp.path().join("missing")).is_empty());
}
#[test]
fn lists_installed_theme_plugins_the_ides_current_first() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("IntelliJIdea2025.2");
let plugins = config.join("plugins");
write(&plugins.join("DeepSea/lib/deep-sea.jar"), &theme_jar());
write(
&plugins.join("Tools/lib/tools.jar"),
&jar_bytes(&[(PLUGIN_XML, b"<idea-plugin><name>T</name></idea-plugin>")]),
);
write(&plugins.join("broken.jar"), b"not a jar");
write(
&config.join("options/laf.xml"),
br#"<application><component name="LafManager"><laf themeId="shallow-id"/></component></application>"#,
);
let ide = IdeDir {
label: "IntelliJ IDEA 2025.2".into(),
product: "IntelliJIdea".into(),
version: (2025, 2),
config: config.clone(),
plugins: plugins.clone(),
};
let found = find_installed(&[ide]);
let theme = |id: &str, name: &str, dark: bool, active: bool| InstalledIntellijTheme {
ide: "IntelliJ IDEA 2025.2".into(),
plugin: "Deep Sea & Friends".into(),
path: plugins.join("DeepSea").into(),
theme_id: id.into(),
theme_name: name.into(),
dark,
active,
};
assert_eq!(found, [theme("shallow-id", "Shallow", false, true), theme("deep-sea-id", "Deep Sea", true, false)]);
assert!(find_installed(&[]).is_empty());
}
}
+43 -2
View File
@@ -4,10 +4,10 @@
//! (`market-index.json`), checks signatures against it, and disables what it
//! revokes — at start-up and on every refresh (the desktop refreshes daily).
use super::{PluginHost, PluginSource, PluginStatus};
use super::{PackageFile, PluginHost, PluginSource, PluginStatus};
use ide_core::{IdeError, IdeResult};
use ide_extension::Manifest;
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
use ide_extension::{MANIFEST_FILE, Manifest};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -20,6 +20,8 @@ pub const INDEX_FILE: &str = "market-index.json";
const DOWNLOADS: &str = ".downloads";
const MAX_INDEX: usize = 16 << 20;
const MAX_PACKAGE: usize = 256 << 20;
const MAX_STAGED_FILES: usize = 256;
const MAX_STAGED_BYTES: usize = 16 << 20;
/// A marketplace entry with what is installed.
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
@@ -182,6 +184,45 @@ impl PluginHost {
Ok(dir)
}
/// Writes a package the UI generated (an imported IntelliJ theme) into a
/// folder next to the downloads, named after `name` (its plugin id); returns the folder.
/// The install page validates it like any other package.
pub fn stage_package(&self, name: &str, files: &[PackageFile]) -> IdeResult<PathBuf> {
ide_extension::validate_item_id(name, "package name").map_err(IdeError::invalid)?;
if !files.iter().any(|f| f.path == MANIFEST_FILE) {
return Err(IdeError::invalid(format!("a package needs a {MANIFEST_FILE}")));
}
let total: usize = files.iter().map(|f| f.text.len()).sum();
if files.len() > MAX_STAGED_FILES || total > MAX_STAGED_BYTES {
return Err(IdeError::invalid("the package is too large"));
}
let mut seen = std::collections::HashSet::new();
for f in files {
let valid = !f.path.is_empty()
&& f.path.len() <= 255
&& f.path.split('/').all(|part| {
!matches!(part, "" | "." | "..") && !part.contains(['\\', ':', '\0']) && !part.starts_with('.')
});
if !valid {
return Err(IdeError::invalid(format!("invalid package path {:?}", f.path)));
}
if !seen.insert(f.path.to_ascii_lowercase()) {
return Err(IdeError::invalid(format!("{} is in the package twice", f.path)));
}
}
// One folder per package, replaced by the next import of it: they don't pile up.
let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{name}-generated"));
let _ = std::fs::remove_dir_all(&dir);
for f in files {
let path = dir.join(&f.path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &f.text)?;
}
Ok(dir)
}
/// Files of the package in `dir` (what its declarations reference, and
/// the component): what a package file holds besides the manifest.
pub fn package_files_of(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
+1
View File
@@ -21,6 +21,7 @@ mod icon_patterns;
mod icon_settings;
mod icons;
mod install;
pub mod intellij;
mod market;
mod points;
mod providers;
+1 -1
View File
@@ -134,7 +134,7 @@ impl ExtensionPoint for ThemesPoint {
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
/// parse errors still point at the right line.
fn strip_json_comments(src: &str) -> String {
pub(super) fn strip_json_comments(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let (mut in_str, mut escaped) = (false, false);
+8
View File
@@ -212,6 +212,14 @@ impl PluginEditor {
}
}
/// A text file of a package the UI generated, at its `/`-separated path in the package.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PackageFile {
pub path: String,
pub text: String,
}
/// A plugin's status bar item.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+51
View File
@@ -2309,6 +2309,57 @@ fn a_zipped_plugin_folder_installs_like_the_folder() {
assert!(err.contains("is not an OpenIDEA plugin"), "{err}");
}
/// What the UI writes for an imported IntelliJ theme goes through the install page.
#[test]
fn a_generated_package_is_staged_for_the_install_page() {
let f = fixture(Limits::default());
let file = |path: &str, text: &str| PackageFile { path: path.into(), text: text.into() };
let manifest = r#"id = "intellij.deep-sea"
name = "Deep Sea"
version = "2.3.0"
engines.openidea = "^1.0"
[[contributes.themes]]
path = "themes/deep-sea.theme.json"
editor-scheme = "themes/deep-sea.scheme.json"
"#;
let files = [
file("plugin.toml", manifest),
file(
"themes/deep-sea.theme.json",
r##"{"id":"deep-sea","name":"Deep Sea","kind":"dark","tokens":{"--bg-app":"#101820"}}"##,
),
file("themes/deep-sea.scheme.json", r##"{"name":"Deep Sea","tokens":{"--syn-keyword":"#cc7832"}}"##),
];
let dir = f.host.stage_package("intellij.deep-sea", &files).unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!((preview.id.as_str(), preview.has_code, preview.unsigned), ("intellij.deep-sea", false, true));
assert!(preview.capabilities.is_empty());
f.install(&dir);
let active = f.host.set_theme(Some(("intellij.deep-sea", "deep-sea"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#101820");
assert_eq!(active.theme.tokens["--syn-keyword"], "#cc7832");
// Staging the same package again starts from an empty folder; importing it again replaces it.
assert_eq!(f.host.stage_package("intellij.deep-sea", &files[..1]).unwrap(), dir);
assert!(!dir.join("themes").exists());
let mut changed = files.clone();
changed[2] = file("themes/deep-sea.scheme.json", r##"{"tokens":{"--syn-keyword":"#ff8800"}}"##);
f.install(&f.host.stage_package("intellij.deep-sea", &changed).unwrap());
let active = f.host.set_theme(Some(("intellij.deep-sea", "deep-sea"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--syn-keyword"], "#ff8800");
for bad in ["../x", "/abs", "a//b", r"a\b", "C:x", ".hidden", "a/./b", ""] {
let err = f.host.stage_package("p", &[file("plugin.toml", manifest), file(bad, "x")]).unwrap_err().message;
assert!(err.contains("invalid package path"), "{bad:?}: {err}");
}
let err = f.host.stage_package("p", &[file("x.json", "{}")]).unwrap_err().message;
assert!(err.contains("needs a plugin.toml"), "{err}");
let err = f.host.stage_package("p", &[file("plugin.toml", ""), file("PLUGIN.toml", "")]).unwrap_err().message;
assert!(err.contains("twice"), "{err}");
assert!(f.host.stage_package("../p", &files).is_err());
}
/// Secrets live outside the plugin's directory (the OS keychain on the
/// desktop): uninstalling the plugin deletes the ones it declares.
#[test]
+23 -3
View File
@@ -132,9 +132,29 @@ and credit the original authors in `authors` and `description`.
### From IntelliJ IDEA (automatic)
To use a theme you already have in IntelliJ, open **Settings › Plugins**, gear
menu:
- **Import Theme from IntelliJ IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, the theme each IDE uses
first. It reads their settings folders: `~/Library/Application Support/JetBrains`
(macOS), `%APPDATA%\JetBrains` (Windows), `~/.config/JetBrains` and
`~/.local/share/JetBrains` (Linux).
- **Import IntelliJ Theme from File…** takes a theme plugin downloaded from the
JetBrains Marketplace (`.jar`, or a `.zip` with the plugin's `lib/*.jar`) or a
`.theme.json` (its `editorScheme` file is read from the same folder).
The whole plugin becomes one OpenIDEA theme plugin, `intellij.<plugin id>`, with
each of its themes and their editor schemes; it is installed through the usual
install page and the chosen theme (otherwise the first) is applied. Importing
the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
`plugins.importInstalledIntellijTheme`). The conversion is the one described
below.
`scripts/theme/import-intellij-theme.ts` converts an IntelliJ `.theme.json`,
and optionally its `.icls` editor scheme, into a ready-to-install plugin. It
has no dependencies beyond Node 22.
and optionally its `.icls` editor scheme, into a plugin folder you can edit
before installing it — the way to port a theme for publishing. It has no
dependencies beyond Node 22.
```sh
pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out ~/my-theme --author "Original Author"
@@ -179,7 +199,7 @@ ship no `.icls` at all keep the built-in syntax colours.
| `--bg-toggle-*` | `ToggleButton.onBackground`, `.offBackground`, `.buttonColor` |
| `--bg-feedback-*`, `--border-feedback-*` | `Banner.infoBackground`, `Banner.errorBorderColor`, … |
The full table is `UI_KEYS` in [`scripts/theme/intellij.ts`](../../scripts/theme/intellij.ts).
The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../src/features/plugins/intellijTheme.ts).
#### `.icls` → editor scheme
+3 -1
View File
@@ -728,7 +728,9 @@ granted, and starts the approved program.
- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (a package
folder) or _Install Plugin from Package File…_ (a `.oiplugin`, a `.zip` with
the package at its root or in its only folder, or a folder's `plugin.toml`;
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused). The install
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused; IntelliJ
theme plugins are imported instead: _Import IntelliJ Theme from File…_ and _Import Theme
from IntelliJ IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
+14 -3
View File
@@ -10,7 +10,8 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { parseArgs } from "node:util";
import { convertIntellijTheme, pluginManifest } from "./intellij.ts";
import { convertIntellijTheme, pluginManifest } from "../../src/features/plugins/intellijTheme.ts";
import { stripJsonComments } from "./tokens.ts";
const { values, positionals } = parseArgs({
allowPositionals: true,
@@ -25,7 +26,7 @@ if (!themePath || !values.out) {
}
const converted = convertIntellijTheme(
readFileSync(themePath, "utf8"),
stripJsonComments(readFileSync(themePath, "utf8")),
iclsPath ? readFileSync(iclsPath, "utf8") : undefined,
values.id,
);
@@ -34,7 +35,17 @@ const source = [basename(themePath), iclsPath && basename(iclsPath)].filter(Bool
const header = (what: string) => `// ${theme.name}: ${what}, converted from ${source}.\n`;
mkdirSync(join(values.out, "themes"), { recursive: true });
writeFileSync(join(values.out, "plugin.toml"), pluginManifest(converted, values.author ?? "Unknown", source));
const info = {
id: `theme-${theme.id}`,
name: `${theme.name} Theme`,
version: "1.0.0",
description: `${theme.name}, converted from an IntelliJ theme.`,
authors: [values.author ?? "Unknown"],
};
writeFileSync(
join(values.out, "plugin.toml"),
pluginManifest(info, [converted], `Converted from ${source} by scripts/theme/import-intellij-theme.ts.`),
);
writeFileSync(
join(values.out, "themes", `${theme.id}.theme.json`),
header("UI theme") + JSON.stringify(theme, null, 2) + "\n",
+64 -4
View File
@@ -5,9 +5,10 @@ import {
convertIntellijTheme,
ICLS_ATTRIBUTES,
ICLS_COLORS,
intellijThemePackage,
pluginManifest,
UI_KEYS,
} from "./intellij.ts";
} from "../../src/features/plugins/intellijTheme.ts";
import { referenceMarkdown, stripJsonComments, themeTokenGroups, tokenSets, withReference } from "./tokens.ts";
const read = (p: string) => readFileSync(p, "utf8");
@@ -129,7 +130,7 @@ describe("IntelliJ theme import", () => {
</scheme>`;
it("maps ui keys, named colours and wildcards", () => {
const { theme: t, scheme: s } = convertIntellijTheme(themeJson);
const { theme: t, scheme: s } = convertIntellijTheme(stripJsonComments(themeJson));
expect(t).toMatchObject({ id: "deep-sea", name: "Deep Sea", kind: "dark" });
expect(t.tokens["--bg-app"]).toBe("#101820"); // *.background → named colour
expect(t.tokens["--accent"]).toBe("#33aaff");
@@ -172,12 +173,71 @@ describe("IntelliJ theme import", () => {
});
it("produces an installable package", () => {
const c = convertIntellijTheme(themeJson.replace('"dark": true,', ""), icls.replace("Darcula", "Default"), "sea");
const light = stripJsonComments(themeJson.replace('"dark": true,', ""));
const c = convertIntellijTheme(light, icls.replace("Darcula", "Default"), "sea");
expect(c.theme.kind).toBe("light");
expect(c.theme.id).toBe("sea");
expect(c.theme.tokens["--bg-island"]).toBe("#101820"); // ToolWindow ← *.background wins over the editor
const toml = pluginManifest(c, "Someone", "DeepSea.theme.json");
const info = {
id: "theme-sea",
name: 'Sea "Deep"',
version: "1.0.0",
description: "a\\b\nc",
authors: ["Someone"],
};
const toml = pluginManifest(info, [c], "Converted from\nDeepSea.theme.json");
expect(toml).toContain("# Converted from DeepSea.theme.json\n");
expect(toml).toContain('id = "theme-sea"');
expect(toml).toContain('name = "Sea \\"Deep\\""');
expect(toml).toContain('description = "a\\\\b\\u000ac"');
expect(toml).toContain('engines.openidea = "^1.0"');
expect(toml).toContain('editor-scheme = "themes/sea.scheme.json"');
});
it("turns a theme plugin into one package with all its themes", () => {
const deep = stripJsonComments(themeJson);
const pkg = intellijThemePackage({
name: "Deep Sea Themes",
pluginId: "com.example.DeepSea",
version: "2.3",
vendor: "Someone",
themes: [
{ id: "deep-sea-id", theme: deep, editorScheme: icls },
{ id: "copy-id", theme: deep, editorScheme: null },
{ id: "light-id", theme: '{"name":"Shallow","dark":false}', editorScheme: null },
],
});
expect(pkg.id).toBe("intellij.com-example-deepsea");
expect(pkg.themes).toEqual([
{ id: "deep-sea", sourceId: "deep-sea-id", name: "Deep Sea" },
{ id: "deep-sea-2", sourceId: "copy-id", name: "Deep Sea" },
{ id: "shallow", sourceId: "light-id", name: "Shallow" },
]);
expect(pkg.files.map((f) => f.path)).toEqual([
"plugin.toml",
"themes/deep-sea.theme.json",
"themes/deep-sea.scheme.json",
"themes/deep-sea-2.theme.json",
"themes/shallow.theme.json",
]);
const manifest = pkg.files[0]!.text;
expect(manifest).toContain('version = "2.3.0"');
expect(manifest).toContain('authors = ["Someone"]');
expect(manifest.match(/\[\[contributes\.themes\]\]/g)).toHaveLength(3);
const shallow = JSON.parse(pkg.files[4]!.text) as { id: string; kind: string };
expect(shallow).toMatchObject({ id: "shallow", kind: "light" });
expect(
intellijThemePackage({
name: "X",
pluginId: null,
version: "SNAPSHOT",
vendor: null,
themes: [{ id: "x", theme: "{}", editorScheme: null }],
}).files[0]!.text,
).toContain('version = "1.0.0"');
expect(() =>
intellijThemePackage({ name: "Empty", pluginId: null, version: null, vendor: null, themes: [] }),
).toThrow();
expect(() => convertIntellijTheme("[1]")).toThrow("Not an IntelliJ theme");
});
});
+3
View File
@@ -457,6 +457,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_set_market_url,
plugin::plugin_market_download,
plugin::plugin_unpack_package,
plugin::plugin_read_intellij_theme,
plugin::plugin_installed_intellij_themes,
plugin::plugin_stage_package,
plugin::plugin_roll_back,
plugin::plugin_secrets,
plugin::plugin_set_secret,
+29 -2
View File
@@ -6,9 +6,10 @@
use crate::state::AppState;
use ide_core::{IdeError, IdeResult};
use ide_extension::ActivationEvent;
use ide_host::plugins::intellij::{self, InstalledIntellijTheme, IntellijThemeSource};
use ide_host::plugins::{
ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PluginEditor, PluginEvent,
PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent,
ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PackageFile, PluginEditor,
PluginEvent, PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent,
};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
@@ -512,6 +513,32 @@ pub async fn plugin_unpack_package(state: State<'_, AppState>, path: PathBuf) ->
blocking(move || host.unpack_package(&path)).await
}
/// Reads an IntelliJ theme plugin (`.jar`, Marketplace `.zip`, plugin folder) or `.theme.json` to import.
#[tauri::command]
#[specta::specta]
pub async fn plugin_read_intellij_theme(path: PathBuf) -> IdeResult<IntellijThemeSource> {
blocking(move || intellij::read_source(&path)).await
}
/// Theme plugins installed in the JetBrains IDEs of this user, the ones in use first.
#[tauri::command]
#[specta::specta]
pub async fn plugin_installed_intellij_themes() -> IdeResult<Vec<InstalledIntellijTheme>> {
blocking(|| Ok(intellij::installed_themes())).await
}
/// Writes a package the UI generated (an imported theme); returns the folder for the install page.
#[tauri::command]
#[specta::specta]
pub async fn plugin_stage_package(
state: State<'_, AppState>,
name: String,
files: Vec<PackageFile>,
) -> IdeResult<PathBuf> {
let host = state.plugins.clone();
blocking(move || host.stage_package(&name, &files)).await
}
/// Goes back to the version the last update of `id` replaced.
#[tauri::command]
#[specta::specta]
+46
View File
@@ -987,6 +987,12 @@ export const commands = {
pluginMarketDownload: (id: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_market_download", { id })),
/** Unpacks a package file (`.oiplugin` or `.zip`); returns the folder for the install page. */
pluginUnpackPackage: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_unpack_package", { path })),
/** Reads an IntelliJ theme plugin (`.jar`, Marketplace `.zip`, plugin folder) or `.theme.json` to import. */
pluginReadIntellijTheme: (path: string) => typedError<IntellijThemeSource, IdeError>(__TAURI_INVOKE("plugin_read_intellij_theme", { path })),
/** Theme plugins installed in the JetBrains IDEs of this user, the ones in use first. */
pluginInstalledIntellijThemes: () => typedError<InstalledIntellijTheme[], IdeError>(__TAURI_INVOKE("plugin_installed_intellij_themes")),
/** Writes a package the UI generated (an imported theme); returns the folder for the install page. */
pluginStagePackage: (name: string, files: PackageFile[]) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_stage_package", { name, files })),
/** Goes back to the version the last update of `id` replaced. */
pluginRollBack: (id: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_roll_back", { id })),
pluginSecrets: (pluginId: string) => typedError<PluginSecretState[], IdeError>(__TAURI_INVOKE("plugin_secrets", { pluginId })),
@@ -4292,6 +4298,40 @@ export type InstallPreview = {
publisher: string | null,
};
/** A theme of a plugin installed in a JetBrains IDE. */
export type InstalledIntellijTheme = {
/** The IDE and its version: `IntelliJ IDEA 2025.2`. */
ide: string,
/** The plugin's name. */
plugin: string,
/** What to import: the plugin's folder or jar. */
path: IpcPath,
themeId: string,
themeName: string,
dark: boolean,
/** The IDE is set to this theme. */
active: boolean,
};
export type IntellijThemeFile = {
/** The `themeProvider` id (what `laf.xml` names), or the file name without `.theme.json`. */
id: string,
/** The `.theme.json`, comments removed. */
theme: string,
/** The editor scheme (`.icls` XML) its `editorScheme` names, when the plugin has it. */
editorScheme: string | null,
};
/** A theme plugin's themes, as read from its files. */
export type IntellijThemeSource = {
/** The plugin's `<name>`, or the theme's for a loose file. */
name: string,
pluginId: string | null,
version: string | null,
vendor: string | null,
themes: IntellijThemeFile[],
};
/** Optional parts of the invalidation; the symbol index is always rebuilt. */
export type InvalidateCachesOptions = {
/** Local History of every project. */
@@ -5151,6 +5191,12 @@ export type OutputChunk = { kind: "stdout"; text: string } | { kind: "stderr"; t
/** System message from the IDE (command line echo, exit code). */
{ kind: "system"; text: string };
/** A text file of a package the UI generated, at its `/`-separated path in the package. */
export type PackageFile = {
path: string,
text: string,
};
/** A file of a partial commit: which of its changes against HEAD to commit. */
export type PartialCommitFile = {
path: IpcPath,
@@ -0,0 +1,140 @@
/**
* Import Theme from IntelliJ IDE: the theme plugins installed in the JetBrains
* IDEs on this machine, each under its plugin's heading, the plugin of the
* theme in use first. Importing a theme brings every theme of its plugin, as
* installing the plugin in IntelliJ does, and applies the chosen one
* (`importIntellijTheme`). A file can be chosen instead.
*/
import { useEffect, useState } from "react";
import clsx from "clsx";
import type { InstalledIntellijTheme } from "@/bindings";
import { tx } from "@/platform/i18n";
import { Badge, Dialog, List, Spinner, T } from "@/ui";
import { pluginsBackend } from "./backend";
import { closeInstalledIntellijThemes, importIntellijTheme, useIntellijThemesDialog } from "./intellijImport";
import { errorText } from "./store";
import styles from "./PluginsPage.module.css";
type Row =
| { kind: "plugin"; key: string; plugin: string; ide: string; count: number; first: boolean }
| { kind: "theme"; key: string; theme: InstalledIntellijTheme };
/** A heading per plugin (of an IDE) with its themes under it, in the order found (the one in use first). */
function themeRows(themes: readonly InstalledIntellijTheme[]): Row[] {
const plugins = new Map<string, InstalledIntellijTheme[]>();
for (const t of themes) {
const key = `${t.ide}\n${t.path}`;
plugins.set(key, [...(plugins.get(key) ?? []), t]);
}
return [...plugins].flatMap(([key, list], i): Row[] => [
{ kind: "plugin", key, plugin: list[0]!.plugin, ide: list[0]!.ide, count: list.length, first: i === 0 },
...list.map((theme): Row => ({ kind: "theme", key: `${key}\n${theme.themeId}`, theme })),
]);
}
function IntellijThemesDialog() {
const [themes, setThemes] = useState<InstalledIntellijTheme[] | null>(null);
const [error, setError] = useState<string | null>(null);
// The first theme, under the first heading.
const [active, setActive] = useState(1);
useEffect(() => {
let live = true;
pluginsBackend()
.installedIntellijThemes()
.then(
(found) => live && setThemes(found),
(e: unknown) => {
if (!live) return;
setError(errorText(e));
setThemes([]);
},
);
return () => {
live = false;
};
}, []);
const run = (theme?: InstalledIntellijTheme) => {
const { onImported } = useIntellijThemesDialog.getState();
closeInstalledIntellijThemes();
void importIntellijTheme(theme?.path, theme?.themeId).then((id) => id && onImported?.(id));
};
const rows = themeRows(themes ?? []);
const row = rows[active];
const chosen = row?.kind === "theme" ? row.theme : undefined;
return (
<Dialog
open
title="Import Theme from IntelliJ IDE"
width={640}
onClose={closeInstalledIntellijThemes}
buttons={[
{ label: "Choose File…", role: "extra", onClick: () => run() },
{ label: "Cancel", role: "cancel" },
{ label: "Import", role: "default", disabled: !chosen, onClick: () => chosen && run(chosen) },
]}
>
<div className={styles.intellijThemes}>
<p className={styles.muted}>
<T>
Theme plugins installed in the JetBrains IDEs on this computer. Importing a theme brings every theme of its
plugin and applies the chosen one. Colours without an OpenIDEA counterpart keep the default theme colours.
</T>
</p>
{themes === null ? (
<div className={styles.intellijThemesStatus}>
<Spinner size={14} label="Looking for IntelliJ themes" />
<T>Looking for IntelliJ themes…</T>
</div>
) : (
<List
items={rows}
getKey={(r) => r.key}
isSection={(r) => r.kind === "plugin"}
activeIndex={chosen ? active : -1}
onActiveChange={setActive}
rowHeight={28}
sectionHeight={30}
className={styles.intellijThemesList}
aria-label={tx("IntelliJ themes")}
empty={
<div className={styles.intellijThemesStatus} role="note">
{error ?? (
<T>
No theme plugins found in JetBrains IDEs on this computer. Choose a theme plugin downloaded from the
JetBrains Marketplace (.jar or .zip) or a .theme.json file instead.
</T>
)}
</div>
}
renderSection={(r) =>
r.kind === "plugin" && (
<div className={clsx(styles.intellijPlugin, !r.first && styles.intellijPluginNext)}>
<span className={styles.intellijPluginName}>{r.plugin}</span>
<span>{r.count === 1 ? tx("1 theme") : tx`${r.count} themes`}</span>
<span className={styles.intellijPluginIde}>{r.ide}</span>
</div>
)
}
renderItem={(r) =>
r.kind === "theme" && (
<div className={styles.intellijTheme}>
<span className={styles.itemMain}>{r.theme.themeName}</span>
{r.theme.active && <Badge tone="accent">{tx("In use")}</Badge>}
<span className={styles.muted}>{r.theme.dark ? tx("Dark") : tx("Light")}</span>
</div>
)
}
/>
)}
</div>
</Dialog>
);
}
/** Overlay of the dialog while `showInstalledIntellijThemes` has it open. */
export function IntellijThemesDialogHost() {
const open = useIntellijThemesDialog((s) => s.open);
return open ? <IntellijThemesDialog /> : null;
}
@@ -384,3 +384,63 @@
padding: 12px 16px;
overflow: auto;
}
/* Import Theme from IntelliJ IDE */
.intellijThemes {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 14px;
}
.intellijThemes > p {
margin: 0;
}
/* As tall as its rows (the list sizes them), scrolling past seven. */
.intellijThemesList {
max-height: 316px;
padding: 4px 0;
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
.intellijThemesStatus {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
color: var(--fg-muted);
}
.intellijTheme {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
min-width: 0;
}
.intellijTheme > .muted {
flex-shrink: 0;
font-size: var(--fs-small);
}
.intellijPlugin {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
align-self: stretch;
min-width: 0;
}
/* A line above each plugin but the first, as in IDEA's Theme list. */
.intellijPluginNext {
border-top: 1px solid var(--border);
}
.intellijPluginName {
overflow: hidden;
color: var(--fg-secondary);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.intellijPluginIde {
flex-shrink: 0;
margin-left: auto;
}
+30 -10
View File
@@ -43,6 +43,8 @@ import {
uninstallPlugin,
usePlugins,
} from "./store";
import { useProject } from "@/platform/project";
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
import styles from "./PluginsPage.module.css";
import { tx } from "@/platform/i18n";
@@ -526,11 +528,26 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
);
}
function gearMenu(plugins: readonly PluginInfo[], install: (packageFile: boolean) => void): MenuItem[] {
interface GearActions {
/** Installing needs a trusted project's window (the host refuses it elsewhere). */
canImport: boolean;
install: (packageFile: boolean) => void;
importIntellijFile: () => void;
importInstalledIntellij: () => void;
}
function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuItem[] {
const installed = plugins.filter((p) => p.source === "installed");
return [
{ label: "Install Plugin from Disk…", icon: "package", run: () => install(false) },
{ label: "Install Plugin from Package File…", run: () => install(true) },
{ label: "Install Plugin from Disk…", icon: "package", run: () => actions.install(false) },
{ label: "Install Plugin from Package File…", run: () => actions.install(true) },
...(actions.canImport
? [
{ kind: "separator" as const },
{ label: "Import Theme from IntelliJ IDE…", run: actions.importInstalledIntellij },
{ label: "Import IntelliJ Theme from File…", run: actions.importIntellijFile },
]
: []),
{ kind: "separator" },
{
label: "Disable All Downloaded Plugins",
@@ -700,19 +717,22 @@ export function PluginsPage() {
const catalogShown = useMemo(() => filterCatalog(catalog ?? [], query), [catalog, query]);
const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null;
const install = async (packageFile = false) => {
const id = await installFromDisk(undefined, packageFile);
if (id) {
setTab("installed");
setSelectedId(id);
}
const showInstalled = (id: string | null) => {
if (!id) return;
setTab("installed");
setSelectedId(id);
};
const openGear = (e: MouseEvent<HTMLButtonElement>) => {
const r = e.currentTarget.getBoundingClientRect();
showContextMenu(
{ x: r.left, y: r.bottom + 2 },
gearMenu(plugins, (packageFile) => void install(packageFile)),
gearMenu(plugins, {
canImport: useProject.getState().project?.trusted === true,
install: (packageFile) => void installFromDisk(undefined, packageFile).then(showInstalled),
importIntellijFile: () => void importIntellijTheme().then(showInstalled),
importInstalledIntellij: () => showInstalledIntellijThemes(showInstalled),
}),
);
};
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { InstalledIntellijTheme, InstallPreview, IntellijThemeSource } from "@/bindings";
import { IpcError } from "@/platform/ipc";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import { closeInstalledIntellijThemes, importIntellijTheme, showInstalledIntellijThemes } from "../intellijImport";
import { IntellijThemesDialogHost } from "../IntellijThemesDialog";
import { setConsentPrompt } from "../store";
type Note = Parameters<NotificationService["notify"]>[0];
const ID = "intellij.com-example-deepsea";
const SOURCE: IntellijThemeSource = {
name: "Deep Sea Themes",
pluginId: "com.example.deepsea",
version: "2.3",
vendor: "Someone",
themes: [
{ id: "deep-id", theme: '{"name":"Deep Sea","dark":true,"ui":{"*":{"background":"#101820"}}}', editorScheme: null },
{ id: "shallow-id", theme: '{"name":"Shallow","dark":false}', editorScheme: null },
],
};
const INSTALLED: InstalledIntellijTheme[] = [
{
ide: "IntelliJ IDEA 2025.2",
plugin: "Deep Sea Themes",
path: "/ij/plugins/DeepSea",
themeId: "shallow-id",
themeName: "Shallow",
dark: false,
active: true,
},
{
ide: "IntelliJ IDEA 2025.2",
plugin: "Deep Sea Themes",
path: "/ij/plugins/DeepSea",
themeId: "deep-id",
themeName: "Deep Sea",
dark: true,
active: false,
},
];
let notes: Note[];
let read: Mock<PluginsBackend["readIntellijTheme"]>;
let stage: Mock<PluginsBackend["stagePackage"]>;
let installed: string[];
function backend(overrides: Partial<PluginsBackend> = {}): PluginsBackend {
const base = createMemoryPluginsBackend();
const preview: InstallPreview = {
id: ID,
name: SOURCE.name,
version: "2.3.0",
description: "",
authors: ["Someone"],
hasCode: false,
capabilities: [],
contributions: [{ point: "themes", count: 2 }],
warnings: [],
replaces: null,
unsigned: true,
publisher: null,
};
return {
...base,
readIntellijTheme: read,
stagePackage: stage,
installedIntellijThemes: async () => INSTALLED,
previewInstall: async () => preview,
installDir: async (path) => {
installed.push(path);
const [template] = (await base.list()).plugins;
return { ...template!, id: ID, name: SOURCE.name, hasCode: false, status: "static" };
},
...overrides,
};
}
beforeEach(() => {
notes = [];
installed = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
read = vi.fn<PluginsBackend["readIntellijTheme"]>(async () => SOURCE);
stage = vi.fn<PluginsBackend["stagePackage"]>(async () => "/app/plugins/.downloads/generated");
setPluginsBackend(backend());
setConsentPrompt(async () => ({ optional: [] }));
useThemeOverride.setState({ theme: null });
});
afterEach(() => {
closeInstalledIntellijThemes();
setConsentPrompt(null);
setPluginsBackend(null);
useThemeOverride.setState({ theme: null });
});
describe("IntelliJ theme import", () => {
it("installs the converted plugin through the install page and applies the chosen theme", async () => {
expect(await importIntellijTheme("/downloads/DeepSea.zip", "shallow-id")).toBe(ID);
expect(read).toHaveBeenCalledWith("/downloads/DeepSea.zip");
const [name, files] = stage.mock.calls[0]!;
expect(name).toBe(ID);
expect(files.map((f) => f.path)).toEqual([
"plugin.toml",
"themes/deep-sea.theme.json",
"themes/shallow.theme.json",
]);
expect(installed).toEqual(["/app/plugins/.downloads/generated"]);
expect(useThemeOverride.getState().theme).toBe(`plugin:${ID}/shallow`);
});
it("applies the first theme without a choice, and nothing when the install is declined", async () => {
expect(await importIntellijTheme("/downloads/DeepSea.jar")).toBe(ID);
expect(useThemeOverride.getState().theme).toBe(`plugin:${ID}/deep-sea`);
useThemeOverride.setState({ theme: null });
setConsentPrompt(async () => null);
expect(await importIntellijTheme("/downloads/DeepSea.jar")).toBeNull();
expect(useThemeOverride.getState().theme).toBeNull();
});
it("asks for a file without a path, and does nothing when the picker is cancelled", async () => {
const pick = vi.fn(async () => null);
setPluginsBackend(backend({ pickIntellijTheme: pick }));
expect(await importIntellijTheme()).toBeNull();
expect(pick).toHaveBeenCalledOnce();
expect(read).not.toHaveBeenCalled();
expect(notes).toEqual([]);
});
it("reports a file that is not a theme plugin", async () => {
read.mockRejectedValueOnce(
new IpcError({
kind: "invalidInput",
message: "tools.jar is not a theme plugin: its plugin.xml declares no themeProvider",
}),
);
expect(await importIntellijTheme("/downloads/tools.jar")).toBeNull();
expect(stage).not.toHaveBeenCalled();
expect(notes).toEqual([
expect.objectContaining({
level: "error",
title: "Could not import the IntelliJ theme",
body: expect.stringContaining("not a theme plugin"),
}),
]);
});
});
describe("Import Theme from IntelliJ IDE", () => {
it("lists the installed themes by plugin, the one in use first, and imports the chosen one", async () => {
const user = userEvent.setup();
const onImported = vi.fn();
render(<IntellijThemesDialogHost />);
act(() => showInstalledIntellijThemes(onImported));
const list = await screen.findByRole("listbox", { name: "IntelliJ themes" });
const options = await waitFor(() => {
const found = Array.from(list.querySelectorAll('[role="option"]'));
expect(found).toHaveLength(2);
return found;
});
// Both under their plugin's heading: importing one brings the plugin's every theme.
expect(within(list).getByText("Deep Sea Themes")).toBeInTheDocument();
expect(within(list).getByText("2 themes")).toBeInTheDocument();
expect(within(list).getByText("IntelliJ IDEA 2025.2")).toBeInTheDocument();
expect(options[0]).toHaveTextContent("Shallow");
expect(options[0]).toHaveTextContent("In use");
expect(options[1]).toHaveTextContent("Deep Sea");
expect(options[1]).not.toHaveTextContent("In use");
await user.click(screen.getByText("Deep Sea"));
await user.click(screen.getByRole("button", { name: "Import" }));
await waitFor(() => expect(onImported).toHaveBeenCalledWith(ID));
expect(read).toHaveBeenCalledWith("/ij/plugins/DeepSea");
expect(useThemeOverride.getState().theme).toBe(`plugin:${ID}/deep-sea`);
expect(screen.queryByRole("listbox", { name: "IntelliJ themes" })).toBeNull();
});
it("offers a file when no IDE has theme plugins", async () => {
const user = userEvent.setup();
const pick = vi.fn(async () => "/downloads/DeepSea.jar");
setPluginsBackend(backend({ installedIntellijThemes: async () => [], pickIntellijTheme: pick }));
render(<IntellijThemesDialogHost />);
act(() => showInstalledIntellijThemes());
expect(await screen.findByText(/No theme plugins found/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Choose File…" }));
await waitFor(() => expect(read).toHaveBeenCalledWith("/downloads/DeepSea.jar"));
expect(pick).toHaveBeenCalledOnce();
});
});
@@ -3,19 +3,21 @@ import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { PluginInfo } from "@/bindings";
import type { PluginInfo, ProjectInfo } from "@/bindings";
import { _setActiveEditor } from "@/editor/api";
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { useContributedIconThemes } from "@/platform/themes";
import { pluginThemeRef, useContributedIconThemes } from "@/platform/themes";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme";
import { applyThemeAttributes, previewTheme, useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
followThemeSetting,
PLUGIN_THEME_STYLE_ID,
handlePluginEvent,
providePluginThemePreviews,
pluginActionId,
startPluginBridge,
syncProjection,
@@ -389,6 +391,36 @@ describe("bridge events", () => {
useThemeOverride.setState({ theme: null });
});
it("previews a live plugin's theme in this window without selecting it", () => {
const root = document.documentElement;
const sheet = () => document.getElementById(PLUGIN_THEME_STYLE_ID)?.textContent ?? null;
const setTheme = vi.spyOn(backend, "setTheme");
const night = { id: "night", name: "Night", kind: "dark", tokens: { "--bg-app": "#101820" } };
const dawn = { id: "dawn", name: "Dawn", kind: "light" as const, tokens: { "--bg-app": "#fff8e7" } };
usePlugins.setState({
ui: [{ pluginId: "acme", point: "themes", key: "night", decl: night }],
activeTheme: { pluginId: "acme", theme: dawn },
});
applyPluginTheme(usePlugins.getState().activeTheme);
const stop = providePluginThemePreviews();
previewTheme(pluginThemeRef("acme", "night"));
expect(sheet()).toContain("--bg-app: #101820;");
expect([root.dataset.pluginTheme, root.dataset.theme]).toEqual(["acme/night", "dark"]);
// A built-in theme shows without the plugin's tokens.
previewTheme("solarized-light");
expect(sheet()).toBeNull();
expect([root.dataset.theme, root.dataset.colorTheme]).toEqual(["light", "solarized-light"]);
// Back to the selected one; nothing was selected on the way.
previewTheme(null);
expect(sheet()).toContain("--bg-app: #fff8e7;");
expect(root.dataset.pluginTheme).toBe("acme/dawn");
expect(setTheme).not.toHaveBeenCalled();
stop();
applyPluginTheme(null);
});
it("runs commands with the focused editor's path, language and UTF-16 selections", async () => {
const calls: unknown[] = [];
setPluginsBackend({
@@ -464,6 +496,32 @@ describe("PluginsPage", () => {
expect(screen.getByText("Toy (.toy)")).toBeInTheDocument();
});
it("offers the IntelliJ theme imports only in a trusted project's window", async () => {
const user = userEvent.setup();
render(
<>
<PluginsPage />
<ContextMenuHost />
</>,
);
const imports = async () => {
await user.click(screen.getByRole("button", { name: "Manage Plugins" }));
const found = ["Import Theme from IntelliJ IDE…", "Import IntelliJ Theme from File…"].filter((name) =>
screen.queryByRole("menuitem", { name }),
);
act(() => hideContextMenu());
return found;
};
// The welcome screen: the host refuses installs without a trusted project.
expect(await imports()).toEqual([]);
const project = { trusted: false, root: "/p", name: "p", isGit: false } as unknown as ProjectInfo;
act(() => useProject.setState({ project }));
expect(await imports()).toEqual([]);
act(() => useProject.setState({ project: { ...project, trusted: true } }));
expect(await imports()).toHaveLength(2);
act(() => useProject.setState({ project: null }));
});
it("restarts a failed plugin instead of disabling it", async () => {
setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "broken", status: "failed", error: "trap" })]));
const user = userEvent.setup();
+30
View File
@@ -10,7 +10,9 @@ import type {
CatalogPlugin,
Consent,
EventKind,
InstalledIntellijTheme,
InstallPreview,
IntellijThemeSource,
MarketPlugin,
PluginEditor,
PluginEvent,
@@ -71,6 +73,14 @@ export interface PluginsBackend {
rollBack(id: string): Promise<PluginInfo>;
/** File picker for a package: an `.oiplugin`, a `.zip` or a folder's `plugin.toml`; `null` when cancelled. */
pickPackage(): Promise<string | null>;
/** File picker for an IntelliJ theme plugin (`.jar`, `.zip`) or `.theme.json`; `null` when cancelled. */
pickIntellijTheme(): Promise<string | null>;
/** Reads the themes of an IntelliJ theme plugin or `.theme.json`. */
readIntellijTheme(path: string): Promise<IntellijThemeSource>;
/** Theme plugins installed in the JetBrains IDEs on this machine, the ones in use first. */
installedIntellijThemes(): Promise<InstalledIntellijTheme[]>;
/** Writes a generated package (`name`: its id); the folder to install from. */
stagePackage(name: string, files: { path: string; text: string }[]): Promise<string>;
/** Content of a plugin view (`doc`: the document of a custom editor). */
renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>;
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
@@ -166,6 +176,18 @@ export const tauriPluginsBackend: PluginsBackend = {
});
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
pickIntellijTheme: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
multiple: false,
title: tx("Import IntelliJ Theme"),
filters: [{ name: tx("IntelliJ theme plugin or theme file"), extensions: ["jar", "zip", "json"] }],
});
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
readIntellijTheme: (path) => call(commands.pluginReadIntellijTheme(path)),
installedIntellijThemes: () => call(commands.pluginInstalledIntellijThemes()),
stagePackage: (name, files) => call(commands.pluginStagePackage(name, files)),
// A tool window's calls (no `doc`) carry the window's active editor.
renderView: (pluginId, view, viewport, doc) =>
call(commands.pluginViewRender(pluginId, view, viewport, doc, doc == null ? editorContext() : null)),
@@ -488,6 +510,14 @@ export function createMemoryPluginsBackend(
throw new IpcError({ kind: "notFound", message: `${id} has no earlier version to roll back to` });
},
pickPackage: async () => null,
pickIntellijTheme: async () => null,
readIntellijTheme: async (path) => {
throw new IpcError({ kind: "unavailable", message: `reading ${path} requires the desktop app` });
},
installedIntellijThemes: async () => [],
stagePackage: async () => {
throw new IpcError({ kind: "unavailable", message: "installing a generated plugin requires the desktop app" });
},
renderView: async (pluginId, view) => ({
toolbar: [],
body: { kind: "message", value: `${pluginId} · ${view} (plugin views need the desktop app)` },
+22 -1
View File
@@ -12,7 +12,12 @@ import { refreshLanguageInfos } from "@/editor/core/cm/languages";
import { notifications } from "@/workbench/contributions";
import { useProducerNames } from "@/platform/inspections";
import { languageListChanged } from "@/platform/languageList";
import { isThemeAvailable, parsePluginThemeRef, useContributedThemes } from "@/platform/themes";
import {
isThemeAvailable,
parsePluginThemeRef,
providePluginThemePreview,
useContributedThemes,
} from "@/platform/themes";
import {
applyThemeAttributes,
currentThemeChoice,
@@ -92,6 +97,22 @@ export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement =
if (had || theme) syncWindowBackground();
}
/**
* Quick Switch's theme preview of plugin themes: a live plugin's theme as it
* contributes it, shown in this window only; nothing is selected or saved.
*/
export function providePluginThemePreviews(): () => void {
return providePluginThemePreview((ref) => {
const state = usePlugins.getState();
if (ref === "selected") return applyPluginTheme(state.activeTheme);
const want = ref ? parsePluginThemeRef(ref) : null;
const item = want
? state.ui.find((c) => c.point === "themes" && c.pluginId === want.pluginId && c.key === want.themeId)
: undefined;
applyPluginTheme(item ? { pluginId: item.pluginId, theme: item.decl as ActiveTheme["theme"] } : null);
});
}
/** localStorage key read by public/boot.js: the selected theme's stylesheet, applied before the app loads. */
export const PLUGIN_THEME_CACHE_KEY = "openidea.pluginTheme";
+35 -1
View File
@@ -5,6 +5,8 @@
* projected by `projection.ts` / `points.tsx`.
*
* Actions: `plugins.settings`, `plugins.installFromDisk`, `plugins.reloadAll`,
* `plugins.importIntellijTheme` (a theme plugin file; `arg`: its path),
* `plugins.importInstalledIntellijTheme` (from the JetBrains IDEs on this machine),
* plus one action per plugin command: `plugin.<pluginId>.<commandId>`
* (searchable in Search Everywhere › Actions and bindable in the keymap editor;
* a string arg is forwarded to the plugin).
@@ -17,12 +19,20 @@ import { lazyComponent } from "@/ui";
import { executeAction, registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions";
import { followPluginChanges, startPluginBridge, syncProjection, trackPluginTheme } from "./bridge";
import {
followPluginChanges,
providePluginThemePreviews,
startPluginBridge,
syncProjection,
trackPluginTheme,
} from "./bridge";
import { ConsentDialogHost, provideConsentDialog } from "./consent";
import { trackIconTheme } from "./iconThemes";
import { pluginLineMarkerGutter } from "./lineMarkerGutter";
import { defineBuiltinUiPoints } from "./points";
import { startViewUi } from "./uiPoints";
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
import { IntellijThemesDialogHost } from "./IntellijThemesDialog";
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
function registerPluginsPage(): () => void {
@@ -41,6 +51,7 @@ export function activateSettingsPages(): () => void {
const disposers = [
registerPluginsPage(),
registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }),
registerOverlay({ id: "plugins.intellijThemes", component: IntellijThemesDialogHost }),
provideConsentDialog(),
defineBuiltinUiPoints(),
followPluginChanges(),
@@ -56,7 +67,9 @@ export function activate(): () => void {
const disposers = [
registerPluginsPage(),
registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }),
registerOverlay({ id: "plugins.intellijThemes", component: IntellijThemesDialogHost }),
provideConsentDialog(),
providePluginThemePreviews(),
registerActions([
{
id: "plugins.settings",
@@ -76,6 +89,27 @@ export function activate(): () => void {
await installFromDisk(typeof arg === "string" ? arg : undefined);
},
},
{
id: "plugins.importIntellijTheme",
title: "Import IntelliJ Theme from File…",
category: "Plugins",
icon: "plugin",
keywords: ["intellij", "jetbrains", "theme", "jar", "theme.json", "color scheme"],
// Installing needs a project's window.
when: "projectOpen",
run: async ({ arg }) => {
await importIntellijTheme(typeof arg === "string" ? arg : undefined);
},
},
{
id: "plugins.importInstalledIntellijTheme",
title: "Import Theme from IntelliJ IDE…",
category: "Plugins",
icon: "plugin",
keywords: ["intellij", "jetbrains", "theme", "color scheme", "migrate"],
when: "projectOpen",
run: () => showInstalledIntellijThemes(),
},
{
id: "plugins.reloadAll",
title: "Reload Plugins",
+61
View File
@@ -0,0 +1,61 @@
/**
* Settings › Plugins › Import IntelliJ Theme: an IntelliJ theme plugin (a
* Marketplace `.jar` / `.zip`, a plugin folder of an installed IDE) or a
* `.theme.json` becomes an OpenIDEA theme plugin — read by the host, converted
* here (`intellijTheme.ts`), installed through the usual install page — and
* its theme is applied.
*/
import { create } from "zustand";
import { pluginThemeRef } from "@/platform/themes";
import { notifications } from "@/workbench/contributions";
import { setTheme } from "@/workbench/theme";
import { pluginsBackend } from "./backend";
import { intellijThemePackage, type ThemePackage } from "./intellijTheme";
import { errorText, installFromDisk } from "./store";
/**
* Imports the IntelliJ theme plugin at `path` (asks for a file without one)
* and applies its theme `themeId` (an IntelliJ theme id), or its first.
* Returns the installed plugin's id.
*/
export async function importIntellijTheme(path?: string, themeId?: string): Promise<string | null> {
const backend = pluginsBackend();
let pkg: ThemePackage;
let dir: string;
try {
const picked = path ?? (await backend.pickIntellijTheme());
if (!picked) return null;
pkg = intellijThemePackage(await backend.readIntellijTheme(picked));
dir = await backend.stagePackage(pkg.id, pkg.files);
} catch (e) {
notifications().notify({
group: "plugins",
level: "error",
title: "Could not import the IntelliJ theme",
body: errorText(e),
});
return null;
}
const id = await installFromDisk(dir);
if (!id) return null;
const theme = pkg.themes.find((t) => t.sourceId === themeId) ?? pkg.themes[0];
if (theme) await setTheme(pluginThemeRef(id, theme.id));
return id;
}
interface DialogState {
open: boolean;
/** Called with the plugin id after an import from the dialog. */
onImported: ((id: string) => void) | null;
}
export const useIntellijThemesDialog = create<DialogState>(() => ({ open: false, onImported: null }));
/** Shows the themes of the JetBrains IDEs on this machine to pick one to import. */
export function showInstalledIntellijThemes(onImported?: (id: string) => void): void {
useIntellijThemesDialog.setState({ open: true, onImported: onImported ?? null });
}
export function closeInstalledIntellijThemes(): void {
useIntellijThemesDialog.setState({ open: false, onImported: null });
}
@@ -1,11 +1,13 @@
/**
* Converts an IntelliJ theme (`.theme.json`, optionally with its editor scheme
* `.icls`) into an OpenIDEA theme + editor scheme. The mapping covers the
* main keys only; anything unmapped keeps the built-in value of the base
* (`kind`). Pure functions, no dependencies: see import-intellij-theme.ts
* for the command line.
* `.icls`) into an OpenIDEA theme + editor scheme, and an IntelliJ theme
* plugin into an OpenIDEA theme plugin package. The mapping covers the main
* keys only; anything unmapped keeps the built-in value of the base (`kind`).
*
* Pure functions without imports: Settings › Plugins › Import IntelliJ Theme
* uses them on what the host read from the plugin (`plugin_read_intellij_theme`),
* and so does the command line (`scripts/theme/import-intellij-theme.ts`).
*/
import { stripJsonComments } from "./tokens.ts";
/** OpenIDEA token ← IntelliJ `ui` keys, first present wins. `*.prop` wildcards apply too. */
export const UI_KEYS: Record<string, string[]> = {
@@ -272,9 +274,11 @@ export function slug(name: string): string {
);
}
/** Converts an IntelliJ `.theme.json` (and optionally its `.icls`) into OpenIDEA theme files. */
/** Converts an IntelliJ `.theme.json` (comments removed) and optionally its `.icls` into OpenIDEA theme files. */
export function convertIntellijTheme(themeJson: string, icls?: string, id?: string): ConvertedTheme {
const src = JSON.parse(stripJsonComments(themeJson)) as { [k: string]: Json };
const parsed = JSON.parse(themeJson) as Json;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Not an IntelliJ theme");
const src = parsed;
const name = typeof src.name === "string" ? src.name : "Imported Theme";
const named = new Map<string, string>();
if (src.colors && typeof src.colors === "object" && !Array.isArray(src.colors)) {
@@ -307,21 +311,104 @@ export function convertIntellijTheme(themeJson: string, icls?: string, id?: stri
};
}
/** `plugin.toml` for a converted theme. */
export function pluginManifest(c: ConvertedTheme, author: string, sourceName: string): string {
const q = (s: string) => JSON.stringify(s);
/** A TOML basic string: quotes, backslashes and control characters escaped, lone surrogates replaced. */
function tomlString(s: string): string {
let body = "";
for (const ch of s) {
const code = ch.codePointAt(0)!;
if (ch === '"' || ch === "\\") body += `\\${ch}`;
else if (code < 0x20 || code === 0x7f) body += `\\u${code.toString(16).padStart(4, "0")}`;
// `for…of` pairs surrogates, so one in this range is alone.
else if (code >= 0xd800 && code <= 0xdfff) body += "�";
else body += ch;
}
return `"${body}"`;
}
export interface ThemePackageInfo {
id: string;
name: string;
version: string;
description: string;
authors: string[];
}
/** `plugin.toml` for converted themes, each at `themes/<id>.theme.json` (+ `.scheme.json`). */
export function pluginManifest(info: ThemePackageInfo, themes: readonly ConvertedTheme[], comment?: string): string {
return [
`# Converted from ${sourceName} by scripts/theme/import-intellij-theme.ts.`,
`id = ${q(`theme-${c.theme.id}`)}`,
`name = ${q(`${c.theme.name} Theme`)}`,
`version = "1.0.0"`,
`description = ${q(`${c.theme.name}, converted from an IntelliJ theme.`)}`,
`authors = [${q(author)}]`,
`openidea-api = "0.1"`,
"",
"[[contributes.themes]]",
`path = ${q(`themes/${c.theme.id}.theme.json`)}`,
...(c.scheme ? [`editor-scheme = ${q(`themes/${c.theme.id}.scheme.json`)}`] : []),
...(comment ? [`# ${comment.replace(/[\r\n]+/g, " ")}`] : []),
`id = ${tomlString(info.id)}`,
`name = ${tomlString(info.name)}`,
`version = ${tomlString(info.version)}`,
`description = ${tomlString(info.description)}`,
`authors = [${info.authors.map(tomlString).join(", ")}]`,
`engines.openidea = "^1.0"`,
...themes.flatMap((c) => [
"",
"[[contributes.themes]]",
`path = ${tomlString(`themes/${c.theme.id}.theme.json`)}`,
...(c.scheme ? [`editor-scheme = ${tomlString(`themes/${c.theme.id}.scheme.json`)}`] : []),
]),
"",
].join("\n");
}
/** An IntelliJ theme plugin as the host read it (`IntellijThemeSource`). */
export interface IntellijThemeSource {
name: string;
pluginId: string | null;
version: string | null;
vendor: string | null;
themes: { id: string; theme: string; editorScheme: string | null }[];
}
export interface ThemePackage {
id: string;
name: string;
files: { path: string; text: string }[];
/** The package's themes: their id here and in the IntelliJ plugin (`sourceId`). */
themes: { id: string; sourceId: string; name: string }[];
}
/** `2.3` → `2.3.0`; IntelliJ plugin versions are not always semver. */
function semver(version: string | null): string {
const m = /^\s*v?(\d{1,9})(?:\.(\d{1,9}))?(?:\.(\d{1,9}))?/.exec(version ?? "");
return m ? `${Number(m[1])}.${Number(m[2] ?? 0)}.${Number(m[3] ?? 0)}` : "1.0.0";
}
/** An OpenIDEA theme plugin (`intellij.<plugin>`) with every theme of an IntelliJ theme plugin. */
export function intellijThemePackage(source: IntellijThemeSource): ThemePackage {
const ids = new Set<string>();
const converted = source.themes.map((t) => {
const c = convertIntellijTheme(t.theme, t.editorScheme ?? undefined);
let id = c.theme.id;
for (let n = 2; ids.has(id); n++) id = `${c.theme.id.slice(0, 44)}-${n}`;
ids.add(id);
c.theme.id = id;
return { sourceId: t.id, converted: c };
});
if (converted.length === 0) throw new Error("The plugin has no themes");
const themes = converted.map((c) => c.converted);
// Package metadata, not UI text: the plugin list shows it as written.
const description = `${source.name}, imported from an IntelliJ theme plugin.`;
const info: ThemePackageInfo = {
id: `intellij.${slug(source.pluginId ?? source.name)}`,
name: source.name,
version: semver(source.version),
description,
authors: source.vendor ? [source.vendor] : [],
};
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
return {
id: info.id,
name: info.name,
files: [
{ path: "plugin.toml", text: pluginManifest(info, themes) },
...themes.flatMap((c) => [
{ path: `themes/${c.theme.id}.theme.json`, text: json(c.theme) },
...(c.scheme ? [{ path: `themes/${c.theme.id}.scheme.json`, text: json(c.scheme) }] : []),
]),
],
themes: converted.map(({ sourceId, converted: c }) => ({ id: c.theme.id, sourceId, name: c.theme.name })),
};
}
+15
View File
@@ -5776,6 +5776,21 @@
"Unsigned": "未签名",
"Roll Back to {previousVersion}": "回滚到 {previousVersion}",
"Install Plugin from Package File…": "从插件包文件安装…",
"Import Theme from IntelliJ IDE…": "从 IntelliJ IDE 导入主题…",
"Import IntelliJ Theme from File…": "从文件导入 IntelliJ 主题…",
"Import Theme from IntelliJ IDE": "从 IntelliJ IDE 导入主题",
"Import IntelliJ Theme": "导入 IntelliJ 主题",
"IntelliJ theme plugin or theme file": "IntelliJ 主题插件或主题文件",
"Could not import the IntelliJ theme": "无法导入 IntelliJ 主题",
"Import": "导入",
"In use": "使用中",
"IntelliJ themes": "IntelliJ 主题",
"Looking for IntelliJ themes": "正在查找 IntelliJ 主题",
"Looking for IntelliJ themes…": "正在查找 IntelliJ 主题…",
"Theme plugins installed in the JetBrains IDEs on this computer. Importing a theme brings every theme of its plugin and applies the chosen one. Colours without an OpenIDEA counterpart keep the default theme colours.": "此计算机上 JetBrains IDE 中安装的主题插件。导入某个主题会一并导入其插件的全部主题,并应用所选主题。没有对应 OpenIDEA 颜色的项保留默认主题的颜色。",
"1 theme": "1 个主题",
"{count} themes": "{count} 个主题",
"No theme plugins found in JetBrains IDEs on this computer. Choose a theme plugin downloaded from the JetBrains Marketplace (.jar or .zip) or a .theme.json file instead.": "在此计算机上的 JetBrains IDE 中未找到主题插件。请改为选择从 JetBrains Marketplace 下载的主题插件(.jar 或 .zip)或 .theme.json 文件。",
"Marketplace index URL": "插件市场索引 URL",
"Refresh Marketplace": "刷新插件市场",
"Marketplace": "插件市场",
+55 -2
View File
@@ -8,6 +8,7 @@ import {
parsePluginThemeRef,
pluginThemeRef,
themeChoices,
themeGroups,
themeKind,
useContributedThemes,
} from "./themes";
@@ -19,6 +20,7 @@ import {
resolveThemeId,
themeSelectOptions,
} from "@/workbench/theme";
import { themeItems } from "@/workbench/quickSwitch";
import { validateSettings } from "@/features/settings/model/schema";
import { applyPluginTheme } from "@/features/plugins/bridge";
@@ -108,7 +110,10 @@ describe("plugin themes", () => {
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" });
expect(themeSelectOptions(themeChoices(), ref).slice(-2)).toEqual([
{ value: "plugin:acme.night", label: "Acme", section: true },
{ value: ref, label: "Night" },
]);
// Its tokens apply over the default of its kind.
expect(resolveThemeId(a, false)).toBe("dark");
@@ -116,7 +121,55 @@ describe("plugin themes", () => {
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" });
expect(themeSelectOptions(themeChoices(), ref).slice(-2)).toMatchObject([
{ value: "plugin:acme.night", label: "acme.night", section: true },
{ value: ref, label: "night", detail: "Plugin not enabled" },
]);
// Its plugin live but the theme gone (an update dropped it): listed under that plugin's heading.
const stay = addContributedTheme({
id: pluginThemeRef("acme.night", "dawn"),
label: "Dawn",
kind: "dark",
origin: "Acme",
});
addContributedTheme({ id: pluginThemeRef("zeta.blue", "blue"), label: "Blue", kind: "dark", origin: "Zeta" });
expect(
themeSelectOptions(themeChoices(), ref)
.slice(BUILTIN_THEMES.length)
.map((o) => (o.section ? `[${o.label}]` : o.label)),
).toEqual(["[Acme]", "Dawn", "night", "[Zeta]", "Blue"]);
stay();
});
it("are grouped by plugin: the built-in ones first, then each plugin's by name", () => {
const theme = (plugin: string, id: string, label: string, origin: string) =>
addContributedTheme({ id: pluginThemeRef(plugin, id), label, kind: "dark", origin });
theme("intellij.dracula", "dracula", "Dracula", "Dracula Theme");
theme("acme.night", "night", "Night", "Acme");
theme("intellij.dracula", "alucard", "Dracula Alucard", "Dracula Theme");
theme("acme.night", "dawn", "Dawn", "Acme");
const groups = themeGroups();
expect(groups.map((g) => [g.origin, g.themes.map((t) => t.label)])).toEqual([
[undefined, BUILTIN_THEMES.map((t) => t.label)],
["Acme", ["Dawn", "Night"]],
["Dracula Theme", ["Dracula", "Dracula Alucard"]],
]);
expect(themeChoices().map((t) => t.label)).toEqual(groups.flatMap((g) => g.themes.map((t) => t.label)));
// Menus draw a line between the groups, as IDEA's Theme list.
const items = themeItems("light");
const lines = items.flatMap((item, i) => (item.kind === "separator" ? [i] : []));
expect(lines).toEqual([BUILTIN_THEMES.length, BUILTIN_THEMES.length + 3]);
expect(items.find((i) => i.kind !== "separator" && "checked" in i && i.checked)).toMatchObject({
label: "Islands Light",
});
// Selects head each plugin's themes with its name.
expect(
themeSelectOptions(themeChoices())
.slice(BUILTIN_THEMES.length)
.map((o) => (o.section ? `[${o.label}]` : o.label)),
).toEqual(["[Acme]", "Dawn", "Night", "[Dracula Theme]", "Dracula", "Dracula Alucard"]);
});
it("parse only well-formed references", () => {
+49 -2
View File
@@ -77,11 +77,58 @@ export function addContributedTheme(theme: ThemeChoice): () => void {
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. */
/** Themes of one origin: the built-in ones, or one plugin's. */
export interface ThemeGroup {
/** The contributing plugin; none for the built-in themes. */
pluginId?: string;
/** The plugin's name. */
origin?: string;
themes: ThemeChoice[];
}
/** The selectable themes by origin: the built-in ones, then each plugin's by its name, themes by name. */
export function themeGroups(
contributed: readonly ThemeChoice[] = useContributedThemes.getState().themes,
): ThemeGroup[] {
const plugins = new Map<string, ThemeGroup>();
for (const theme of contributed) {
const pluginId = parsePluginThemeRef(theme.id)?.pluginId ?? "";
const group = plugins.get(pluginId) ?? { pluginId, origin: theme.origin, themes: [] };
plugins.set(pluginId, group);
group.themes.push(theme);
}
const name = (g: ThemeGroup) => g.origin ?? g.pluginId ?? "";
const groups = [...plugins.values()].sort(
(a, b) => name(a).localeCompare(name(b)) || (a.pluginId ?? "").localeCompare(b.pluginId ?? ""),
);
for (const group of groups) group.themes.sort((a, b) => a.label.localeCompare(b.label));
return [{ themes: [...BUILTIN_THEMES] }, ...groups];
}
/** Every theme that can be selected, in the order of `themeGroups`. */
export function themeChoices(
contributed: readonly ThemeChoice[] = useContributedThemes.getState().themes,
): ThemeChoice[] {
return [...BUILTIN_THEMES, ...[...contributed].sort((a, b) => a.label.localeCompare(b.label))];
return themeGroups(contributed).flatMap((g) => g.themes);
}
/**
* Shows a plugin theme in this window without selecting it, `null` none, or
* `"selected"` the one selected again. The plugins feature provides it.
*/
export type PluginThemePreview = (theme: PluginThemeRef | null | "selected") => void;
let pluginThemePreview: PluginThemePreview | null = null;
export function providePluginThemePreview(preview: PluginThemePreview): () => void {
pluginThemePreview = preview;
return () => {
if (pluginThemePreview === preview) pluginThemePreview = null;
};
}
export function previewPluginTheme(theme: PluginThemeRef | null | "selected"): void {
pluginThemePreview?.(theme);
}
/** Whether `id` can be applied now (a contributed theme's plugin may be off). */
+11 -3
View File
@@ -45,6 +45,8 @@ export type MenuItem =
checked?: boolean;
danger?: boolean;
run?: () => void;
/** The pointer or the keyboard made it the active item (not on opening the menu). */
onActive?: () => void;
submenu?: MenuItem[];
};
@@ -58,6 +60,7 @@ interface Resolved {
checked?: boolean;
danger?: boolean;
run?: () => void;
onActive?: () => void;
submenu?: MenuItem[];
}
@@ -132,6 +135,11 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
}, [active, openSub]);
useEffect(() => () => clearTimeout(hoverTimer.current), []);
const focusItem = (i: number) => {
setActive(i);
resolved[i]?.onActive?.();
};
const move = (delta: number, from = active) => {
const n = resolved.length;
if (!n) return;
@@ -139,7 +147,7 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
for (let step = 0; step < n; step++) {
i = (i + delta + n) % n;
if (selectable(resolved[i])) {
setActive(i);
focusItem(i);
return;
}
}
@@ -191,7 +199,7 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
const i = (active + s + n) % n;
const r = resolved[i]!;
if (selectable(r) && r.label.toLowerCase().startsWith(ch)) {
setActive(i);
focusItem(i);
break;
}
}
@@ -235,7 +243,7 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
)}
onPointerEnter={() => {
if (r.disabled) return;
setActive(i);
focusItem(i);
clearTimeout(hoverTimer.current);
hoverTimer.current = setTimeout(() => setOpenSub(hasSub ? i : null), hasSub ? 120 : 200);
}}
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { ContextMenuHost, hideContextMenu, MenuList } from "@/ui";
import { showQuickSwitch, THEME_PREVIEW_DELAY, themePreview } from "./quickSwitch";
import {
applyThemeAttributes,
currentThemeId,
STARTUP_THEME_KEY,
syncWindowBackground,
useThemeOverride,
} from "./theme";
const root = document.documentElement;
const shown = () => [root.dataset.theme, root.dataset.colorTheme ?? null];
beforeEach(() => {
vi.useFakeTimers();
useThemeOverride.setState({ theme: null });
applyThemeAttributes(currentThemeId());
});
afterEach(() => {
act(() => hideContextMenu());
vi.useRealTimers();
useThemeOverride.setState({ theme: null });
applyThemeAttributes(currentThemeId());
});
describe("menu items", () => {
it("report becoming active by pointer or keyboard, not on opening", () => {
const active: string[] = [];
const item = (label: string) => ({ label, onActive: () => active.push(label), run: () => {} });
render(<MenuList items={[item("One"), item("Two"), item("Three")]} onDone={() => {}} />);
expect(active).toEqual([]);
fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" });
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Three" }));
fireEvent.keyDown(screen.getByRole("menu"), { key: "o" });
expect(active).toEqual(["Two", "Three", "One"]);
});
});
describe("Quick Switch theme preview", () => {
it("shows the active theme after a moment, in this window only, and takes it back", () => {
syncWindowBackground();
const startup = localStorage.getItem(STARTUP_THEME_KEY);
const before = shown();
const preview = themePreview();
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY - 1);
expect(shown()).toEqual(before);
// Moving on restarts the delay: only the theme the user stays on shows.
preview.preview("one-light");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY - 1);
expect(shown()).toEqual(before);
vi.advanceTimersByTime(1);
expect(shown()).toEqual(["light", "one-light"]);
// Nothing is selected or saved, not even as the next start-up's theme.
expect(useThemeOverride.getState().theme).toBeNull();
expect(localStorage.getItem(STARTUP_THEME_KEY)).toBe(startup);
preview.restore();
expect(shown()).toEqual(before);
expect(localStorage.getItem(STARTUP_THEME_KEY)).toBe(startup);
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY);
preview.end();
expect(shown()).toEqual(before);
});
it("selects the chosen theme and keeps it when the menu closes", () => {
const preview = themePreview();
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY);
preview.choose("one-dark");
expect(useThemeOverride.getState().theme).toBe("one-dark");
expect(shown()).toEqual(["dark", "one-dark"]);
preview.end();
expect(shown()).toEqual(["dark", "one-dark"]);
});
it("previews from the Theme submenu and takes the preview back when the menu closes", async () => {
const before = shown();
render(<ContextMenuHost />);
act(() => showQuickSwitch({ x: 10, y: 10 }));
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
act(() => vi.advanceTimersByTime(200));
fireEvent.pointerEnter(screen.getByRole("menuitemcheckbox", { name: "Solarized Light" }));
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
expect(shown()).toEqual(["light", "solarized-light"]);
// Back on another entry: the selected theme again.
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Keymap" }));
expect(shown()).toEqual(before);
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
act(() => vi.advanceTimersByTime(200));
fireEvent.pointerEnter(screen.getByRole("menuitemcheckbox", { name: "Solarized Light" }));
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
act(() => hideContextMenu());
await act(async () => {});
expect(shown()).toEqual(before);
expect(useThemeOverride.getState().theme).toBeNull();
});
it("selects a theme clicked in the Theme submenu", async () => {
render(<ContextMenuHost />);
act(() => showQuickSwitch({ x: 10, y: 10 }));
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
act(() => vi.advanceTimersByTime(200));
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Solarized Dark" }));
await act(async () => {});
expect(useThemeOverride.getState().theme).toBe("solarized-dark");
expect(shown()).toEqual(["dark", "solarized-dark"]);
expect(screen.queryByRole("menu")).toBeNull();
});
});
+84 -17
View File
@@ -7,9 +7,9 @@ import { listSchemes } from "@/platform/colorScheme";
import { editorPrefs } from "@/platform/editorSettings";
import { PRESET_TITLES, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import type { MenuItem } from "@/ui";
import { themeChoices } from "@/platform/themes";
import { currentThemeChoice, setTheme } from "./theme";
import { showContextMenu, useContextMenuStore, type MenuItem } from "@/ui";
import { themeGroups, type ThemeChoiceId } from "@/platform/themes";
import { currentThemeChoice, previewTheme, setTheme } from "./theme";
async function setGlobal(key: string, value: unknown, what: string) {
if (!isTauri()) return;
@@ -22,15 +22,67 @@ async function setGlobal(key: string, value: unknown, what: string) {
export const selectKeymap = (id: string) => setGlobal("keymap", id, "keymap");
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),
}));
/** How long a theme stays under the pointer or keyboard before Quick Switch shows it (IDEA's delay). */
export const THEME_PREVIEW_DELAY = 500;
/**
* IDEA's instant theme switch (`ide.instant.theme.switch`): in Quick Switch
* the active theme shows after a moment, in this window only; choosing one
* selects it, and closing the menu without a choice brings the selected
* theme back.
*/
export interface ThemePreview {
preview(id: ThemeChoiceId): void;
/** Back to the selected theme (another Quick Switch entry became active). */
restore(): void;
choose(id: ThemeChoiceId): void;
/** The menu closed. */
end(): void;
}
export function themePreview(): ThemePreview {
let timer: ReturnType<typeof setTimeout> | undefined;
let shown: ThemeChoiceId | null = null;
let chosen = false;
const restore = () => {
clearTimeout(timer);
if (shown !== null && !chosen) previewTheme(null);
shown = null;
};
return {
preview(id) {
clearTimeout(timer);
timer = setTimeout(() => {
shown = id;
previewTheme(id);
}, THEME_PREVIEW_DELAY);
},
restore,
choose(id) {
clearTimeout(timer);
chosen = true;
// Shown right away; selecting it then changes nothing visible.
if (shown !== id) previewTheme(id);
void setTheme(id);
},
end: restore,
};
}
/** The built-in themes, then each plugin's, a separator between them (as IDEA's Theme list). */
export function themeItems(themeId: string = currentThemeChoice(), preview?: ThemePreview): MenuItem[] {
return themeGroups().flatMap<MenuItem>((group, i) => [
...(i > 0 ? [{ kind: "separator" as const }] : []),
...group.themes.map<MenuItem>((c) => ({
id: `theme.${c.id}`,
label: c.label,
// Plugin themes keep their own names.
verbatim: !!c.origin,
checked: c.id === themeId,
onActive: preview && (() => preview.preview(c.id)),
run: () => (preview ? preview.choose(c.id) : void setTheme(c.id)),
})),
]);
}
export function keymapItems(keymapId: string, customKeymaps: string[]): MenuItem[] {
@@ -78,17 +130,32 @@ export function keymapState(): { keymapId: string; customs: string[] } {
};
}
/** View › Quick Switch Scheme…: IDEA's popup of the switchable schemes. */
export function quickSwitchItems(): MenuItem[] {
/** View › Quick Switch Scheme…: IDEA's popup of the switchable schemes, its themes previewed. */
export function quickSwitchItems(preview?: ThemePreview): MenuItem[] {
const { keymapId, customs } = keymapState();
const restore = preview && (() => preview.restore());
return [
{
id: "qs.scheme",
label: "Editor Color Scheme",
onActive: restore,
submenu: colorSchemeItems(useProject.getState().settings?.editor?.colorScheme),
},
{ id: "qs.keymap", label: "Keymap", submenu: keymapItems(keymapId, customs) },
{ id: "qs.viewMode", label: "View Mode", submenu: VIEW_MODE_ITEMS },
{ id: "qs.theme", label: "Theme", submenu: themeItems() },
{ id: "qs.keymap", label: "Keymap", onActive: restore, submenu: keymapItems(keymapId, customs) },
{ id: "qs.viewMode", label: "View Mode", onActive: restore, submenu: VIEW_MODE_ITEMS },
{ id: "qs.theme", label: "Theme", submenu: themeItems(undefined, preview) },
];
}
/** Opens Quick Switch Scheme; a theme previewed there goes away with the menu unless chosen. */
export function showQuickSwitch(at: { x: number; y: number }) {
const preview = themePreview();
showContextMenu(at, quickSwitchItems(preview));
const menu = useContextMenuStore.getState().menu;
const unsubscribe = useContextMenuStore.subscribe((s) => {
if (s.menu === menu) return;
unsubscribe();
// A chosen item runs right after the menu closes: let it choose first.
queueMicrotask(() => preview.end());
});
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { isTauri } from "@/platform/ipc";
import { closeProject, confirmLeaveProject, openProject, useProject } from "@/platform/project";
import { runSearchFieldCommand, showContextMenu } from "@/ui";
import { showCustomizeToolbar } from "./MainToolbarDialog";
import { quickSwitchItems } from "./quickSwitch";
import { showQuickSwitch } from "./quickSwitch";
import { notify } from "./notificationStore";
import { NOTIFICATIONS_TOOL_WINDOW } from "./Notifications";
import { activateToolWindow } from "./toolwindows/store";
@@ -96,7 +96,7 @@ export function registerShellActions(): () => void {
title: "Quick Switch Scheme…",
category: "View",
keywords: ["theme", "color scheme", "keymap", "view mode"],
run: () => showContextMenu({ x: window.innerWidth / 2 - 120, y: 96 }, quickSwitchItems()),
run: () => showQuickSwitch({ x: window.innerWidth / 2 - 120, y: 96 }),
},
{
id: "toolbar.customize",
+48 -8
View File
@@ -6,8 +6,10 @@ import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import {
baseTheme,
isBuiltinTheme,
isThemeChoiceId,
parsePluginThemeRef,
previewPluginTheme,
themeChoices,
themeKind,
useContributedThemes,
@@ -181,7 +183,7 @@ export function rememberStartupTheme(root: HTMLElement = document.documentElemen
* for the next window's start-up.
*/
export function syncWindowBackground() {
rememberStartupTheme();
if (!previewing) rememberStartupTheme();
if (!isTauri()) return;
const color = windowBackgroundColor();
if (!color) return;
@@ -231,6 +233,28 @@ export async function setTheme(next: ThemeSetting) {
}
}
/** A previewed theme is not the next start-up's (`rememberStartupTheme`). */
let previewing = false;
/**
* Shows `id` in this window without selecting it (Quick Switch's instant
* theme switch); `null` shows the selected theme again. Nothing is saved and
* other windows keep theirs.
*/
export function previewTheme(id: ThemeChoiceId | null) {
previewing = id !== null;
if (id === null) {
previewPluginTheme("selected");
applyThemeAttributes(currentThemeId());
} else if (isBuiltinTheme(id)) {
previewPluginTheme(null);
applyThemeAttributes(id);
} else {
previewPluginTheme(id);
}
syncWindowBackground();
}
export async function toggleTheme() {
await setTheme(currentTheme() === "dark" ? "light" : "dark");
}
@@ -244,14 +268,30 @@ export function originDetail(t: { label: string; origin?: string }): string | un
* 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.
*/
/** The themes for a picker: each plugin's under a heading with its name (`choices` in `themeGroups` order). */
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 options: SelectOption<ThemeChoiceId>[] = [];
let group: string | undefined;
for (const c of choices) {
const pluginId = parsePluginThemeRef(c.id)?.pluginId;
if (pluginId && pluginId !== group)
options.push({ value: `plugin:${pluginId}`, label: c.origin ?? pluginId, section: true });
group = pluginId;
// Under its plugin's heading a theme needs no origin.
options.push({ value: c.id, label: c.label });
}
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" });
if (missing && !options.some((o) => o.value === selected)) {
// Under its own plugin's heading, not the last one listed.
const option = { value: selected as ThemeChoiceId, label: missing.themeId, detail: "Plugin not enabled" };
const heading = options.findIndex((o) => o.section && o.value === `plugin:${missing.pluginId}`);
if (heading < 0)
options.push({ value: `plugin:${missing.pluginId}`, label: missing.pluginId, section: true }, option);
else {
let end = heading + 1;
while (end < options.length && !options[end]!.section) end++;
options.splice(end, 0, option);
}
}
return options;
}