From 49f9c57bccbfd300f2bb6e4136e02ee806b93089 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:46:40 +0800 Subject: [PATCH 1/5] docs: remove the README build section --- README.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/README.md b/README.md index 77488960..59b4f7c0 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,6 @@  ·  Benchmark  ·  - Build -  ·  Discord

@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on both sides. Both are measured from the outside, the same way: see [`perf/compare`](perf/compare) to run it on your machine and projects. -## Build - -```sh -pnpm install -pnpm tauri dev -``` - -Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). - -Native notebook execution tests are opt-in and need two distinct Python environments -with `ipykernel` installed: - -```sh -OPENIDEA_TEST_PYTHON=/first/env/bin/python \ -OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \ -cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1 -``` - ## License [Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). From 210f9a78ac32b99af07a634ae5d9c340667def51 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:02:00 +0800 Subject: [PATCH 2/5] feat(run): project CMake profiles (#679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMake run configurations used fixed arguments (-B build, Debug) and only recognised existing build directories. Projects can now define CMake profiles under Settings › Build, Execution, Deployment › CMake: build type, generator, CMake arguments, build directory (default cmake-build-), build arguments and environment. Each profile gets its own Configure, Build, CTest and target run configurations, named after the profile when there are several. Configure runs first only while the build directory has no CMakeCache.txt, and a generator change clears the stale cache before configuring. Projects without profiles keep the previous detection. Arguments are stored as lists and edited with the same quoting rules as program arguments. Toolchain selection is not part of this change. Refs #655 --- crates/ide-core/src/settings.rs | 56 ++++++ crates/ide-host/src/ops/run.rs | 9 +- crates/ide-run/src/detect.rs | 10 +- crates/ide-run/src/detect/cmake.rs | 177 +++++++++++++++++- crates/ide-run/src/lib.rs | 9 + crates/ide-run/tests/detect.rs | 69 +++++++ src/bindings.ts | 26 +++ src/features/run/index.ts | 9 +- src/features/settings/__tests__/cmake.test.ts | 46 +++++ .../settings/__tests__/cmakeEditor.test.tsx | 72 +++++++ .../settings/__tests__/dialog.test.tsx | 23 +++ src/features/settings/model/schema.ts | 4 + src/features/settings/pages/CMake.module.css | 4 + src/features/settings/pages/cmake.tsx | 151 +++++++++++++++ src/features/settings/pages/execution.tsx | 20 ++ src/platform/cmakeSettings.ts | 38 ++++ src/platform/locales/text/zh.json | 14 +- src/platform/locales/textCatalog.test.ts | 1 + 18 files changed, 726 insertions(+), 12 deletions(-) create mode 100644 src/features/settings/__tests__/cmake.test.ts create mode 100644 src/features/settings/__tests__/cmakeEditor.test.tsx create mode 100644 src/features/settings/pages/CMake.module.css create mode 100644 src/features/settings/pages/cmake.tsx create mode 100644 src/platform/cmakeSettings.ts diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 7f360b1f..7c2c17a3 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -58,6 +58,43 @@ pub struct Settings { pub deployment: DeploymentSettings, /// Build, Execution, Deployment › Run Targets. pub run_targets: Vec, + pub cmake: CMakeSettings, +} + +/// Absent profiles retain automatic discovery for existing projects. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CMakeSettings { + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub profiles: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CMakeProfile { + pub name: String, + pub build_type: String, + pub generator: String, + pub cmake_args: Vec, + /// Empty uses cmake-build-. + pub build_dir: String, + pub build_args: Vec, + pub env: BTreeMap, +} + +impl Default for CMakeProfile { + fn default() -> Self { + Self { + name: "Debug".into(), + build_type: "Debug".into(), + generator: String::new(), + cmake_args: Vec::new(), + build_dir: String::new(), + build_args: Vec::new(), + env: BTreeMap::new(), + } + } } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -154,6 +191,7 @@ impl Default for Settings { ssh: SshSettings::default(), deployment: DeploymentSettings::default(), run_targets: Vec::new(), + cmake: CMakeSettings::default(), } } } @@ -765,6 +803,24 @@ impl Settings { mod tests { use super::*; + #[test] + fn cmake_profile_argument_arrays_round_trip() { + let mut settings = Settings::default(); + assert!(settings.cmake.profiles.is_none()); + assert!(CMakeProfile::default().cmake_args.is_empty()); + assert!(CMakeProfile::default().build_args.is_empty()); + let profile = CMakeProfile { + cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(), + build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(), + ..Default::default() + }; + settings.cmake.profiles = Some(vec![profile.clone()]); + let value = serde_json::to_value(&settings).unwrap(); + assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args)); + assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args)); + assert_eq!(serde_json::from_value::(value).unwrap(), settings); + } + #[test] fn ui_font_fallback_defaults_empty_and_round_trips() { let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap(); diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 21f21e0a..8798e52a 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -50,10 +50,11 @@ fn detected_and_saved( suppressed: &[String], ) -> Vec { let excluded_poms = crate::project::unlinked_maven_poms(root, settings); - let mut all: Vec = ide_run::detect_excluding(root, &excluded_poms) - .into_iter() - .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) - .collect(); + let mut all: Vec = + ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref()) + .into_iter() + .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) + .collect(); all.extend(saved.iter().cloned()); for config in all.iter_mut().filter(|c| c.kind != "gradle") { ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings); diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 9f6e2c5f..13a1f5d5 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -16,6 +16,14 @@ pub(crate) fn detect(root: &Path) -> Vec { } pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec { + detect_with_profiles(root, excluded_poms, None) +} + +pub(crate) fn detect_with_profiles( + root: &Path, + excluded_poms: &[PathBuf], + profiles: Option<&[ide_core::settings::CMakeProfile]>, +) -> Vec { let mut out = Vec::new(); detect_node(root, &mut out); detect_deno(root, &mut out); @@ -24,7 +32,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec) { +pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec) { if !root.join("CMakeLists.txt").is_file() { return; } + if let Some(profiles) = profiles { + for (index, profile) in profiles.iter().enumerate() { + detect_profile(root, profile, index, profiles.len() > 1, out); + } + return; + } let mut builds = Vec::new(); if let Ok(entries) = fs::read_dir(root) { builds.extend(entries.flatten().filter_map(|e| { @@ -79,11 +86,69 @@ pub(super) fn detect(root: &Path, out: &mut Vec) { ); before(&mut test, &build_id); out.push(test); - targets(root, &build, &dir, &prefix, out); + targets(root, &build, &dir, &prefix, None, out); } } -fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec) { +fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec) { + let dir = if profile.build_dir.trim().is_empty() { + format!("cmake-build-{}", profile.build_type.to_lowercase()) + } else { + profile.build_dir.clone() + }; + let build = root.join(&dir); + let prefix = format!("cmake:profile:{index}"); + let configure_id = format!("{prefix}:configure"); + let build_id = format!("{prefix}:build"); + let start = out.len(); + let mut configure = + config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]); + if !profile.generator.trim().is_empty() { + configure.args.extend(["-G".into(), profile.generator.clone()]); + } + configure.args.push(format!("-DCMAKE_BUILD_TYPE={}", profile.build_type)); + configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into()); + configure.args.extend_from_slice(&profile.cmake_args); + configure.extras.params.insert("buildDir".into(), dir.clone()); + out.push(configure); + let mut all = config( + &build_id, + format!("CMake Build ({dir})"), + "cmake", + "cmake", + &["--build", &dir, "--config", &profile.build_type], + ); + all.args.extend_from_slice(&profile.build_args); + if !build.join("CMakeCache.txt").is_file() { + before(&mut all, &configure_id); + } + out.push(all); + let mut test = config( + format!("{prefix}:test"), + format!("CTest ({dir})"), + "cmake", + "ctest", + &["--test-dir", &dir, "-C", &profile.build_type, "--output-on-failure"], + ); + before(&mut test, &build_id); + out.push(test); + targets(root, &build, &dir, &prefix, Some(profile), out); + for config in &mut out[start..] { + config.env = profile.env.clone(); + if multiple { + config.name = format!("{} [{}]", config.name, profile.name); + } + } +} + +fn targets( + root: &Path, + build: &Path, + dir: &str, + prefix: &str, + profile: Option<&CMakeProfile>, + out: &mut Vec, +) { let reply = build.join(".cmake/api/v1/reply"); let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| { entries @@ -113,6 +178,9 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec crate::IdeResult<()> { && config.args.first().is_some_and(|a| a == "-S") && let Some(dir) = config.extras.params.get("buildDir") { - let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea"); + let build = cwd.join(dir); + let generator = config.args.windows(2).find(|a| a[0] == "-G").map(|a| a[1].as_str()); + if let Some(generator) = generator + && let Ok(cache) = fs::read_to_string(build.join("CMakeCache.txt")) + && let Some(cached) = cache.lines().find_map(|l| l.strip_prefix("CMAKE_GENERATOR:INTERNAL=")) + && cached != generator + { + fs::remove_file(build.join("CMakeCache.txt"))?; + let files = build.join("CMakeFiles"); + if files.is_dir() { + fs::remove_dir_all(files)?; + } + } + let query = build.join(".cmake/api/v1/query/client-openidea"); fs::create_dir_all(&query)?; fs::write(query.join("codemodel-v2"), "")?; } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profiles_select_file_api_configuration_and_apply_target_options() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap(); + let reply = root.path().join("output/.cmake/api/v1/reply"); + fs::create_dir_all(&reply).unwrap(); + let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap(); + write( + "index-1.json", + serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}), + ); + write( + "model.json", + serde_json::json!({"paths":{"source":root.path()},"configurations":[ + {"name":"Debug","targets":[{"jsonFile":"debug.json"}]}, + {"name":"Release","targets":[{"jsonFile":"release.json"}]} + ]}), + ); + for name in ["debug", "release"] { + write( + &format!("{name}.json"), + serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}), + ); + } + let profile = CMakeProfile { + name: "Production".into(), + build_type: "Release".into(), + build_dir: "output".into(), + build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(), + env: [("FOO".into(), "bar".into())].into(), + ..Default::default() + }; + let mut configs = Vec::new(); + detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs); + let run = configs.iter().find(|c| c.kind == "native").unwrap(); + assert!(run.command.ends_with("release/app")); + assert_eq!(run.env, profile.env); + assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1); + let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap(); + assert_eq!( + build.args, + ["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"] + ); + assert_eq!(build.env, profile.env); + assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure"); + fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap(); + let mut configured = Vec::new(); + detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured); + for id in ["cmake:profile:0:build", build.id.as_str()] { + let build = configured.iter().find(|c| c.id == id).unwrap(); + assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration"); + } + } + + #[test] + fn generator_change_clears_only_cmake_state_at_launch() { + let root = tempfile::tempdir().unwrap(); + let build = root.path().join("cmake-build-debug"); + fs::create_dir_all(build.join("CMakeFiles")).unwrap(); + fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap(); + fs::write(build.join("keep.txt"), "artifact").unwrap(); + fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap(); + let mut configs = Vec::new(); + detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs); + assert!(build.join("CMakeCache.txt").exists()); + prepare(&configs[0], root.path()).unwrap(); + assert!(!build.join("CMakeCache.txt").exists()); + assert!(!build.join("CMakeFiles").exists()); + assert!(build.join("keep.txt").exists()); + assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists()); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index db94a458..95bf00bd 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -201,6 +201,15 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec, +) -> Vec { + detect::detect_with_profiles(root, excluded_poms, profiles) +} + /// Reads `/.openidea/run.toml`; a missing file yields no configs. pub fn load_user_configs(root: &Path) -> IdeResult> { config::load(root) diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 601f9af7..57801933 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -652,3 +652,72 @@ fn go_default_build_output_is_ignored_by_version_control() { let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap(); assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}"); } + +#[test] +fn cmake_profiles_control_commands_names_and_environment() { + use ide_core::settings::CMakeProfile; + let dir = project(&[("CMakeLists.txt", "project(app)")]); + for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] { + let profile = CMakeProfile { + name: "Production".into(), + build_type: "Release".into(), + generator: generator.into(), + cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(), + build_dir: "output dir".into(), + build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(), + env: [("MY_ENV".into(), "yes".into())].into(), + }; + let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile))); + let configure = find(&configs, "cmake:profile:0:configure"); + let mut expected = vec!["-S", ".", "-B", "output dir"]; + if !generator.is_empty() { + expected.extend(["-G", generator]); + } + expected.extend([ + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + "-DFEATURE=ON", + "-DMESSAGE=two words", + "-DQUOTE=\"hello world\"", + "", + ]); + assert_eq!(configure.args, expected); + assert_eq!(configure.name, "CMake Configure (output dir)"); + let build = find(&configs, "cmake:profile:0:build"); + assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]); + assert_eq!(build.extras.before_launch[0].target, configure.id); + assert!(configs.iter().all(|c| c.env == profile.env)); + let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile])); + assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]")); + assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]")); + assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug"); + } + assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path())); + assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty()); +} + +#[test] +fn cmake_profile_build_configures_only_without_a_cache() { + use ide_core::settings::CMakeProfile; + let root = project(&[("CMakeLists.txt", "project(app)")]); + for dir in ["", "custom output"] { + let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() }; + let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir }); + for cached in [false, true] { + if cached { + fs::create_dir_all(&build_dir).unwrap(); + // Generator changes are handled by an explicit Configure, not every Build. + fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap(); + } + let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile))); + let build = find(&configs, "cmake:profile:0:build"); + if cached { + assert!(build.extras.before_launch.is_empty()); + } else { + assert_eq!(build.extras.before_launch.len(), 1); + assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure"); + } + assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id); + } + } +} diff --git a/src/bindings.ts b/src/bindings.ts index b6524018..645f8668 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1639,6 +1639,30 @@ export type ByteRange = { total: number, }; +export type CMakeProfile = { + name?: string, + buildType?: string, + generator?: string, + cmakeArgs?: string[], + /** Empty uses cmake-build-. */ + buildDir?: string, + buildArgs?: string[], + env?: { [key in string]: string }, +}; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings = CMakeSettings_Serialize | CMakeSettings_Deserialize; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings_Deserialize = { + profiles?: CMakeProfile[] | null, +}; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings_Serialize = { + profiles?: CMakeProfile[] | null, +}; + export type CallHierarchy = { items: HierarchyItem[], incomplete: ReferencesIncomplete | null, @@ -5635,6 +5659,7 @@ export type Settings_Deserialize = { deployment?: DeploymentSettings, /** Build, Execution, Deployment › Run Targets. */ runTargets?: RunTarget[], + cmake?: CMakeSettings_Deserialize, }; export type Settings_Serialize = { @@ -5690,6 +5715,7 @@ export type Settings_Serialize = { deployment: DeploymentSettings, /** Build, Execution, Deployment › Run Targets. */ runTargets: RunTarget[], + cmake: CMakeSettings_Serialize, }; export type Severity = "error" | "warning" | "info" | "hint"; diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 5cac309c..a07662b1 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -538,7 +538,14 @@ export function activate(): () => void { }), // Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options). onIdeEvent("settingsChanged", (e) => { - if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload(); + if ( + !e.key || + e.key === "languages" || + e.key.startsWith("languages.") || + e.key === "cmake" || + e.key.startsWith("cmake.") + ) + scheduleReload(); }), ); diff --git a/src/features/settings/__tests__/cmake.test.ts b/src/features/settings/__tests__/cmake.test.ts new file mode 100644 index 00000000..bf5da3a7 --- /dev/null +++ b/src/features/settings/__tests__/cmake.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { validateSettings } from "../model/schema"; +import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings"; + +describe("CMake settings", () => { + it("preserves absent and explicitly empty profiles", () => { + expect(validateSettings({}).cmake).toEqual({}); + expect(validateSettings({ cmake: { profiles: [] } }).cmake.profiles).toEqual([]); + }); + it("preserves argument arrays and drops malformed argument values", () => { + const cmakeArgs = ["-DMESSAGE=two words", '-DQUOTE="hello"', ""]; + const buildArgs = ["-j", "8", "--", "LABEL=it's a build"]; + const profiles = validateSettings({ cmake: { profiles: [{ cmakeArgs, buildArgs }] } }).cmake.profiles; + expect(profiles?.[0]).toMatchObject({ cmakeArgs, buildArgs }); + expect( + validateSettings({ cmake: { profiles: [{ cmakeArgs: ["-DFOO=bar", 42, null] }] } }).cmake.profiles?.[0] + ?.cmakeArgs, + ).toEqual(["-DFOO=bar"]); + }); + it("retains custom types and generators while normalizing malformed fields", () => { + expect( + validateSettings({ + cmake: { + profiles: [ + { + name: "Custom", + buildType: "Coverage", + generator: "Custom generator", + buildArgs: 5, + env: { KEEP: "yes", DROP: 5 }, + }, + null, + ], + }, + }).cmake.profiles, + ).toEqual([ + { + ...DEFAULT_CMAKE_PROFILE, + name: "Custom", + buildType: "Coverage", + generator: "Custom generator", + env: { KEEP: "yes" }, + }, + ]); + }); +}); diff --git a/src/features/settings/__tests__/cmakeEditor.test.tsx b/src/features/settings/__tests__/cmakeEditor.test.tsx new file mode 100644 index 00000000..2d11a991 --- /dev/null +++ b/src/features/settings/__tests__/cmakeEditor.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it } from "vitest"; +import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings"; +import { parseArgs } from "@/platform/commandLine"; +import { PageContext } from "../components/context"; +import { validateSettings } from "../model/schema"; +import { useSettingsDialog } from "../model/staging"; +import { CMakeProfiles } from "../pages/cmake"; + +const savedDialog = useSettingsDialog.getState(); +afterEach(() => { + cleanup(); + useSettingsDialog.setState(savedDialog, true); +}); + +it.each([ + ["CMake arguments", "cmakeArgs"], + ["Build arguments", "buildArgs"], +] as const)("round-trips spaces, quotes and empty values in %s", async (label, key) => { + const args = ["two words", 'a "quote"', "it's", "", String.raw`C:\some path\file`]; + useSettingsDialog.setState({ + base: validateSettings({ cmake: { profiles: [{ ...DEFAULT_CMAKE_PROFILE, [key]: args }] } }), + staged: {}, + }); + const mount = () => + render( + + + , + ); + const view = mount(); + const input = screen.getByLabelText(label); + expect(parseArgs(input.value)).toEqual(args); + await userEvent.clear(input); + const text = String.raw`-DNAME="two words" "-DQUOTE=it's" ""`; + await userEvent.type(input, text); + expect(input).toHaveValue(text); + fireEvent.blur(input); + const expected = ["-DNAME=two words", "-DQUOTE=it's", ""]; + expect(parseArgs(input.value)).toEqual(expected); + expect(useSettingsDialog.getState().staged["project|cmake.profiles"]?.value).toEqual([ + { ...DEFAULT_CMAKE_PROFILE, [key]: expected }, + ]); + view.unmount(); + mount(); + expect(parseArgs(screen.getByLabelText(label).value)).toEqual(expected); +}); + +it("displays each profile's arguments when switching profiles", () => { + useSettingsDialog.setState({ + base: validateSettings({ + cmake: { + profiles: [ + { ...DEFAULT_CMAKE_PROFILE, name: "Debug" }, + { ...DEFAULT_CMAKE_PROFILE, name: "Release", cmakeArgs: ["-DMESSAGE=release build"] }, + ], + }, + }), + staged: {}, + }); + render( + + + , + ); + fireEvent.change(screen.getByLabelText("CMake arguments"), { target: { value: '"unfinished ' } }); + fireEvent.click(screen.getByRole("option", { name: "Release" })); + expect(parseArgs(screen.getByLabelText("CMake arguments").value)).toEqual([ + "-DMESSAGE=release build", + ]); +}); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index dbdb5f02..a72f4752 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -84,6 +84,29 @@ describe("Settings dialog", () => { expect(settingsSet).toHaveBeenLastCalledWith("global", "editor.encodings.nativeToAscii", "false"); }); + it("edits CMake profiles in project settings and persists them on Apply", async () => { + useProject.setState({ project: { root: "/project" } as never }); + const dialog = await openDialog("build.cmake"); + expect(within(dialog).getByRole("option", { name: "Debug" })).toBeInTheDocument(); + fireEvent.click( + within(within(dialog).getByRole("toolbar", { name: "CMake profile actions" })).getByRole("button", { + name: "Add", + }), + ); + fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Release" } }); + fireEvent.change(within(dialog).getByLabelText("Build type"), { target: { value: "Release" } }); + fireEvent.change(within(dialog).getByLabelText("Generator"), { target: { value: "Ninja" } }); + fireEvent.change(within(dialog).getByLabelText("Build arguments"), { target: { value: "-j 8" } }); + expect(settingsSet).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + const write = settingsSet.mock.calls.find((c) => c[1] === "cmake.profiles")!; + expect(write[0]).toBe("project"); + expect(JSON.parse(write[2])).toEqual([ + expect.objectContaining({ name: "Debug", buildType: "Debug" }), + expect.objectContaining({ name: "Release", buildType: "Release", generator: "Ninja", buildArgs: ["-j", "8"] }), + ]); + }); + it("finds Gradle JVM and applies project Gradle settings", async () => { useProject.setState({ project: { root: "/project" } as never }); const dialog = await openDialog("build.gradle"); diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index 8bf6e185..d92540d3 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -4,6 +4,7 @@ * so `validateSettings` never throws — anything unknown or malformed falls * back to the default (mirrors `ide-core::settings` defaults). */ +import { validateCMake } from "@/platform/cmakeSettings"; import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes"; import type { LanguageServerSettings, Settings } from "@/platform/ipc"; @@ -82,6 +83,7 @@ export interface ValidSettings extends WorkflowSettings { /** System Settings › HTTP Proxy (global). */ proxy: ProxySettingsValid; languages: LanguagesValid; + cmake: ReturnType; projectStructure: ProjectStructureValid; inspections: InspectionSettingsValid; /** Editor › Natural Languages › Spelling. */ @@ -150,6 +152,7 @@ export const DEFAULT_SETTINGS: ValidSettings = { notifications: DEFAULT_NOTIFICATIONS, proxy: DEFAULT_PROXY, languages: DEFAULT_LANGUAGES, + cmake: {}, projectStructure: DEFAULT_PROJECT_STRUCTURE, inspections: DEFAULT_INSPECTIONS, spelling: { ideWords: [], projectWords: [], customDictionaries: [] }, @@ -254,6 +257,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { notifications: validateNotifications(root.notifications), proxy: validateProxy(root.proxy), languages: validateLanguages(root.languages), + cmake: validateCMake(root.cmake), projectStructure: validateProjectStructure(root.projectStructure), inspections: validateInspections(root.inspections), spelling: validateSpelling(root.spelling), diff --git a/src/features/settings/pages/CMake.module.css b/src/features/settings/pages/CMake.module.css new file mode 100644 index 00000000..1196d4e8 --- /dev/null +++ b/src/features/settings/pages/CMake.module.css @@ -0,0 +1,4 @@ +.profile { + width: 100%; + text-align: left; +} diff --git a/src/features/settings/pages/cmake.tsx b/src/features/settings/pages/cmake.tsx new file mode 100644 index 00000000..6bdf008b --- /dev/null +++ b/src/features/settings/pages/cmake.tsx @@ -0,0 +1,151 @@ +import { useId, useState } from "react"; +import { DEFAULT_CMAKE_PROFILE, type CMakeProfileValid } from "@/platform/cmakeSettings"; +import { formatArgs, parseArgs } from "@/platform/commandLine"; +import { tx } from "@/platform/i18n"; +import { IconButton, Input, T } from "@/ui"; +import { Row, Section } from "../components/Form"; +import { useSetting } from "../components/context"; +import { TableEditor } from "./workflowControls"; +import styles from "./Workflow.module.css"; +import own from "./CMake.module.css"; + +function ArgumentsInput({ id, value, onChange }: { id: string; value: string[]; onChange: (args: string[]) => void }) { + const [text, setText] = useState(); + return ( + { + setText(e.target.value); + onChange(parseArgs(e.target.value)); + }} + onBlur={() => setText(undefined)} + /> + ); +} + +export function CMakeProfiles() { + const [saved, setProfiles] = useSetting("cmake.profiles"); + const profiles = saved ?? [DEFAULT_CMAKE_PROFILE]; + const [selected, setSelected] = useState(0); + const index = Math.min(selected, Math.max(0, profiles.length - 1)); + const current = profiles[index]; + const id = useId(); + const update = (patch: Partial) => + setProfiles(profiles.map((p, i) => (i === index ? { ...p, ...patch } : p))); + const fields = [ + ["name", "Name"], + ["buildType", "Build type"], + ["generator", "Generator"], + ["cmakeArgs", "CMake arguments"], + ["buildDir", "Build directory"], + ["buildArgs", "Build arguments"], + ] as const; + return ( +
+
+
+
+ { + setProfiles([ + ...profiles, + { ...DEFAULT_CMAKE_PROFILE, name: tx`CMake profile ${profiles.length + 1}`, env: {} }, + ]); + setSelected(profiles.length); + }} + /> + { + setProfiles(profiles.filter((_, i) => i !== index)); + setSelected(Math.max(0, index - 1)); + }} + /> +
+
+ {profiles.map((p, i) => ( + + ))} + {profiles.length === 0 && No CMake profiles. Click + to add one.} +
+
+
+ {current && ( +
+ {fields.map(([key, label]) => ( + + {key === "cmakeArgs" || key === "buildArgs" ? ( + update({ [key]: args })} + /> + ) : ( + update({ [key]: e.target.value })} + /> + )} + + ))} + + {["Debug", "Release", "RelWithDebInfo", "MinSizeRel"].map((v) => ( + + + {["Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022", "Xcode"].map((v) => ( + + + ({ name, value }))} + columns={[ + { key: "name", title: "Name", placeholder: "NAME" }, + { key: "value", title: "Value", placeholder: "value" }, + ]} + onChange={(rows) => update({ env: Object.fromEntries(rows.map((r) => [r.name ?? "", r.value ?? ""])) })} + empty="No variables" + aria-label="Environment variables" + /> + +
+ + Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool + options. + +
+
+ )} +
+ ); +} diff --git a/src/features/settings/pages/execution.tsx b/src/features/settings/pages/execution.tsx index 77ce59a0..3498655c 100644 --- a/src/features/settings/pages/execution.tsx +++ b/src/features/settings/pages/execution.tsx @@ -3,6 +3,7 @@ * Stepping), Python Debugger and Run/Debug. Consumed by the run and debugger * features (and `ide-run` for encoding / termination timeout). */ +import { CMakeProfiles } from "./cmake"; import { WORKFLOW_LIMITS } from "@/platform/workflowSettings"; import type { SelectOption } from "@/ui"; import { GlobListEditor, NumberInput, Row } from "../components/Form"; @@ -90,6 +91,25 @@ function TooltipDelayRow() { } export const EXECUTION_PAGES: PageSpec[] = [ + { + id: "build.cmake", + title: "CMake", + group: BUILD_GROUP, + order: 357, + projectScope: true, + defaultScope: "project", + keywords: [ + "profile", + "generator", + "Ninja", + "Build type", + "CMake arguments", + "Build directory", + "Build arguments", + "Environment variables", + ], + component: CMakeProfiles, + }, { id: "build.gradle", projectScope: true, diff --git a/src/platform/cmakeSettings.ts b/src/platform/cmakeSettings.ts new file mode 100644 index 00000000..6c4cd5ed --- /dev/null +++ b/src/platform/cmakeSettings.ts @@ -0,0 +1,38 @@ +import type { CMakeProfile } from "@/bindings"; + +export type CMakeProfileValid = Required; + +export const DEFAULT_CMAKE_PROFILE: CMakeProfileValid = { + name: "Debug", + buildType: "Debug", + generator: "", + cmakeArgs: [], + buildDir: "", + buildArgs: [], + env: {}, +}; + +export function validateCMake(raw: unknown): { profiles?: CMakeProfileValid[] } { + const profiles = (raw as { profiles?: unknown } | undefined)?.profiles; + if (!Array.isArray(profiles)) return {}; + const string = (value: unknown, fallback = "") => (typeof value === "string" ? value : fallback); + const args = (value: unknown): string[] => + Array.isArray(value) ? value.filter((arg): arg is string => typeof arg === "string") : []; + return { + profiles: profiles + .filter((p) => p && typeof p === "object") + .map((p) => ({ + name: string(p.name, "Debug"), + buildType: string(p.buildType, "Debug"), + generator: string(p.generator), + cmakeArgs: args(p.cmakeArgs), + buildDir: string(p.buildDir), + buildArgs: args(p.buildArgs), + env: Object.fromEntries( + Object.entries(p.env && typeof p.env === "object" ? p.env : {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + })), + }; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5c81a726..839f1066 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5454,5 +5454,17 @@ "Cannot read Java formatter XML {path}: {error}": "无法读取 Java 格式化 XML {path}:{error}", "Java formatter configuration unavailable": "Java 格式化配置不可用", "Choose an Eclipse formatter XML file.": "请选择 Eclipse 格式化 XML 文件。", - "Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}" + "Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}", + "CMake profiles": "CMake 配置", + "CMake profile actions": "CMake 配置操作", + "No CMake profiles. Click + to add one.": "没有 CMake 配置。点击 + 添加。", + "Profile settings": "配置设置", + "Build type": "构建类型", + "Generator": "生成器", + "CMake arguments": "CMake 参数", + "Build directory": "构建目录", + "Build arguments": "构建参数", + "CMake default": "CMake 默认", + "Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool options.": "包含空格的参数请加引号。构建参数传给 cmake --build;原生构建工具参数前请加 --。", + "CMake profile {0}": "CMake 配置 {0}" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 7d74d60e..3a418ebf 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -80,6 +80,7 @@ const BUILT_AT_RUNTIME = new Set([]); /** Text that stays as written in every locale: names, code, formats, shortcuts. */ const UNTRANSLATED = new Set([ // Product names + "CMake", "Prettier", "IntelliJ IDEA", // Go environment variables and placeholder values From be7e2ff71068be5cf9577cd59226b26d2eb7b8d4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:04:04 +0800 Subject: [PATCH 3/5] fix(debugger): keep execution highlights synchronized (#681) Inline debugger values looked up source identifiers in a plain object, so a name like toString on a nearby line resolved to an inherited Object.prototype member and crashed the editor's inline-values plugin. The execution-point subscription lived in that plugin, so after the crash the highlighted line stayed on the old position while the stack had moved on, and resume or stop did not clear it. Locals are now kept in a prototype-free dictionary and looked up by own property, and the execution point is synced by its own plugin so an inline-value failure can't freeze it; hidden editors resync when remounted. Stepping, resume, run to cursor, reset frame and smart step clear the old execution point as soon as the request is sent, stale stack/locals responses from a previous stop are ignored, and another thread's continued event no longer clears the selected thread's execution point. Fixes #668 --- .../debugger/__tests__/actions.test.ts | 31 ++- .../debugger/__tests__/editor.test.ts | 54 +++++ .../debugger/__tests__/executionState.test.ts | 202 ++++++++++++++++++ .../debugger/__tests__/smartStep.test.ts | 4 +- .../debugger/__tests__/values.test.ts | 6 + src/features/debugger/actions.ts | 57 +++-- src/features/debugger/editor/execution.ts | 51 +++-- src/features/debugger/editor/smartStep.ts | 4 +- src/features/debugger/state.ts | 43 +++- src/features/debugger/ui/Overlays.tsx | 6 +- 10 files changed, 416 insertions(+), 42 deletions(-) create mode 100644 src/features/debugger/__tests__/executionState.test.ts diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index 295b6944..0e89449d 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -6,6 +6,7 @@ const ipc = vi.hoisted(() => { return { ok, debugStep: vi.fn(() => ok(null)), + debugResume: vi.fn(() => ok(null)), debugForceStepOver: vi.fn(() => ok(null)), debugRunTo: vi.fn(() => ok(null)), debugStepInTargets: vi.fn(() => ok([] as Array<{ id: number; label: string }>)), @@ -54,7 +55,7 @@ import type { DebugCapabilities } from "@/platform/ipc"; import { registerDebugActions } from "../actions"; import { breakpointsOf, loadBreakpoints } from "../breakpoints/store"; import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special"; -import { publishContext, useDebug, type DebugSession } from "../state"; +import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state"; import { focusDebugView, blurDebugView } from "../ui/focusedView"; import { useOverlays } from "../ui/popoverStore"; @@ -97,6 +98,7 @@ function paused(p: Partial = {}) { }; useDebug.setState({ sessions: { 3: s }, active: 3 }); publishContext(); + updateExecutionPoint(); } function caret(path: string, language: string, doc: string, line: number) { @@ -154,15 +156,41 @@ describe("debugger actions (IDEA parity)", () => { expect(getAction("debug.jumpToSource")!.keyWhen).toBe("debugFramesFocus || debugVariablesFocus"); }); + it.each([ + ["debug.resume", "debugResume"], + ["debug.stepOver", "debugStep"], + ["debug.stepInto", "debugStep"], + ["debug.stepOut", "debugStep"], + ["debug.forceStepOver", "debugForceStepOver"], + ["debug.forceStepInto", "debugStep"], + ["debug.smartStepInto", "debugStep"], + ["debug.runToCursor", "debugRunTo"], + ["debug.forceRunToCursor", "debugRunTo"], + ["debug.resetFrame", "debugRestartFrame"], + ] as const)("%s clears the old execution point before its request", async (id, command) => { + paused({ caps: caps({ restartFrame: true }) }); + caret("/p/app.py", "python", "a\nb\nc", 2); + const request = ipc[command].mockImplementationOnce(() => { + expect(useExecutionPoint.getState().path).toBeNull(); + expect(useDebug.getState().sessions[3]?.status).toBe("running"); + return ipc.ok(null); + }); + await executeAction(id); + expect(request).toHaveBeenCalledOnce(); + }); + it("Force Step Over / Force Step Into / Force Run to Cursor", async () => { paused(); caret("/p/app.py", "python", "a\nb\nc\nd", 2); await executeAction("debug.forceStepOver"); expect(ipc.debugForceStepOver).toHaveBeenCalledWith(3, 1); + paused(); await executeAction("debug.forceStepInto"); expect(ipc.debugStep).toHaveBeenCalledWith(3, 1, "into"); + paused(); await executeAction("debug.forceRunToCursor"); expect(ipc.debugRunTo).toHaveBeenCalledWith(3, 1, "/p/app.py", 2, true); + paused(); await executeAction("debug.runToCursor"); expect(ipc.debugRunTo).toHaveBeenLastCalledWith(3, 1, "/p/app.py", 2, false); }); @@ -180,6 +208,7 @@ describe("debugger actions (IDEA parity)", () => { expect(ipc.debugStepInTargets).toHaveBeenCalledWith(3, 71); expect(useOverlays.getState().smartStep?.targets.map((t) => t.label)).toEqual(["str(i)", "parse(str(i))"]); + paused({ caps: caps({ stepInTargets: true }) }); ipc.debugStepInTargets.mockReturnValueOnce(ipc.ok([{ id: 5, label: "parse()" }])); await executeAction("debug.smartStepInto"); await settle(); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index b975cca6..c7967b7d 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -166,6 +166,60 @@ describe("breakpoint gutter clicks", () => { }); describe("execution point", () => { + it("keeps the execution line synchronized if inline rendering throws", async () => { + useExecutionPoint.setState({ path: null, values: {} }); + const errors: unknown[] = []; + const view = new EditorView({ + state: EditorState.create({ + doc: "x\nx\nx", + extensions: [executionPoint(ctx), EditorView.exceptionSink.of((e) => errors.push(e))], + }), + parent: document.body, + }); + views.push(view); + const values: Record = Object.defineProperty({}, "x", { + enumerable: true, + get() { + throw new Error("inline rendering failed"); + }, + }); + useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values }); + await flush(); + expect(errors).toHaveLength(1); + useExecutionPoint.setState({ line: 2, values: {} }); + await flush(); + expect(view.dom.querySelectorAll(".cm-line")[2]!.className).toContain(styles.execLine); + useExecutionPoint.setState({ path: null }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull(); + }); + + it("keeps refreshing the execution line around Object.prototype names", async () => { + useExecutionPoint.setState({ path: null, values: {} }); + const view = makeView("String text = Integer.toString(40);\nint count = 40;\ncount++;\ncount++;"); + useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { text: "40" } }); + await flush(); + useExecutionPoint.setState({ line: 3, values: { text: "40", count: "42" } }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)?.textContent).toContain("count++"); + useExecutionPoint.setState({ path: null, values: {} }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull(); + }); + + it.each([2, null])("refreshes a cached editor state when remounted at %s", async (line) => { + useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values: {} }); + const view = makeView("first\nsecond\nthird"); + const state = view.state; + view.destroy(); + views.splice(views.indexOf(view), 1); + useExecutionPoint.setState({ path: line == null ? null : ctx.path, line: line ?? 0, values: {} }); + const restored = new EditorView({ state, parent: document.body }); + views.push(restored); + await flush(); + expect(restored.dom.querySelector(`.${styles.execLine}`)?.textContent ?? null).toBe(line == null ? null : "third"); + }); + it("highlights the suspended line and shows inline values", async () => { const view = makeView("x = 1\ny = x + 1\nprint(y)"); useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { x: "1", y: "2" } }); diff --git a/src/features/debugger/__tests__/executionState.test.ts b/src/features/debugger/__tests__/executionState.test.ts new file mode 100644 index 00000000..9cbbde52 --- /dev/null +++ b/src/features/debugger/__tests__/executionState.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StackFrame, Variable } from "@/platform/ipc"; + +const ipc = vi.hoisted(() => { + const ok = (data: T) => ({ status: "ok" as const, data }); + return { + ok, + debugCapabilities: vi.fn(async () => ok(null)), + debugThreads: vi.fn(async () => + ok([ + { id: 5, name: "http-nio-8179-exec-5" }, + { id: 1, name: "main" }, + ]), + ), + debugStackTrace: vi.fn(async () => ok([] as StackFrame[])), + debugScopes: vi.fn(async () => ok([{ name: "Locals", variablesReference: 10, expensive: false }])), + debugVariables: vi.fn(async () => ok([] as Variable[])), + }; +}); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + const { ok: _ok, ...commands } = ipc; + return { ...real, commands: { ...real.commands, ...commands } }; +}); +vi.mock("@/workbench/contributions", async (orig) => ({ + ...(await orig()), + maybeEditorService: () => ({ openFile: vi.fn(async () => {}) }), +})); +import { adoptSession, handleDebugEvent, selectThread, resumeExecution, useDebug, useExecutionPoint } from "../state"; + +const path = "/proj/MsMemberUtils.java"; +const frame = (line: number, id = 100): StackFrame => ({ + id, + name: "queryMsTicket", + path, + line, + column: 0, + presentationHint: null, +}); +const stopped = (threadId = 5, allThreadsStopped = false) => + handleDebugEvent(1, { kind: "stopped", threadId, allThreadsStopped, reason: "step", description: null }); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + useDebug.setState({ sessions: {}, active: null, stopSeq: 0 }); + useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); + adoptSession(1, { adapter: "java-debug" }); + ipc.debugStackTrace.mockResolvedValue(ipc.ok([frame(6840)])); + ipc.debugVariables.mockResolvedValue(ipc.ok([])); +}); + +describe("execution point state", () => { + it.each([false, true])("selects each stopped thread and its fresh top frame (all=%s)", async (all) => { + stopped(5, all); + await settle(); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 }); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(5, all); + await settle(); + expect(useDebug.getState().sessions[1]).toMatchObject({ selectedThread: 5, selectedFrame: 100 }); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(9)])); + stopped(1, all); + await settle(); + expect(useDebug.getState().sessions[1]?.selectedThread).toBe(1); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 9 }); + }); + + it("clears the old point while a new stack or selected thread is loading", async () => { + stopped(); + await settle(); + const pending = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(pending.promise); + stopped(); + expect(useExecutionPoint.getState().path).toBeNull(); + pending.resolve(ipc.ok([frame(6842)])); + await settle(); + const other = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(other.promise); + const selecting = selectThread(1, 1); + expect(useExecutionPoint.getState().path).toBeNull(); + other.resolve(ipc.ok([frame(9)])); + await selecting; + expect(useExecutionPoint.getState().line).toBe(9); + }); + + it("ignores a stack response from an earlier stop on the same thread", async () => { + const old = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(old.promise); + stopped(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + old.resolve(ipc.ok([frame(6840)])); + await settle(); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + expect(useDebug.getState().sessions[1]?.frames[0]?.line).toBe(6842); + }); + + it("keeps the selected paused thread when only another thread continues", async () => { + stopped(); + await settle(); + handleDebugEvent(1, { kind: "continued", threadId: 1, allThreadsContinued: false }); + expect(useExecutionPoint.getState().path).toBe(path); + expect(useDebug.getState().sessions[1]?.status).toBe("paused"); + handleDebugEvent(1, { kind: "continued", threadId: 5, allThreadsContinued: false }); + expect(useExecutionPoint.getState().path).toBeNull(); + }); + + it.each(["continued", "terminated"] as const)("clears the execution point on %s", async (kind) => { + stopped(); + await settle(); + handleDebugEvent(1, kind === "continued" ? { kind, threadId: 5, allThreadsContinued: true } : { kind }); + expect(useExecutionPoint.getState()).toMatchObject({ path: null, values: {} }); + }); + + it("retains locals whose names are Object.prototype properties", async () => { + const names = Object.getOwnPropertyNames(Object.prototype); + ipc.debugVariables.mockResolvedValueOnce( + ipc.ok( + names.map((name) => ({ name, value: "known", typeName: null, variablesReference: 0, evaluateName: name })), + ), + ); + stopped(); + await settle(); + for (const name of names) expect(Object.hasOwn(useExecutionPoint.getState().values, name), name).toBe(true); + }); + it.each(["step", "continue"])("clears suspended data before a %s request resolves", async () => { + stopped(); + await settle(); + const pending = deferred(); + const request = vi.fn(() => { + expect(useExecutionPoint.getState().path).toBeNull(); + expect(useDebug.getState().sessions[1]).toMatchObject({ status: "running", frames: [], selectedFrame: null }); + return pending.promise; + }); + const result = resumeExecution(1, request); + expect(request).toHaveBeenCalledOnce(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + pending.resolve(); + await result; + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + }); + + it("restores suspended data if the command fails without a newer event", async () => { + stopped(); + await settle(); + await expect( + resumeExecution(1, async () => { + throw new Error("rejected"); + }), + ).rejects.toThrow("rejected"); + expect(useDebug.getState().sessions[1]?.status).toBe("paused"); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 }); + }); + + it("does not restore an older stop when a failed command has already stopped again", async () => { + stopped(); + await settle(); + let reject!: (e: Error) => void; + const result = resumeExecution( + 1, + () => + new Promise((_, r) => { + reject = r; + }), + ); + const rejected = expect(result).rejects.toThrow("rejected"); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + reject(new Error("rejected")); + await rejected; + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + }); + + it("discards locals from an earlier stop even when the frame ID is reused", async () => { + const old = deferred>>(); + ipc.debugVariables.mockReturnValueOnce(old.promise); + stopped(); + await settle(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + ipc.debugVariables.mockResolvedValueOnce( + ipc.ok([{ name: "count", value: "42", typeName: null, variablesReference: 0, evaluateName: "count" }]), + ); + stopped(); + await settle(); + old.resolve(ipc.ok([{ name: "count", value: "40", typeName: null, variablesReference: 0, evaluateName: "count" }])); + await settle(); + expect(useExecutionPoint.getState().values.count).toBe("42"); + }); +}); diff --git a/src/features/debugger/__tests__/smartStep.test.ts b/src/features/debugger/__tests__/smartStep.test.ts index 84eb48fd..eeffc599 100644 --- a/src/features/debugger/__tests__/smartStep.test.ts +++ b/src/features/debugger/__tests__/smartStep.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api"; -import { useExecutionPoint } from "../state"; +import { adoptSession, useDebug, useExecutionPoint } from "../state"; import { useOverlays } from "../ui/popoverStore"; import { calleeName, @@ -92,6 +92,8 @@ describe("picking in the editor", () => { }); function setup() { + useDebug.setState({ sessions: {}, active: null }); + adoptSession(3, { status: "paused", selectedThread: 1, stoppedThread: 1 }); const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false }; const view = new EditorView({ state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }), diff --git a/src/features/debugger/__tests__/values.test.ts b/src/features/debugger/__tests__/values.test.ts index 38a5bcfc..15ee72a7 100644 --- a/src/features/debugger/__tests__/values.test.ts +++ b/src/features/debugger/__tests__/values.test.ts @@ -52,6 +52,12 @@ describe("value classification", () => { ); expect(inlineValuesFor("return 1", values)).toBeNull(); }); + + it.each(Object.getOwnPropertyNames(Object.prototype))("only displays an own local named %s", (name) => { + expect(inlineValuesFor(name, { count: "40" })).toBeNull(); + expect(inlineValuesFor(name, Object.fromEntries([[name, "known"]]))).toBe(`${name}: known`); + expect(inlineValuesFor(name, Object.create({ [name]: "inherited" }))).toBeNull(); + }); }); const frame = (id: number, path: string | null, hint: string | null = null, line = 9): StackFrame => ({ diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 235b86be..098395a0 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -24,6 +24,7 @@ import { debugUnsupportedReason, launchConfigFor } from "./launch"; import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote"; import { activeSession, + resumeExecution, evaluate, isLive, rerunSession, @@ -89,7 +90,10 @@ function breakpointPathAllowed(path: string): boolean { export function runToLine(path: string, line: number) { const t = pausedTarget(); if (t && breakpointPathAllowed(path)) - void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false))); + void report( + "Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, path, line, false))), + ); } function notSupported(what: string, s: DebugSession) { @@ -115,15 +119,17 @@ async function smartStepInto() { if (!t) return; const frame = selectedFrame(t.s); if (!frame || !sessionSupports("stepInTargets", t.s)) { - await call(commands.debugStep(t.s.id, t.thread, "into")); + await resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))); return; } const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id))); if (targets.length <= 1) { - await call( - targets[0] - ? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id) - : commands.debugStep(t.s.id, t.thread, "into"), + await resumeExecution(t.s.id, () => + call( + targets[0] + ? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id) + : commands.debugStep(t.s.id, t.thread, "into"), + ), ); return; } @@ -317,7 +323,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind))); + if (t) + void report( + title, + resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, kind))), + ); }, }); @@ -400,7 +410,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread))); + if (t) + void report( + "Resume failed", + resumeExecution(t.s.id, () => call(commands.debugResume(t.s.id, t.thread))), + ); }, }, { @@ -432,7 +446,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread))); + if (t) + void report( + "Force Step Over failed", + resumeExecution(t.s.id, () => call(commands.debugForceStepOver(t.s.id, t.thread))), + ); }, }, { @@ -445,7 +463,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into"))); + if (t) + void report( + "Force Step Into failed", + resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))), + ); }, }, { @@ -484,7 +506,10 @@ export function registerDebugActions(): () => void { notSupported("Reset Frame", t.s); return; } - void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id))); + void report( + "Reset Frame failed", + resumeExecution(t.s.id, () => call(commands.debugRestartFrame(t.s.id, frame.id))), + ); }, }, { @@ -531,7 +556,10 @@ export function registerDebugActions(): () => void { const t = pausedTarget(); const c = cursor(); if (t && c && breakpointPathAllowed(c.path)) - void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))); + void report( + "Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))), + ); }, }, { @@ -545,7 +573,10 @@ export function registerDebugActions(): () => void { const t = pausedTarget(); const c = cursor(); if (t && c && breakpointPathAllowed(c.path)) - void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))); + void report( + "Force Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))), + ); }, }, { diff --git a/src/features/debugger/editor/execution.ts b/src/features/debugger/editor/execution.ts index f466e459..749bce22 100644 --- a/src/features/debugger/editor/execution.ts +++ b/src/features/debugger/editor/execution.ts @@ -49,6 +49,7 @@ function currentExec(path: string): Exec | null { export function inlineValuesFor(text: string, values: Record): string | null { const parts: string[] = []; for (const id of identifiersIn(text)) { + if (!Object.hasOwn(values, id)) continue; const v = values[id]; if (v === undefined) continue; parts.push(`${id}: ${inlineValue(v, 40)}`); @@ -80,33 +81,48 @@ export function executionPoint(ctx: EditorDocContext): Extension { provide: (f) => EditorView.decorations.compute([f], (state) => lineDecorations(state, state.field(f))), }); + // Keep the execution line alive even if an inline-value renderer fails. + const sync = ViewPlugin.fromClass( + class { + private unsub: () => void; + private pending = false; + private destroyed = false; + constructor(readonly view: EditorView) { + this.unsub = useExecutionPoint.subscribe(() => this.refresh()); + // Hidden tabs retain EditorState while their views are unmounted. + this.refresh(); + } + refresh() { + if (this.pending) return; + this.pending = true; + queueMicrotask(() => { + this.pending = false; + if (this.destroyed) return; + const next = currentExec(path); + const cur = this.view.state.field(execField); + const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next; + // Locals can change without moving the execution point. + this.view.dispatch(moved ? { effects: setExec.of(next) } : {}); + }); + } + destroy() { + this.destroyed = true; + this.unsub(); + } + }, + ); + const inline = ViewPlugin.fromClass( class { decorations: DecorationSet = Decoration.none; - private unsub: () => void; - private pending = false; private destroyed = false; private unsubSettings: () => void; constructor(readonly view: EditorView) { this.decorations = this.build(); - // Settings › Debugger › Data Views › Show values inline. this.unsubSettings = onWorkflowSettingsChange( (s) => s.debugger.inlineValues, () => queueMicrotask(() => !this.destroyed && this.view.dispatch({})), ); - this.unsub = useExecutionPoint.subscribe(() => { - if (this.pending) return; - this.pending = true; - queueMicrotask(() => { - this.pending = false; - if (this.destroyed) return; - const next = currentExec(path); - const cur = this.view.state.field(execField); - const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next; - // An empty transaction still rebuilds the inline values (locals arrive after the frame). - this.view.dispatch(moved ? { effects: setExec.of(next) } : {}); - }); - }); } update(u: ViewUpdate) { @@ -136,12 +152,11 @@ export function executionPoint(ctx: EditorDocContext): Extension { destroy() { this.destroyed = true; - this.unsub(); this.unsubSettings(); } }, { decorations: (v) => v.decorations }, ); - return [execField, inline]; + return [execField, sync, inline]; } diff --git a/src/features/debugger/editor/smartStep.ts b/src/features/debugger/editor/smartStep.ts index 6eec156e..c5617ab4 100644 --- a/src/features/debugger/editor/smartStep.ts +++ b/src/features/debugger/editor/smartStep.ts @@ -10,7 +10,7 @@ import { editorsFor, activeEditor, type EditorDocContext, type TextEditorHandle import { call, commands, type DebugSessionId, type StepInTarget } from "@/platform/ipc"; import { suspendKeyboardDispatch } from "@/platform/keymap"; import { notifications } from "@/workbench/contributions"; -import { useExecutionPoint } from "../state"; +import { useExecutionPoint, resumeExecution } from "../state"; import { useOverlays, type InlineStepTarget } from "../ui/popoverStore"; import styles from "./editor.module.css"; @@ -139,7 +139,7 @@ export function chooseStepTarget(id?: number) { const target = id ?? s.inline?.ranges[s.inline.index]?.id; cancelSmartStep(); if (target == null) return; - void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) => + void resumeExecution(s.session, () => call(commands.debugStepInTarget(s.session, s.thread, target))).catch((e) => notifications().notify({ group: "run", level: "error", diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index d983c628..e910ff0c 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -644,6 +644,7 @@ export function selectSession(id: DebugSessionId) { export async function selectThread(id: DebugSessionId, threadId: number) { patch(id, { selectedThread: threadId, frames: [], selectedFrame: null }); + updateExecutionPoint(); await loadFrames(id, threadId, true); } @@ -652,7 +653,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true) updateExecutionPoint(); const s = useDebug.getState().sessions[id]; const frame = s?.frames.find((f) => f.id === frameId); - if (reveal && frame?.path) { + if (reveal && useDebug.getState().active === id && frame?.path) { void maybeEditorService()?.openFile(frame.path, { selection: { line: frame.line, col: frame.column }, focus: false, @@ -662,6 +663,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true) } async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) { + const before = useDebug.getState().sessions[id]; let frames: StackFrame[] = []; try { frames = await call(commands.debugStackTrace(id, threadId)); @@ -669,7 +671,7 @@ async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) console.warn("stackTrace failed", e); } const s = useDebug.getState().sessions[id]; - if (!s || s.status !== "paused" || s.selectedThread !== threadId) return; + if (!s || s.status !== "paused" || s.selectedThread !== threadId || s.frames !== before?.frames) return; patch(id, { frames }); const top = frames.find((f) => f.path && f.presentationHint !== "label") ?? frames[0]; if (top?.path && (s.stopReason === "breakpoint" || s.stopReason === "function breakpoint")) { @@ -707,19 +709,21 @@ async function loadThreads(id: DebugSessionId) { /** Locals of `frameId` (first non-expensive scope) for inline values. */ async function loadLocals(id: DebugSessionId, frameId: number) { - const values: Record = {}; + const frames = useDebug.getState().sessions[id]?.frames; + const values: Record = Object.create(null); try { const scopes = await call(commands.debugScopes(id, frameId)); const scope = scopes.find((sc) => !sc.expensive); if (scope) { const vars = await call(commands.debugVariables(id, scope.variablesReference)); - for (const v of vars) if (v.name && !(v.name in values)) values[v.name] = v.value; + for (const v of vars) if (v.name && !Object.hasOwn(values, v.name)) values[v.name] = v.value; } } catch { // No inline values. } const s = useDebug.getState().sessions[id]; - if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) return; + if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames) + return; useExecutionPoint.setState({ values }); } @@ -729,6 +733,7 @@ export async function showExecutionPoint(id: DebugSessionId) { if (!s || s.status !== "paused" || s.stoppedThread == null) return; if (s.selectedThread !== s.stoppedThread) { patch(id, { selectedThread: s.stoppedThread, frames: [], selectedFrame: null }); + updateExecutionPoint(); await loadFrames(id, s.stoppedThread, false); } const cur = useDebug.getState().sessions[id]; @@ -757,6 +762,32 @@ export function updateExecutionPoint() { useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {} }); } +/** Invalidate suspended data before sending a command that resumes execution. */ +export async function resumeExecution(id: DebugSessionId, request: () => Promise) { + const before = useDebug.getState().sessions[id]; + if (!before || before.status !== "paused") return; + const frames: StackFrame[] = []; + patch(id, { status: "running", frames, selectedFrame: null, stoppedThread: null }); + updateExecutionPoint(); + try { + await request(); + } catch (e) { + const current = useDebug.getState().sessions[id]; + // A stop/continue/termination received during the request owns the newer state. + if (current?.status === "running" && current.frames === frames) { + patch(id, { + status: before.status, + frames: before.frames, + selectedFrame: before.selectedFrame, + stoppedThread: before.stoppedThread, + }); + updateExecutionPoint(); + if (before.selectedFrame != null) void loadLocals(id, before.selectedFrame); + } + throw e; + } +} + // --- watches ------------------------------------------------------------------- export function addWatch(expression: string) { @@ -862,6 +893,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { }); useDebug.setState((st) => ({ active: id, stopSeq: st.stopSeq + 1 })); publishContext(); + updateExecutionPoint(); void loadThreads(id); void loadFrames(id, event.threadId, true); const dbg = workflowSettings().debugger; @@ -876,6 +908,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { case "continued": { const cur = useDebug.getState().sessions[id]!; if (cur.status === "terminated") break; + if (!event.allThreadsContinued && event.threadId !== (cur.selectedThread ?? cur.stoppedThread)) break; patch(id, { status: "running", frames: [], diff --git a/src/features/debugger/ui/Overlays.tsx b/src/features/debugger/ui/Overlays.tsx index 14806793..1f4ae51e 100644 --- a/src/features/debugger/ui/Overlays.tsx +++ b/src/features/debugger/ui/Overlays.tsx @@ -13,7 +13,7 @@ import { updateBreakpoint, useBreakpoints, } from "../breakpoints/store"; -import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state"; +import { activeSession, addWatch, evaluate, isLive, startSession, useDebug, resumeExecution } from "../state"; import { addMethodBreakpoint, removeAllSpecial, @@ -978,7 +978,9 @@ function SmartStepChooser() { const pick = (targetId: number) => { if (!state) return; close(); - void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) => + void resumeExecution(state.session, () => + call(commands.debugStepInTarget(state.session, state.thread, targetId)), + ).catch((e) => notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }), ); }; From b9d3ce7723a4274008c534f77748c3e1d66c9a54 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:16:25 +0800 Subject: [PATCH 4/5] fix(java): align debug JVM encoding with Run (#674) --- crates/ide-core/src/output_encoding.rs | 69 ++++++++++++++++++++++---- crates/ide-host/src/ops/debug.rs | 21 ++++++-- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/crates/ide-core/src/output_encoding.rs b/crates/ide-core/src/output_encoding.rs index f5e85f3d..de53b540 100644 --- a/crates/ide-core/src/output_encoding.rs +++ b/crates/ide-core/src/output_encoding.rs @@ -215,13 +215,21 @@ fn fix_kind( } /// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding` -/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself. -pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option { +/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only +/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`]. +pub fn java_debug_vm_args( + vm_args: &str, + vars: &BTreeMap, + inherited: impl Fn(&str) -> Option, + encoding: Option<&str>, + windows: bool, +) -> Option { let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8"); - if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) { + let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args; + if added.is_empty() { return None; } - let added = options(STD_PROPERTIES, encoding).join(" "); + let added = added.join(" "); Some(match vm_args.trim() { "" => added, own => format!("{own} {added}"), @@ -446,12 +454,53 @@ mod tests { #[test] fn java_debug_launches_write_what_java_debug_decodes() { - assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8)); - assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}"))); - assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK")); - assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None); - assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None); - assert!(java_debug_vm_args("", Some("GBK"), false).is_some()); + let debug = + |args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows); + for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] { + let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8"); + let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" ")); + assert_eq!(debug("", encoding, true), Some(utf8.clone())); + assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}"))); + assert_eq!(debug("-Xmx1g", encoding, false), None); + } + for windows in [true, false] { + assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" "))); + } + } + + #[test] + fn java_debug_preserves_user_options_independently() { + let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true); + assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}"))); + for property in STD_PROPERTIES { + let args = format!("-D{property}=GBK"); + assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8"))); + assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None); + } + for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] { + for (own, expected) in [ + ("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())), + ("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())), + ("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None), + ] { + assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected); + assert_eq!( + java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true), + expected + ); + } + // A launch environment value overrides the inherited one, as in Run. + assert_eq!( + java_debug_vm_args( + "", + &vars(&[(key, "")]), + |k| (k == key).then(|| "-Dfile.encoding=GBK".into()), + None, + true + ), + Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8")) + ); + } } fn filtered(chunks: &[&str]) -> String { diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 492ce842..9dd91de7 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -96,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC }; let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default(); let encoding = extra.get("encoding").and_then(|v| v.as_str()); - if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) { + if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args( + vm_args, + &config.env, + |key| std::env::var(key).ok(), + encoding, + windows, + ) { extra.insert("vmArgs".into(), vm_args.into()); config.extra_json = Some(serde_json::Value::Object(extra).to_string()); } @@ -452,7 +458,7 @@ mod tests { program: None, args: vec![], cwd: None, - env: Default::default(), + env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(), extra_json: Some(extra.to_string()), } } @@ -468,7 +474,7 @@ mod tests { let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true); assert_eq!( vm_args(&fixed), - "-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" + "-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8" ); assert_eq!( serde_json::from_str::(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"], @@ -481,4 +487,13 @@ mod tests { assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g"); } } + + #[test] + fn java_debug_launches_respect_environment_encoding() { + for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] { + let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"})); + config.env.insert(key.into(), "-Dfile.encoding=GBK".into()); + assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK"); + } + } } From 93328d78955b7f2e8c735d5378f86a51d40e46c0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:18:37 +0800 Subject: [PATCH 5/5] Store rebuildable Windows data in Local (#652) * fix: store rebuildable Windows data in Local * fix(windows): preserve relocated tool wrappers and local tool consumers --- Cargo.lock | 1 + crates/ide-host/Cargo.toml | 3 + crates/ide-host/src/bin/server.rs | 1 + crates/ide-host/src/data_dirs.rs | 474 +++++++++++++++++++ crates/ide-host/src/formatter.rs | 2 +- crates/ide-host/src/lib.rs | 23 +- crates/ide-host/src/ops/agent.rs | 2 +- crates/ide-host/src/ops/debug.rs | 2 +- crates/ide-host/src/ops/doc/tests.rs | 1 + crates/ide-host/src/ops/java.rs | 2 +- crates/ide-host/src/ops/tools.rs | 8 +- crates/ide-host/src/project.rs | 22 +- crates/ide-host/tests/data_dirs.rs | 39 ++ crates/ide-host/tests/light_edit.rs | 1 + crates/ide-host/tests/properties.rs | 1 + crates/ide-host/tests/proxy.rs | 1 + crates/ide-host/tests/remote_env.rs | 1 + crates/ide-host/tests/rpc.rs | 3 + crates/ide-host/tests/run_configs.rs | 1 + crates/ide-host/tests/terminal_activation.rs | 1 + crates/ide-host/tests/trust.rs | 1 + crates/ide-host/tests/trust_git_binary.rs | 1 + crates/ide-host/tests/vcs_encoding.rs | 1 + crates/ide-host/tests/vcs_roots.rs | 1 + crates/ide-host/tests/workspaces.rs | 1 + crates/ide-workspace/src/caches.rs | 56 ++- perf/compare/apps.toml | 2 +- src-tauri/src/lib.rs | 2 +- src-tauri/src/remote_dev.rs | 11 +- src-tauri/src/state.rs | 9 +- 30 files changed, 637 insertions(+), 37 deletions(-) create mode 100644 crates/ide-host/src/data_dirs.rs create mode 100644 crates/ide-host/tests/data_dirs.rs diff --git a/Cargo.lock b/Cargo.lock index c3ad7874..3d5f3a35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4250,6 +4250,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index c6330434..39922852 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -52,3 +52,6 @@ tempfile.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index 6f405fcb..ada210d8 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -55,6 +55,7 @@ async fn run(base: PathBuf) -> i32 { let sink_peer = peer.clone(); let host = Arc::new(Host::new(HostConfig { config_dir, + local_data_dir: data_dir.clone(), data_dir, languages, syntax: Arc::default(), diff --git a/crates/ide-host/src/data_dirs.rs b/crates/ide-host/src/data_dirs.rs new file mode 100644 index 00000000..9c96c415 --- /dev/null +++ b/crates/ide-host/src/data_dirs.rs @@ -0,0 +1,474 @@ +//! Storage policy and best-effort migration of rebuildable desktop data. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Unknown entries and recovery/user content deliberately stay in roaming data. +pub fn is_local(entry: &Path) -> bool { + ["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"] + .iter() + .any(|root| entry.starts_with(root)) + || entry.starts_with("agents/tools") + || entry == Path::new("agents/registry.json") + || (entry.components().count() == 1 + && entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-")) + && entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp")) +} + +pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf { + if is_local(entry) { local.join(entry) } else { data.join(entry) } +} + +#[derive(Debug, PartialEq, Eq)] +enum Action { + Skip, + Rename, +} + +fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action { + if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename } +} + +#[derive(Debug, PartialEq, Eq)] +enum RenameFailure { + Copy, + KeepSource, +} + +fn rename_failure(error: &io::Error) -> RenameFailure { + if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource } +} + +/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in +/// the background. Consumers always use Local, even when migration fails. +pub fn migrate(data: &Path, local: &Path) { + if cfg!(target_os = "windows") && data != local { + migrate_entries(data, local); + } +} + +fn migrate_entries(data: &Path, local: &Path) { + let mut entries: Vec = [ + "language-server-runtimes", + "index", + "cache", + "lsp", + "notebook-runtime", + "server", + "java-usages-1.1.1.jar", + "agents/registry.json", + ] + .into_iter() + .map(PathBuf::from) + .collect(); + // Virtual environments contain absolute interpreter paths. Keep the old copy + // intact; these tools are installed afresh in Local when requested. Registry + // agents also have absolute commands in user config, so leave legacy installs. + match fs::read_dir(data.join("tools")) { + Ok(tools) => { + for entry in tools { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local"); + continue; + } + }; + let name = entry.file_name(); + let venv = ide_tools::registry::TOOLS.iter().any(|tool| { + name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. }) + }); + if venv { + tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed"); + } else { + entries.push(Path::new("tools").join(name)); + } + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"), + } + let mut copies = Vec::new(); + for entry in entries { + let source = data.join(&entry); + let target = data_path(data, local, &entry); + match try_rename(&source, &target) { + Ok(true) => copies.push((source, target)), + Ok(false) => {} + Err(error) => { + tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local") + } + } + } + if !copies.is_empty() { + let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || { + for (source, target) in copies { + tracing::info!(?source, ?target, "copying legacy data in background"); + if let Err(error) = copy_then_remove(&source, &target) { + tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local"); + } + } + }); + if let Err(error) = result { + tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local"); + } + } +} + +/// Returns true when a cross-volume copy is needed. +fn try_rename(source: &Path, target: &Path) -> io::Result { + if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip { + return Ok(false); + } + if fs::symlink_metadata(source)?.file_type().is_symlink() { + return Err(io::Error::other("refusing to migrate a linked data entry")); + } + fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?; + // Relative wrappers work at the old location too, so even a failed rename + // leaves the old installation usable and needs no rollback. + relocate_shims(source, source)?; + match rename_missing(source, target) { + Ok(()) => { + tracing::info!(?source, ?target, "moved legacy data to Local"); + Ok(false) + } + Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true), + Err(error) => Err(error), + } +} + +fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> { + if target.try_exists()? { + return Ok(()); + } + let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?; + fs::create_dir_all(parent)?; + let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4())); + let result = (|| { + copy_tree(source, &staging)?; + relocate_shims(&staging, source)?; + // A tool may have been installed or a cache rebuilt while copying. Never + // merge into or overwrite it, and retain the original for recovery. + if target.try_exists()? { + tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy"); + return Ok(()); + } + rename_missing(&staging, target)?; + remove_entry(source)?; + tracing::info!(?source, ?target, "copied legacy data to Local"); + Ok(()) + })(); + if staging.exists() { + let _ = remove_entry(&staging); + } + result +} + +// std::fs::rename may replace even an empty directory on modern Windows. +#[cfg(windows)] +fn rename_missing(source: &Path, target: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::MoveFileExW; + + // canonicalize supplies extended-length paths, including redirected UNC roots. + let source = fs::canonicalize(source)?; + let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)? + .join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?); + let wide = |path: &Path| -> io::Result> { + let mut value: Vec = path.as_os_str().encode_wide().collect(); + if value.contains(&0) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path")); + } + value.push(0); + Ok(value) + }; + let source = wide(&source)?; + let target = wide(&target)?; + // SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means + // neither replacement nor implicit cross-volume copying is permitted. + if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +// Filesystem tests run on Unix too; production migration is Windows-only. +#[cfg(not(windows))] +fn rename_missing(source: &Path, target: &Path) -> io::Result<()> { + if target.try_exists()? { + return Err(io::Error::from(io::ErrorKind::AlreadyExists)); + } + fs::rename(source, target) +} + +fn remove_entry(path: &Path) -> io::Result<()> { + if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) } +} + +fn copy_tree(source: &Path, target: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink() { + return Err(io::Error::other("refusing to follow a link during data migration")); + } + if metadata.is_dir() { + fs::create_dir(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + copy_tree(&entry.path(), &target.join(entry.file_name()))?; + } + } else if metadata.is_file() { + fs::copy(source, target)?; + } else { + return Err(io::Error::other("unsupported data entry")); + } + Ok(()) +} + +/// Make our absolute .cmd wrappers relative to their bin directory. They remain +/// valid in both locations if the move must be rolled back. npm shims already use +/// relative paths; payloads and user configuration are never rewritten. +fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> { + if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") { + return Ok(()); + } + let bin = root.join("bin"); + if !bin.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(bin)? { + let path = entry?.path(); + if path.extension().is_none_or(|ext| ext != "cmd") { + continue; + } + let text = fs::read_to_string(&path)?; + if text.starts_with("@echo off\r\n") { + let updated = relative_wrapper(&text, source); + if updated != text { + fs::write(path, updated)?; + } + } + } + Ok(()) +} + +fn relative_wrapper(text: &str, source: &Path) -> String { + // PathBuf can contain both separators (notably roots joined with "tools/demo"). + // Compare normalized paths, but retain every other byte of the managed wrapper. + let normalized = text.replace('\\', "/"); + let prefix = source.to_string_lossy().replace('\\', "/"); + #[cfg(windows)] + let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase()); + let mut updated = text.to_owned(); + let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect(); + for offset in offsets.into_iter().rev() { + let end = offset + prefix.len(); + if normalized.as_bytes().get(end) == Some(&b'/') { + updated.replace_range(offset..end, "%~dp0.."); + } + } + updated +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() { + let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"; + assert_eq!( + relative_wrapper(text, Path::new("C:/old/tools/demo")), + "@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n" + ); + } + + #[test] + fn storage_policy_is_conservative_and_preserves_unix_layout() { + for entry in [ + "tools/jdks", + "language-server-runtimes", + "lsp/jdtls", + "index/x", + "cache/x", + "notebook-runtime", + "server", + "logs", + "agents/tools", + "agents/registry.json", + "java-usages-1.1.1.jar", + ] { + assert!(is_local(Path::new(entry)), "{entry}"); + assert_eq!( + data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)), + Path::new("local").join(entry) + ); + assert_eq!( + data_path(Path::new("data"), Path::new("data"), Path::new(entry)), + Path::new("data").join(entry) + ); + } + for entry in [ + "git-credentials.json", + "history", + "remote", + "scratches", + "consoles", + "plugins", + "workspaces", + "agents/models.json", + "agents/project", + "unknown", + ] { + assert!(!is_local(Path::new(entry)), "{entry}"); + assert_eq!( + data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)), + Path::new("roaming").join(entry) + ); + } + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn unix_startup_does_not_migrate_even_if_given_distinct_roots() { + assert_eq!(dirs::data_dir(), dirs::data_local_dir()); + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("index")).unwrap(); + migrate(&old, &new); + assert!(old.join("index").exists()); + assert!(!new.exists()); + } + + #[test] + fn decisions_cover_missing_existing_and_failed_renames() { + assert_eq!(action(true, true, false), Action::Skip); + assert_eq!(action(false, false, false), Action::Skip); + assert_eq!(action(false, true, true), Action::Skip); + assert_eq!(action(false, true, false), Action::Rename); + assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy); + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] { + assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource); + } + } + + #[test] + fn same_volume_moves_and_second_start_does_not_overwrite() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("tools/demo/bin")).unwrap(); + let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display()); + fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap(); + fs::create_dir_all(old.join("tools/ty/venv")).unwrap(); + fs::write(old.join("git-credentials.json"), "secret").unwrap(); + fs::create_dir_all(old.join("index")).unwrap(); + migrate_entries(&old, &new); + assert!(!old.join("tools/demo").exists()); + assert!(old.join("tools/ty/venv").exists()); + assert!(old.join("git-credentials.json").exists()); + assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0..")); + fs::create_dir_all(old.join("index")).unwrap(); + fs::write(old.join("index/legacy"), "old").unwrap(); + migrate_entries(&old, &new); + assert!(old.join("index/legacy").exists()); + assert!(!new.join("index/legacy").exists()); + } + + #[cfg(unix)] + #[test] + fn copy_failure_does_not_publish_or_remove_source() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap(); + assert!(copy_then_remove(&old, &new).is_err()); + assert!(old.join("payload").exists()); + assert!(!new.exists()); + assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1); + } + + #[test] + fn staged_copy_publishes_complete_data_and_preserves_existing_target() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("nested")).unwrap(); + fs::write(old.join("nested/tool.exe"), "payload").unwrap(); + copy_then_remove(&old, &new).unwrap(); + assert!(!old.exists()); + assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("legacy"), "old").unwrap(); + copy_then_remove(&old, &new).unwrap(); + assert!(old.join("legacy").exists()); + assert!(!new.join("legacy").exists()); + let old_file = tmp.path().join("registry-old.json"); + let new_file = tmp.path().join("registry-new.json"); + fs::write(&old_file, "cached registry").unwrap(); + copy_then_remove(&old_file, &new_file).unwrap(); + assert!(!old_file.exists()); + assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry"); + } + + #[test] + fn publication_refuses_existing_files_and_empty_directories() { + let tmp = tempfile::tempdir().unwrap(); + for directory in [false, true] { + let old = tmp.path().join(if directory { "old-dir" } else { "old-file" }); + let new = tmp.path().join(if directory { "new-dir" } else { "new-file" }); + if directory { + fs::create_dir(&old).unwrap(); + fs::create_dir(&new).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + } else { + fs::write(&old, "old").unwrap(); + fs::write(&new, "new").unwrap(); + } + assert!(rename_missing(&old, &new).is_err()); + assert!(old.exists() && new.exists()); + if directory { + assert!(!new.join("payload").exists()); + } else { + assert_eq!(fs::read_to_string(new).unwrap(), "new"); + } + } + } + + #[test] + fn wrapper_failure_keeps_the_old_installation() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old/tools/demo"); + let new = tmp.path().join("new/tools/demo"); + fs::create_dir_all(old.join("bin")).unwrap(); + fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap(); + assert!(try_rename(&old, &new).is_err()); + assert!(old.join("bin/demo.cmd").exists()); + assert!(!new.exists()); + } + + #[test] + fn failures_keep_source_without_publishing_partial_data() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let blocker = tmp.path().join("blocker"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + fs::write(&blocker, "file").unwrap(); + assert!(try_rename(&old, &blocker.join("target")).is_err()); + assert!(copy_then_remove(&old, &blocker.join("target")).is_err()); + assert!(old.join("payload").exists()); + assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err()); + assert!(!tmp.path().join("new").exists()); + assert!( + !fs::read_dir(tmp.path()) + .unwrap() + .flatten() + .any(|e| e.file_name().to_string_lossy().starts_with(".migration-")) + ); + } +} diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 2da08e36..7bc9af9d 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -45,7 +45,7 @@ pub async fn format( } let config = config.unwrap().clone(); let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); - let tools = Tools::new(&host.data_dir); + let tools = Tools::new(&host.local_data_dir); let whole_selection = range.is_some() && !config.range; let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 5937b972..7acd168d 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -3,6 +3,7 @@ //! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server` //! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). +pub mod data_dirs; pub mod environment; mod formatter; mod java_runtime; @@ -33,7 +34,10 @@ pub type SinkFactory = Box SharedSink + Send + Sync>; pub struct HostConfig { pub config_dir: PathBuf, + /// Persistent user state, including credentials and recovery history. pub data_dir: PathBuf, + /// Machine-local tools and rebuildable caches. + pub local_data_dir: PathBuf, pub languages: Arc, /// Grammars, possibly still being built on another thread. pub syntax: Arc>>, @@ -49,7 +53,10 @@ pub struct HostConfig { pub struct Host { pub trust: trust::TrustStore, pub config_dir: PathBuf, + /// Persistent user state, including credentials and recovery history. pub data_dir: PathBuf, + /// Machine-local tools and rebuildable caches. + pub local_data_dir: PathBuf, pub languages: Arc, syntax: Arc>>, pub global_settings: SettingsStore, @@ -76,8 +83,17 @@ pub struct Host { impl Host { pub fn new(config: HostConfig) -> Self { - let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } = - config; + let HostConfig { + config_dir, + data_dir, + local_data_dir, + languages, + syntax, + sink_for, + owner, + runtime, + project_environment, + } = config; let global_settings = SettingsStore::load(&config_dir, None); let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); ssh.set_configs(global_settings.effective().ssh.configs); @@ -86,7 +102,7 @@ impl Host { proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")), remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), ssh, - environment: environment::Environment::new(project_environment, data_dir.clone()), + environment: environment::Environment::new(project_environment, local_data_dir.clone()), global_settings, // Sessions, runs and terminals report to the window that started them (`window_sink`). terminals: TerminalManager::new(), @@ -96,6 +112,7 @@ impl Host { syntax, config_dir, data_dir, + local_data_dir, sink_for, owner, runtime, diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 47f089f9..f72d603c 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult> { /// Where ACP Registry agents are saved (`/agents`, like the user's own) and installed. pub(crate) fn registry_paths(host: &Host) -> RegistryPaths { - RegistryPaths::new(&host.config_dir, &host.data_dir) + RegistryPaths::new(&host.config_dir, &host.local_data_dir) } fn join_err(e: impl std::fmt::Display) -> IdeError { diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 9dd91de7..2af1eedd 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide .await?; } if config.adapter == ide_dap::kotlin::ADAPTER { - let data_dir = host.data_dir.clone(); + let data_dir = host.local_data_dir.clone(); let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); let runtime_settings = settings.clone(); let runtime = host diff --git a/crates/ide-host/src/ops/doc/tests.rs b/crates/ide-host/src/ops/doc/tests.rs index 2f8d8bda..6c649bed 100644 --- a/crates/ide-host/src/ops/doc/tests.rs +++ b/crates/ide-host/src/ops/doc/tests.rs @@ -30,6 +30,7 @@ impl Fixture { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |_| { diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 47beb38d..d1cc0a61 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -61,7 +61,7 @@ async fn resolve_launch( let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None }; let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); - let data_dir = host.data_dir.clone(); + let data_dir = host.local_data_dir.clone(); let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { let _external_build = external_build; diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index f8f05b1f..b92399c2 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; /// The installer, reporting the executables chosen in the window's settings. fn tools(host: &Host, window: &str) -> IdeResult { host.require_trust(window)?; - let data = host.data_dir.clone(); + let data = host.local_data_dir.clone(); let (settings, root) = match host.project(window) { Ok(project) => (project.settings.effective(), project.root.clone()), Err(_) => (host.global_settings.effective(), Default::default()), @@ -104,7 +104,7 @@ pub async fn jdk_download_releases( vendor: String, request: RequestId, ) -> IdeResult { - let tools = Tools::new(&host.data_dir); + let tools = Tools::new(&host.local_data_dir); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); host.end_request(request); @@ -113,7 +113,7 @@ pub async fn jdk_download_releases( /// Where Download JDK puts JDKs unless told otherwise. pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult { - Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into()) + Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into()) } /// Downloads the latest `vendor` JDK of Java `version` into `location` (default: @@ -130,7 +130,7 @@ pub async fn jdk_download( request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = Tools::new(&host.data_dir); + let tools = Tools::new(&host.local_data_dir); let location = location.filter(|p| !p.as_os_str().is_empty()); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index baf7e10e..faf28db0 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -112,7 +112,7 @@ impl Host { let project_key = project_key(&root); let index = ProjectIndex::open( &root, - &self.data_dir.join("index").join(&project_key), + &self.local_data_dir.join("index").join(&project_key), index_roots(&root, extra_roots.clone()), excludes.clone(), languages.clone(), @@ -127,7 +127,7 @@ impl Host { { let settings = settings.clone(); let root = root.clone(); - let data_dir = self.data_dir.clone(); + let data_dir = self.local_data_dir.clone(); let config_dir = self.config_dir.clone(); let sink = sink.clone(); let formatter_problem = std::sync::Mutex::new(None); @@ -214,7 +214,7 @@ impl Host { { let settings = settings.clone(); let root = root.clone(); - let data_dir = self.data_dir.clone(); + let data_dir = self.local_data_dir.clone(); let sink = sink.clone(); lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| { let effective = settings.effective(); @@ -255,8 +255,8 @@ impl Host { let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new( root.clone(), - self.data_dir.join("cache").join(&project_key).join("build"), - &self.data_dir, + self.local_data_dir.join("cache").join(&project_key).join("build"), + &self.local_data_dir, settings.clone(), lsp.clone(), sink.clone(), @@ -385,7 +385,7 @@ impl Host { let (db, http_history, agents) = self.project_services(&root, &project_key); let notebooks = crate::notebook::NotebookService::new( - self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), + self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); ide_agent::registry::maintain(crate::ops::agent::registry_paths(self)); @@ -404,9 +404,9 @@ impl Host { structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots, - cache_dir: self.data_dir.join("cache").join(&project_key), + cache_dir: self.local_data_dir.join("cache").join(&project_key), java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( - self.data_dir.join("cache").join(&project_key).join("build"), + self.local_data_dir.join("cache").join(&project_key).join("build"), )), db, http_history, @@ -509,7 +509,7 @@ impl Host { let project_key = project_key(&dir); let (db, http_history, agents) = self.project_services(&dir, &project_key); let notebooks = crate::notebook::NotebookService::new( - self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), + self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); let project = Arc::new(Project { @@ -526,9 +526,9 @@ impl Host { structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots: Arc::default(), - cache_dir: self.data_dir.join("cache").join(&project_key), + cache_dir: self.local_data_dir.join("cache").join(&project_key), java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( - self.data_dir.join("cache").join(&project_key).join("build"), + self.local_data_dir.join("cache").join(&project_key).join("build"), )), db, http_history, diff --git a/crates/ide-host/tests/data_dirs.rs b/crates/ide-host/tests/data_dirs.rs new file mode 100644 index 00000000..557a2b7d --- /dev/null +++ b/crates/ide-host/tests/data_dirs.rs @@ -0,0 +1,39 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let data = base.join("roaming"); + let local = base.join("local"); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("notes.txt"), "hello\n").unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: data.clone(), + local_data_dir: local.clone(), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + assert_eq!( + std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()), + local.join("tools/jdks") + ); + let light = host.open_light("main", &root.join("notes.txt")).unwrap(); + assert!(light.cache_dir.starts_with(&local)); + assert!(data.join("history").exists()); + assert!(!local.join("history").exists()); + let project = host.open_project("main", &root).unwrap(); + assert!(project.cache_dir.starts_with(&local)); + assert!(local.join("index").exists()); + assert!(!data.join("index").exists()); + assert!(!data.join("tools").exists()); + host.shutdown().await; +} diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index d3732431..1eff9e38 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -9,6 +9,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/properties.rs b/crates/ide-host/tests/properties.rs index dce783dd..2d8e5a1a 100644 --- a/crates/ide-host/tests/properties.rs +++ b/crates/ide-host/tests/properties.rs @@ -20,6 +20,7 @@ fn host(base: &Path) -> Host { Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/proxy.rs b/crates/ide-host/tests/proxy.rs index cef7910d..cf74d5dd 100644 --- a/crates/ide-host/tests/proxy.rs +++ b/crates/ide-host/tests/proxy.rs @@ -10,6 +10,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/remote_env.rs b/crates/ide-host/tests/remote_env.rs index 5f1fbf7c..121f3bed 100644 --- a/crates/ide-host/tests/remote_env.rs +++ b/crates/ide-host/tests/remote_env.rs @@ -79,6 +79,7 @@ fn server_environment_from_login_shell_and_devcontainer() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |_| sink_events.clone() as SharedSink), diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 58e4ede3..2a101b01 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -56,6 +56,7 @@ async fn serves_projects_files_documents_search_and_runs() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))), @@ -261,6 +262,7 @@ async fn local_deployment_runs_on_the_project_host() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))), @@ -328,6 +330,7 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), diff --git a/crates/ide-host/tests/run_configs.rs b/crates/ide-host/tests/run_configs.rs index 339121f7..9313c756 100644 --- a/crates/ide-host/tests/run_configs.rs +++ b/crates/ide-host/tests/run_configs.rs @@ -6,6 +6,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/terminal_activation.rs b/crates/ide-host/tests/terminal_activation.rs index 9561a23e..3678ffdb 100644 --- a/crates/ide-host/tests/terminal_activation.rs +++ b/crates/ide-host/tests/terminal_activation.rs @@ -24,6 +24,7 @@ fn terminal_activation_uses_the_resolved_default_shell() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 4a1d0250..aa41a315 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -11,6 +11,7 @@ fn host(base: &Path, environment: bool) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/trust_git_binary.rs b/crates/ide-host/tests/trust_git_binary.rs index 0002b46b..d4f27820 100644 --- a/crates/ide-host/tests/trust_git_binary.rs +++ b/crates/ide-host/tests/trust_git_binary.rs @@ -12,6 +12,7 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/vcs_encoding.rs b/crates/ide-host/tests/vcs_encoding.rs index 71b8e6a4..7da472b8 100644 --- a/crates/ide-host/tests/vcs_encoding.rs +++ b/crates/ide-host/tests/vcs_encoding.rs @@ -28,6 +28,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/vcs_roots.rs b/crates/ide-host/tests/vcs_roots.rs index d0431a1a..b833e8ed 100644 --- a/crates/ide-host/tests/vcs_roots.rs +++ b/crates/ide-host/tests/vcs_roots.rs @@ -31,6 +31,7 @@ async fn patches_read_and_write_the_requested_repository() { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/workspaces.rs b/crates/ide-host/tests/workspaces.rs index 86a8d9cd..cfa22542 100644 --- a/crates/ide-host/tests/workspaces.rs +++ b/crates/ide-host/tests/workspaces.rs @@ -22,6 +22,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-workspace/src/caches.rs b/crates/ide-workspace/src/caches.rs index 65dc1603..b1ff5ca9 100644 --- a/crates/ide-workspace/src/caches.rs +++ b/crates/ide-workspace/src/caches.rs @@ -23,16 +23,26 @@ pub struct InvalidateCachesOptions { const MARKER: &str = "invalidate-caches.json"; /// Data-directory folders removed for `options`. -pub fn cache_dirs(data_dir: &Path, options: &InvalidateCachesOptions) -> Vec { - let mut dirs = vec![data_dir.join("index")]; +pub fn cache_dirs(data_dir: &Path, local_data_dir: &Path, options: &InvalidateCachesOptions) -> Vec { + let mut dirs = vec![local_data_dir.join("index")]; if options.local_history { dirs.push(data_dir.join("history")); } if options.language_servers { - dirs.push(data_dir.join("lsp")); + dirs.push(local_data_dir.join("lsp")); } if options.build_caches { - dirs.push(data_dir.join("cache")); + dirs.push(local_data_dir.join("cache")); + } + // Also invalidate pre-migration caches so a later start cannot revive them. + if data_dir != local_data_dir { + dirs.push(data_dir.join("index")); + if options.language_servers { + dirs.push(data_dir.join("lsp")); + } + if options.build_caches { + dirs.push(data_dir.join("cache")); + } } dirs } @@ -57,11 +67,11 @@ pub fn cancel(data_dir: &Path) { /// Carries out a scheduled invalidation (call at start-up, before projects /// open). Returns the folders removed. -pub fn apply_pending(data_dir: &Path) -> Vec { +pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec { let Some(options) = pending(data_dir) else { return Vec::new() }; cancel(data_dir); let mut removed = Vec::new(); - for dir in cache_dirs(data_dir, &options) { + for dir in cache_dirs(data_dir, local_data_dir, &options) { match fs::remove_dir_all(&dir) { Ok(()) => removed.push(dir), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} @@ -75,6 +85,34 @@ pub fn apply_pending(data_dir: &Path) -> Vec { mod tests { use super::*; + #[test] + fn split_roots_invalidate_local_caches_and_roaming_history() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("roaming"); + let local = tmp.path().join("local"); + let options = InvalidateCachesOptions { local_history: true, language_servers: true, build_caches: true }; + assert_eq!( + cache_dirs(&data, &local, &options), + vec![ + local.join("index"), + data.join("history"), + local.join("lsp"), + local.join("cache"), + data.join("index"), + data.join("lsp"), + data.join("cache") + ] + ); + for path in cache_dirs(&data, &local, &options) { + fs::create_dir_all(path).unwrap(); + } + fs::create_dir_all(local.join("tools")).unwrap(); + schedule(&data, &options).unwrap(); + assert_eq!(apply_pending(&data, &local).len(), 7); + assert!(local.join("tools").exists()); + assert_eq!(pending(&data), None); + } + #[test] fn scheduled_invalidation_runs_once() { let tmp = tempfile::tempdir().unwrap(); @@ -82,16 +120,16 @@ mod tests { for d in ["index/p1", "history/p1", "lsp/jdtls/x", "cache/p1/build", "tools/jdk"] { fs::create_dir_all(data.join(d)).unwrap(); } - assert!(apply_pending(data).is_empty()); + assert!(apply_pending(data, data).is_empty()); let opts = InvalidateCachesOptions { local_history: true, language_servers: false, build_caches: true }; schedule(data, &opts).unwrap(); assert_eq!(pending(data), Some(opts)); - let removed = apply_pending(data); + let removed = apply_pending(data, data); assert_eq!(removed, vec![data.join("index"), data.join("history"), data.join("cache")]); assert!(data.join("lsp/jdtls/x").exists() && data.join("tools/jdk").exists()); assert_eq!(pending(data), None); - assert!(apply_pending(data).is_empty()); + assert!(apply_pending(data, data).is_empty()); schedule(data, &InvalidateCachesOptions::default()).unwrap(); cancel(data); diff --git a/perf/compare/apps.toml b/perf/compare/apps.toml index 30c01d25..5ebb48b3 100644 --- a/perf/compare/apps.toml +++ b/perf/compare/apps.toml @@ -22,7 +22,7 @@ install_dir = { windows = "%LOCALAPPDATA%/OpenIDEA", macos = "/Applications/Open logs = { windows = "%LOCALAPPDATA%/ai.l0ng.openidea/logs/openidea*.log", macos = "~/Library/Logs/ai.l0ng.openidea/openidea*.log", linux = "~/.local/share/ai.l0ng.openidea/logs/openidea*.log" } # The index and build caches. jdtls keeps its own workspace under lsp/jdtls, # next to the jdtls install, so a cold Java run still reuses jdtls's build state. -cache_dirs = { windows = ["%APPDATA%/ai.l0ng.openidea/index", "%APPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] } +cache_dirs = { windows = ["%LOCALAPPDATA%/ai.l0ng.openidea/index", "%LOCALAPPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] } [apps.openidea.markers] project_opened = 'opened .* in window' diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa413b6f..bee40a88 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -53,7 +53,7 @@ pub fn run() { let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier); // Managed LSP/DAP installs; must run before other threads start and before PATH is cached. - if let Some(data) = dirs::data_dir() { + if let Some(data) = dirs::data_local_dir() { ide_tools::extend_process_path(&data.join(&context.config().identifier)); ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier))); ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier))); diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 5bcf0cb5..e45f7eef 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -182,7 +182,7 @@ fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option, ) -> IdeResult { - let (data, version, os, arch) = (state.data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned()); + let (data, version, os, arch) = (state.local_data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned()); tokio::task::spawn_blocking(move || { ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| match e { ide_tools::InstallEvent::Line { text } => tracing::info!("openidea-server download: {text}"), @@ -259,7 +259,12 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: A `cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \ (or set OPENIDEA_SERVER_BINARY)", e.message, - state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server").display() + state + .local_data_dir + .join("server") + .join(format!("{os}-{arch}")) + .join("openidea-server") + .display() )) }); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 67413569..e3ad4ea3 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -64,21 +64,28 @@ impl AppState { } let config_dir = app.path().app_config_dir()?; let data_dir = app.path().app_data_dir()?; + let local_data_dir = app.path().app_local_data_dir()?; std::fs::create_dir_all(&config_dir)?; std::fs::create_dir_all(&data_dir)?; + if let Err(error) = std::fs::create_dir_all(&local_data_dir) { + tracing::warn!(?local_data_dir, %error, "cannot create local data directory"); + } + tracing::info!(?data_dir, ?local_data_dir, "application data directories"); // Saved git HTTPS credentials (askpass answers them silently). ide_ssh::git_creds::init(data_dir.join("git-credentials.json")); // File › Invalidate Caches… requested at the last run. - let removed = ide_workspace::caches::apply_pending(&data_dir); + let removed = ide_workspace::caches::apply_pending(&data_dir, &local_data_dir); if !removed.is_empty() { tracing::info!("invalidated caches: {removed:?}"); } + ide_host::data_dirs::migrate(&data_dir, &local_data_dir); let global_sink: SharedSink = Arc::new(TauriSink { app: app.clone(), window: None }); let languages = Arc::new(LanguageRegistry::load(Some(&config_dir.join("languages")))?); let sink_app = app.clone(); let host = Arc::new(Host::new(ide_host::HostConfig { config_dir: config_dir.clone(), data_dir: data_dir.clone(), + local_data_dir, languages: languages.clone(), syntax, sink_for: Box::new(move |window| {