mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b396ae6ab2 | ||
|
|
26e3ecc3a0 | ||
|
|
456b329d73 |
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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, ¤t), "every open returns the window's one project");
|
||||
}
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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>/`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -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"]}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user