diff --git a/Cargo.lock b/Cargo.lock index 62c74e7e..a9bce378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4272,6 +4272,7 @@ dependencies = [ "ide-workspace", "ignore", "parking_lot", + "quick-xml", "regex-syntax", "reqwest", "rustix", diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index cdb17dcf..70c843e7 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -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"] } diff --git a/crates/ide-host/src/plugins/intellij.rs b/crates/ide-host/src/plugins/intellij.rs new file mode 100644 index 00000000..3ccc62fa --- /dev/null +++ b/crates/ide-host/src/plugins/intellij.rs @@ -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 ``, or the theme's for a loose file. + pub name: String, + pub plugin_id: Option, + pub version: Option, + pub vendor: Option, + pub themes: Vec, +} + +#[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, +} + +/// 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 { + 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 { + 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 { + 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 { + 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 { + 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; + +/// 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::(&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, 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 ReadSeek for T {} +type Jar = zip::ZipArchive>; + +fn open_jar(path: &Path) -> Result { + let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + let reader: Box = 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 (`/lib/*.jar`) +/// or an installed plugin's folder (`lib/*.jar`). +fn plugin_jars(path: &Path) -> Result, 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 = [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 = 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 { + 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 = Box::new(Cursor::new(bytes)); + if let Ok(jar) = zip::ZipArchive::new(reader) { + jars.push(jar); + } + } + jars +} + +fn entry_names(jar: &Jar) -> Vec { + 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`, `/lib/x.jar` or `/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>, 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>, 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: ``'s `id`, `name`, `version`, +/// `vendor`, and its `themeProvider`s (id, path in the jar). +#[derive(Debug, Default, PartialEq)] +struct Descriptor { + id: Option, + name: Option, + version: Option, + vendor: Option, + themes: Vec<(String, String)>, +} + +/// Reads `META-INF/plugin.xml` and the descriptors it includes (``), in any jar. +fn descriptor(jars: &mut [Jar]) -> Result { + 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 `` 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) -> Result<(), String> { + let mut reader = Reader::from_str(xml); + let mut stack: Vec = 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 . + 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//`, plugins in its `plugins/`. + Mac, + /// `%APPDATA%\\`, plugins in its `plugins\`. + Windows, + /// `$XDG_CONFIG_HOME//`, plugins in `$XDG_DATA_HOME//`. + 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) -> Vec { + 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 { + 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 { + let mut out = Vec::new(); + for ide in ides { + let active = active_theme_ids(&ide.config); + let mut plugins: Vec = 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#" + com.example.deepsea + Deep Sea & Friends + 2.3 + Someone + Dark themes]]> + com.intellij.modules.platform + + + + +"#; + + 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#""#; + + fn jar_bytes(entries: &[(&str, &[u8])]) -> Vec { + 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 { + 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(¬_zip, b"hello"); + assert!(read_source(¬_zip).unwrap_err().message.contains("not a jar or zip")); + + let other_plugin = tmp.path().join("tools.jar"); + let xml = "Tools"; + 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#"M"#; + 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#"Split + "#; + let part = r#" + Not this + "#; + let more = r#""#; + let unrelated = r#""#; + 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::>(), ["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::>(), + ["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::>(), ["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#" + + + + +"#, + ); + 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"T")]), + ); + write(&plugins.join("broken.jar"), b"not a jar"); + write( + &config.join("options/laf.xml"), + br#""#, + ); + 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()); + } +} diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index a9e045c1..4ab77fdb 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -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 { + 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> { diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 631bec5e..208da138 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -21,6 +21,7 @@ mod icon_patterns; mod icon_settings; mod icons; mod install; +pub mod intellij; mod market; mod points; mod providers; diff --git a/crates/ide-host/src/plugins/theme.rs b/crates/ide-host/src/plugins/theme.rs index e4f38ff5..7b4fd88b 100644 --- a/crates/ide-host/src/plugins/theme.rs +++ b/crates/ide-host/src/plugins/theme.rs @@ -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); diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index c9cac430..db3b18f9 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -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")] diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index b052cd72..f4d361f9 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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] diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index b11082a3..7f13e1ef 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -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.`, 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 diff --git a/plugins/README.md b/plugins/README.md index 5bc1d385..d90839c1 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 `/plugins///` diff --git a/scripts/theme/import-intellij-theme.ts b/scripts/theme/import-intellij-theme.ts index 4fdd0cf1..d294c1ce 100644 --- a/scripts/theme/import-intellij-theme.ts +++ b/scripts/theme/import-intellij-theme.ts @@ -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", diff --git a/scripts/theme/theme.test.ts b/scripts/theme/theme.test.ts index bf533329..4e340c60 100644 --- a/scripts/theme/theme.test.ts +++ b/scripts/theme/theme.test.ts @@ -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", () => { `; 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"); + }); }); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 20884ff9..ffae151c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -457,6 +457,9 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 3698057b..998c591a 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -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 { + 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> { + 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, +) -> IdeResult { + 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] diff --git a/src/bindings.ts b/src/bindings.ts index 5d09fc49..c78b7e45 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -987,6 +987,12 @@ export const commands = { pluginMarketDownload: (id: string) => typedError(__TAURI_INVOKE("plugin_market_download", { id })), /** Unpacks a package file (`.oiplugin` or `.zip`); returns the folder for the install page. */ pluginUnpackPackage: (path: string) => typedError(__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(__TAURI_INVOKE("plugin_read_intellij_theme", { path })), + /** Theme plugins installed in the JetBrains IDEs of this user, the ones in use first. */ + pluginInstalledIntellijThemes: () => typedError(__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(__TAURI_INVOKE("plugin_stage_package", { name, files })), /** Goes back to the version the last update of `id` replaced. */ pluginRollBack: (id: string) => typedError(__TAURI_INVOKE("plugin_roll_back", { id })), pluginSecrets: (pluginId: string) => typedError(__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 ``, 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, diff --git a/src/features/plugins/IntellijThemesDialog.tsx b/src/features/plugins/IntellijThemesDialog.tsx new file mode 100644 index 00000000..7924b639 --- /dev/null +++ b/src/features/plugins/IntellijThemesDialog.tsx @@ -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(); + 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(null); + const [error, setError] = useState(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 ( + run() }, + { label: "Cancel", role: "cancel" }, + { label: "Import", role: "default", disabled: !chosen, onClick: () => chosen && run(chosen) }, + ]} + > +
+

+ + 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. + +

+ {themes === null ? ( +
+ + Looking for IntelliJ themes… +
+ ) : ( + 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={ +
+ {error ?? ( + + 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. + + )} +
+ } + renderSection={(r) => + r.kind === "plugin" && ( +
+ {r.plugin} + {r.count === 1 ? tx("1 theme") : tx`${r.count} themes`} + {r.ide} +
+ ) + } + renderItem={(r) => + r.kind === "theme" && ( +
+ {r.theme.themeName} + {r.theme.active && {tx("In use")}} + {r.theme.dark ? tx("Dark") : tx("Light")} +
+ ) + } + /> + )} +
+
+ ); +} + +/** Overlay of the dialog while `showInstalledIntellijThemes` has it open. */ +export function IntellijThemesDialogHost() { + const open = useIntellijThemesDialog((s) => s.open); + return open ? : null; +} diff --git a/src/features/plugins/PluginsPage.module.css b/src/features/plugins/PluginsPage.module.css index f54100aa..1f4b0e8d 100644 --- a/src/features/plugins/PluginsPage.module.css +++ b/src/features/plugins/PluginsPage.module.css @@ -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; +} diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 662e7dc0..33a2065a 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -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) => { 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), + }), ); }; diff --git a/src/features/plugins/__tests__/intellijImport.test.tsx b/src/features/plugins/__tests__/intellijImport.test.tsx new file mode 100644 index 00000000..611f4e88 --- /dev/null +++ b/src/features/plugins/__tests__/intellijImport.test.tsx @@ -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[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; +let stage: Mock; +let installed: string[]; + +function backend(overrides: Partial = {}): 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(async () => SOURCE); + stage = vi.fn(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(); + 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(); + 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(); + }); +}); diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 8bafe2f6..ad701931 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -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( + <> + + + , + ); + 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(); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 4d79330e..8593c3a3 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -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; /** File picker for a package: an `.oiplugin`, a `.zip` or a folder's `plugin.toml`; `null` when cancelled. */ pickPackage(): Promise; + /** File picker for an IntelliJ theme plugin (`.jar`, `.zip`) or `.theme.json`; `null` when cancelled. */ + pickIntellijTheme(): Promise; + /** Reads the themes of an IntelliJ theme plugin or `.theme.json`. */ + readIntellijTheme(path: string): Promise; + /** Theme plugins installed in the JetBrains IDEs on this machine, the ones in use first. */ + installedIntellijThemes(): Promise; + /** Writes a generated package (`name`: its id); the folder to install from. */ + stagePackage(name: string, files: { path: string; text: string }[]): Promise; /** Content of a plugin view (`doc`: the document of a custom editor). */ renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise; viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise; @@ -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)` }, diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index 9e4d06af..8dc89b07 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -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"; diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index 3991847b..9a90df93 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -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..` * (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", diff --git a/src/features/plugins/intellijImport.ts b/src/features/plugins/intellijImport.ts new file mode 100644 index 00000000..3ba281f7 --- /dev/null +++ b/src/features/plugins/intellijImport.ts @@ -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 { + 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(() => ({ 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 }); +} diff --git a/scripts/theme/intellij.ts b/src/features/plugins/intellijTheme.ts similarity index 76% rename from scripts/theme/intellij.ts rename to src/features/plugins/intellijTheme.ts index 407f2ca0..b1e81364 100644 --- a/scripts/theme/intellij.ts +++ b/src/features/plugins/intellijTheme.ts @@ -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 = { @@ -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(); 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/.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.`) with every theme of an IntelliJ theme plugin. */ +export function intellijThemePackage(source: IntellijThemeSource): ThemePackage { + const ids = new Set(); + 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 })), + }; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d9b32f77..8bf614a0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "插件市场", diff --git a/src/platform/themes.test.ts b/src/platform/themes.test.ts index d8df8716..d8d332fe 100644 --- a/src/platform/themes.test.ts +++ b/src/platform/themes.test.ts @@ -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", () => { diff --git a/src/platform/themes.ts b/src/platform/themes.ts index 3454f893..4fd4ff74 100644 --- a/src/platform/themes.ts +++ b/src/platform/themes.ts @@ -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(); + 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). */ diff --git a/src/ui/Menu.tsx b/src/ui/Menu.tsx index be637b6b..74ef0dcc 100644 --- a/src/ui/Menu.tsx +++ b/src/ui/Menu.tsx @@ -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); }} diff --git a/src/workbench/quickSwitch.test.tsx b/src/workbench/quickSwitch.test.tsx new file mode 100644 index 00000000..9d5c7d10 --- /dev/null +++ b/src/workbench/quickSwitch.test.tsx @@ -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( {}} />); + 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(); + 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(); + 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(); + }); +}); diff --git a/src/workbench/quickSwitch.ts b/src/workbench/quickSwitch.ts index fb3d5039..e45266b2 100644 --- a/src/workbench/quickSwitch.ts +++ b/src/workbench/quickSwitch.ts @@ -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((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 | 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((group, i) => [ + ...(i > 0 ? [{ kind: "separator" as const }] : []), + ...group.themes.map((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()); + }); +} diff --git a/src/workbench/shellActions.ts b/src/workbench/shellActions.ts index 8ef0762d..f12a5c77 100644 --- a/src/workbench/shellActions.ts +++ b/src/workbench/shellActions.ts @@ -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", diff --git a/src/workbench/theme.ts b/src/workbench/theme.ts index 44e73c78..e4559ad3 100644 --- a/src/workbench/theme.ts +++ b/src/workbench/theme.ts @@ -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[] { - const options: SelectOption[] = choices.map((c) => ({ - value: c.id, - label: c.label, - detail: originDetail(c), - })); + const options: SelectOption[] = []; + 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; }