Compare commits

...
Author SHA1 Message Date
l0ng-ai 6694c8ea17 fix(dev): rewrite src/bindings.ts only when the bindings change
Every launch of the debug binary exports the TypeScript bindings, including a second instance that only hands its path to the running one. Writing an unchanged file still made Vite reload the running app's windows, so opening a file from outside in a dev build reloaded the window, which lost a LightEdit session and reopened the last project. The bindings are now exported to a temporary file and src/bindings.ts is replaced only when its content differs; `pnpm gen:bindings` behaves as before.
2026-11-05 09:05:19 +08:00
l0ng-ai 01e09565b7 feat(run): recognize TestNG classes annotated with @Test (#1403)
TestNG's class-level @Test makes the class's public void methods tests; the scanner now recognizes them (excluding @Before*/@After*/@DataProvider/@Factory). Test results locate TestNG failures past org.testng frames and parse TestNG's comparison message. Refs #1368
2026-11-05 08:59:49 +08:00
l0ng-ai 1f70b134de feat(vcs): show the selected commit's details in the Push dialog (#1402)
Selecting one outgoing commit shows its message, revision, author and time above its files; a Group by Directory toggle switches the files between tree and flat list and is remembered. Fixes #1373
2026-11-05 08:57:19 +08:00
l0ng-ai 11fd64f399 feat(java): add the "Serializable class without 'serialVersionUID'" inspection (#1401)
Settings › Editor › Inspections › Java gets the inspection, off by default. When on, jdtls's compiler reports the missing field and offers the add default/generated ID quick fixes. Built-in inspections can now be off by default, the backend resolves the profile in effect, the compiler-defaults file is named after the variant so jdtls restarts with it, and saving inspection settings reconfigures language servers. Fixes #1199
2026-11-05 08:50:59 +08:00
l0ng-ai 8a884b6362 test(project): smoke-test opening each language's fixture (#1398)
Opens each fixtures/ project through the ops the app's commands call and checks the source and test roots from its build file, the index, Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files come and go, and the Maven / Gradle model. Ignored tests run a Maven and a Gradle sync and check virtualenv and JDK detection; the Language smoke java, kotlin and python jobs run them.
2026-11-05 08:49:08 +08:00
l0ng-ai 481d44930f fix(themes): add --syn-global to the Harbor Night scheme and the token reference (#1400)
#1393 added the --syn-global token to tokens.css but not to the Harbor Night scheme (the theme template) or the generated token reference in docs/plugins/themes.md, so the theme tests failed. The scheme gives it the --syn-variable colour, as tokens.css does.
2026-11-05 08:48:39 +08:00
23 changed files with 651 additions and 27 deletions

No files matched your search

+8
View File
@@ -2,6 +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,
@@ -107,6 +109,12 @@ 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
@@ -36,6 +36,23 @@ 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")]
@@ -111,4 +128,20 @@ 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"));
}
}
+6 -1
View File
@@ -175,7 +175,12 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
// 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 toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
+1
View File
@@ -209,6 +209,7 @@ 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
@@ -0,0 +1,285 @@
//! 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:?}");
}
+71 -8
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::{LanguageServerSettings, Settings};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
@@ -442,15 +442,41 @@ 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.
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
/// 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) {
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(DEFAULTS) {
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
ide_vfs::write_atomic(&file, content.as_bytes())?;
}
Ok(())
};
@@ -1095,13 +1121,50 @@ 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);
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
let options = options(&cfg);
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
assert!(path.starts_with(&cache));
@@ -1112,7 +1175,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);
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
assert_eq!(self::options(&cfg), options);
}
@@ -1122,7 +1185,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());
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
for settings in [
-1
View File
@@ -369,7 +369,6 @@ 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);
+2 -1
View File
@@ -318,7 +318,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
223 themeable tokens: 117 in the UI theme file, 106 in the editor colour scheme file.
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -420,6 +420,7 @@ 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` |
@@ -18,6 +18,7 @@
"--syn-number": "#7fc8f8",
"--syn-punct": "#a9b4c2",
"--syn-variable": "#d9dfe8",
"--syn-global": "#d9dfe8",
"--syn-param": "#d9dfe8",
"--syn-property": "#b9a3f0",
"--syn-constant": "#e58fa3",
+11 -2
View File
@@ -28,7 +28,7 @@ mod windows;
use parking_lot::Mutex;
use state::AppState;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::{Emitter, Manager};
use windows::WindowsState;
@@ -40,12 +40,21 @@ 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`."),
concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"),
&fresh,
)
.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,6 +94,31 @@ 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)", () => {
+12 -1
View File
@@ -89,6 +89,8 @@ 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|@)$/;
@@ -246,7 +248,16 @@ 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();
if (annotations.some((a) => TEST_ANNOTATIONS.has(a)) && owner) {
// 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) {
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,6 +10,13 @@ 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,6 +21,8 @@ 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|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/;
/^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|org\.testng|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,6 +471,24 @@ 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 { validateInspections } from "@/platform/inspections";
import { JAVA_SERIAL_VERSION_INSPECTION, 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,6 +69,7 @@ describe("inspections tree", () => {
expect(tree.map((g) => g.name)).toEqual([
"Cargo",
"General",
"Java",
"JavaScript",
"Other",
"Proofreading",
@@ -109,6 +110,19 @@ 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,6 +8,7 @@ import {
INSPECTION_LEVELS,
codeInspectionId,
customProfileId,
enabledByDefault,
newProfileKey,
overridesOf,
producerInspectionId,
@@ -159,7 +160,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 !== false;
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled ?? enabledByDefault(id);
export type CheckState = "on" | "off" | "mixed";
@@ -181,9 +182,12 @@ function withOverride(o: ProfileOverrides, id: string, patch: Partial<Inspection
return next;
}
/** Turns a node and all its descendants on or off (on is the default, so it removes the override). */
/** Turns a node and all its descendants on or off; an inspection back at its default drops the override. */
export function setEnabled(o: ProfileOverrides, node: InspectionNode, enabled: boolean): ProfileOverrides {
return inspectionIds(node).reduce((acc, id) => withOverride(acc, id, { enabled: enabled ? undefined : false }), o);
return inspectionIds(node).reduce(
(acc, id) => withOverride(acc, id, { enabled: enabled === enabledByDefault(id) ? undefined : enabled }),
o,
);
}
/** `undefined` restores the default ("As reported"). */
+59 -6
View File
@@ -1,19 +1,29 @@
/**
* 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.
* which is modal. A single commit also shows its message, author and time.
*/
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, Spinner, T } from "@/ui";
import { Button, IconButton, Spinner, T } from "@/ui";
import { ComparisonTree } from "./ComparisonTree";
import { buildFileTree } from "./fileTree";
import { toLogChanges, type LogChange } from "./logChanges";
import { KIND_COLOR, fileCount } from "./model";
import { flatFileNodes, toLogChanges, type LogChange } from "./logChanges";
import { KIND_COLOR, dirName, 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(" ")}`;
@@ -34,6 +44,7 @@ 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;
@@ -50,7 +61,18 @@ export function PushChanges({
};
}, [repo, commits]);
const tree = useMemo(() => (changes ? buildFileTree(changes, (f) => f.path) : []), [changes]);
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 count = commits.length;
if (!count) return null;
return (
@@ -65,7 +87,16 @@ 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 ? (
@@ -80,9 +111,31 @@ export function PushChanges({
diffFor={(c) => c.diff}
getColor={(c) => KIND_COLOR[c.kind === "untracked" ? "added" : c.kind]}
ariaLabel="Changed files"
renderDetails={(n) => (n.file ? null : <span className={styles.fileDir}>{n.count}</span>)}
renderDetails={(n) =>
n.file ? (
byDirectory ? null : (
<span className={styles.fileDir}>{dirName(n.file.path)}</span>
)
) : (
<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,7 +12,16 @@ 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, author: "me", timeMs: 0 }) as unknown as Commit;
({
id,
shortId: id.slice(0, 7),
parents: [parent],
summary: id,
message: id,
author: "me",
email: "[email protected]",
timeMs: 0,
}) as unknown as Commit;
const newer = commit("bbbbbbbbbb", "aaaaaaaaaa");
const older = commit("aaaaaaaaaa", "9999999999");
@@ -41,4 +50,36 @@ 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,6 +143,27 @@
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;
+21 -1
View File
@@ -216,9 +216,14 @@ 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";
@@ -283,6 +288,16 @@ 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",
@@ -310,6 +325,11 @@ 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];
@@ -363,7 +383,7 @@ export function applyProfile<D extends Diagnostic>(
): readonly D[] {
const base = producerInspectionId(producer);
const own = profile[base];
if (own?.enabled === false) return [];
if (!(own?.enabled ?? enabledByDefault(base))) return [];
const fallback = BUILTIN_BY_PRODUCER.get(producer)?.defaultLevel;
let changed = false;
const out: D[] = [];
+3
View File
@@ -32,6 +32,8 @@
"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`。",
@@ -475,6 +477,7 @@
"Comment": "评论",
"Comment:": "注释:",
"Commit": "提交",
"Commit details": "提交详细信息",
"Commit Anyway": "仍然提交",
"Commit Changelist…": "提交更改列表…",
"Commit Checks": "提交检查",