Compare commits

...
Author SHA1 Message Date
l0ng-ai dc7109526b fix(vfs): preserve removals of files discovered by scans 2026-11-05 09:47:22 +08:00
l0ng-ai de0f9d6ecd feat(database): global data sources listed in every project (#1407)
Data sources can be made global (Global option in their properties): stored in data-sources.toml of the app data directory, listed in every project, with their saved passwords under an app-wide key that moves along when the scope changes. Fixes #1252
2026-11-05 09:24:23 +08:00
l0ng-ai 6d55eb4aa9 fix(plugins): hash package paths with forward slashes on every OS (#1315)
package_hash fed each package-relative path to the digest with
to_string_lossy, so a walked path hashed as `web\index.html` on Windows
and `web/index.html` elsewhere. The same package had two digests: a
market index built on Linux failed verification on Windows, and a
package signed on one OS did not verify on another.

Join path components with `/` before hashing. Digests on macOS and
Linux are unchanged byte for byte; installed plugins keep their recorded
.package-hash, and a recomputed digest is accepted as a silent update by
the grant ledger, so existing Windows installs are not re-prompted.

Closes #1258
2026-11-05 09:20:42 +08:00
l0ng-ai a247f1ce67 feat(vcs): draw lane changes in the Git log graph as straight diagonals (#1406)
Lane changes bent within half a row as S-curves. A lane change and the vertical half of the neighbouring row that continues it now meet halfway between the lanes, so edges between adjacent rows run straight from node to node; only drawn positions change. Fixes #800
2026-11-05 09:12:41 +08:00
l0ng-ai f16ec29f4a fix(dev): rewrite src/bindings.ts only when the bindings change (#1404)
Debug launches (also second instances that only hand over a path) exported the bindings and rewrote an unchanged src/bindings.ts, which made Vite reload the running app's windows. The bindings now go to a temporary file first and the source file is replaced only when it differs.
2026-11-05 09:05:42 +08:00
ntbang0901 02d30b1609 fix(editor): keep the caret and view when a file changes in distant places (#1329)
Document::diff only trimmed the common prefix and suffix, so two distant
edits on disk became one change spanning everything between them. The
caret and scroll position inside that span were mapped to its start,
which made the editor jump to the first edit after an agent, formatter
or git operation rewrote the file.

Diff the changed middle line by line (Myers via similar, 50 ms deadline
with a coarser fallback) and emit one change per edited region, trimmed
to its differing characters. The frontend DocSync path produces the same
per-region changes, so external reloads, local history restores and
formatting keep the caret and view in place.

Closes #1326

Co-authored-by: ntbang0901 <[email protected]>
2026-11-05 09:04:48 +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
l0ng-ai f5895d0336 test(debug): smoke-test debugging each language's fixture (#1357)
Debugs each language's fixture the way the editor does, against openidea-server and the debugger the IDE installs: a breakpoint on the entry point's marker line, Debug from the file's context, then the paused frame, its variables, Evaluate Expression, Step Over and Resume. Runs per language in the Language smoke workflow. The build model is loaded first, as the Build Tools feature does, so a Gradle Application is debugged through Gradle; Step Over allows a repeated stop on a line (Python 3.12 reports a conditional expression's line twice).
2026-11-05 08:32:11 +08:00
l0ng-ai c3c1d88d10 fix(vcs): let Commit and Shelf tree names use the whole row width (#1396)
Tree names in the Commit and Shelf windows were capped at 75% of the row, so long folder names were truncated with empty space left over. Names now take the whole row and the grey detail after them is truncated first. Fixes #1367
2026-11-05 08:25:51 +08:00
54 changed files with 1634 additions and 257 deletions

No files matched your search

+15 -1
View File
@@ -2,8 +2,12 @@ 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.
# (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.
on:
push:
branches: [main]
@@ -17,6 +21,7 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/features/debugger/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
@@ -104,7 +109,16 @@ 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,6 +71,9 @@ 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
Generated
+2 -23
View File
@@ -2163,15 +2163,6 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -4634,6 +4625,7 @@ dependencies = [
"proptest",
"ropey",
"serde",
"similar",
"specta",
]
@@ -4690,7 +4682,7 @@ dependencies = [
"globset",
"ide-core",
"ignore",
"notify-debouncer-full",
"notify",
"parking_lot",
"serde",
"serde_json",
@@ -5637,19 +5629,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-full"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "2.1.0"
-1
View File
@@ -76,7 +76,6 @@ ignore = "0.4"
base64 = "0.22"
globset = "0.4"
notify = "8"
notify-debouncer-full = "0.6"
trash = "5"
blake3 = "1"
dunce = "1"
@@ -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"));
}
}
+113 -15
View File
@@ -213,6 +213,8 @@ pub struct DbManager {
/// App-wide SSH connections and host-key trust.
ssh: Arc<SshManager>,
saved_passwords: SecretStore,
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
global: Option<(PathBuf, SecretStore)>,
history: Arc<HistoryStore>,
/// Records executed statements in the history off the async threads, in order.
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
@@ -339,6 +341,7 @@ impl DbManager {
}
Self {
saved_passwords: SecretStore::new(passwords, &root),
global: None,
history,
recorder,
root,
@@ -351,8 +354,42 @@ impl DbManager {
}
}
/// Also lists the global data sources stored in `dir` (the app data directory).
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
let passwords = self.saved_passwords.scoped(&dir);
self.global = Some((dir, passwords));
self
}
/// The project's data sources, then the global ones (a project one wins on the same id).
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
source::load(&self.root)
let mut all = source::load(&self.root)?;
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
all.extend(global);
Ok(all)
}
fn global_sources(&self) -> Vec<DataSource> {
let Some((dir, _)) = &self.global else { return Vec::new() };
source::load_global(dir).unwrap_or_else(|e| {
tracing::warn!("global data sources: {}", e.message);
Vec::new()
})
}
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
_ => &self.saved_passwords,
}
}
fn store_of(&self, source: &DataSource) -> &SecretStore {
match &self.global {
Some((_, store)) if source.global => store,
_ => &self.saved_passwords,
}
}
/// The connection settings of target `target` ([`DataSource::resolved`]), with
@@ -372,18 +409,43 @@ impl DbManager {
}
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
let old = self.sources().unwrap_or_default();
source::save(&self.root, &sources)?;
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
}
source::save(&self.root, &project)?;
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
gone_or_changed => {
if gone_or_changed.is_none() {
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
self.session_passwords.lock().remove(&k);
self.saved_passwords.delete(&k);
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
match gone_or_changed {
None => {
for k in &keys {
self.session_passwords.lock().remove(k);
self.store_of(&o).delete(k);
}
}
// Made global or project-level: its saved passwords move along.
Some(n) if n.global != o.global => {
for k in &keys {
if let Some(p) = self.store_of(&o).get(k) {
self.store_of(n).set(k, &p)?;
self.store_of(&o).delete(k);
}
}
}
Some(_) => {}
}
self.disconnect_source(&o.id).await;
}
@@ -395,7 +457,7 @@ impl DbManager {
/// Remembers `password` for the session, and in the saved passwords when
/// the data source saves passwords (`None` forgets it).
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
self.remember(id.to_owned(), password, save).await
self.remember(id, id.to_owned(), password, save).await
}
/// [`DbManager::set_password`] for the SSH password / key passphrase.
@@ -407,10 +469,10 @@ impl DbManager {
self.ssh.set_secret(&c, secret, save);
return Ok(());
}
self.remember(secrets::ssh_key(id), secret, save).await
self.remember(id, secrets::ssh_key(id), secret, save).await
}
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
match &secret {
Some(p) => {
self.session_passwords.lock().insert(key.clone(), p.clone());
@@ -419,10 +481,11 @@ impl DbManager {
self.session_passwords.lock().remove(&key);
}
}
let store = self.saved_for(id);
let result = match (secret, save) {
(Some(p), true) => self.saved_passwords.set(&key, &p),
(Some(p), true) => store.set(&key, &p),
_ => {
self.saved_passwords.delete(&key);
store.delete(&key);
Ok(())
}
};
@@ -433,18 +496,18 @@ impl DbManager {
Ok(())
}
async fn secret(&self, key: String, saved: bool) -> Option<String> {
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
if let Some(p) = self.session_passwords.lock().get(&key) {
return Some(p.clone());
}
if !saved {
return None;
}
self.saved_passwords.get(&key)
self.store_of(source).get(&key)
}
async fn password(&self, source: &DataSource) -> Option<String> {
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
Some(p) => Some(p),
None => source.url_password(),
}
@@ -500,7 +563,7 @@ impl DbManager {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
};
let target = SshTarget {
host: ssh.host.clone(),
@@ -1099,6 +1162,41 @@ impl DbManager {
mod tests {
use super::*;
#[tokio::test]
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
let tmp = tempfile::tempdir().unwrap();
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
let manager = |root: &std::path::Path| {
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
.with_global_sources(app.clone())
};
let (a, b) = (manager(&one), manager(&two));
let source = |id: &str, global: bool| DataSource {
id: id.into(),
name: id.into(),
driver: DbDriver::Postgres,
save_password: true,
global,
..Default::default()
};
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
a.set_password("shared", Some("pw".into()), true).await.unwrap();
// Made global: listed in the other project too, with its saved password.
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
assert_eq!(ids(&b), [("shared".into(), true)]);
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
// Made project-level again: gone from the other project, the password stays with it.
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
assert!(b.sources().unwrap().is_empty());
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
}
#[tokio::test]
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
let tmp = tempfile::tempdir().unwrap();
+5
View File
@@ -28,6 +28,11 @@ impl SecretStore {
Self { path, project: root.to_string_lossy().into_owned() }
}
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
pub fn scoped(&self, scope: &Path) -> Self {
Self::new(self.path.clone(), scope)
}
pub fn get(&self, key: &str) -> Option<String> {
let _g = LOCK.lock();
self.read().remove(&self.project)?.remove(key)
+42 -10
View File
@@ -1,6 +1,7 @@
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
//! only in memory for the session.
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
//! the others only in memory for the session.
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
use serde::{Deserialize, Serialize};
@@ -181,6 +182,9 @@ pub struct DataSource {
pub options: DbOptions,
pub introspection: DbIntrospection,
pub properties: Vec<DbProperty>,
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
}
impl Default for DataSource {
@@ -209,6 +213,7 @@ impl Default for DataSource {
options: DbOptions::default(),
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
}
}
}
@@ -397,9 +402,32 @@ struct SourcesFile {
sources: Vec<DataSource>,
}
/// The project's data sources.
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
let path = project_dir::path(root, FILE);
let text = match std::fs::read_to_string(&path) {
read(&project_dir::path(root, FILE))
}
/// Saves the project's data sources.
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&project_dir::path(root, FILE), sources)
}
/// The global data sources stored in `dir` (the app data directory).
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
let mut sources = read(&dir.join(FILE))?;
for s in &mut sources {
s.global = true;
}
Ok(sources)
}
/// Saves the global data sources in `dir`.
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&dir.join(FILE), sources)
}
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
@@ -408,19 +436,23 @@ pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
Ok(file.sources)
}
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
for s in sources {
if s.id.is_empty() || s.name.trim().is_empty() {
return Err(IdeError::invalid("every data source needs an id and a name"));
}
}
let path = project_dir::path(root, FILE);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
.map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(path, text)?;
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
table.remove("global");
}
}
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
Ok(())
}
+23 -1
View File
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
sorted.dedup();
let mut hasher = blake3::Hasher::new();
for rel in sorted {
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(hashed_path(rel).as_bytes());
hasher.update(&[0]);
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
hasher.update(&[0]);
@@ -372,6 +372,12 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
Ok(hasher.finalize().to_hex().to_string())
}
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
fn hashed_path(rel: &Path) -> String {
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -480,4 +486,20 @@ mod tests {
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
}
#[test]
fn hashes_paths_with_forward_slashes_on_every_os() {
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
#[cfg(windows)]
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("web")).unwrap();
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
// A walked path (native separators) hashes like the manifest's `/` spelling.
assert_eq!(
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
);
}
}
+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}.")));
+10 -6
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,
@@ -519,12 +520,15 @@ impl Host {
root: &Path,
project_key: &str,
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
let db = Arc::new(ide_db::DbManager::new(
root.to_path_buf(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
self.ssh.clone(),
));
let db = Arc::new(
ide_db::DbManager::new(
root.to_path_buf(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
self.ssh.clone(),
)
.with_global_sources(self.data_dir.clone()),
);
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key)));
let agents = Arc::new(ide_agent::AgentManager::new(
ide_agent::AgentPaths {
+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);
+1
View File
@@ -12,6 +12,7 @@ ide-core.workspace = true
# Hence no `unicode_lines` / `cr_lines` (a test guards against feature unification).
ropey = { version = "1.6", default-features = false, features = ["simd"] }
serde.workspace = true
similar = { version = "2.7", default-features = false }
specta.workspace = true
[dev-dependencies]
+4
View File
@@ -73,6 +73,10 @@ fn bench(c: &mut Criterion) {
BatchSize::SmallInput,
)
});
let two_edits = text.replacen("value_100 ", "renamed_100 ", 1).replacen("value_9000 ", "renamed_9000 ", 1);
c.bench_function("text/diff_two_edits_1mb", |b| b.iter(|| base.diff(black_box(&two_edits))));
let every_line = text.replace("compute", "calculate");
c.bench_function("text/diff_every_line_1mb", |b| b.iter(|| base.diff(black_box(&every_line))));
c.bench_function("text/offset_to_pos_round_trip", |b| {
let mut rng = Lcg(3);
let len = base.len_utf16();
+124 -31
View File
@@ -14,6 +14,7 @@ use ide_core::{ChangeSet, IdeError, IdeResult, Pos, Range, TextChange, TextEdit}
use serde::{Deserialize, Serialize};
use specta::Type;
use std::borrow::Cow;
use std::time::{Duration, Instant};
pub use ropey;
@@ -66,6 +67,36 @@ impl LineEnding {
}
}
/// How long [`Document::diff`] may look for separate regions, so a reload never stalls on a huge file.
const DIFF_BUDGET: Duration = Duration::from_millis(50);
type LineRange = std::ops::Range<usize>;
/// Byte lengths of the common prefix and suffix; they never overlap.
fn common_ends(a: &str, b: &str) -> (usize, usize) {
let shared = |a: &mut dyn Iterator<Item = char>, b: &mut dyn Iterator<Item = char>| -> usize {
a.zip(b).take_while(|(x, y)| x == y).map(|(x, _)| x.len_utf8()).sum()
};
let prefix = shared(&mut a.chars(), &mut b.chars());
let suffix = shared(&mut a[prefix..].chars().rev(), &mut b[prefix..].chars().rev());
(prefix, suffix)
}
/// Byte offset of each line, then the total length.
fn line_starts(lines: &[&str]) -> Vec<usize> {
let mut at = 0;
let mut starts = vec![0];
starts.extend(lines.iter().map(|line| {
at += line.len();
at
}));
starts
}
fn utf16_len(text: &str) -> u32 {
text.chars().map(|c| c.len_utf16() as u32).sum()
}
/// Converts `\r\n` and lone `\r` to `\n`. Borrows when there is nothing to do.
pub fn normalize_line_endings(text: &str) -> Cow<'_, str> {
if !text.contains('\r') {
@@ -334,42 +365,63 @@ impl Document {
cs
}
/// The minimal single-change (common prefix/suffix) change set turning the
/// current text into `text` (after line-ending normalization). Empty when equal.
/// The change set turning the current text into `text` (after line-ending
/// normalization), one change per changed region. Empty when equal. A single change
/// spanning two distant edits would drag the editor's caret and view to the first one.
pub fn diff(&self, text: &str) -> ChangeSet {
self.diff_until(text, Instant::now() + DIFF_BUDGET)
}
/// Past `deadline` the regions get coarser, never wrong.
fn diff_until(&self, text: &str, deadline: Instant) -> ChangeSet {
let new = normalize_line_endings(text);
let old_chars = self.rope.len_chars();
let old = self.rope.to_string();
let (prefix, suffix) = common_ends(&old, &new);
let old_rest = &old[prefix..old.len() - suffix];
let new_rest = &new[prefix..new.len() - suffix];
let mut prefix_chars = 0usize;
let mut prefix_bytes = 0usize;
for (a, b) in self.rope.chars().zip(new.chars()) {
if a != b {
break;
let old_lines: Vec<&str> = old_rest.split_inclusive('\n').collect();
let new_lines: Vec<&str> = new_rest.split_inclusive('\n').collect();
let old_starts = line_starts(&old_lines);
let new_starts = line_starts(&new_lines);
let mut changes = Vec::new();
let mut utf16_at = utf16_len(&old[..prefix]);
let mut old_at = 0;
let mut region: Option<(LineRange, LineRange)> = None;
let ops =
similar::capture_diff_slices_deadline(similar::Algorithm::Myers, &old_lines, &new_lines, Some(deadline));
let mut flush = |region: Option<(LineRange, LineRange)>| {
let Some((old_range, new_range)) = region else { return };
let region_old = &old_rest[old_starts[old_range.start]..old_starts[old_range.end]];
let region_new = &new_rest[new_starts[new_range.start]..new_starts[new_range.end]];
let (head, tail) = common_ends(region_old, region_new);
let start = old_starts[old_range.start] + head;
let end = old_starts[old_range.end] - tail;
let from = utf16_at + utf16_len(&old_rest[old_at..start]);
let to = from + utf16_len(&old_rest[start..end]);
changes.push(TextChange { from, to, insert: region_new[head..region_new.len() - tail].to_owned() });
utf16_at = to;
old_at = end;
};
// Positions come from the lengths: `similar` can report a stale start for the empty
// side of an op (an insert after an equal run still names the run's first old line).
let (mut old_line, mut new_line) = (0, 0);
for op in ops {
let old_end = old_line + op.old_range().len();
let new_end = new_line + op.new_range().len();
if op.tag() == similar::DiffTag::Equal {
flush(region.take());
} else {
region = Some(match region.take() {
Some((o, n)) => (o.start..old_end, n.start..new_end),
None => (old_line..old_end, new_line..new_end),
});
}
prefix_chars += 1;
prefix_bytes += b.len_utf8();
(old_line, new_line) = (old_end, new_end);
}
let new_rest = &new[prefix_bytes..];
let new_rest_chars = new_rest.chars().count();
if prefix_chars == old_chars && new_rest_chars == 0 {
return ChangeSet::default();
}
let max_suffix = (old_chars - prefix_chars).min(new_rest_chars);
let mut suffix_chars = 0usize;
let mut suffix_bytes = 0usize;
for (a, b) in self.rope.chars_at(old_chars).reversed().zip(new_rest.chars().rev()).take(max_suffix) {
if a != b {
break;
}
suffix_chars += 1;
suffix_bytes += b.len_utf8();
}
let from = self.rope.char_to_utf16_cu(prefix_chars) as u32;
let to = self.rope.char_to_utf16_cu(old_chars - suffix_chars) as u32;
let insert = new_rest[..new_rest.len() - suffix_bytes].to_owned();
ChangeSet { changes: vec![TextChange { from, to, insert }] }
flush(region);
ChangeSet { changes }
}
// ---- coordinate conversion (UTF-16 offset ⇄ Pos ⇄ byte offset) ----
@@ -606,4 +658,45 @@ mod tests {
assert_eq!(set.changes, vec![tc(2, 2, "x")]);
assert_eq!(doc.text(), "😀x😀");
}
fn numbered_lines(n: usize) -> String {
(0..n).map(|i| format!("line {i}\n")).collect()
}
#[test]
fn diff_keeps_distant_edits_apart() {
let old = numbered_lines(400);
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
let at = |line: &str| (old.find(line).unwrap() + line.len() - 1) as u32;
let set = Document::new(&old).diff(&new);
assert_eq!(
set.changes,
vec![tc(at("line 20\n"), at("line 20\n"), "x"), tc(at("line 299\n"), at("line 299\n"), "x")]
);
}
#[test]
fn diff_handles_added_and_removed_lines() {
let old = "a\nb\nc\nd\ne\nf\n";
let set = Document::new(old).diff("a\nnew\nb\nc\ne\nf\n");
assert_eq!(set.changes, vec![tc(2, 2, "new\n"), tc(5, 7, "")]);
}
#[test]
fn diff_counts_utf16_past_astral_text() {
let old = "😀 one\nsame\n😀 two\n";
let set = Document::new(old).diff("😀 ONE\nsame\n😀 TWO\n");
assert_eq!(set.changes, vec![tc(3, 6, "ONE"), tc(15, 18, "TWO")]);
}
#[test]
fn diff_past_its_deadline_is_coarser_but_still_correct() {
let old = numbered_lines(5000);
let new = old.replace("line 20\n", "line 20x\n").replace("line 4000\n", "line 4000x\n");
let doc = Document::new(&old);
let set = doc.diff_until(&new, std::time::Instant::now());
let mut replayed = Document::new(&old);
replayed.apply(0, &set).unwrap();
assert_eq!(replayed.text(), new);
}
}
@@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc afa0785335731ca8adc050b09312843aeb3ff05dc895963d09d9f728c553717c # shrinks to doc_text = "a", picks = [(0, 0, ""), (0, 0, ""), (658842129343018477, 1922606258417419361, ""), (0, 4297423518677873183, ""), (953525717999286983, 557348908835472853, "a"), (0, 0, "b"), (0, 1653396554766652711, "")], seed = 7072935092038531240
cc dd6ec32be77e8ad96d0ce3ef14ad9d72d5ba2a06f227ea693bfdee62debf08b7 # shrinks to a = "中é\n\n", b = "\n\na"
+1 -1
View File
@@ -176,7 +176,7 @@ proptest! {
let set = doc.replace_all(&b);
prop_assert_eq!(doc.text(), b.clone());
prop_assert_eq!(naive_apply(&a, &set), b.clone());
prop_assert!(set.changes.len() <= 1);
prop_assert!(set.changes.windows(2).all(|w| w[0].to < w[1].from));
prop_assert_eq!(set.changes.is_empty(), a == b);
}
+1 -1
View File
@@ -13,7 +13,7 @@ serde.workspace = true
specta.workspace = true
ignore.workspace = true
globset.workspace = true
notify-debouncer-full.workspace = true
notify.workspace = true
encoding_rs.workspace = true
chardetng.workspace = true
trash.workspace = true
+163 -92
View File
@@ -12,9 +12,8 @@
use crate::FsBatch;
use crate::rules::{ExcludeMatcher, GitignoreStack};
use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
use notify::event::{ModifyKind, RenameMode};
use notify::{Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
use parking_lot::{Condvar, Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Component, Path, PathBuf};
@@ -62,10 +61,9 @@ pub struct Watcher {
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
enum Inner {
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
Polling(#[allow(dead_code)] Debouncer<PollWatcher, NoCache>),
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
struct Inner {
state: Arc<DirState>,
thread: Option<JoinHandle<()>>,
}
impl Watcher {
@@ -131,80 +129,32 @@ impl Watcher {
/// Number of OS-level watches (1 in recursive and flat mode).
pub fn watch_count(&self) -> usize {
match &self.inner {
Inner::Recursive(_) | Inner::Polling(_) => 1,
Inner::PerDirectory { state, .. } => state.watched.lock().len(),
}
self.inner.state.watched.lock().len()
}
fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
let config = notify::Config::default();
match mode {
WatchMode::Recursive => {
Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive)
}
WatchMode::Flat => {
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive)
}
WatchMode::PerDirectory => Self::start_per_directory::<RecommendedWatcher>(root, filter, emit, config),
}
Self::start_queued::<RecommendedWatcher>(root, filter, emit, mode, notify::Config::default())
}
/// Polls per directory unless flat: a recursive poll would rescan
/// `node_modules` and `target` every round.
fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
let config = notify::Config::default().with_poll_interval(POLL_INTERVAL);
match mode {
WatchMode::Flat => {
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling)
}
WatchMode::Recursive | WatchMode::PerDirectory => {
Self::start_per_directory::<PollWatcher>(root, filter, emit, config)
}
}
let mode = if mode == WatchMode::Flat { mode } else { WatchMode::PerDirectory };
Self::start_queued::<PollWatcher>(root, filter, emit, mode, config)
}
fn start_debounced<W: notify::Watcher>(
root: &Path,
filter: EventFilter,
emit: Emit,
recursive: RecursiveMode,
config: notify::Config,
) -> IdeResult<Debouncer<W, NoCache>> {
let polled = W::kind() == WatcherKind::PollWatcher;
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
// which made opening large projects block for seconds. Renames arrive as remove + create.
let handler = move |result: DebounceEventResult| match result {
Ok(mut events) => {
if polled {
events.retain(|e| !is_dir_touch(&e.event));
}
let (batch, _) = filter.batch_detailed(&events);
if !batch.is_empty() {
emit(&batch);
}
}
Err(errors) => {
for e in errors {
tracing::warn!("file watcher: {e}");
}
}
};
let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config)
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
Ok(debouncer)
}
fn start_per_directory<W: notify::Watcher + Send + 'static>(
fn start_queued<W: notify::Watcher + Send + 'static>(
root: &Path,
filter: EventFilter,
emit: Emit,
mode: WatchMode,
config: notify::Config,
) -> IdeResult<Inner> {
let polled = W::kind() == WatcherKind::PollWatcher;
let state = Arc::new(DirState {
filter,
mode,
watcher: Mutex::new(None),
watched: Mutex::default(),
queue: Mutex::default(),
@@ -228,8 +178,11 @@ impl Watcher {
)
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
*state.watcher.lock() = Some(Box::new(watcher));
let mut created = Vec::new();
state.watch_tree(root, false, &mut created);
if mode == WatchMode::PerDirectory {
state.watch_tree(root, false, &mut Vec::new());
} else {
state.add_watch(root);
}
if state.watched.lock().is_empty() {
return Err(io_error(format!("cannot watch {}", root.display())));
}
@@ -240,20 +193,22 @@ impl Watcher {
.spawn(move || state.run(&*emit))
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
};
Ok(Inner::PerDirectory { state, thread: Some(thread) })
Ok(Inner { state, thread: Some(thread) })
}
}
impl Drop for Watcher {
impl Drop for Inner {
fn drop(&mut self) {
if let Inner::PerDirectory { state, thread } = &mut self.inner {
let state = &self.state;
{
let _queue = state.queue.lock();
state.stop.store(true, Ordering::SeqCst);
state.wake.notify_all();
let watcher = state.watcher.lock().take();
drop(watcher);
if let Some(t) = thread.take() {
let _ = t.join();
}
}
let watcher = state.watcher.lock().take();
drop(watcher);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
@@ -268,10 +223,10 @@ fn is_dir_touch(event: &notify::Event) -> bool {
matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir())
}
/// State of the per-directory strategy, shared with the notify callback
/// (weakly) and the debounce thread.
/// Shared with the notify callback (weakly) and the debounce thread.
struct DirState {
filter: EventFilter,
mode: WatchMode,
watcher: Mutex<Option<Box<dyn notify::Watcher + Send>>>,
watched: Mutex<HashSet<PathBuf>>,
queue: Mutex<Vec<(notify::Event, Instant)>>,
@@ -310,9 +265,13 @@ impl DirState {
}
std::mem::take(&mut *q)
};
let events: Vec<DebouncedEvent> = events.into_iter().map(|(e, t)| DebouncedEvent::new(e, t)).collect();
// A scan may publish a path before its create event is delivered:
// keep create + remove pairs so the consumer still sees the removal.
let events: Vec<Event> = events.into_iter().map(|(e, _)| e).collect();
let (mut batch, rule_dirs) = self.filter.batch_detailed(&events);
self.update_watches(&mut batch, &rule_dirs);
if self.mode == WatchMode::PerDirectory {
self.update_watches(&mut batch, &rule_dirs);
}
if !batch.is_empty() && !self.stopped() {
emit(&batch);
}
@@ -363,7 +322,9 @@ impl DirState {
}
let mut guard = self.watcher.lock();
let Some(w) = guard.as_mut() else { return false };
match w.watch(dir, RecursiveMode::NonRecursive) {
let recursive =
if self.mode == WatchMode::Recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
match w.watch(dir, recursive) {
Ok(()) => {
self.watched.lock().insert(dir.to_path_buf());
true
@@ -555,17 +516,17 @@ impl EventFilter {
}
#[cfg(test)]
pub fn batch(&self, events: &[DebouncedEvent]) -> FsBatch {
pub fn batch(&self, events: &[Event]) -> FsBatch {
self.batch_detailed(events).0
}
/// The coalesced, filtered batch plus the directories whose ignore rules
/// changed (their cached matchers have been rebuilt already).
pub fn batch_detailed(&self, events: &[DebouncedEvent]) -> (FsBatch, Vec<PathBuf>) {
pub fn batch_detailed(&self, events: &[Event]) -> (FsBatch, Vec<PathBuf>) {
let mut changes: BTreeMap<PathBuf, Change> = BTreeMap::new();
for ev in events {
let paths = &ev.event.paths;
match ev.event.kind {
let paths = &ev.paths;
match ev.kind {
EventKind::Access(_) => {}
EventKind::Create(_) => paths.iter().for_each(|p| self.classify(p, Change::Created, &mut changes)),
EventKind::Remove(_) => paths.iter().for_each(|p| self.classify(p, Change::Removed, &mut changes)),
@@ -601,7 +562,7 @@ impl EventFilter {
rule_dirs.insert(dir.to_path_buf());
}
}
if events.iter().flat_map(|e| &e.event.paths).any(|p| p.ends_with(".git/info/exclude")) {
if events.iter().flat_map(|e| &e.paths).any(|p| p.ends_with(".git/info/exclude")) {
self.gitignore.reload_repo_level();
rule_dirs.insert(self.root.clone());
}
@@ -669,11 +630,125 @@ impl EventFilter {
#[cfg(test)]
mod tests {
use super::*;
use notify_debouncer_full::notify::Event;
use notify_debouncer_full::notify::event::{CreateKind, DataChange, RemoveKind};
use notify::event::{CreateKind, DataChange, RemoveKind};
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> DebouncedEvent {
DebouncedEvent::new(Event { kind, paths, attrs: Default::default() }, Instant::now())
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
Event { kind, paths, attrs: Default::default() }
}
const TRANSIENT_PATHS: &[&str] = &["fresh.txt", "src/fresh.rs", "src/tasks/fresh.py"];
/// Delivers a whole debounce window without depending on OS scheduling.
struct TransientWatcher(Box<dyn notify::EventHandler>);
impl notify::Watcher for TransientWatcher {
fn new<F: notify::EventHandler>(handler: F, _: notify::Config) -> notify::Result<Self> {
Ok(Self(Box::new(handler)))
}
fn watch(&mut self, root: &Path, _: RecursiveMode) -> notify::Result<()> {
for rel in TRANSIENT_PATHS {
for kind in [
EventKind::Create(CreateKind::File),
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
EventKind::Remove(RemoveKind::File),
] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
}
}
for kind in [EventKind::Create(CreateKind::Folder), EventKind::Remove(RemoveKind::Folder)] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("new"))));
}
for (rel, kind) in [
("from.py", EventKind::Create(CreateKind::File)),
("from.py", EventKind::Modify(ModifyKind::Name(RenameMode::From))),
("to.py", EventKind::Modify(ModifyKind::Name(RenameMode::To))),
("to.py", EventKind::Remove(RemoveKind::File)),
] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
}
for kind in [EventKind::Remove(RemoveKind::File), EventKind::Create(CreateKind::File)] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("kept.py"))));
}
Ok(())
}
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
Ok(())
}
fn kind() -> WatcherKind {
WatcherKind::NullWatcher
}
}
#[test]
fn debounce_preserves_transient_removals_and_atomic_replacements() {
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("kept.py"), "class Kept: pass\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let emit: Emit = Arc::new(move |b| tx.send(b.clone()).unwrap());
let _watcher = Watcher::start_queued::<TransientWatcher>(
&root,
EventFilter::new(root.clone(), &[]),
emit,
mode,
notify::Config::default(),
)
.unwrap();
let batch = rx.recv_timeout(Duration::from_secs(5)).unwrap();
let removed: BTreeSet<_> = batch.removed.into_iter().collect();
let expected =
TRANSIENT_PATHS.iter().copied().chain(["new", "from.py", "to.py"]).map(|rel| root.join(rel)).collect();
assert_eq!(removed, expected, "{mode:?}: scans may have already published transient paths");
assert_eq!(batch.modified, vec![root.join("kept.py")]);
assert!(batch.created.is_empty());
}
}
#[cfg(windows)]
#[test]
fn native_watch_reports_quickly_removed_files() {
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(root.join("src/tasks")).unwrap();
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
let watcher = {
let batches = batches.clone();
Watcher::start_with(
&root,
Vec::new(),
Arc::new(ide_core::NullSink),
Box::new(move |b| batches.lock().push(b.clone())),
mode,
)
.unwrap()
};
assert!(watcher.polling().is_none());
assert_eq!(watcher.watch_count(), if mode == WatchMode::PerDirectory { 3 } else { 1 });
let paths: Vec<_> = TRANSIENT_PATHS
.iter()
.filter(|rel| mode != WatchMode::Flat || !rel.contains('/'))
.map(|rel| root.join(rel))
.collect();
for path in &paths {
std::fs::write(path, "class FreshWidget:\n pass\n").unwrap();
std::fs::remove_file(path).unwrap();
}
assert!(
wait_for(&batches, |b| paths.iter().all(|p| b.iter().any(|batch| batch.removed.contains(p)))),
"{mode:?}: {:?}",
batches.lock()
);
drop(watcher);
let count = batches.lock().len();
std::fs::write(root.join("after-drop.txt"), "x").unwrap();
std::thread::sleep(DEBOUNCE * 2);
assert_eq!(batches.lock().len(), count, "no callback after drop in {mode:?}");
}
}
#[test]
@@ -822,8 +897,7 @@ mod tests {
}
let filter = EventFilter::new(root.clone(), &[]);
let modify = EventKind::Modify(ModifyKind::Data(DataChange::Content));
let touch =
|paths: &[&str]| -> Vec<DebouncedEvent> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
let touch = |paths: &[&str]| -> Vec<Event> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
let (batch, rules) = filter.batch_detailed(&touch(&["pkg/a.tmp", "pkg/gen/x.rs", "pkg/keep.rs", "b.tmp"]));
assert_eq!(batch.modified, vec![root.join("b.tmp"), root.join("pkg/keep.rs")]);
assert_eq!(batch.ignored, vec![root.join("pkg/a.tmp")], "nested rules, but not inside ignored folders");
@@ -873,10 +947,7 @@ mod tests {
}
fn watcher_state(w: &Watcher) -> &DirState {
match &w.inner {
Inner::PerDirectory { state, .. } => state,
Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"),
}
&w.inner.state
}
fn wait_for(events: &Mutex<Vec<FsBatch>>, pred: impl Fn(&[FsBatch]) -> bool) -> bool {
+22
View File
@@ -344,6 +344,28 @@ fn backend_edits_emit_patches() {
assert_eq!(fs::read_to_string(&path).unwrap(), "ONE\r\n2\r\nTHREE\r\n");
}
#[test]
fn external_change_in_two_places_patches_each() {
let f = fixture();
let path = f.root.join("two.txt");
let old: String = (0..400).map(|i| format!("line {i}\n")).collect();
fs::write(&path, &old).unwrap();
let id = f.docs.open(&path).unwrap().id;
std::thread::sleep(std::time::Duration::from_millis(10));
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
fs::write(&path, &new).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
assert_eq!(f.docs.snapshot(id).unwrap().text, new);
let events = f.events.lock();
let changes = events.iter().find_map(|e| match e {
IdeEvent::DocPatched { changes, .. } => Some(changes),
_ => None,
});
assert_eq!(changes.map(Vec::len), Some(2));
}
#[test]
fn external_changes() {
let f = fixture();
+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` |
+4 -3
View File
@@ -20,9 +20,10 @@ 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, 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.
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.
## Setup
+2
View File
@@ -62,6 +62,8 @@
"text/open_1mb": 2000,
"text/apply_keystroke_1mb": 20,
"text/apply_100_changes_1mb": 1000,
"text/diff_two_edits_1mb": 10000,
"text/diff_every_line_1mb": 100000,
"text/offset_to_pos_round_trip": 5,
"text/pos_to_byte": 5,
"text/text_for_save_crlf_1mb": 1000,
@@ -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",
+251
View File
@@ -0,0 +1,251 @@
/**
* 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));
}
+11 -7
View File
@@ -3,23 +3,25 @@
* (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, as in the app: the frontend registers its event listeners once
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
// 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.
const callbacks = new Map<number, Callback>();
const listeners: { event: string; handler: number }[] = [];
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
let nextCallback = 1;
let installed = false;
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
function install() {
if (installed) return;
installed = true;
{
const internals = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
transformCallback(cb: Callback) {
@@ -38,6 +40,9 @@ function install() {
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 };
},
@@ -50,7 +55,6 @@ function install() {
/** 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,6 +3,7 @@
* 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;
@@ -35,6 +36,8 @@ 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();
});
+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)]
+5
View File
@@ -2480,6 +2480,11 @@ export type DataSource = {
options?: DbOptions,
introspection?: DbIntrospection,
properties?: DbProperty[],
/**
* Stored app-wide and listed in every project. Implied by the file it is read from,
* so not written to it.
*/
global?: boolean,
};
/** System Settings › Date Formats (Git log, Local History, blame). */
+61 -5
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { ChangeSet, Text } from "@codemirror/state";
import type { TextChange } from "@/platform/ipc";
import { DocSync, toTextChanges } from "./DocSync";
import { minimalChange } from "./diff";
import { minimalChanges } from "./diff";
function apply(text: string, changes: TextChange[]) {
let out = "";
@@ -14,6 +14,10 @@ function apply(text: string, changes: TextChange[]) {
return out + text.slice(pos);
}
function numberedLines(count: number) {
return Array.from({ length: count }, (_, i) => `line ${i}\n`).join("");
}
class OutOfSync extends Error {
kind = "outOfSync";
}
@@ -226,6 +230,46 @@ describe("DocSync", () => {
expect(sync.idle).toBe(true);
});
it("a hard resync touches only the regions that differ", async () => {
const initial = numberedLines(40);
const { server, view, type, frame, host } = setup(initial);
server.failNext = { kind: "permissionDenied", message: "read-only" };
type(initial.indexOf("line 2\n"), initial.indexOf("line 2\n"), "#");
type(view.text.toString().indexOf("line 37\n"), view.text.toString().indexOf("line 37\n"), "#");
frame();
await server.processAll();
await flushMicrotasks();
expect(view.text.toString()).toBe(initial);
const touched: number[] = [];
host.applyRemote.mock.calls[0]![0].iterChanges((fromA) => touched.push(fromA));
expect(touched).toHaveLength(2);
});
it("a snapshot resync patches each region a lost event changed", async () => {
vi.useFakeTimers();
try {
const initial = numberedLines(40);
const { server, view, type, frame, host } = setup(initial);
type(initial.length, initial.length, "tail\n");
frame();
const lost = [
{ from: initial.indexOf("line 2\n"), to: initial.indexOf("line 2\n"), insert: "#" },
{ from: initial.indexOf("line 37\n"), to: initial.indexOf("line 37\n"), insert: "#" },
];
server.patch(lost);
await server.processAll();
await vi.advanceTimersByTimeAsync(100);
await server.processAll();
await flushMicrotasks();
expect(view.text.toString()).toBe(server.text);
const touched: number[] = [];
host.applyRemote.mock.calls[0]![0].iterChanges((fromA) => touched.push(fromA));
expect(touched).toHaveLength(2);
} finally {
vi.useRealTimers();
}
});
it("whenIdle resolves after everything is acked", async () => {
const { server, sync, type } = setup("");
type(0, 0, "a");
@@ -279,10 +323,22 @@ describe("DocSync", () => {
});
});
describe("minimalChange", () => {
describe("minimalChanges", () => {
it("trims common prefix and suffix", () => {
expect(minimalChange("hello world", "hello brave world")).toEqual({ from: 6, to: 6, insert: "brave " });
expect(minimalChange("abc", "abc")).toBeNull();
expect(minimalChange("aaa", "a")).toEqual({ from: 1, to: 3, insert: "" });
expect(minimalChanges("hello world", "hello brave world")).toEqual([{ from: 6, to: 6, insert: "brave " }]);
expect(minimalChanges("abc", "abc")).toEqual([]);
expect(apply("aaa", minimalChanges("aaa", "a"))).toBe("a");
});
it("keeps distant edits apart", () => {
const before = numberedLines(40);
const after = before.replace("line 2\n", "line two\n").replace("line 37\n", "line thirty-seven\n");
const changes = minimalChanges(before, after);
expect(changes).toHaveLength(2);
expect(apply(before, changes)).toBe(after);
});
it("rebuilds text across astral characters", () => {
expect(apply("a😀b\n😁c", minimalChanges("a😀b\n😁c", "a😁b\n😀c"))).toBe("a😁b\n😀c");
});
});
+6 -6
View File
@@ -26,7 +26,7 @@
*/
import { ChangeSet, Text } from "@codemirror/state";
import type { TextChange } from "@/platform/ipc";
import { minimalChange } from "./diff";
import { minimalChanges } from "./diff";
export interface SyncTransport {
edit(baseVersion: number, changes: TextChange[]): Promise<number>;
@@ -258,9 +258,9 @@ export class DocSync {
}
if (gen !== this.generation || this.disposed) return;
if (snap.version > this.serverVersion) {
const change = minimalChange(this.confirmed.toString(), snap.text);
const changes = minimalChanges(this.confirmed.toString(), snap.text);
this.pendingRemote = this.pendingRemote.filter((p) => p.version > snap.version);
this.applyRemoteNow({ baseVersion: this.serverVersion, version: snap.version, changes: change ? [change] : [] });
this.applyRemoteNow({ baseVersion: this.serverVersion, version: snap.version, changes });
this.drainRemote();
}
this.resend();
@@ -329,9 +329,9 @@ export class DocSync {
this.pendingRemote = this.pendingRemote.filter((p) => p.version > snap.version);
this.confirmed = Text.of(snap.text.split("\n"));
this.serverVersion = snap.version;
const change = minimalChange(local, snap.text);
if (change) {
const cs = ChangeSet.of([change], local.length);
const changes = minimalChanges(local, snap.text);
if (changes.length) {
const cs = ChangeSet.of(changes, local.length);
this.localText = this.confirmed;
this.host.applyRemote(cs);
}
+6 -15
View File
@@ -1,20 +1,11 @@
import { diff } from "@codemirror/merge";
import type { TextChange } from "@/platform/ipc";
/**
* The single replacement turning `a` into `b` (common prefix / suffix
* trimmed), or null when equal. Linear time; used for resyncs, where the
* backend's own patches are already minimal.
* The changes turning `a` into `b`, one per changed region; empty when equal. Used for resyncs:
* a single change spanning two distant edits would drag the caret and view to the first one.
* The timeout keeps a huge file from stalling the UI; past it the regions only get coarser.
*/
export function minimalChange(a: string, b: string): TextChange | null {
if (a === b) return null;
const max = Math.min(a.length, b.length);
let start = 0;
while (start < max && a.charCodeAt(start) === b.charCodeAt(start)) start++;
let endA = a.length;
let endB = b.length;
while (endA > start && endB > start && a.charCodeAt(endA - 1) === b.charCodeAt(endB - 1)) {
endA--;
endB--;
}
return { from: start, to: endA, insert: b.slice(start, endB) };
export function minimalChanges(a: string, b: string): TextChange[] {
return diff(a, b, { timeout: 50 }).map((c) => ({ from: c.fromA, to: c.toA, insert: b.slice(c.fromB, c.toB) }));
}
+5
View File
@@ -432,6 +432,11 @@ export function DatabaseView() {
</span>
)}
<span className={labelClass}>{label(n)}</span>
{n.kind === "source" && n.source.global && (
<span className={styles.treeDetail} title={tx("Available in all projects")}>
{tx("global")}
</span>
)}
{d && (
<span
className={
+4
View File
@@ -1069,6 +1069,10 @@ export function SourceDialog({
</span>
)}
/>
{/* IDEA's Make Global: stored with the IDE and listed in every project. */}
<span title={tx("Available in all projects")}>
<Checkbox checked={!!s.global} onChange={(global) => set({ global })} label="Global" />
</span>
</div>
<span />
{showComment ? (
+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"). */
+6 -2
View File
@@ -62,9 +62,9 @@
.icon {
flex: none;
}
/* The name takes the whole row; when it doesn't fit, the grey detail after it gives way first. */
.label {
flex: 0 0 auto;
max-width: 75%;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -74,6 +74,7 @@
font-weight: 600;
}
.detail {
flex: 0 10000 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -190,6 +191,9 @@
align-items: center;
gap: 8px;
}
.stashRow > .detail {
flex-shrink: 1;
}
.stashMessage {
flex: 1;
min-width: 0;
+8 -7
View File
@@ -1956,10 +1956,9 @@ function x(lane: number) {
return LANE / 2 + lane * LANE;
}
/** Straight, also across lanes (IDEA's diagonal edges). */
function seg(fromX: number, fromY: number, toX: number, toY: number) {
if (fromX === toX) return `M${fromX} ${fromY}L${toX} ${toY}`;
const mid = (fromY + toY) / 2;
return `M${fromX} ${fromY}C${fromX} ${mid} ${toX} ${mid} ${toX} ${toY}`;
return `M${fromX} ${fromY}L${toX} ${toY}`;
}
export function GraphCell({
@@ -2008,9 +2007,11 @@ export function GraphCell({
if (canNavigate) onNavigate?.(s.targetRow!, target);
else if (canFragment) onFragment?.(target!, !!s.dashed, s.source);
};
const fromX = x(s.fromX ?? s.from);
const toX = x(s.toX ?? s.to);
const path =
seg(x(s.from), up ? 0 : cy, x(s.to), up ? cy : ROW) +
(s.arrow ? arrowHead(x(up ? s.from : s.to), up ? 0 : ROW, up ? -1 : 1) : "");
seg(fromX, up ? 0 : cy, toX, up ? cy : ROW) +
(s.arrow ? arrowHead(up ? fromX : toX, up ? 0 : ROW, up ? -1 : 1) : "");
return (
<g
key={`${up ? "u" : "d"}${i}`}
@@ -2033,9 +2034,9 @@ export function GraphCell({
>
<title>{label}</title>
<rect
x={Math.min(x(s.from), x(s.to)) - 5}
x={Math.min(fromX, toX) - 5}
y={up ? 0 : cy}
width={Math.max(10, Math.abs(x(s.from) - x(s.to)) + 10)}
width={Math.max(10, Math.abs(fromX - toX) + 10)}
height={cy}
fill="transparent"
pointerEvents="none"
+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>
);
}
+45 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computeGraph } from "../graph";
import { computeGraph, straightenLaneChanges } from "../graph";
const c = (id: string, ...parents: string[]) => ({ id, parents });
@@ -62,3 +62,47 @@ describe("computeGraph", () => {
expect(m2.down).toContainEqual({ from: m2.lane, to: 0, color: rows[0]!.color });
});
});
describe("straightenLaneChanges", () => {
it("draws a lane change as one diagonal across the row boundary, keeping the lanes", () => {
// m merges f into main; f branched from a; b sits in lane 0 while lane 1 passes it.
const rows = straightenLaneChanges(computeGraph([c("m", "b", "f"), c("f", "a"), c("b", "a"), c("a")]).rows);
const [m, f, b, a] = rows;
// m → f: leaves m's node towards lane 1 and reaches f's node in a straight line.
expect(m!.down[1]).toMatchObject({ from: 0, to: 1, toX: 0.5 });
expect(f!.up.find((s) => s.from === 1)).toMatchObject({ from: 1, to: 1, fromX: 0.5 });
// Lane 1 converging into a: the vertical line of b's row meets it halfway.
expect(a!.up[1]).toMatchObject({ from: 1, to: 0, fromX: 0.5 });
expect(b!.down.find((s) => s.from === 1)).toMatchObject({ from: 1, to: 1, toX: 0.5 });
// Straight lines stay where they are.
expect(m!.down[0]!.toX).toBeUndefined();
expect(a!.up[0]!.fromX).toBeUndefined();
});
it("leaves a lane that another line also ends in, and arrows, as they are", () => {
const rows = straightenLaneChanges([
{
lane: 0,
color: 0,
up: [],
down: [
{ from: 0, to: 1, color: 0 },
{ from: 1, to: 1, color: 1 },
],
width: 2,
},
{
lane: 1,
color: 1,
up: [{ from: 1, to: 1, color: 1 }],
down: [{ from: 1, to: 2, color: 1, arrow: "down" }],
width: 3,
},
{ lane: 2, color: 1, up: [{ from: 2, to: 2, color: 1 }], down: [], width: 3 },
]);
expect(rows[0]!.down[0]!.toX).toBeUndefined();
expect(rows[1]!.up[0]!.fromX).toBeUndefined();
expect(rows[1]!.down[0]!.toX).toBeUndefined();
expect(rows[2]!.up[0]!.fromX).toBeUndefined();
});
});
@@ -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;
+40
View File
@@ -21,6 +21,9 @@ export interface GraphSegment {
arrow?: "up" | "down";
/** Destination row of a long-edge arrow in the displayed graph. */
targetRow?: number;
/** Drawn lane positions when they differ from `from` / `to` (see {@link straightenLaneChanges}). */
fromX?: number;
toX?: number;
}
export interface GraphRow {
@@ -503,6 +506,43 @@ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE):
return out;
}
/**
* Draws a lane change as one straight diagonal across the row boundary, like IDEA: a half-row
* segment that changes lane and the vertical half of the neighbouring row that continues it meet
* halfway between the two lanes. Only the drawn positions change; `from` / `to` stay the lanes.
*/
export function straightenLaneChanges(rows: readonly GraphRow[]): GraphRow[] {
const out = rows.map((r) => ({ ...r, up: [...r.up], down: [...r.down] }));
const vertical = (s: GraphSegment, lane: number) => s.from === lane && s.to === lane && !s.arrow;
for (let r = 0; r < out.length; r++) {
const row = out[r]!;
// Leaving the node towards another lane: continues straight down in the next row.
const next = out[r + 1];
// Another line ending at the same lane (a merge into a lane that continues) keeps its position.
const shared = (list: readonly GraphSegment[], lane: number, end: "from" | "to") =>
list.filter((x) => x[end] === lane).length > 1;
row.down.forEach((s, i) => {
if (s.from === s.to || s.arrow || !next || shared(row.down, s.to, "to")) return;
const j = next.up.findIndex((u) => vertical(u, s.to) && u.edge === s.edge && u.fromX === undefined);
if (j < 0) return;
const mid = (s.from + s.to) / 2;
row.down[i] = { ...s, toX: mid };
next.up[j] = { ...next.up[j]!, fromX: mid };
});
// Converging into the node from another lane: continues a vertical line of the previous row.
const prev = out[r - 1];
row.up.forEach((s, i) => {
if (s.from === s.to || s.arrow || !prev || shared(prev.down, s.from, "to")) return;
const j = prev.down.findIndex((d) => vertical(d, s.from) && d.edge === s.edge && d.toX === undefined);
if (j < 0) return;
const mid = (s.from + s.to) / 2;
row.up[i] = { ...s, fromX: mid };
prev.down[j] = { ...prev.down[j]!, toX: mid };
});
}
return out;
}
/** Lane colours (tokens), cycled by colour index. */
export const LANE_COLORS = [
"var(--accent)",
+3 -2
View File
@@ -1,4 +1,4 @@
import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges } from "./graph";
import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges, straightenLaneChanges } from "./graph";
import { commitKey, graphCommit, type RepoCommit } from "./multiRoot";
import type { LogOptions } from "./logOptions";
@@ -26,5 +26,6 @@ export function presentLogGraph(
const byId = new Map(all.map((c) => [commitKey(c), c]));
const commits = graph.map((c) => byId.get(c.id)!);
const laid = computeGraph(graph).rows;
return { commits, graph, rows: options.longEdges ? laid : hideLongEdges(laid), linear, collapsed };
const rows = straightenLaneChanges(options.longEdges ? laid : hideLongEdges(laid));
return { commits, graph, rows, linear, collapsed };
}
+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[] = [];
+5
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": "提交检查",
@@ -4239,6 +4242,8 @@
"Root markers": "根标记",
"Initialization options": "初始化选项",
"Global": "全局",
"global": "全局",
"Available in all projects": "在所有项目中可用",
"This project": "此项目",
"Executable name or path": "可执行文件名或路径",
"Select the server executable": "选择服务器可执行文件",