Compare commits

..
Author SHA1 Message Date
l0ng-ai 8ec4032cf7 fix(vcs): let Commit and Shelf tree names use the whole row width
File and folder names in the Commit tool window's changes tree (and the Shelf tree, which shares the styles) were capped at 75% of the row, so a long folder name was cut with an ellipsis while a quarter of the row stayed empty, and the gap grew as the window widened. The name now takes the whole row; when it does not fit, the grey detail after it (the path or file count) is truncated first. Stash rows keep their previous shrinking.

Fixes #1367
2026-11-05 08:25:29 +08:00
28 changed files with 38 additions and 930 deletions

No files matched your search

+1 -15
View File
@@ -2,12 +2,8 @@ name: Language smoke
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
# Step Over, Resume): smoke/debug.test.ts.
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
on:
push:
branches: [main]
@@ -21,7 +17,6 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/features/debugger/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
@@ -109,16 +104,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
env: { GITHUB_TOKEN: "${{ github.token }}" }
-3
View File
@@ -71,9 +71,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
`sudo DevToolsSecurity -enable`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
@@ -36,23 +36,6 @@ impl Default for InspectionSettings {
}
}
impl InspectionSettings {
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
let custom = |level: &str, key: &str| match level {
"ide" => self.ide_profiles.get(key),
"project" => self.project_profiles.get(key),
_ => None,
};
match self.profile.as_str() {
"project" => &self.project,
id => {
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
}
}
}
}
/// A named profile made with Copy.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -128,20 +111,4 @@ mod tests {
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
#[test]
fn active_profile_falls_back_to_default() {
let off = |id: &str| {
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
};
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
assert!(s.active().contains_key("a"));
s.profile = "project".into();
assert!(s.active().contains_key("b"));
s.profile = "ide:strict".into();
assert!(s.active().contains_key("c"));
s.profile = "project:gone".into();
assert!(s.active().contains_key("a"));
}
}
+1 -6
View File
@@ -175,12 +175,7 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
let code_style = key == "editor"
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
-1
View File
@@ -209,7 +209,6 @@ impl Host {
cfg.launch_error = Some(e);
None
});
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
ide_sdk::java::configure_jdtls(
cfg,
server_root,
-285
View File
@@ -1,285 +0,0 @@
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
//! the source and test roots detected from its build file, the index (files and symbols),
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
//! come and go, and the Maven / Gradle model.
//!
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
//! java, kotlin and python jobs):
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
use ide_host::{Host, HostConfig, ops};
use ide_index::SymbolFilter;
use ide_workspace::roots::RootKind;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Fixture {
dir: &'static str,
/// Detected roots: (project-relative path, kind).
roots: &'static [(&'static str, RootKind)],
/// Search Everywhere › Files: (query, file found).
file: (&'static str, &'static str),
/// Go to Class: (name, file declaring it).
class: (&'static str, &'static str),
/// Go to Symbol: (name, file declaring it).
symbol: (&'static str, &'static str),
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
added: (&'static str, &'static str),
}
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
async fn open(fixture: &str) -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
Opened { host, root, _tmp: tmp }
}
/// Polls `check` until it holds; fails with its last answer.
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
let deadline = Instant::now() + timeout;
loop {
match check() {
Ok(()) => return,
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
}
}
}
impl Opened {
fn files(&self, query: &str) -> Vec<String> {
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
found.into_iter().map(|f| f.rel_path).collect()
}
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
}
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
let found = self.symbols(name, filter);
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
}
}
async fn smoke(f: Fixture) {
let p = open(f.dir).await;
until("indexing", Duration::from_secs(30), || {
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
})
.await;
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
for (path, kind) in f.roots {
assert!(
roots.iter().any(|r| r.path == *path && r.kind == *kind),
"{}: no {kind:?} root {path:?} in {roots:?}",
f.dir
);
}
let files = p.files(f.file.0);
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
}
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
}
// The index follows the file system while the project is open.
let (added, text) = f.added;
std::fs::write(p.root.join(added), text).unwrap();
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
.await;
std::fs::remove_file(p.root.join(added)).unwrap();
until("a deleted file's class", Duration::from_secs(20), || {
let found = p.symbols("FreshWidget", SymbolFilter::Types);
if found.is_empty() { Ok(()) } else { Err(found) }
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rust_app() {
smoke(Fixture {
dir: "rust-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("pars", "src/parser.rs"),
class: ("Inventory", "src/lib.rs"),
symbol: ("low_stock", "src/lib.rs"),
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ts_app() {
smoke(Fixture {
dir: "ts-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("cart", "src/cart.ts"),
class: ("Cart", "src/cart.ts"),
symbol: ("formatMoney", "src/money.ts"),
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn python_app() {
smoke(Fixture {
dir: "python-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("models", "src/tasks/models.py"),
class: ("TaskList", "src/tasks/models.py"),
symbol: ("build_demo", "src/tasks/cli.py"),
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn go_app() {
smoke(Fixture {
dir: "go-app",
roots: &[("", RootKind::Source)],
file: ("store", "internal/store/store.go"),
class: ("Store", "internal/store/store.go"),
symbol: ("writeJSON", "cmd/server/main.go"),
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn java_app() {
smoke(Fixture {
dir: "java-app",
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
added: (
"src/main/java/com/example/orders/FreshWidget.java",
"package com.example.orders;\n\npublic class FreshWidget {}\n",
),
})
.await;
let p = open("java-app").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let maven = model.maven.expect("a Maven model");
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn kotlin_gradle() {
smoke(Fixture {
dir: "kotlin-gradle",
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
})
.await;
let p = open("kotlin-gradle").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let gradle = model.gradle.expect("a Gradle model");
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
}
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
let p = open(fixture).await;
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
let model =
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
// The cached sync is what the next open shows.
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
assert_eq!(reloaded, model);
model
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Maven and a JDK"]
async fn maven_sync() {
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
assert_eq!(maven.sync_error, None);
assert!(maven.synced_at.is_some());
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
// The resolved tree, not only the declaration.
assert!(!junit.children.is_empty(), "{junit:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
assert_eq!(gradle.sync_error, None);
assert!(gradle.synced_at.is_some());
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
// The build's own tasks: the application and Kotlin plugins' too.
for task in ["run", "compileKotlin", "test"] {
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs python3"]
async fn python_venv() {
let p = open("python-app").await;
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
assert!(made.unwrap().success());
// The project's virtualenv: listed first, and the interpreter the project uses.
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs JDK 21"]
async fn jdk() {
let p = open("java-app").await;
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
}
+8 -71
View File
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path};
use crate::{angular, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::settings::{LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
@@ -442,41 +442,15 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
}
}
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
if o.enabled != Some(true) {
return None;
}
Some(match o.severity {
Some(InspectionLevel::Error) => "error",
None | Some(InspectionLevel::Warning) => "warning",
Some(_) => "info",
})
}
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
/// Each variant has its own file, so changing an inspection restarts the server with it.
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
let serial = missing_serial_version(inspections);
let (name, content) = match serial {
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
Some(level) => (
format!("compiler-defaults-serial-{level}.prefs"),
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
),
};
let file = data_dir.join("lsp/jdtls").join(name);
let write = || -> ide_core::IdeResult<()> {
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
ide_vfs::write_atomic(&file, content.as_bytes())?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
}
Ok(())
};
@@ -1121,50 +1095,13 @@ mod java_compiler_tests {
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
}
#[test]
fn serial_version_inspection_sets_the_compiler_option() {
let tmp = tempfile::tempdir().unwrap();
let url = |inspections: &InspectionSettings| {
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
configure_java_compiler(&mut cfg, tmp.path(), inspections);
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
};
let set = |o: ide_core::settings::InspectionOverride| {
let mut s = InspectionSettings::default();
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
s
};
let off = url(&InspectionSettings::default());
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
assert_ne!(on, off, "a new file, so the server restarts with it");
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
let error = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
let weak = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::WeakWarning),
..Default::default()
}));
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
let severity_only = url(&set(ide_core::settings::InspectionOverride {
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert_eq!(severity_only, off, "off by default");
}
#[test]
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("cache with spaces 测试");
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
configure_java_compiler(&mut cfg, &cache);
let options = options(&cfg);
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
assert!(path.starts_with(&cache));
@@ -1175,7 +1112,7 @@ mod java_compiler_tests {
}
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
configure_java_compiler(&mut cfg, &cache);
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
assert_eq!(self::options(&cfg), options);
}
@@ -1185,7 +1122,7 @@ mod java_compiler_tests {
let tmp = tempfile::tempdir().unwrap();
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
configure_java_compiler(&mut cfg, tmp.path());
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
for settings in [
+1
View File
@@ -369,6 +369,7 @@ pub fn configure_jdtls(
if cfg.name != "jdtls" {
return;
}
crate::apply::configure_java_compiler(cfg, data_dir);
// A Lombok agent from the user's JVM arguments wins.
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
let agent = own_agent.as_deref().or(lombok);
+1 -2
View File
@@ -318,7 +318,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
223 themeable tokens: 117 in the UI theme file, 106 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -420,7 +420,6 @@ a token.
| `--syn-number` | `#2aacb8` | `#1750eb` |
| `--syn-punct` | `#bcbec4` | `#080808` |
| `--syn-variable` | `#bcbec4` | `#080808` |
| `--syn-global` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-param` | `#bcbec4` | `#080808` |
| `--syn-property` | `#c77dbb` | `#871094` |
| `--syn-constant` | `#c77dbb` | `#871094` |
+3 -4
View File
@@ -20,10 +20,9 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
language servers, `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts`
debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run
them for that language.
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests): when you change a
fixture, run both for that language.
## Setup
@@ -18,7 +18,6 @@
"--syn-number": "#7fc8f8",
"--syn-punct": "#a9b4c2",
"--syn-variable": "#d9dfe8",
"--syn-global": "#d9dfe8",
"--syn-param": "#d9dfe8",
"--syn-property": "#b9a3f0",
"--syn-constant": "#e58fa3",
-251
View File
@@ -1,251 +0,0 @@
/**
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
*/
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { get } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { Channel } from "@tauri-apps/api/core";
import { commands, type InstallEvent } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, nextRequestId, onIdeEvent } from "@/platform/ipc";
import { useBuildModel } from "@/platform/buildModel";
import { openProject, useProject } from "@/platform/project";
import { activate as activateBuildTools } from "@/features/build-tools";
import { useBuild } from "@/features/build-tools/store";
import { activate as activateDebugger } from "@/features/debugger";
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
import { activeSession, evaluate, useDebug } from "@/features/debugger/state";
import { activate as activateRun } from "@/features/run";
import { resetRuns, useRun } from "@/features/run/store";
import { activateWorkbench } from "@/workbench/activate";
import { useNotifications } from "@/workbench/notificationStore";
import { connectDesktop } from "./desktop";
import { repoRoot, Server } from "./server";
interface Case {
fixture: string;
/** Runs in the project copy first (installs its dependencies). */
setup?: (root: string) => void;
/** The IDE's tool that debugs it. */
debugger: string;
/** The entry point, debugged from its context. */
file: string;
/** A variable of the paused frame. */
variable: string;
/** An expression evaluated there (Evaluate Expression), and its value. */
evaluate: [string, RegExp];
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
request?: string;
}
const CASES: Record<string, Case> = {
typescript: {
fixture: "ts-app",
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
debugger: "js-debug",
file: "src/index.ts",
variable: "cart",
evaluate: ["cart.items.length", /^2$/],
},
python: {
fixture: "python-app",
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
debugger: "debugpy",
file: "src/tasks/cli.py",
variable: "tasks",
evaluate: ["len(tasks.pending())", /^3$/],
},
rust: {
fixture: "rust-app",
debugger: "codelldb",
file: "src/main.rs",
variable: "inventory",
evaluate: ["inventory", /\S/],
},
go: {
fixture: "go-app",
debugger: "delve",
file: "cmd/server/main.go",
variable: "r",
evaluate: ["r.Method", /^"GET"$/],
request: "http://127.0.0.1:8080/notes?q=openidea",
},
java: {
fixture: "java-app",
debugger: "java-debug",
file: "src/main/java/com/example/orders/App.java",
variable: "book",
evaluate: ["book.size()", /^3$/],
},
kotlin: {
fixture: "kotlin-gradle",
debugger: "kotlin-debug-adapter",
file: "src/main/kotlin/com/example/library/App.kt",
variable: "library",
evaluate: ["library", /Library/],
},
};
let deactivate: (() => void) | null = null;
let server: Server | null = null;
let disconnect: (() => void) | null = null;
let base = "";
/** Backend events received, for failure messages. */
const events: string[] = [];
beforeAll(() => {
onIdeEvent("*", (e) => events.push(e.type === "debug" ? `debug ${e.session} ${e.event.kind}` : e.type));
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
deactivate = () => disposers.reverse().forEach((d) => d());
});
afterAll(() => deactivate?.());
afterEach(() => {
resetRuns();
disconnect?.();
server?.close();
if (base) rmSync(base, { recursive: true, force: true });
});
async function open(c: Case): Promise<string> {
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
const root = join(base, c.fixture);
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
c.setup?.(root);
server = await Server.start(join(base, "home"));
disconnect = connectDesktop(server);
await openProject(root);
const trusted = await commands.projectTrustSet(root, true, false);
if (trusted.status !== "ok") throw new Error(trusted.error.message);
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
// Run and Debug follow the build (a Gradle project's Application runs through Gradle).
await until(
"the build model",
() => {
const error = useBuild.getState().error;
if (error) throw new Error(`the build model did not load: ${error}`);
return useBuildModel.getState().root === root && useBuildModel.getState().model;
},
5 * 60_000,
);
return root;
}
async function install(tool: string) {
const tools = await call(commands.toolsList());
if (tools.find((t) => t.name === tool)?.status === "installed") return;
const channel = new Channel<InstallEvent>();
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
expect(info.status, `${tool} installs`).toBe("installed");
}
/** What the Debug and Run tool windows show, for failure messages. */
function shown(): string {
const { sessions, console } = useDebug.getState();
const debug = Object.values(sessions).map(
(s) =>
`debug ${s.id} ${s.name} ${s.status} ${s.stopReason ?? ""}\n` +
(console[s.id] ?? [])
.slice(-40)
.map((l) => ` ${l.text}`)
.join(""),
);
const runs = useRun.getState().sessions.map((s) => `run ${s.id} ${s.config.name} ${s.status} ${s.exitCode ?? ""}`);
const notes = useNotifications.getState().items.map((n) => `${n.level}: ${n.title} ${n.body ?? ""}`);
return [...debug, ...runs, ...notes, `events: ${events.slice(-60).join(", ")}`, server?.log() ?? ""].join("\n");
}
/** Waits for `value` to be truthy; fails with what the tool windows show. */
async function until<T>(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise<T> {
const end = Date.now() + timeoutMs;
for (;;) {
const v = value();
if (v) return v;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}\n${shown()}`);
await new Promise((r) => setTimeout(r, 200));
}
}
/** Requests `url` once the debugged server accepts connections (the response waits for Resume). */
async function requestWhenListening(url: string) {
for (let i = 0; i < 600; i++) {
const connected = await new Promise<boolean>((resolve) => {
const req = get(url, (res) => {
res.resume();
resolve(true);
});
req.on("socket", (socket) => socket.on("connect", () => resolve(true)));
req.on("error", () => resolve(false));
});
if (connected) return;
await new Promise((r) => setTimeout(r, 500));
}
}
const paused = () => {
const s = activeSession();
return s?.status === "paused" && s.frames.length > 0 ? s : null;
};
/** Paused, failing as soon as every session has ended instead. */
const pausedOrEnded = () => {
const sessions = Object.values(useDebug.getState().sessions);
if (sessions.length > 0 && sessions.every((s) => s.status === "terminated"))
throw new Error(`the debugger ended before the breakpoint\n${shown()}`);
return paused();
};
async function smoke(language: string) {
const c = CASES[language]!;
const root = await open(c);
await install(c.debugger);
const path = join(root, c.file);
const content = readFileSync(path, "utf8");
const marker = content.split("\n").findIndex((l) => l.includes("Set a breakpoint here"));
const line = marker + 1;
addBreakpoint(path, line);
await executeAction("run.debugContext", { path, content });
if (c.request) void requestWhenListening(c.request);
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
const top = s.frames[0]!;
expect(top.path).toBe(path);
expect(top.line).toBe(line);
const scopes = await call(commands.debugScopes(s.id, top.id));
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
expect(locals.map((v) => v.name)).toContain(c.variable);
const value = await evaluate(c.evaluate[0], "repl", s);
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
// (and its breakpoint) again.
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
const stops = useDebug.getState().stopSeq;
await executeAction("debug.stepOver");
await until("Step Over", () => useDebug.getState().stopSeq > stops && paused());
}
const after = paused()!;
expect(after.stopReason, shown()).toBe("step");
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
await executeAction("debug.resume");
if (c.request) {
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
await executeAction("debug.stop");
}
// Every session ends (a JVM that listens again as its debugger detaches may be attached once more).
await until("the end", () => Object.values(useDebug.getState().sessions).every((d) => d.status === "terminated"));
}
for (const language of Object.keys(CASES)) {
test(language, () => smoke(language));
}
+7 -11
View File
@@ -3,25 +3,23 @@
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
*/
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { setInvokeRoute } from "@/platform/tauriCore";
import { encodeArgs } from "@/platform/remote";
import { Server } from "./server";
type Callback = (message: unknown) => void;
// One runtime per page, there from the start as in the app: the frontend registers its event
// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it
// across the servers connected one after another.
// One runtime per page, as in the app: the frontend registers its event listeners once
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
const callbacks = new Map<number, Callback>();
const listeners: { event: string; handler: number }[] = [];
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
let nextCallback = 1;
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
let installed = false;
{
function install() {
if (installed) return;
installed = true;
const internals = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
transformCallback(cb: Callback) {
@@ -40,9 +38,6 @@ const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
return listeners.length;
}
if (cmd === "plugin:window|get_all_windows") return [];
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null;
if (cmd === "plugin:path|join") return join(...(args.paths as string[]));
if (cmd.startsWith("plugin:")) return null;
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
},
@@ -55,6 +50,7 @@ const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
export function connectDesktop(server: Server): () => void {
install();
channels.clear();
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
server.onEvent = (target, event) => {
-3
View File
@@ -3,7 +3,6 @@
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { dirname, join } from "node:path";
const PROTOCOL_VERSION = 2;
@@ -36,8 +35,6 @@ export class Server {
private constructor(private readonly child: ChildProcessWithoutNullStreams) {
child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
child.stderr.on("data", (chunk: Buffer) => {
// The whole log, when asked for (OPENIDEA_SMOKE_LOG=<file>; OPENIDEA_LOG sets its level).
if (process.env.OPENIDEA_SMOKE_LOG) appendFileSync(process.env.OPENIDEA_SMOKE_LOG, chunk);
this.stderr.push(chunk.toString());
if (this.stderr.length > 200) this.stderr.shift();
});
+2 -11
View File
@@ -28,7 +28,7 @@ mod windows;
use parking_lot::Mutex;
use state::AppState;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{Emitter, Manager};
use windows::WindowsState;
@@ -40,21 +40,12 @@ pub fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
}
pub fn export_bindings(builder: &tauri_specta::Builder<tauri::Wry>) {
let target = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"));
// Rewritten only when it changes: every debug launch exports, also a second instance that just
// hands its path over, and a rewrite makes Vite reload the running app's windows.
let fresh = std::env::temp_dir().join(format!("openidea-bindings-{}.ts", std::process::id()));
builder
.export(
specta_typescript::Typescript::default().header("// @ts-nocheck\n/* eslint-disable */\n// Generated by tauri-specta. Do not edit; run `pnpm gen:bindings`."),
&fresh,
concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"),
)
.expect("failed to export TypeScript bindings");
let text = std::fs::read(&fresh).expect("exported TypeScript bindings");
let _ = std::fs::remove_file(&fresh);
if std::fs::read(target).ok().as_deref() != Some(text.as_slice()) {
std::fs::write(target, text).expect("failed to write TypeScript bindings");
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
-25
View File
@@ -94,31 +94,6 @@ describe("scanJvmTargets (Java)", () => {
[16, "test", "a.b.OrderTest$Fixture", "inner", false],
]);
});
it("treats the public void methods of a TestNG class-level @Test as tests", () => {
const src = [
"package a.b;", // 0
"import org.testng.annotations.*;",
'@Test(groups = "unit")',
"public class PriceTest {", // 3
" @BeforeMethod public void setUp() {}",
" public void rounds() {}", // 5
' @Test(dataProvider = "rows")',
" public void parses(String row) {}", // 7
" @DataProvider public Object[][] rows() { return null; }",
' @DataProvider(name = "more") public void notATest() {}',
" void packagePrivate() {}",
" private void util() {}",
" public static void shared() {}",
" public int value() { return 1; }",
"}",
].join("\n");
expect(pick(src, "src/test/java/a/b/PriceTest.java")).toEqual([
[3, "suite", "a.b.PriceTest", null, false],
[5, "test", "a.b.PriceTest", "rounds", false],
[7, "test", "a.b.PriceTest", "parses", false],
]);
});
});
describe("scanJvmTargets (Kotlin)", () => {
+1 -12
View File
@@ -89,8 +89,6 @@ function tokenize(code: string): Token[] {
}
const TEST_ANNOTATIONS = new Set(["Test", "ParameterizedTest", "RepeatedTest", "TestFactory", "TestTemplate"]);
/** TestNG methods that set up or feed tests; not tests themselves under a class-level `@Test`. */
const TESTNG_NOT_TESTS = /^(Before|After)\w*$|^(DataProvider|Factory)$/;
const TYPE_KEYWORDS = new Set(["class", "interface", "enum", "record", "object"]);
const NOT_TYPES = new Set(["new", "return", "throw", "else", "case", "assert", "yield", "await", "this", "super"]);
const KOTLIN_DECL = /^(fun|val|var|class|object|interface|enum|data|sealed|abstract|open|private|public|internal|@)$/;
@@ -248,16 +246,7 @@ function scan(src: string, relPath: string, kotlin: boolean) {
const modifiers: string[] = [];
for (let k = i - 1; k >= 0 && !MEMBER_END.has(tokens[k]!.text); k--) modifiers.push(tokens[k]!.text);
const owner = top();
// TestNG `@Test` on a class (JUnit's is method-only): its public `void` methods are tests.
const classLevelTest =
!kotlin &&
owner?.kind === "class" &&
owner.annotations.includes("Test") &&
prev === "void" &&
modifiers.includes("public") &&
!modifiers.includes("static") &&
!annotations.some((a) => TESTNG_NOT_TESTS.test(a));
if ((annotations.some((a) => TEST_ANNOTATIONS.has(a)) || classLevelTest) && owner) {
if (annotations.some((a) => TEST_ANNOTATIONS.has(a)) && owner) {
const className = qualify(binaryName(frames));
out.push({ line: tokens[i]!.line, kind: "test", className, method: name });
for (let f = frames.length; f > 0; f--) {
@@ -10,13 +10,6 @@ describe("parseComparison", () => {
expect(parseComparison(['expected: "a b"', ' but was: "a c"'])).toEqual({ expected: '"a b"', actual: '"a c"' });
});
it("TestNG", () => {
expect(parseComparison(["java.lang.AssertionError: total expected [3] but found [2]"])).toEqual({
expected: "3",
actual: "2",
});
});
it("kotlin.test", () => {
expect(parseComparison(["java.lang.AssertionError: Expected <1>, actual <2>."])).toEqual({
expected: "1",
-2
View File
@@ -21,8 +21,6 @@ export function parseComparison(output: readonly string[]): Comparison | null {
// JUnit / opentest4j / AssertJ / Hamcrest-style: `expected: <x> but was: <y>`.
if ((m = /expected:\s*(<[\s\S]*?>|\S[^\n]*?)\s*but was:\s*(<[\s\S]*?>|[^\n]*)/.exec(text)))
return { expected: unwrap(m[1]!), actual: unwrap(m[2]!) };
// TestNG `Assert.assertEquals`: `expected [x] but found [y]`.
if ((m = /expected \[([\s\S]*?)\] but found \[([\s\S]*?)\]/.exec(text))) return { expected: m[1]!, actual: m[2]! };
// kotlin.test: `Expected <x>, actual <y>.`
if ((m = /Expected <([\s\S]*?)>, actual <([\s\S]*?)>/.exec(text))) return { expected: m[1]!, actual: m[2]! };
// Rust `assert_eq!`: ` left: x` / ` right: y` (the macro doesn't say which one is expected).
+1 -1
View File
@@ -21,7 +21,7 @@ const SUREFIRE_CLASS_RE =
const SUREFIRE_TOTAL_RE = /^(?:\[\w+\]\s+)?Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)\s*$/;
// `at org.junit…`, also behind a class loader or module (`at app//org.junit…`, `at java.base@21/java.lang…`).
const LIBRARY_FRAME_RE =
/^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|org\.testng|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/;
/^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/;
/** The source position of a stack frame in the project's code, null for library frames. */
function frameLocation(line: string): TestLocation | null {
-18
View File
@@ -471,24 +471,6 @@ org.opentest4j.AssertionFailedError: expected: <3> but was: <2>
expect(r.get("com.acme.UserTest")?.own).toBe("passed");
expect(r.summary).toMatchObject({ passed: 2, failed: 1 });
});
it("locates a TestNG failure in the test, past TestNG's own frames", () => {
const r = results(
jvmParser(),
`[INFO] Running com.acme.PriceTest
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.2 s <<< FAILURE! -- in com.acme.PriceTest
[ERROR] com.acme.PriceTest.rounds -- Time elapsed: 0.01 s <<< FAILURE!
java.lang.AssertionError: expected [3] but found [2]
at org.testng.Assert.fail(Assert.java:111)
at org.testng.Assert.assertEquals(Assert.java:1080)
at com.acme.PriceTest.rounds(PriceTest.java:12)
`,
);
expect(leaf(r, "com.acme.PriceTest", "rounds")?.location).toMatchObject({
path: "com/acme/PriceTest.java",
line: 12,
});
});
});
describe("parser selection", () => {
@@ -15,7 +15,7 @@ import {
type InspectionNode,
} from "../pages/editor/inspectionsModel";
import { splitPageTarget } from "../model/staging";
import { JAVA_SERIAL_VERSION_INSPECTION, validateInspections } from "@/platform/inspections";
import { validateInspections } from "@/platform/inspections";
import { NATURAL_LANGUAGES_PAGE, SPELLING_PAGE } from "../pages/editor/spelling";
import { INSPECTIONS_PAGE } from "../pages/editor/inspections";
import { EDITOR_PAGES } from "../pages/editor";
@@ -69,7 +69,6 @@ describe("inspections tree", () => {
expect(tree.map((g) => g.name)).toEqual([
"Cargo",
"General",
"Java",
"JavaScript",
"Other",
"Proofreading",
@@ -110,19 +109,6 @@ describe("inspections tree", () => {
expect(checkState(o, python)).toBe("on");
});
it("keeps an inspection that is off by default off until turned on", () => {
const serial = find(tree, `Java/${JAVA_SERIAL_VERSION_INSPECTION}`)!;
expect(checkState({}, serial)).toBe("off");
let o = setEnabled({}, serial, true);
expect(o).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
expect(checkState(o, serial)).toBe("on");
o = setEnabled(o, serial, false);
expect(o).toEqual({});
// Turning a whole group on or off only records what differs from each default.
const java = find(tree, "group:Java")!;
expect(setEnabled({}, java, true)).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
});
it("keeps severity and enabled overrides independent", () => {
let o = setLevel({}, "lsp:ruff", "error");
o = setEnabled(o, find(tree, "Python/lsp:ruff")!, false);
@@ -8,7 +8,6 @@ import {
INSPECTION_LEVELS,
codeInspectionId,
customProfileId,
enabledByDefault,
newProfileKey,
overridesOf,
producerInspectionId,
@@ -160,7 +159,7 @@ export function inspectionIds(node: InspectionNode): string[] {
return [...(node.id ? [node.id] : []), ...node.children.flatMap(inspectionIds)];
}
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled ?? enabledByDefault(id);
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled !== false;
export type CheckState = "on" | "off" | "mixed";
@@ -182,12 +181,9 @@ function withOverride(o: ProfileOverrides, id: string, patch: Partial<Inspection
return next;
}
/** Turns a node and all its descendants on or off; an inspection back at its default drops the override. */
/** Turns a node and all its descendants on or off (on is the default, so it removes the override). */
export function setEnabled(o: ProfileOverrides, node: InspectionNode, enabled: boolean): ProfileOverrides {
return inspectionIds(node).reduce(
(acc, id) => withOverride(acc, id, { enabled: enabled === enabledByDefault(id) ? undefined : enabled }),
o,
);
return inspectionIds(node).reduce((acc, id) => withOverride(acc, id, { enabled: enabled ? undefined : false }), o);
}
/** `undefined` restores the default ("As reported"). */
+6 -59
View File
@@ -1,29 +1,19 @@
/**
* Push dialog: the files changed by the outgoing commits (the selected commit's, or all of
* them), with the selected file's diff beside the tree. The diff stays inside the dialog,
* which is modal. A single commit also shows its message, author and time.
* which is modal.
*/
import { useEffect, useMemo, useState } from "react";
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { Button, IconButton, Spinner, T } from "@/ui";
import { Button, Spinner, T } from "@/ui";
import { ComparisonTree } from "./ComparisonTree";
import { buildFileTree } from "./fileTree";
import { flatFileNodes, toLogChanges, type LogChange } from "./logChanges";
import { KIND_COLOR, dirName, fileCount } from "./model";
import { toLogChanges, type LogChange } from "./logChanges";
import { KIND_COLOR, fileCount } from "./model";
import styles from "./common.module.css";
const GROUP_KEY = "openidea.vcs.push.groupByDirectory";
function readGroupByDirectory(): boolean {
try {
return localStorage.getItem(GROUP_KEY) !== "false";
} catch {
return true;
}
}
/** Identifies what `PushChanges` shows; render it with this `key` so a new selection starts afresh. */
export const pushChangesKey = (repo: string | null, commits: readonly Commit[]) =>
`${repo ?? ""}:${commits.map((c) => c.id).join(" ")}`;
@@ -44,7 +34,6 @@ export function PushChanges({
const [changes, setChanges] = useState<LogChange[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [current, setCurrent] = useState<LogChange | null>(null);
const [byDirectory, setByDirectory] = useState(readGroupByDirectory);
useEffect(() => {
if (!commits.length) return;
@@ -61,18 +50,7 @@ export function PushChanges({
};
}, [repo, commits]);
const tree = useMemo(
() => (!changes ? [] : byDirectory ? buildFileTree(changes, (f) => f.path) : flatFileNodes(changes, (f) => f.path)),
[changes, byDirectory],
);
const toggleGrouping = () => {
setByDirectory(!byDirectory);
try {
localStorage.setItem(GROUP_KEY, String(!byDirectory));
} catch {
/* kept for this dialog only */
}
};
const tree = useMemo(() => (changes ? buildFileTree(changes, (f) => f.path) : []), [changes]);
const count = commits.length;
if (!count) return null;
return (
@@ -87,16 +65,7 @@ export function PushChanges({
<T>Show All Commits</T>
</Button>
)}
<IconButton
icon="folder"
size="small"
title={tx("Group by Directory")}
pressed={byDirectory}
onClick={toggleGrouping}
className={styles.pushGrouping}
/>
</div>
{count === 1 && <PushCommitDetails commit={commits[0]!} />}
{error ? (
<div className={styles.error}>{error}</div>
) : !changes ? (
@@ -111,31 +80,9 @@ export function PushChanges({
diffFor={(c) => c.diff}
getColor={(c) => KIND_COLOR[c.kind === "untracked" ? "added" : c.kind]}
ariaLabel="Changed files"
renderDetails={(n) =>
n.file ? (
byDirectory ? null : (
<span className={styles.fileDir}>{dirName(n.file.path)}</span>
)
) : (
<span className={styles.fileDir}>{n.count}</span>
)
}
renderDetails={(n) => (n.file ? null : <span className={styles.fileDir}>{n.count}</span>)}
/>
)}
</div>
);
}
/** The selected commit's full message, revision, author and time. */
function PushCommitDetails({ commit }: { commit: Commit }) {
const date = commit.timeMs ? new Date(commit.timeMs).toLocaleString() : null;
return (
<section className={styles.pushCommitDetails} aria-label={tx("Commit details")}>
<div className={styles.pushCommitMessage}>{commit.message.trimEnd() || commit.summary}</div>
<div className={styles.fileDir}>
{commit.shortId} {commit.author} &lt;{commit.email}&gt;
{date ? " " + tx`on ${date}` : ""}
</div>
</section>
);
}
@@ -12,16 +12,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
import { PushChanges } from "../PushChanges";
const commit = (id: string, parent: string): Commit =>
({
id,
shortId: id.slice(0, 7),
parents: [parent],
summary: id,
message: id,
author: "me",
email: "[email protected]",
timeMs: 0,
}) as unknown as Commit;
({ id, shortId: id.slice(0, 7), parents: [parent], summary: id, author: "me", timeMs: 0 }) as unknown as Commit;
const newer = commit("bbbbbbbbbb", "aaaaaaaaaa");
const older = commit("aaaaaaaaaa", "9999999999");
@@ -50,36 +41,4 @@ describe("Push dialog changes", () => {
fireEvent.click(screen.getByRole("button", { name: "Show All Commits" }));
expect(showAll).toHaveBeenCalled();
});
it("shows the selected commit's message and author, and lists files flat on request", async () => {
localStorage.clear();
files.mockResolvedValue({
status: "ok",
data: [{ path: "src/orders/service.py", oldPath: null, kind: "added" }],
});
const one = { ...older, message: "Add order service\n\nIntroduces the service.\n", timeMs: 1_700_000_000_000 };
render(<PushChanges repo="/r" commits={[one]} />);
const details = screen.getByRole("region", { name: "Commit details" });
expect(details.textContent).toContain("Add order service\n\nIntroduces the service.");
expect(details.textContent).toContain("aaaaaaa me <[email protected]> on ");
expect(await screen.findByText("src/orders")).toBeTruthy();
expect(screen.getAllByRole("treeitem")).toHaveLength(2);
const grouping = screen.getByRole("button", { name: "Group by Directory" });
expect(grouping.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(grouping);
expect(grouping.getAttribute("aria-pressed")).toBe("false");
// Flat: one row, the file, with its directory beside the name.
const rows = screen.getAllByRole("treeitem");
expect(rows).toHaveLength(1);
expect(rows[0]!.textContent).toContain("service.py");
expect(rows[0]!.textContent).toContain("src/orders");
expect(localStorage.getItem("openidea.vcs.push.groupByDirectory")).toBe("false");
});
it("shows no commit details for several commits", async () => {
combined.mockResolvedValue({ status: "ok", data: [] });
render(<PushChanges repo="/r" commits={[newer, older]} />);
expect(await screen.findByText(/0 files in 2 commits/)).toBeTruthy();
expect(screen.queryByRole("region", { name: "Commit details" })).toBeNull();
});
});
-21
View File
@@ -143,27 +143,6 @@
padding: 0 14px 6px;
color: var(--fg-subtle);
}
.pushGrouping {
margin-left: auto;
}
.pushCommitDetails {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 96px;
margin: 0 14px 8px;
padding: 6px 8px;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-small);
-webkit-user-select: text;
user-select: text;
}
.pushCommitMessage {
color: var(--fg-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.todoList {
margin: 8px 0 0;
padding-left: 18px;
+1 -21
View File
@@ -216,14 +216,9 @@ export interface BuiltinInspection {
description: string;
/** Highlighting level when the profile sets none (else as reported). */
defaultLevel?: InspectionLevel;
/** `false`: off until a profile turns it on. */
defaultEnabled?: boolean;
options?: readonly InspectionOptionSpec[];
}
/** Turned on, the Java language server's compiler reports it (`ide-sdk` `SERIAL_VERSION_INSPECTION`). */
export const JAVA_SERIAL_VERSION_INSPECTION = "java:missing-serial-version";
/** Producer key of the Typo inspection's diagnostics. */
export const SPELLING_PRODUCER = "spelling";
export const SPELLING_INSPECTION = "builtin:spelling";
@@ -288,16 +283,6 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
group: "General",
description: "Compiler errors and warnings of the last Build Project, shown at their source positions.",
},
{
id: JAVA_SERIAL_VERSION_INSPECTION,
producer: "jdt-serial-version",
name: "Serializable class without 'serialVersionUID'",
group: "Java",
description:
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.",
defaultLevel: "warning",
defaultEnabled: false,
},
{
id: "builtin:sql",
producer: "sql-inspections",
@@ -325,11 +310,6 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
const BUILTIN_BY_PRODUCER = new Map(BUILTIN_INSPECTIONS.map((b) => [b.producer, b]));
const BUILTIN_BY_ID = new Map(BUILTIN_INSPECTIONS.map((b) => [b.id, b]));
/** Whether inspection `id` is on when a profile doesn't say. */
export function enabledByDefault(id: string): boolean {
return BUILTIN_BY_ID.get(id)?.defaultEnabled ?? true;
}
/** Option `key` of inspection `id` under `profile` (its default when unset). */
export function inspectionOption(profile: ProfileOverrides, id: string, key: string): boolean {
const value = profile[id]?.options?.[key];
@@ -383,7 +363,7 @@ export function applyProfile<D extends Diagnostic>(
): readonly D[] {
const base = producerInspectionId(producer);
const own = profile[base];
if (!(own?.enabled ?? enabledByDefault(base))) return [];
if (own?.enabled === false) return [];
const fallback = BUILTIN_BY_PRODUCER.get(producer)?.defaultLevel;
let changed = false;
const out: D[] = [];
-3
View File
@@ -32,8 +32,6 @@
"Not found": "未找到",
"Not found on PATH": "未在 PATH 中找到",
"Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。",
"Serializable class without 'serialVersionUID'": "没有 'serialVersionUID' 的可序列化类",
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.": "报告实现了 Serializable 但没有声明 serialVersionUID 字段的类。在类名上按 Alt+Enter 可添加默认 ID 或生成的 ID。此检查由 Java 语言服务器执行,更改后语言服务器会重启。",
"Run `auggie login` in a terminal.": "在终端中运行 `auggie login`。",
"Run `claude` in a terminal and sign in.": "在终端中运行 `claude` 并登录。",
"Run `codex login` in a terminal.": "在终端中运行 `codex login`。",
@@ -477,7 +475,6 @@
"Comment": "评论",
"Comment:": "注释:",
"Commit": "提交",
"Commit details": "提交详细信息",
"Commit Anyway": "仍然提交",
"Commit Changelist…": "提交更改列表…",
"Commit Checks": "提交检查",