Merge remote-tracking branch 'origin/main' into fix/translate-template-text
# Conflicts: # src/platform/locales/text/zh.json
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('&', "&").replace('<', "<")
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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" }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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%)"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ mod logging;
|
||||
mod menu;
|
||||
mod shell_env;
|
||||
mod state;
|
||||
mod theme_frame;
|
||||
mod windows;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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[] => [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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} 的预览",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||