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 1/6] 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 2/6] 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 3/6] 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 4/6] 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| { From 4034853f91efa04bc4f7e8d39773cd421a53fb66 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:21:20 +0800 Subject: [PATCH 5/6] fix(windows): keep maximized windows within the work area (#644) * fix(windows): keep maximized windows within the work area * fix(windows): constrain final maximized outer placement * Revert "fix(windows): constrain final maximized outer placement" This reverts commit e481c900cac075c34d2ef2d0abd4facb99b36ddf. --- Cargo.lock | 1 + src-tauri/Cargo.toml | 8 ++ src-tauri/src/lib.rs | 5 + src-tauri/src/window_work_area.rs | 208 ++++++++++++++++++++++++++++++ src-tauri/src/windows.rs | 12 ++ 5 files changed, 234 insertions(+) create mode 100644 src-tauri/src/window_work_area.rs diff --git a/Cargo.lock b/Cargo.lock index 3d5f3a35..5c25ebe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6208,6 +6208,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ebf05c5..ab942c55 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -78,3 +78,11 @@ objc2-core-foundation = { version = "0.3", default-features = false, features = objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } # Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bee40a88..ed795e66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,8 @@ mod theme_frame; #[cfg(target_os = "macos")] mod titlebar; mod window_groups; +#[cfg(any(windows, test))] +mod window_work_area; mod windows; use parking_lot::Mutex; @@ -70,6 +72,9 @@ pub fn run() { // Single instance first: a second launch focuses this one and opens its path argument. // Debug builds have a separate identifier per worktree. let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance)); + // Install before window-state restores maximized geometry during window creation. + #[cfg(windows)] + let app = app.plugin(window_work_area::init()); let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init()); #[cfg(target_os = "linux")] let app = app.plugin(linux_window_state::plugin()); diff --git a/src-tauri/src/window_work_area.rs b/src-tauri/src/window_work_area.rs new file mode 100644 index 00000000..116a748a --- /dev/null +++ b/src-tauri/src/window_work_area.rs @@ -0,0 +1,208 @@ +//! Native maximize bounds for every Windows frameless window, including state restoration. + +#[cfg(windows)] +pub use native::init; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Rect { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +/// All coordinates are physical pixels in the selected monitor's coordinate space. +/// Auto-hide needs one exposed pixel, but an already reserved edge needs no extra inset. +fn bounds(monitor: Rect, work: Rect, auto_hide: [bool; 4], fullscreen: bool) -> Rect { + if fullscreen { + return monitor; + } + let [left, top, right, bottom] = auto_hide; + Rect { + left: work.left + i32::from(left && work.left == monitor.left), + top: work.top + i32::from(top && work.top == monitor.top), + right: work.right - i32::from(right && work.right == monitor.right), + bottom: work.bottom - i32::from(bottom && work.bottom == monitor.bottom), + } +} + +fn maximize_geometry(monitor: Rect, area: Rect) -> (i32, i32, i32, i32) { + (area.left - monitor.left, area.top - monitor.top, area.right - area.left, area.bottom - area.top) +} + +#[cfg(windows)] +mod native { + use super::{Rect, bounds, maximize_geometry}; + use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}, + Graphics::Gdi::{ + GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromRect, MonitorFromWindow, + }, + UI::{ + Shell::{ + ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_GETAUTOHIDEBAREX, APPBARDATA, DefSubclassProc, + RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass, + }, + WindowsAndMessaging::{ + GWL_STYLE, GetWindowLongPtrW, IsZoomed, MINMAXINFO, NCCALCSIZE_PARAMS, WM_GETMINMAXINFO, WM_NCCALCSIZE, + WM_NCDESTROY, WS_CAPTION, + }, + }, + }; + + const SUBCLASS: usize = 0x410; + + pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("window-work-area") + .on_window_ready(|window| { + if window.is_decorated().unwrap_or(true) { + return; + } + let install = || -> tauri::Result<()> { + let hwnd = window.hwnd()?.0 as usize; + window.run_on_main_thread(move || { + // Subclasses must be installed and removed on the owning GUI thread. + if unsafe { SetWindowSubclass(hwnd as HWND, Some(window_proc), SUBCLASS, 0) } == 0 { + tracing::warn!( + "cannot install window work area handler: {}", + std::io::Error::last_os_error() + ); + } + }) + }; + if let Err(error) = install() { + tracing::warn!("cannot install window work area handler: {error}"); + } + }) + .build() + } + + fn rect(rect: RECT) -> Rect { + Rect { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } + } + + fn native_rect(rect: Rect) -> RECT { + RECT { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } + } + + unsafe fn monitor_bounds(monitor: HMONITOR, fullscreen: bool) -> Option<(Rect, Rect)> { + let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; + if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 { + return None; + } + let mut auto_hide = [false; 4]; + if !fullscreen { + for (index, edge) in [ABE_LEFT, ABE_TOP, ABE_RIGHT, ABE_BOTTOM].into_iter().enumerate() { + let mut data = APPBARDATA { + cbSize: size_of::() as u32, + uEdge: edge, + rc: info.rcMonitor, + ..Default::default() + }; + auto_hide[index] = unsafe { SHAppBarMessage(ABM_GETAUTOHIDEBAREX, &mut data) } != 0; + } + } + let monitor = rect(info.rcMonitor); + Some((monitor, bounds(monitor, rect(info.rcWork), auto_hide, fullscreen))) + } + + unsafe extern "system" fn window_proc( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _id: usize, + _data: usize, + ) -> LRESULT { + // Tao keeps WS_CAPTION on frameless windows and removes it for native fullscreen. + // Read native styles here: a Tauri state query from its window procedure can deadlock. + let fullscreen = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32 & WS_CAPTION == 0; + match message { + WM_GETMINMAXINFO if !fullscreen => { + // Keep Tao's min/max tracking constraints, then set the outer maximize bounds. + let result = unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }; + let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + if let Some((monitor, area)) = unsafe { monitor_bounds(monitor, false) } { + let (x, y, width, height) = maximize_geometry(monitor, area); + let info = unsafe { &mut *(lparam as *mut MINMAXINFO) }; + info.ptMaxPosition.x = x; + info.ptMaxPosition.y = y; + info.ptMaxSize.x = width; + info.ptMaxSize.y = height; + } + result + } + WM_NCCALCSIZE if wparam != 0 && (fullscreen || unsafe { IsZoomed(hwnd) } != 0) => { + let params = unsafe { &mut *(lparam as *mut NCCALCSIZE_PARAMS) }; + // Use the proposed rectangle: a move/snap/DPI change can target another monitor. + let monitor = unsafe { MonitorFromRect(¶ms.rgrc[0], MONITOR_DEFAULTTONEAREST) }; + if let Some((_, area)) = unsafe { monitor_bounds(monitor, fullscreen) } { + // Replace Tao's client-only clamp and global auto-hide query. Fullscreen + // must retain the entire display, even when entered from a maximized window. + params.rgrc[0] = native_rect(area); + 0 + } else { + unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } + } + } + WM_NCDESTROY => { + unsafe { RemoveWindowSubclass(hwnd, Some(window_proc), SUBCLASS) }; + unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } + } + _ => unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(left: i32, top: i32, right: i32, bottom: i32) -> Rect { + Rect { left, top, right, bottom } + } + + #[test] + fn taskbars_on_every_edge_and_secondary_displays() { + for (monitor, work, expected) in [ + // Reported case: maximized window must leave the bottom taskbar visible. + (r(0, 0, 1920, 1080), r(0, 0, 1920, 1040), (0, 0, 1920, 1040)), + (r(0, 0, 1920, 1080), r(48, 0, 1920, 1080), (48, 0, 1872, 1080)), + (r(0, 0, 1920, 1080), r(0, 40, 1920, 1080), (0, 40, 1920, 1040)), + (r(0, 0, 1920, 1080), r(0, 0, 1872, 1080), (0, 0, 1872, 1080)), + // Negative origin, top taskbar, 150% display beside a 100% display. + (r(-2560, -1440, 0, 0), r(-2560, -1380, 0, 0), (0, 60, 2560, 1380)), + // 250% display: physical bounds must not be scaled with the previous monitor's DPI. + (r(1920, 0, 5760, 2160), r(1920, 0, 5760, 2060), (0, 0, 3840, 2060)), + ] { + let area = bounds(monitor, work, [false; 4], false); + assert_eq!(area, work); + assert_eq!(maximize_geometry(monitor, area), expected); + } + } + + #[test] + fn auto_hide_leaves_a_physical_pixel_only_on_its_own_monitor_edge() { + let monitor = r(-1920, 0, 0, 1080); + for (edges, expected) in [ + ([true, false, false, false], r(-1919, 0, 0, 1080)), + ([false, true, false, false], r(-1920, 1, 0, 1080)), + ([false, false, true, false], r(-1920, 0, -1, 1080)), + ([false, false, false, true], r(-1920, 0, 0, 1079)), + ([false; 4], monitor), + ([true; 4], r(-1919, 1, -1, 1079)), + ] { + assert_eq!(bounds(monitor, monitor, edges, false), expected); + } + let reserved = r(-1920, 0, 0, 1040); + assert_eq!(bounds(monitor, reserved, [false, false, false, true], false), reserved); + } + + #[test] + fn fullscreen_covers_taskbars_including_when_entered_from_maximized() { + let monitor = r(1920, -100, 5760, 2060); + let work = r(1980, -100, 5760, 1960); + assert_eq!(bounds(monitor, work, [true; 4], true), monitor); + assert_eq!(bounds(monitor, work, [false; 4], false), work); + } +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index f905ed51..1599e060 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -841,9 +841,18 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri: /// scale and its position is given in physical pixels. #[cfg(not(target_os = "macos"))] pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { + // Placement setters leave maximized mode. Fit the restored frame, then reapply the saved state. + // A restored fullscreen window already covers its display and must not be fitted to the work area. + if window.is_fullscreen()? { + return Ok(()); + } let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); }; + let maximized = window.is_maximized()?; + if maximized { + window.unmaximize()?; + } let scale = monitor.scale_factor(); let area = monitor.work_area(); let origin = area.position.to_logical::(scale); @@ -872,6 +881,9 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind )))?; window.set_size(tauri::LogicalSize::new(width, height))?; } + if maximized { + window.maximize()?; + } Ok(()) } From b5dbd3c02012b3db022d0b8212e66eda09d30129 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:30:28 +0800 Subject: [PATCH 6/6] feat(cargo): dependency version inspections and updates (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml had no way to tell that a dependency had a newer release. Dependencies in [dependencies], [dev-dependencies], [build-dependencies], target tables and [workspace.dependencies] now get a weak warning when the newest stable release is outside the version requirement, with a quick fix that rewrites the requirement at its original precision ("0.7" → "0.10") in both string and inline-table forms. Versions complete inside the version string, newest first, without yanked releases. Path, git and workspace-inherited dependencies are skipped, prereleases are only suggested to prerelease requirements, and failures stay silent. Versions come from the crates.io sparse index through the backend's HTTP client, with an in-memory and on-disk cache and coalesced requests, behind a dependency version source shared by file-type parsers so other manifests can be added later. semver is now used directly; it was already linked into the app. Fixes #479 --- Cargo.lock | 1 + crates/ide-host/src/lib.rs | 4 + crates/ide-host/src/ops/dependencies.rs | 123 ++++++++++ crates/ide-host/src/ops/mod.rs | 2 + crates/ide-workspace/Cargo.toml | 1 + .../ide-workspace/src/dependencies/cache.rs | 102 +++++++++ .../src/dependencies/cache/tests.rs | 97 ++++++++ .../ide-workspace/src/dependencies/cargo.rs | 150 +++++++++++++ .../src/dependencies/cargo/tests.rs | 143 ++++++++++++ crates/ide-workspace/src/dependencies/mod.rs | 41 ++++ crates/ide-workspace/src/lib.rs | 1 + src-tauri/src/commands/dependencies.rs | 17 ++ src-tauri/src/commands/mod.rs | 2 + src/bindings.ts | 20 ++ src/editor/api.ts | 14 +- src/editor/lsp/diagnostics.ts | 7 +- src/editor/lsp/hover.ts | 2 +- src/editor/lsp/intentions.test.ts | 23 ++ src/editor/lsp/intentions.ts | 94 ++++++-- src/editor/lsp/popupStore.ts | 2 + .../dependencies/dependencies.test.ts | 210 ++++++++++++++++++ src/features/dependencies/index.ts | 208 +++++++++++++++++ src/features/index.ts | 2 + src/platform/inspections.ts | 11 + src/platform/locales/text/zh.json | 4 + src/platform/locales/textCatalog.test.ts | 1 + 26 files changed, 1257 insertions(+), 25 deletions(-) create mode 100644 crates/ide-host/src/ops/dependencies.rs create mode 100644 crates/ide-workspace/src/dependencies/cache.rs create mode 100644 crates/ide-workspace/src/dependencies/cache/tests.rs create mode 100644 crates/ide-workspace/src/dependencies/cargo.rs create mode 100644 crates/ide-workspace/src/dependencies/cargo/tests.rs create mode 100644 crates/ide-workspace/src/dependencies/mod.rs create mode 100644 src-tauri/src/commands/dependencies.rs create mode 100644 src/features/dependencies/dependencies.test.ts create mode 100644 src/features/dependencies/index.ts diff --git a/Cargo.lock b/Cargo.lock index 5c25ebe5..ee5c7b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4684,6 +4684,7 @@ dependencies = [ "ide-text", "ide-vfs", "parking_lot", + "semver", "serde", "serde_json", "specta", diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 7acd168d..66e21c9b 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -60,6 +60,7 @@ pub struct Host { pub languages: Arc, syntax: Arc>>, pub global_settings: SettingsStore, + pub dependency_versions: Arc, pub terminals: TerminalManager, pub processes: ProcessManager, /// Debug sessions (adapters run on this machine, next to the project). @@ -104,6 +105,9 @@ impl Host { ssh, environment: environment::Environment::new(project_environment, local_data_dir.clone()), global_settings, + dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new( + data_dir.join("cache/dependency-versions"), + )), // Sessions, runs and terminals report to the window that started them (`window_sink`). terminals: TerminalManager::new(), processes: ProcessManager::new(), diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs new file mode 100644 index 00000000..96dfad9b --- /dev/null +++ b/crates/ide-host/src/ops/dependencies.rs @@ -0,0 +1,123 @@ +//! Repository version lookups for manifest inspections and completion. +use crate::{Host, rpc::Dispatcher}; +use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId}; +use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo}; + +struct CratesIo; +impl DependencyVersionSource for CratesIo { + fn id(&self) -> &'static str { + "crates-io" + } + + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult> { + let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?; + let search_path = std::env::var_os("PATH").unwrap_or_default(); + let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited( + &format!("https://index.crates.io/{path}"), + Some(16 * 1024 * 1024), + cancel, + )?; + cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index")) + } +} + +/// `offset` limits a completion request to its version string; inspections omit it. +pub async fn dependency_versions( + host: &Host, + window: &str, + doc: DocId, + offset: Option, + request: RequestId, +) -> IdeResult> { + let project = host.project(window)?; + let cache = host.dependency_versions.clone(); + let cancel = host.begin_request(request); + let result = host + .blocking(move || { + let snap = project.documents.snapshot(doc)?; + if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for dependency in cargo::dependencies(&snap.text) { + if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) { + continue; + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?; + let update = cargo::update(&dependency.requirement, &versions); + out.push(DependencyVersions { dependency, versions, update }); + } + Ok(out) + }) + .await; + host.end_request(request); + result +} + +pub(crate) fn register(d: &mut Dispatcher) { + d.add("dependency_versions", |c| async move { + dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HostConfig; + use std::sync::Arc; + + struct Source; + impl DependencyVersionSource for Source { + fn id(&self) -> &'static str { + "crates-io" + } + fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult> { + Ok(vec!["1.10.0".into(), "0.2.1".into()]) + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn host_uses_synced_document_cache_and_cursor_filter_without_network() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n"; + let path = root.join("Cargo.toml"); + std::fs::write(&path, text).unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(|_| {})), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = host.open_project("main", &root).unwrap(); + let info = project.documents.open(&path).unwrap(); + for package in ["tokio", "serde"] { + host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap(); + } + let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10"); + assert!(all[1].update.is_none()); + let offset = all[1].dependency.from; + let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap(); + assert_eq!(at_cursor.len(), 1); + assert_eq!(at_cursor[0].dependency.package, "serde"); + assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty()); + host.cancel_request(RequestId(413)); + assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err()); + let other = root.join("other.toml"); + std::fs::write(&other, text).unwrap(); + let info = project.documents.open(&other).unwrap(); + assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty()); + host.close_project("main"); + } +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 228d02fc..93a0b28f 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -8,6 +8,7 @@ pub mod build; pub mod bytes; pub mod coverage; pub mod debug; +pub mod dependencies; pub mod deploy; pub mod doc; pub mod env; @@ -69,6 +70,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) { analysis::register(d); syntax::register(d); spell::register(d); + dependencies::register(d); history::register(d); coverage::register(d); build::register(d); diff --git a/crates/ide-workspace/Cargo.toml b/crates/ide-workspace/Cargo.toml index cfb75f56..9036108e 100644 --- a/crates/ide-workspace/Cargo.toml +++ b/crates/ide-workspace/Cargo.toml @@ -15,6 +15,7 @@ serde_json.workspace = true specta.workspace = true toml.workspace = true toml_edit.workspace = true +semver = "1.0.28" parking_lot.workspace = true blake3.workspace = true tracing.workspace = true diff --git a/crates/ide-workspace/src/dependencies/cache.rs b/crates/ide-workspace/src/dependencies/cache.rs new file mode 100644 index 00000000..7be33fe7 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cache.rs @@ -0,0 +1,102 @@ +use super::DependencyVersionSource; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const TTL: Duration = Duration::from_secs(3600); +const FAILURE_TTL: Duration = Duration::from_secs(30); +const MAX_DISK_BYTES: u64 = 2 * 1024 * 1024; +type Slot = Arc>>; + +#[derive(Clone, Serialize, Deserialize)] +struct Cached { + fetched: u64, + versions: Vec, + #[serde(skip)] + failed: bool, +} + +/// Same-package lookups share one lock and fetch. Different packages don't block +/// each other; waiting callers can cancel independently of the active fetch. +pub struct VersionCache { + directory: PathBuf, + entries: Mutex>, + ttl: Duration, +} + +impl VersionCache { + pub fn new(directory: PathBuf) -> Self { + Self { directory, entries: Mutex::default(), ttl: TTL } + } + + pub fn versions( + &self, + source: &dyn DependencyVersionSource, + package: &str, + cancel: &CancellationToken, + ) -> IdeResult> { + let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); + if !safe(source.id()) || !safe(package) || package.len() > 64 { + return Ok(Vec::new()); + } + let key = format!("{}-{}", source.id(), package.to_ascii_lowercase()); + let slot = self.entries.lock().entry(key.clone()).or_default().clone(); + let mut cached = loop { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if let Some(guard) = slot.try_lock_for(Duration::from_millis(50)) { + break guard; + } + }; + let checked_at = now(); + let fresh = |c: &Cached| { + c.fetched <= checked_at && checked_at - c.fetched < if c.failed { FAILURE_TTL } else { self.ttl }.as_secs() + }; + if let Some(entry) = cached.as_ref().filter(|c| fresh(c)) { + return Ok(entry.versions.clone()); + } + let path = self.directory.join(format!("{key}.json")); + let disk = std::fs::metadata(&path) + .ok() + .filter(|m| m.len() <= MAX_DISK_BYTES) + .and_then(|_| std::fs::read(&path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(fresh); + if let Some(entry) = disk { + let versions = entry.versions.clone(); + *cached = Some(entry); + return Ok(versions); + } + let result = source.fetch(package, cancel); + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let failed = result.is_err(); + let versions = result.unwrap_or_default(); + let entry = Cached { fetched: now(), versions: versions.clone(), failed }; + if !failed + && let Ok(bytes) = serde_json::to_vec(&entry) + && std::fs::create_dir_all(&self.directory).is_ok() + { + let temporary = path.with_extension("tmp"); + if std::fs::write(&temporary, bytes).is_ok() { + let _ = std::fs::rename(&temporary, &path); + let _ = std::fs::remove_file(&temporary); + } + } + *cached = Some(entry); + Ok(versions) + } +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-workspace/src/dependencies/cache/tests.rs b/crates/ide-workspace/src/dependencies/cache/tests.rs new file mode 100644 index 00000000..c8426510 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cache/tests.rs @@ -0,0 +1,97 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Source { + calls: AtomicUsize, + fail: bool, +} +impl DependencyVersionSource for Source { + fn id(&self) -> &'static str { + "test" + } + fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult> { + self.calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(20)); + if self.fail { Err(IdeError::unavailable("offline")) } else { Ok(vec!["2.0.0".into()]) } + } +} +fn source(fail: bool) -> Source { + Source { calls: AtomicUsize::new(0), fail } +} + +#[test] +fn memory_disk_ttl_corruption_and_failed_cache_writes() { + let dir = tempfile::tempdir().unwrap(); + let s = source(false); + let cancel = CancellationToken::new(); + let cache = VersionCache::new(dir.path().into()); + for _ in 0..2 { + assert_eq!(cache.versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]); + } + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 1, "disk hit after service restart"); + let mut expired = VersionCache::new(dir.path().into()); + expired.ttl = Duration::ZERO; + expired.versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 2); + std::fs::write(dir.path().join("test-foo.json"), b"corrupt").unwrap(); + VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 3); + let file = dir.path().join("not-a-directory"); + std::fs::write(&file, "x").unwrap(); + assert_eq!(VersionCache::new(file).versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]); +} + +#[test] +fn concurrent_fetches_coalesce_and_failures_are_silent_and_short_cached() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"])); + } + }); + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + let s = source(true); + for _ in 0..2 { + assert!(cache.versions(&s, "offline", &CancellationToken::new()).unwrap().is_empty()); + } + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + assert!(!dir.path().join("test-offline.json").exists(), "failures aren't persisted"); + let slot = cache.entries.lock().get("test-offline").unwrap().clone(); + slot.lock().as_mut().unwrap().fetched -= 31; + cache.versions(&s, "offline", &CancellationToken::new()).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn cancellation_and_invalid_cache_keys_do_not_fetch() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + let token = CancellationToken::new(); + token.cancel(); + assert!(cache.versions(&s, "foo", &token).is_err()); + assert!(cache.versions(&s, "../foo", &CancellationToken::new()).unwrap().is_empty()); + assert_eq!(s.calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn cancelled_waiter_does_not_cancel_the_shared_fetch() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + let slot = Arc::new(Mutex::new(None)); + cache.entries.lock().insert("test-foo".into(), slot.clone()); + let guard = slot.lock(); + let token = CancellationToken::new(); + std::thread::scope(|scope| { + let waiter = scope.spawn(|| cache.versions(&s, "foo", &token)); + token.cancel(); + assert!(waiter.join().unwrap().is_err()); + }); + drop(guard); + assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]); +} diff --git a/crates/ide-workspace/src/dependencies/cargo.rs b/crates/ide-workspace/src/dependencies/cargo.rs new file mode 100644 index 00000000..a63e701e --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cargo.rs @@ -0,0 +1,150 @@ +use super::{DependencyUpdate, ManifestDependency}; +use semver::{Op, Version, VersionReq}; +use serde::Deserialize; +use toml_edit::{Document, Item, TableLike}; + +/// Cargo's sparse-index path, including the special cases for short names. +pub fn index_path(name: &str) -> Option { + if name.is_empty() + || name.len() > 64 + || !name.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')) + || !name.as_bytes()[0].is_ascii_alphabetic() + { + return None; + } + let name = name.to_ascii_lowercase(); + Some(match name.len() { + 1 => format!("1/{name}"), + 2 => format!("2/{name}"), + 3 => format!("3/{}/{name}", &name[..1]), + _ => format!("{}/{}/{name}", &name[..2], &name[2..4]), + }) +} + +pub fn parse_index(text: &str) -> Option> { + #[derive(Deserialize)] + struct Entry { + vers: String, + #[serde(default)] + yanked: bool, + } + let mut versions = Vec::new(); + for line in text.lines().filter(|l| !l.trim().is_empty()) { + let entry: Entry = serde_json::from_str(line).ok()?; + if !entry.yanked { + versions.push(Version::parse(&entry.vers).ok()?); + } + } + versions.sort_unstable_by(|a, b| b.cmp(a)); + versions.dedup(); + Some(versions.into_iter().map(|v| v.to_string()).collect()) +} + +/// A complete TOML parser supplies spans, so strings/comments that resemble tables +/// never become dependencies. Broken documents are silent until they parse again. +pub fn dependencies(text: &str) -> Vec { + let Ok(doc) = text.parse::>() else { return Vec::new() }; + let mut out = Vec::new(); + sections(doc.as_table(), text, &mut out); + if let Some(workspace) = doc.get("workspace").and_then(Item::as_table_like) + && let Some(deps) = workspace.get("dependencies").and_then(Item::as_table_like) + { + collect(deps, text, &mut out); + } + if let Some(targets) = doc.get("target").and_then(Item::as_table_like) { + for (_, target) in targets.iter() { + if let Some(target) = target.as_table_like() { + sections(target, text, &mut out); + } + } + } + out.sort_unstable_by_key(|d| d.from); + out +} + +fn sections(table: &dyn TableLike, text: &str, out: &mut Vec) { + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(deps) = table.get(section).and_then(Item::as_table_like) { + collect(deps, text, out); + } + } +} + +fn collect(table: &dyn TableLike, text: &str, out: &mut Vec) { + for (name, item) in table.iter() { + let (package, value) = if let Some(dep) = item.as_table_like() { + if ["path", "git", "registry", "registry-index"].iter().any(|k| dep.contains_key(k)) + || dep.get("workspace").and_then(Item::as_bool) == Some(true) + { + continue; + } + let Some(version) = dep.get("version") else { continue }; + (dep.get("package").and_then(Item::as_str).unwrap_or(name), version) + } else { + (name, item) + }; + if index_path(package).is_none() { + continue; + } + let Some(requirement) = value.as_str() else { continue }; + let Some(span) = value.span() else { continue }; + let Some(raw) = text.get(span.clone()) else { continue }; + // Spans cover the encoded TOML string. Replace its entire contents, + // including escapes, while retaining the original quote delimiters. + let delimiter = if raw.starts_with("\"\"\"") || raw.starts_with("'''") { 3 } else { 1 }; + out.push(ManifestDependency { + package: package.to_owned(), + requirement: requirement.to_owned(), + from: text[..span.start + delimiter].encode_utf16().count() as u32, + to: text[..span.end - delimiter].encode_utf16().count() as u32, + }); + } +} + +pub fn update(requirement: &str, versions: &[String]) -> Option { + let req = VersionReq::parse(requirement).ok()?; + let prerelease = req.comparators.iter().any(|c| !c.pre.is_empty()); + let latest = + versions.iter().filter_map(|v| Version::parse(v).ok()).filter(|v| prerelease || v.pre.is_empty()).max()?; + if req.matches(&latest) { + return None; + } + // An unavailable future version isn't an invitation to downgrade. + if req.comparators.iter().any(|c| { + (c.op == Op::Greater + && c.patch.is_none() + && (latest.major < c.major + || (latest.major == c.major && c.minor.is_none_or(|minor| latest.minor <= minor)))) + || !matches!(c.op, Op::Less | Op::LessEq) + && latest + <= Version { + major: c.major, + minor: c.minor.unwrap_or(0), + patch: c.patch.unwrap_or(0), + pre: c.pre.clone(), + build: Default::default(), + } + }) { + return None; + } + let trimmed = requirement.trim(); + let digits = trimmed.trim_start_matches(['^', '~', '=']).trim_start(); + let simple = digits.split('.').all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())); + let mut replacement = latest.to_string(); + if simple && latest.pre.is_empty() { + let precision = digits.split('.').count(); + let version = match precision { + 1 => latest.major.to_string(), + 2 => format!("{}.{}", latest.major, latest.minor), + _ => latest.to_string(), + }; + let candidate = format!("{}{version}", &trimmed[..trimmed.len() - digits.len()]); + if VersionReq::parse(&candidate).is_ok_and(|r| r.matches(&latest)) { + replacement = candidate; + } + } + Some(DependencyUpdate { latest: latest.to_string(), replacement }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-workspace/src/dependencies/cargo/tests.rs b/crates/ide-workspace/src/dependencies/cargo/tests.rs new file mode 100644 index 00000000..7be9927d --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cargo/tests.rs @@ -0,0 +1,143 @@ +use super::*; + +#[test] +fn sparse_paths_and_input_validation() { + for (name, path) in + [("A", "1/a"), ("ab", "2/ab"), ("AbC", "3/a/abc"), ("serde", "se/rd/serde"), ("my_crate", "my/_c/my_crate")] + { + assert_eq!(index_path(name).as_deref(), Some(path)); + } + for name in ["", "../serde", "a/b", "sé", "-foo", "a?b", "1abc"] { + assert!(index_path(name).is_none(), "{name}"); + } +} + +#[test] +fn sparse_index_filters_yanked_sorts_semver_and_keeps_prereleases_for_completion() { + let text = r#"{"vers":"1.9.0","yanked":false} +{"vers":"2.0.0-beta.1","yanked":false} +{"vers":"9.0.0","yanked":true} +{"vers":"1.10.0","yanked":false} +{"vers":"1.10.0"}"#; + assert_eq!(parse_index(text).unwrap(), ["2.0.0-beta.1", "1.10.0", "1.9.0"]); + assert!(parse_index("{broken").is_none()); + assert!(parse_index(r#"{"vers":"bogus"}"#).is_none()); +} + +#[test] +fn cargo_requirements_updates_and_precision() { + for (req, latest, expected) in [ + ("1", "1.9.0", None), + ("1.0.100", "1.0.228", None), + ("^1.2", "1.9.0", None), + ("0.7", "0.7.9", None), + ("0.0.1", "0.0.2", Some("0.0.2")), + ("1", "2.3.4", Some("2")), + ("1.2", "2.3.4", Some("2.3")), + ("1.2.3", "2.3.0", Some("2.3.0")), + ("1.2.3", "1.3.0", None), + ("0.2", "1.3.0", Some("1.3")), + ("=1.2.3", "1.3.0", Some("=1.3.0")), + ("~1.2", "1.3.0", Some("~1.3")), + ("^0.2", "0.3.1", Some("^0.3")), + ("1.*", "1.9.0", None), + ("*", "2.3.4", None), + (">=1, <2", "2.3.4", Some("2.3.4")), + (">=1, <2", "2.0.0", Some("2.0.0")), + ("<1", "1.0.0", Some("1.0.0")), + (">=1, <3", "2.3.4", None), + (">=9", "2.3.4", None), + (">1", "1.9.0", None), + (">1.2", "1.2.9", None), + (">1.2.3", "1.2.3", None), + ("=9.1.2", "2.3.4", None), + ("bad", "2.3.4", None), + ("1.0", "2.0.0-beta.1", None), + ("1.0.0-beta.1", "1.0.0-beta.2", None), + ("=1.0.0-beta.1", "1.0.0-beta.2", Some("1.0.0-beta.2")), + ("1.0.0-beta.1", "2.0.0-beta.1", Some("2.0.0-beta.1")), + ] { + let actual = update(req, &[latest.to_owned()]); + assert_eq!(actual.as_ref().map(|u| u.replacement.as_str()), expected, "{req} -> {latest}"); + if let Some(u) = actual { + assert!(VersionReq::parse(&u.replacement).unwrap().matches(&Version::parse(latest).unwrap())); + } + } + let versions = vec!["3.0.0-beta.1".into(), "2.0.0".into(), "1.0.0".into()]; + assert_eq!(update("1", &versions).unwrap().latest, "2.0.0"); +} + +#[test] +fn locates_all_cargo_sections_and_only_replaces_version_contents() { + let text = r#"# 中文 😀 +[package] +name = "fixture" +version = "1.0.0" +[dependencies] +serde = "1.0.100" # stays +rand = '0.7' +tokio = { version = "0.2", features = ["full"] } +renamed = { package = "real-crate", version = "1.2" } +local = { path = "../local", version = "1" } +git = { git = "https://example.com/repo", version = "1" } +inherited = { workspace = true } +other = { registry = "private", version = "1" } +[dev-dependencies] +check = "1" +[build-dependencies.build] +version = "1.2.3" +[target.'cfg(unix)'.dependencies] +native = "0.1" +[target.'cfg(windows)'.dev-dependencies] +win = { version = "1" } +[target.'cfg(unix)'.build-dependencies] +build-native = "2" +[workspace.dependencies] +shared = { version = "1", features = ["std"] } +[package.metadata.dependencies] +not-a-dependency = "1" +"#; + let deps = dependencies(text); + assert_eq!( + deps.iter().map(|d| d.package.as_str()).collect::>(), + ["serde", "rand", "tokio", "real-crate", "check", "build", "native", "win", "build-native", "shared"] + ); + let utf16: Vec<_> = text.encode_utf16().collect(); + for dep in &deps { + assert_eq!(String::from_utf16(&utf16[dep.from as usize..dep.to as usize]).unwrap(), dep.requirement); + } + let tokio = &deps[2]; + let mut edited = utf16.clone(); + edited.splice(tokio.from as usize..tokio.to as usize, "1.3".encode_utf16()); + assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("version = \"0.2\"", "version = \"1.3\"")); + let rand = &deps[1]; + let mut edited = utf16; + edited.splice(rand.from as usize..rand.to as usize, "0.9".encode_utf16()); + assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("'0.7'", "'0.9'")); +} + +#[test] +fn invalid_toml_and_decoy_tables_are_silent() { + assert!(dependencies("[dependencies]\na = \"").is_empty()); + assert!(dependencies("[package]\ndescription = '''\n[dependencies]\na = \"1\"\n'''\n").is_empty()); + assert!(dependencies("[dependencies]\na = { version = 1 }\nb = { workspace = true, version = \"1\" }\nc = { version = \"1\", registry-index = \"x\" }").is_empty()); + let deps = dependencies("[dependencies]\na = \"\"\n"); + assert_eq!(deps.len(), 1, "empty version strings still offer completion"); + assert_eq!(deps[0].from, deps[0].to); +} + +#[test] +fn replaces_encoded_and_multiline_strings_without_damaging_quotes() { + for raw in [r#""0.\u0032""#, "\"\"\"\n0.2\"\"\"", "'''0.2'''"] { + let text = format!("[dependencies]\ntokio = {{ version = {raw}, features = [\"full\"] }}\n"); + let dep = dependencies(&text).pop().unwrap(); + assert_eq!(dep.requirement, "0.2"); + let mut encoded: Vec<_> = text.encode_utf16().collect(); + encoded.splice(dep.from as usize..dep.to as usize, "1.10".encode_utf16()); + let edited = String::from_utf16(&encoded).unwrap(); + assert!(edited.contains("features = [\"full\"]")); + assert_eq!(dependencies(&edited)[0].requirement, "1.10"); + assert_eq!(edited.matches('"').count(), text.matches('"').count()); + assert_eq!(edited.matches('\'').count(), text.matches('\'').count()); + } +} diff --git a/crates/ide-workspace/src/dependencies/mod.rs b/crates/ide-workspace/src/dependencies/mod.rs new file mode 100644 index 00000000..eef3a69a --- /dev/null +++ b/crates/ide-workspace/src/dependencies/mod.rs @@ -0,0 +1,41 @@ +//! Manifest dependencies and repository versions, independent of editor and transport. +mod cache; +pub mod cargo; + +pub use cache::VersionCache; +use ide_core::{CancellationToken, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// A repository adapter; Maven/npm can use the same cache and editor protocol. +pub trait DependencyVersionSource: Send + Sync { + /// A filesystem-safe, stable repository identifier. + fn id(&self) -> &'static str; + /// Non-yanked versions in descending semantic order. + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult>; +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DependencyUpdate { + pub latest: String, + pub replacement: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ManifestDependency { + pub package: String, + pub requirement: String, + /// UTF-16 offsets of the string's contents (quotes excluded). + pub from: u32, + pub to: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DependencyVersions { + pub dependency: ManifestDependency, + pub versions: Vec, + pub update: Option, +} diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index 1ab611e6..38ba30ca 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -1,6 +1,7 @@ //! The project model that ties documents, settings and history together. pub mod caches; +pub mod dependencies; pub mod devcontainer; pub mod documents; pub mod editorconfig; diff --git a/src-tauri/src/commands/dependencies.rs b/src-tauri/src/commands/dependencies.rs new file mode 100644 index 00000000..ae5e9bbb --- /dev/null +++ b/src-tauri/src/commands/dependencies.rs @@ -0,0 +1,17 @@ +//! Typed IPC adapters for manifest dependency versions. +use crate::state::AppState; +use ide_core::{DocId, IdeResult, RequestId}; +use ide_workspace::dependencies::DependencyVersions; +use tauri::{State, Window}; + +#[tauri::command] +#[specta::specta] +pub async fn dependency_versions( + window: Window, + state: State<'_, AppState>, + doc: DocId, + offset: Option, + request: RequestId, +) -> IdeResult> { + ide_host::ops::dependencies::dependency_versions(&state.host, window.label(), doc, offset, request).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c71ce840..2d37e23e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod clipboard; pub mod coverage; pub mod db; pub mod debug; +pub mod dependencies; pub mod deploy; pub mod doc; pub mod docker; @@ -190,6 +191,7 @@ pub fn builder() -> tauri_specta::Builder { nav::fs_resolve_reference, nav::path_completions, nav::web_unresolved_references, + dependencies::dependency_versions, spell::spell_check, spell::spell_suggest, search::search_start, diff --git a/src/bindings.ts b/src/bindings.ts index 645f8668..e2a005bb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -327,6 +327,7 @@ export const commands = { pathCompletions: (from: IpcPath, prefix: string) => typedError(__TAURI_INVOKE("path_completions", { from, prefix })), /** Paths in an HTML document or stylesheet that name no existing file. */ webUnresolvedReferences: (doc: DocId) => typedError(__TAURI_INVOKE("web_unresolved_references", { doc })), + dependencyVersions: (doc: DocId, offset: number | null, request: RequestId) => typedError(__TAURI_INVOKE("dependency_versions", { doc, offset, request })), /** * Misspelt words in the comments, string literals and declared names of * `doc` that `scope` includes (all of its text for prose). Cancel with @@ -2468,6 +2469,17 @@ export type DeletedBranch = { unmerged: boolean, }; +export type DependencyUpdate = { + latest: string, + replacement: string, +}; + +export type DependencyVersions = { + dependency: ManifestDependency, + versions: string[], + update: DependencyUpdate | null, +}; + /** Where a local file is deployed. */ export type DeployedPath = { server: string, @@ -4289,6 +4301,14 @@ export type MainToolbarSettings = { hidden?: string[], }; +export type ManifestDependency = { + package: string, + requirement: string, + /** UTF-16 offsets of the string's contents (quotes excluded). */ + from: number, + to: number, +}; + export type Markup = { /** `markdown` or `plaintext`. */ kind: string, diff --git a/src/editor/api.ts b/src/editor/api.ts index 9f2ab9a0..9f0251cb 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -21,6 +21,7 @@ import { Annotation, Compartment, Facet, + StateEffect, type EditorState, type Extension, type RangeSet, @@ -332,14 +333,25 @@ export function contributedAt(state: EditorState, key: string, pos: number): return state.languageDataAt(key, pos)[0]; } -/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */ +/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵). */ export interface ContributedIntention { id: string; label: string; icon?: string; + quickFix?: boolean; run: () => void | Promise; } +/** A background provider has new context actions for the current document. */ +export const intentionsChanged = StateEffect.define(); + +/** Local fixes of a diagnostic range (hover link and Apply Quick Fix). */ +export const quickFixProviders = Facet.define<(view: EditorView, from: number, to: number) => ContributedIntention[]>(); + +export function contributedQuickFixes(view: EditorView, from: number, to: number): ContributedIntention[] { + return view.state.facet(quickFixProviders).flatMap((provider) => provider(view, from, to)); +} + /** Providers of `ContributedIntention`s for the caret of `view` (cheap and synchronous: also drives the light bulb). */ export const intentionProviders = Facet.define<(view: EditorView) => ContributedIntention[]>(); diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index f4cc7850..65691292 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -12,7 +12,7 @@ import type { Diagnostic, Severity } from "@/bindings"; import { commands, ideEventsReady, isTauri, onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { publishLocalDiagnostics } from "@/platform/localDiagnostics"; -import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; +import { intentionsChanged, scrollbarMarks, type ScrollbarMark } from "@/editor/api"; import { rangeToOffsets } from "./positions"; import { lspDoc } from "./lsp"; import { showHint } from "./hint"; @@ -199,7 +199,10 @@ const diagnosticsSync = ViewPlugin.fromClass( if (!path || !this.view.dom.isConnected) return; const lint = toLintDiagnostics(this.view.state.doc, diagnosticsFor(path)); const tr = setDiagnostics(this.view.state, lint); - this.view.dispatch({ ...tr, effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint))] }); + this.view.dispatch({ + ...tr, + effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint)), intentionsChanged.of(null)], + }); }); } destroy() { diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index e6614dc2..4da03f15 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -98,7 +98,7 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle fix.className = "cm-lsp-link-button"; const keys = shortcutLabel("code.showIntentions"); fix.textContent = keys ? `More actions… ${keys}` : "More actions…"; - if (d.severity === "error" || d.severity === "warning") offerQuickFix(view, meta, fix, from, to); + if (d.severity !== "hint") offerQuickFix(view, meta, fix, from, to); fix.addEventListener("mousedown", (e) => e.preventDefault()); fix.addEventListener("click", () => { view.dispatch({ selection: EditorSelection.cursor(from) }); diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts index bb913d15..a76d9b4a 100644 --- a/src/editor/lsp/intentions.test.ts +++ b/src/editor/lsp/intentions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; +import { setDiagnostics } from "@codemirror/lint"; import type { CodeAction } from "@/bindings"; const resolved: CodeAction = { @@ -35,6 +36,7 @@ const { intentionPreview, lightsBulb, listedCodeActions, + listedIntentions, withInspectionControls, } = await import("./intentions"); const { setFileDiagnostics } = await import("./diagnostics"); @@ -90,6 +92,27 @@ describe("inspection controls in ⌥↵ (issue #42)", () => { setFileDiagnostics("/p/two.py", "ruff", []); expect(withInspectionControls(view, [])).toEqual([]); }); + + it.each(["info", "warning", "error"] as const)( + "uses the effective %s severity for local and LSP fixes", + (severity) => { + const path = `/p/severity-${severity}.py`; + setFileDiagnostics(path, "ruff", [{ ...unused, severity }]); + const view = editorAt(path); + view.dispatch(setDiagnostics(view.state, [{ from: 7, to: 9, severity, message: unused.message }])); + const items = listedIntentions(view, [ + { id: "refactor", label: "Extract method", icon: "bulb", run: vi.fn() }, + { id: "local", label: "Local fix", quickFix: true, run: vi.fn() }, + { id: "lsp", label: "LSP fix", quickFix: true, emphasis: true, run: vi.fn() }, + ]); + expect(items.map((i) => i.id)).toEqual(["lsp", "local", "refactor"]); + expect(items.slice(0, 2).map((i) => i.icon)).toEqual(Array(2).fill(severity === "error" ? "bulb-fix" : "bulb")); + const controls = withInspectionControls(view, items); + expect(controls.slice(0, 2).every((i) => i.submenu?.length === 2)).toBe(true); + expect(controls[2]!.submenu).toBeUndefined(); + setFileDiagnostics(path, "ruff", []); + }, + ); }); describe("intention preview", () => { diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 07cb7d6d..7dd58413 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -9,7 +9,13 @@ import { forEachDiagnostic } from "@codemirror/lint"; import { indentUnit } from "@codemirror/language"; import { diff } from "@codemirror/merge"; import { commands, isCancelled, type CodeAction } from "@/platform/ipc"; -import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api"; +import { + contributedIntentions, + contributedQuickFixes, + intentionProviders, + intentionsChanged, + whenSynced, +} from "@/editor/api"; import { shortcutLabel } from "@/platform/keymap"; import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { offsetsToRange } from "./positions"; @@ -32,6 +38,27 @@ const codeActionBackoff = featureBackoff(); const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix.")); +/** The red bulb is reserved for errors; other inspections use the ordinary bulb. */ +export function quickFixIcon(view: EditorView): "bulb" | "bulb-fix" { + const sel = view.state.selection.main; + const line = view.state.doc.lineAt(sel.head); + let error = false; + forEachDiagnostic(view.state, (d, from, to) => { + if (d.severity === "error" && from <= line.to && to >= line.from) error = true; + }); + return error ? "bulb-fix" : "bulb"; +} + +const isFixItem = (item: ListPopupItem) => item.quickFix ?? item.icon === "bulb-fix"; + +/** Local and language-server fixes share icons and the same stable ordering. */ +export function listedIntentions(view: EditorView, items: ListPopupItem[]): ListPopupItem[] { + const rank = (item: ListPopupItem) => (item.disabled ? 4 : 0) + (isFixItem(item) ? 0 : 2) + (item.emphasis ? 0 : 1); + return items + .map((item) => (isFixItem(item) ? { ...item, quickFix: true, icon: quickFixIcon(view) } : item)) + .sort((a, b) => rank(a) - rank(b)); +} + /** IDEA ordering: quick fixes first, preferred before others, disabled last; stable otherwise. */ export function sortCodeActions(actions: readonly CodeAction[]): CodeAction[] { const rank = (a: CodeAction) => (a.disabledReason ? 4 : 0) + (isQuickFix(a) ? 0 : 2) + (a.isPreferred ? 0 : 1); @@ -104,15 +131,23 @@ async function fetchIntentions(view: EditorView): Promise { return actions; } -/** Enabled quick fixes of the problem at `from..to`, preferred first. */ -export async function quickFixesAt(view: EditorView, from: number, to: number): Promise { +export type EditorQuickFix = CodeAction | { title: string; run: () => void | Promise }; + +/** Enabled quick fixes of the problem at `from..to`, including local inspections. */ +export async function quickFixesAt(view: EditorView, from: number, to: number): Promise { + const local = contributedQuickFixes(view, from, to).map((fix) => ({ title: fix.label, run: fix.run })); const doc = docOf(view); - if (!doc) return []; - await whenSynced(view); - const actions = await lspRequest(() => - commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]), - ); - return sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason)); + if (!doc) return local; + try { + await whenSynced(view); + const actions = await lspRequest(() => + commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]), + ); + return [...local, ...sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason))]; + } catch (e) { + if (local.length) return local; + throw e; + } } /** @@ -144,7 +179,8 @@ export async function applyQuickFix(view: EditorView) { showHint(view, "No quick fixes available"); } -export async function applyCodeAction(view: EditorView, action: CodeAction) { +export async function applyCodeAction(view: EditorView, action: EditorQuickFix) { + if ("run" in action) return action.run(); const doc = docOf(view); if (!doc) return; if (action.disabledReason) { @@ -276,9 +312,10 @@ function actionItems( resolved?: Map>, ): ListPopupItem[] { return listedCodeActions(actions).map((a, i) => ({ - id: `${i}`, + id: `lsp:${i}`, label: a.title, - icon: isQuickFix(a) ? "bulb-fix" : "bulb", + icon: isQuickFix(a) ? quickFixIcon(view) : "bulb", + quickFix: isQuickFix(a), emphasis: a.isPreferred, disabled: a.disabledReason, // The edit the preview already resolved is applied as is. @@ -344,7 +381,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[]) (i) => i.id !== SPELLING_INSPECTION, ); if (!inspections.length) return items; - const fixes = items.filter((i) => i.icon === "bulb-fix" && !i.submenu); + const fixes = items.filter((i) => isFixItem(i) && !i.submenu); if (inspections.length === 1 && fixes.length) { const submenu = intentionControls(inspections[0]!); return items.map((i) => (fixes.includes(i) ? { ...i, submenu } : i)); @@ -364,7 +401,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[]) /** ⌥↵ */ export async function showIntentions(view: EditorView) { try { - // Typo fixes and feature-contributed actions come first and need no language server. + // Local inspections need no language server. const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)]; let actions: CodeAction[] = []; let failure: { error: unknown } | null = null; @@ -376,7 +413,9 @@ export async function showIntentions(view: EditorView) { } const sorted = listedCodeActions(actions); const resolved = new Map>(); - const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]); + const serverItems = actionItems(view, sorted, resolved); + const previews = new Map(serverItems.map((item, i) => [item.id, sorted[i]!])); + const items = withInspectionControls(view, listedIntentions(view, [...local, ...serverItems])); if (!items.length) { if (failure) throw failure.error; showHint(view, "No intentions available"); @@ -388,7 +427,7 @@ export async function showIntentions(view: EditorView) { title: "Intentions", items, preview: (item, signal) => { - const action = sorted[items.indexOf(item) - local.length]; + const action = previews.get(item.id); return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null); }, }); @@ -500,7 +539,13 @@ const bulbPlugin = ViewPlugin.fromClass( this.lookup(); } update(u: ViewUpdate) { - if (u.docChanged || u.selectionSet || (u.focusChanged && u.view.hasFocus)) this.lookup(); + if ( + u.docChanged || + u.selectionSet || + (u.focusChanged && u.view.hasFocus) || + u.transactions.some((tr) => tr.effects.some((e) => e.is(intentionsChanged))) + ) + this.lookup(); else if (u.focusChanged) { this.lookup.cancel(); // A blur dispatched from inside the update is not allowed. @@ -516,16 +561,16 @@ const bulbPlugin = ViewPlugin.fromClass( if (!doc || doc.readOnly || !this.view.dom.isConnected || !this.view.hasFocus) return; const state = this.view.state; const typoFix = spellingIntentions(this.view).length > 0; - const local = typoFix || contributedIntentions(this.view).length > 0; + const selection = state.selection.main; + const ownFix = contributedQuickFixes(this.view, selection.from, selection.to).length > 0; + const local = typoFix || ownFix || contributedIntentions(this.view).length > 0; const backoff = codeActionBackoff.active(doc.doc); - if (backoff && !local) return; let actions: CodeAction[] = []; if (!backoff) { try { actions = await fetchIntentions(this.view); } catch (e) { if (isUnavailable(e)) codeActionBackoff.mark(doc.doc); - if (!local) return; } } if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus) @@ -533,7 +578,14 @@ const bulbPlugin = ViewPlugin.fromClass( const line = state.doc.lineAt(state.selection.main.head).number; const any = actions.some(lightsBulb) || local; this.view.dispatch({ - effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null), + effects: setBulb.of( + any + ? { + line, + quickFix: (typoFix || ownFix || actions.some(isQuickFix)) && quickFixIcon(this.view) === "bulb-fix", + } + : null, + ), }); } destroy() { diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index e5276d6c..a855ccd3 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -18,6 +18,8 @@ export interface ListPopupItem { /** Second line / muted text after the label (code preview). */ preview?: string; icon?: string; + /** Inspection fix, independent of the icon chosen for its severity. */ + quickFix?: boolean; /** Symbol badge kind (`ui/icons` `SymbolBadge`) shown instead of an icon. */ symbol?: string; emphasis?: boolean; diff --git a/src/features/dependencies/dependencies.test.ts b/src/features/dependencies/dependencies.test.ts new file mode 100644 index 00000000..e3322c59 --- /dev/null +++ b/src/features/dependencies/dependencies.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { CompletionContext } from "@codemirror/autocomplete"; +import type { DependencyVersions } from "@/bindings"; + +const dependencyVersions = vi.fn(); +const cancelRequest = vi.fn(async () => {}); +const lspCodeActions = vi.fn(async () => ({ status: "error", error: { kind: "unavailable", message: "No server" } })); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, dependencyVersions, cancelRequest, lspCodeActions }, + }; +}); +const { activate, versionCompletions } = await import("./index"); +const { editorContributions, contributedIntentions } = await import("@/editor/api"); +const { diagnosticsFor } = await import("@/editor/lsp/diagnostics"); +const { lspDoc } = await import("@/editor/lsp/lsp"); +const { quickFixesAt, applyCodeAction, showIntentions } = await import("@/editor/lsp/intentions"); +const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore"); +const { useProject } = await import("@/platform/project"); +const { DEPENDENCY_INSPECTION } = await import("@/platform/inspections"); +const text = '# 中文 😀\n[dependencies]\ntokio = { version = "0.2", features = ["full"] }\n'; +const from = text.indexOf('"0.2"') + 1; +const item: DependencyVersions = { + dependency: { package: "tokio", requirement: "0.2", from, to: from + 3 }, + versions: ["2.0.0-beta.1", "1.10.0", "1.9.0", "0.2.1"], + update: { latest: "1.10.0", replacement: "1.10" }, +}; +let deactivate: () => void; +const views: EditorView[] = []; +beforeEach(() => { + vi.useFakeTimers(); + dependencyVersions.mockResolvedValue({ status: "ok", data: [item] }); + useProject.setState({ project: { root: "/p" } as never, settings: {} as never }); + deactivate = activate(); +}); +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()); + deactivate(); + closePopup(); + useProject.setState({ project: null, settings: null }); + vi.clearAllMocks(); + vi.useRealTimers(); +}); +function editor(path = "/p/Cargo.toml", readOnly = false) { + const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly }; + const contribution = editorContributions().find((c) => c.id === "dependency-versions")!; + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: text, + selection: { anchor: from }, + extensions: [lspDoc.of(ctx), contribution.extensions(ctx)], + }), + }); + views.push(view); + return view; +} +async function settle() { + await vi.advanceTimersByTimeAsync(550); +} + +describe("Cargo dependency versions", () => { + it("publishes a weak warning and fixes only the version through context actions", async () => { + const view = editor(); + await settle(); + expect(dependencyVersions).toHaveBeenCalledWith(7, null, expect.any(Number)); + expect(diagnosticsFor("/p/Cargo.toml")).toMatchObject([ + { + severity: "info", + message: "A newer version is available: 1.10.0", + range: { start: { line: 2, col: 21 }, end: { line: 2, col: 24 } }, + }, + ]); + const [fix] = contributedIntentions(view); + expect(fix?.label).toBe("Update to 1.10"); + await fix!.run(); + expect(view.state.doc.toString()).toBe(text.replace('"0.2"', '"1.10"')); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + }); + it("provides direct and hover quick fixes even without a language server", async () => { + const view = editor(); + await settle(); + const [fix] = await quickFixesAt(view, from, from + 3); + expect(fix?.title).toBe("Update to 1.10"); + await applyCodeAction(view, fix!); + expect(view.state.doc.toString()).toContain('version = "1.10", features = ["full"]'); + }); + it.each(["0.10", "^0.10", "=0.10.3"])( + "shows the exact replacement %s with weak-warning inspection controls", + async (replacement) => { + dependencyVersions.mockResolvedValue({ + status: "ok", + data: [{ ...item, update: { latest: "0.10.3", replacement } }], + }); + const view = editor(); + await settle(); + await showIntentions(view); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("list"); + if (popup?.kind !== "list") throw new Error("Missing intentions"); + expect(popup.title).toBe("Intentions"); + expect(popup.items).toHaveLength(1); + expect(popup.items[0]).toMatchObject({ label: `Update to ${replacement}`, icon: "bulb", quickFix: true }); + expect(popup.items[0]!.submenu).toHaveLength(2); + await popup.items[0]!.run(); + expect(view.state.doc.toString()).toBe(text.replace('"0.2"', `"${replacement}"`)); + }, + ); + it("keeps diagnostics when one of two split editors closes", async () => { + editor(); + const split = editor(); + await settle(); + split.destroy(); + views.pop(); + expect(diagnosticsFor("/p/Cargo.toml")).toHaveLength(1); + }); + it("invalidates fixes immediately after edits and rejects a stale fix callback", async () => { + const view = editor(); + await settle(); + const [fix] = contributedIntentions(view); + view.dispatch({ changes: { from: 0, insert: "# edit\n" } }); + expect(contributedIntentions(view)).toEqual([]); + await fix!.run(); + expect(view.state.doc.toString()).toBe("# edit\n" + text); + }); + it("discards delayed responses after an edit, project switch or editor close", async () => { + let resolve!: (value: unknown) => void; + dependencyVersions.mockImplementation( + () => + new Promise((r) => { + resolve = r; + }), + ); + const view = editor(); + await settle(); + view.dispatch({ changes: { from: 0, insert: "# edit\n" } }); + resolve({ status: "ok", data: [item] }); + await vi.advanceTimersByTimeAsync(0); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + await settle(); + useProject.setState({ project: { root: "/other" } as never }); + resolve({ status: "ok", data: [item] }); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + expect(cancelRequest).toHaveBeenCalled(); + const other = editor("/other/Cargo.toml"); + await settle(); + other.destroy(); + views.pop(); + resolve({ status: "ok", data: [item] }); + await settle(); + expect(diagnosticsFor("/other/Cargo.toml")).toEqual([]); + }); + it("stays silent offline, with compatible versions and for non-Cargo or readonly files", async () => { + dependencyVersions.mockRejectedValue(new Error("offline")); + editor(); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + dependencyVersions.mockResolvedValue({ status: "ok", data: [{ ...item, update: null }] }); + editor("/p/sub/Cargo.toml"); + await settle(); + expect(diagnosticsFor("/p/sub/Cargo.toml")).toEqual([]); + dependencyVersions.mockClear(); + editor("/p/other.toml"); + editor("/p/readonly/Cargo.toml", true); + await settle(); + expect(dependencyVersions).not.toHaveBeenCalled(); + }); + it("honours the inspection profile and cleans up on close", async () => { + const view = editor(); + await settle(); + useProject.setState({ + settings: { inspections: { default: { [DEPENDENCY_INSPECTION]: { enabled: false } } } } as never, + }); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + expect(contributedIntentions(view)).toEqual([]); + view.destroy(); + views.pop(); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + }); + it("completes only at a parsed dependency version, preserving descending source order", async () => { + const view = editor(); + const source = view.state.languageDataAt<(ctx: CompletionContext) => Promise>( + "openideaCompletion", + from, + )[0]!; + const result = await source(new CompletionContext(view.state, from + 3, true, view)); + expect(result).toMatchObject({ + from, + to: from + 3, + filter: false, + options: item.versions.map((label) => ({ label })), + }); + expect(dependencyVersions).toHaveBeenLastCalledWith(7, from + 3, expect.any(Number)); + dependencyVersions.mockResolvedValue({ status: "ok", data: [] }); + expect(await source(new CompletionContext(view.state, 0, true, view))).toBeNull(); + }); + it("replaces the whole version and retains a simple requirement operator", () => { + const state = EditorState.create({ doc: "^1.2" }); + const context = new CompletionContext(state, 4, true); + const result = versionCompletions({ ...item, dependency: { ...item.dependency, from: 0, to: 4 } }, context); + expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" }); + }); +}); diff --git a/src/features/dependencies/index.ts b/src/features/dependencies/index.ts new file mode 100644 index 00000000..3a47f969 --- /dev/null +++ b/src/features/dependencies/index.ts @@ -0,0 +1,208 @@ +import { EditorState, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { ViewPlugin, type EditorView } from "@codemirror/view"; +import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import { + intentionProviders, + intentionsChanged, + quickFixProviders, + registerEditorContribution, + whenSynced, + type ContributedIntention, + type EditorDocContext, +} from "@/editor/api"; +import { setFileDiagnostics } from "@/editor/lsp/diagnostics"; +import { offsetsToRange } from "@/editor/lsp/positions"; +import { call, commands, isTauri, nextRequestId, type DependencyVersions, type RequestId } from "@/platform/ipc"; +import { + DEPENDENCY_INSPECTION, + DEPENDENCY_PRODUCER, + inspectionProfile, + subscribeInspectionProfile, +} from "@/platform/inspections"; +import { useProject } from "@/platform/project"; +import { tx } from "@/platform/i18n"; + +const setVersions = StateEffect.define(); +const versionsField = StateField.define({ + create: () => [], + update(value, tr) { + if (tr.docChanged) return []; + for (const effect of tr.effects) if (effect.is(setVersions)) return effect.value; + return value; + }, +}); + +const viewers = new Map(); + +const enabled = () => inspectionProfile()[DEPENDENCY_INSPECTION]?.enabled !== false; + +export function dependencyFixes(view: EditorView, from: number, to: number): ContributedIntention[] { + if (!enabled() || view.state.readOnly) return []; + const text = view.state.doc; + return (view.state.field(versionsField, false) ?? []).flatMap(({ dependency, update }) => { + if (!update || from > dependency.to || to < dependency.from) return []; + const { replacement } = update; + return [ + { + id: `dependency.update:${dependency.from}`, + label: tx`Update to ${replacement}`, + quickFix: true, + run: () => { + if (view.state.doc !== text) return; + view.dispatch({ + changes: { from: dependency.from, to: dependency.to, insert: replacement }, + userEvent: "input.dependency", + }); + }, + }, + ]; + }); +} + +export function versionCompletions(item: DependencyVersions, ctx: CompletionContext): CompletionResult { + const { dependency, versions } = item; + const typed = ctx.state.sliceDoc(dependency.from, ctx.pos); + const prefix = /^[\s^~<>=]*/.exec(typed)?.[0] ?? ""; + const filter = typed.slice(prefix.length); + return { + from: dependency.from, + to: dependency.to, + filter: false, + options: versions + .filter((v) => ctx.explicit || v.startsWith(filter)) + .map((version) => ({ + label: version, + type: "constant", + detail: dependency.package, + apply: prefix + version, + })), + }; +} + +function extension(doc: EditorDocContext): Extension { + const plugin = ViewPlugin.define((view) => { + if (!isTauri()) return {}; + viewers.set(doc.path, (viewers.get(doc.path) ?? 0) + 1); + let timer: ReturnType | undefined; + let request: RequestId | null = null; + let sequence = 0; + let disposed = false; + const clear = () => setFileDiagnostics(doc.path, DEPENDENCY_PRODUCER, []); + const cancel = () => { + sequence++; + if (request !== null) void commands.cancelRequest(request); + request = null; + }; + const run = async () => { + if (disposed || !enabled()) return; + const mine = sequence; + const text = view.state.doc; + const root = useProject.getState().project?.root; + const id = nextRequestId(); + request = id; + try { + await whenSynced(view); + if (mine !== sequence || disposed) return; + const items = await call(commands.dependencyVersions(doc.doc, null, id)); + if (mine !== sequence || disposed || view.state.doc !== text || useProject.getState().project?.root !== root) + return; + view.dispatch({ effects: [setVersions.of(items), intentionsChanged.of(null)] }); + setFileDiagnostics( + doc.path, + DEPENDENCY_PRODUCER, + items.flatMap(({ dependency, update }) => { + if (!update) return []; + const { latest } = update; + return [ + { + range: offsetsToRange(text, dependency.from, dependency.to), + severity: "info", + message: tx`A newer version is available: ${latest}`, + source: "Cargo", + code: null, + }, + ]; + }), + ); + } catch { + // Offline registries and cancelled checks never interrupt editing. + } finally { + if (request === id) request = null; + } + }; + const schedule = () => { + cancel(); + clear(); + clearTimeout(timer); + timer = setTimeout(() => void run(), 500); + }; + schedule(); + const offProfile = subscribeInspectionProfile(() => { + schedule(); + view.dispatch({ effects: intentionsChanged.of(null) }); + }); + const offProject = useProject.subscribe((state, previous) => { + if (state.project?.root !== previous.project?.root) { + disposed = true; + cancel(); + clearTimeout(timer); + clear(); + } + }); + return { + update: (u) => { + if (u.docChanged) schedule(); + }, + destroy: () => { + disposed = true; + cancel(); + clearTimeout(timer); + offProfile(); + offProject(); + const remaining = (viewers.get(doc.path) ?? 1) - 1; + if (remaining) viewers.set(doc.path, remaining); + else { + viewers.delete(doc.path); + clear(); + } + }, + }; + }); + const completion = async (ctx: CompletionContext): Promise => { + if (!isTauri()) return null; + const id = nextRequestId(); + const text = ctx.state.doc; + const root = useProject.getState().project?.root; + ctx.addEventListener("abort", () => void commands.cancelRequest(id)); + try { + // The completion context is immutable; only use it while its document is current. + const view = ctx.view; + if (!view) return null; + await whenSynced(view); + if (ctx.aborted || view.state.doc !== text) return null; + const items = await call(commands.dependencyVersions(doc.doc, ctx.pos, id)); + if (ctx.aborted || view.state.doc !== text || useProject.getState().project?.root !== root) return null; + const item = items.find(({ dependency }) => dependency.from <= ctx.pos && ctx.pos <= dependency.to); + return item ? versionCompletions(item, ctx) : null; + } catch { + return null; + } + }; + return [ + versionsField, + plugin, + intentionProviders.of((view) => { + const selection = view.state.selection.main; + return dependencyFixes(view, selection.from, selection.to); + }), + quickFixProviders.of(dependencyFixes), + EditorState.languageData.of(() => [{ openideaCompletion: completion }]), + ]; +} + +export function activate(): () => void { + return registerEditorContribution({ + id: "dependency-versions", + extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []), + }); +} diff --git a/src/features/index.ts b/src/features/index.ts index 2678ade5..6286ef08 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -10,6 +10,7 @@ import { activate as editorLsp } from "@/editor/lsp"; import { activate as welcome } from "./welcome"; import { activate as projectTree } from "./project-tree"; import { activate as search } from "./search"; +import { activate as dependencies } from "./dependencies"; import { activate as codeInsight } from "./code-insight"; import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs"; import { activate as terminal } from "./terminal"; @@ -55,6 +56,7 @@ const features: Array<[string, Activate]> = [ ["project-tree", projectTree], ["search", search], ["code-insight", codeInsight], + ["dependencies", dependencies], ["vcs", vcs], ["terminal", terminal], ["run", run], diff --git a/src/platform/inspections.ts b/src/platform/inspections.ts index 88fcfb51..8a0ed22e 100644 --- a/src/platform/inspections.ts +++ b/src/platform/inspections.ts @@ -222,6 +222,9 @@ export interface BuiltinInspection { export const SPELLING_PRODUCER = "spelling"; export const SPELLING_INSPECTION = "builtin:spelling"; +export const DEPENDENCY_PRODUCER = "dependency-versions"; +export const DEPENDENCY_INSPECTION = "builtin:dependency-versions"; + /** IDEA's Typo options: which parts of source files are checked (plain text always is). */ export const SPELLING_OPTIONS = { code: "processCode", @@ -231,6 +234,14 @@ export const SPELLING_OPTIONS = { /** Checks the IDE runs itself (not a language server). */ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [ + { + id: DEPENDENCY_INSPECTION, + producer: DEPENDENCY_PRODUCER, + name: "Newer dependency version available", + group: "Cargo", + description: "Reports newer crate versions that Cargo.toml requirements do not already allow.", + defaultLevel: "weakWarning", + }, { id: "builtin:file-references", producer: "file-references", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 839f1066..b92b2166 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,8 @@ { + "Newer dependency version available": "有更新的依赖版本可用", + "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", + "Update to {latest}": "更新至 {latest}", + "A newer version is available: {latest}": "有新版本可用:{latest}", "Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录", "Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用", "Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 3a418ebf..2079d23a 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 + "Cargo", "CMake", "Prettier", "IntelliJ IDEA",