diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index ca12804a..d31065b2 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -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. +``` diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index db686b0f..9e57b8b2 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -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 = 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() diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 6a8c29c4..b5e29650 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -130,6 +130,37 @@ pub struct MavenDependency { pub optional: bool, /// Transitive dependencies (only after a sync). pub children: Vec, + /// Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`). + #[serde(default)] + #[specta(optional)] + pub omitted: Option, + /// The version that won when `omitted` is `Conflict`. + #[serde(default)] + #[specta(optional)] + pub conflict_with: Option, + /// The version requested before `` replaced it. + #[serde(default)] + #[specta(optional)] + pub managed_from: Option, +} + +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) -> 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, Vec Vec<(String, Vec, Vec Option { 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 { [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, 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, 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}"); + } + } } diff --git a/crates/ide-build/src/pom_edit.rs b/crates/ide-build/src/pom_edit.rs new file mode 100644 index 00000000..56db1c9b --- /dev/null +++ b/crates/ide-build/src/pom_edit.rs @@ -0,0 +1,336 @@ +//! Edits of `pom.xml` text for the dependency analyzer: finding where a direct +//! dependency is declared and adding an `` 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, artifact_id: impl Into) -> 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 { + let mut props: HashMap = 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) -> 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 `` declaring `key` in the project's ``, else in a +/// profile's (never ``, 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 `` of the dependency declaring `key` in `text` (a POM), or of +/// the whole `` when it has none; `None` when the POM does not declare it. +pub fn declaration(text: &str, key: &ArtifactKey) -> Option { + 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 `` 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 { + 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, "".to_owned()), + (depth + 1, format!("{g}")), + (depth + 1, format!("{a}")), + (depth, "".to_owned()), + ] + }; + let compact = format!("{g}{a}"); + + 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, "".to_owned())]; + lines.extend(exclusion_lines(1)); + lines.push((0, "".to_owned())); + Some(insert(dep, &lines, format!("{compact}"))) +} + +/// 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 { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = 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 { + 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 = " + demo + + com.google.inject + + + + + com.google.inject + guice + 5.1.0 + + + + + + ${guice.group} + guice + + + +"; + + #[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( + " guice\n \n ", + " guice + + + com.google.guava + guava + + + + ", + ); + 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( + " + + aopalliance + aopalliance + + " + )); + assert_eq!(exclude(&twice, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(), twice); + } + + #[test] + fn keeps_tabs_crlf_and_one_line_dependencies() { + let tabs = "\r\n\t\r\n\t\t\r\n\t\t\tg\r\n\t\t\ta\r\n\t\t\r\n\t\r\n\r\n"; + let out = exclude(tabs, ("g", "a"), ("x", "y")).unwrap(); + assert!(out.contains("a\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tx\r\n")); + assert!(out.contains("\t\t\t\r\n\t\t")); + + let inline = "ga"; + assert_eq!( + exclude(inline, ("g", "a"), ("x", "y")).unwrap(), + "gaxy" + ); + } + + #[test] + fn wildcards_profiles_and_missing_declarations() { + let pom = " + + + + + ga + x* + + + + +"; + 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( + "zw" + )); + 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 = "\n ga\n"; + let r = declaration(pom, &ArtifactKey::new("g", "a")).unwrap(); + assert_eq!(r, Range::new(Pos::new(1, 34), Pos::new(1, 60))); + let first = "ga"; + let r = declaration(&format!("{first}"), &ArtifactKey::new("g", "a")) + .unwrap(); + let start = first.find("").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", + "dparent1pomapp", + ); + write( + "app/pom.xml", + "dparent1app", + ); + 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()); + } +} diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index 2470c425..d29c1d19 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -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, projects: Vec, + /// 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 { + 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(dir: &Path, name: &str) -> Option { @@ -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 = 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!( diff --git a/crates/ide-build/src/xml.rs b/crates/ide-build/src/xml.rs index 38f96ed1..86dec9db 100644 --- a/crates/ide-build/src/xml.rs +++ b/crates/ide-build/src/xml.rs @@ -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 (`` → `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, + /// 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 ``). + pub end: usize, } impl Element { @@ -76,6 +83,7 @@ fn local(name: &str) -> String { pub fn parse(text: &str) -> Option { let mut stack: Vec = 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 { } else if let Some(body) = tag.strip_prefix("').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 { 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 = "\n x\n"; + 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], "x"); + assert_eq!(&doc[b.start..b.inner_end], "x"); + let c = root.child("c").unwrap(); + assert_eq!(&doc[c.start..c.end], ""); + let broken = parse("x").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("xy").unwrap(); diff --git a/crates/ide-build/testdata/dependency-tree-verbose.txt b/crates/ide-build/testdata/dependency-tree-verbose.txt new file mode 100644 index 00000000..217b46a3 --- /dev/null +++ b/crates/ide-build/testdata/dependency-tree-verbose.txt @@ -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 diff --git a/crates/ide-lsp/tests/tcp_server.rs b/crates/ide-lsp/tests/tcp_server.rs index 0652ebd5..226e0e86 100644 --- a/crates/ide-lsp/tests/tcp_server.rs +++ b/crates/ide-lsp/tests/tcp_server.rs @@ -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; diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs index caf0adb4..573851cd 100644 --- a/crates/ide-plugin/tests/host.rs +++ b/crates/ide-plugin/tests/host.rs @@ -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")); } diff --git a/docs/media/themes/one-dark-popup.png b/docs/media/themes/one-dark-popup.png new file mode 100644 index 00000000..a9775461 Binary files /dev/null and b/docs/media/themes/one-dark-popup.png differ diff --git a/docs/media/themes/one-dark-workbench.png b/docs/media/themes/one-dark-workbench.png new file mode 100644 index 00000000..0fff44ae Binary files /dev/null and b/docs/media/themes/one-dark-workbench.png differ diff --git a/docs/media/themes/one-light-popup.png b/docs/media/themes/one-light-popup.png new file mode 100644 index 00000000..c9cba35e Binary files /dev/null and b/docs/media/themes/one-light-popup.png differ diff --git a/docs/media/themes/one-light-workbench.png b/docs/media/themes/one-light-workbench.png new file mode 100644 index 00000000..ee908575 Binary files /dev/null and b/docs/media/themes/one-light-workbench.png differ diff --git a/docs/media/themes/solarized-dark-popup.png b/docs/media/themes/solarized-dark-popup.png new file mode 100644 index 00000000..6ff01a14 Binary files /dev/null and b/docs/media/themes/solarized-dark-popup.png differ diff --git a/docs/media/themes/solarized-dark-workbench.png b/docs/media/themes/solarized-dark-workbench.png new file mode 100644 index 00000000..e702358d Binary files /dev/null and b/docs/media/themes/solarized-dark-workbench.png differ diff --git a/docs/media/themes/solarized-light-popup.png b/docs/media/themes/solarized-light-popup.png new file mode 100644 index 00000000..e931171e Binary files /dev/null and b/docs/media/themes/solarized-light-popup.png differ diff --git a/docs/media/themes/solarized-light-workbench.png b/docs/media/themes/solarized-light-workbench.png new file mode 100644 index 00000000..8cdae4c6 Binary files /dev/null and b/docs/media/themes/solarized-light-workbench.png differ diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 767de03a..33e03ca2 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -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 diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 00000000..55b0531b --- /dev/null +++ b/docs/themes.md @@ -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. diff --git a/e2e/themes.spec.ts b/e2e/themes.spec.ts new file mode 100644 index 00000000..74ee13e4 --- /dev/null +++ b/e2e/themes.spec.ts @@ -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); + } +}); diff --git a/plugins/README.md b/plugins/README.md index 8cf83e55..530f116d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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" } } ``` diff --git a/plugins/theme-one/LICENSE b/plugins/theme-one/LICENSE deleted file mode 100644 index 3c8c405b..00000000 --- a/plugins/theme-one/LICENSE +++ /dev/null @@ -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. diff --git a/plugins/theme-one/plugin.toml b/plugins/theme-one/plugin.toml deleted file mode 100644 index bc5169ca..00000000 --- a/plugins/theme-one/plugin.toml +++ /dev/null @@ -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" diff --git a/plugins/theme-one/themes/one-dark.scheme.json b/plugins/theme-one/themes/one-dark.scheme.json deleted file mode 100644 index b15db467..00000000 --- a/plugins/theme-one/themes/one-dark.scheme.json +++ /dev/null @@ -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" - } -} diff --git a/plugins/theme-one/themes/one-dark.theme.json b/plugins/theme-one/themes/one-dark.theme.json deleted file mode 100644 index ab397060..00000000 --- a/plugins/theme-one/themes/one-dark.theme.json +++ /dev/null @@ -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%)" - } -} diff --git a/plugins/theme-one/themes/one-light.scheme.json b/plugins/theme-one/themes/one-light.scheme.json deleted file mode 100644 index bbabeb5a..00000000 --- a/plugins/theme-one/themes/one-light.scheme.json +++ /dev/null @@ -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%)" - } -} diff --git a/plugins/theme-one/themes/one-light.theme.json b/plugins/theme-one/themes/one-light.theme.json deleted file mode 100644 index ad650187..00000000 --- a/plugins/theme-one/themes/one-light.theme.json +++ /dev/null @@ -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%)" - } -} diff --git a/plugins/theme-solarized/LICENSE b/plugins/theme-solarized/LICENSE deleted file mode 100644 index 0e62e915..00000000 --- a/plugins/theme-solarized/LICENSE +++ /dev/null @@ -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. diff --git a/plugins/theme-solarized/plugin.toml b/plugins/theme-solarized/plugin.toml deleted file mode 100644 index 51223ba0..00000000 --- a/plugins/theme-solarized/plugin.toml +++ /dev/null @@ -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" diff --git a/plugins/theme-solarized/themes/solarized-dark.scheme.json b/plugins/theme-solarized/themes/solarized-dark.scheme.json deleted file mode 100644 index 57721bad..00000000 --- a/plugins/theme-solarized/themes/solarized-dark.scheme.json +++ /dev/null @@ -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" - } -} diff --git a/plugins/theme-solarized/themes/solarized-dark.theme.json b/plugins/theme-solarized/themes/solarized-dark.theme.json deleted file mode 100644 index 5f76df9a..00000000 --- a/plugins/theme-solarized/themes/solarized-dark.theme.json +++ /dev/null @@ -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" - } -} diff --git a/plugins/theme-solarized/themes/solarized-light.scheme.json b/plugins/theme-solarized/themes/solarized-light.scheme.json deleted file mode 100644 index e5c4d7a4..00000000 --- a/plugins/theme-solarized/themes/solarized-light.scheme.json +++ /dev/null @@ -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" - } -} diff --git a/plugins/theme-solarized/themes/solarized-light.theme.json b/plugins/theme-solarized/themes/solarized-light.theme.json deleted file mode 100644 index d1750b3c..00000000 --- a/plugins/theme-solarized/themes/solarized-light.theme.json +++ /dev/null @@ -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" - } -} diff --git a/public/boot.js b/public/boot.js index 28052db0..6f81c4e8 100644 --- a/public/boot.js +++ b/public/boot.js @@ -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 = [ diff --git a/scripts/theme/theme.test.ts b/scripts/theme/theme.test.ts index 2e623b76..8224b5c4 100644 --- a/scripts/theme/theme.test.ts +++ b/scripts/theme/theme.test.ts @@ -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; - }; - const ed = JSON.parse(stripJsonComments(read(`${t.dir}/${t.scheme}`))) as { tokens: Record }; - 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 diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 05bb1336..30d0e2bb 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -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 { + 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 `` 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 { + 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) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index af2dfe7b..e55f4e98 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -246,6 +246,8 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 463721f4..f2c8be4a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod logging; mod menu; mod shell_env; mod state; +mod theme_frame; mod windows; use state::AppState; diff --git a/src-tauri/src/theme_frame.rs b/src-tauri/src/theme_frame.rs new file mode 100644 index 00000000..a7c2cb66 --- /dev/null +++ b/src-tauri/src/theme_frame.rs @@ -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, HashMap); + +/// The rule blocks of tokens.css, in source order. +static BLOCKS: LazyLock> = 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=""]` block); `None` for any other id. +pub fn builtin_frame(id: &str) -> Option { + builtin_frame_in(&BLOCKS, id) +} + +fn builtin_frame_in(blocks: &[Block], id: &str) -> Option { + 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 { + 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 { + 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 = args + .split([',', ' ']) + .filter(|s| !s.is_empty()) + .map(|s| s.trim_end_matches("deg").trim_end_matches('%').parse()) + .collect::>() + .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); + } +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index ce12536d..0c1cc1dd 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -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 { /// 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 [ diff --git a/src/bindings.ts b/src/bindings.ts index b77333b5..75150d95 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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) => typedError(__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(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })), + /** + * Adds an `` 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(__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 `` 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. */ diff --git a/src/editor/core/colorSchemeRuntime.ts b/src/editor/core/colorSchemeRuntime.ts index 6c1d8b14..b94600d4 100644 --- a/src/editor/core/colorSchemeRuntime.ts +++ b/src/editor/core/colorSchemeRuntime.ts @@ -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(); diff --git a/src/features/build-tools/BuildToolView.tsx b/src/features/build-tools/BuildToolView.tsx index 0b503c2f..7060620a 100644 --- a/src/features/build-tools/BuildToolView.tsx +++ b/src/features/build-tools/BuildToolView.tsx @@ -153,6 +153,7 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[] action={`${tool}.toggleOffline`} pressed={maven ? settings.maven.offline : settings.gradle.offline} /> + {maven && } {maven && ( * { + 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); +} diff --git a/src/features/build-tools/DependencyAnalyzer.test.tsx b/src/features/build-tools/DependencyAnalyzer.test.tsx new file mode 100644 index 00000000..deb09ba0 --- /dev/null +++ b/src/features/build-tools/DependencyAnalyzer.test.tsx @@ -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(); + return { + ...real, + call: (value: unknown) => value, + commands: { ...real.commands, mavenExcludeDependency: ipc.exclude, mavenDependencyDeclaration: ipc.declaration }, + }; +}); + +function dep(coords: string, extra: Partial = {}, 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(); + 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" }, + ); + }); +}); diff --git a/src/features/build-tools/DependencyAnalyzer.tsx b/src/features/build-tools/DependencyAnalyzer.tsx new file mode 100644 index 00000000..ac769f0b --- /dev/null +++ b/src/features/build-tools/DependencyAnalyzer.tsx @@ -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 ( + + + + {artifact.key} + + {artifact.version ?? tx("omitted")} + {artifact.conflict && ( + {artifact.requested.filter((v) => v !== artifact.version).join(", ")} + )} + {artifact.scope !== "compile" && ({artifact.scope})} + + ); +} + +function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; conflict: boolean; selected: boolean }) { + const tx = useTxTemplate(); + const d = node.dependency; + const status = statusText(usageStatus(d)); + return ( + + + + {artifactKey(d)} + + {versionLabel(d)} + {d.scope !== "compile" && ({d.scope})} + {status && {tx(status)}} + + ); +} + +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 ( +
+
+ + {status.version} + + {text ? tx(text) : tx("resolved")} + + + {canExclude && ( + + )} + void jumpToDeclaration(pom, direct)} + /> +
+
    +
  1. + + {module.displayName} +
  2. + {usage.path.map((d, i) => ( +
  3. + + + {coordinates(d)} + + {d.scope !== "compile" && ({d.scope})} +
  4. + ))} +
+
+ ); +} + +function UsagesPane({ + artifact, + module, + pom, +}: { + artifact: AnalyzedArtifact | null; + module: MavenProject; + pom: string; +}) { + if (!artifact) return ; + const usages = sortedUsages(artifact.usages); + const coords = coordinates({ ...artifact, version: artifact.version }); + return ( +
+
+ {coords} + void copyText(coords)} /> +
+ {artifact.conflict && ( +
+ + + Versions requested: {artifact.requested.join(", ")} + +
+ )} +
+ {usages.map((u, i) => ( + + ))} +
+
+ ); +} + +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(ALL_SCOPES); + const [mode, setMode] = useState("list"); + const [selectedKey, setSelectedKey] = useState(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 } | null>(null); + const expanded = useMemo(() => { + if (manual?.id === filterId) return manual.keys; + const keys = new Set(); + 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, 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 ; + + return ( +
+
+ + + setMode("list")} + /> + setMode("tree")} + /> + + {syncing ? ( + + ) : ( + + )} +
+ {syncing ? ( +
+ + {syncing.message} +
+ ) : maven.stale && maven.syncedAt != null ? ( +
+ + + Dependency trees may be out of date + + +
+ ) : null} + {maven.syncedAt == null ? ( + void syncTool("maven")} disabled={!!syncing}> + Reload All Maven Projects + + } + /> + ) : !module || !pom ? ( + + ) : ( +
+ + + + 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" ? ( + + ) : ( + + ) + } + onActivate={(n) => { + const usage = usageOf(n); + if (usage) void jumpToDeclaration(pom, usage.path[0]!); + }} + onContextMenu={contextMenu} + multiSelect={false} + empty={} + /> + + + + + + +
+ )} +
+ ); +} diff --git a/src/features/build-tools/actions.test.tsx b/src/features/build-tools/actions.test.tsx index 1ecd76bf..bb8ac601 100644 --- a/src/features/build-tools/actions.test.tsx +++ b/src/features/build-tools/actions.test.tsx @@ -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", diff --git a/src/features/build-tools/analyzer.test.ts b/src/features/build-tools/analyzer.test.ts new file mode 100644 index 00000000..db281189 --- /dev/null +++ b/src/features/build-tools/analyzer.test.ts @@ -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 = {}, 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) => 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"); + }); +}); diff --git a/src/features/build-tools/analyzer.ts b/src/features/build-tools/analyzer.ts new file mode 100644 index 00000000..2f82006d --- /dev/null +++ b/src/features/build-tools/analyzer.ts @@ -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) => + [d.groupId, d.artifactId, d.classifier].filter(Boolean).join(":"); + +/** `groupId:artifactId[:classifier]:version`. */ +export function coordinates(d: Pick): 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 `` 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(); + 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, 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, +): 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); +} diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index 066810ad..f70182fd 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -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(); /** 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", diff --git a/src/features/build-tools/model.ts b/src/features/build-tools/model.ts index 78ed6931..036dd86f 100644 --- a/src/features/build-tools/model.ts +++ b/src/features/build-tools/model.ts @@ -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 }] + : [], + ); } // --------------------------------------------------------------------------- diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 1d12067b..d26f964b 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -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) { 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 }); +} diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 69204cdf..c6412dfb 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -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 () => { diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 68d90ce8..d3a4b131 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -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 }; }; diff --git a/src/features/project-tree/HeaderActions.tsx b/src/features/project-tree/HeaderActions.tsx index c629caa8..29ee809e 100644 --- a/src/features/project-tree/HeaderActions.tsx +++ b/src/features/project-tree/HeaderActions.tsx @@ -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"], ]; diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 2770da91..476a83a2 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -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")); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index b1cfcdce..3756fb24 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -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)} > @@ -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; diff --git a/src/features/project-tree/actions.ts b/src/features/project-tree/actions.ts index e4d2f7eb..621d6805 100644 --- a/src/features/project-tree/actions.ts +++ b/src/features/project-tree/actions.ts @@ -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]), })); diff --git a/src/features/project-tree/store.test.ts b/src/features/project-tree/store.test.ts index e03560af..6c2fd5d7 100644 --- a/src/features/project-tree/store.test.ts +++ b/src/features/project-tree/store.test.ts @@ -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); + }); +}); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index d4322905..95a9dc26 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -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) { persistExpanded(); } +/** Expands or collapses a folder node; expanding a compacted chain opens every folder in it. */ +export function toggleExpanded(node: Pick) { + 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, lead: string | null) { set({ selected, lead }); } diff --git a/src/features/settings/__tests__/contrast.test.ts b/src/features/settings/__tests__/contrast.test.ts index 5a9b914f..ec3dff20 100644 --- a/src/features/settings/__tests__/contrast.test.ts +++ b/src/features/settings/__tests__/contrast.test.ts @@ -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(); + }); +}); diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index 8a7eb909..d0b5a060 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -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 { diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index 5551d80e..34505b45 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -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; diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 1d6939d6..5afe5565 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -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[] = [ - { value: "light", label: "Islands Light" }, - { value: "dark", label: "Islands Dark" }, - { value: "dark-hc", label: "High Contrast" }, -]; +const THEMES: SelectOption[] = 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, }, { diff --git a/src/features/terminal/index.ts b/src/features/terminal/index.ts index 5fba3423..c3e39ac6 100644 --- a/src/features/terminal/index.ts +++ b/src/features/terminal/index.ts @@ -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()); } diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 5cf33d48..398bd016 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -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[] => [ diff --git a/src/features/vcs/__tests__/diffOptions.test.ts b/src/features/vcs/__tests__/diffOptions.test.ts index 54be8b97..40b337d5 100644 --- a/src/features/vcs/__tests__/diffOptions.test.ts +++ b/src/features/vcs/__tests__/diffOptions.test.ts @@ -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(); diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index a8b83d72..8e4dd327 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -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(); + 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), ]; diff --git a/src/features/vcs/diffOptions.ts b/src/features/vcs/diffOptions.ts index e2db3d1a..35860fe4 100644 --- a/src/features/vcs/diffOptions.ts +++ b/src/features/vcs/diffOptions.ts @@ -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(); + 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(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 }; diff --git a/src/features/viewers/markdown/mermaid.test.ts b/src/features/viewers/markdown/mermaid.test.ts index 567ec590..bff0c51e 100644 --- a/src/features/viewers/markdown/mermaid.test.ts +++ b/src/features/viewers/markdown/mermaid.test.ts @@ -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); diff --git a/src/features/viewers/markdown/mermaid.ts b/src/features/viewers/markdown/mermaid.ts index 22ee6e8e..750e1e85 100644 --- a/src/features/viewers/markdown/mermaid.ts +++ b/src/features/viewers/markdown/mermaid.ts @@ -30,8 +30,8 @@ function loadMermaid(): Promise { /** 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(); } diff --git a/src/features/welcome/CustomizePage.tsx b/src/features/welcome/CustomizePage.tsx index 1417302b..cd334ae0 100644 --- a/src/features/welcome/CustomizePage.tsx +++ b/src/features/welcome/CustomizePage.tsx @@ -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[] = [ - { value: "light", label: "Islands Light" }, - { value: "dark", label: "Islands Dark" }, - { value: "dark-hc", label: "High Contrast" }, -]; +export const THEME_OPTIONS: SelectOption[] = 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["settings"]): AppearanceView { const a = (settings?.appearance ?? {}) as Record; - 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 { diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index 9f4c3255..6b6fe358 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -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"; } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 197d8505..5552803a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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} 的预览", diff --git a/src/platform/themes.test.ts b/src/platform/themes.test.ts new file mode 100644 index 00000000..50f54636 --- /dev/null +++ b/src/platform/themes.test.ts @@ -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(); + }); +}); diff --git a/src/platform/themes.ts b/src/platform/themes.ts new file mode 100644 index 00000000..fd8a7c81 --- /dev/null +++ b/src/platform/themes.ts @@ -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; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 00b43124..2f81f644 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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. diff --git a/src/workbench/theme.ts b/src/workbench/theme.ts index c16db22d..9350b495 100644 --- a/src/workbench/theme.ts +++ b/src/workbench/theme.ts @@ -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())