Merge remote-tracking branch 'origin/main' into fix/translate-template-text

# Conflicts:
#	src/platform/locales/text/zh.json
This commit is contained in:
l0ng-ai
2026-10-27 18:54:45 +08:00
78 changed files with 3537 additions and 1409 deletions
+83 -11
View File
@@ -100,17 +100,6 @@ c → cpp, php_only), node names adapted to the bundled grammar versions
definitions re-kinded as `field` / `method`, and missing scopes, definitions and
references added; each file's header notes its changes.
## Theme plugin palettes
The sample theme plugins port MIT-licensed palettes; each package's `LICENSE`
holds the full notice and source revisions:
- `plugins/theme-solarized/`: [Solarized](https://github.com/altercation/solarized),
Copyright (c) 2011 Ethan Schoonover.
- `plugins/theme-one/`: Atom [One Light](https://github.com/atom/one-light-syntax) /
[One Dark](https://github.com/atom/one-dark-syntax) syntax themes, Copyright (c) 2016
GitHub Inc., and their UI themes, Copyright (c) 2014 GitHub Inc.
---
Apache License
@@ -168,3 +157,86 @@ If the Work includes a "NOTICE" text file as part of its distribution, then any
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
## Solarized
The Solarized palettes in `src/styles/tokens.css` are adapted from
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
See [theme sources and mapping](docs/themes.md). MIT license:
```text
Copyright (c) 2011 Ethan Schoonover
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## Atom One Light and One Dark
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
adapted from GitHub’s Atom One syntax and UI themes.
Source revisions are listed in [theme sources and mapping](docs/themes.md).
Both syntax themes use the following MIT notice:
```text
Copyright (c) 2016 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
Both UI themes use the following MIT notice:
```text
Copyright (c) 2014 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
+19 -2
View File
@@ -8,12 +8,13 @@
pub mod compile;
pub mod gradle;
pub mod maven;
pub mod pom_edit;
mod process;
mod sync;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
use serde::{Deserialize, Serialize};
@@ -69,6 +70,10 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
let modules: std::collections::HashSet<String> = m.projects.iter().map(MavenProject::key).collect();
let mut stack: Vec<&MavenDependency> = m.projects.iter().flat_map(|p| &p.dependencies).collect();
while let Some(d) = stack.pop() {
// Omitted occurrences (conflict losers, duplicates) are not on any classpath.
if !d.resolved() {
continue;
}
stack.extend(&d.children);
let Some(version) = d.version.as_deref().filter(|v| !v.is_empty() && !v.contains("${")) else {
continue;
@@ -150,7 +155,19 @@ mod tests {
artifact_id: "app".into(),
dependencies: vec![
dep("me", "core", Some("1"), vec![]),
dep("org.b", "y", Some("2"), vec![dep("org.c", "z", Some("3"), vec![])]),
dep(
"org.b",
"y",
Some("2"),
vec![
dep("org.c", "z", Some("3"), vec![]),
MavenDependency {
omitted: Some(Omission::Conflict),
conflict_with: Some("3".into()),
..dep("org.c", "z", Some("1"), vec![])
},
],
),
dep("org.d", "unresolved", Some("${v}"), vec![]),
],
..Default::default()
+141 -10
View File
@@ -130,6 +130,37 @@ pub struct MavenDependency {
pub optional: bool,
/// Transitive dependencies (only after a sync).
pub children: Vec<MavenDependency>,
/// Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`).
#[serde(default)]
#[specta(optional)]
pub omitted: Option<Omission>,
/// The version that won when `omitted` is `Conflict`.
#[serde(default)]
#[specta(optional)]
pub conflict_with: Option<String>,
/// The version requested before `<dependencyManagement>` replaced it.
#[serde(default)]
#[specta(optional)]
pub managed_from: Option<String>,
}
impl MavenDependency {
/// Part of the resolved graph (not a verbose-tree placeholder for an omitted occurrence).
pub fn resolved(&self) -> bool {
self.omitted.is_none()
}
}
/// Why a dependency occurrence is not part of the resolved graph.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum Omission {
/// Another occurrence of the same version is nearer the root.
Duplicate,
/// Another version is nearer the root (or declared first) and wins.
Conflict,
/// The artifact depends on itself through this path.
Cycle,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -871,7 +902,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
classifier: d.classifier.clone(),
scope: d.scope.clone().or_else(|| managed_scope(d)).unwrap_or_else(|| "compile".into()),
optional: d.optional,
children: Vec::new(),
..MavenDependency::default()
})
.collect();
@@ -968,7 +999,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
classifier: d.classifier.clone(),
scope: d.scope.clone().unwrap_or_else(|| "compile".into()),
optional: d.optional,
children: Vec::new(),
..MavenDependency::default()
})
.collect();
(format!("{group}:{}", raw.artifact_id), plugins, deps)
@@ -976,13 +1007,16 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
.collect()
}
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line.
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line, with the
/// `-Dverbose` annotations: `(g:a:jar:1:compile - omitted for conflict with 2)`,
/// `g:a:jar:1:compile (version managed from 0.9)`, `(optional)`.
fn parse_coords(s: &str) -> Option<MavenDependency> {
let s = s.trim();
let (coords, extra) = match s.find(" (") {
Some(i) => (&s[..i], &s[i..]),
None => (s, ""),
let (s, wrapped) = match s.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
Some(inner) => (inner, true),
None => (s, false),
};
let (coords, notes) = s.split_once(' ').unwrap_or((s, ""));
let parts: Vec<&str> = coords.split(':').collect();
let (g, a, t, c, v, scope) = match parts.as_slice() {
[g, a, t, v] => (g, a, t, None, v, None),
@@ -990,16 +1024,35 @@ fn parse_coords(s: &str) -> Option<MavenDependency> {
[g, a, t, c, v, s] => (g, a, t, Some(*c), v, Some(*s)),
_ => return None,
};
Some(MavenDependency {
let mut dep = MavenDependency {
group_id: (*g).to_owned(),
artifact_id: (*a).to_owned(),
version: Some((*v).to_owned()),
kind: (*t).to_owned(),
classifier: c.map(|c| (*c).to_owned()),
scope: scope.unwrap_or("compile").to_owned(),
optional: extra.contains("optional"),
children: Vec::new(),
})
..MavenDependency::default()
};
let notes = notes.replace(['(', ')'], ";").replace(" - ", ";");
for note in notes.split(';').map(|n| n.trim().trim_start_matches("- ").trim()) {
if note == "optional" {
dep.optional = true;
} else if let Some(winner) = note.strip_prefix("omitted for conflict with ") {
dep.omitted = Some(Omission::Conflict);
dep.conflict_with = Some(winner.trim().to_owned());
} else if note == "omitted for duplicate" {
dep.omitted = Some(Omission::Duplicate);
} else if note == "omitted for cycle" {
dep.omitted = Some(Omission::Cycle);
} else if let Some(from) = note.strip_prefix("version managed from ") {
dep.managed_from = Some(from.trim().to_owned());
}
}
// A parenthesised line is always an omitted occurrence, even with an unknown reason.
if wrapped && dep.omitted.is_none() {
dep.omitted = Some(Omission::Duplicate);
}
Some(dep)
}
fn insert_at(list: &mut Vec<MavenDependency>, depth: usize, dep: MavenDependency) {
@@ -1327,4 +1380,82 @@ mod tests {
);
assert_eq!(trees[2].1[0].scope, "compile");
}
/// `mvn org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree -Dverbose` on a project
/// whose libraries ask for different commons-lang3 / commons-text / guava versions.
#[test]
fn verbose_dependency_tree_output() {
let trees = parse_dependency_tree(include_str!("../testdata/dependency-tree-verbose.txt"));
assert_eq!(trees.len(), 1);
let (key, deps) = &trees[0];
assert_eq!(key, "demo:conflict-demo");
let direct: Vec<&str> = deps.iter().map(|d| d.artifact_id.as_str()).collect();
assert_eq!(direct, ["hamcrest-core", "commons-text", "commons-configuration2", "guava", "guice", "junit"]);
assert!(deps.iter().all(MavenDependency::resolved));
let config = &deps[2].children;
let lang3 = &config[0];
assert_eq!(
(lang3.artifact_id.as_str(), lang3.version.as_deref(), lang3.omitted, lang3.conflict_with.as_deref()),
("commons-lang3", Some("3.14.0"), Some(Omission::Conflict), Some("3.12.0"))
);
assert_eq!(lang3.scope, "compile");
let logging = &config[2];
assert_eq!(
(logging.version.as_deref(), logging.managed_from.as_deref(), logging.omitted),
(Some("1.2"), Some("1.3.0"), None)
);
let guava = &deps[4].children[2];
assert_eq!((guava.version.as_deref(), guava.conflict_with.as_deref()), (Some("30.1-jre"), Some("32.1.3-jre")));
let hamcrest = &deps[5].children[0];
assert_eq!((hamcrest.omitted, hamcrest.scope.as_str()), (Some(Omission::Duplicate), "test"));
assert_eq!(deps[1].children[0].omitted, None, "the winning commons-lang3");
}
#[test]
fn verbose_annotations() {
// (line, omitted, conflict winner, managed from, optional)
type Case<'a> = (&'a str, Option<Omission>, Option<&'a str>, Option<&'a str>, bool);
let cases: &[Case] = &[
("(g:a:jar:1:compile - omitted for cycle)", Some(Omission::Cycle), None, None, false),
(
"(g:a:jar:1:compile - version managed from 0.9; omitted for duplicate)",
Some(Omission::Duplicate),
None,
Some("0.9"),
false,
),
("g:a:jar:1:compile (optional)", None, None, None, true),
(
"(g:a:jar:1:runtime (optional) - omitted for conflict with 2)",
Some(Omission::Conflict),
Some("2"),
None,
true,
),
(
"(g:a:jar:1:compile - scope updated from runtime; omitted for duplicate)",
Some(Omission::Duplicate),
None,
None,
false,
),
(
"g:a:jar:1:compile (version managed from 0.9; scope managed from runtime)",
None,
None,
Some("0.9"),
false,
),
];
for (line, omitted, winner, managed, optional) in cases {
let d = parse_coords(line).unwrap();
assert_eq!(
(d.omitted, d.conflict_with.as_deref(), d.managed_from.as_deref(), d.optional),
(*omitted, *winner, *managed, *optional),
"{line}"
);
assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}");
}
}
}
+336
View File
@@ -0,0 +1,336 @@
//! Edits of `pom.xml` text for the dependency analyzer: finding where a direct
//! dependency is declared and adding an `<exclusion>` to it, in the style
//! (indentation, line endings, one-line elements) of the surrounding POM.
use crate::maven::{self, MavenModel};
use crate::xml::{self, Element};
use ide_core::{Pos, Range, TextEdit};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{HashMap, HashSet};
/// `groupId:artifactId` of a dependency.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactKey {
pub group_id: String,
pub artifact_id: String,
}
impl ArtifactKey {
pub fn new(group_id: impl Into<String>, artifact_id: impl Into<String>) -> Self {
Self { group_id: group_id.into(), artifact_id: artifact_id.into() }
}
}
impl std::fmt::Display for ArtifactKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.group_id, self.artifact_id)
}
}
/// What adding an exclusion does to a POM.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Exclusion {
/// The edit that adds it.
Edit(TextEdit),
/// The dependency already excludes it (or everything, `*:*`).
Present,
}
/// Properties the coordinates of a POM's dependencies may refer to.
fn properties(project: &Element) -> HashMap<String, String> {
let mut props: HashMap<String, String> = project
.child("properties")
.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect())
.unwrap_or_default();
let own_or_parent = |name: &str| project.text_of(name).or_else(|| project.child("parent")?.text_of(name));
for (key, value) in [
("project.groupId", own_or_parent("groupId")),
("project.version", own_or_parent("version")),
("project.artifactId", project.text_of("artifactId")),
] {
if let Some(v) = value {
props.insert(key.into(), v);
}
}
props
}
fn matches(e: &Element, key: &ArtifactKey, props: &HashMap<String, String>) -> bool {
let text = |name: &str| e.text_of(name).map(|t| maven::interpolate(&t, props));
text("groupId").as_deref() == Some(key.group_id.as_str())
&& text("artifactId").as_deref() == Some(key.artifact_id.as_str())
}
/// The `<dependency>` declaring `key` in the project's `<dependencies>`, else in a
/// profile's (never `<dependencyManagement>`, which declares no dependency).
fn find_dependency<'a>(project: &'a Element, key: &ArtifactKey) -> Option<&'a Element> {
let props = properties(project);
let own = project.list("dependencies", "dependency");
let profiles = project.list("profiles", "profile").into_iter().flat_map(|p| p.list("dependencies", "dependency"));
own.into_iter().chain(profiles).find(|d| matches(d, key, &props))
}
/// 0-based line / UTF-16 column of byte offset `at`.
fn pos_at(text: &str, at: usize) -> Pos {
let before = &text[..at];
let line_start = before.rfind('\n').map_or(0, |i| i + 1);
Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32)
}
fn line_start(text: &str, at: usize) -> usize {
text[..at].rfind('\n').map_or(0, |i| i + 1)
}
/// The whitespace before `at` on its line, or `None` when other text precedes it there.
fn indent_before(text: &str, at: usize) -> Option<&str> {
let ws = &text[line_start(text, at)..at];
ws.chars().all(|c| c == ' ' || c == '\t').then_some(ws)
}
/// One level of indentation: what the first child of `e` adds to `e`'s, else two spaces.
fn indent_unit(text: &str, e: &Element) -> String {
let outer = indent_before(text, e.start);
let inner = e.children.first().and_then(|c| indent_before(text, c.start));
match (outer, inner) {
(Some(o), Some(i)) if i.len() > o.len() && i.starts_with(o) => i[o.len()..].to_owned(),
(_, Some(i)) if i.starts_with('\t') => "\t".into(),
_ => " ".into(),
}
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;")
}
/// Range of the `<artifactId>` of the dependency declaring `key` in `text` (a POM), or of
/// the whole `<dependency>` when it has none; `None` when the POM does not declare it.
pub fn declaration(text: &str, key: &ArtifactKey) -> Option<Range> {
let project = xml::parse(text)?;
let dep = find_dependency(&project, key)?;
let target = dep.child("artifactId").unwrap_or(dep);
Some(Range::new(pos_at(text, target.start), pos_at(text, target.end)))
}
/// Adds `exclude` to the `<exclusions>` of the dependency declaring `dependency` in `text`
/// (a POM); `None` when the POM does not declare it.
pub fn add_exclusion(text: &str, dependency: &ArtifactKey, exclude: &ArtifactKey) -> Option<Exclusion> {
let project = xml::parse(text)?;
let dep = find_dependency(&project, dependency)?;
let nl = if text.contains("\r\n") { "\r\n" } else { "\n" };
let unit = indent_unit(text, dep);
let (g, a) = (escape(&exclude.group_id), escape(&exclude.artifact_id));
// Insert `block` (lines relative to `parent`'s indentation + one level) before `parent`'s
// end tag; on one line (`compact`) when `parent` is not laid out one element per line.
let insert = |parent: &Element, lines: &[(usize, String)], compact: String| {
let close_on_own_line = indent_before(text, parent.inner_end).is_some() && parent.inner_end < parent.end;
let (at, new_text) = match indent_before(text, parent.start) {
Some(base) if close_on_own_line => {
let mut out = String::new();
for (depth, line) in lines {
out.push_str(base);
out.push_str(&unit.repeat(depth + 1));
out.push_str(line);
out.push_str(nl);
}
(line_start(text, parent.inner_end), out)
}
_ => (parent.inner_end, compact),
};
let p = pos_at(text, at);
Exclusion::Edit(TextEdit { range: Range::point(p), new_text })
};
let exclusion_lines = |depth: usize| {
vec![
(depth, "<exclusion>".to_owned()),
(depth + 1, format!("<groupId>{g}</groupId>")),
(depth + 1, format!("<artifactId>{a}</artifactId>")),
(depth, "</exclusion>".to_owned()),
]
};
let compact = format!("<exclusion><groupId>{g}</groupId><artifactId>{a}</artifactId></exclusion>");
if let Some(exclusions) = dep.child("exclusions") {
let props = properties(&project);
let wildcard = ArtifactKey::new("*", "*");
let any_artifact = ArtifactKey::new(exclude.group_id.clone(), "*");
if exclusions
.children_named("exclusion")
.any(|e| [exclude, &wildcard, &any_artifact].iter().any(|k| matches(e, k, &props)))
{
return Some(Exclusion::Present);
}
return Some(insert(exclusions, &exclusion_lines(0), compact));
}
let mut lines = vec![(0, "<exclusions>".to_owned())];
lines.extend(exclusion_lines(1));
lines.push((0, "</exclusions>".to_owned()));
Some(insert(dep, &lines, format!("<exclusions>{compact}</exclusions>")))
}
/// The POMs that may declare a dependency of the module `pom` (project-relative), nearest
/// first: the module's own, then its parents that are part of the project.
pub fn declaring_poms(model: &MavenModel, pom: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut current = model.projects.iter().find(|p| p.pom == pom);
while let Some(p) = current {
if !seen.insert(p.pom.clone()) {
break;
}
out.push(p.pom.clone());
current = p.parent.as_deref().and_then(|coords| {
model.projects.iter().find(|q| format!("{}:{}:{}", q.group_id, q.artifact_id, q.version) == coords)
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn apply(text: &str, edit: &TextEdit) -> String {
let offset = |p: Pos| {
let start: usize = text.split_inclusive('\n').take(p.line as usize).map(str::len).sum();
let line = &text[start..];
start + line.char_indices().nth(p.col as usize).map_or(line.len(), |(i, _)| i)
};
let (from, to) = (offset(edit.range.start), offset(edit.range.end));
format!("{}{}{}", &text[..from], edit.new_text, &text[to..])
}
fn exclude(text: &str, dep: (&str, &str), excl: (&str, &str)) -> Option<String> {
match add_exclusion(text, &ArtifactKey::new(dep.0, dep.1), &ArtifactKey::new(excl.0, excl.1))? {
Exclusion::Edit(e) => Some(apply(text, &e)),
Exclusion::Present => Some(text.to_owned()),
}
}
const POM: &str = "<project>
<groupId>demo</groupId>
<properties>
<guice.group>com.google.inject</guice.group>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>5.1.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>${guice.group}</groupId>
<artifactId>guice</artifactId>
</dependency>
</dependencies>
</project>
";
#[test]
fn adds_exclusions_to_the_declaration_not_the_managed_entry() {
let out = exclude(POM, ("com.google.inject", "guice"), ("com.google.guava", "guava")).unwrap();
let expected = POM.replace(
" <artifactId>guice</artifactId>\n </dependency>\n </dependencies>",
" <artifactId>guice</artifactId>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>",
);
assert_eq!(out, expected);
// A second exclusion joins the first; excluding it again changes nothing.
let twice = exclude(&out, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap();
assert!(twice.contains(
" </exclusion>
<exclusion>
<groupId>aopalliance</groupId>
<artifactId>aopalliance</artifactId>
</exclusion>
</exclusions>"
));
assert_eq!(exclude(&twice, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(), twice);
}
#[test]
fn keeps_tabs_crlf_and_one_line_dependencies() {
let tabs = "<project>\r\n\t<dependencies>\r\n\t\t<dependency>\r\n\t\t\t<groupId>g</groupId>\r\n\t\t\t<artifactId>a</artifactId>\r\n\t\t</dependency>\r\n\t</dependencies>\r\n</project>\r\n";
let out = exclude(tabs, ("g", "a"), ("x", "y")).unwrap();
assert!(out.contains("<artifactId>a</artifactId>\r\n\t\t\t<exclusions>\r\n\t\t\t\t<exclusion>\r\n\t\t\t\t\t<groupId>x</groupId>\r\n"));
assert!(out.contains("\t\t\t</exclusions>\r\n\t\t</dependency>"));
let inline = "<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId></dependency></dependencies></project>";
assert_eq!(
exclude(inline, ("g", "a"), ("x", "y")).unwrap(),
"<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId><exclusions><exclusion><groupId>x</groupId><artifactId>y</artifactId></exclusion></exclusions></dependency></dependencies></project>"
);
}
#[test]
fn wildcards_profiles_and_missing_declarations() {
let pom = "<project>
<profiles>
<profile>
<dependencies>
<dependency>
<groupId>g</groupId><artifactId>a</artifactId>
<exclusions><exclusion><groupId>x</groupId><artifactId>*</artifactId></exclusion></exclusions>
</dependency>
</dependencies>
</profile>
</profiles>
</project>";
let key = ArtifactKey::new("g", "a");
assert_eq!(add_exclusion(pom, &key, &ArtifactKey::new("x", "y")), Some(Exclusion::Present));
let out = exclude(pom, ("g", "a"), ("z", "w")).unwrap();
assert!(out.contains(
"</exclusion><exclusion><groupId>z</groupId><artifactId>w</artifactId></exclusion></exclusions>"
));
assert_eq!(add_exclusion(pom, &ArtifactKey::new("g", "other"), &key), None);
assert_eq!(add_exclusion("not xml", &key, &key), None);
}
#[test]
fn declaration_ranges_use_utf16_columns() {
let pom = "<project><!-- é😀 --><dependencies>\n <dependency><groupId>g</groupId><artifactId>a</artifactId></dependency>\n</dependencies></project>";
let r = declaration(pom, &ArtifactKey::new("g", "a")).unwrap();
assert_eq!(r, Range::new(Pos::new(1, 34), Pos::new(1, 60)));
let first = "<project><!-- é😀 --><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId>";
let r = declaration(&format!("{first}</dependency></dependencies></project>"), &ArtifactKey::new("g", "a"))
.unwrap();
let start = first.find("<artifactId>").unwrap();
assert_eq!(r.start.col as usize, first[..start].encode_utf16().count());
}
#[test]
fn declaring_poms_follow_parents_inside_the_project() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let write = |rel: &str, text: &str| {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
};
write(
"pom.xml",
"<project><groupId>d</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
"app/pom.xml",
"<project><parent><groupId>d</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>app</artifactId></project>",
);
let model = maven::load_with_repo(root, None);
assert_eq!(declaring_poms(&model, "app/pom.xml"), ["app/pom.xml", "pom.xml"]);
assert_eq!(declaring_poms(&model, "pom.xml"), ["pom.xml"]);
assert!(declaring_poms(&model, "missing/pom.xml").is_empty());
}
}
+43 -6
View File
@@ -1,5 +1,5 @@
//! Reading the effective build from Maven / Gradle itself ("Reload" in the tool
//! windows): `mvn help:effective-pom dependency:tree` and `gradle tasks --all`,
//! windows): `mvn help:effective-pom dependency:tree -Dverbose` and `gradle tasks --all`,
//! run in the background with progress and cancellation. Results are cached
//! per project (`maven.json`, `gradle.json` in the cache directory) and merged
//! into the static model by [`crate::load`]; a changed build file marks them stale.
@@ -49,6 +49,9 @@ struct MavenCache {
at_ms: f64,
error: Option<String>,
projects: Vec<CachedMavenProject>,
/// The trees list omitted occurrences (`-Dverbose`); older caches are reloaded.
#[serde(default)]
verbose: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -60,6 +63,21 @@ struct GradleCache {
}
const MAVEN_CACHE: &str = "maven.json";
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
/// Offline, the project's own plugin version is used instead, since this one may not be cached.
const DEPENDENCY_TREE: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree";
/// The goals that read the effective POM into `effective` and the verbose dependency trees.
fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
vec![
"help:effective-pom".into(),
format!("-Doutput={}", effective.to_string_lossy()),
if offline { "dependency:tree".into() } else { DEPENDENCY_TREE.into() },
"-Dverbose".into(),
]
}
const GRADLE_CACHE: &str = "gradle.json";
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
@@ -110,7 +128,7 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = cache.hash != hash_files(&maven::build_files(root, model));
model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model));
}
/// Merges the cached task list into `model`.
@@ -182,7 +200,8 @@ pub fn sync_maven(
) -> IdeResult<()> {
let model = maven::load(root);
std::fs::create_dir_all(cache_dir)?;
let mut cache = MavenCache { hash: hash_files(&maven::build_files(root, &model)), ..MavenCache::default() };
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
let mut errors: Vec<String> = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
@@ -195,9 +214,7 @@ pub fn sync_maven(
if !options.profiles.is_empty() {
args.push(format!("-P{}", options.profiles.join(",")));
}
args.push("help:effective-pom".into());
args.push(format!("-Doutput={}", effective.to_string_lossy()));
args.push("dependency:tree".into());
args.extend(maven_sync_goals(&effective, options.offline));
progress(SyncProgress { message: format!("Reading {}", top.display_name), fraction: None });
let command = options.maven_command.as_deref().unwrap_or(&top.command);
let out = run(command, &args, &root.join(&top.dir), &options.env, cancel, |line| {
@@ -277,6 +294,7 @@ mod tests {
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
verbose: true,
projects: vec![CachedMavenProject {
key: "g:a".into(),
plugins: vec![maven::plugin(
@@ -327,6 +345,25 @@ mod tests {
assert!(!model.stale);
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
assert_eq!(online, ["help:effective-pom", "-Doutput=e.xml", DEPENDENCY_TREE, "-Dverbose"]);
let offline = maven_sync_goals(Path::new("e.xml"), true);
assert_eq!(offline[2], "dependency:tree");
assert!(offline.contains(&"-Dverbose".to_owned()));
}
#[test]
fn cache_written_before_omissions_existed_still_loads() {
let json = r#"{"hash":"x","at_ms":1.0,"error":null,"projects":[{"key":"g:a","plugins":[],
"dependencies":[{"groupId":"g","artifactId":"d","version":"1","type":"jar","classifier":null,
"scope":"compile","optional":false,"children":[]}]}]}"#;
let cache: MavenCache = serde_json::from_str(json).unwrap();
assert!(cache.projects[0].dependencies[0].resolved());
assert!(!cache.verbose, "reloaded to read the omitted occurrences");
}
#[test]
fn progress_and_error_lines() {
assert_eq!(
+42 -7
View File
@@ -1,12 +1,19 @@
//! A small, forgiving XML element reader for POM files: elements, text and
//! CDATA; attributes, comments, processing instructions and DOCTYPEs are
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
pub text: String,
pub children: Vec<Element>,
/// Byte offset of the start tag's `<`.
pub start: usize,
/// Byte offset of the end tag's `<` (`end` for an empty or unclosed element).
pub inner_end: usize,
/// Byte offset just past the end tag (past the start tag for `<empty/>`).
pub end: usize,
}
impl Element {
@@ -76,6 +83,7 @@ fn local(name: &str) -> String {
pub fn parse(text: &str) -> Option<Element> {
let mut stack: Vec<Element> = vec![Element::default()];
let mut rest = text;
let at = |rest: &str| text.len() - rest.len();
while !rest.is_empty() {
let Some(lt) = rest.find('<') else {
decode_entities(rest, &mut stack.last_mut()?.text);
@@ -98,13 +106,19 @@ pub fn parse(text: &str) -> Option<Element> {
} else if let Some(body) = tag.strip_prefix("</") {
let end = body.find('>').unwrap_or(body.len());
let name = local(body[..end].trim());
if let Some(at) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) {
while stack.len() > at {
let done = stack.pop()?;
let close_start = at(tag);
rest = body.get(end + 1..).unwrap_or("");
let close_end = at(rest);
if let Some(i) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) {
while stack.len() > i {
let mut done = stack.pop()?;
// Elements closed implicitly by an outer end tag end where it starts.
let matched = stack.len() == i;
done.inner_end = close_start;
done.end = if matched { close_end } else { close_start };
stack.last_mut()?.children.push(done);
}
}
rest = body.get(end + 1..).unwrap_or("");
} else {
// Start or empty tag; skip quoted attribute values when looking for `>`.
let bytes = tag.as_bytes();
@@ -124,17 +138,22 @@ pub fn parse(text: &str) -> Option<Element> {
let empty = inner.ends_with('/');
let inner = inner.trim_end_matches('/');
let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len());
let el = Element { name: local(&inner[..name_end]), ..Element::default() };
let start = at(tag);
rest = tag.get(i + 1..).unwrap_or("");
let after = at(rest);
let el =
Element { name: local(&inner[..name_end]), start, inner_end: after, end: after, ..Element::default() };
if empty {
stack.last_mut()?.children.push(el);
} else {
stack.push(el);
}
rest = tag.get(i + 1..).unwrap_or("");
}
}
while stack.len() > 1 {
let done = stack.pop()?;
let mut done = stack.pop()?;
done.inner_end = text.len();
done.end = text.len();
stack.last_mut()?.children.push(done);
}
stack.pop()?.children.into_iter().next()
@@ -167,6 +186,22 @@ mod tests {
assert_eq!(root.children_named("name").count(), 1);
}
#[test]
fn element_offsets() {
let doc = "<a>\n <b>x</b><c/>\n</a>";
let root = parse(doc).unwrap();
assert_eq!((root.start, root.inner_end, root.end), (0, doc.len() - 4, doc.len()));
let b = root.child("b").unwrap();
assert_eq!(&doc[b.start..b.end], "<b>x</b>");
assert_eq!(&doc[b.start..b.inner_end], "<b>x");
let c = root.child("c").unwrap();
assert_eq!(&doc[c.start..c.end], "<c/>");
let broken = parse("<a><b>x</a>").unwrap();
let b = broken.child("b").unwrap();
assert_eq!((b.inner_end, b.end), (7, 7));
assert_eq!(broken.end, 11);
}
#[test]
fn tolerates_broken_documents() {
let root = parse("<a><b>x</c><d>y").unwrap();
+24
View File
@@ -0,0 +1,24 @@
[INFO] --- dependency:3.8.1:tree (default-cli) @ conflict-demo ---
[INFO] demo:conflict-demo:jar:1.0-SNAPSHOT
[INFO] +- org.hamcrest:hamcrest-core:jar:1.3:compile
[INFO] +- org.apache.commons:commons-text:jar:1.10.0:compile
[INFO] | \- org.apache.commons:commons-lang3:jar:3.12.0:compile
[INFO] +- org.apache.commons:commons-configuration2:jar:2.10.1:compile
[INFO] | +- (org.apache.commons:commons-lang3:jar:3.14.0:compile - omitted for conflict with 3.12.0)
[INFO] | +- (org.apache.commons:commons-text:jar:1.11.0:compile - omitted for conflict with 1.10.0)
[INFO] | \- commons-logging:commons-logging:jar:1.2:compile (version managed from 1.3.0)
[INFO] +- com.google.guava:guava:jar:32.1.3-jre:compile
[INFO] | +- com.google.guava:failureaccess:jar:1.0.1:compile
[INFO] | +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
[INFO] | +- com.google.code.findbugs:jsr305:jar:3.0.2:compile
[INFO] | +- org.checkerframework:checker-qual:jar:3.37.0:compile
[INFO] | +- com.google.errorprone:error_prone_annotations:jar:2.21.1:compile
[INFO] | \- com.google.j2objc:j2objc-annotations:jar:2.8:compile
[INFO] +- com.google.inject:guice:jar:5.1.0:compile
[INFO] | +- javax.inject:javax.inject:jar:1:compile
[INFO] | +- aopalliance:aopalliance:jar:1.0:compile
[INFO] | \- (com.google.guava:guava:jar:30.1-jre:compile - omitted for conflict with 32.1.3-jre)
[INFO] \- junit:junit:jar:4.13.2:test
[INFO] \- (org.hamcrest:hamcrest-core:jar:1.3:test - omitted for duplicate)
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
+3 -1
View File
@@ -5,7 +5,9 @@ use ide_core::event::ServerStatus;
use ide_core::{CancellationToken, DocId, IdeEvent, LanguageId, Pos};
use ide_jsonrpc::{Handler, Peer};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
use ide_lsp::{LaunchError, Launcher, LspManager, ProcessLauncher};
#[cfg(unix)]
use ide_lsp::{LaunchError, Launcher};
use ide_lsp::{LspManager, ProcessLauncher};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::path::PathBuf;
+66 -41
View File
@@ -22,6 +22,33 @@ fn sample(name: &str) -> PathBuf {
repo().join("plugins").join(name)
}
fn theme_template() -> PathBuf {
repo().join("templates/theme-plugin")
}
/// A theme package with a light and a dark theme, plus licence files, in `dir`.
fn write_theme_pair(dir: &Path) {
std::fs::create_dir_all(dir.join("t")).unwrap();
std::fs::write(
dir.join("plugin.toml"),
"id = \"theme-pair\"\nname = \"Pair\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\
[[contributes.themes]]\npath = \"t/day.json\"\n[[contributes.themes]]\npath = \"t/night.json\"\n",
)
.unwrap();
std::fs::write(
dir.join("t/day.json"),
r##"{"id":"day","name":"Day","kind":"light","tokens":{"--bg-frame":"#eeeeee"}}"##,
)
.unwrap();
std::fs::write(
dir.join("t/night.json"),
r##"{"id":"night","name":"Night","kind":"dark","tokens":{"--bg-frame":"#111111"}}"##,
)
.unwrap();
std::fs::write(dir.join("LICENSE"), "MIT License\n").unwrap();
std::fs::write(dir.join("NOTICE"), "Palette notice\n").unwrap();
}
fn misbehave_pkg() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/misbehave")
}
@@ -217,11 +244,11 @@ fn runtime_language_and_theme_registration() {
#[test]
fn contribution_only_plugins() {
let f = fixture(Limits::default());
let theme = f.host.install_from_dir(&sample("theme-solarized")).unwrap();
let theme = f.host.install_from_dir(&theme_template()).unwrap();
assert_eq!(theme.status, PluginStatus::Static);
assert!(!theme.has_code);
let ids: Vec<&str> = theme.themes.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["solarized-light", "solarized-dark"]);
assert_eq!(ids, ["harbor-night"]);
let lang = f.host.install_from_dir(&sample("language-toy")).unwrap();
assert_eq!(lang.status, PluginStatus::Static);
assert_eq!(lang.languages[0].id, "toy");
@@ -229,14 +256,14 @@ fn contribution_only_plugins() {
assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code"));
f.take_events();
let active = f.host.set_theme(Some(("theme-solarized", "solarized-dark"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#002b36");
assert_eq!(active.theme.tokens["--syn-string"], "#2aa198");
let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#14171c");
assert_eq!(active.theme.tokens["--syn-string"], "#9ccc7a");
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) })));
assert_eq!(f.host.list(None).active_theme, Some(active));
// Disabling the plugin withdraws its theme and language.
f.host.set_enabled("theme-solarized", false).unwrap();
f.host.set_enabled("theme-harbor-night", false).unwrap();
assert!(f.host.active_theme().is_none());
assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None }));
f.host.set_enabled("language-toy", false).unwrap();
@@ -247,7 +274,7 @@ fn contribution_only_plugins() {
#[test]
fn theme_template_installs_with_its_editor_scheme() {
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&repo().join("templates/theme-plugin")).unwrap();
let info = f.host.install_from_dir(&theme_template()).unwrap();
assert_eq!(info.status, PluginStatus::Static);
assert!(!info.has_code);
assert_eq!(info.themes[0].id, "harbor-night");
@@ -263,44 +290,42 @@ fn theme_template_installs_with_its_editor_scheme() {
}
#[test]
fn bundled_theme_plugins_install_with_their_licence() {
fn theme_packages_install_with_their_licence() {
let f = fixture(Limits::default());
for (pkg, themes) in
[("theme-solarized", ["solarized-light", "solarized-dark"]), ("theme-one", ["one-light", "one-dark"])]
{
let info = f.host.install_from_dir(&sample(pkg)).unwrap();
assert_eq!(info.status, PluginStatus::Static, "{pkg}: {:?}", info.error);
assert!(info.authors.iter().any(|a| a.starts_with("bghtyu")));
let license = std::fs::read_to_string(f.plugins_dir.join(pkg).join("LICENSE")).unwrap();
assert!(license.contains("MIT License"));
for (theme, kind) in themes.iter().zip([ThemeKind::Light, ThemeKind::Dark]) {
let active = f.host.set_theme(Some((pkg, theme))).unwrap().unwrap();
assert_eq!(active.theme.kind, kind);
let tokens = &active.theme.tokens;
assert!(["--bg-frame", "--syn-keyword", "--ansi-red"].iter().all(|t| tokens.contains_key(*t)));
}
let dir = tempfile::tempdir().unwrap();
write_theme_pair(dir.path());
let info = f.host.install_from_dir(dir.path()).unwrap();
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let installed = f.plugins_dir.join("theme-pair");
assert!(std::fs::read_to_string(installed.join("LICENSE")).unwrap().contains("MIT License"));
assert!(installed.join("NOTICE").is_file());
for (theme, kind) in [("day", ThemeKind::Light), ("night", ThemeKind::Dark)] {
let active = f.host.set_theme(Some(("theme-pair", theme))).unwrap().unwrap();
assert_eq!(active.theme.kind, kind);
}
}
#[test]
fn theme_frame_follows_the_selection() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("theme-one")).unwrap();
let dir = tempfile::tempdir().unwrap();
write_theme_pair(dir.path());
f.host.install_from_dir(dir.path()).unwrap();
// Nothing to attach it to without a selected theme.
f.host.set_theme_frame(Some("#21252b")).unwrap();
f.host.set_theme_frame(Some("#111111")).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_theme(Some(("theme-one", "one-dark"))).unwrap();
f.host.set_theme(Some(("theme-pair", "night"))).unwrap();
assert!(f.host.set_theme_frame(Some("hsl(220, 13%, 15%)")).is_err());
f.host.set_theme_frame(Some("#21252b")).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#21252b"));
f.host.set_enabled("theme-one", false).unwrap();
f.host.set_theme_frame(Some("#111111")).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#111111"));
f.host.set_enabled("theme-pair", false).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_enabled("theme-one", true).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#21252b"));
f.host.set_enabled("theme-pair", true).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#111111"));
// A new selection drops the old theme's colour until the UI reports the new one.
f.host.set_theme(Some(("theme-one", "one-light"))).unwrap();
f.host.set_theme(Some(("theme-pair", "day"))).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_theme_frame(Some("#eaeaeb")).unwrap();
f.host.set_theme_frame(Some("#eeeeee")).unwrap();
f.host.set_theme(None).unwrap();
assert_eq!(f.host.startup_frame(), None);
}
@@ -327,14 +352,14 @@ fn theme_with_broken_editor_scheme_fails_to_load() {
fn state_persists_across_restarts() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("hello")).unwrap();
f.host.install_from_dir(&sample("theme-solarized")).unwrap();
f.host.install_from_dir(&theme_template()).unwrap();
f.host.set_enabled("hello", false).unwrap();
f.host.set_theme(Some(("theme-solarized", "solarized-dark"))).unwrap();
f.host.set_theme_frame(Some("#073642")).unwrap();
f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap();
f.host.set_theme_frame(Some("#1f252e")).unwrap();
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
// Known before the plugins are loaded: new windows open in the theme's frame colour.
assert_eq!(again.startup_frame().as_deref(), Some("#073642"));
assert_eq!(again.startup_frame().as_deref(), Some("#1f252e"));
assert!(!again.wait_initial_load(Duration::from_millis(1)));
again.load_all();
assert!(again.wait_initial_load(Duration::ZERO));
@@ -342,7 +367,7 @@ fn state_persists_across_restarts() {
let hello = list.plugins.iter().find(|p| p.id == "hello").unwrap();
assert!(!hello.enabled);
assert_eq!(hello.status, PluginStatus::Disabled);
assert_eq!(list.active_theme.unwrap().theme.id, "solarized-dark");
assert_eq!(list.active_theme.unwrap().theme.id, "harbor-night");
assert!(
again
.run_command("hello", "insert-date", None, Invocation::default())
@@ -436,16 +461,16 @@ fn catalog_lists_sample_packages() {
std::fs::create_dir_all(tmp.path().join("junk")).unwrap();
std::fs::write(tmp.path().join("junk/plugin.toml"), "garbage = ").unwrap();
let catalog = scan_catalog(&[repo().join("plugins"), tmp.path().to_path_buf()]);
let catalog = scan_catalog(&[repo().join("plugins"), repo().join("templates"), tmp.path().to_path_buf()]);
let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect();
for id in ["hello", "theme-solarized", "theme-one", "language-toy", "x"] {
for id in ["hello", "theme-harbor-night", "language-toy", "x"] {
assert!(ids.contains(&id), "{id} missing from {ids:?}");
}
let hello = catalog.iter().find(|c| c.id == "hello").unwrap();
assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some());
assert_eq!(hello.commands.len(), 2);
let theme = catalog.iter().find(|c| c.id == "theme-solarized").unwrap();
assert_eq!((theme.theme_count, theme.has_code), (2, false));
let theme = catalog.iter().find(|c| c.id == "theme-harbor-night").unwrap();
assert_eq!((theme.theme_count, theme.has_code), (1, false));
let x = catalog.iter().find(|c| c.id == "x").unwrap();
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

+5 -30
View File
@@ -1,7 +1,8 @@
# Theme plugins
OpenIDEA ships three built-in themes (Islands Light, Islands Dark, High
Contrast). Everything else comes from **theme plugins**, the same model as
OpenIDEA ships seven [built-in themes](../themes.md): Islands Light, Islands Dark,
High Contrast, Solarized Light, Solarized Dark, One Light and One Dark. Additional
themes come from **theme plugins**, the same model as
IntelliJ IDEA, where most themes are plugins rather than part of the IDE.
A theme plugin is data only. Like an IntelliJ theme (a `.theme.json` plus an
@@ -16,31 +17,6 @@ my-theme/
└─ my-theme.scheme.json # editor colours, optional (IntelliJ: .icls)
```
## Ready-made theme plugins
The repository's [`plugins/`](../../plugins) folder holds two complete theme
plugins, each with a light and a dark theme and an editor colour scheme
(syntax, editor and terminal ANSI colours):
| Package | Themes | Palette |
|---|---|---|
| [`plugins/theme-solarized`](../../plugins/theme-solarized) | Solarized Light, Solarized Dark | [Solarized](https://github.com/altercation/solarized) by Ethan Schoonover |
| [`plugins/theme-one`](../../plugins/theme-one) | One Light, One Dark | Atom [One Light](https://github.com/atom/one-light-syntax) / [One Dark](https://github.com/atom/one-dark-syntax) by GitHub |
Both palettes are MIT-licensed; each package carries the notice in its
`LICENSE`, which is installed with it. To use one:
1. *Settings › Plugins › ⚙ › Install Plugin from Disk…* and pick the package
folder (`plugins/theme-solarized` or `plugins/theme-one` of a checkout or a
downloaded copy). Development builds also list both on the *Available*
tab, where *Install* does the same.
2. Select the plugin and press *Apply* next to a theme.
The IDE remembers the theme: the next start-up opens its windows, splash and
workbench in the theme's colours before the plugin host has loaded.
## Writing a theme plugin
The quickest way to start is to copy [`templates/theme-plugin`](../../templates/theme-plugin).
It contains *Harbor Night*, a complete example dark theme that sets every
themeable token. The rest of this page covers the file format, the token
@@ -307,9 +283,8 @@ A `fontStyle: "italic"` on comments maps to `"--syn-comment-style": "italic"`.
- Text should keep at least WCAG AA contrast (4.5:1) on its surfaces. The
built-in themes are checked by `src/features/settings/__tests__/contrast.test.ts`.
- Repository checks (for changes to this repo): `pnpm vitest run scripts/theme`
validates the template and the theme plugins in `plugins/` against
tokens.css and keeps this reference current, and `cargo test -p ide-plugin`
loads them through the plugin host.
validates the template against tokens.css and keeps this reference current,
and `cargo test -p ide-plugin` loads the template through the plugin host.
## Token reference
+100
View File
@@ -0,0 +1,100 @@
# Built-in theme sources
OpenIDEA includes Islands Light, Islands Dark, High Contrast, Solarized Light,
Solarized Dark, One Light and One Dark. Select them in Settings › Appearance,
Welcome › Customize or the existing quick theme menu. The same themes are
available as explicit editor schemes; “Theme default” follows the UI theme.
“Sync with OS” uses the existing preferred light/dark theme settings. Existing
ids (`light`, `dark`, `dark-hc`, `system`) and attribute overrides remain valid.
## Upstream palettes and licenses
All sources below are the original authors’ repositories, pinned for reproducibility.
Full MIT notices are preserved in [THIRD_PARTY_LICENSES.md](../THIRD_PARTY_LICENSES.md).
| Theme | Original source revision | Palette / mapping source |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Solarized Light / Dark | [Ethan Schoonover, 62f656a](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3) | README’s sixteen exact sRGB hex colours; `vim-colors-solarized/colors/solarized.vim` semantic roles; iTerm2 ANSI slots |
| One Light syntax | [GitHub / Atom, d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438) | `styles/colors.less`, `styles/syntax-variables.less` |
| One Dark syntax | [GitHub / Atom, 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1) | `styles/colors.less`, `styles/syntax-variables.less` |
| One Light UI | [GitHub / Atom, a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8) | `styles/ui-variables.less`, `styles/ui-variables-custom.less` |
| One Dark UI | [GitHub / Atom, 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17) | `styles/ui-variables.less`, `styles/ui-variables-custom.less` |
## Mapping into OpenIDEA
`src/platform/themes.ts` registers the stable ids. `data-theme` retains the
light/dark kind for icons, native controls and existing selectors;
`data-color-theme` selects the additional palette. High Contrast continues to
use `data-contrast="high"`. Plugin themes clear the built-in palette attribute
and retain their existing light/dark fallback; removing a plugin restores the
chosen built-in palette.
`src/styles/tokens.css` contains the shared mapping for all colour tokens and
four palette blocks. No new dependency is required. Solarized uses base3/base2
for light backgrounds and base03/base02 for dark backgrounds, base00/base0 for
text, and the original eight accents. Vim’s statements are green, constants
and strings cyan, identifiers blue, types yellow, and comments the original
subdued base1/base01. Solarized’s original ANSI slot order is preserved (the
bright slots intentionally include orange, violet and neutrals).
One retains the exact HSL definitions from Atom, avoiding rounded palette
approximations: 230° / 1% / 98% light and 220° / 13% / 18% dark editor backgrounds;
purple keywords, green strings, blue functions, red variables/tags, orange
numbers and attributes, yellow classes, and the original mono-3 comments.
UI frame/popup levels use Atom’s relative background lightness formulas with
the matching syntax theme. OpenIDEA’s shared UI text uses the syntax foreground
and mono-2 for readable secondary labels. Selection and cursor-line values
follow Atom’s syntax variables. Atom does not define a terminal ANSI palette:
One’s ANSI slots reuse its exact semantic syntax hues, repeating them for bright
colours rather than inventing a different palette. One Light maps ANSI white
and bright white to its mono-2 and mono-1 text colours so they remain visible
on the light terminal background.
Components without an upstream equivalent (feedback banners, tool window states,
search fills and focus decoration) use the original semantic hues, neutrals,
or explicit `color-mix()` tints. This adapts colours to the existing layout;
it does not recreate Atom’s or Vim’s UI. Platform traffic-light colours stay
platform colours. Original subdued comments and some syntax accents have lower
contrast than a high-contrast accessibility theme; High Contrast remains available.
Native windows open in the theme's frame colour before their page loads:
`src-tauri/src/theme_frame.rs` reads `--bg-frame` of each built-in theme from
`tokens.css` itself (resolving `var()` and `hsl()`), so there is no second
colour table to keep in sync. Elsewhere the frontend resolves HSL and
`color-mix()` tokens to RGB (`src/platform/cssColor.ts`) for Tauri's window
API, the native Settings window, Mermaid and the terminal.
`public/boot.js` restores the palette (`data-color-theme`) and the splash's
resolved frame, text and accent colours before the application stylesheet
loads, so a restart in One Dark or Solarized shows no flash of another theme.
The backend's settings persistence remains authoritative. Terminal, Mermaid
and editor-scheme observers also watch palette changes within the same
light/dark kind. Custom accents, explicit editor schemes, per-scheme attribute
overrides, compact mode and accessibility options retain their precedence.
`src/features/settings/__tests__/contrast.test.ts` requires every palette theme
to define every colour token of the base themes (in the shared mapping or its
own block), so a token added to `tokens.css` later can't silently fall back to
an Islands colour.
## Browser evidence
These approved screenshots are reused from the original theme implementation on
0.1.15; regression checks are rerun against the upstream contribution base.
Captured at 1400 × 900 in Linux Chromium 154 using OpenIDEA's public in-memory
`acme-api` project / Kotlin fixture and synthetic ANSI terminal output. The
terminal is a real xterm renderer, without a native PTY or shell. These images
cover the shell, sidebar, editor, gutter, find matches, terminal, Settings,
focused controls, disabled settings and the selected theme popup.
| Theme | Workbench, editor and terminal | Settings and theme popup |
|---|---|---|
| Solarized Light | [Screenshot](media/themes/solarized-light-workbench.png) | [Screenshot](media/themes/solarized-light-popup.png) |
| Solarized Dark | [Screenshot](media/themes/solarized-dark-workbench.png) | [Screenshot](media/themes/solarized-dark-popup.png) |
| One Light | [Screenshot](media/themes/one-light-workbench.png) | [Screenshot](media/themes/one-light-popup.png) |
| One Dark | [Screenshot](media/themes/one-dark-workbench.png) | [Screenshot](media/themes/one-dark-popup.png) |
The browser regression also checks refresh / startup cache restoration,
selection visibility, same-kind switches, the quick menu, existing themes,
explicit editor schemes and preferred OS themes. Native startup colours are
covered by Rust unit tests.
+188
View File
@@ -0,0 +1,188 @@
import { expect, openFromTree, test } from "./fixtures";
import type { Page } from "@playwright/test";
test.setTimeout(60_000);
const palettes = [
{
id: "solarized-light",
label: "Solarized Light",
kind: "light",
bg: "rgb(253, 246, 227)",
fg: "rgb(101, 123, 131)",
frame: [238, 232, 213, 255],
},
{
id: "solarized-dark",
label: "Solarized Dark",
kind: "dark",
bg: "rgb(0, 43, 54)",
fg: "rgb(131, 148, 150)",
frame: [7, 54, 66, 255],
},
{
id: "one-light",
label: "One Light",
kind: "light",
bg: "rgb(250, 250, 250)",
fg: "rgb(56, 58, 66)",
frame: [234, 234, 235, 255],
},
{
id: "one-dark",
label: "One Dark",
kind: "dark",
bg: "rgb(40, 44, 52)",
fg: "rgb(171, 178, 191)",
frame: [33, 37, 43, 255],
},
];
async function choose(page: Page, control: string, label: string) {
await page.getByRole("button", { name: control, exact: true }).click();
await page.getByRole("option", { name: label, exact: true }).click();
}
async function settings(page: Page) {
await page.keyboard.press("Meta+,");
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
await expect(dialog).toBeVisible();
await dialog
.locator('[role="treeitem"][aria-level="2"]')
.filter({ hasText: /^Appearance$/ })
.click();
return dialog;
}
for (const palette of palettes) {
test(`${palette.label}: settings, editor, terminal, popup and reload`, async ({ workbench: page }, info) => {
const editor = await openFromTree(page, "build.gradle.kts");
await page.keyboard.press("Alt+F12");
await expect(page.locator(".xterm")).toBeVisible();
// Use only synthetic ANSI output; no shell, network or native PTY is involved.
await page.evaluate(async () => {
const path = "/src/features/terminal/store.ts";
const { activeSession } = await import(/* @vite-ignore */ path);
const session = activeSession();
session.term.clear();
session.term.writeln("Synthetic theme regression — no PTY");
session.term.writeln("\x1b[31merror\x1b[0m \x1b[33mwarning\x1b[0m \x1b[32msuccess\x1b[0m \x1b[34minfo\x1b[0m");
});
const dialog = await settings(page);
await choose(page, "Theme", palette.label);
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-color-theme", palette.id);
await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind);
expect(
await page.evaluate(async () => {
const path = "/src/workbench/theme.ts";
const { windowBackgroundColor, windowBackgroundHex } = await import(/* @vite-ignore */ path);
return { rgba: windowBackgroundColor(), hex: windowBackgroundHex() };
}),
).toEqual({
rgba: palette.frame,
hex:
"#" +
palette.frame
.slice(0, 3)
.map((n) => n.toString(16).padStart(2, "0"))
.join(""),
});
await page.screenshot({ path: info.outputPath(`${palette.id}-settings.png`), animations: "disabled" });
await page.keyboard.press("Escape");
await expect(editor).toHaveCSS("background-color", palette.bg);
await expect(editor).toHaveCSS("color", palette.fg);
await expect(editor.locator(".cm-gutters")).toHaveCSS("background-color", palette.bg);
await expect
.poll(() =>
page.evaluate(async () => {
const path = "/src/features/terminal/store.ts";
const { activeSession } = await import(/* @vite-ignore */ path);
return activeSession().term.options.theme.background;
}),
)
.toBe(
palette.id === "solarized-light"
? "#fdf6e3"
: palette.id === "solarized-dark"
? "#002b36"
: palette.id === "one-light"
? "#fafafa"
: "#282c34",
);
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await expect(editor.locator(".cm-selectionBackground").first()).toBeVisible();
await page.keyboard.press("ArrowRight");
await page.keyboard.press("Meta+f");
await page.getByRole("textbox", { name: "Find", exact: true }).fill("Order");
await expect(editor.locator(".cm-openidea-findMatch").first()).toBeVisible();
await page.screenshot({ path: info.outputPath(`${palette.id}-workbench.png`), animations: "disabled" });
await page.keyboard.press("Escape");
await settings(page);
await page.getByRole("button", { name: "Theme", exact: true }).click();
await expect(page.getByRole("listbox", { name: "Theme", exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath(`${palette.id}-popup.png`), animations: "disabled" });
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await page.reload();
await expect(page.getByRole("tree", { name: "Project" })).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("data-color-theme", palette.id);
const cached = await page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme")!));
expect(cached).toMatchObject({ theme: palette.kind, colorTheme: palette.id });
});
}
test("same-kind switching, legacy themes, system mode and explicit editor schemes", async ({ workbench: page }) => {
await openFromTree(page, "build.gradle.kts");
const dialog = await settings(page);
for (const label of [
"Solarized Light",
"One Light",
"Solarized Dark",
"One Dark",
"Islands Light",
"Islands Dark",
"High Contrast",
]) {
await choose(page, "Theme", label);
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
if (label === "High Contrast") await expect(page.locator("html")).toHaveAttribute("data-contrast", "high");
else await expect(page.locator("html")).not.toHaveAttribute("data-contrast");
const added = palettes.find((p) => p.label === label);
if (added) await expect(page.locator("html")).toHaveAttribute("data-color-theme", added.id);
else await expect(page.locator("html")).not.toHaveAttribute("data-color-theme");
const bg =
added?.bg ??
(label === "Islands Light"
? "rgb(255, 255, 255)"
: label === "Islands Dark"
? "rgb(25, 26, 28)"
: "rgb(0, 0, 0)");
await expect(page.locator(".cm-editor").first()).toHaveCSS("background-color", bg);
}
await choose(page, "Theme", "One Light");
await choose(page, "Editor color scheme", "Solarized Dark");
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.locator(".cm-editor").first()).toHaveCSS("background-color", "rgb(0, 43, 54)");
await choose(page, "Editor color scheme", "Theme default");
await dialog.getByLabel("Sync with OS", { exact: true }).check();
await dialog.getByRole("button", { name: "Preferred themes", exact: true }).click();
await choose(page, "Preferred light theme", "Solarized Light");
await choose(page, "Preferred dark theme", "One Dark");
await page.keyboard.press("Escape");
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await page.emulateMedia({ colorScheme: "light" });
await expect(page.locator("html")).toHaveAttribute("data-color-theme", "solarized-light");
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.locator("html")).toHaveAttribute("data-color-theme", "one-dark");
});
test("quick theme menu offers all four palettes and applies their kind", async ({ workbench: page }) => {
for (const palette of palettes) {
await page.getByRole("button", { name: "IDE and Project Settings", exact: true }).click();
await page.getByText("Theme", { exact: true }).click();
await page.getByText(palette.label, { exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-color-theme", palette.id);
await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind);
}
});
+3 -5
View File
@@ -11,8 +11,6 @@ code at all.
plugins/
├─ build.sh # builds Rust plugins into components
├─ hello/ # Rust plugin: two commands
├─ theme-solarized/ # manifest-only: Solarized Light / Dark themes
├─ theme-one/ # manifest-only: One Light / Dark themes
└─ language-toy/ # manifest-only: a language definition
```
@@ -171,10 +169,10 @@ never affected.
```json
{
"id": "solarized-dark",
"name": "Solarized Dark",
"id": "harbor-night",
"name": "Harbor Night",
"kind": "dark",
"tokens": { "--bg-app": "#002b36", "--accent": "#268bd2", "--syn-keyword": "#859900" }
"tokens": { "--bg-app": "#14171c", "--accent": "#2b9e93", "--syn-keyword": "#e0a46e" }
}
```
-60
View File
@@ -1,60 +0,0 @@
The One Light and One Dark palettes are adapted from GitHub's Atom One themes
(styles/colors.less, styles/syntax-variables.less, styles/ui-variables.less):
- https://github.com/atom/one-light-syntax (revision d84579027410c576086dfca14d934c4bd74b0438)
- https://github.com/atom/one-dark-syntax (revision 9c96f4454362267ac45322063e193ccf9d2debb1)
- https://github.com/atom/one-light-ui (revision a27bce35088ecd323e44f5c2510bcf2d4e9efcf8)
- https://github.com/atom/one-dark-ui (revision 18c2143d41e17b337fff6dc7024562c4d999be17)
The exact HSL definitions are kept. Atom defines no terminal palette, so the
ANSI slots reuse One's syntax hues. Adapted to OpenIDEA's design tokens by bghtyu.
## one-light-syntax, one-dark-syntax
MIT License
Copyright (c) 2016 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## one-light-ui, one-dark-ui
MIT License
Copyright (c) 2014 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-17
View File
@@ -1,17 +0,0 @@
# Contribution-only theme plugin: no code, just theme and editor scheme files.
id = "theme-one"
name = "One Themes"
version = "1.0.0"
description = "One Light and One Dark: the Atom One palettes by GitHub for the UI, the editor and the terminal."
authors = ["bghtyu <3931591+bghtyu@users.noreply.github.com>", "GitHub Inc. (Atom One palettes)"]
homepage = "https://github.com/atom/one-dark-syntax"
change-notes = "1.0.0 — One Light and One Dark: UI, editor colour scheme and terminal ANSI colours."
openidea-api = "0.1"
[[contributes.themes]]
path = "themes/one-light.theme.json"
editor-scheme = "themes/one-light.scheme.json"
[[contributes.themes]]
path = "themes/one-dark.theme.json"
editor-scheme = "themes/one-dark.scheme.json"
@@ -1,103 +0,0 @@
// Atom One palette by GitHub Inc. (https://github.com/atom/one-dark-syntax and friends),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// Editor colour scheme: syntax, editor surfaces and terminal ANSI colours.
{
"name": "One Dark",
"tokens": {
// Syntax
"--syn-keyword": "hsl(286, 60%, 67%)",
"--syn-string": "hsl(95, 38%, 62%)",
"--syn-type": "hsl(39, 67%, 69%)",
"--syn-function": "hsl(207, 82%, 66%)",
"--syn-function-call": "hsl(207, 82%, 66%)",
"--syn-comment": "hsl(220, 10%, 40%)",
"--syn-comment-style": "italic",
"--syn-doc-comment": "hsl(220, 10%, 40%)",
"--syn-docstring": "hsl(95, 38%, 62%)",
"--syn-annotation": "hsl(29, 54%, 61%)",
"--syn-decorator": "hsl(29, 54%, 61%)",
"--syn-number": "hsl(29, 54%, 61%)",
"--syn-punct": "hsl(220, 14%, 71%)",
"--syn-variable": "hsl(355, 65%, 65%)",
"--syn-param": "hsl(220, 14%, 71%)",
"--syn-property": "hsl(220, 14%, 71%)",
"--syn-constant": "hsl(29, 54%, 61%)",
"--syn-builtin": "hsl(187, 47%, 55%)",
"--syn-self": "hsl(355, 65%, 65%)",
"--syn-keyword-arg": "hsl(355, 65%, 65%)",
"--syn-predefined": "hsl(187, 47%, 55%)",
"--syn-escape": "hsl(187, 47%, 55%)",
"--syn-regexp": "hsl(187, 47%, 55%)",
"--syn-tag": "hsl(355, 65%, 65%)",
"--syn-attribute": "hsl(29, 54%, 61%)",
"--syn-todo": "hsl(286, 60%, 67%)",
"--syn-link": "hsl(207, 82%, 66%)",
"--syn-markup-heading": "hsl(207, 82%, 66%)",
"--syn-markup-code": "hsl(95, 38%, 62%)",
"--syn-invalid": "hsl(355, 65%, 65%)",
"--syn-inline-value": "hsl(220, 9%, 55%)",
"--syn-rainbow-1": "hsl(187, 47%, 55%)",
"--syn-rainbow-2": "hsl(29, 54%, 61%)",
"--syn-rainbow-3": "hsl(95, 38%, 62%)",
"--syn-rainbow-4": "hsl(286, 60%, 67%)",
"--syn-rainbow-5": "hsl(207, 82%, 66%)",
"--syn-bracket-1": "hsl(39, 67%, 69%)",
"--syn-bracket-2": "hsl(286, 60%, 67%)",
"--syn-bracket-3": "hsl(207, 82%, 66%)",
// Editor
"--fg-editor": "hsl(220, 14%, 71%)",
"--caret": "hsl(220, 100%, 66%)",
"--fg-line-number": "hsl(220, 9%, 55%)",
"--fg-line-number-current": "hsl(220, 14%, 71%)",
"--border-gutter": "hsl(220, 13%, 10.8%)",
"--border-indent-guide": "hsl(220, 13%, 10.8%)",
"--border-indent-guide-active": "hsl(220, 100%, 66%)",
"--border-right-margin": "hsl(220, 13%, 10.8%)",
"--bg-editor-selection": "hsl(220, 13%, 28%)",
"--bg-editor-selection-inactive": "hsl(220, 13%, 28%)",
"--bg-text-search-result": "color-mix(in srgb, hsl(39, 67%, 69%) 22%, hsl(220, 13%, 18%))",
"--bg-text-search-result-current": "color-mix(in srgb, hsl(39, 67%, 69%) 36%, hsl(220, 13%, 18%))",
"--bg-matched-brace": "hsl(220, 13%, 28%)",
"--matched-brace-weight": "var(--fw-code-bold)",
"--bg-matched-tag": "hsl(220, 13%, 28%)",
"--bg-fold-placeholder": "hsl(220, 13%, 15%)",
"--fg-fold-placeholder": "hsl(220, 14%, 71%)",
"--fg-fold-marker": "hsl(220, 9%, 55%)",
"--fg-whitespace": "hsl(220, 9%, 55%)",
"--fg-unused": "hsl(220, 9%, 55%)",
"--bg-identifier-under-caret": "hsl(220, 13%, 28%)",
"--bg-identifier-under-caret-write": "color-mix(in srgb, hsl(355, 65%, 65%) 12%, hsl(220, 13%, 18%))",
"--fg-inlay": "hsl(220, 9%, 55%)",
"--bg-inlay": "hsl(220, 13%, 15%)",
"--wave-error": "hsl(355, 65%, 65%)",
"--wave-warning": "hsl(39, 67%, 69%)",
"--editor-error-decoration": "underline wavy var(--wave-error)",
"--editor-warning-decoration": "underline wavy var(--wave-warning)",
"--editor-warning-background": "color-mix(in srgb, hsl(39, 67%, 69%) 12%, hsl(220, 13%, 18%))",
"--bg-breakpoint-line": "color-mix(in srgb, hsl(355, 65%, 65%) 12%, hsl(220, 13%, 18%))",
"--fg-completion-match": "hsl(207, 82%, 66%)",
"--bg-editor-inline": "hsl(220, 13%, 15%)",
"--bg-editor-banner": "color-mix(in srgb, hsl(207, 82%, 66%) 12%, hsl(220, 13%, 18%))",
"--fg-weak-warning": "hsl(220, 9%, 55%)",
"--fg-bulb": "hsl(39, 67%, 69%)",
// ANSI palette
"--ansi-black": "hsl(220, 13%, 15%)",
"--ansi-red": "hsl(355, 65%, 65%)",
"--ansi-green": "hsl(95, 38%, 62%)",
"--ansi-yellow": "hsl(39, 67%, 69%)",
"--ansi-blue": "hsl(207, 82%, 66%)",
"--ansi-magenta": "hsl(286, 60%, 67%)",
"--ansi-cyan": "hsl(187, 47%, 55%)",
"--ansi-white": "hsl(220, 14%, 71%)",
"--ansi-bright-black": "hsl(220, 9%, 55%)",
"--ansi-bright-red": "hsl(355, 65%, 65%)",
"--ansi-bright-green": "hsl(95, 38%, 62%)",
"--ansi-bright-yellow": "hsl(39, 67%, 69%)",
"--ansi-bright-blue": "hsl(207, 82%, 66%)",
"--ansi-bright-magenta": "hsl(286, 60%, 67%)",
"--ansi-bright-cyan": "hsl(187, 47%, 55%)",
"--ansi-bright-white": "#ffffff"
}
}
@@ -1,142 +0,0 @@
// Atom One palette by GitHub Inc. (https://github.com/atom/one-dark-syntax and friends),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// UI theme; the editor colour scheme is in one-dark.scheme.json.
{
"id": "one-dark",
"name": "One Dark",
"kind": "dark",
"tokens": {
// Surfaces
"--bg-app": "hsl(220, 13%, 18%)",
"--bg-elev": "hsl(220, 13%, 15%)",
"--bg-popup": "hsl(220, 13%, 16.5%)",
"--bg-hover": "hsl(220, 13%, 22%)",
"--bg-selected": "hsl(220, 13%, 28%)",
"--bg-selected-strong": "hsl(220, 13%, 28%)",
"--bg-current-line": "color-mix(in srgb, hsl(220, 100%, 80%) 4%, hsl(220, 13%, 18%))",
"--bg-debug-line": "color-mix(in srgb, hsl(207, 82%, 66%) 12%, hsl(220, 13%, 18%))",
"--bg-backdrop": "hsl(220, 13%, 15%)",
"--bg-input": "hsl(220, 13%, 18%)",
// Lines
"--border": "hsl(220, 13%, 10.8%)",
"--border-popup": "hsl(220, 13%, 10.8%)",
"--border-strong": "hsl(220, 9%, 55%)",
"--border-window": "hsl(220, 13%, 10.8%)",
"--control-off": "hsl(220, 9%, 55%)",
// Text
"--fg-strong": "hsl(220, 14%, 71%)",
"--fg-emphasis": "hsl(220, 14%, 71%)",
"--fg": "hsl(220, 14%, 71%)",
"--fg-secondary": "hsl(220, 9%, 55%)",
"--fg-muted": "hsl(220, 9%, 55%)",
"--fg-subtle": "hsl(220, 9%, 55%)",
"--fg-faint": "hsl(220, 9%, 55%)",
"--fg-gutter": "hsl(220, 9%, 55%)",
"--fg-icon": "hsl(220, 14%, 71%)",
// Accent
"--accent": "hsl(220, 100%, 66%)",
"--link": "hsl(207, 82%, 66%)",
"--link-hover": "hsl(187, 47%, 55%)",
// Charts
"--chart-series": "hsl(355, 65%, 65%)",
// Status
"--danger": "hsl(355, 65%, 65%)",
"--warning": "hsl(39, 67%, 69%)",
"--success": "hsl(95, 38%, 62%)",
"--db-connected": "hsl(95, 38%, 62%)",
"--db-connected-ring": "hsl(95, 38%, 62%)",
"--info": "hsl(207, 82%, 66%)",
// Vcs
"--vcs-modified": "hsl(207, 82%, 66%)",
"--vcs-added": "hsl(95, 38%, 62%)",
"--vcs-deleted": "hsl(220, 9%, 55%)",
"--vcs-untracked": "hsl(355, 65%, 65%)",
"--vcs-ignored": "hsl(39, 67%, 69%)",
"--vcs-conflict": "hsl(355, 65%, 65%)",
"--gutter-modified": "hsl(207, 82%, 66%)",
"--gutter-added": "hsl(95, 38%, 62%)",
"--gutter-deleted": "hsl(355, 65%, 65%)",
// Shadows and window controls
"--shadow-popup": "0 4px 16px #00000024",
"--shadow-dialog": "0 10px 36px #00000033",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "hsl(220, 13%, 22%)",
"--traffic-off-border": "hsl(220, 13%, 10.8%)",
// Additive
"--bg-tooltip": "hsl(220, 13%, 16.5%)",
"--fg-tooltip": "hsl(220, 14%, 71%)",
"--border-tooltip": "hsl(220, 13%, 10.8%)",
"--bg-control": "hsl(220, 13%, 18%)",
"--bg-control-hover": "hsl(220, 13%, 22%)",
"--bg-active": "hsl(220, 13%, 22%)",
"--bg-scrim": "color-mix(in srgb, hsl(220, 13%, 18%) 68%, transparent)",
"--fg-on-accent": "#000000",
"--shadow-tooltip": "0 4px 16px #00000024",
"--shadow-balloon": "0 10px 36px #00000033",
// Islands contract
"--bg-frame": "hsl(220, 13%, 15%)",
"--bg-island": "hsl(220, 13%, 18%)",
"--bg-stripe-hover": "hsl(220, 13%, 22%)",
"--bg-stripe-selected": "hsl(220, 13%, 28%)",
"--fg-stripe-selected-active": "#000000",
"--fg-stripe-label": "hsl(220, 14%, 71%)",
"--bg-tab-selected": "hsl(220, 13%, 28%)",
"--border-tab-selected": "hsl(220, 100%, 66%)",
"--bg-tab-selected-inactive": "hsl(220, 13%, 15%)",
"--border-tab-selected-inactive": "hsl(220, 13%, 10.8%)",
"--bg-tab-hover": "hsl(220, 13%, 22%)",
"--border-editor-tabs": "hsl(220, 13%, 10.8%)",
"--bg-tree-selected": "hsl(220, 13%, 28%)",
"--bg-tree-selected-inactive": "hsl(220, 13%, 22%)",
"--bg-tree-hover": "hsl(220, 13%, 22%)",
"--bg-toolbar-hover": "hsl(220, 13%, 22%)",
"--bg-toolbar-pressed": "hsl(220, 13%, 28%)",
"--bg-toolbar-selected": "hsl(220, 13%, 28%)",
"--bg-toolbar-selected-hover": "hsl(220, 13%, 28%)",
"--bg-button-primary-hover": "color-mix(in srgb, var(--accent) 86%, hsl(220, 14%, 71%))",
"--fg-button-primary": "#000000",
"--bg-button": "hsl(220, 13%, 18%)",
"--border-button": "hsl(220, 13%, 10.8%)",
"--bg-field": "hsl(220, 13%, 18%)",
"--bg-checkbox": "hsl(220, 13%, 18%)",
"--border-checkbox": "hsl(220, 9%, 55%)",
"--bg-toggle-on": "hsl(95, 38%, 62%)",
"--fg-toggle-on": "#000000",
"--bg-toggle-off": "hsl(220, 13%, 15%)",
"--fg-toggle-off": "hsl(220, 14%, 71%)",
"--border-toggle": "hsl(220, 9%, 55%)",
"--bg-toggle-knob": "hsl(220, 9%, 55%)",
"--focus-ring-soft": "hsl(220, 100%, 66%)",
"--scrollbar-thumb": "color-mix(in srgb, hsl(220, 14%, 71%) 24%, transparent)",
"--scrollbar-thumb-hover": "color-mix(in srgb, hsl(220, 14%, 71%) 42%, transparent)",
"--bg-search-match": "color-mix(in srgb, hsl(39, 67%, 69%) 22%, hsl(220, 13%, 18%))",
"--bg-search-match-current": "color-mix(in srgb, hsl(39, 67%, 69%) 36%, hsl(220, 13%, 18%))",
"--fg-search-match": "hsl(220, 14%, 71%)",
"--bg-dialog": "hsl(220, 13%, 18%)",
"--bg-popup-inline": "hsl(220, 13%, 15%)",
"--bg-popup-advertiser": "hsl(220, 13%, 15%)",
"--bg-feedback-info": "color-mix(in srgb, hsl(207, 82%, 66%) 12%, hsl(220, 13%, 18%))",
"--border-feedback-info": "color-mix(in srgb, hsl(207, 82%, 66%) 55%, hsl(220, 13%, 18%))",
"--bg-feedback-error": "color-mix(in srgb, hsl(355, 65%, 65%) 12%, hsl(220, 13%, 18%))",
"--border-feedback-error": "color-mix(in srgb, hsl(355, 65%, 65%) 55%, hsl(220, 13%, 18%))",
"--bg-feedback-warning": "color-mix(in srgb, hsl(39, 67%, 69%) 12%, hsl(220, 13%, 18%))",
"--border-feedback-warning": "color-mix(in srgb, hsl(39, 67%, 69%) 55%, hsl(220, 13%, 18%))",
"--bg-feedback-success": "color-mix(in srgb, hsl(95, 38%, 62%) 12%, hsl(220, 13%, 18%))",
"--border-feedback-success": "color-mix(in srgb, hsl(95, 38%, 62%) 55%, hsl(220, 13%, 18%))",
"--bg-counter": "hsl(220, 100%, 66%)",
"--fg-counter": "#000000",
"--bg-progress-track": "hsl(220, 13%, 22%)"
}
}
@@ -1,103 +0,0 @@
// Atom One palette by GitHub Inc. (https://github.com/atom/one-dark-syntax and friends),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// Editor colour scheme: syntax, editor surfaces and terminal ANSI colours.
{
"name": "One Light",
"tokens": {
// Syntax
"--syn-keyword": "hsl(301, 63%, 40%)",
"--syn-string": "hsl(119, 34%, 47%)",
"--syn-type": "hsl(41, 99%, 38%)",
"--syn-function": "hsl(221, 87%, 60%)",
"--syn-function-call": "hsl(221, 87%, 60%)",
"--syn-comment": "hsl(230, 4%, 64%)",
"--syn-comment-style": "italic",
"--syn-doc-comment": "hsl(230, 4%, 64%)",
"--syn-docstring": "hsl(119, 34%, 47%)",
"--syn-annotation": "hsl(41, 99%, 30%)",
"--syn-decorator": "hsl(41, 99%, 30%)",
"--syn-number": "hsl(41, 99%, 30%)",
"--syn-punct": "hsl(230, 8%, 24%)",
"--syn-variable": "hsl(5, 74%, 59%)",
"--syn-param": "hsl(230, 8%, 24%)",
"--syn-property": "hsl(230, 8%, 24%)",
"--syn-constant": "hsl(41, 99%, 30%)",
"--syn-builtin": "hsl(198, 99%, 37%)",
"--syn-self": "hsl(5, 74%, 59%)",
"--syn-keyword-arg": "hsl(5, 74%, 59%)",
"--syn-predefined": "hsl(198, 99%, 37%)",
"--syn-escape": "hsl(198, 99%, 37%)",
"--syn-regexp": "hsl(198, 99%, 37%)",
"--syn-tag": "hsl(5, 74%, 59%)",
"--syn-attribute": "hsl(41, 99%, 30%)",
"--syn-todo": "hsl(301, 63%, 40%)",
"--syn-link": "hsl(221, 87%, 60%)",
"--syn-markup-heading": "hsl(221, 87%, 60%)",
"--syn-markup-code": "hsl(119, 34%, 47%)",
"--syn-invalid": "hsl(5, 74%, 59%)",
"--syn-inline-value": "hsl(230, 6%, 44%)",
"--syn-rainbow-1": "hsl(198, 99%, 37%)",
"--syn-rainbow-2": "hsl(41, 99%, 30%)",
"--syn-rainbow-3": "hsl(119, 34%, 47%)",
"--syn-rainbow-4": "hsl(301, 63%, 40%)",
"--syn-rainbow-5": "hsl(221, 87%, 60%)",
"--syn-bracket-1": "hsl(41, 99%, 38%)",
"--syn-bracket-2": "hsl(301, 63%, 40%)",
"--syn-bracket-3": "hsl(221, 87%, 60%)",
// Editor
"--fg-editor": "hsl(230, 8%, 24%)",
"--caret": "hsl(230, 100%, 66%)",
"--fg-line-number": "hsl(230, 6%, 44%)",
"--fg-line-number-current": "hsl(230, 8%, 24%)",
"--border-gutter": "hsl(230, 1%, 86%)",
"--border-indent-guide": "hsl(230, 1%, 86%)",
"--border-indent-guide-active": "hsl(230, 100%, 66%)",
"--border-right-margin": "hsl(230, 1%, 86%)",
"--bg-editor-selection": "hsl(230, 1%, 90%)",
"--bg-editor-selection-inactive": "hsl(230, 1%, 90%)",
"--bg-text-search-result": "color-mix(in srgb, hsl(41, 99%, 38%) 22%, hsl(230, 1%, 98%))",
"--bg-text-search-result-current": "color-mix(in srgb, hsl(41, 99%, 38%) 36%, hsl(230, 1%, 98%))",
"--bg-matched-brace": "hsl(230, 1%, 90%)",
"--matched-brace-weight": "var(--fw-code-bold)",
"--bg-matched-tag": "hsl(230, 1%, 90%)",
"--bg-fold-placeholder": "hsl(230, 1%, 92%)",
"--fg-fold-placeholder": "hsl(230, 8%, 24%)",
"--fg-fold-marker": "hsl(230, 6%, 44%)",
"--fg-whitespace": "hsl(230, 6%, 44%)",
"--fg-unused": "hsl(230, 6%, 44%)",
"--bg-identifier-under-caret": "hsl(230, 1%, 90%)",
"--bg-identifier-under-caret-write": "color-mix(in srgb, hsl(5, 74%, 59%) 12%, hsl(230, 1%, 98%))",
"--fg-inlay": "hsl(230, 6%, 44%)",
"--bg-inlay": "hsl(230, 1%, 92%)",
"--wave-error": "hsl(5, 74%, 59%)",
"--wave-warning": "hsl(41, 99%, 38%)",
"--editor-error-decoration": "underline wavy var(--wave-error)",
"--editor-warning-decoration": "underline wavy var(--wave-warning)",
"--editor-warning-background": "color-mix(in srgb, hsl(41, 99%, 38%) 12%, hsl(230, 1%, 98%))",
"--bg-breakpoint-line": "color-mix(in srgb, hsl(5, 74%, 59%) 12%, hsl(230, 1%, 98%))",
"--fg-completion-match": "hsl(221, 87%, 60%)",
"--bg-editor-inline": "hsl(230, 1%, 92%)",
"--bg-editor-banner": "color-mix(in srgb, hsl(221, 87%, 60%) 12%, hsl(230, 1%, 98%))",
"--fg-weak-warning": "hsl(230, 6%, 44%)",
"--fg-bulb": "hsl(41, 99%, 38%)",
// ANSI palette
"--ansi-black": "hsl(230, 8%, 24%)",
"--ansi-red": "hsl(5, 74%, 59%)",
"--ansi-green": "hsl(119, 34%, 47%)",
"--ansi-yellow": "hsl(41, 99%, 38%)",
"--ansi-blue": "hsl(221, 87%, 60%)",
"--ansi-magenta": "hsl(301, 63%, 40%)",
"--ansi-cyan": "hsl(198, 99%, 37%)",
"--ansi-white": "hsl(230, 6%, 44%)",
"--ansi-bright-black": "hsl(230, 6%, 44%)",
"--ansi-bright-red": "hsl(5, 74%, 59%)",
"--ansi-bright-green": "hsl(119, 34%, 47%)",
"--ansi-bright-yellow": "hsl(41, 99%, 38%)",
"--ansi-bright-blue": "hsl(221, 87%, 60%)",
"--ansi-bright-magenta": "hsl(301, 63%, 40%)",
"--ansi-bright-cyan": "hsl(198, 99%, 37%)",
"--ansi-bright-white": "hsl(230, 8%, 24%)"
}
}
@@ -1,142 +0,0 @@
// Atom One palette by GitHub Inc. (https://github.com/atom/one-dark-syntax and friends),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// UI theme; the editor colour scheme is in one-light.scheme.json.
{
"id": "one-light",
"name": "One Light",
"kind": "light",
"tokens": {
// Surfaces
"--bg-app": "hsl(230, 1%, 98%)",
"--bg-elev": "hsl(230, 1%, 92%)",
"--bg-popup": "hsl(230, 1%, 95%)",
"--bg-hover": "hsl(230, 1%, 90%)",
"--bg-selected": "hsl(230, 1%, 90%)",
"--bg-selected-strong": "hsl(230, 1%, 90%)",
"--bg-current-line": "color-mix(in srgb, hsl(230, 8%, 24%) 5%, hsl(230, 1%, 98%))",
"--bg-debug-line": "color-mix(in srgb, hsl(221, 87%, 60%) 12%, hsl(230, 1%, 98%))",
"--bg-backdrop": "hsl(230, 1%, 92%)",
"--bg-input": "hsl(230, 1%, 98%)",
// Lines
"--border": "hsl(230, 1%, 86%)",
"--border-popup": "hsl(230, 1%, 86%)",
"--border-strong": "hsl(230, 6%, 44%)",
"--border-window": "hsl(230, 1%, 86%)",
"--control-off": "hsl(230, 6%, 44%)",
// Text
"--fg-strong": "hsl(230, 8%, 24%)",
"--fg-emphasis": "hsl(230, 8%, 24%)",
"--fg": "hsl(230, 8%, 24%)",
"--fg-secondary": "hsl(230, 6%, 44%)",
"--fg-muted": "hsl(230, 6%, 44%)",
"--fg-subtle": "hsl(230, 6%, 44%)",
"--fg-faint": "hsl(230, 6%, 44%)",
"--fg-gutter": "hsl(230, 6%, 44%)",
"--fg-icon": "hsl(230, 8%, 24%)",
// Accent
"--accent": "hsl(230, 100%, 66%)",
"--link": "hsl(221, 87%, 60%)",
"--link-hover": "hsl(198, 99%, 37%)",
// Charts
"--chart-series": "hsl(5, 74%, 59%)",
// Status
"--danger": "hsl(5, 74%, 59%)",
"--warning": "hsl(41, 99%, 38%)",
"--success": "hsl(119, 34%, 47%)",
"--db-connected": "hsl(119, 34%, 47%)",
"--db-connected-ring": "hsl(119, 34%, 47%)",
"--info": "hsl(221, 87%, 60%)",
// Vcs
"--vcs-modified": "hsl(221, 87%, 60%)",
"--vcs-added": "hsl(119, 34%, 47%)",
"--vcs-deleted": "hsl(230, 6%, 44%)",
"--vcs-untracked": "hsl(5, 74%, 59%)",
"--vcs-ignored": "hsl(41, 99%, 38%)",
"--vcs-conflict": "hsl(5, 74%, 59%)",
"--gutter-modified": "hsl(221, 87%, 60%)",
"--gutter-added": "hsl(119, 34%, 47%)",
"--gutter-deleted": "hsl(5, 74%, 59%)",
// Shadows and window controls
"--shadow-popup": "0 4px 16px #00000024",
"--shadow-dialog": "0 10px 36px #00000033",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "hsl(230, 1%, 90%)",
"--traffic-off-border": "hsl(230, 1%, 86%)",
// Additive
"--bg-tooltip": "hsl(230, 1%, 95%)",
"--fg-tooltip": "hsl(230, 8%, 24%)",
"--border-tooltip": "hsl(230, 1%, 86%)",
"--bg-control": "hsl(230, 1%, 98%)",
"--bg-control-hover": "hsl(230, 1%, 90%)",
"--bg-active": "hsl(230, 1%, 90%)",
"--bg-scrim": "color-mix(in srgb, hsl(230, 1%, 98%) 68%, transparent)",
"--fg-on-accent": "#ffffff",
"--shadow-tooltip": "0 4px 16px #00000024",
"--shadow-balloon": "0 10px 36px #00000033",
// Islands contract
"--bg-frame": "hsl(230, 1%, 92%)",
"--bg-island": "hsl(230, 1%, 98%)",
"--bg-stripe-hover": "hsl(230, 1%, 90%)",
"--bg-stripe-selected": "hsl(230, 1%, 90%)",
"--fg-stripe-selected-active": "#ffffff",
"--fg-stripe-label": "hsl(230, 8%, 24%)",
"--bg-tab-selected": "hsl(230, 1%, 90%)",
"--border-tab-selected": "hsl(230, 100%, 66%)",
"--bg-tab-selected-inactive": "hsl(230, 1%, 92%)",
"--border-tab-selected-inactive": "hsl(230, 1%, 86%)",
"--bg-tab-hover": "hsl(230, 1%, 90%)",
"--border-editor-tabs": "hsl(230, 1%, 86%)",
"--bg-tree-selected": "hsl(230, 1%, 90%)",
"--bg-tree-selected-inactive": "hsl(230, 1%, 90%)",
"--bg-tree-hover": "hsl(230, 1%, 90%)",
"--bg-toolbar-hover": "hsl(230, 1%, 90%)",
"--bg-toolbar-pressed": "hsl(230, 1%, 90%)",
"--bg-toolbar-selected": "hsl(230, 1%, 90%)",
"--bg-toolbar-selected-hover": "hsl(230, 1%, 90%)",
"--bg-button-primary-hover": "color-mix(in srgb, var(--accent) 86%, hsl(230, 8%, 24%))",
"--fg-button-primary": "#ffffff",
"--bg-button": "hsl(230, 1%, 98%)",
"--border-button": "hsl(230, 1%, 86%)",
"--bg-field": "hsl(230, 1%, 98%)",
"--bg-checkbox": "hsl(230, 1%, 98%)",
"--border-checkbox": "hsl(230, 6%, 44%)",
"--bg-toggle-on": "hsl(119, 34%, 47%)",
"--fg-toggle-on": "#ffffff",
"--bg-toggle-off": "hsl(230, 1%, 92%)",
"--fg-toggle-off": "hsl(230, 8%, 24%)",
"--border-toggle": "hsl(230, 6%, 44%)",
"--bg-toggle-knob": "hsl(230, 6%, 44%)",
"--focus-ring-soft": "hsl(230, 100%, 66%)",
"--scrollbar-thumb": "color-mix(in srgb, hsl(230, 8%, 24%) 24%, transparent)",
"--scrollbar-thumb-hover": "color-mix(in srgb, hsl(230, 8%, 24%) 42%, transparent)",
"--bg-search-match": "color-mix(in srgb, hsl(41, 99%, 38%) 22%, hsl(230, 1%, 98%))",
"--bg-search-match-current": "color-mix(in srgb, hsl(41, 99%, 38%) 36%, hsl(230, 1%, 98%))",
"--fg-search-match": "hsl(230, 8%, 24%)",
"--bg-dialog": "hsl(230, 1%, 98%)",
"--bg-popup-inline": "hsl(230, 1%, 92%)",
"--bg-popup-advertiser": "hsl(230, 1%, 92%)",
"--bg-feedback-info": "color-mix(in srgb, hsl(221, 87%, 60%) 12%, hsl(230, 1%, 98%))",
"--border-feedback-info": "color-mix(in srgb, hsl(221, 87%, 60%) 55%, hsl(230, 1%, 98%))",
"--bg-feedback-error": "color-mix(in srgb, hsl(5, 74%, 59%) 12%, hsl(230, 1%, 98%))",
"--border-feedback-error": "color-mix(in srgb, hsl(5, 74%, 59%) 55%, hsl(230, 1%, 98%))",
"--bg-feedback-warning": "color-mix(in srgb, hsl(41, 99%, 38%) 12%, hsl(230, 1%, 98%))",
"--border-feedback-warning": "color-mix(in srgb, hsl(41, 99%, 38%) 55%, hsl(230, 1%, 98%))",
"--bg-feedback-success": "color-mix(in srgb, hsl(119, 34%, 47%) 12%, hsl(230, 1%, 98%))",
"--border-feedback-success": "color-mix(in srgb, hsl(119, 34%, 47%) 55%, hsl(230, 1%, 98%))",
"--bg-counter": "hsl(230, 100%, 66%)",
"--fg-counter": "#ffffff",
"--bg-progress-track": "hsl(230, 1%, 90%)"
}
}
-27
View File
@@ -1,27 +0,0 @@
The Solarized palette is adapted from Ethan Schoonover's Solarized,
https://github.com/altercation/solarized (revision 62f656a02f93c5190a8753159e34b385588d5ff3):
the sixteen sRGB colours of its README, the semantic roles of
vim-colors-solarized/colors/solarized.vim and its iTerm2 ANSI slots.
Adapted to OpenIDEA's design tokens by bghtyu.
MIT License
Copyright (c) 2011 Ethan Schoonover
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-17
View File
@@ -1,17 +0,0 @@
# Contribution-only theme plugin: no code, just theme and editor scheme files.
id = "theme-solarized"
name = "Solarized Themes"
version = "1.0.0"
description = "Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette for the UI, the editor and the terminal."
authors = ["bghtyu <3931591+bghtyu@users.noreply.github.com>", "Ethan Schoonover (Solarized palette)"]
homepage = "https://github.com/altercation/solarized"
change-notes = "1.0.0 — Solarized Light and Solarized Dark: UI, editor colour scheme and terminal ANSI colours."
openidea-api = "0.1"
[[contributes.themes]]
path = "themes/solarized-light.theme.json"
editor-scheme = "themes/solarized-light.scheme.json"
[[contributes.themes]]
path = "themes/solarized-dark.theme.json"
editor-scheme = "themes/solarized-dark.scheme.json"
@@ -1,103 +0,0 @@
// Solarized palette by Ethan Schoonover (https://github.com/altercation/solarized),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// Editor colour scheme: syntax, editor surfaces and terminal ANSI colours.
{
"name": "Solarized Dark",
"tokens": {
// Syntax
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
"--syn-type": "#b58900",
"--syn-function": "#268bd2",
"--syn-function-call": "#268bd2",
"--syn-comment": "#586e75",
"--syn-comment-style": "italic",
"--syn-doc-comment": "#586e75",
"--syn-docstring": "#2aa198",
"--syn-annotation": "#cb4b16",
"--syn-decorator": "#cb4b16",
"--syn-number": "#2aa198",
"--syn-punct": "#839496",
"--syn-variable": "#268bd2",
"--syn-param": "#839496",
"--syn-property": "#268bd2",
"--syn-constant": "#2aa198",
"--syn-builtin": "#2aa198",
"--syn-self": "#dc322f",
"--syn-keyword-arg": "#dc322f",
"--syn-predefined": "#2aa198",
"--syn-escape": "#dc322f",
"--syn-regexp": "#2aa198",
"--syn-tag": "#268bd2",
"--syn-attribute": "#cb4b16",
"--syn-todo": "#d33682",
"--syn-link": "#268bd2",
"--syn-markup-heading": "#268bd2",
"--syn-markup-code": "#2aa198",
"--syn-invalid": "#dc322f",
"--syn-inline-value": "#839496",
"--syn-rainbow-1": "#2aa198",
"--syn-rainbow-2": "#cb4b16",
"--syn-rainbow-3": "#859900",
"--syn-rainbow-4": "#d33682",
"--syn-rainbow-5": "#268bd2",
"--syn-bracket-1": "#b58900",
"--syn-bracket-2": "#d33682",
"--syn-bracket-3": "#268bd2",
// Editor
"--fg-editor": "#839496",
"--caret": "#268bd2",
"--fg-line-number": "#839496",
"--fg-line-number-current": "#839496",
"--border-gutter": "#073642",
"--border-indent-guide": "#073642",
"--border-indent-guide-active": "#268bd2",
"--border-right-margin": "#073642",
"--bg-editor-selection": "#073642",
"--bg-editor-selection-inactive": "#073642",
"--bg-text-search-result": "color-mix(in srgb, #b58900 22%, #002b36)",
"--bg-text-search-result-current": "color-mix(in srgb, #b58900 36%, #002b36)",
"--bg-matched-brace": "#073642",
"--matched-brace-weight": "var(--fw-code-bold)",
"--bg-matched-tag": "#073642",
"--bg-fold-placeholder": "#073642",
"--fg-fold-placeholder": "#839496",
"--fg-fold-marker": "#839496",
"--fg-whitespace": "#839496",
"--fg-unused": "#839496",
"--bg-identifier-under-caret": "#073642",
"--bg-identifier-under-caret-write": "color-mix(in srgb, #dc322f 12%, #002b36)",
"--fg-inlay": "#839496",
"--bg-inlay": "#073642",
"--wave-error": "#dc322f",
"--wave-warning": "#b58900",
"--editor-error-decoration": "underline wavy var(--wave-error)",
"--editor-warning-decoration": "underline wavy var(--wave-warning)",
"--editor-warning-background": "color-mix(in srgb, #b58900 12%, #002b36)",
"--bg-breakpoint-line": "color-mix(in srgb, #dc322f 12%, #002b36)",
"--fg-completion-match": "#268bd2",
"--bg-editor-inline": "#073642",
"--bg-editor-banner": "color-mix(in srgb, #268bd2 12%, #002b36)",
"--fg-weak-warning": "#839496",
"--fg-bulb": "#b58900",
// ANSI palette
"--ansi-black": "#073642",
"--ansi-red": "#dc322f",
"--ansi-green": "#859900",
"--ansi-yellow": "#b58900",
"--ansi-blue": "#268bd2",
"--ansi-magenta": "#d33682",
"--ansi-cyan": "#2aa198",
"--ansi-white": "#eee8d5",
"--ansi-bright-black": "#002b36",
"--ansi-bright-red": "#cb4b16",
"--ansi-bright-green": "#586e75",
"--ansi-bright-yellow": "#657b83",
"--ansi-bright-blue": "#839496",
"--ansi-bright-magenta": "#6c71c4",
"--ansi-bright-cyan": "#93a1a1",
"--ansi-bright-white": "#fdf6e3"
}
}
@@ -1,142 +0,0 @@
// Solarized palette by Ethan Schoonover (https://github.com/altercation/solarized),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// UI theme; the editor colour scheme is in solarized-dark.scheme.json.
{
"id": "solarized-dark",
"name": "Solarized Dark",
"kind": "dark",
"tokens": {
// Surfaces
"--bg-app": "#002b36",
"--bg-elev": "#073642",
"--bg-popup": "#073642",
"--bg-hover": "#073642",
"--bg-selected": "#073642",
"--bg-selected-strong": "#073642",
"--bg-current-line": "#073642",
"--bg-debug-line": "color-mix(in srgb, #268bd2 12%, #002b36)",
"--bg-backdrop": "#073642",
"--bg-input": "#002b36",
// Lines
"--border": "#073642",
"--border-popup": "#073642",
"--border-strong": "#839496",
"--border-window": "#073642",
"--control-off": "#839496",
// Text
"--fg-strong": "#839496",
"--fg-emphasis": "#839496",
"--fg": "#839496",
"--fg-secondary": "#839496",
"--fg-muted": "#839496",
"--fg-subtle": "#839496",
"--fg-faint": "#839496",
"--fg-gutter": "#839496",
"--fg-icon": "#839496",
// Accent
"--accent": "#268bd2",
"--link": "#268bd2",
"--link-hover": "#2aa198",
// Charts
"--chart-series": "#dc322f",
// Status
"--danger": "#dc322f",
"--warning": "#b58900",
"--success": "#859900",
"--db-connected": "#859900",
"--db-connected-ring": "#859900",
"--info": "#268bd2",
// Vcs
"--vcs-modified": "#268bd2",
"--vcs-added": "#859900",
"--vcs-deleted": "#839496",
"--vcs-untracked": "#dc322f",
"--vcs-ignored": "#b58900",
"--vcs-conflict": "#dc322f",
"--gutter-modified": "#268bd2",
"--gutter-added": "#859900",
"--gutter-deleted": "#dc322f",
// Shadows and window controls
"--shadow-popup": "0 4px 16px #00000024",
"--shadow-dialog": "0 10px 36px #00000033",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "#073642",
"--traffic-off-border": "#073642",
// Additive
"--bg-tooltip": "#073642",
"--fg-tooltip": "#839496",
"--border-tooltip": "#073642",
"--bg-control": "#002b36",
"--bg-control-hover": "#073642",
"--bg-active": "#073642",
"--bg-scrim": "color-mix(in srgb, #002b36 68%, transparent)",
"--fg-on-accent": "#002b36",
"--shadow-tooltip": "0 4px 16px #00000024",
"--shadow-balloon": "0 10px 36px #00000033",
// Islands contract
"--bg-frame": "#073642",
"--bg-island": "#002b36",
"--bg-stripe-hover": "#073642",
"--bg-stripe-selected": "#073642",
"--fg-stripe-selected-active": "#002b36",
"--fg-stripe-label": "#839496",
"--bg-tab-selected": "#073642",
"--border-tab-selected": "#268bd2",
"--bg-tab-selected-inactive": "#073642",
"--border-tab-selected-inactive": "#073642",
"--bg-tab-hover": "#073642",
"--border-editor-tabs": "#073642",
"--bg-tree-selected": "#073642",
"--bg-tree-selected-inactive": "#073642",
"--bg-tree-hover": "#073642",
"--bg-toolbar-hover": "#073642",
"--bg-toolbar-pressed": "#073642",
"--bg-toolbar-selected": "#073642",
"--bg-toolbar-selected-hover": "#073642",
"--bg-button-primary-hover": "color-mix(in srgb, var(--accent) 86%, #839496)",
"--fg-button-primary": "#002b36",
"--bg-button": "#002b36",
"--border-button": "#073642",
"--bg-field": "#002b36",
"--bg-checkbox": "#002b36",
"--border-checkbox": "#839496",
"--bg-toggle-on": "#859900",
"--fg-toggle-on": "#002b36",
"--bg-toggle-off": "#073642",
"--fg-toggle-off": "#839496",
"--border-toggle": "#839496",
"--bg-toggle-knob": "#839496",
"--focus-ring-soft": "#268bd2",
"--scrollbar-thumb": "color-mix(in srgb, #839496 24%, transparent)",
"--scrollbar-thumb-hover": "color-mix(in srgb, #839496 42%, transparent)",
"--bg-search-match": "color-mix(in srgb, #b58900 22%, #002b36)",
"--bg-search-match-current": "color-mix(in srgb, #b58900 36%, #002b36)",
"--fg-search-match": "#839496",
"--bg-dialog": "#002b36",
"--bg-popup-inline": "#073642",
"--bg-popup-advertiser": "#073642",
"--bg-feedback-info": "color-mix(in srgb, #268bd2 12%, #002b36)",
"--border-feedback-info": "color-mix(in srgb, #268bd2 55%, #002b36)",
"--bg-feedback-error": "color-mix(in srgb, #dc322f 12%, #002b36)",
"--border-feedback-error": "color-mix(in srgb, #dc322f 55%, #002b36)",
"--bg-feedback-warning": "color-mix(in srgb, #b58900 12%, #002b36)",
"--border-feedback-warning": "color-mix(in srgb, #b58900 55%, #002b36)",
"--bg-feedback-success": "color-mix(in srgb, #859900 12%, #002b36)",
"--border-feedback-success": "color-mix(in srgb, #859900 55%, #002b36)",
"--bg-counter": "#268bd2",
"--fg-counter": "#002b36",
"--bg-progress-track": "#073642"
}
}
@@ -1,103 +0,0 @@
// Solarized palette by Ethan Schoonover (https://github.com/altercation/solarized),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// Editor colour scheme: syntax, editor surfaces and terminal ANSI colours.
{
"name": "Solarized Light",
"tokens": {
// Syntax
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
"--syn-type": "#b58900",
"--syn-function": "#268bd2",
"--syn-function-call": "#268bd2",
"--syn-comment": "#93a1a1",
"--syn-comment-style": "italic",
"--syn-doc-comment": "#93a1a1",
"--syn-docstring": "#2aa198",
"--syn-annotation": "#cb4b16",
"--syn-decorator": "#cb4b16",
"--syn-number": "#2aa198",
"--syn-punct": "#657b83",
"--syn-variable": "#268bd2",
"--syn-param": "#657b83",
"--syn-property": "#268bd2",
"--syn-constant": "#2aa198",
"--syn-builtin": "#2aa198",
"--syn-self": "#dc322f",
"--syn-keyword-arg": "#dc322f",
"--syn-predefined": "#2aa198",
"--syn-escape": "#dc322f",
"--syn-regexp": "#2aa198",
"--syn-tag": "#268bd2",
"--syn-attribute": "#cb4b16",
"--syn-todo": "#d33682",
"--syn-link": "#268bd2",
"--syn-markup-heading": "#268bd2",
"--syn-markup-code": "#2aa198",
"--syn-invalid": "#dc322f",
"--syn-inline-value": "#657b83",
"--syn-rainbow-1": "#2aa198",
"--syn-rainbow-2": "#cb4b16",
"--syn-rainbow-3": "#859900",
"--syn-rainbow-4": "#d33682",
"--syn-rainbow-5": "#268bd2",
"--syn-bracket-1": "#b58900",
"--syn-bracket-2": "#d33682",
"--syn-bracket-3": "#268bd2",
// Editor
"--fg-editor": "#657b83",
"--caret": "#268bd2",
"--fg-line-number": "#657b83",
"--fg-line-number-current": "#657b83",
"--border-gutter": "#eee8d5",
"--border-indent-guide": "#eee8d5",
"--border-indent-guide-active": "#268bd2",
"--border-right-margin": "#eee8d5",
"--bg-editor-selection": "#eee8d5",
"--bg-editor-selection-inactive": "#eee8d5",
"--bg-text-search-result": "color-mix(in srgb, #b58900 22%, #fdf6e3)",
"--bg-text-search-result-current": "color-mix(in srgb, #b58900 36%, #fdf6e3)",
"--bg-matched-brace": "#eee8d5",
"--matched-brace-weight": "var(--fw-code-bold)",
"--bg-matched-tag": "#eee8d5",
"--bg-fold-placeholder": "#eee8d5",
"--fg-fold-placeholder": "#657b83",
"--fg-fold-marker": "#657b83",
"--fg-whitespace": "#657b83",
"--fg-unused": "#657b83",
"--bg-identifier-under-caret": "#eee8d5",
"--bg-identifier-under-caret-write": "color-mix(in srgb, #dc322f 12%, #fdf6e3)",
"--fg-inlay": "#657b83",
"--bg-inlay": "#eee8d5",
"--wave-error": "#dc322f",
"--wave-warning": "#b58900",
"--editor-error-decoration": "underline wavy var(--wave-error)",
"--editor-warning-decoration": "underline wavy var(--wave-warning)",
"--editor-warning-background": "color-mix(in srgb, #b58900 12%, #fdf6e3)",
"--bg-breakpoint-line": "color-mix(in srgb, #dc322f 12%, #fdf6e3)",
"--fg-completion-match": "#268bd2",
"--bg-editor-inline": "#eee8d5",
"--bg-editor-banner": "color-mix(in srgb, #268bd2 12%, #fdf6e3)",
"--fg-weak-warning": "#657b83",
"--fg-bulb": "#b58900",
// ANSI palette
"--ansi-black": "#073642",
"--ansi-red": "#dc322f",
"--ansi-green": "#859900",
"--ansi-yellow": "#b58900",
"--ansi-blue": "#268bd2",
"--ansi-magenta": "#d33682",
"--ansi-cyan": "#2aa198",
"--ansi-white": "#eee8d5",
"--ansi-bright-black": "#002b36",
"--ansi-bright-red": "#cb4b16",
"--ansi-bright-green": "#586e75",
"--ansi-bright-yellow": "#657b83",
"--ansi-bright-blue": "#839496",
"--ansi-bright-magenta": "#6c71c4",
"--ansi-bright-cyan": "#93a1a1",
"--ansi-bright-white": "#fdf6e3"
}
}
@@ -1,142 +0,0 @@
// Solarized palette by Ethan Schoonover (https://github.com/altercation/solarized),
// MIT licence, see LICENSE. Adapted to OpenIDEA's tokens by bghtyu.
// UI theme; the editor colour scheme is in solarized-light.scheme.json.
{
"id": "solarized-light",
"name": "Solarized Light",
"kind": "light",
"tokens": {
// Surfaces
"--bg-app": "#fdf6e3",
"--bg-elev": "#eee8d5",
"--bg-popup": "#fdf6e3",
"--bg-hover": "#eee8d5",
"--bg-selected": "#eee8d5",
"--bg-selected-strong": "#eee8d5",
"--bg-current-line": "#eee8d5",
"--bg-debug-line": "color-mix(in srgb, #268bd2 12%, #fdf6e3)",
"--bg-backdrop": "#eee8d5",
"--bg-input": "#fdf6e3",
// Lines
"--border": "#eee8d5",
"--border-popup": "#eee8d5",
"--border-strong": "#657b83",
"--border-window": "#eee8d5",
"--control-off": "#657b83",
// Text
"--fg-strong": "#657b83",
"--fg-emphasis": "#657b83",
"--fg": "#657b83",
"--fg-secondary": "#657b83",
"--fg-muted": "#657b83",
"--fg-subtle": "#657b83",
"--fg-faint": "#657b83",
"--fg-gutter": "#657b83",
"--fg-icon": "#657b83",
// Accent
"--accent": "#268bd2",
"--link": "#268bd2",
"--link-hover": "#2aa198",
// Charts
"--chart-series": "#dc322f",
// Status
"--danger": "#dc322f",
"--warning": "#b58900",
"--success": "#859900",
"--db-connected": "#859900",
"--db-connected-ring": "#859900",
"--info": "#268bd2",
// Vcs
"--vcs-modified": "#268bd2",
"--vcs-added": "#859900",
"--vcs-deleted": "#657b83",
"--vcs-untracked": "#dc322f",
"--vcs-ignored": "#b58900",
"--vcs-conflict": "#dc322f",
"--gutter-modified": "#268bd2",
"--gutter-added": "#859900",
"--gutter-deleted": "#dc322f",
// Shadows and window controls
"--shadow-popup": "0 4px 16px #00000024",
"--shadow-dialog": "0 10px 36px #00000033",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "#eee8d5",
"--traffic-off-border": "#eee8d5",
// Additive
"--bg-tooltip": "#fdf6e3",
"--fg-tooltip": "#657b83",
"--border-tooltip": "#eee8d5",
"--bg-control": "#fdf6e3",
"--bg-control-hover": "#eee8d5",
"--bg-active": "#eee8d5",
"--bg-scrim": "color-mix(in srgb, #fdf6e3 68%, transparent)",
"--fg-on-accent": "#002b36",
"--shadow-tooltip": "0 4px 16px #00000024",
"--shadow-balloon": "0 10px 36px #00000033",
// Islands contract
"--bg-frame": "#eee8d5",
"--bg-island": "#fdf6e3",
"--bg-stripe-hover": "#eee8d5",
"--bg-stripe-selected": "#eee8d5",
"--fg-stripe-selected-active": "#002b36",
"--fg-stripe-label": "#657b83",
"--bg-tab-selected": "#eee8d5",
"--border-tab-selected": "#268bd2",
"--bg-tab-selected-inactive": "#eee8d5",
"--border-tab-selected-inactive": "#eee8d5",
"--bg-tab-hover": "#eee8d5",
"--border-editor-tabs": "#eee8d5",
"--bg-tree-selected": "#eee8d5",
"--bg-tree-selected-inactive": "#eee8d5",
"--bg-tree-hover": "#eee8d5",
"--bg-toolbar-hover": "#eee8d5",
"--bg-toolbar-pressed": "#eee8d5",
"--bg-toolbar-selected": "#eee8d5",
"--bg-toolbar-selected-hover": "#eee8d5",
"--bg-button-primary-hover": "color-mix(in srgb, var(--accent) 86%, #657b83)",
"--fg-button-primary": "#002b36",
"--bg-button": "#fdf6e3",
"--border-button": "#eee8d5",
"--bg-field": "#fdf6e3",
"--bg-checkbox": "#fdf6e3",
"--border-checkbox": "#657b83",
"--bg-toggle-on": "#859900",
"--fg-toggle-on": "#002b36",
"--bg-toggle-off": "#eee8d5",
"--fg-toggle-off": "#657b83",
"--border-toggle": "#657b83",
"--bg-toggle-knob": "#657b83",
"--focus-ring-soft": "#268bd2",
"--scrollbar-thumb": "color-mix(in srgb, #657b83 24%, transparent)",
"--scrollbar-thumb-hover": "color-mix(in srgb, #657b83 42%, transparent)",
"--bg-search-match": "color-mix(in srgb, #b58900 22%, #fdf6e3)",
"--bg-search-match-current": "color-mix(in srgb, #b58900 36%, #fdf6e3)",
"--fg-search-match": "#657b83",
"--bg-dialog": "#fdf6e3",
"--bg-popup-inline": "#eee8d5",
"--bg-popup-advertiser": "#eee8d5",
"--bg-feedback-info": "color-mix(in srgb, #268bd2 12%, #fdf6e3)",
"--border-feedback-info": "color-mix(in srgb, #268bd2 55%, #fdf6e3)",
"--bg-feedback-error": "color-mix(in srgb, #dc322f 12%, #fdf6e3)",
"--border-feedback-error": "color-mix(in srgb, #dc322f 55%, #fdf6e3)",
"--bg-feedback-warning": "color-mix(in srgb, #b58900 12%, #fdf6e3)",
"--border-feedback-warning": "color-mix(in srgb, #b58900 55%, #fdf6e3)",
"--bg-feedback-success": "color-mix(in srgb, #859900 12%, #fdf6e3)",
"--border-feedback-success": "color-mix(in srgb, #859900 55%, #fdf6e3)",
"--bg-counter": "#268bd2",
"--fg-counter": "#002b36",
"--bg-progress-track": "#eee8d5"
}
}
+4
View File
@@ -13,6 +13,9 @@ try {
const last = JSON.parse(localStorage.getItem("openidea.theme") ?? "null");
if (last?.theme) root.dataset.theme = last.theme;
if (last?.contrast) root.dataset.contrast = last.contrast;
// A built-in palette theme (Solarized, One) over its light / dark base (tokens.css).
if (typeof last?.colorTheme === "string" && /^[a-z][a-z0-9-]*$/.test(last.colorTheme))
root.dataset.colorTheme = last.colorTheme;
// A plugin theme's stylesheet (plugins/bridge.ts), so the workbench doesn't
// show the built-in theme until the plugin host answers.
const plugin = JSON.parse(localStorage.getItem("openidea.pluginTheme") ?? "null");
@@ -21,6 +24,7 @@ try {
root.dataset.pluginTheme = plugin.id;
root.dataset.pluginThemeKind = root.dataset.theme = plugin.kind === "light" ? "light" : "dark";
delete root.dataset.contrast;
delete root.dataset.colorTheme;
}
// The splash's frame, text and accent colours, resolved by theme.ts (splash.css).
const colors = [
+1 -68
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
convertIcls,
@@ -77,73 +77,6 @@ describe("theme plugin template", () => {
});
});
/** Theme packages in plugins/: every theme they contribute, with its editor scheme. */
function pluginThemes() {
return readdirSync("plugins", { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(`plugins/${d.name}/plugin.toml`))
.flatMap((d) => {
const dir = `plugins/${d.name}`;
const manifest = read(`${dir}/plugin.toml`);
return manifest
.split("[[contributes.themes]]")
.slice(1)
.map((entry) => ({
dir,
manifest,
path: /^path\s*=\s*"([^"]+)"/m.exec(entry)?.[1],
scheme: /^editor-scheme\s*=\s*"([^"]+)"/m.exec(entry)?.[1],
}));
});
}
describe("theme plugins in plugins/", () => {
const themes = pluginThemes();
it("include Solarized and One, light and dark", () => {
expect(themes.map((t) => `${t.dir}/${t.path}`)).toEqual(
expect.arrayContaining([
"plugins/theme-solarized/themes/solarized-light.theme.json",
"plugins/theme-solarized/themes/solarized-dark.theme.json",
"plugins/theme-one/themes/one-light.theme.json",
"plugins/theme-one/themes/one-dark.theme.json",
]),
);
});
it.each(themes.map((t) => [`${t.dir}/${t.path}`, t] as const))(
"%s sets every UI token and its scheme every editor token",
(_name, t) => {
// A failure here means tokens.css gained (or lost) a themeable token: add it to the theme too.
expect(t.path && t.scheme, "theme and editor-scheme paths").toBeTruthy();
expect(t.manifest).not.toMatch(/^wasm\s*=/m);
const ui = JSON.parse(stripJsonComments(read(`${t.dir}/${t.path}`))) as {
id: string;
name: string;
kind: string;
tokens: Record<string, string>;
};
const ed = JSON.parse(stripJsonComments(read(`${t.dir}/${t.scheme}`))) as { tokens: Record<string, string> };
expect(ui.id).toMatch(/^[a-z0-9][a-z0-9._-]*$/);
expect(ui.name.trim()).not.toBe("");
expect(["light", "dark"]).toContain(ui.kind);
expect(sorted(Object.keys(ui.tokens))).toEqual(sorted(sets.ui));
expect(sorted(Object.keys(ed.tokens))).toEqual(sorted(sets.scheme));
for (const [k, v] of Object.entries({ ...ui.tokens, ...ed.tokens })) {
expect(/[;{}<>\\\n]|url\(|image\(/i.test(v), `${k}: ${v}`).toBe(false);
// var() may only point at tokens the IDE defines.
for (const [, ref] of v.matchAll(/var\((--[a-z0-9-]+)/g)) expect(css, `${k} → ${ref}`).toContain(`${ref}:`);
}
},
);
it("ship the licence of the palettes they port", () => {
for (const dir of new Set(themes.map((t) => t.dir))) {
if (dir === "plugins/theme-solarized" || dir === "plugins/theme-one")
expect(read(`${dir}/LICENSE`), dir).toContain("MIT License");
}
});
});
describe("IntelliJ theme import", () => {
const themeJson = `{
// comment
+71 -2
View File
@@ -1,11 +1,12 @@
//! Maven / Gradle tool windows (the build model and its background sync) and
//! Build › Build Project (compiling with the project's build system).
use crate::state::AppState;
use crate::state::{AppState, apply_workspace_edit};
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{IdeError, IdeEvent, IdeResult, RequestId};
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit};
use std::collections::BTreeMap;
use std::path::Path;
use tauri::ipc::Channel;
@@ -126,6 +127,74 @@ pub async fn build_sync(
result?
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
project: &crate::state::Project,
pom: &str,
dependency: &ArtifactKey,
) -> IdeResult<(std::path::PathBuf, String, Range)> {
let model = ide_build::maven::load(&project.root);
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
let path = project.root.join(&rel);
let text = project.text_of(&path)?;
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
return Ok((path, text, range));
}
}
Err(IdeError::not_found(format!(
"{dependency} is not declared in a pom.xml of this project (it may come from an imported BOM or an external parent POM)"
)))
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
#[tauri::command]
#[specta::specta]
pub async fn maven_dependency_declaration(
window: Window,
state: State<'_, AppState>,
pom: String,
dependency: ArtifactKey,
) -> IdeResult<Location> {
let project = state.project(window.label())?;
tauri::async_runtime::spawn_blocking(move || {
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
Ok(Location::file(path, range))
})
.await
.map_err(join_err)?
}
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
/// `dependency` of the Maven module `pom`, through the open document when there is one
/// (saved). Returns the declaration; nothing changes when it already excludes it.
#[tauri::command]
#[specta::specta]
pub async fn maven_exclude_dependency(
window: Window,
state: State<'_, AppState>,
pom: String,
dependency: ArtifactKey,
exclusion: ArtifactKey,
) -> IdeResult<Location> {
let project = state.project(window.label())?;
tauri::async_runtime::spawn_blocking(move || {
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
Some(Exclusion::Edit(edit)) => {
let edit =
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
apply_workspace_edit(&project.documents, &edit)?;
}
Some(Exclusion::Present) => {}
None => return Err(IdeError::not_found(format!("{dependency} is not declared in {}", path.display()))),
}
Ok(Location::file(path, range))
})
.await
.map_err(join_err)?
}
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build)
+2
View File
@@ -246,6 +246,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_test_reports,
build::build_model,
build::build_sync,
build::maven_dependency_declaration,
build::maven_exclude_dependency,
build::build_run,
java::java_resolve_launch,
java::java_debug_server,
+1
View File
@@ -9,6 +9,7 @@ mod logging;
mod menu;
mod shell_env;
mod state;
mod theme_frame;
mod windows;
use state::AppState;
+159
View File
@@ -0,0 +1,159 @@
//! Built-in themes' window frame colour (`--bg-frame`), read from the UI's own
//! `src/styles/tokens.css` so a native window opens in the colour its page paints.
use std::collections::HashMap;
use std::sync::LazyLock;
use tauri::utils::config::Color;
const TOKENS_CSS: &str = include_str!("../../src/styles/tokens.css");
/// A top-level rule: its selector list and custom-property declarations.
type Block = (Vec<String>, HashMap<String, String>);
/// The rule blocks of tokens.css, in source order.
static BLOCKS: LazyLock<Vec<Block>> = LazyLock::new(|| blocks(TOKENS_CSS));
/// `--bg-frame` of built-in theme `id` (`light`, `dark`, `dark-hc` or a palette
/// theme with a `:root[data-color-theme="<id>"]` block); `None` for any other id.
pub fn builtin_frame(id: &str) -> Option<Color> {
builtin_frame_in(&BLOCKS, id)
}
fn builtin_frame_in(blocks: &[Block], id: &str) -> Option<Color> {
let palette = format!(":root[data-color-theme=\"{id}\"]");
let mut selectors = vec![":root".to_owned()];
match id {
"dark" => {}
"light" => selectors.push(":root[data-theme=\"light\"]".into()),
"dark-hc" => selectors.push(":root[data-theme=\"dark-hc\"]".into()),
_ if blocks.iter().any(|(sel, _)| sel.contains(&palette)) => {
if id.ends_with("light") {
selectors.push(":root[data-theme=\"light\"]".into());
}
selectors.push(":root[data-color-theme]".into());
selectors.push(palette);
}
_ => return None,
}
// Later matching blocks win, as in the page (the selectors involved share a specificity order).
let mut vars: HashMap<&str, &str> = HashMap::new();
for (sel, decls) in blocks {
if sel.iter().any(|s| selectors.contains(s)) {
vars.extend(decls.iter().map(|(k, v)| (k.as_str(), v.as_str())));
}
}
let mut value = *vars.get("--bg-frame")?;
for _ in 0..8 {
match value.strip_prefix("var(").and_then(|v| v.strip_suffix(')')) {
Some(name) => value = vars.get(name.split(',').next()?.trim())?,
None => return parse_color(value),
}
}
None
}
fn blocks(css: &str) -> Vec<Block> {
let mut text = String::with_capacity(css.len());
let mut rest = css;
while let Some(start) = rest.find("/*") {
text.push_str(&rest[..start]);
rest = rest[start + 2..].find("*/").map_or("", |end| &rest[start + 2 + end + 2..]);
}
text.push_str(rest);
let mut out = Vec::new();
let mut rest = text.as_str();
while let Some(open) = rest.find('{') {
let selector = rest[..open].trim();
// Matching close brace (nested at-rule blocks are skipped whole).
let mut depth = 0;
let Some(close) = rest[open..].char_indices().find_map(|(i, c)| {
match c {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
(depth == 0).then_some(open + i)
}) else {
break;
};
if !selector.starts_with('@') {
let decls = rest[open + 1..close]
.split(';')
.filter_map(|d| d.split_once(':'))
.filter(|(k, _)| k.trim().starts_with("--"))
.map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
.collect();
out.push((selector.split(',').map(|s| s.trim().to_owned()).collect(), decls));
}
rest = &rest[close + 1..];
}
out
}
/// `#hex` or `hsl(h, s%, l%)`, rounded to 8-bit channels as browsers do.
fn parse_color(value: &str) -> Option<Color> {
if value.starts_with('#') {
return serde_json::from_value(serde_json::Value::String(value.to_owned())).ok();
}
let args = value.strip_prefix("hsl(")?.strip_suffix(')')?;
let nums: Vec<f64> = args
.split([',', ' '])
.filter(|s| !s.is_empty())
.map(|s| s.trim_end_matches("deg").trim_end_matches('%').parse())
.collect::<Result<_, _>>()
.ok()?;
let [h, s, l] = nums[..] else { return None };
let (s, l) = (s / 100.0, l / 100.0);
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = (h.rem_euclid(360.0)) / 60.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r, g, b) = match hp as u8 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let byte = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
Some(Color(byte(r), byte(g), byte(b), 0xff))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_every_builtin_frame_from_tokens_css() {
for (id, color) in [
("light", Color(0xe9, 0xea, 0xee, 0xff)),
("dark", Color(0x26, 0x28, 0x2c, 0xff)),
("dark-hc", Color(0x1a, 0x1b, 0x1e, 0xff)),
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
] {
assert_eq!(builtin_frame(id), Some(color), "{id}");
}
assert_eq!(builtin_frame("system"), None);
assert_eq!(builtin_frame("nope"), None);
}
#[test]
fn resolves_var_chains_and_hsl() {
let css = r#"
/* a { comment } */
:root { --bg-frame: #000; --p: hsl(0, 100%, 50%); }
@media (x) { :root { --bg-frame: #fff; } }
:root[data-color-theme] { --bg-frame: var(--q); }
:root[data-color-theme="t-dark"] { --q: var(--p, #123); }
"#;
let b = blocks(css);
assert_eq!(builtin_frame_in(&b, "dark"), Some(Color(0, 0, 0, 0xff)));
assert_eq!(builtin_frame_in(&b, "t-dark"), Some(Color(0xff, 0, 0, 0xff)));
assert_eq!(builtin_frame_in(&b, "u-dark"), None);
}
}
+23 -10
View File
@@ -11,6 +11,7 @@
//! [`Launch::settings_page`] tells it which page to show.
use crate::state::AppState;
use crate::theme_frame::builtin_frame;
use ide_core::settings::AppearanceSettings;
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
@@ -212,22 +213,15 @@ fn window_config(app: &AppHandle) -> IdeResult<WindowConfig> {
/// The theme's `--bg-frame` (tokens.css), mirroring `resolveThemeId` in theme.ts.
pub fn frame_color(appearance: &AppearanceSettings, os_dark: bool) -> Color {
const LIGHT: Color = Color(0xe9, 0xea, 0xee, 0xff);
const DARK: Color = Color(0x26, 0x28, 0x2c, 0xff);
const DARK_HC: Color = Color(0x1a, 0x1b, 0x1e, 0xff);
let sync = appearance.sync_with_os || appearance.theme == "system";
let theme = match (sync, os_dark) {
(false, _) => &appearance.theme,
(true, true) => &appearance.preferred_dark_theme,
(true, false) => &appearance.preferred_light_theme,
};
match theme.as_str() {
"dark-hc" => DARK_HC,
"dark" => DARK,
"light" => LIGHT,
_ if sync && os_dark => DARK,
_ => LIGHT,
}
builtin_frame(theme)
.or_else(|| builtin_frame(if sync && os_dark { "dark" } else { "light" }))
.unwrap_or(Color(0xe9, 0xea, 0xee, 0xff))
}
/// A `#rgb` / `#rrggbb` / `#rrggbbaa` colour sent by the UI (Tauri's own parser).
@@ -475,6 +469,25 @@ mod tests {
assert_eq!(frame_color(&a, true), Color(0x26, 0x28, 0x2c, 0xff));
}
#[test]
fn additional_palettes_keep_native_startup_and_os_preferences_in_sync() {
for (id, color) in [
("solarized-light", Color(0xee, 0xe8, 0xd5, 0xff)),
("solarized-dark", Color(0x07, 0x36, 0x42, 0xff)),
("one-light", Color(0xea, 0xea, 0xeb, 0xff)),
("one-dark", Color(0x21, 0x25, 0x2b, 0xff)),
] {
let mut a = AppearanceSettings { theme: id.into(), ..Default::default() };
assert_eq!(frame_color(&a, false), color);
assert_eq!(frame_color(&a, true), color);
a.sync_with_os = true;
a.preferred_light_theme = id.into();
a.preferred_dark_theme = id.into();
assert_eq!(frame_color(&a, false), color);
assert_eq!(frame_color(&a, true), color);
}
}
#[test]
fn frame_colours_from_the_ui_parse_as_hex() {
for (hex, expected) in [
+29
View File
@@ -478,6 +478,14 @@ export const commands = {
* Returns the refreshed model; a failed build is reported in its `syncError`.
*/
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
/** Where the direct dependency `dependency` of the Maven module `pom` is declared. */
mavenDependencyDeclaration: (pom: string, dependency: ArtifactKey) => typedError<Location, IdeError>(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })),
/**
* Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
* `dependency` of the Maven module `pom`, through the open document when there is one
* (saved). Returns the declaration; nothing changes when it already excludes it.
*/
mavenExcludeDependency: (pom: string, dependency: ArtifactKey, exclusion: ArtifactKey) => typedError<Location, IdeError>(__TAURI_INVOKE("maven_exclude_dependency", { pom, dependency, exclusion })),
/**
* Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
* module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
@@ -1102,6 +1110,12 @@ export type ApplyOutcome = {
conflicts: string[],
};
/** `groupId:artifactId` of a dependency. */
export type ArtifactKey = {
groupId: string,
artifactId: string,
};
export type AttachKind = "java" | "node" | "python" | "go" | "native";
export type AttachableProcess = {
@@ -3512,6 +3526,12 @@ export type MavenDependency = {
optional: boolean,
/** Transitive dependencies (only after a sync). */
children: MavenDependency[],
/** Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`). */
omitted?: Omission | null,
/** The version that won when `omitted` is `Conflict`. */
conflictWith?: string | null,
/** The version requested before `<dependencyManagement>` replaced it. */
managedFrom?: string | null,
};
export type MavenModel = {
@@ -3723,6 +3743,15 @@ export type NotificationSettings = {
export type NotifyLevel = "info" | "warning" | "error";
/** Why a dependency occurrence is not part of the resolved graph. */
export type Omission =
/** Another occurrence of the same version is nearer the root. */
"duplicate" |
/** Another version is nearer the root (or declared first) and wins. */
"conflict" |
/** The artifact depends on itself through this path. */
"cycle";
/** How `open_project_window` satisfied the request. */
export type OpenOutcome =
/** Already open in `label`; that window was focused. */
+1 -1
View File
@@ -28,7 +28,7 @@ export function installColorScheme(): () => void {
const observer = typeof MutationObserver !== "undefined" ? new MutationObserver(apply) : null;
observer?.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme"],
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme"],
});
return () => {
unsubscribe();
@@ -153,6 +153,7 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
action={`${tool}.toggleOffline`}
pressed={maven ? settings.maven.offline : settings.gradle.offline}
/>
{maven && <IconButton icon="build-dependencies" size="small" iconSize={16} action="maven.analyzeDependencies" />}
{maven && (
<IconButton
icon="skip-tests"
@@ -309,8 +310,11 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
},
{ kind: "action", action: "maven.reload", label: "Reload All Maven Projects" },
{ kind: "action", action: "maven.executeGoal" },
{ kind: "action", action: "maven.analyzeDependencies", arg: { pom: node.project.pom } },
);
}
if (node.kind === "dependencies")
items.push({ kind: "action", action: "maven.analyzeDependencies", arg: { pom: node.project.pom } });
if (node.kind === "gradleProject") {
const file = node.project.buildFile;
if (file)
@@ -0,0 +1,205 @@
.view {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg-editor);
}
.toolbar {
flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-height: 36px;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
}
.module {
min-width: 160px;
max-width: 260px;
}
.search {
flex: 1 1 200px;
min-width: 140px;
max-width: 360px;
}
.scope {
min-width: 110px;
}
.sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--border);
}
.banner {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 2px 10px;
font-size: var(--fs-small);
color: var(--fg-secondary);
border-bottom: 1px solid var(--border);
}
.bannerText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
flex: 1;
min-height: 0;
display: flex;
}
.body > * {
flex: 1;
min-width: 0;
}
.panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.panel > * {
flex: 1;
min-height: 0;
}
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
white-space: nowrap;
}
.icon {
flex: none;
}
.label {
flex: 0 1 auto;
min-width: 24px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg);
}
.selectedLabel {
color: var(--fg-strong);
}
.version {
flex: none;
font-family: var(--font-mono);
font-size: var(--fs-small);
color: var(--fg-secondary);
}
.detail {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.conflict {
color: var(--warning);
}
.omitted .label,
.omitted .version {
color: var(--fg-muted);
}
.usages {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.usagesHeader {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 10px;
border-bottom: 1px solid var(--border);
}
.usagesTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
color: var(--fg-strong);
}
.conflictNote {
flex: none;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: var(--fs-small);
color: var(--fg-secondary);
background: var(--bg-feedback-warning);
border-bottom: 1px solid var(--border-feedback-warning);
}
.usageList {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 0;
}
.usage {
padding: 6px 10px 8px;
border-bottom: 1px solid var(--border);
}
.usageHeader {
display: flex;
align-items: center;
gap: 6px;
min-height: 26px;
}
.usageVersion {
flex: none;
font-family: var(--font-mono);
color: var(--fg);
}
.usageStatus {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--fs-small);
color: var(--fg-muted);
}
.spacer {
flex: 1;
}
.path {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.step {
display: flex;
align-items: center;
gap: 6px;
min-height: 22px;
white-space: nowrap;
}
.stepLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-secondary);
}
.stepTarget {
color: var(--fg-strong);
}
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, MavenDependency } from "@/platform/ipc";
import { menuGroupItems, provideEditorService, type EditorService } from "@/workbench/contributions";
import { DependencyAnalyzer } from "./DependencyAnalyzer";
import { activate } from "./index";
import { ANALYZER_KIND, useAnalyzer, useBuild } from "./store";
const ipc = vi.hoisted(() => ({ exclude: vi.fn(), declaration: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
call: (value: unknown) => value,
commands: { ...real.commands, mavenExcludeDependency: ipc.exclude, mavenDependencyDeclaration: ipc.declaration },
};
});
function dep(coords: string, extra: Partial<MavenDependency> = {}, children: MavenDependency[] = []): MavenDependency {
const [groupId, artifactId, version] = coords.split(":");
return {
groupId: groupId!,
artifactId: artifactId!,
version: version!,
type: "jar",
classifier: null,
scope: "compile",
optional: false,
children,
...extra,
};
}
const model: BuildModel = {
maven: {
projects: [
{
pom: "pom.xml",
dir: "",
groupId: "demo",
artifactId: "conflict-demo",
version: "1.0",
packaging: "jar",
displayName: "conflict-demo",
parent: null,
modules: [],
aggregated: false,
plugins: [],
dependencies: [
dep("com.google.guava:guava:32.1.3-jre"),
dep("com.google.inject:guice:5.1.0", {}, [
dep("com.google.guava:guava:30.1-jre", { omitted: "conflict", conflictWith: "32.1.3-jre" }),
]),
],
profiles: [],
javaVersion: 17,
command: "mvn",
options: [],
env: {},
},
],
profiles: [],
syncedAt: 1,
stale: false,
syncError: null,
},
gradle: null,
};
describe("dependency analyzer", () => {
let dispose: () => void = () => {};
const openInput = vi.fn(() => Promise.resolve());
const openFile = vi.fn(() => Promise.resolve());
beforeEach(() => {
provideEditorService({ openInput, openFile, activePath: () => null } as unknown as EditorService);
setContext("projectOpen", true);
dispose = activate();
act(() => useBuild.setState({ root: "/p", model }));
});
afterEach(() => {
dispose();
setContext("projectOpen", false);
vi.clearAllMocks();
});
it("opens as an editor tab from the action and from a pom.xml's menus", async () => {
await executeAction("maven.analyzeDependencies", { pom: "pom.xml" });
expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: ANALYZER_KIND }), { focus: true });
expect(useAnalyzer.getState().pom).toBe("pom.xml");
for (const menu of ["editor", "projectView", "editorTab"] as const) {
const items = menuGroupItems(menu, { paths: ["/p/pom.xml"] });
expect(items[0]).toMatchObject({ action: "maven.analyzeDependencies", arg: { pom: "pom.xml" } });
}
expect(menuGroupItems("editor", { paths: ["/p/other/pom.xml"] })).toEqual([]);
expect(menuGroupItems("editor", { paths: ["/p/src/Main.java"] })).toEqual([]);
});
it("marks conflicts, shows their usages and excludes through the direct dependency", async () => {
ipc.exclude.mockResolvedValue({ path: "/p/pom.xml", range: {} });
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
fireEvent.click(screen.getByRole("checkbox", { name: "Show conflicts only" }));
expect(screen.queryByText("com.google.inject:guice")).toBeNull();
fireEvent.mouseDown(screen.getByText("com.google.guava:guava").closest("[role=treeitem]")!, { button: 0 });
expect(screen.getByText("omitted for conflict with 32.1.3-jre")).toBeTruthy();
expect(screen.getByText("com.google.inject:guice:5.1.0")).toBeTruthy();
// Only the transitive usage can be excluded.
const exclude = screen.getAllByRole("button", { name: "Exclude" });
expect(exclude).toHaveLength(1);
await act(async () => fireEvent.click(exclude[0]!));
expect(ipc.exclude).toHaveBeenCalledWith(
"pom.xml",
{ groupId: "com.google.inject", artifactId: "guice" },
{ groupId: "com.google.guava", artifactId: "guava" },
);
});
});
@@ -0,0 +1,420 @@
/**
* Dependency Analyzer editor tab: a Maven module's resolved artifacts (flat list or
* the transitive tree) with conflicts marked, and for the selected artifact every
* path that brings it in, with Exclude / Jump to Source / Copy Coordinates.
*/
import { useMemo, useState, type MouseEvent } from "react";
import clsx from "clsx";
import { call, commands, type MavenDependency, type MavenProject } from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { editorService, notifications, type EditorInput } from "@/workbench/contributions";
import {
Button,
Checkbox,
EmptyState,
Icon,
IconButton,
SearchField,
Select,
SplitGroup,
SplitHandle,
SplitPanel,
Spinner,
T,
Tree,
copyText,
showContextMenu,
type MenuItem,
type SelectOption,
} from "@/ui";
import {
analyze,
artifactKey,
coordinates,
exclusionFor,
filterArtifacts,
filterTree,
scopesOf,
sortedUsages,
statusText,
usageStatus,
type AnalyzedArtifact,
type DependencyTreeNode,
type DependencyUsage,
} from "./analyzer";
import { syncTool, useAnalyzer, useBuild } from "./store";
import styles from "./DependencyAnalyzer.module.css";
type Mode = "list" | "tree";
type Node =
| { kind: "artifact"; key: string; artifact: AnalyzedArtifact }
| { kind: "node"; key: string; node: DependencyTreeNode; children: Node[] };
const ALL_SCOPES = "*";
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
async function jumpToDeclaration(pom: string, d: MavenDependency) {
try {
const loc = await call(commands.mavenDependencyDeclaration(pom, { groupId: d.groupId, artifactId: d.artifactId }));
const { start, end } = loc.range;
await editorService().openFile(loc.path, {
focus: true,
selection: { line: start.line, col: start.col, endLine: end.line, endCol: end.col },
});
} catch (e) {
notifications().notify({
level: "warning",
group: "tools",
title: "Cannot find the declaration",
body: errorText(e),
});
}
}
async function exclude(pom: string, usage: DependencyUsage) {
const target = exclusionFor(usage);
if (!target) return;
const what = `${target.exclusion.groupId}:${target.exclusion.artifactId}`;
try {
await call(commands.mavenExcludeDependency(pom, target.dependency, target.exclusion));
} catch (e) {
notifications().notify({ level: "error", group: "tools", title: `Cannot exclude ${what}`, body: errorText(e) });
return;
}
await syncTool("maven");
}
function versionLabel(d: MavenDependency) {
return d.version ?? "";
}
function moduleOptions(projects: readonly MavenProject[]): SelectOption[] {
return projects
.filter((p) => p.packaging !== "pom" || p.dependencies.length > 0)
.map((p) => ({ value: p.pom, label: p.displayName, detail: p.pom, icon: "maven-project" }));
}
function ArtifactRow({ artifact, selected }: { artifact: AnalyzedArtifact; selected: boolean }) {
const tx = useTx();
return (
<span className={styles.row}>
<Icon name={artifact.conflict ? "warning" : "library"} size={16} className={styles.icon} />
<span className={clsx(styles.label, artifact.conflict && styles.conflict, selected && styles.selectedLabel)}>
{artifact.key}
</span>
<span className={styles.version}>{artifact.version ?? tx("omitted")}</span>
{artifact.conflict && (
<span className={styles.detail}>{artifact.requested.filter((v) => v !== artifact.version).join(", ")}</span>
)}
{artifact.scope !== "compile" && <span className={styles.detail}>({artifact.scope})</span>}
</span>
);
}
function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; conflict: boolean; selected: boolean }) {
const tx = useTxTemplate();
const d = node.dependency;
const status = statusText(usageStatus(d));
return (
<span className={clsx(styles.row, d.omitted != null && styles.omitted)}>
<Icon name={conflict ? "warning" : "library"} size={16} className={styles.icon} />
<span className={clsx(styles.label, conflict && styles.conflict, selected && styles.selectedLabel)}>
{artifactKey(d)}
</span>
<span className={styles.version}>{versionLabel(d)}</span>
{d.scope !== "compile" && <span className={styles.detail}>({d.scope})</span>}
{status && <span className={styles.detail}>{tx(status)}</span>}
</span>
);
}
function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: MavenProject; pom: string }) {
const tx = useTxTemplate();
const status = usageStatus(usage.dependency);
const text = statusText(status);
const direct = usage.path[0]!;
const canExclude = !!exclusionFor(usage);
return (
<section className={styles.usage} aria-label={coordinates(usage.dependency)}>
<div className={styles.usageHeader}>
<Icon
name={status.kind === "conflict" ? "warning" : status.kind === "resolved" ? "success" : "info"}
size={16}
className={styles.icon}
/>
<span className={styles.usageVersion}>{status.version}</span>
<span className={clsx(styles.usageStatus, status.kind === "conflict" && styles.conflict)}>
{text ? tx(text) : tx("resolved")}
</span>
<span className={styles.spacer} />
{canExclude && (
<Button size="small" variant="ghost" onClick={() => void exclude(pom, usage)}>
Exclude
</Button>
)}
<IconButton
icon="autoscroll-to-source"
size="small"
title="Jump to Source"
onClick={() => void jumpToDeclaration(pom, direct)}
/>
</div>
<ol className={styles.path}>
<li className={styles.step} style={{ paddingLeft: 0 }}>
<Icon name="maven-project" size={16} className={styles.icon} />
<span className={styles.stepLabel}>{module.displayName}</span>
</li>
{usage.path.map((d, i) => (
<li key={i} className={styles.step} style={{ paddingLeft: (i + 1) * 16 }}>
<Icon name="library" size={16} className={styles.icon} />
<span className={clsx(styles.stepLabel, i === usage.path.length - 1 && styles.stepTarget)}>
{coordinates(d)}
</span>
{d.scope !== "compile" && <span className={styles.detail}>({d.scope})</span>}
</li>
))}
</ol>
</section>
);
}
function UsagesPane({
artifact,
module,
pom,
}: {
artifact: AnalyzedArtifact | null;
module: MavenProject;
pom: string;
}) {
if (!artifact) return <EmptyState title="Select a dependency to see its usages" />;
const usages = sortedUsages(artifact.usages);
const coords = coordinates({ ...artifact, version: artifact.version });
return (
<div className={styles.usages}>
<div className={styles.usagesHeader}>
<span className={clsx(styles.usagesTitle, artifact.conflict && styles.conflict)}>{coords}</span>
<IconButton icon="copy" size="small" title="Copy Coordinates" onClick={() => void copyText(coords)} />
</div>
{artifact.conflict && (
<div className={styles.conflictNote} role="note">
<Icon name="warning" size={16} />
<span>
<T>Versions requested</T>: {artifact.requested.join(", ")}
</span>
</div>
)}
<div className={styles.usageList} aria-label="Usages">
{usages.map((u, i) => (
<UsageBlock key={i} usage={u} module={module} pom={pom} />
))}
</div>
</div>
);
}
export function DependencyAnalyzer(_props: { input: EditorInput }) {
const tx = useTx();
const maven = useBuild((s) => s.model?.maven ?? null);
const syncing = useBuild((s) => s.syncing.maven);
const chosen = useAnalyzer((s) => s.pom);
const [query, setQuery] = useState("");
const [conflictsOnly, setConflictsOnly] = useState(false);
const [scope, setScope] = useState<string>(ALL_SCOPES);
const [mode, setMode] = useState<Mode>("list");
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const options = useMemo(() => moduleOptions(maven?.projects ?? []), [maven]);
const pom = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null);
const module = maven?.projects.find((p) => p.pom === pom) ?? null;
const artifacts = useMemo(() => analyze(module?.dependencies ?? []), [module]);
const conflicted = useMemo(() => new Set(artifacts.filter((a) => a.conflict).map((a) => a.key)), [artifacts]);
const filter = useMemo(
() => ({ query, conflictsOnly, scope: scope === ALL_SCOPES ? null : scope }),
[query, conflictsOnly, scope],
);
const listed = useMemo(() => filterArtifacts(artifacts, filter), [artifacts, filter]);
const tree = useMemo(() => filterTree(module?.dependencies ?? [], filter, conflicted), [module, filter, conflicted]);
const roots: Node[] = useMemo(() => {
const toNode = (node: DependencyTreeNode): Node => ({
kind: "node",
key: `n:${node.key}`,
node,
children: node.children.map(toNode),
});
return mode === "list"
? listed.map((artifact) => ({ kind: "artifact", key: `a:${artifact.key}`, artifact }))
: tree.map(toNode);
}, [mode, listed, tree]);
// A narrowing filter opens the tree down to its matches until the user expands or collapses.
const filterId = `${mode}|${filter.query}|${filter.conflictsOnly}|${filter.scope}|${pom}`;
const [manual, setManual] = useState<{ id: string; keys: Set<string> } | null>(null);
const expanded = useMemo(() => {
if (manual?.id === filterId) return manual.keys;
const keys = new Set<string>();
if (filter.conflictsOnly || filter.scope != null || filter.query.trim()) {
const walk = (nodes: readonly Node[]) => {
for (const n of nodes)
if (n.kind === "node" && n.children.length) {
keys.add(n.key);
walk(n.children);
}
};
walk(roots);
}
return keys;
}, [manual, filterId, filter, roots]);
const selected = artifacts.find((a) => a.key === selectedKey) ?? null;
const scopeOptions: SelectOption[] = [
{ value: ALL_SCOPES, label: tx("All scopes") },
...scopesOf(artifacts).map((s) => ({ value: s, label: s })),
];
const nodeArtifactKey = (n: Node) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency));
/** The usage a row stands for: a tree node's own, else the artifact's direct (or first) one. */
const usageOf = (n: Node): DependencyUsage | undefined => {
if (n.kind === "node")
return artifacts
.find((a) => a.key === nodeArtifactKey(n))
?.usages.find((u) => u.dependency === n.node.dependency);
return n.artifact.usages.find((u) => u.path.length === 1) ?? n.artifact.usages[0];
};
const contextMenu = (n: Node | null, _sel: Set<string>, e: MouseEvent) => {
if (!n || !pom) return;
e.preventDefault();
const usage = usageOf(n);
if (!usage) return;
const items: MenuItem[] = [
{ label: "Jump to Source", icon: "autoscroll-to-source", run: () => void jumpToDeclaration(pom, usage.path[0]!) },
];
if (exclusionFor(usage)) items.push({ label: "Exclude", run: () => void exclude(pom, usage) });
items.push({ label: "Copy Coordinates", icon: "copy", run: () => void copyText(coordinates(usage.dependency)) });
showContextMenu({ x: e.clientX, y: e.clientY }, items);
};
if (!maven) return <EmptyState title="No Maven projects" />;
return (
<div className={styles.view}>
<div className={styles.toolbar} role="toolbar" aria-label="Dependency Analyzer">
<Select
aria-label="Module"
className={styles.module}
value={pom}
options={options}
onChange={(v) => {
useAnalyzer.setState({ pom: v });
setSelectedKey(null);
}}
/>
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder={tx("Search dependencies")}
aria-label="Search dependencies"
clearable
/>
<Checkbox checked={conflictsOnly} onChange={setConflictsOnly} label="Show conflicts only" />
<Select aria-label="Scope" className={styles.scope} value={scope} options={scopeOptions} onChange={setScope} />
<span className={styles.sep} />
<IconButton
icon="list"
size="small"
iconSize={16}
title="Show as List"
pressed={mode === "list"}
onClick={() => setMode("list")}
/>
<IconButton
icon="hierarchy"
size="small"
iconSize={16}
title="Show as Tree"
pressed={mode === "tree"}
onClick={() => setMode("tree")}
/>
<span className={styles.sep} />
{syncing ? (
<IconButton icon="stop" size="small" iconSize={16} action="maven.cancelReload" />
) : (
<IconButton icon="refresh" size="small" iconSize={16} action="maven.reload" />
)}
</div>
{syncing ? (
<div className={styles.banner} role="status">
<Spinner size={12} />
<span className={styles.bannerText}>{syncing.message}</span>
</div>
) : maven.stale && maven.syncedAt != null ? (
<div className={styles.banner}>
<Icon name="maven-load-changes" size={16} />
<span className={styles.bannerText}>
<T>Dependency trees may be out of date</T>
</span>
<Button size="small" variant="ghost" onClick={() => void syncTool("maven")}>
Load Maven Changes
</Button>
</div>
) : null}
{maven.syncedAt == null ? (
<EmptyState
title="Dependency trees are read when Maven projects are reloaded"
actions={
<Button size="small" onClick={() => void syncTool("maven")} disabled={!!syncing}>
Reload All Maven Projects
</Button>
}
/>
) : !module || !pom ? (
<EmptyState title="No Maven modules" />
) : (
<div className={styles.body}>
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
<Tree<Node>
aria-label="Dependencies"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency))}
getChildren={(n) => (n.kind === "node" ? n.children : undefined)}
isExpandable={(n) => n.kind === "node" && n.node.children.length > 0}
expanded={expanded}
onExpandedChange={(next) => setManual({ id: filterId, keys: next })}
onSelectionChange={(_keys, lead) => setSelectedKey(lead ? nodeArtifactKey(lead) : null)}
renderNode={(n, state) =>
n.kind === "artifact" ? (
<ArtifactRow artifact={n.artifact} selected={state.selected} />
) : (
<TreeRow
node={n.node}
conflict={conflicted.has(artifactKey(n.node.dependency))}
selected={state.selected}
/>
)
}
onActivate={(n) => {
const usage = usageOf(n);
if (usage) void jumpToDeclaration(pom, usage.path[0]!);
}}
onContextMenu={contextMenu}
multiSelect={false}
empty={<EmptyState title={artifacts.length ? "No dependencies match the filter" : "No dependencies"} />}
/>
</SplitPanel>
<SplitHandle />
<SplitPanel id="usages" minSize={240} className={styles.panel}>
<UsagesPane artifact={selected} module={module} pom={pom} />
</SplitPanel>
</SplitGroup>
</div>
)}
</div>
);
}
@@ -70,6 +70,7 @@ describe("build tools feature", () => {
it("offers its actions in Search Everywhere only for Maven projects and toggles run options", async () => {
const visible = () => allActions().filter((a) => !a.hidden && a.id.startsWith("maven."));
expect(visible().map((a) => a.id).sort()).toEqual([
"maven.analyzeDependencies",
"maven.executeGoal",
"maven.reload",
"maven.toggleOffline",
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { MavenDependency } from "@/platform/ipc";
import {
analyze,
coordinates,
exclusionFor,
filterArtifacts,
filterTree,
scopesOf,
sortedUsages,
statusText,
usageStatus,
type AnalyzerFilter,
} from "./analyzer";
function dep(coords: string, extra: Partial<MavenDependency> = {}, children: MavenDependency[] = []): MavenDependency {
const [groupId, artifactId, version, scope = "compile"] = coords.split(":");
return {
groupId: groupId!,
artifactId: artifactId!,
version: version!,
type: "jar",
classifier: null,
scope,
optional: false,
children,
...extra,
};
}
/** The tree `dependency:tree -Dverbose` printed for the conflict demo project. */
const TREE: MavenDependency[] = [
dep("org.hamcrest:hamcrest-core:1.3"),
dep("org.apache.commons:commons-text:1.10.0", {}, [dep("org.apache.commons:commons-lang3:3.12.0")]),
dep("org.apache.commons:commons-configuration2:2.10.1", {}, [
dep("org.apache.commons:commons-lang3:3.14.0", { omitted: "conflict", conflictWith: "3.12.0" }),
dep("org.apache.commons:commons-text:1.11.0", { omitted: "conflict", conflictWith: "1.10.0" }),
dep("commons-logging:commons-logging:1.2", { managedFrom: "1.3.0" }),
]),
dep("com.google.guava:guava:32.1.3-jre", {}, [dep("com.google.guava:failureaccess:1.0.1")]),
dep("com.google.inject:guice:5.1.0", {}, [
dep("javax.inject:javax.inject:1"),
dep("com.google.guava:guava:30.1-jre", { omitted: "conflict", conflictWith: "32.1.3-jre" }),
]),
dep("junit:junit:4.13.2:test", {}, [dep("org.hamcrest:hamcrest-core:1.3:test", { omitted: "duplicate" })]),
];
const ALL: AnalyzerFilter = { query: "", conflictsOnly: false, scope: null };
const byKey = (key: string) => analyze(TREE).find((a) => a.key === key)!;
describe("dependency analyzer", () => {
it("collects every artifact with its resolved version and conflicts", () => {
const artifacts = analyze(TREE);
expect(artifacts.filter((a) => a.conflict).map((a) => a.key)).toEqual([
"com.google.guava:guava",
"org.apache.commons:commons-lang3",
"org.apache.commons:commons-text",
]);
const lang3 = byKey("org.apache.commons:commons-lang3");
expect(lang3).toMatchObject({ version: "3.12.0", requested: ["3.12.0", "3.14.0"], scope: "compile" });
const hamcrest = byKey("org.hamcrest:hamcrest-core");
expect(hamcrest.conflict).toBe(false);
expect(hamcrest.usages).toHaveLength(2);
// Managed versions count the version that was asked for.
expect(byKey("commons-logging:commons-logging").requested).toEqual(["1.3.0"]);
});
it("lists usage paths from the direct dependency, resolved first", () => {
const usages = sortedUsages(byKey("com.google.guava:guava").usages);
expect(usages.map((u) => u.path.map((d) => d.artifactId))).toEqual([["guava"], ["guice", "guava"]]);
expect(usages.map((u) => statusText(usageStatus(u.dependency)))).toEqual([
null,
"omitted for conflict with 32.1.3-jre",
]);
expect(statusText(usageStatus(byKey("commons-logging:commons-logging").usages[0]!.dependency))).toBe(
"version managed from 1.3.0",
);
expect(statusText(usageStatus(dep("a:b:1", { omitted: "duplicate" })))).toBe("omitted for duplicate");
});
it("excludes through the direct dependency that brings an occurrence in", () => {
const [direct, transitive] = sortedUsages(byKey("com.google.guava:guava").usages);
expect(exclusionFor(direct!)).toBeNull();
expect(exclusionFor(transitive!)).toEqual({
dependency: { groupId: "com.google.inject", artifactId: "guice" },
exclusion: { groupId: "com.google.guava", artifactId: "guava" },
});
});
it("filters by conflicts, scope and text (including requested versions)", () => {
const keys = (f: Partial<AnalyzerFilter>) => filterArtifacts(analyze(TREE), { ...ALL, ...f }).map((a) => a.key);
expect(keys({ conflictsOnly: true })).toHaveLength(3);
expect(keys({ scope: "test" })).toEqual(["junit:junit", "org.hamcrest:hamcrest-core"]);
expect(keys({ query: "LANG3" })).toEqual(["org.apache.commons:commons-lang3"]);
expect(keys({ query: "30.1-jre" })).toEqual(["com.google.guava:guava"]);
expect(scopesOf(analyze(TREE))).toEqual(["compile", "test"]);
});
it("keeps the paths to matches in the tree view", () => {
const conflicted = new Set(
analyze(TREE)
.filter((a) => a.conflict)
.map((a) => a.key),
);
expect(filterTree(TREE, ALL, conflicted)).toHaveLength(TREE.length);
const tree = filterTree(TREE, { ...ALL, conflictsOnly: true }, conflicted);
expect(tree.map((n) => n.dependency.artifactId)).toEqual([
"commons-text",
"commons-configuration2",
"guava",
"guice",
]);
expect(tree[1]!.children.map((n) => n.dependency.artifactId)).toEqual(["commons-lang3", "commons-text"]);
expect(tree[3]!.children.map((n) => n.key)).toEqual(["/4/1"]);
expect(filterTree(TREE, { ...ALL, query: "hamcrest", scope: "test" }, conflicted).map((n) => n.key)).toEqual([
"/5",
]);
});
it("formats coordinates with the classifier", () => {
expect(coordinates(dep("g:a:1", { classifier: "linux" }))).toBe("g:a:linux:1");
expect(coordinates(dep("g:a:1"))).toBe("g:a:1");
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Dependency Analyzer model: the artifacts of a Maven module's dependency tree
* (read with `dependency:tree -Dverbose`, so it also lists the occurrences Maven
* omitted), which of them conflict, and every path that brings them in.
*/
import type { ArtifactKey, MavenDependency } from "@/platform/ipc";
/** One occurrence of an artifact in the tree, with the path from a direct dependency down to it. */
export interface DependencyUsage {
/** `path[0]` is the module's direct dependency, the last element the occurrence. */
path: MavenDependency[];
dependency: MavenDependency;
}
export interface AnalyzedArtifact {
/** `groupId:artifactId[:classifier]`. */
key: string;
groupId: string;
artifactId: string;
classifier: string | null;
/** Version on the classpath; `null` when every occurrence was omitted. */
version: string | null;
/** Scope of the resolved occurrence (else of the first one). */
scope: string;
/** Distinct versions asked for (before conflict resolution and dependency management). */
requested: string[];
/** Several versions were asked for and some lost. */
conflict: boolean;
usages: DependencyUsage[];
}
export interface AnalyzerFilter {
query: string;
conflictsOnly: boolean;
/** `null` = any scope. */
scope: string | null;
}
export const artifactKey = (d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier">) =>
[d.groupId, d.artifactId, d.classifier].filter(Boolean).join(":");
/** `groupId:artifactId[:classifier]:version`. */
export function coordinates(d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier" | "version">): string {
return [d.groupId, d.artifactId, d.classifier, d.version].filter(Boolean).join(":");
}
const isResolved = (d: MavenDependency) => d.omitted == null;
/** The version a usage asked for: before `<dependencyManagement>` replaced it, if it did. */
export const requestedVersion = (d: MavenDependency) => d.managedFrom ?? d.version ?? null;
/** Every artifact of `deps` (a module's dependency tree), sorted by key. */
export function analyze(deps: readonly MavenDependency[]): AnalyzedArtifact[] {
const byKey = new Map<string, AnalyzedArtifact>();
const walk = (list: readonly MavenDependency[], parents: MavenDependency[]) => {
for (const d of list) {
const path = [...parents, d];
const key = artifactKey(d);
let a = byKey.get(key);
if (!a) {
a = {
key,
groupId: d.groupId,
artifactId: d.artifactId,
classifier: d.classifier ?? null,
version: null,
scope: d.scope,
requested: [],
conflict: false,
usages: [],
};
byKey.set(key, a);
}
a.usages.push({ path, dependency: d });
if (isResolved(d) && a.version == null) {
a.version = d.version ?? null;
a.scope = d.scope;
}
const requested = requestedVersion(d);
if (requested && !a.requested.includes(requested)) a.requested.push(requested);
if (d.omitted === "conflict") a.conflict = true;
walk(d.children, path);
}
};
walk(deps, []);
return [...byKey.values()].sort((x, y) => x.key.localeCompare(y.key));
}
/** Scopes used in the tree, in Maven's order. */
export function scopesOf(artifacts: readonly AnalyzedArtifact[]): string[] {
const order = ["compile", "provided", "runtime", "test", "system", "import"];
const found = new Set(artifacts.flatMap((a) => a.usages.map((u) => u.dependency.scope)));
return [...found].sort((x, y) => (order.indexOf(x) + 1 || 99) - (order.indexOf(y) + 1 || 99) || x.localeCompare(y));
}
function matchesQuery(d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier" | "version">, query: string) {
const q = query.trim().toLowerCase();
return !q || coordinates(d).toLowerCase().includes(q);
}
export function filterArtifacts(artifacts: readonly AnalyzedArtifact[], f: AnalyzerFilter): AnalyzedArtifact[] {
return artifacts.filter(
(a) =>
(!f.conflictsOnly || a.conflict) &&
(f.scope == null || a.usages.some((u) => u.dependency.scope === f.scope)) &&
(matchesQuery(a, f.query) || a.requested.some((v) => matchesQuery({ ...a, version: v }, f.query))),
);
}
/** A node of the tree view; `key` is unique (index path). */
export interface DependencyTreeNode {
key: string;
dependency: MavenDependency;
children: DependencyTreeNode[];
}
/**
* The tree view: nodes that pass the filter, with their ancestors (so every match keeps
* its path), and every descendant of a match when no filter narrows them.
*/
export function filterTree(
deps: readonly MavenDependency[],
f: AnalyzerFilter,
conflicted: ReadonlySet<string>,
): DependencyTreeNode[] {
const narrowing = f.conflictsOnly || f.scope != null || !!f.query.trim();
const passes = (d: MavenDependency) =>
(!f.conflictsOnly || conflicted.has(artifactKey(d))) &&
(f.scope == null || d.scope === f.scope) &&
matchesQuery(d, f.query);
const build = (list: readonly MavenDependency[], parent: string): DependencyTreeNode[] =>
list.flatMap((dependency, i) => {
const key = `${parent}/${i}`;
const children = build(dependency.children, key);
return !narrowing || passes(dependency) || children.length ? [{ key, dependency, children }] : [];
});
return build(deps, "");
}
export type UsageStatus =
| { kind: "resolved"; version: string | null; managedFrom: string | null }
| { kind: "conflict"; version: string | null; winner: string | null }
| { kind: "duplicate"; version: string | null }
| { kind: "cycle"; version: string | null };
export function usageStatus(d: MavenDependency): UsageStatus {
const version = d.version ?? null;
switch (d.omitted) {
case "conflict":
return { kind: "conflict", version, winner: d.conflictWith ?? null };
case "duplicate":
return { kind: "duplicate", version };
case "cycle":
return { kind: "cycle", version };
default:
return { kind: "resolved", version, managedFrom: d.managedFrom ?? null };
}
}
/** Human-readable status, e.g. `omitted for conflict with 3.12.0`. */
export function statusText(s: UsageStatus): string | null {
switch (s.kind) {
case "conflict":
return s.winner ? `omitted for conflict with ${s.winner}` : "omitted for conflict";
case "duplicate":
return "omitted for duplicate";
case "cycle":
return "omitted for cycle";
default:
return s.managedFrom ? `version managed from ${s.managedFrom}` : null;
}
}
/** What Exclude on `usage` adds: an exclusion of the occurrence to the direct dependency bringing it in. */
export function exclusionFor(usage: DependencyUsage): { dependency: ArtifactKey; exclusion: ArtifactKey } | null {
const [direct] = usage.path;
if (!direct || usage.path.length < 2) return null;
return {
dependency: { groupId: direct.groupId, artifactId: direct.artifactId },
exclusion: { groupId: usage.dependency.groupId, artifactId: usage.dependency.artifactId },
};
}
/** The resolved usage first, then the ones that lost a conflict, then the rest; shorter paths first. */
export function sortedUsages(usages: readonly DependencyUsage[]): DependencyUsage[] {
const rank = (u: DependencyUsage) => (isResolved(u.dependency) ? 0 : u.dependency.omitted === "conflict" ? 1 : 2);
return [...usages].sort((a, b) => rank(a) - rank(b) || a.path.length - b.path.length);
}
+54 -1
View File
@@ -7,14 +7,23 @@ import { registerActions } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerOverlay, registerRunAnythingProvider, registerToolWindow } from "@/workbench/contributions";
import {
registerEditorKind,
registerMenuGroup,
registerOverlay,
registerRunAnythingProvider,
registerToolWindow,
} from "@/workbench/contributions";
import { lazyComponent } from "@/ui";
import { GradleView, MavenView } from "./BuildToolView";
import { ExecutePopupHost } from "./ExecutePopup";
import { buildToolsRunAnything } from "./runAnything";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model";
import {
ANALYZER_KIND,
cancelSync,
loadModel,
openAnalyzer,
resetBuild,
setExpanded,
syncTool,
@@ -45,6 +54,19 @@ function openExecute(tool: BuildTool) {
useBuild.setState({ executeOpen: tool });
}
/** Project-relative `pom` of a Maven project from an action argument: `{ pom }`, or `{ path }` of a pom.xml. */
function analyzerPom(arg: unknown): string | null {
const { model, root, current } = useBuild.getState();
const projects = model?.maven?.projects ?? [];
const a = (arg ?? {}) as { pom?: unknown; path?: unknown };
if (typeof a.pom === "string") return a.pom;
if (typeof a.path === "string" && root) {
const rel = a.path.startsWith(`${root}/`) ? a.path.slice(root.length + 1) : a.path;
if (projects.some((p) => p.pom === rel)) return rel;
}
return current.maven ?? null;
}
const synced = new Set<string>();
/** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */
@@ -85,6 +107,27 @@ export function activate(): () => void {
const disposers: Array<() => void> = [
registerOverlay({ id: "build-tools.execute", component: ExecutePopupHost }),
registerRunAnythingProvider(buildToolsRunAnything),
registerEditorKind({
kind: ANALYZER_KIND,
component: lazyComponent(() => import("./DependencyAnalyzer").then((m) => m.DependencyAnalyzer)),
title: () => "Dependency Analyzer",
icon: () => "build-dependencies",
restorable: true,
}),
// pom.xml in the editor, its tab and the project view: Analyze Dependencies of that module.
registerMenuGroup({
id: "maven.analyzeDependencies",
menus: ["editor", "editorTab", "projectView"],
order: 30,
items: (_menu, { paths, dirs }) => {
const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null;
if (!path || !path.endsWith("/pom.xml")) return [];
const { model, root } = useBuild.getState();
const pom = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null;
if (!pom || !model?.maven?.projects.some((p) => p.pom === pom)) return [];
return [{ kind: "action", action: "maven.analyzeDependencies", arg: { pom }, label: "Analyze Dependencies…" }];
},
}),
useBuild.subscribe((s, prev) => {
if (s.model !== prev.model || s.root !== prev.root) apply(s.root ? s.model : null, registered);
}),
@@ -130,6 +173,16 @@ export function activate(): () => void {
keywords: ["maven", "mvn", "goal", "run anything"],
run: () => openExecute("maven"),
},
{
id: "maven.analyzeDependencies",
title: "Analyze Dependencies…",
description: "Shows a Maven module's dependency tree, version conflicts and where each library comes from",
category: "Maven",
icon: "build-dependencies",
when: MAVEN_WHEN,
keywords: ["maven", "dependency", "dependencies", "conflict", "tree", "exclude", "analyzer"],
run: ({ arg }) => openAnalyzer(analyzerPom(arg)),
},
{
id: "maven.toggleOffline",
title: "Toggle Offline Mode",
+10 -7
View File
@@ -181,8 +181,10 @@ export function mavenChildren(node: BuildNode, model: MavenModel, configs: reado
}));
case "dependencies":
return dependencyNodes(node.key, node.dependencies);
case "dependency":
return node.dependency.children.length ? dependencyNodes(node.key, node.dependency.children) : undefined;
case "dependency": {
const kids = dependencyNodes(node.key, node.dependency.children);
return kids.length ? kids : undefined;
}
case "runConfigs":
return node.configs.map((config) => ({ kind: "runConfig", key: `${node.key}:${config.id}`, config }));
default:
@@ -190,12 +192,13 @@ export function mavenChildren(node: BuildNode, model: MavenModel, configs: reado
}
}
/** The resolved dependencies; occurrences Maven omitted are shown by the Dependency Analyzer only. */
function dependencyNodes(parent: string, deps: readonly MavenDependency[]): BuildNode[] {
return deps.map((dependency, i) => ({
kind: "dependency",
key: `${parent}/${dependency.groupId}:${dependency.artifactId}:${i}`,
dependency,
}));
return deps.flatMap((dependency, i) =>
dependency.omitted == null
? [{ kind: "dependency" as const, key: `${parent}/${dependency.groupId}:${dependency.artifactId}:${i}`, dependency }]
: [],
);
}
// ---------------------------------------------------------------------------
+16 -1
View File
@@ -22,7 +22,7 @@ import {
} from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { editorService, notifications } from "@/workbench/contributions";
import { removeTask, upsertTask } from "@/workbench/tasks";
import { profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
@@ -232,6 +232,7 @@ export function cancelSync(tool: BuildTool) {
export function resetBuild() {
generation++;
useAnalyzer.setState({ pom: null });
for (const tool of ["maven", "gradle"] as const) {
cancelSync(tool);
removeTask(`build-sync:${tool}`);
@@ -269,3 +270,17 @@ export function setExpanded(tool: BuildTool, expanded: Set<string>) {
export function setCurrentProject(tool: BuildTool, id: string | undefined) {
if (useBuild.getState().current[tool] !== id) useBuild.setState((s) => ({ current: { ...s.current, [tool]: id } }));
}
// ---------------------------------------------------------------------------
// Dependency Analyzer (an editor tab)
export const ANALYZER_KIND = "maven.dependencyAnalyzer";
/** Module the Dependency Analyzer shows (`pom`, project-relative); `null` = the first one. */
export const useAnalyzer = create<{ pom: string | null }>(() => ({ pom: null }));
/** Opens (or focuses) the Dependency Analyzer tab, showing the module `pom` when given. */
export async function openAnalyzer(pom: string | null) {
if (pom) useAnalyzer.setState({ pom });
await editorService().openInput({ kind: ANALYZER_KIND, key: ANALYZER_KIND, data: {} }, { focus: true });
}
@@ -144,8 +144,8 @@ describe("store", () => {
it("selects and clears a plugin theme", async () => {
await refreshPlugins();
await selectPluginTheme("theme-solarized", "solarized-dark");
expect(usePlugins.getState().activeTheme?.theme.id).toBe("solarized-dark");
await selectPluginTheme("theme-harbor-night", "harbor-night");
expect(usePlugins.getState().activeTheme?.theme.id).toBe("harbor-night");
await selectPluginTheme(null, null);
expect(usePlugins.getState().activeTheme).toBeNull();
});
@@ -238,8 +238,8 @@ describe("PluginsPage", () => {
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument());
expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled();
await user.click(within(list).getByText("Solarized Themes"));
await user.click(screen.getByRole("button", { name: "Apply theme Solarized Dark" }));
await user.click(within(list).getByText("Harbor Night Theme"));
await user.click(screen.getByRole("button", { name: "Apply theme Harbor Night" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use Default Theme" })).toBeInTheDocument());
await user.type(screen.getByLabelText("Filter plugins"), "toy");
@@ -309,7 +309,7 @@ describe("catalog helpers", () => {
expect(catalogState(entry, [plugin({ id: "hello", version: "0.0.9" })])).toBe("update");
expect(catalogState(entry, [plugin({ id: "hello", version: "0.1.0" })])).toBe("installed");
expect(catalogState(entry, [plugin({ id: "hello", source: "dev" })])).toBe("dev");
expect(filterCatalog(sampleCatalog(), "solarized").map((c) => c.id)).toEqual(["theme-solarized"]);
expect(filterCatalog(sampleCatalog(), "harbor").map((c) => c.id)).toEqual(["theme-harbor-night"]);
});
it("disables and re-enables all downloaded plugins, leaving dev plugins alone", async () => {
+18 -32
View File
@@ -76,35 +76,21 @@ function ensureSubscribed() {
// ---------------------------------------------------------------------------
// Browser simulation: the bundled sample plugins, with `hello` implemented in JS.
const SOLARIZED: ActiveTheme["theme"][] = [
// templates/theme-plugin, abridged.
const SAMPLE_THEMES: ActiveTheme["theme"][] = [
{
id: "solarized-light",
name: "Solarized Light",
kind: "light",
tokens: {
"--bg-app": "#fdf6e3",
"--bg-island": "#fdf6e3",
"--bg-frame": "#eee8d5",
"--bg-elev": "#eee8d5",
"--fg": "#657b83",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
},
},
{
id: "solarized-dark",
name: "Solarized Dark",
id: "harbor-night",
name: "Harbor Night",
kind: "dark",
tokens: {
"--bg-app": "#002b36",
"--bg-island": "#002b36",
"--bg-frame": "#073642",
"--bg-elev": "#073642",
"--fg": "#839496",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
"--bg-app": "#14171c",
"--bg-island": "#14171c",
"--bg-frame": "#1f252e",
"--bg-elev": "#1b2027",
"--fg": "#c9d1dc",
"--accent": "#2b9e93",
"--syn-keyword": "#e0a46e",
"--syn-string": "#9ccc7a",
},
},
];
@@ -140,14 +126,14 @@ function samplePlugins(): PluginInfo[] {
},
{
...base,
id: "theme-solarized",
name: "Solarized Themes",
id: "theme-harbor-night",
name: "Harbor Night Theme",
version: "1.0.0",
description: "Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized",
description: "Example dark theme: slate surfaces, a teal accent and a warm syntax palette.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-harbor-night",
status: "static",
hasCode: false,
themes: SOLARIZED.map(({ id, name, kind }) => ({ id, name, kind })),
themes: SAMPLE_THEMES.map(({ id, name, kind }) => ({ id, name, kind })),
},
{
...base,
@@ -201,7 +187,7 @@ export function createMemoryPluginsBackend(
const activeTheme = (): ActiveTheme | null => {
if (!theme) return null;
const p = plugins.find((x) => x.id === theme?.pluginId);
const t = SOLARIZED.find((x) => x.id === theme?.themeId);
const t = SAMPLE_THEMES.find((x) => x.id === theme?.themeId);
if (!p?.enabled || !t) return null;
return { pluginId: p.id, theme: t };
};
@@ -19,6 +19,7 @@ export const OPTION_LABELS: Array<[keyof ViewOptions, string]> = [
["autoscrollToSource", "Autoscroll to Source"],
["autoscrollFromSource", "Autoscroll from Source"],
["singleClickOpen", "Open Files with Single Click"],
["singleClickExpand", "Expand Directories with Single Click"],
["previewTab", "Enable Preview Tab"],
];
@@ -109,6 +109,38 @@ describe("ProjectView", () => {
act(() => setViewOption("previewTab", false));
});
it("expands and collapses folders on single click only when enabled", async () => {
const { row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
const src = () => row("src").querySelector("[data-project-path]")!;
const click = (detail = 1, init: MouseEventInit = {}) => {
fireEvent.mouseDown(row("src"), { detail, ...init });
fireEvent.click(src(), { detail, ...init });
};
click();
expect(rows()).not.toContain("App.kt");
act(() => setViewOption("singleClickExpand", true));
click();
await waitFor(() => expect(rows()).toContain("App.kt"));
click();
expect(rows()).not.toContain("App.kt");
// A double click toggles once: the second click and the dblclick are ignored.
click(1);
click(2);
fireEvent.doubleClick(src());
await waitFor(() => expect(rows()).toContain("App.kt"));
// Modifier clicks only change the selection.
fireEvent.mouseDown(row("README.md"));
click(1, { metaKey: true });
expect(useProjectTree.getState().selected).toEqual(new Set(["/w/acme/README.md", "/w/acme/src"]));
click(1, { shiftKey: true });
expect(rows()).toContain("App.kt");
act(() => setViewOption("singleClickExpand", false));
});
it("cancels a new file when its name field loses focus", async () => {
const { rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
+21 -2
View File
@@ -72,6 +72,7 @@ import {
selectModel,
setDropTarget,
setExpanded,
toggleExpanded,
setSelection,
useProjectTree,
type Editing,
@@ -221,6 +222,7 @@ interface RowProps {
detail: string | null;
onEditDone: () => void;
onClick: (node: ProjectNode, e: ReactMouseEvent) => void;
onDoubleClick: (node: ProjectNode, e: ReactMouseEvent) => void;
onPointerDown: (node: ProjectNode, e: ReactPointerEvent) => void;
}
@@ -233,6 +235,7 @@ const Row = memo(function Row({
detail,
onEditDone,
onClick,
onDoubleClick,
onPointerDown,
}: RowProps) {
const deco = useFileDecoration(node.path);
@@ -252,6 +255,7 @@ const Row = memo(function Row({
data-dir={node.dir || undefined}
title={deco.tooltip}
onClick={(e) => onClick(node, e)}
onDoubleClick={(e) => onDoubleClick(node, e)}
onPointerDown={(e) => onPointerDown(node, e)}
>
<Icon name={icon} size={16} className={clsx(styles.icon, node.dir && styles.folderIcon, dimIcon && styles.faded)} />
@@ -408,17 +412,30 @@ export function ProjectView() {
const focusTree = useCallback(() => requestAnimationFrame(() => handle.current?.focus()), []);
// A pointer drag that ends on the row it started from still clicks it.
const dragged = useRef(false);
const onRowClick = useCallback((node: ProjectNode, e: ReactMouseEvent) => {
if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || e.detail > 1) return;
if (node.dir || node.placeholder) return;
if (node.placeholder || dragged.current) return;
const { options: o, editing: ed } = useProjectTree.getState();
if (ed) return;
if (node.dir) {
if (o.singleClickExpand) toggleExpanded(node);
return;
}
if (o.singleClickOpen) void openFile(node.path, { focus: false });
else if (o.previewTab) void openFile(node.path, { preview: true, focus: false });
}, []);
// Pointer-driven drag & drop (HTML5 DnD is unreliable inside the Tauri webview).
// The single click already toggled the folder; double-click must not toggle it back.
const onRowDoubleClick = useCallback((node: ProjectNode, e: ReactMouseEvent) => {
if (node.dir && !node.placeholder && useProjectTree.getState().options.singleClickExpand) e.stopPropagation();
}, []);
const onRowPointerDown = useCallback((node: ProjectNode, e: ReactPointerEvent) => {
if (e.button === 0) dragged.current = false;
if (e.button !== 0 || node.root || node.placeholder || node.readOnly || useProjectTree.getState().editing) return;
const before = useProjectTree.getState().selected;
const lead = useProjectTree.getState().lead;
@@ -430,6 +447,7 @@ export function ProjectView() {
if (!active) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
active = true;
dragged.current = true;
const m = selectModel(useProjectTree.getState());
const keys = before.has(node.key) ? [...before] : [node.key];
if (before.has(node.key)) setSelection(before, lead);
@@ -577,10 +595,11 @@ export function ProjectView() {
detail={node.root && !node.icon ? rootDetail : (node.detail ?? null)}
onEditDone={focusTree}
onClick={onRowClick}
onDoubleClick={onRowDoubleClick}
onPointerDown={onRowPointerDown}
/>
),
[editing, cutPaths, dropTarget, rootDetail, focusTree, onRowClick, onRowPointerDown],
[editing, cutPaths, dropTarget, rootDetail, focusTree, onRowClick, onRowDoubleClick, onRowPointerDown],
);
if (!root) return null;
+1
View File
@@ -154,6 +154,7 @@ export function registerProjectActions(): () => void {
title: tx`Project View: ${label}`,
category: "View",
keywords: ["project", "tree", "toggle"],
toggled: () => useProjectTree.getState().options[key],
run: () => setViewOption(key, !useProjectTree.getState().options[key]),
}));
+26
View File
@@ -10,6 +10,8 @@ import {
revealPath,
setExpanded,
setSelection,
setViewOption,
toggleExpanded,
useProjectTree,
} from "./store";
import { beginNew, commitEditing, copySelection, paste, transfer } from "./operations";
@@ -173,3 +175,27 @@ describe("file operations", () => {
expect(fs.nodes.has("/p/docs/guide.md")).toBe(true);
});
});
describe("single-click expansion", () => {
it("toggles a folder, opening every folder of a compacted chain", async () => {
await loadDir(ROOT);
await loadDir("/p/src");
await vi.waitFor(() => expect(childLabels(ROOT)).toContain("src/main/kotlin"));
const chain = currentModel().byKey.get("/p/src")!;
toggleExpanded(chain);
expect([...useProjectTree.getState().expanded]).toEqual(
expect.arrayContaining(["/p/src", "/p/src/main", "/p/src/main/kotlin"]),
);
toggleExpanded(chain);
expect(useProjectTree.getState().expanded.has("/p/src")).toBe(false);
});
it("is off by default and its option persists across reloads", async () => {
expect(useProjectTree.getState().options.singleClickExpand).toBe(false);
setViewOption("singleClickExpand", true);
vi.resetModules();
const fresh = await import("./store");
expect(fresh.useProjectTree.getState().options.singleClickExpand).toBe(true);
setViewOption("singleClickExpand", false);
});
});
+15
View File
@@ -24,6 +24,7 @@ import {
planRefresh,
visibleEntries,
type Placeholder,
type ProjectNode,
type TreeModel,
type TreeOptions,
} from "./model";
@@ -38,6 +39,8 @@ export interface ViewOptions extends TreeOptions {
autoscrollToSource: boolean;
/** Single click opens files (as regular tabs). */
singleClickOpen: boolean;
/** Single click on a folder row expands / collapses it. */
singleClickExpand: boolean;
/** Single click shows files in the preview tab (IDEA's "Enable Preview Tab"). */
previewTab: boolean;
}
@@ -47,6 +50,7 @@ export const DEFAULT_VIEW_OPTIONS: ViewOptions = {
autoscrollFromSource: false,
autoscrollToSource: false,
singleClickOpen: false,
singleClickExpand: false,
// IDEA's "Enable Preview Tab" is off by default.
previewTab: false,
};
@@ -297,6 +301,17 @@ export function setExpanded(expanded: ReadonlySet<string>) {
persistExpanded();
}
/** Expands or collapses a folder node; expanding a compacted chain opens every folder in it. */
export function toggleExpanded(node: Pick<ProjectNode, "key" | "chain">) {
const next = new Set(get().expanded);
if (next.has(node.key)) next.delete(node.key);
else {
next.add(node.key);
for (const p of node.chain) next.add(keyLike(node.key, p));
}
setExpanded(next);
}
export function setSelection(selected: ReadonlySet<string>, lead: string | null) {
set({ selected, lead });
}
@@ -2,6 +2,7 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { contrastRatio, isColorValue, readTokens } from "../theme/contrast";
import { BUILTIN_THEMES } from "@/platform/themes";
const css = readFileSync(`${process.cwd()}/src/styles/tokens.css`, "utf8");
const dark = readTokens(css, ":root");
@@ -116,3 +117,49 @@ describe("theme tokens", () => {
expect(contrastRatio("#000", "#fff")).toBeCloseTo(21, 5);
});
});
/**
* Tokens the palette themes (Solarized, One) take from their light / dark base
* on purpose: colours the user assigns (project and data source colours), the
* platform's caption close button, and the tint strength.
*/
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
describe("palette themes", () => {
const ISLANDS = ["light", "dark", "dark-hc"];
const palettes = BUILTIN_THEMES.filter((t) => !ISLANDS.includes(t.id));
const shared = readTokens(css, ":root[data-color-theme]");
// Every themeable token (the Islands Light block lists them all) and every plain colour of :root.
const required = [
...new Set([
...Object.keys(readTokens(css, ':root[data-theme="light"]')),
...Object.entries(dark)
.filter(([, v]) => isColorValue(v) && !v.includes("var("))
.map(([k]) => k),
]),
];
it("have a tokens.css block for every palette theme id, and no other", () => {
const blocks = [...css.matchAll(/:root\[data-color-theme="([a-z0-9-]+)"\]/g)].map((m) => m[1]);
expect([...new Set(blocks)].sort()).toEqual(palettes.map((t) => t.id).sort());
expect(palettes.length).toBeGreaterThanOrEqual(4);
});
it.each(palettes)("$id defines every token or inherits it from its $kind base on purpose", ({ id, kind }) => {
// A failure here means tokens.css gained a token: map it in the :root[data-color-theme]
// block (or the theme's own), or add it to INHERITED_FROM_BASE if the base's value is right.
const own = { ...shared, ...readTokens(css, `:root[data-color-theme="${id}"]`) };
expect(required.length).toBeGreaterThan(200);
expect(required.filter((k) => !(k in own) && !INHERITED_FROM_BASE.test(k))).toEqual([]);
const base = kind === "light" ? light : dark;
expect(required.filter((k) => INHERITED_FROM_BASE.test(k) && !(k in base))).toEqual([]);
// Every palette role the shared mapping uses is set by the theme.
const refs = Object.values(own)
.join(" ")
.matchAll(/var\((--palette-[a-z0-9-]+)/g);
const roles = new Set([...refs].map((m) => m[1]!));
expect([...roles].filter((r) => !(r in own))).toEqual([]);
for (const t of ["--chart-series", "--bg-frame", "--fg-muted", "--accent", "--ansi-red", "--syn-keyword"])
expect(own[t], `${id} ${t}`).toBeTruthy();
});
});
@@ -4,11 +4,13 @@
* `ide-core::settings` defaults; validation never throws.
*/
import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes";
export type Antialiasing = "subpixel" | "greyscale" | "none";
export type OpenProjectIn = "ask" | "new" | "same";
export type NotificationDisplay = "balloon" | "sticky" | "none";
/** Themes selectable as "preferred" when syncing with the OS. */
export type ConcreteTheme = "light" | "dark" | "dark-hc";
export type ConcreteTheme = BuiltinThemeId;
export interface AppearanceExtras {
syncWithOs: boolean;
@@ -102,7 +104,7 @@ const int = (v: unknown, d: number, { min, max }: { min: number; max: number })
typeof v === "number" && Number.isFinite(v) ? Math.round(Math.min(max, Math.max(min, v))) : d;
const AA: readonly Antialiasing[] = ["subpixel", "greyscale", "none"];
const CONCRETE: readonly ConcreteTheme[] = ["light", "dark", "dark-hc"];
const CONCRETE = BUILTIN_THEME_IDS;
const DISPLAYS: readonly NotificationDisplay[] = ["balloon", "sticky", "none"];
export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
+3 -2
View File
@@ -33,8 +33,9 @@ import {
type GeneralExtras,
type NotificationsValid,
} from "./behaviorSchema";
import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes";
export type ThemeId = "dark" | "light" | "system" | "dark-hc";
export type ThemeId = BuiltinThemeId | "system";
export type LocaleId = "en" | "zh";
/** A user keymap (Keymap › Duplicate…): its own overrides on top of a bundled preset. */
@@ -155,7 +156,7 @@ export const LIMITS = {
localHistoryMaxMb: { min: 16, max: 16384 },
} as const;
const THEMES: readonly ThemeId[] = ["dark", "light", "system", "dark-hc"];
const THEMES: readonly ThemeId[] = [...BUILTIN_THEME_IDS, "system"];
const LOCALES: readonly LocaleId[] = ["en", "zh"];
export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
+4 -6
View File
@@ -29,13 +29,11 @@ import type { PageSpec } from "./spec";
import styles from "./Behavior.module.css";
import { tx, useT } from "@/platform/i18n";
import { BUILTIN_THEMES } from "@/platform/themes";
export const APPEARANCE_GROUP = "Appearance & Behavior";
const THEMES: SelectOption<ConcreteTheme>[] = [
{ value: "light", label: "Islands Light" },
{ value: "dark", label: "Islands Dark" },
{ value: "dark-hc", label: "High Contrast" },
];
const THEMES: SelectOption<ConcreteTheme>[] = BUILTIN_THEMES.map((t) => ({ value: t.id, label: t.label }));
/** Checkbox bound to a key, optionally disabled. */
function BoundToggle({ k, label, hint, disabled }: { k: string; label: string; hint?: ReactNode; disabled?: boolean }) {
@@ -500,7 +498,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
{
kind: "custom",
label: "Theme",
keywords: ["Sync with OS", "Islands Light", "Islands Dark", "High Contrast", "preferred theme"],
keywords: ["Sync with OS", ...BUILTIN_THEMES.map((t) => t.label), "preferred theme"],
component: ThemeRow,
},
{
+2 -2
View File
@@ -195,10 +195,10 @@ export function activate(): () => void {
terminalRuntime()?.applyThemeToAll();
terminalRuntime()?.applyThemeToEmbedded();
});
// A plugin theme switch keeps data-theme when both themes share a light / dark base.
// A palette or plugin theme switch keeps data-theme when both themes share a light / dark base.
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme", "style"],
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
});
disposers.push(() => observer.disconnect());
}
+1 -1
View File
@@ -216,7 +216,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
});
const collapseUnchanged = opts.collapse ? { margin: 3, minSize: 6 } : undefined;
const override = whitespaceDiff(opts.ignore);
const diffConfig = override ? { override } : undefined;
const diffConfig = { override };
const highlightChanges = opts.highlight === "words";
const inclusion = includable ? inclusionGutter(path, textA) : [];
const side = (att: DocumentAttachment | null, editable: boolean, notes?: "left" | "right"): Extension[] => [
+146 -3
View File
@@ -2,15 +2,22 @@ import { describe, expect, it } from "vitest";
import { Chunk, MergeView } from "@codemirror/merge";
import { Text } from "@codemirror/state";
import { applyChunk, arrowControls } from "../diffControls";
import { DEFAULT_DIFF_OPTIONS, parseDiffOptions, transferChunk, whitespaceDiff, type IgnorePolicy } from "../diffOptions";
import {
DEFAULT_DIFF_OPTIONS,
parseDiffOptions,
transferChunk,
whitespaceDiff,
type IgnorePolicy,
} from "../diffOptions";
import { clipboardDiffData, compareFilesData, parseTextsArg } from "../compare";
import { diffInput, diffTitle, sideFile, sideKey } from "../diffInput";
import { viewerBase } from "../cm";
const doc = (s: string) => Text.of(s.split("\n"));
function chunks(a: string, b: string, ignore: IgnorePolicy) {
const override = whitespaceDiff(ignore);
return Chunk.build(doc(a), doc(b), override ? { override } : undefined);
return Chunk.build(doc(a), doc(b), { override });
}
describe("diff options", () => {
@@ -45,6 +52,140 @@ describe("diff options", () => {
expect(c!.changes.every((ch) => ch.toB - ch.fromB < 20)).toBe(true);
});
it("keeps distant insertions separate in a long file", () => {
const lines = Array.from({ length: 1200 }, (_, i) => `line_${i}: ${"content ".repeat(8)}${i}`);
const changed = [...lines];
for (let i = 1000; i >= 100; i -= 100) changed.splice(i, 0, `insert_${i} ${"x".repeat(90)}`);
const a = lines.join("\n");
const b = changed.join("\n");
const result = chunks(a, b, "none");
expect(result).toHaveLength(10);
expect(result.every((c) => c.fromA === c.toA && c.toB - c.fromB < 120)).toBe(true);
let restored = doc(b);
for (const c of [...result].reverse()) {
const edit = transferChunk(c, "a", doc(a), restored);
restored = restored.replace(edit.from, edit.to, Text.of(edit.insert.split("\n")));
}
expect(restored.toString()).toBe(a);
});
it("keeps distant edits separate in a very long file", () => {
const lines = Array.from({ length: 50000 }, (_, i) => `const v${i} = ${i * 7};`);
const changed = [...lines];
for (let i = 49000; i > 0; i -= 2500) changed.splice(i, 1, `inserted ${i}`, `${changed[i]} // edited`);
const result = chunks(lines.join("\n"), changed.join("\n"), "none");
expect(result).toHaveLength(20);
expect(result.every((c) => c.toB - c.fromB < 60)).toBe(true);
});
it("matches unchanged lines between many small edits", () => {
const lines = Array.from({ length: 4000 }, (_, i) => `line ${i}`);
const a = lines.join("\n");
const b = lines.map((l, i) => (i % 2 ? `${l} changed` : l)).join("\n");
expect(chunks(a, b, "none")).toHaveLength(2000);
expect(chunks(a, b, "whitespace")).toHaveLength(2000);
});
it("handles empty documents", () => {
expect(chunks("", "", "none")).toHaveLength(0);
const [ins] = chunks("", "one\ntwo\n", "none");
expect(ins).toMatchObject({ fromA: 0, toA: 0, fromB: 0 });
const [del] = chunks("one\ntwo\n", "", "whitespace");
expect(del).toMatchObject({ fromA: 0, fromB: 0, toB: 0 });
});
it("detects a missing final newline", () => {
for (const [a, b] of [
["same\n", "same"],
["same", "same\n"],
]) {
const [chunk] = chunks(a!, b!, "none");
expect(chunk).toBeDefined();
const edit = transferChunk(chunk!, "a", doc(a!), doc(b!));
expect(
doc(b!)
.replace(edit.from, edit.to, Text.of(edit.insert.split("\n")))
.toString(),
).toBe(a);
}
});
it("decorates consecutive inserted and deleted lines as whole lines", () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const mv = new MergeView({
a: { doc: "before\nremoved one\nremoved two\nafter\n", extensions: viewerBase({ readOnly: true }) },
b: { doc: "before\nafter\n", extensions: viewerBase({ readOnly: true }) },
parent,
diffConfig: { override: whitespaceDiff("none") },
});
expect(mv.a.dom.querySelectorAll(".cm-diffWholeLine")).toHaveLength(2);
expect(mv.b.dom.querySelectorAll(".cm-diffWholeLine")).toHaveLength(0);
mv.destroy();
parent.remove();
});
it("keeps word highlighting on paired lines of a multi-line replacement", () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const mv = new MergeView({
a: {
doc: "before\nfrom old.first import X\nfrom old.second import Y\nafter\n",
extensions: viewerBase({ readOnly: true }),
},
b: {
doc: "before\nfrom new.first import A\nfrom new.second import B\nadded line\nafter\n",
extensions: viewerBase({ readOnly: true }),
},
parent,
diffConfig: { override: whitespaceDiff("none") },
});
expect(mv.a.dom.querySelectorAll(".cm-changedLine")).toHaveLength(2);
expect(mv.a.dom.querySelector(".cm-diffWholeLine")).toBeNull();
expect([...mv.a.dom.querySelectorAll(".cm-changedText")].map((e) => e.textContent)).toEqual([
"old",
"X",
"old",
"Y",
]);
expect(mv.b.dom.querySelectorAll(".cm-changedLine")).toHaveLength(3);
expect([...mv.b.dom.querySelectorAll(".cm-diffWholeLine")].map((e) => e.textContent)).toEqual(["added line"]);
expect(mv.b.dom.querySelectorAll(".cm-changedLine:not(.cm-diffWholeLine) .cm-changedText")).toHaveLength(4);
mv.destroy();
parent.remove();
});
it("marks a deleted last line as a whole line", () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const mv = new MergeView({
a: { doc: "keep\ngone", extensions: viewerBase({ readOnly: true }) },
b: { doc: "keep", extensions: viewerBase({ readOnly: true }) },
parent,
diffConfig: { override: whitespaceDiff("none") },
});
expect([...mv.a.dom.querySelectorAll(".cm-diffWholeLine")].map((e) => e.textContent)).toEqual(["gone"]);
mv.destroy();
parent.remove();
});
it("keeps word highlighting for a single-line replacement", () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const mv = new MergeView({
a: { doc: "before\nvalue = old\nafter\n", extensions: viewerBase({ readOnly: true }) },
b: { doc: "before\nvalue = new\nafter\n", extensions: viewerBase({ readOnly: true }) },
parent,
diffConfig: { override: whitespaceDiff("none") },
});
expect(mv.a.dom.querySelector(".cm-diffWholeLine")).toBeNull();
expect(mv.b.dom.querySelector(".cm-diffWholeLine")).toBeNull();
expect(mv.a.dom.querySelector(".cm-changedText")).not.toBeNull();
expect(mv.b.dom.querySelector(".cm-changedText")).not.toBeNull();
mv.destroy();
parent.remove();
});
it("transfers a chunk between sides (>> / << / Revert)", () => {
const a = doc("x\nold\ny\n");
const b = doc("x\nnew\nnewer\ny\n");
@@ -105,7 +246,9 @@ describe("compare entry points", () => {
const k1 = sideKey({ type: "text", text: "a", label: "Original" });
const k2 = sideKey({ type: "text", text: "b", label: "Original" });
expect(k1).not.toBe(k2);
expect(parseTextsArg({ path: "/p/a", left: { text: "1", label: "L" }, right: { text: "2", label: "R" } })).toMatchObject({
expect(
parseTextsArg({ path: "/p/a", left: { text: "1", label: "L" }, right: { text: "2", label: "R" } }),
).toMatchObject({
path: "/p/a",
});
expect(parseTextsArg({ path: "/p/a", left: { text: 1 } })).toBeNull();
+78 -4
View File
@@ -3,14 +3,79 @@
* based theme + syntax colours and lazy language loading by file name.
*/
import { HighlightStyle, LanguageDescription, syntaxHighlighting, type LanguageSupport } from "@codemirror/language";
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView, drawSelection, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view";
import { EditorState, RangeSetBuilder, type Extension, type Line } from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
drawSelection,
highlightActiveLine,
keymap,
lineNumbers,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { getChunks } from "@codemirror/merge";
import { defaultKeymap } from "@codemirror/commands";
import { search, searchKeymap } from "@codemirror/search";
import { tags as t } from "@lezer/highlight";
const wholeLine = Decoration.line({ class: "cm-diffWholeLine" });
/**
* Marks the changed lines that have no counterpart on the other side (inserted
* or deleted as a whole) so they get a solid background. Lines paired with a
* line on the other side keep the line tint plus word-level highlights.
*/
function wholeLineDeco(view: EditorView): DecorationSet {
const info = getChunks(view.state);
if (!info || !info.side) return Decoration.none;
const isA = info.side === "a";
const { doc } = view.state;
const { from: vpFrom, to: vpTo } = view.viewport;
const builder = new RangeSetBuilder<Decoration>();
for (const chunk of info.chunks) {
const from = isA ? chunk.fromA : chunk.fromB;
const to = isA ? chunk.toA : chunk.toB;
if (from >= vpTo) break;
if (from === to || to < vpFrom) continue;
const otherEmpty = isA ? chunk.fromB === chunk.toB : chunk.fromA === chunk.toA;
const ranges = chunk.changes.map((c) => (isA ? [c.fromA, c.toA] : [c.fromB, c.toB]).map((p) => p + from));
// Covered including the line break after it, or the one before it (a deleted last line).
const covered = (line: Line) =>
ranges.some(([f = 0, t = 0]) => (f <= line.from && t > line.to) || (f < line.from && t >= line.to));
for (let line = doc.lineAt(Math.max(from, vpFrom)); line.from < Math.min(to, vpTo);) {
if (otherEmpty || covered(line)) builder.add(line.from, line.from, wholeLine);
if (line.number === doc.lines) break;
line = doc.line(line.number + 1);
}
}
return builder.finish();
}
const wholeChangedLines = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = wholeLineDeco(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
getChunks(update.startState)?.chunks !== getChunks(update.state)?.chunks
)
this.decorations = wholeLineDeco(update.view);
}
},
{ decorations: (value) => value.decorations },
);
export const viewerHighlight = HighlightStyle.define([
{ tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword, t.definitionKeyword], color: "var(--syn-keyword)" },
{
tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword, t.definitionKeyword],
color: "var(--syn-keyword)",
},
{ tag: [t.string, t.special(t.string), t.regexp, t.character], color: "var(--syn-string)" },
{ tag: [t.typeName, t.className, t.namespace, t.tagName], color: "var(--syn-type)" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--syn-function)" },
@@ -55,6 +120,15 @@ export const viewerTheme = EditorView.theme(
"&.cm-merge-b .cm-changedLine, .cm-inlineChangedLine": {
backgroundColor: "color-mix(in srgb, var(--gutter-added) 12%, transparent)",
},
"&.cm-merge-a .cm-changedLine.cm-diffWholeLine": {
backgroundColor: "color-mix(in srgb, var(--danger) 26%, transparent)",
},
"&.cm-merge-b .cm-changedLine.cm-diffWholeLine": {
backgroundColor: "color-mix(in srgb, var(--success) 26%, transparent)",
},
"&.cm-merge-a .cm-diffWholeLine .cm-changedText, &.cm-merge-b .cm-diffWholeLine .cm-changedText": {
background: "transparent",
},
"&.cm-merge-a .cm-changedText, .cm-deletedChunk .cm-deletedText": {
background: "color-mix(in srgb, var(--danger) 26%, transparent)",
},
@@ -64,7 +138,6 @@ export const viewerTheme = EditorView.theme(
"&.cm-merge-b .cm-changedText, .cm-insertedLine .cm-changedText": {
background: "color-mix(in srgb, var(--success) 26%, transparent)",
},
".cm-insertedLine": { backgroundColor: "color-mix(in srgb, var(--gutter-added) 12%, transparent)" },
".cm-changeGutter": { width: "3px", paddingLeft: "0" },
"&.cm-merge-a .cm-changedLineGutter, .cm-deletedLineGutter": { background: "var(--gutter-deleted)" },
"&.cm-merge-b .cm-changedLineGutter": { background: "var(--gutter-modified)" },
@@ -104,6 +177,7 @@ export function viewerBase(opts: { readOnly: boolean; lineNumbers?: boolean }):
keymap.of([...searchKeymap, ...defaultKeymap]),
syntaxHighlighting(viewerHighlight),
viewerTheme,
wholeChangedLines,
EditorState.readOnly.of(opts.readOnly),
EditorView.editable.of(!opts.readOnly),
];
+67 -11
View File
@@ -94,7 +94,7 @@ function splitLines(s: string, policy: IgnorePolicy): Line[] {
for (;;) {
const nl = s.indexOf("\n", from);
const end = nl < 0 ? s.length : nl;
const key = lineKey(s.slice(from, end), policy);
const key = lineKey(s.slice(from, end), policy) + (policy === "none" && nl >= 0 ? "\n" : "");
if (!(policy === "whitespaceAndLines" && key === "")) out.push({ from, to: nl < 0 ? s.length : nl + 1, key });
if (nl < 0) return out;
from = nl + 1;
@@ -119,13 +119,67 @@ function encoder() {
};
}
/** Bounds the expensive diff search; larger inputs fall back to a cheaper, coarser match. */
const DIFF_CONFIG = { scanLimit: 500 };
/**
* Diff that ignores whitespace per `policy`: lines are matched by their
* normalized text, then changed blocks get a precise character diff (for word
* highlighting). Returns null when `policy` is "none" (use the default diff).
* Diff of encoded lines (one char per line). Lines that occur exactly once on
* each side anchor the match (patience diff), and only the gaps between the
* anchors get a regular diff, so distant edits in long files stay separate
* without a quadratic search.
*/
export function whitespaceDiff(policy: IgnorePolicy): ((a: string, b: string) => readonly Change[]) | null {
if (policy === "none") return null;
function diffLines(a: string, b: string): Change[] {
const seen = new Map<string, { inA: number; inB: number; at: number }>();
for (let i = 0; i < a.length; i++) {
const e = seen.get(a[i]!);
if (e) e.inA++;
else seen.set(a[i]!, { inA: 1, inB: 0, at: i });
}
for (const c of b) {
const e = seen.get(c);
if (e) e.inB++;
}
// Unique common lines as [indexA, indexB] in A order, then their longest increasing run in B.
const pairs: [number, number][] = [];
for (let j = 0; j < b.length; j++) {
const e = seen.get(b[j]!);
if (e && e.inA === 1 && e.inB === 1) pairs.push([e.at, j]);
}
pairs.sort((x, y) => x[0] - y[0]);
const tails: number[] = [];
const prev = new Array<number>(pairs.length);
for (let i = 0; i < pairs.length; i++) {
let lo = 0;
let hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (pairs[tails[mid]!]![1] < pairs[i]![1]) lo = mid + 1;
else hi = mid;
}
prev[i] = lo > 0 ? tails[lo - 1]! : -1;
tails[lo] = i;
}
const anchors: [number, number][] = [];
for (let i = tails.length ? tails[tails.length - 1]! : -1; i >= 0; i = prev[i]!) anchors.push(pairs[i]!);
anchors.reverse().push([a.length, b.length]);
const out: Change[] = [];
let fromA = 0;
let fromB = 0;
for (const [toA, toB] of anchors) {
if (toA > fromA || toB > fromB)
for (const c of diff(a.slice(fromA, toA), b.slice(fromB, toB), DIFF_CONFIG))
out.push(new Change(c.fromA + fromA, c.toA + fromA, c.fromB + fromB, c.toB + fromB));
fromA = toA + 1;
fromB = toB + 1;
}
return out;
}
/**
* Match lines first, then compare the contents of changed blocks for word
* highlighting. The line keys also implement the whitespace policy.
*/
export function whitespaceDiff(policy: IgnorePolicy): (a: string, b: string) => readonly Change[] {
return (a, b) => {
const la = splitLines(a, policy);
const lb = splitLines(b, policy);
@@ -134,17 +188,17 @@ export function whitespaceDiff(policy: IgnorePolicy): ((a: string, b: string) =>
const eb: string[] = [];
for (const l of la) {
const c = enc(l.key);
if (c === null) return diff(a, b);
if (c === null) return diff(a, b, DIFF_CONFIG);
ea.push(c);
}
for (const l of lb) {
const c = enc(l.key);
if (c === null) return diff(a, b);
if (c === null) return diff(a, b, DIFF_CONFIG);
eb.push(c);
}
const out: Change[] = [];
const at = (lines: Line[], i: number) => (i > 0 ? lines[i - 1]!.to : 0);
for (const c of diff(ea.join(""), eb.join(""))) {
for (const c of diffLines(ea.join(""), eb.join(""))) {
const fromA = c.toA > c.fromA ? la[c.fromA]!.from : at(la, c.fromA);
const toA = c.toA > c.fromA ? la[c.toA - 1]!.to : fromA;
const fromB = c.toB > c.fromB ? lb[c.fromB]!.from : at(lb, c.fromB);
@@ -153,7 +207,7 @@ export function whitespaceDiff(policy: IgnorePolicy): ((a: string, b: string) =>
out.push(new Change(fromA, toA, fromB, toB));
continue;
}
for (const inner of diff(a.slice(fromA, toA), b.slice(fromB, toB)))
for (const inner of diff(a.slice(fromA, toA), b.slice(fromB, toB), DIFF_CONFIG))
out.push(new Change(inner.fromA + fromA, inner.toA + fromA, inner.fromB + fromB, inner.toB + fromB));
}
return out;
@@ -166,7 +220,9 @@ export function whitespaceDiff(policy: IgnorePolicy): ((a: string, b: string) =>
*/
export function transferChunk(chunk: Chunk, source: "a" | "b", src: Text, dst: Text) {
const [srcFrom, srcTo, dstFrom, dstTo] =
source === "a" ? [chunk.fromA, chunk.toA, chunk.fromB, chunk.toB] : [chunk.fromB, chunk.toB, chunk.fromA, chunk.toA];
source === "a"
? [chunk.fromA, chunk.toA, chunk.fromB, chunk.toB]
: [chunk.fromB, chunk.toB, chunk.fromA, chunk.toA];
let insert = src.sliceString(srcFrom, Math.max(srcFrom, srcTo - 1));
if (srcFrom !== srcTo && dstTo <= dst.length) insert += "\n";
return { from: dstFrom, to: Math.min(dst.length, dstTo), insert };
+24 -2
View File
@@ -108,16 +108,38 @@ describe("mermaid in the markdown preview", () => {
expect(ids.size).toBe(2);
});
it("re-renders when switching between built-in palette themes of the same light / dark base", async () => {
const root = document.documentElement;
const previous = root.dataset.colorTheme;
const changed = vi.fn();
const off = subscribeTheme(changed);
try {
root.dataset.colorTheme = "one-dark";
const firstKey = themeKey();
await renderMermaidIn(preview(SEQ)).done;
root.dataset.colorTheme = "solarized-dark";
await Promise.resolve();
expect(changed).toHaveBeenCalled();
expect(themeKey()).not.toBe(firstKey);
await renderMermaidIn(preview(SEQ)).done;
expect(m.render).toHaveBeenCalledTimes(2);
} finally {
off();
if (previous === undefined) delete root.dataset.colorTheme;
else root.dataset.colorTheme = previous;
}
});
it("re-renders when switching between plugin themes of the same light / dark base", async () => {
const root = document.documentElement;
const changed = vi.fn();
const off = subscribeTheme(changed);
try {
root.dataset.theme = "dark";
root.dataset.pluginTheme = "theme-one/one-dark";
root.dataset.pluginTheme = "acme-themes/night";
const firstKey = themeKey();
await renderMermaidIn(preview(SEQ)).done;
root.dataset.pluginTheme = "theme-solarized/solarized-dark";
root.dataset.pluginTheme = "acme-themes/dusk";
await Promise.resolve();
expect(changed).toHaveBeenCalled();
expect(themeKey()).not.toBe(firstKey);
+3 -3
View File
@@ -30,8 +30,8 @@ function loadMermaid(): Promise<Mermaid> {
/** Theme identity: anything that changes the colours read below. */
export function themeKey(): string {
const root = document.documentElement;
const { theme = "", contrast = "", pluginTheme = "" } = root.dataset;
return `${theme}|${contrast}|${pluginTheme}|${root.style.getPropertyValue("--accent")}`;
const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset;
return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${root.style.getPropertyValue("--accent")}`;
}
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
@@ -40,7 +40,7 @@ export function subscribeTheme(cb: () => void): () => void {
const observer = new MutationObserver(cb);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-plugin-theme", "style"],
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
});
return () => observer.disconnect();
}
+4 -7
View File
@@ -13,14 +13,11 @@ import { notifications } from "@/workbench/contributions";
import { ACCENT_PRESETS, DEFAULT_ACCENT } from "@/workbench/theme";
import { reopenEnabled, setReopenPreference, useSession } from "./session";
import styles from "./Welcome.module.css";
import { BUILTIN_THEMES, isBuiltinTheme, type BuiltinThemeId } from "@/platform/themes";
type Theme = "light" | "dark" | "dark-hc";
type Theme = BuiltinThemeId;
export const THEME_OPTIONS: SelectOption<Theme>[] = [
{ value: "light", label: "Islands Light" },
{ value: "dark", label: "Islands Dark" },
{ value: "dark-hc", label: "High Contrast" },
];
export const THEME_OPTIONS: SelectOption<Theme>[] = BUILTIN_THEMES.map((t) => ({ value: t.id, label: t.label }));
const DEFAULT_UI_FONT_SIZE = 13;
const DEFAULT_UI_FONT = "Inter";
@@ -52,7 +49,7 @@ interface AppearanceView {
/** The values Customize shows, from (possibly partial / legacy) settings. */
export function customizeView(settings: ReturnType<typeof useProject.getState>["settings"]): AppearanceView {
const a = (settings?.appearance ?? {}) as Record<string, unknown>;
const theme = a.theme === "dark" || a.theme === "dark-hc" ? a.theme : "light";
const theme = isBuiltinTheme(a.theme) ? a.theme : "light";
const custom = a.useCustomFont === true;
const size = typeof a.uiFontSize === "number" ? a.uiFontSize : DEFAULT_UI_FONT_SIZE;
return {
+7 -8
View File
@@ -9,14 +9,11 @@
*/
import tokensCss from "@/styles/tokens.css?raw";
import type { TextAttributesOverride } from "./editorSettings";
import { BUILTIN_THEMES, isBuiltinTheme, themeKind, type BuiltinThemeId } from "./themes";
export type SchemeId = "light" | "dark" | "dark-hc";
export type SchemeId = BuiltinThemeId;
export const SCHEMES: ReadonlyArray<{ id: SchemeId; name: string }> = [
{ id: "light", name: "Islands Light" },
{ id: "dark", name: "Islands Dark" },
{ id: "dark-hc", name: "High Contrast" },
];
export const SCHEMES: ReadonlyArray<{ id: SchemeId; name: string }> = BUILTIN_THEMES.map((t) => ({ id: t.id, name: t.label }));
export interface AttributeDef {
id: string;
@@ -167,7 +164,8 @@ function blocks(css: string): Array<{ selectors: string[]; decls: Vars }> {
function themeSelectors(id: SchemeId): string[] {
if (id === "light") return [':root[data-theme="light"]'];
if (id === "dark-hc") return [':root[data-theme="dark-hc"]', ':root[data-theme="dark"][data-contrast="high"]'];
return [':root[data-theme="dark"]'];
if (id === "dark") return [':root[data-theme="dark"]'];
return [`:root[data-theme="${themeKind(id)}"]`, ":root[data-color-theme]", `:root[data-color-theme="${id}"]`];
}
let source: string = tokensCss;
@@ -205,13 +203,14 @@ export function schemeTokens(id: SchemeId, css: string = source): Vars {
/** The scheme the editor shows for `setting` under the current theme. */
export function effectiveScheme(setting: string | undefined, root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null): SchemeId {
if (setting === "light" || setting === "dark" || setting === "dark-hc") return setting;
if (isBuiltinTheme(setting)) return setting;
return themeScheme(root);
}
/** Scheme belonging to the active IDE theme (`data-theme` / `data-contrast`). */
export function themeScheme(root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null): SchemeId {
if (!root) return "light";
if (isBuiltinTheme(root.dataset.colorTheme)) return root.dataset.colorTheme;
if (root.dataset.theme === "light") return "light";
return root.dataset.contrast === "high" || root.dataset.theme === "dark-hc" ? "dark-hc" : "dark";
}
+37 -1
View File
@@ -633,7 +633,6 @@
"Environment": "环境",
"Environment variables": "环境变量",
"Error Highlighting": "错误高亮",
"Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette.": "Solarized Light 和 Solarized Dark:Ethan Schoonover 的 Solarized 调色板。",
"Evaluate": "计算",
"Evaluate Expression…": "计算表达式…",
"Evaluate expression": "计算表达式",
@@ -3643,6 +3642,43 @@
"Process comments": "处理注释",
"Distraction Free Mode": "无干扰模式",
"Exit Distraction Free Mode": "退出无干扰模式",
"Show Hidden Files": "显示隐藏文件",
"Show Excluded Files": "显示已排除的文件",
"Compact Middle Packages": "压缩中间软件包",
"Sort Folders First": "文件夹排在前面",
"Open Files with Single Click": "单击打开文件",
"Expand Directories with Single Click": "单击展开目录",
"Enable Preview Tab": "启用预览标签页",
"Analyze Dependencies…": "分析依赖…",
"Shows a Maven module's dependency tree, version conflicts and where each library comes from": "显示 Maven 模块的依赖树、版本冲突以及每个库的引入路径",
"Dependency Analyzer": "依赖分析器",
"Search dependencies": "搜索依赖",
"Show conflicts only": "仅显示冲突",
"All scopes": "所有范围",
"Show as List": "以列表显示",
"Show as Tree": "以树形显示",
"Dependency trees may be out of date": "依赖树可能已过期",
"Dependency trees are read when Maven projects are reloaded": "重新加载 Maven 项目时会读取依赖树",
"No Maven modules": "没有 Maven 模块",
"Dependencies": "依赖",
"No dependencies match the filter": "没有符合筛选条件的依赖",
"No dependencies": "没有依赖",
"Select a dependency to see its usages": "选择一个依赖以查看其用法",
"Versions requested": "请求的版本",
"omitted": "已省略",
"resolved": "已解析",
"omitted for conflict with {version}": "因与 {version} 冲突而省略",
"omitted for conflict": "因冲突而省略",
"omitted for duplicate": "因重复而省略",
"omitted for cycle": "因循环依赖而省略",
"version managed from {version}": "版本由 {version} 经依赖管理调整",
"Cannot find the declaration": "找不到声明",
"Cannot exclude {name}": "无法排除 {name}",
"Solarized Light": "Solarized 浅色",
"Solarized Dark": "Solarized 深色",
"One Light": "One 浅色",
"One Dark": "One 深色",
"Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette.": "Solarized Light 和 Solarized Dark:Ethan Schoonover 的 Solarized 调色板。",
"Copied reference: {reference}": "已复制引用:{reference}",
"{count} lines": "{count} 行",
"Preview of {path}": "{path} 的预览",
+76
View File
@@ -0,0 +1,76 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { BUILTIN_THEMES, isBuiltinTheme, themeKind } from "./themes";
import { _setSchemeSource, effectiveScheme, schemeCss, schemeTokens, themeScheme } from "./colorScheme";
import { applyThemeAttributes, resolveTheme, resolveThemeId } from "@/workbench/theme";
import { validateSettings } from "@/features/settings/model/schema";
import { applyPluginTheme } from "@/features/plugins/bridge";
const css = readFileSync("src/styles/tokens.css", "utf8");
_setSchemeSource(css);
afterEach(() => {
applyPluginTheme(null);
delete document.documentElement.dataset.colorTheme;
delete document.documentElement.dataset.contrast;
});
describe("built-in palettes", () => {
it.each(BUILTIN_THEMES)("preserves $id through validation and system resolution", ({ id, kind }) => {
const a = { theme: id, preferredLightTheme: id, preferredDarkTheme: id };
expect(validateSettings({ appearance: a }).appearance).toMatchObject(a);
expect(resolveThemeId(a, false)).toBe(id);
expect(resolveTheme(id, false)).toBe(kind);
expect(resolveThemeId({ ...a, syncWithOs: true }, true)).toBe(id);
expect(resolveThemeId({ ...a, theme: "system" }, false)).toBe(id);
applyThemeAttributes(id);
expect(document.documentElement.dataset.theme).toBe(kind);
expect(themeScheme()).toBe(id);
expect(effectiveScheme(id)).toBe(id);
expect(schemeCss("theme-default", undefined)).toBe("");
expect(schemeTokens(id).get("--bg-editor")).not.toContain("var(");
});
it("rejects unknown ids and retains legacy fallbacks", () => {
expect(isBuiltinTheme("not-a-theme")).toBe(false);
expect(resolveThemeId({ theme: "bad" }, true)).toBe("light");
expect(resolveThemeId({ theme: "system", preferredDarkTheme: "bad" }, false)).toBe("dark");
expect(themeKind("solarized-light")).toBe("light");
});
// Token coverage of every palette theme: settings/__tests__/contrast.test.ts.
it("resolves editor scheme tokens from the palettes", () => {
expect(schemeTokens("solarized-light").get("--bg-editor")).toBe("#fdf6e3");
expect(schemeTokens("solarized-dark").get("--bg-editor")).toBe("#002b36");
expect(schemeTokens("solarized-dark").get("--syn-string")).toBe("#2aa198");
expect(schemeTokens("solarized-light").get("--ansi-bright-blue")).toBe("#839496");
expect(schemeTokens("one-light").get("--syn-keyword")).toBe("hsl(301, 63%, 40%)");
expect(schemeTokens("one-dark").get("--syn-string")).toBe("hsl(95, 38%, 62%)");
});
it("keeps explicit editor schemes and per-scheme edits independent of UI palettes", () => {
applyThemeAttributes("solarized-light");
expect(schemeCss("one-dark", undefined)).toContain("--bg-editor: hsl(220, 13%, 18%) !important");
expect(schemeCss("theme-default", { "solarized-light": { keyword: { foreground: "#123456" } } })).toContain(
"--syn-keyword: #123456 !important",
);
applyThemeAttributes("one-light");
expect(schemeCss("theme-default", { "solarized-light": { keyword: { foreground: "#123456" } } })).toBe("");
});
it("clears the palette for plugins and restores it when the plugin is removed", () => {
const root = document.documentElement;
applyThemeAttributes("one-dark", root);
root.dataset.pluginThemeKind = "light";
applyThemeAttributes("one-dark", root);
expect(root.dataset.colorTheme).toBeUndefined();
expect(root.dataset.theme).toBe("light");
delete root.dataset.pluginThemeKind;
applyThemeAttributes("one-dark", root);
expect(root.dataset.colorTheme).toBe("one-dark");
applyThemeAttributes("dark-hc", root);
expect(root.dataset.colorTheme).toBeUndefined();
expect(root.dataset.contrast).toBe("high");
applyThemeAttributes("solarized-dark", root);
expect(root.dataset.contrast).toBeUndefined();
});
});
+19
View File
@@ -0,0 +1,19 @@
/** Stable built-in ids shared by settings, quick switch and editor schemes. */
export const BUILTIN_THEMES = [
{ id: "light", label: "Islands Light", kind: "light" },
{ id: "dark", label: "Islands Dark", kind: "dark" },
{ id: "dark-hc", label: "High Contrast", kind: "dark" },
{ id: "solarized-light", label: "Solarized Light", kind: "light" },
{ id: "solarized-dark", label: "Solarized Dark", kind: "dark" },
{ id: "one-light", label: "One Light", kind: "light" },
{ id: "one-dark", label: "One Dark", kind: "dark" },
] as const;
export type BuiltinThemeId = (typeof BUILTIN_THEMES)[number]["id"];
export const BUILTIN_THEME_IDS: readonly BuiltinThemeId[] = BUILTIN_THEMES.map((t) => t.id);
export function isBuiltinTheme(value: unknown): value is BuiltinThemeId {
return typeof value === "string" && (BUILTIN_THEME_IDS as readonly string[]).includes(value);
}
export function themeKind(id: BuiltinThemeId): "light" | "dark" {
return BUILTIN_THEMES.find((t) => t.id === id)!.kind;
}
+396
View File
@@ -824,6 +824,402 @@ html {
--bg-progress-track: #55585f;
}
/* Solarized and Atom One adaptations. Exact upstream palettes and MIT notices:
* docs/themes.md and THIRD_PARTY_LICENSES.md. The shared mapping covers every
* colour token; only palette roles below differ. UI-only states use explicit
* mixes of original colours where the source has no equivalent component.
* Keep before appearance options so accessibility preferences still win.
*/
:root[data-color-theme] {
--bg-app: var(--palette-bg);
--bg-island: var(--palette-bg);
--bg-input: var(--palette-bg);
--bg-field: var(--palette-bg);
--bg-checkbox: var(--palette-bg);
--bg-control: var(--palette-bg);
--bg-button: var(--palette-bg);
--bg-dialog: var(--palette-bg);
--bg-frame: var(--palette-panel);
--bg-elev: var(--palette-panel);
--bg-backdrop: var(--palette-panel);
--bg-popup-inline: var(--palette-panel);
--bg-popup-advertiser: var(--palette-panel);
--bg-tab-selected-inactive: var(--palette-panel);
--bg-toggle-off: var(--palette-panel);
--bg-editor-inline: var(--palette-panel);
--bg-inlay: var(--palette-panel);
--bg-fold-placeholder: var(--palette-panel);
--bg-popup: var(--palette-popup);
--bg-tooltip: var(--palette-popup);
--bg-hover: var(--palette-hover);
--bg-control-hover: var(--palette-hover);
--bg-active: var(--palette-hover);
--bg-tree-selected-inactive: var(--palette-hover);
--bg-progress-track: var(--palette-hover);
--bg-tab-hover: var(--palette-hover);
--bg-tree-hover: var(--palette-hover);
--bg-toolbar-hover: var(--palette-hover);
--bg-stripe-hover: var(--palette-hover);
--bg-selected: var(--palette-selection);
--bg-selected-strong: var(--palette-selection);
--bg-tree-selected: var(--palette-selection);
--bg-tab-selected: var(--palette-selection);
--bg-toolbar-selected: var(--palette-selection);
--bg-toolbar-selected-hover: var(--palette-selection);
--bg-toolbar-pressed: var(--palette-selection);
--bg-stripe-selected: var(--palette-selection);
--bg-editor-selection: var(--palette-selection);
--bg-editor-selection-inactive: var(--palette-selection);
--bg-matched-brace: var(--palette-selection);
--bg-matched-tag: var(--palette-selection);
--bg-identifier-under-caret: var(--palette-selection);
--bg-current-line: var(--palette-line);
--border: var(--palette-border);
--border-gutter: var(--palette-border);
--border-indent-guide: var(--palette-border);
--border-right-margin: var(--palette-border);
--border-editor-tabs: var(--palette-border);
--border-tab-selected-inactive: var(--palette-border);
--border-button: var(--palette-border);
--border-window: var(--palette-border);
--border-popup: var(--palette-border);
--border-tooltip: var(--palette-border);
--border-strong: var(--palette-muted);
--control-off: var(--palette-muted);
--border-checkbox: var(--palette-muted);
--border-toggle: var(--palette-muted);
--bg-toggle-knob: var(--palette-muted);
--fg: var(--palette-fg);
--fg-emphasis: var(--palette-fg);
--fg-strong: var(--palette-fg);
--fg-icon: var(--palette-fg);
--fg-tooltip: var(--palette-fg);
--fg-editor: var(--palette-fg);
--fg-fold-placeholder: var(--palette-fg);
--fg-line-number-current: var(--palette-fg);
--fg-stripe-label: var(--palette-fg);
--fg-toggle-off: var(--palette-fg);
--fg-secondary: var(--palette-muted);
--fg-muted: var(--palette-muted);
--fg-subtle: var(--palette-muted);
--fg-faint: var(--palette-muted);
--fg-gutter: var(--palette-muted);
--fg-line-number: var(--palette-muted);
--fg-fold-marker: var(--palette-muted);
--fg-whitespace: var(--palette-muted);
--fg-unused: var(--palette-muted);
--fg-inlay: var(--palette-muted);
--fg-weak-warning: var(--palette-muted);
--syn-inline-value: var(--palette-muted);
--accent: var(--palette-accent);
--caret: var(--palette-accent);
--fg-on-accent: var(--palette-on-accent);
--fg-button-primary: var(--palette-on-accent);
--fg-stripe-selected-active: var(--palette-on-accent);
--fg-toggle-on: var(--palette-on-accent);
--fg-counter: var(--palette-on-accent);
--link: var(--palette-blue);
--fg-completion-match: var(--palette-blue);
--info: var(--palette-blue);
--vcs-modified: var(--palette-blue);
--gutter-modified: var(--palette-blue);
--syn-link: var(--palette-blue);
--link-hover: var(--palette-cyan);
--danger: var(--palette-red);
--vcs-untracked: var(--palette-red);
--vcs-conflict: var(--palette-red);
--gutter-deleted: var(--palette-red);
--wave-error: var(--palette-red);
--syn-invalid: var(--palette-red);
--chart-series: var(--palette-red);
--warning: var(--palette-yellow);
--wave-warning: var(--palette-yellow);
--fg-bulb: var(--palette-yellow);
--vcs-ignored: var(--palette-yellow);
--success: var(--palette-green);
--vcs-added: var(--palette-green);
--gutter-added: var(--palette-green);
--db-connected: var(--palette-green);
--bg-toggle-on: var(--palette-green);
--db-connected-ring: var(--palette-green);
--vcs-deleted: var(--palette-muted);
--bg-search-match: color-mix(in srgb, var(--palette-yellow) 22%, var(--palette-bg));
--bg-text-search-result: color-mix(in srgb, var(--palette-yellow) 22%, var(--palette-bg));
--bg-search-match-current: color-mix(in srgb, var(--palette-yellow) 36%, var(--palette-bg));
--bg-text-search-result-current: color-mix(in srgb, var(--palette-yellow) 36%, var(--palette-bg));
--fg-search-match: var(--palette-fg);
--border-tab-selected: var(--palette-accent);
--border-indent-guide-active: var(--palette-accent);
--focus-ring-soft: var(--palette-accent);
--bg-counter: var(--palette-accent);
--bg-button-primary-hover: color-mix(in srgb, var(--accent) 86%, var(--palette-fg));
--scrollbar-thumb: color-mix(in srgb, var(--palette-fg) 24%, transparent);
--scrollbar-thumb-hover: color-mix(in srgb, var(--palette-fg) 42%, transparent);
--bg-scrim: color-mix(in srgb, var(--palette-bg) 68%, transparent);
--bg-debug-line: color-mix(in srgb, var(--palette-blue) 12%, var(--palette-bg));
--bg-editor-banner: color-mix(in srgb, var(--palette-blue) 12%, var(--palette-bg));
--bg-feedback-info: color-mix(in srgb, var(--palette-blue) 12%, var(--palette-bg));
--bg-breakpoint-line: color-mix(in srgb, var(--palette-red) 12%, var(--palette-bg));
--bg-identifier-under-caret-write: color-mix(in srgb, var(--palette-red) 12%, var(--palette-bg));
--bg-feedback-error: color-mix(in srgb, var(--palette-red) 12%, var(--palette-bg));
--bg-feedback-warning: color-mix(in srgb, var(--palette-yellow) 12%, var(--palette-bg));
--editor-warning-background: color-mix(in srgb, var(--palette-yellow) 12%, var(--palette-bg));
--bg-feedback-success: color-mix(in srgb, var(--palette-green) 12%, var(--palette-bg));
--border-feedback-info: color-mix(in srgb, var(--palette-blue) 55%, var(--palette-bg));
--border-feedback-error: color-mix(in srgb, var(--palette-red) 55%, var(--palette-bg));
--border-feedback-warning: color-mix(in srgb, var(--palette-yellow) 55%, var(--palette-bg));
--border-feedback-success: color-mix(in srgb, var(--palette-green) 55%, var(--palette-bg));
--editor-error-decoration: underline wavy var(--wave-error);
--editor-warning-decoration: underline wavy var(--wave-warning);
--matched-brace-weight: var(--fw-code-bold);
--shadow-popup: 0 4px 16px #00000024;
--shadow-tooltip: 0 4px 16px #00000024;
--shadow-dialog: 0 10px 36px #00000033;
--shadow-balloon: 0 10px 36px #00000033;
--traffic-off: var(--palette-hover);
--traffic-off-border: var(--palette-border);
--traffic-close: #ff5f57;
--traffic-close-border: #e0443e;
--traffic-close-pressed: #bf4942;
--traffic-glyph: #4d0000;
--syn-keyword: var(--palette-keyword);
--syn-string: var(--palette-green);
--syn-type: var(--palette-type);
--syn-function: var(--palette-blue);
--syn-function-call: var(--palette-blue);
--syn-comment: var(--palette-comment);
--syn-doc-comment: var(--palette-comment);
--syn-docstring: var(--palette-green);
--syn-annotation: var(--palette-orange);
--syn-decorator: var(--palette-orange);
--syn-number: var(--palette-number);
--syn-punct: var(--palette-fg);
--syn-variable: var(--palette-variable);
--syn-param: var(--palette-fg);
--syn-property: var(--palette-property);
--syn-constant: var(--palette-number);
--syn-builtin: var(--palette-cyan);
--syn-self: var(--palette-red);
--syn-keyword-arg: var(--palette-red);
--syn-predefined: var(--palette-cyan);
--syn-escape: var(--palette-cyan);
--syn-regexp: var(--palette-cyan);
--syn-tag: var(--palette-red);
--syn-attribute: var(--palette-orange);
--syn-todo: var(--palette-magenta);
--syn-markup-heading: var(--palette-blue);
--syn-markup-code: var(--palette-green);
--syn-comment-style: italic;
--syn-rainbow-1: var(--palette-cyan);
--syn-rainbow-2: var(--palette-orange);
--syn-rainbow-3: var(--palette-green);
--syn-rainbow-4: var(--palette-magenta);
--syn-rainbow-5: var(--palette-blue);
--syn-bracket-1: var(--palette-yellow);
--syn-bracket-2: var(--palette-magenta);
--syn-bracket-3: var(--palette-blue);
--ansi-black: var(--palette-ansi-black);
--ansi-bright-black: var(--palette-ansi-bright-black);
--ansi-red: var(--palette-ansi-red);
--ansi-bright-red: var(--palette-ansi-bright-red);
--ansi-green: var(--palette-ansi-green);
--ansi-bright-green: var(--palette-ansi-bright-green);
--ansi-yellow: var(--palette-ansi-yellow);
--ansi-bright-yellow: var(--palette-ansi-bright-yellow);
--ansi-blue: var(--palette-ansi-blue);
--ansi-bright-blue: var(--palette-ansi-bright-blue);
--ansi-magenta: var(--palette-ansi-magenta);
--ansi-bright-magenta: var(--palette-ansi-bright-magenta);
--ansi-cyan: var(--palette-ansi-cyan);
--ansi-bright-cyan: var(--palette-ansi-bright-cyan);
--ansi-white: var(--palette-ansi-white);
--ansi-bright-white: var(--palette-ansi-bright-white);
}
:root[data-color-theme="solarized-light"] {
--palette-yellow: #b58900;
--palette-orange: #cb4b16;
--palette-red: #dc322f;
--palette-magenta: #d33682;
--palette-violet: #6c71c4;
--palette-blue: #268bd2;
--palette-cyan: #2aa198;
--palette-green: #859900;
--palette-bg: #fdf6e3;
--palette-panel: #eee8d5;
--palette-popup: #fdf6e3;
--palette-fg: #657b83;
--palette-muted: #657b83;
--palette-border: #eee8d5;
--palette-hover: #eee8d5;
--palette-selection: #eee8d5;
--palette-line: #eee8d5;
--palette-accent: #268bd2;
--palette-on-accent: #002b36;
--palette-keyword: #859900;
--palette-type: #b58900;
--palette-number: #2aa198;
--palette-variable: #268bd2;
--palette-property: #268bd2;
--palette-comment: #93a1a1;
--palette-ansi-black: #073642;
--palette-ansi-red: #dc322f;
--palette-ansi-green: #859900;
--palette-ansi-yellow: #b58900;
--palette-ansi-blue: #268bd2;
--palette-ansi-magenta: #d33682;
--palette-ansi-cyan: #2aa198;
--palette-ansi-white: #eee8d5;
--palette-ansi-bright-black: #002b36;
--palette-ansi-bright-red: #cb4b16;
--palette-ansi-bright-green: #586e75;
--palette-ansi-bright-yellow: #657b83;
--palette-ansi-bright-blue: #839496;
--palette-ansi-bright-magenta: #6c71c4;
--palette-ansi-bright-cyan: #93a1a1;
--palette-ansi-bright-white: #fdf6e3;
--syn-string: #2aa198;
--syn-docstring: #2aa198;
--syn-markup-code: #2aa198;
--syn-tag: #268bd2;
--syn-escape: #dc322f;
}
:root[data-color-theme="solarized-dark"] {
--palette-yellow: #b58900;
--palette-orange: #cb4b16;
--palette-red: #dc322f;
--palette-magenta: #d33682;
--palette-violet: #6c71c4;
--palette-blue: #268bd2;
--palette-cyan: #2aa198;
--palette-green: #859900;
--palette-bg: #002b36;
--palette-panel: #073642;
--palette-popup: #073642;
--palette-fg: #839496;
--palette-muted: #839496;
--palette-border: #073642;
--palette-hover: #073642;
--palette-selection: #073642;
--palette-line: #073642;
--palette-accent: #268bd2;
--palette-on-accent: #002b36;
--palette-keyword: #859900;
--palette-type: #b58900;
--palette-number: #2aa198;
--palette-variable: #268bd2;
--palette-property: #268bd2;
--palette-comment: #586e75;
--palette-ansi-black: #073642;
--palette-ansi-red: #dc322f;
--palette-ansi-green: #859900;
--palette-ansi-yellow: #b58900;
--palette-ansi-blue: #268bd2;
--palette-ansi-magenta: #d33682;
--palette-ansi-cyan: #2aa198;
--palette-ansi-white: #eee8d5;
--palette-ansi-bright-black: #002b36;
--palette-ansi-bright-red: #cb4b16;
--palette-ansi-bright-green: #586e75;
--palette-ansi-bright-yellow: #657b83;
--palette-ansi-bright-blue: #839496;
--palette-ansi-bright-magenta: #6c71c4;
--palette-ansi-bright-cyan: #93a1a1;
--palette-ansi-bright-white: #fdf6e3;
--syn-string: #2aa198;
--syn-docstring: #2aa198;
--syn-markup-code: #2aa198;
--syn-tag: #268bd2;
--syn-escape: #dc322f;
}
:root[data-color-theme="one-light"] {
--palette-bg: hsl(230, 1%, 98%);
--palette-panel: hsl(230, 1%, 92%);
--palette-popup: hsl(230, 1%, 95%);
--palette-fg: hsl(230, 8%, 24%);
--palette-muted: hsl(230, 6%, 44%);
--palette-border: hsl(230, 1%, 86%);
--palette-hover: hsl(230, 1%, 90%);
--palette-selection: hsl(230, 1%, 90%);
--palette-line: color-mix(in srgb, hsl(230, 8%, 24%) 5%, hsl(230, 1%, 98%));
--palette-accent: hsl(230, 100%, 66%);
--palette-on-accent: #ffffff;
--palette-cyan: hsl(198, 99%, 37%);
--palette-blue: hsl(221, 87%, 60%);
--palette-magenta: hsl(301, 63%, 40%);
--palette-green: hsl(119, 34%, 47%);
--palette-red: hsl(5, 74%, 59%);
--palette-orange: hsl(41, 99%, 30%);
--palette-yellow: hsl(41, 99%, 38%);
--palette-comment: hsl(230, 4%, 64%);
--palette-keyword: hsl(301, 63%, 40%);
--palette-type: hsl(41, 99%, 38%);
--palette-number: hsl(41, 99%, 30%);
--palette-variable: hsl(5, 74%, 59%);
--palette-property: hsl(230, 8%, 24%);
--palette-ansi-black: hsl(230, 8%, 24%);
--palette-ansi-red: hsl(5, 74%, 59%);
--palette-ansi-green: hsl(119, 34%, 47%);
--palette-ansi-yellow: hsl(41, 99%, 38%);
--palette-ansi-blue: hsl(221, 87%, 60%);
--palette-ansi-magenta: hsl(301, 63%, 40%);
--palette-ansi-cyan: hsl(198, 99%, 37%);
--palette-ansi-white: hsl(230, 6%, 44%);
--palette-ansi-bright-black: hsl(230, 6%, 44%);
--palette-ansi-bright-red: hsl(5, 74%, 59%);
--palette-ansi-bright-green: hsl(119, 34%, 47%);
--palette-ansi-bright-yellow: hsl(41, 99%, 38%);
--palette-ansi-bright-blue: hsl(221, 87%, 60%);
--palette-ansi-bright-magenta: hsl(301, 63%, 40%);
--palette-ansi-bright-cyan: hsl(198, 99%, 37%);
--palette-ansi-bright-white: hsl(230, 8%, 24%);
}
:root[data-color-theme="one-dark"] {
--palette-bg: hsl(220, 13%, 18%);
--palette-panel: hsl(220, 13%, 15%);
--palette-popup: hsl(220, 13%, 16.5%);
--palette-fg: hsl(220, 14%, 71%);
--palette-muted: hsl(220, 9%, 55%);
--palette-border: hsl(220, 13%, 10.8%);
--palette-hover: hsl(220, 13%, 22%);
--palette-selection: hsl(220, 13%, 28%);
--palette-line: color-mix(in srgb, hsl(220, 100%, 80%) 4%, hsl(220, 13%, 18%));
--palette-accent: hsl(220, 100%, 66%);
--palette-on-accent: #000000;
--palette-cyan: hsl(187, 47%, 55%);
--palette-blue: hsl(207, 82%, 66%);
--palette-magenta: hsl(286, 60%, 67%);
--palette-green: hsl(95, 38%, 62%);
--palette-red: hsl(355, 65%, 65%);
--palette-orange: hsl(29, 54%, 61%);
--palette-yellow: hsl(39, 67%, 69%);
--palette-comment: hsl(220, 10%, 40%);
--palette-keyword: hsl(286, 60%, 67%);
--palette-type: hsl(39, 67%, 69%);
--palette-number: hsl(29, 54%, 61%);
--palette-variable: hsl(355, 65%, 65%);
--palette-property: hsl(220, 14%, 71%);
--palette-ansi-black: hsl(220, 13%, 15%);
--palette-ansi-red: hsl(355, 65%, 65%);
--palette-ansi-green: hsl(95, 38%, 62%);
--palette-ansi-yellow: hsl(39, 67%, 69%);
--palette-ansi-blue: hsl(207, 82%, 66%);
--palette-ansi-magenta: hsl(286, 60%, 67%);
--palette-ansi-cyan: hsl(187, 47%, 55%);
--palette-ansi-white: hsl(220, 14%, 71%);
--palette-ansi-bright-black: hsl(220, 9%, 55%);
--palette-ansi-bright-red: hsl(355, 65%, 65%);
--palette-ansi-bright-green: hsl(95, 38%, 62%);
--palette-ansi-bright-yellow: hsl(39, 67%, 69%);
--palette-ansi-bright-blue: hsl(207, 82%, 66%);
--palette-ansi-bright-magenta: hsl(286, 60%, 67%);
--palette-ansi-bright-cyan: hsl(187, 47%, 55%);
--palette-ansi-bright-white: #ffffff;
}
/*
* Appearance options (Settings › Appearance), applied as attributes on :root
* by the settings runtime.
+16 -14
View File
@@ -4,9 +4,10 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { BUILTIN_THEMES, isBuiltinTheme, themeKind, type BuiltinThemeId } from "@/platform/themes";
import { resolveTokenColor, toHex } from "@/platform/cssColor";
export type ThemeSetting = "dark" | "light" | "system" | "dark-hc";
export type ThemeSetting = BuiltinThemeId | "system";
/** PyCharm's brand blue: the accent both Islands themes ship with. */
export const DEFAULT_ACCENT = "#3871e1";
/** Accent presets (Settings › Appearance, Welcome › Customize); the first is the Islands default. */
@@ -44,8 +45,8 @@ interface AppearanceLike {
* Concrete theme id: the chosen theme, or with "Sync with OS" (legacy
* `theme = "system"`) the preferred light / dark theme for the OS mode.
*/
export function resolveThemeId(a: AppearanceLike | undefined, systemLight: boolean): "dark" | "light" | "dark-hc" {
const concrete = (v: string | undefined, d: "dark" | "light") => (v === "dark" || v === "light" || v === "dark-hc" ? v : d);
export function resolveThemeId(a: AppearanceLike | undefined, systemLight: boolean): BuiltinThemeId {
const concrete = (v: string | undefined, d: "dark" | "light") => (isBuiltinTheme(v) ? v : d);
if (a?.syncWithOs || a?.theme === "system")
return systemLight ? concrete(a?.preferredLightTheme, "light") : concrete(a?.preferredDarkTheme, "dark");
return concrete(a?.theme, "light");
@@ -53,7 +54,7 @@ export function resolveThemeId(a: AppearanceLike | undefined, systemLight: boole
/** `dark-hc` resolves to `dark` plus `data-contrast="high"`. */
export function resolveTheme(setting: string | undefined, systemLight: boolean): "dark" | "light" {
return resolveThemeId({ theme: setting }, systemLight) === "light" ? "light" : "dark";
return themeKind(resolveThemeId({ theme: setting }, systemLight));
}
const ACCENT_RE = /^#[0-9a-f]{3,8}$/i;
@@ -82,17 +83,21 @@ export function useApplyTheme() {
}
/**
* Sets `data-theme` / `data-contrast` for a built-in theme id. While a plugin
* Sets `data-theme` / `data-contrast` / `data-color-theme` (the palette themes
* over a light / dark base, tokens.css) for a built-in theme id. While a plugin
* theme is active (`data-plugin-theme-kind`), its light / dark base wins.
*/
export function applyThemeAttributes(id: "dark" | "light" | "dark-hc", root: HTMLElement = document.documentElement) {
export function applyThemeAttributes(id: BuiltinThemeId, root: HTMLElement = document.documentElement) {
const pluginKind = root.dataset.pluginThemeKind;
if (pluginKind === "light" || pluginKind === "dark") {
delete root.dataset.colorTheme;
root.dataset.theme = pluginKind;
delete root.dataset.contrast;
return;
}
root.dataset.theme = id === "light" ? "light" : "dark";
if (id === "light" || id === "dark" || id === "dark-hc") delete root.dataset.colorTheme;
else root.dataset.colorTheme = id;
root.dataset.theme = themeKind(id);
if (id === "dark-hc") root.dataset.contrast = "high";
else delete root.dataset.contrast;
}
@@ -135,6 +140,7 @@ export function rememberStartupTheme(root: HTMLElement = document.documentElemen
JSON.stringify({
theme: root.dataset.theme,
contrast: root.dataset.contrast,
colorTheme: root.dataset.colorTheme,
frame: hex("--bg-frame"),
fg: hex("--fg-muted"),
accent: hex("--accent"),
@@ -162,18 +168,14 @@ export function syncWindowBackground() {
}
export function currentTheme(): "dark" | "light" {
return currentThemeId() === "light" ? "light" : "dark";
return themeKind(currentThemeId());
}
/** Themes offered by quick switch menus, in IDEA's order. */
export const THEME_CHOICES: ReadonlyArray<{ id: "light" | "dark" | "dark-hc"; label: string }> = [
{ id: "light", label: "Islands Light" },
{ id: "dark", label: "Islands Dark" },
{ id: "dark-hc", label: "High Contrast" },
];
export const THEME_CHOICES = BUILTIN_THEMES;
/** The concrete theme in effect (`dark-hc` kept distinct). */
export function currentThemeId(): "dark" | "light" | "dark-hc" {
export function currentThemeId(): BuiltinThemeId {
const override = useThemeOverride.getState().theme;
return override
? resolveThemeId({ theme: override }, prefersLight())