Compare commits

...
Author SHA1 Message Date
l0ng-ai b396ae6ab2 fix(run): import JetBrains Spring Boot and Application configurations
The JetBrains importer skipped SpringBootApplicationConfigurationType and Application as unsupported types. Both now map to the spring-boot / application forms' command line with their module, working directory and environment; options without a field are listed as left out.
2026-11-05 16:10:03 +08:00
l0ng-ai 26e3ecc3a0 fix(project): open a window's project once when opens race (#1503)
Two opens of one window could each find no current project and build a full project (index, language servers, watchers); the second replaced the first without closing it, leaving two jdtls on one workspace. open_project and open_light now hold a per-window lock from the check to the insert.
2026-11-05 16:05:54 +08:00
Lee Dong Geon 456b329d73 feat(themes): preview themes from the gear menu and Settings, and import theme plugins from every JetBrains IDE (#1498)
The main toolbar gear's Theme submenu and Settings › Appearance › Theme preview the theme under the pointer after the same delay as Quick Switch Scheme, and closing without a choice brings the selected theme back. Choosing a theme while it was previewed left the preview flag on, so the start-up theme cache kept the previous theme; `setTheme` now ends the preview.

Import Theme from JetBrains IDE… lists one row per theme plugin and installs it without switching the theme, like any theme plugin install. It reads only the newest settings folder of each product, so folders left behind by upgrades don't list their plugins again.

Co-authored-by: ldg030201 <[email protected]>
2026-11-05 15:57:04 +08:00
25 changed files with 511 additions and 175 deletions

No files matched your search

+10
View File
@@ -86,6 +86,8 @@ pub struct Host {
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
/// One open at a time per window (see [`Host::opening`]).
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
}
/// How long exit waits for project shutdown before giving up.
@@ -135,6 +137,7 @@ impl Host {
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
open_locks: Default::default(),
};
host.apply_proxy();
host
@@ -401,6 +404,13 @@ impl Host {
self.requests.cancel(id);
}
/// The lock an open of `window` holds from its check for the current project to its
/// insert: two opens racing (a double click, a launch path and a restore) would each start
/// an index, language servers and watchers, and the second would replace the first unclosed.
pub(crate) fn opening(&self, window: &str) -> Arc<parking_lot::Mutex<()>> {
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
}
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
self.projects.write().insert(window.to_owned(), project);
}
+11 -1
View File
@@ -422,7 +422,8 @@ struct IdeDir {
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
/// per product: its newest version's (an upgrade leaves the older ones behind).
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
@@ -462,6 +463,8 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
let mut products = std::collections::HashSet::new();
out.retain(|ide| products.insert(ide.product.clone()));
out
}
@@ -784,6 +787,13 @@ mod tests {
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
+4
View File
@@ -77,6 +77,8 @@ impl Host {
lap = std::time::Instant::now();
};
let root = dunce::canonicalize(root)?;
let opening = self.opening(window);
let _opening = opening.lock();
// Reject a damaged manifest before replacing the currently open project.
ide_workspace::workspace::Workspace::load(&root)?;
// The window reloaded (or asked again) for the project it already has: keep
@@ -569,6 +571,8 @@ impl Host {
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
}
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
let opening = self.opening(window);
let _opening = opening.lock();
if let Ok(current) = self.project(window)
&& current.light
{
+46
View File
@@ -0,0 +1,46 @@
//! Opening the project a window is already opening reuses it: racing opens (a double
//! click, a launch path and a restore) must not start a second index and language servers.
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use std::sync::{Arc, Barrier};
#[test]
fn racing_opens_of_one_window_share_one_project() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let _guard = runtime.enter();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let start = Arc::new(Barrier::new(4));
let opens: Vec<_> = (0..4)
.map(|_| {
let (host, root, start) = (host.clone(), root.clone(), start.clone());
std::thread::spawn(move || {
start.wait();
host.open_project("main", &root).unwrap()
})
})
.collect();
let projects: Vec<_> = opens.into_iter().map(|t| t.join().unwrap()).collect();
let current = host.project("main").unwrap();
for project in &projects {
assert!(Arc::ptr_eq(project, &current), "every open returns the window's one project");
}
host.close_project("main");
}
+105 -2
View File
@@ -3,8 +3,9 @@
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
//! than one place is taken from the first, in that order.
//!
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
//! compound configurations become the matching kinds; other types are skipped with their type.
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
//! Application, Spring Boot and compound configurations become the matching kinds; other types
//! are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
//! path variable skips the configuration.
@@ -162,6 +163,8 @@ const VITEST: &str = "JavaScriptTestRunnerVitest";
const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
const APPLICATION: &str = "Application";
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
type Res<T> = Result<T, SkipReason>;
@@ -329,6 +332,8 @@ impl Cx<'_> {
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
COMPOUND => {
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
config(name, "compound", String::new(), Vec::new(), None)
@@ -695,6 +700,54 @@ impl Cx<'_> {
Ok(rc)
}
// ------------------------------------------------------------- Java
/// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
/// them: `java <vm options> -cp ${classpath} <main> [--spring.profiles.active=…] <arguments>`
/// with the module's runtime classpath.
fn java_app(&self, name: &str, c: &El, spring: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
let main = if spring { "SPRING_BOOT_MAIN_CLASS" } else { "MAIN_CLASS_NAME" };
let Some(main) = self.text(c.option(main))? else { return Err(SkipReason::NothingToRun) };
let (_, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let mut args = self.words(c.option("VM_PARAMETERS"))?;
args.extend(["-cp".to_owned(), "${classpath}".to_owned(), main]);
if spring && let Some(profiles) = self.text(c.option("ACTIVE_PROFILES"))? {
args.push(format!("--spring.profiles.active={profiles}"));
}
args.extend(self.words(c.option("PROGRAM_PARAMETERS"))?);
let mut rc =
config(name, if spring { "spring-boot" } else { "application" }, "java".into(), args, Some("java-debug"));
rc.cwd = cwd.map(Into::into);
self.envs(c, &mut rc);
if let Some(module) = c.child("module").and_then(|m| m.attr("name")).filter(|m| !m.is_empty()) {
match module_dir(self.root, module) {
Some(dir) => rc.extras.module = Some(dir),
None => ignored.push(format!("module {module}")),
}
}
// Options the forms have no field for.
if c.flag("ALTERNATIVE_JRE_PATH_ENABLED") {
ignored.push("JRE".into());
}
if c.flag("INCLUDE_PROVIDED_SCOPE") {
ignored.push("provided scope".into());
}
if c.child("classpathModifications").is_some_and(|m| !m.children.is_empty()) {
ignored.push("classpath modifications".into());
}
if spring {
for (option, label) in [("HIDE_BANNER", "hide banner"), ("DEBUG_MODE", "debug output")] {
if c.flag(option) {
ignored.push(label.into());
}
}
if c.child("additionalParameters").is_some_and(|p| !p.children.is_empty()) {
ignored.push("override configuration properties".into());
}
}
Ok(rc)
}
// ------------------------------------------------------------- Shell
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
@@ -835,6 +888,56 @@ fn module_count(root: &Path) -> usize {
.sum()
}
/// The folder (relative, `""` for the project's own) of IDEA module `name`: a Maven module is
/// named after its `artifactId` (usually its folder), a Gradle one `<project>.<path>.<source set>`.
fn module_dir(root: &Path, name: &str) -> Option<String> {
let folder = |dir: &Path| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let exact = |dir: &Path| folder(dir) == name || pom_artifact(dir).is_some_and(|a| a == name);
if let Some(dir) = find_module(root, &exact) {
return Some(dir);
}
let gradle = name.strip_suffix(".main").or_else(|| name.strip_suffix(".test")).unwrap_or(name);
let last = gradle.rsplit('.').next().filter(|last| *last != name)?;
find_module(root, &|dir: &Path| folder(dir) == last)
}
/// The shallowest build folder under `root` (itself included) that `names` accepts.
fn find_module(root: &Path, names: &dyn Fn(&Path) -> bool) -> Option<String> {
const BUILDS: [&str; 3] = ["pom.xml", "build.gradle", "build.gradle.kts"];
let mut level = vec![root.to_path_buf()];
for _ in 0..5 {
if let Some(found) = level.iter().find(|d| BUILDS.iter().any(|b| d.join(b).is_file()) && names(d)) {
let rel = found.strip_prefix(root).ok()?;
return Some(rel.to_string_lossy().replace('\\', "/"));
}
let mut next: Vec<PathBuf> = level
.iter()
.filter_map(|d| std::fs::read_dir(d).ok())
.flat_map(|entries| entries.flatten().map(|e| e.path()))
.filter(|p| p.is_dir())
.filter(|p| {
let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
!n.starts_with('.') && !["node_modules", "target", "build", "out", "src"].contains(&n.as_str())
})
.collect();
next.sort();
level = next;
}
None
}
/// The `artifactId` of the module's own `pom.xml` (not its parent's).
fn pom_artifact(dir: &Path) -> Option<String> {
let pom = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
let own = match (pom.find("<parent>"), pom.find("</parent>")) {
(Some(start), Some(end)) if start < end => format!("{}{}", &pom[..start], &pom[end..]),
_ => pom,
};
let start = own.find("<artifactId>")? + "<artifactId>".len();
let end = start + own[start..].find("</artifactId>")?;
Some(own[start..end].trim().to_owned())
}
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
/// the project) becomes its module relative to the working directory; a module stays as it is.
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
+93 -8
View File
@@ -239,8 +239,8 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
<method v="2" />
</configuration>
<configuration name="Main" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="shop.Main" />
<configuration name="Main" type="JUnit" factoryName="JUnit">
<option name="MAIN_CLASS_NAME" value="shop.MainTest" />
<method v="2" />
</configuration>"#
)),
@@ -272,7 +272,7 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
let dev = get(&r, "dev");
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
@@ -526,14 +526,11 @@ fn sources_are_offered_when_their_files_exist() {
),
);
assert!(available(dir.path()).is_empty());
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="JUnit" />"#));
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
write(dir.path(), ".vscode/launch.json", "{}");
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
assert_eq!(
reason(&import_from(dir.path(), &[]), "App"),
&SkipReason::UnsupportedType { type_: "Application".into() }
);
assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
}
#[test]
@@ -702,3 +699,91 @@ fn flags_defaults_inherited_variables_and_nested_django_tests() {
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
}
#[test]
fn java_applications_and_spring_boot_keep_their_module() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
write(&root, "pom.xml", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
write(
&root,
"example-service/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>example-service</artifactId></project>",
);
// IDEA names a Maven module after its artifactId, which needn't be its folder.
write(
&root,
"services/api/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>api-service</artifactId></project>",
);
write(
&root,
".idea/workspace.xml",
&workspace(
r#" <configuration name="ExampleApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.ExampleApplication" />
<option name="ACTIVE_PROFILES" value="dev,local" />
<option name="VM_PARAMETERS" value="-Xmx512m -Dfile.encoding=UTF-8" />
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/example-service" />
<option name="HIDE_BANNER" value="true" />
<envs>
<env name="TZ" value="UTC" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="Api" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="com.example.api.Main" />
<module name="api-service" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="17" />
<method v="2" />
</configuration>
<configuration name="Gone" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="Gone" />
<module name="removed-module" />
</configuration>
<configuration name="NoMain" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
</configuration>"#,
),
);
let r = import_from(&root, &[]);
let boot = get(&r, "ExampleApplication");
assert_eq!(boot.kind, "spring-boot");
assert_eq!(boot.command, "java");
assert_eq!(
boot.args,
s(&[
"-Xmx512m",
"-Dfile.encoding=UTF-8",
"-cp",
"${classpath}",
"com.example.ExampleApplication",
"--spring.profiles.active=dev,local",
"--server.port=8081",
])
);
assert_eq!(boot.extras.module.as_deref(), Some("example-service"));
assert_eq!(cwd(boot), Some(PathBuf::from("example-service")));
assert_eq!(boot.env.get("TZ").map(String::as_str), Some("UTC"));
assert_eq!(boot.debug_adapter.as_deref(), Some("java-debug"));
assert_eq!(ignored(&r, "ExampleApplication"), ["before launch", "hide banner"]);
let api = get(&r, "Api");
assert_eq!(api.kind, "application");
assert_eq!(api.args, s(&["-cp", "${classpath}", "com.example.api.Main"]));
assert_eq!(api.extras.module.as_deref(), Some("services/api"));
assert_eq!(ignored(&r, "Api"), ["JRE"]);
// A module that isn't here any more: the form finds it by the main class.
assert_eq!(get(&r, "Gone").extras.module, None);
assert_eq!(ignored(&r, "Gone"), ["module removed-module"]);
assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
}
+8 -7
View File
@@ -130,14 +130,15 @@ install it from disk.
Only port themes whose licence allows redistribution, keep their licence file,
and credit the original authors in `authors` and `description`.
### From IntelliJ IDEA (automatic)
### From JetBrains IDEs (automatic)
To use a theme you already have in IntelliJ, open **Settings › Plugins**, gear
To use a theme you already have in a JetBrains IDE, 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`
- **Import Theme from JetBrains IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, one row per plugin of
each IDE, the plugins of the themes the IDEs use first. It reads the settings
folder of each IDE's newest version: `~/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
@@ -146,8 +147,8 @@ menu:
The whole plugin becomes one OpenIDEA theme plugin, `intellij.<plugin id>`, with
each of its themes and their editor schemes; it is installed through the usual
install page and the chosen theme (otherwise the first) is applied. Importing
the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
install page, and the theme in use stays as it is: choose one of its themes in
**Settings › Appearance › Theme**. Importing the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
`plugins.importInstalledIntellijTheme`). The conversion is the one described
below.
+46
View File
@@ -186,3 +186,49 @@ test("quick theme menu offers all four palettes and applies their kind", async (
await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind);
}
});
test("gear › Theme previews the theme under the pointer and keeps the one chosen", async ({ workbench: page }) => {
const html = page.locator("html");
const gear = page.getByRole("button", { name: "IDE and Project Settings", exact: true });
const theme = (label: string) => page.getByRole("menuitemcheckbox", { name: label, exact: true });
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("Solarized Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
// A plugin's theme previews too.
await theme("Harbor Night").hover();
await expect(html).toHaveAttribute("data-plugin-theme", /harbor-night$/);
await expect(html).not.toHaveAttribute("data-color-theme");
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(html).not.toHaveAttribute("data-plugin-theme");
await expect(html).toHaveAttribute("data-theme", "light");
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("One Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await theme("One Dark").click();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
// Chosen, not previewed: the next start-up opens with it.
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme") ?? "{}").colorTheme))
.toBe("one-dark");
});
test("Settings › Appearance › Theme previews the highlighted theme until Cancel", async ({ workbench: page }) => {
const html = page.locator("html");
const dialog = await settings(page);
await page.getByRole("button", { name: "Theme", exact: true }).click();
await page.getByRole("option", { name: "Solarized Dark", exact: true }).hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
await page.keyboard.press("Escape");
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
// Picked: it shows until Apply or Cancel.
await choose(page, "Theme", "One Dark");
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
});
+1 -1
View File
@@ -769,7 +769,7 @@ granted, and starts the approved program.
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; 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
from JetBrains IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
-5
View File
@@ -220,11 +220,6 @@ describe("IntelliJ theme import", () => {
],
});
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",
+47 -51
View File
@@ -1,12 +1,11 @@
/**
* 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 Theme from JetBrains IDE: the theme plugins installed in the JetBrains
* IDEs (and Android Studio) on this machine, one row per plugin of each IDE, the
* plugins of the themes in use first. Importing installs the plugin with all its
* themes, as installing it in the IDE does (`importIntellijTheme`); which theme
* to use stays a choice in Settings › Appearance. 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";
@@ -15,28 +14,30 @@ import { closeInstalledIntellijThemes, importIntellijTheme, useIntellijThemesDia
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 };
interface PluginRow {
key: string;
plugin: string;
ide: string;
path: string;
themes: InstalledIntellijTheme[];
}
/** A heading per plugin (of an IDE) with its themes under it, in the order found (the one in use first). */
function themeRows(themes: readonly InstalledIntellijTheme[]): Row[] {
const plugins = new Map<string, InstalledIntellijTheme[]>();
/** A row per plugin (of an IDE), in the order found (the one in use first). */
function pluginRows(themes: readonly InstalledIntellijTheme[]): PluginRow[] {
const plugins = new Map<string, PluginRow>();
for (const t of themes) {
const key = `${t.ide}\n${t.path}`;
plugins.set(key, [...(plugins.get(key) ?? []), t]);
const row = plugins.get(key) ?? { key, plugin: t.plugin, ide: t.ide, path: t.path, themes: [] };
plugins.set(key, row);
row.themes.push(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 })),
]);
return [...plugins.values()];
}
function IntellijThemesDialog() {
const [themes, setThemes] = useState<InstalledIntellijTheme[] | null>(null);
const [error, setError] = useState<string | null>(null);
// The first theme, under the first heading.
const [active, setActive] = useState(1);
const [active, setActive] = useState(0);
useEffect(() => {
let live = true;
pluginsBackend()
@@ -54,50 +55,48 @@ function IntellijThemesDialog() {
};
}, []);
const run = (theme?: InstalledIntellijTheme) => {
const run = (path?: string) => {
const { onImported } = useIntellijThemesDialog.getState();
closeInstalledIntellijThemes();
void importIntellijTheme(theme?.path, theme?.themeId).then((id) => id && onImported?.(id));
void importIntellijTheme(path).then((id) => id && onImported?.(id));
};
const rows = themeRows(themes ?? []);
const row = rows[active];
const chosen = row?.kind === "theme" ? row.theme : undefined;
const rows = pluginRows(themes ?? []);
const chosen = rows[active];
return (
<Dialog
open
title="Import Theme from IntelliJ IDE"
title="Import Theme from JetBrains IDE"
width={640}
onClose={closeInstalledIntellijThemes}
buttons={[
{ label: "Choose File…", role: "extra", onClick: () => run() },
{ label: "Cancel", role: "cancel" },
{ label: "Import", role: "default", disabled: !chosen, onClick: () => chosen && run(chosen) },
{ label: "Import", role: "default", disabled: !chosen, onClick: () => chosen && run(chosen.path) },
]}
>
<div className={styles.intellijThemes}>
<p className={styles.muted}>
<T>
Theme plugins installed in the JetBrains IDEs on this computer. Importing a theme brings every theme of its
plugin and applies the chosen one. Colours without an OpenIDEA counterpart keep the default theme colours.
Theme plugins installed in the JetBrains IDEs on this computer. Importing one installs it with all its
themes; choose the theme to use in Settings › Appearance. Colours without an OpenIDEA counterpart keep the
default theme colours.
</T>
</p>
{themes === null ? (
<div className={styles.intellijThemesStatus}>
<Spinner size={14} label="Looking for IntelliJ themes" />
<T>Looking for IntelliJ themes…</T>
<Spinner size={14} label="Looking for JetBrains themes" />
<T>Looking for JetBrains themes…</T>
</div>
) : (
<List
items={rows}
getKey={(r) => r.key}
isSection={(r) => r.kind === "plugin"}
activeIndex={chosen ? active : -1}
onActiveChange={setActive}
rowHeight={28}
sectionHeight={30}
rowHeight={44}
className={styles.intellijThemesList}
aria-label={tx("IntelliJ themes")}
aria-label={tx("JetBrains theme plugins")}
empty={
<div className={styles.intellijThemesStatus} role="note">
{error ?? (
@@ -108,24 +107,21 @@ function IntellijThemesDialog() {
)}
</div>
}
renderSection={(r) =>
r.kind === "plugin" && (
<div className={clsx(styles.intellijPlugin, !r.first && styles.intellijPluginNext)}>
<span className={styles.intellijPluginName}>{r.plugin}</span>
<span>{r.count === 1 ? tx("1 theme") : tx`${r.count} themes`}</span>
<span className={styles.intellijPluginIde}>{r.ide}</span>
renderItem={(r) => {
const count = r.themes.length;
return (
<div className={styles.intellijPlugin}>
<div className={styles.intellijPluginHead}>
<span className={styles.itemMain}>{r.plugin}</span>
{r.themes.some((t) => t.active) && <Badge tone="accent">{tx("In use")}</Badge>}
<span className={styles.intellijPluginIde}>
{count === 1 ? tx("1 theme") : tx`${count} themes`} · {r.ide}
</span>
</div>
<span className={styles.intellijPluginThemes}>{r.themes.map((t) => t.themeName).join(", ")}</span>
</div>
)
}
renderItem={(r) =>
r.kind === "theme" && (
<div className={styles.intellijTheme}>
<span className={styles.itemMain}>{r.theme.themeName}</span>
{r.theme.active && <Badge tone="accent">{tx("In use")}</Badge>}
<span className={styles.muted}>{r.theme.dark ? tx("Dark") : tx("Light")}</span>
</div>
)
}
);
}}
/>
)}
</div>
+13 -20
View File
@@ -407,37 +407,30 @@
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;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: 0;
}
/* A line above each plugin but the first, as in IDEA's Theme list. */
.intellijPluginNext {
border-top: 1px solid var(--border);
.intellijPluginHead {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.intellijPluginName {
.intellijPluginThemes {
overflow: hidden;
color: var(--fg-secondary);
font-weight: 600;
color: var(--fg-muted);
font-size: var(--fs-small);
text-overflow: ellipsis;
white-space: nowrap;
}
.intellijPluginIde {
flex-shrink: 0;
margin-left: auto;
color: var(--fg-muted);
font-size: var(--fs-small);
}
+1 -1
View File
@@ -569,7 +569,7 @@ function gearMenu(plugins: readonly PluginInfo[], updates: number, actions: Gear
...(actions.canImport
? [
{ kind: "separator" as const },
{ label: "Import Theme from IntelliJ IDE…", run: actions.importInstalledIntellij },
{ label: "Import Theme from JetBrains IDE…", run: actions.importInstalledIntellij },
{ label: "Import IntelliJ Theme from File…", run: actions.importIntellijFile },
]
: []),
@@ -41,6 +41,15 @@ const INSTALLED: InstalledIntellijTheme[] = [
dark: true,
active: false,
},
{
ide: "DataGrip 2026.2",
plugin: "Night Owl",
path: "/dg/plugins/NightOwl",
themeId: "owl-id",
themeName: "Night Owl",
dark: true,
active: false,
},
];
let notes: Note[];
@@ -100,8 +109,8 @@ afterEach(() => {
});
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);
it("installs the converted plugin through the install page and leaves the theme in use as it is", async () => {
expect(await importIntellijTheme("/downloads/DeepSea.zip")).toBe(ID);
expect(read).toHaveBeenCalledWith("/downloads/DeepSea.zip");
const [name, files] = stage.mock.calls[0]!;
expect(name).toBe(ID);
@@ -111,14 +120,11 @@ describe("IntelliJ theme import", () => {
"themes/shallow.theme.json",
]);
expect(installed).toEqual(["/app/plugins/.downloads/generated"]);
expect(useThemeOverride.getState().theme).toBe(`plugin:${ID}/shallow`);
// Which of its themes to use is the user's choice.
expect(useThemeOverride.getState().theme).toBeNull();
});
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 });
it("returns nothing when the install is declined", async () => {
setConsentPrompt(async () => null);
expect(await importIntellijTheme("/downloads/DeepSea.jar")).toBeNull();
expect(useThemeOverride.getState().theme).toBeNull();
@@ -152,33 +158,33 @@ describe("IntelliJ theme import", () => {
});
});
describe("Import Theme from IntelliJ IDE", () => {
it("lists the installed themes by plugin, the one in use first, and imports the chosen one", async () => {
describe("Import Theme from JetBrains IDE", () => {
it("lists one row per theme plugin, the one in use first, and imports the chosen plugin", async () => {
const user = userEvent.setup();
const onImported = vi.fn();
render(<IntellijThemesDialogHost />);
act(() => showInstalledIntellijThemes(onImported));
const list = await screen.findByRole("listbox", { name: "IntelliJ themes" });
const list = await screen.findByRole("listbox", { name: "JetBrains theme plugins" });
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");
// A plugin brings all its themes: they are listed in its row, not offered one by one.
expect(options[0]).toHaveTextContent("Deep Sea Themes");
expect(options[0]).toHaveTextContent("In use");
expect(options[1]).toHaveTextContent("Deep Sea");
expect(options[0]).toHaveTextContent("2 themes · IntelliJ IDEA 2025.2");
expect(options[0]).toHaveTextContent("Shallow, Deep Sea");
expect(options[1]).toHaveTextContent("Night Owl");
expect(options[1]).toHaveTextContent("1 theme · DataGrip 2026.2");
expect(options[1]).not.toHaveTextContent("In use");
await user.click(screen.getByText("Deep Sea"));
await user.click(within(list).getByText("1 theme · DataGrip 2026.2"));
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();
expect(read).toHaveBeenCalledWith("/dg/plugins/NightOwl");
expect(useThemeOverride.getState().theme).toBeNull();
expect(screen.queryByRole("listbox", { name: "JetBrains theme plugins" })).toBeNull();
});
it("offers a file when no IDE has theme plugins", async () => {
@@ -517,7 +517,7 @@ describe("PluginsPage", () => {
);
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) =>
const found = ["Import Theme from JetBrains IDE…", "Import IntelliJ Theme from File…"].filter((name) =>
screen.queryByRole("menuitem", { name }),
);
act(() => hideContextMenu());
+11 -2
View File
@@ -113,10 +113,19 @@ export function activate(): () => void {
},
{
id: "plugins.importInstalledIntellijTheme",
title: "Import Theme from IntelliJ IDE…",
title: "Import Theme from JetBrains IDE…",
category: "Plugins",
icon: "plugin",
keywords: ["intellij", "jetbrains", "theme", "color scheme", "migrate"],
keywords: [
"intellij",
"jetbrains",
"pycharm",
"webstorm",
"android studio",
"theme",
"color scheme",
"migrate",
],
when: "projectOpen",
run: () => showInstalledIntellijThemes(),
},
+8 -16
View File
@@ -2,30 +2,26 @@
* 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.
* here (`intellijTheme.ts`), installed through the usual install page. Its
* themes join the others; which one to use is chosen in Settings › Appearance.
*/
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 { intellijThemePackage } 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.
* Imports the IntelliJ theme plugin at `path` (asks for a file without one).
* Returns the installed plugin's id; the theme in use stays as it is.
*/
export async function importIntellijTheme(path?: string, themeId?: string): Promise<string | null> {
export async function importIntellijTheme(path?: string): Promise<string | null> {
const backend = pluginsBackend();
let pkg: ThemePackage;
let dir: string;
try {
const picked = path ?? (await backend.pickIntellijTheme());
if (!picked) return null;
pkg = intellijThemePackage(await backend.readIntellijTheme(picked));
const pkg = intellijThemePackage(await backend.readIntellijTheme(picked));
dir = await backend.stagePackage(pkg.id, pkg.files);
} catch (e) {
notifications().notify({
@@ -36,11 +32,7 @@ export async function importIntellijTheme(path?: string, themeId?: string): Prom
});
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;
return installFromDisk(dir);
}
interface DialogState {
+3 -7
View File
@@ -384,8 +384,6 @@ 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. */
@@ -397,16 +395,15 @@ function semver(version: string | null): string {
/** An OpenIDEA theme plugin (`intellij.<plugin>`) with every theme of an IntelliJ theme plugin. */
export function intellijThemePackage(source: IntellijThemeSource): ThemePackage {
const ids = new Set<string>();
const converted = source.themes.map((t) => {
const themes = 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 };
return c;
});
if (converted.length === 0) throw new Error("The plugin has no themes");
const themes = converted.map((c) => c.converted);
if (themes.length === 0) throw new Error("The plugin has no themes");
// 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 = {
@@ -427,6 +424,5 @@ export function intellijThemePackage(source: IntellijThemeSource): ThemePackage
...(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 })),
};
}
+3 -1
View File
@@ -171,16 +171,18 @@ export interface SelectProps<V extends string> {
options: readonly SelectOption<V>[];
onChange: (value: V) => void;
disabled?: boolean;
onPreview?: (value: V | null) => void;
"aria-label"?: string;
}
export function Select<V extends string>({ value, options, onChange, disabled, ...aria }: SelectProps<V>) {
export function Select<V extends string>({ value, options, onChange, disabled, onPreview, ...aria }: SelectProps<V>) {
return (
<UiSelect<V>
value={value}
options={options}
onChange={onChange}
disabled={disabled}
onPreview={onPreview}
className={styles.select}
aria-label={aria["aria-label"]}
/>
+19 -2
View File
@@ -6,7 +6,7 @@
*/
import { FontPicker } from "../components/FontPicker";
import { useId, useRef, useState, type ReactNode } from "react";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { shortcutLabel } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
@@ -33,7 +33,8 @@ import { HTTP_PROXY_PAGE } from "./httpProxy";
import styles from "./Behavior.module.css";
import { LOCALES, SUPPORTED_LOCALES, tx, useT } from "@/platform/i18n";
import { BUILTIN_THEMES, useContributedIconThemes } from "@/platform/themes";
import { originDetail, themeSelectOptions, useThemeChoices } from "@/workbench/theme";
import { currentThemeChoice, originDetail, previewTheme, themeSelectOptions, useThemeChoices } from "@/workbench/theme";
import { themePreview } from "@/workbench/quickSwitch";
const BUILTIN_THEME_LABELS = BUILTIN_THEMES.map((t) => t.label);
@@ -62,12 +63,28 @@ function ThemeRow() {
const matched = useLabelMatch("Sync with OS");
const choices = useThemeChoices();
const shown: ConcreteTheme = theme === "system" ? "light" : theme;
// The theme under the pointer shows as in Quick Switch; the one picked here shows until Apply or Cancel.
const picker = useMemo(() => themePreview(), []);
const previewed = useRef(false);
useEffect(
() => () => {
if (previewed.current) previewTheme(null);
},
[],
);
const preview = (id: ConcreteTheme | null) => {
previewed.current = true;
if (id !== null) return picker.preview(id);
picker.restore();
if (shown !== currentThemeChoice()) previewTheme(shown);
};
return (
<Row label="Theme">
<Select<ConcreteTheme>
value={shown}
options={themeSelectOptions(choices, shown)}
onChange={setTheme}
onPreview={preview}
aria-label="Theme"
disabled={sync}
/>
+6 -6
View File
@@ -5807,18 +5807,18 @@
"Unsigned": "Sin firmar",
"Roll Back to {previousVersion}": "Revertir a {previousVersion}",
"Install Plugin from Package File…": "Instalar plugin desde archivo de paquete…",
"Import Theme from IntelliJ IDE…": "Importar tema desde IntelliJ IDE…",
"Import Theme from JetBrains IDE…": "Importar tema desde un IDE de JetBrains…",
"Import IntelliJ Theme from File…": "Importar tema de IntelliJ desde archivo…",
"Import Theme from IntelliJ IDE": "Importar tema desde IntelliJ IDE",
"Import Theme from JetBrains IDE": "Importar tema desde un IDE de JetBrains",
"Import IntelliJ Theme": "Importar tema de IntelliJ",
"IntelliJ theme plugin or theme file": "Plugin de tema o archivo de tema de IntelliJ",
"Could not import the IntelliJ theme": "No se pudo importar el tema de IntelliJ",
"Import": "Importar",
"In use": "En uso",
"IntelliJ themes": "Temas de IntelliJ",
"Looking for IntelliJ themes": "Buscando temas de IntelliJ",
"Looking for IntelliJ themes…": "Buscando temas de 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.": "Plugins de temas instalados en los IDE de JetBrains en este equipo. Al importar un tema se incluyen todos los temas de su plugin y se aplica el elegido. Los colores sin equivalente en OpenIDEA conservan los colores del tema predeterminado.",
"JetBrains theme plugins": "Plugins de temas de JetBrains",
"Looking for JetBrains themes": "Buscando temas de JetBrains",
"Looking for JetBrains themes…": "Buscando temas de JetBrains…",
"Theme plugins installed in the JetBrains IDEs on this computer. Importing one installs it with all its themes; choose the theme to use in Settings › Appearance. Colours without an OpenIDEA counterpart keep the default theme colours.": "Plugins de temas instalados en los IDE de JetBrains en este equipo. Al importar uno se instala con todos sus temas; elija el tema que desea usar en Configuración › Apariencia. Los colores sin equivalente en OpenIDEA conservan los colores del tema predeterminado.",
"1 theme": "1 tema",
"{count} themes": "{count} temas",
"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.": "No se encontraron plugins de temas en los IDE de JetBrains en este equipo. En su lugar, elija un plugin de tema descargado de JetBrains Marketplace (.jar o .zip) o un archivo .theme.json.",
+6 -6
View File
@@ -5842,18 +5842,18 @@
"Unsigned": "未签名",
"Roll Back to {previousVersion}": "回滚到 {previousVersion}",
"Install Plugin from Package File…": "从插件包文件安装…",
"Import Theme from IntelliJ IDE…": "从 IntelliJ IDE 导入主题…",
"Import Theme from JetBrains IDE…": "从 JetBrains IDE 导入主题…",
"Import IntelliJ Theme from File…": "从文件导入 IntelliJ 主题…",
"Import Theme from IntelliJ IDE": "从 IntelliJ IDE 导入主题",
"Import Theme from JetBrains IDE": "从 JetBrains 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 颜色的项保留默认主题的颜色。",
"JetBrains theme plugins": "JetBrains 主题插件",
"Looking for JetBrains themes": "正在查找 JetBrains 主题",
"Looking for JetBrains themes…": "正在查找 JetBrains 主题…",
"Theme plugins installed in the JetBrains IDEs on this computer. Importing one installs it with all its themes; choose the theme to use in Settings › Appearance. 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 文件。",
+12 -1
View File
@@ -1,4 +1,4 @@
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import clsx from "clsx";
import { Icon, type IconName } from "./icons";
import { Popup } from "./Popup";
@@ -33,6 +33,8 @@ export interface SelectProps<V extends string = string> {
renderValue?: (option: SelectOption<V> | undefined) => ReactNode;
/** Show a search box in the popup (useful for long option lists). */
searchable?: boolean;
/** The option under the pointer or keyboard while the list is open; `null` once it closes. */
onPreview?: (value: V | null) => void;
}
const TYPE_AHEAD_MS = 800;
@@ -59,6 +61,7 @@ export function Select<V extends string = string>({
className,
renderValue,
searchable = false,
onPreview,
...aria
}: SelectProps<V>) {
const tx = useTx();
@@ -112,6 +115,14 @@ export function Select<V extends string = string>({
const activeIndex =
filtered[active] && !unavailable(filtered[active]!) ? active : filtered.findIndex((o) => !unavailable(o));
const previewed = open ? (filtered[activeIndex]?.value ?? null) : null;
const lastPreview = useRef<V | null>(null);
useEffect(() => {
if (previewed === lastPreview.current) return;
lastPreview.current = previewed;
onPreview?.(previewed);
}, [previewed, onPreview]);
const openPopup = () => {
setQuery("");
setActive(
+25 -13
View File
@@ -23,7 +23,7 @@ import {
import { WindowControls } from "./WindowControls";
import { usePlatform } from "@/platform/platformInfo";
import { projectInitials } from "./projectColor";
import { keymapItems, themeItems, VIEW_MODE_ITEMS } from "./quickSwitch";
import { keymapItems, themeItems, themePreview, type ThemePreview, VIEW_MODE_ITEMS } from "./quickSwitch";
import { useContributedThemes } from "@/platform/themes";
import { currentThemeChoice, useThemeOverride } from "./theme";
import styles from "./TitleBar.module.css";
@@ -147,13 +147,18 @@ export function customizeToolbarItems(
];
}
/** IDEA's main toolbar gear: Run Anything, Settings, Plugins, quick switches, toolbar customization, updates. */
/**
* IDEA's main toolbar gear: Run Anything, Settings, Plugins, quick switches, toolbar customization, updates.
* With `preview`, a theme under the pointer shows as in Quick Switch.
*/
export function gearMenuItems(
themeId: string,
keymapId: string,
customKeymaps: string[],
customize: MenuItem[] = [{ kind: "action", action: "toolbar.customize", label: "Customize Toolbar…" }],
preview?: ThemePreview,
): MenuItem[] {
const restore = preview && (() => preview.restore());
return [
{ kind: "action", action: "runAnything.show" },
{ kind: "separator" },
@@ -163,20 +168,25 @@ export function gearMenuItems(
{
id: "gear.theme",
label: "Theme",
submenu: [...themeItems(themeId), { kind: "separator" }, { kind: "action", action: "settings.appearance" }],
submenu: [
...themeItems(themeId, preview),
{ kind: "separator" },
{ kind: "action", action: "settings.appearance" },
],
},
{
id: "gear.keymap",
label: "Keymap",
onActive: restore,
submenu: [
...keymapItems(keymapId, customKeymaps),
{ kind: "separator" },
{ kind: "action", action: "settings.keymap", label: "Configure Keymap…" },
],
},
{ id: "gear.viewMode", label: "View Mode", submenu: VIEW_MODE_ITEMS },
{ id: "gear.viewMode", label: "View Mode", onActive: restore, submenu: VIEW_MODE_ITEMS },
{ kind: "separator" },
{ id: "gear.customizeToolbar", label: "Customize Main Toolbar", submenu: customize },
{ id: "gear.customizeToolbar", label: "Customize Main Toolbar", onActive: restore, submenu: customize },
{ kind: "separator" },
{ kind: "action", action: "app.checkForUpdates" },
];
@@ -205,12 +215,20 @@ function GearButton({ customize }: { customize: MenuItem[] }) {
const preset = useKeymap((s) => s.preset);
const customs = settings?.customKeymaps;
useContributedThemes((s) => s.themes);
// One preview per opening: a theme shown from it goes away with the menu unless chosen.
const preview = useMemo(() => (open ? themePreview() : undefined), [open]);
const items = gearMenuItems(
currentThemeChoice(),
typeof settings?.keymap === "string" ? settings.keymap : preset,
customs && typeof customs === "object" ? Object.keys(customs) : [],
customize,
preview,
);
const close = () => {
setOpen(false);
// A chosen theme runs right after the menu closes: let it choose first.
queueMicrotask(() => preview?.end());
};
return (
<>
<IconButton
@@ -221,15 +239,9 @@ function GearButton({ customize }: { customize: MenuItem[] }) {
className={styles.tool}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
/>
<Menu
open={open}
onClose={() => setOpen(false)}
anchor={ref}
items={items}
aria-label="IDE and Project Settings"
onClick={() => (open ? close() : setOpen(true))}
/>
<Menu open={open} onClose={close} anchor={ref} items={items} aria-label="IDE and Project Settings" />
</>
);
}
+5 -3
View File
@@ -218,7 +218,12 @@ export function currentThemeChoice(): ThemeChoiceId {
: resolveThemeChoice(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight());
}
/** A previewed theme is not the next start-up's (`rememberStartupTheme`). */
let previewing = false;
export async function setTheme(next: ThemeSetting) {
// A theme chosen while previewed is the next start-up's.
previewing = false;
useThemeOverride.setState({ theme: next });
if (!isTauri()) return;
try {
@@ -233,9 +238,6 @@ 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