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 001/108] 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 002/108] 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 003/108] 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 004/108] 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 005/108] 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 006/108] 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", From 245fe3cdf40c0605429abb2398cdea6020c6f464 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:34:31 +0800 Subject: [PATCH 007/108] feat(run): .NET project run and debug configurations (#686) .NET projects had no run configurations. Projects from .csproj files (and those listed in .sln/.slnx) now get configurations: executable projects (OutputType Exe/WinExe, or the Web SDK) get a .NET Project configuration running dotnet run, one per Project launch profile in Properties/launchSettings.json with its application URL and environment shown; test projects (Microsoft.NET.Test.Sdk) get dotnet test. The form edits project, target framework, launch profile, program arguments and environment, which override the profile's values. Debug builds the project, resolves the output assembly through MSBuild and launches it under netcoredbg, offering to install netcoredbg when missing. The source-text catalog test now ignores Rust character literals when collecting backend strings. Part of #615 --- crates/ide-host/src/ops/run/commands.rs | 20 ++ crates/ide-run/src/detect.rs | 2 + crates/ide-run/src/detect/dotnet.rs | 121 +++++++++ crates/ide-run/src/dotnet.rs | 257 ++++++++++++++++++ crates/ide-run/src/lib.rs | 1 + crates/ide-run/tests/dotnet.rs | 138 ++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/run.rs | 11 + src/bindings.ts | 1 + .../debugger/__tests__/launch.test.ts | 20 ++ .../debugger/__tests__/missingAdapter.test.ts | 3 + src/features/debugger/actions.ts | 4 +- src/features/debugger/launch.ts | 9 + src/features/debugger/missingAdapter.ts | 1 + src/features/run/ConfigForm.tsx | 6 +- src/features/run/beforeLaunch.ts | 8 +- src/features/run/configs.ts | 5 +- src/features/run/dotnetTypes.test.tsx | 123 +++++++++ src/features/run/dotnetTypes.ts | 134 +++++++++ src/features/run/types.ts | 8 + src/platform/locales/text/zh.json | 22 +- src/platform/locales/textCatalog.test.ts | 19 +- 22 files changed, 908 insertions(+), 6 deletions(-) create mode 100644 crates/ide-run/src/detect/dotnet.rs create mode 100644 crates/ide-run/src/dotnet.rs create mode 100644 crates/ide-run/tests/dotnet.rs create mode 100644 src/features/run/dotnetTypes.test.tsx create mode 100644 src/features/run/dotnetTypes.ts diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index 7a916a51..c1b2acc7 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -5,6 +5,23 @@ use crate::rpc::Dispatcher; use ide_core::{IdeError, IdeResult}; use ide_run::RunConfig; +pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult { + host.require_trust(window)?; + if !matches!(config.kind.as_str(), "dotnet" | "dotnet-test") + || config.extras.target.as_ref().is_some_and(|s| !s.is_empty()) + { + return Err(IdeError::invalid(".NET Project requires the local machine")); + } + let project = host.project(window)?; + let mut config = host.blocking(move || ide_run::dotnet::resolve(&project.root, config)).await?; + if debug { + let root = host.project(window)?.root.clone(); + let target = ide_run::dotnet::target_path(&root, &config).await?; + config.extras.params.insert("targetPath".into(), target); + } + Ok(config) +} + fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf { use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); @@ -206,6 +223,9 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { + d.add("run_dotnet_resolve", |c| async move { + run_dotnet_resolve(&c.host, &c.window, c.arg("config")?, c.arg("debug")?).await + }); d.add("run_application_build", |c| async move { run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await }); diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 13a1f5d5..82f36594 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -4,6 +4,7 @@ //! Detection never fails: unreadable or malformed files simply contribute nothing. pub(crate) mod cmake; +mod dotnet; use crate::RunConfig; use serde_json::Value as Json; @@ -33,6 +34,7 @@ pub(crate) fn detect_with_profiles( detect_go(root, &mut out); detect_python(root, &mut out); cmake::detect(root, profiles, &mut out); + dotnet::detect(root, &mut out); detect_make(root, &mut out); detect_just(root, &mut out); detect_godot(root, &mut out); diff --git a/crates/ide-run/src/detect/dotnet.rs b/crates/ide-run/src/detect/dotnet.rs new file mode 100644 index 00000000..ce1f433e --- /dev/null +++ b/crates/ide-run/src/detect/dotnet.rs @@ -0,0 +1,121 @@ +//! Runnable .NET projects and launch profiles, without a solution model. + +use crate::{ + RunConfig, + dotnet::{parse_project, profiles, solution_projects}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +pub(crate) fn detect(root: &Path, out: &mut Vec) { + let mut dirs = vec![root.to_path_buf()]; + let mut projects = BTreeSet::new(); + while let Some(dir) = dirs.pop() { + let Ok(entries) = fs::read_dir(&dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if entry.file_type().is_ok_and(|t| t.is_dir()) { + if !name.starts_with('.') + && !matches!( + name.as_str(), + "bin" | "obj" | "node_modules" | "target" | "build" | "dist" | "vendor" | "packages" + ) + { + dirs.push(path); + } + } else if entry.file_type().is_ok_and(|t| t.is_file()) { + match path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase().as_str() { + "csproj" => { + projects.insert(path); + } + "sln" | "slnx" => { + if let Ok(src) = fs::read_to_string(&path) { + for project in solution_projects( + &src, + path.extension().is_some_and(|e| e.eq_ignore_ascii_case("slnx")), + ) { + projects.insert(dir.join(project)); + } + } + } + _ => {} + } + } + } + } + let root = dunce::canonicalize(root).unwrap_or_else(|_| root.into()); + let projects: BTreeSet<_> = projects.into_iter().filter_map(|p| dunce::canonicalize(p).ok()).collect(); + for path in projects { + let Some(project) = fs::read_to_string(&path).ok().and_then(|s| parse_project(&s)) else { continue }; + let rel = path.strip_prefix(&root).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + let name = path.file_stem().unwrap_or_default().to_string_lossy(); + let profiles = profiles(&path); + let mut params = BTreeMap::from([ + ("project".into(), rel.clone()), + ("framework".into(), project.frameworks.first().cloned().unwrap_or_default()), + ("frameworks".into(), project.frameworks.join(";")), + ("profiles".into(), serde_json::to_string(&profiles).unwrap_or_default()), + ("launchProfile".into(), String::new()), + ("programArgs".into(), String::new()), + ]); + if project.executable && !project.test { + out.push(detected(&rel, &name, "", params.clone(), false)); + for profile in profiles.keys() { + params.insert("launchProfile".into(), profile.clone()); + out.push(detected(&rel, &name, profile, params.clone(), false)); + } + } + if project.test { + params.insert("launchProfile".into(), String::new()); + out.push(detected(&rel, &name, "", params, true)); + } + } +} + +fn detected(project: &str, name: &str, profile: &str, params: BTreeMap, test: bool) -> RunConfig { + let op = if test { "test" } else { "run" }; + let mut config = super::config( + format!("dotnet:{project}:{op}:{profile}"), + if test { + format!("dotnet test ({name})") + } else if profile.is_empty() { + format!("{name} (.NET Project)") + } else { + format!("{name}: {profile}") + }, + if test { "dotnet-test" } else { "dotnet" }, + "dotnet", + &[], + ); + config.args = command_args(¶ms, test); + config.is_test = test; + config.debug_adapter = (!test).then(|| "netcoredbg".into()); + config.extras.params = params; + if !test { + config.extras.before_launch.push(crate::BeforeLaunchTask { kind: "dotnetBuild".into(), target: String::new() }); + } + config +} + +fn command_args(params: &BTreeMap, test: bool) -> Vec { + let mut args = if test { + vec!["test".into(), params.get("project").cloned().unwrap_or_default()] + } else { + vec!["run".into(), "--project".into(), params.get("project").cloned().unwrap_or_default()] + }; + if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) { + args.extend(["--framework".into(), framework.clone()]); + } + if !test { + if let Some(profile) = params.get("launchProfile").filter(|s| !s.is_empty()) { + args.extend(["--launch-profile".into(), profile.clone()]); + } else { + args.push("--no-launch-profile".into()); + } + } + args +} diff --git a/crates/ide-run/src/dotnet.rs b/crates/ide-run/src/dotnet.rs new file mode 100644 index 00000000..1ba990f6 --- /dev/null +++ b/crates/ide-run/src/dotnet.rs @@ -0,0 +1,257 @@ +//! Lightweight .NET project discovery; MSBuild remains the authority for build outputs. + +use crate::RunConfig; +use ide_core::{IdeError, IdeResult}; +use quick_xml::{Reader, XmlVersion, events::Event}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Default, PartialEq)] +pub struct Project { + pub executable: bool, + pub frameworks: Vec, + pub test: bool, +} + +#[derive(Default)] +struct Element { + name: String, + attrs: BTreeMap, + text: String, + conditional: bool, +} + +fn elements(xml: &str) -> Option> { + let mut reader = Reader::from_str(xml); + let mut out: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + loop { + let event = reader.read_event().ok()?; + let empty = matches!(&event, Event::Empty(_)); + match event { + Event::Start(tag) | Event::Empty(tag) => { + let attrs: BTreeMap<_, _> = tag + .attributes() + .map(|a| { + let a = a.ok()?; + Some(( + a.key.as_ref().to_owned(), + a.normalized_value(XmlVersion::Implicit1_0).ok()?.into_owned(), + )) + }) + .collect::>()?; + let conditional = attrs.contains_key("Condition") || stack.last().is_some_and(|i| out[*i].conditional); + out.push(Element { + name: tag.local_name().as_ref().to_owned(), + attrs, + conditional, + ..Default::default() + }); + // Empty elements have no corresponding End event. + if !empty { + stack.push(out.len() - 1); + } + } + Event::End(_) => { + stack.pop()?; + } + Event::Text(text) => { + if let Some(i) = stack.last() { + out[*i].text.push_str(&text.xml_content(XmlVersion::Implicit1_0)); + } + } + Event::CData(text) => { + if let Some(i) = stack.last() { + out[*i].text.push_str(text.as_ref()); + } + } + Event::GeneralRef(reference) => { + let name = reference.as_ref(); + let escaped = format!("&{name};"); + if let Some(i) = stack.last() { + out[*i].text.push_str(&quick_xml::escape::unescape(&escaped).ok()?); + } + } + Event::Eof => return stack.is_empty().then_some(out), + _ => {} + } + } +} + +pub fn parse_project(xml: &str) -> Option { + let tags = elements(xml)?; + if tags.first()?.name != "Project" { + return None; + } + let property = |name: &str| tags.iter().rev().find(|t| t.name == name && !t.conditional).map(|t| t.text.trim()); + let web = tags.iter().any(|t| { + t.attrs + .get("Sdk") + .or_else(|| (t.name == "Sdk").then(|| t.attrs.get("Name")).flatten()) + .is_some_and(|s| s.split(';').any(|sdk| sdk.split('/').next() == Some("Microsoft.NET.Sdk.Web"))) + }); + let executable = match property("OutputType") { + Some(output) => output.eq_ignore_ascii_case("exe") || output.eq_ignore_ascii_case("winexe"), + None => web, + }; + let frameworks = property("TargetFramework") + .filter(|v| !v.is_empty()) + .or_else(|| property("TargetFrameworks")) + .unwrap_or_default() + .split(';') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_owned) + .collect(); + let test = tags.iter().any(|t| { + t.name == "PackageReference" + && t.attrs + .get("Include") + .or_else(|| t.attrs.get("Update")) + .is_some_and(|v| v.eq_ignore_ascii_case("Microsoft.NET.Test.Sdk")) + }); + Some(Project { executable, frameworks, test }) +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct Profile { + pub command_name: String, + pub command_line_args: String, + pub working_directory: String, + pub application_url: String, + pub environment_variables: BTreeMap, +} + +pub fn parse_profiles(json: &str) -> BTreeMap { + let Ok(json) = serde_json::from_str::(&ide_core::jsonc::strip(json)) else { + return BTreeMap::new(); + }; + json["profiles"] + .as_object() + .into_iter() + .flatten() + .filter_map(|(name, value)| { + let profile: Profile = serde_json::from_value(value.clone()).ok()?; + (profile.command_name == "Project").then(|| (name.clone(), profile)) + }) + .collect() +} + +pub fn solution_projects(src: &str, xml: bool) -> Vec { + let paths: Vec = if xml { + elements(src) + .unwrap_or_default() + .into_iter() + .filter(|e| e.name == "Project") + .filter_map(|mut e| e.attrs.remove("Path")) + .collect() + } else { + src.lines() + .filter_map(|line| { + let line = line.trim(); + if !line.starts_with("Project(") { + return None; + } + Some(line.split_once('=')?.1.split('"').nth(3)?.to_owned()) + }) + .collect() + }; + paths + .into_iter() + .filter(|p| p.to_ascii_lowercase().ends_with(".csproj")) + .map(|p| PathBuf::from(p.replace('\\', "/"))) + .collect() +} + +pub(crate) fn profiles(project: &Path) -> BTreeMap { + project + .parent() + .and_then(|p| fs::read_to_string(p.join("Properties/launchSettings.json")).ok()) + .map(|s| parse_profiles(&s)) + .unwrap_or_default() +} + +/// Resolves the selected profile afresh so changing project/profile never reuses stale values. +pub fn resolve(root: &Path, mut config: RunConfig) -> IdeResult { + let params = &config.extras.params; + let project = params + .get("project") + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| IdeError::invalid("Select a .NET project"))?; + let project = dunce::canonicalize(root.join(project))?; + let dir = project.parent().ok_or_else(|| IdeError::invalid("Invalid .NET project path"))?; + let profile_name = params.get("launchProfile").cloned().unwrap_or_default(); + let profile = if profile_name.is_empty() { + Profile::default() + } else { + profiles(&project).remove(&profile_name).ok_or_else(|| { + IdeError::invalid(format!(".NET launch profile '{profile_name}' is missing or is not a Project profile")) + })? + }; + let mut env = profile.environment_variables; + if !profile.application_url.is_empty() { + env.insert("ASPNETCORE_URLS".into(), profile.application_url); + } + if !profile_name.is_empty() { + env.insert("DOTNET_LAUNCH_PROFILE".into(), profile_name); + } + env.extend(config.env); + config.env = env; + config.cwd = Some( + config + .cwd + .map(|p| root.join(&*p)) + .unwrap_or_else(|| { + if profile.working_directory.is_empty() { dir.into() } else { dir.join(&profile.working_directory) } + }) + .into(), + ); + config.extras.params.insert("project".into(), project.to_string_lossy().into_owned()); + config.extras.params.insert("profileArgs".into(), profile.command_line_args); + Ok(config) +} + +/// Queries the evaluated output path, including AssemblyName, OutputPath and imported properties. +pub async fn target_path(root: &Path, config: &RunConfig) -> IdeResult { + let params = &config.extras.params; + let project = params.get("project").ok_or_else(|| IdeError::invalid("Select a .NET project"))?; + let mut args = vec![ + "msbuild".to_owned(), + root.join(project).to_string_lossy().into_owned(), + "-nologo".into(), + "-getProperty:TargetPath".into(), + "-p:Configuration=Debug".into(), + ]; + if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) { + args.push(format!("-p:TargetFramework={framework}")); + } + let mut command = std::process::Command::new(&config.command); + ide_core::process::host_env(&mut command); + command.args(args).current_dir(root); + if !config.extras.include_system_env { + command.env_clear(); + } else { + command.envs(ide_core::process::shell_env()); + } + command.envs(crate::resolved_env(root, config)?); + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output()) + .await + .map_err(|_| IdeError::unavailable(".NET output resolution timed out"))??; + if !output.status.success() { + return Err(IdeError::invalid(format!( + ".NET output resolution failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ))); + } + let target = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if target.is_empty() || !Path::new(&target).is_file() { + return Err(IdeError::invalid(".NET build output is missing; run dotnet build before debugging")); + } + Ok(target) +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 95bf00bd..f35036b1 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -2,6 +2,7 @@ mod config; mod detect; +pub mod dotnet; pub mod envfile; pub mod go; pub mod gradle_jvm; diff --git a/crates/ide-run/tests/dotnet.rs b/crates/ide-run/tests/dotnet.rs new file mode 100644 index 00000000..61befdd5 --- /dev/null +++ b/crates/ide-run/tests/dotnet.rs @@ -0,0 +1,138 @@ +use ide_run::{ + detect, + dotnet::{parse_profiles, parse_project, resolve, solution_projects}, +}; +use std::fs; +use std::path::PathBuf; + +const CONSOLE: &str = r#"Exenet10.0; net9.0"#; +const PROFILES: &str = r#"{ + // Only Project profiles can be run with dotnet. + "profiles": { + "http": {"commandName":"Project", "applicationUrl":"http://localhost:5165", "commandLineArgs":"--message \"hello world\"", "workingDirectory":"work", "environmentVariables":{"DEMO":"profile"}}, + "https": {"commandName":"Project", "environmentVariables":{"DEMO":"https"}}, + "IIS Express": {"commandName":"IISExpress"}, + "external": {"commandName":"Executable"}, + "broken": {"commandName":"Project", "environmentVariables":7} + } +}"#; + +fn write(root: &std::path::Path, file: &str, src: &str) { + let path = root.join(file); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, src).unwrap(); +} + +#[test] +fn project_properties_and_sdk_defaults() { + let p = parse_project(CONSOLE).unwrap(); + assert!(p.executable && !p.test); + assert_eq!(p.frameworks, ["net10.0", "net9.0"]); + for (sdk, output, executable) in [ + ("Microsoft.NET.Sdk", "", false), + ("Microsoft.NET.Sdk", "WinExe", true), + ("Microsoft.NET.Sdk.Web", "", true), + ("Microsoft.NET.Sdk.Web", "Library", false), + ] { + let xml = format!( + r#"{output}net10.0"# + ); + let p = parse_project(&xml).unwrap(); + assert_eq!(p.executable, executable, "{xml}"); + assert_eq!(p.frameworks, ["net10.0"]); + } + let p = parse_project(r#"net10.0Library"#).unwrap(); + assert!(p.executable && p.test); + assert!(parse_project("").is_none()); + assert!(parse_project("garbage").is_none()); + assert!( + parse_project( + r#""# + ) + .unwrap() + .test + ); + assert!( + !parse_project(r#""#) + .unwrap() + .test + ); +} + +#[test] +fn profiles_filter_and_preserve_launch_properties() { + let p = parse_profiles(PROFILES); + assert_eq!(p.keys().map(String::as_str).collect::>(), ["http", "https"]); + assert_eq!(p["http"].application_url, "http://localhost:5165"); + assert_eq!(p["http"].environment_variables["DEMO"], "profile"); + assert_eq!(p["http"].command_line_args, "--message \"hello world\""); + assert_eq!(p["http"].working_directory, "work"); + assert!(parse_profiles("invalid").is_empty()); +} + +#[test] +fn solution_paths_support_windows_separators_folders_and_entities() { + let sln = "Project(\"{guid}\") = \"App\", \"src\\App\\App.csproj\", \"{guid}\"\nEndProject\nProject(\"{folder}\") = \"Folder\", \"Folder\", \"{guid}\"\nProject(\"{guid}\") = \"External\", \"..\\other\\Other.csproj\", \"{guid}\""; + assert_eq!( + solution_projects(sln, false), + [PathBuf::from("src/App/App.csproj"), PathBuf::from("../other/Other.csproj")] + ); + let slnx = r#""#; + assert_eq!(solution_projects(slnx, true), [PathBuf::from("src/A&B/App.csproj")]); + assert!(solution_projects(""#, + ); + write( + root, + "src/Test/Test.csproj", + r#"net10.0"#, + ); + write(root, "bin/Ignored.csproj", CONSOLE); + write(root, "obj/Ignored.csproj", CONSOLE); + write(root, "node_modules/Ignored.csproj", CONSOLE); + write(root, "Lib.csproj", ""); + let configs = detect(root); + assert_eq!(configs.len(), 4); + let app = configs.iter().find(|c| c.extras.params.get("launchProfile").is_some_and(|p| p == "http")).unwrap(); + assert_eq!( + app.args, + ["run", "--project", "src/App/App.csproj", "--framework", "net10.0", "--launch-profile", "http"] + ); + assert_eq!(app.debug_adapter.as_deref(), Some("netcoredbg")); + assert_eq!(app.extras.before_launch[0].kind, "dotnetBuild"); + let test = configs.iter().find(|c| c.is_test).unwrap(); + assert_eq!(test.kind, "dotnet-test"); + assert_eq!(test.args, ["test", "src/Test/Test.csproj", "--framework", "net10.0"]); + assert!(test.debug_adapter.is_none()); + let mut edited = app.clone(); + edited.env.insert("DEMO".into(), "override".into()); + let resolved = resolve(root, edited.clone()).unwrap(); + assert_eq!(resolved.env["DEMO"], "override"); + assert_eq!(resolved.env["ASPNETCORE_URLS"], "http://localhost:5165"); + assert!(resolved.cwd.unwrap().ends_with("src/App/work")); + edited.extras.params.insert("launchProfile".into(), "https".into()); + let resolved = resolve(root, edited).unwrap(); + assert!(!resolved.env.contains_key("ASPNETCORE_URLS")); + assert!(resolved.cwd.unwrap().ends_with("src/App")); +} + +#[test] +fn solution_references_can_reach_projects_outside_the_open_directory() { + let temp = tempfile::tempdir().unwrap(); + write(temp.path(), "external/App.csproj", CONSOLE); + write(temp.path(), "open/demo.sln", "Project(\"{guid}\") = \"App\", \"..\\external\\App.csproj\", \"{guid}\""); + let configs = detect(&temp.path().join("open")); + assert_eq!(configs.len(), 1); + assert!(configs[0].args[2].ends_with("external/App.csproj")); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2d37e23e..ba282249 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -305,6 +305,7 @@ pub fn builder() -> tauri_specta::Builder { term::term_default_shell, term::term_available_shells, run::run_configs, + run::run_dotnet_resolve, run::run_save_configs, run::run_templates, run::run_save_templates, diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index 998cb5b9..3bdbcecd 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -8,6 +8,17 @@ use ide_run::{OutputChunk, RunConfig, RunInfo}; use tauri::ipc::Channel; use tauri::{State, Window}; +#[tauri::command] +#[specta::specta] +pub async fn run_dotnet_resolve( + window: Window, + state: State<'_, AppState>, + config: RunConfig, + debug: bool, +) -> IdeResult { + ide_host::ops::run::run_dotnet_resolve(&state.host, window.label(), config, debug).await +} + #[tauri::command] #[specta::specta] pub async fn run_application_build( diff --git a/src/bindings.ts b/src/bindings.ts index e2a005bb..f4603e36 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -565,6 +565,7 @@ export const commands = { termAvailableShells: () => __TAURI_INVOKE("term_available_shells"), /** Detected configs followed by user-defined ones (user ids win on conflict). */ runConfigs: () => typedError(__TAURI_INVOKE("run_configs")), + runDotnetResolve: (config: RunConfig_Deserialize, debug: boolean) => typedError(__TAURI_INVOKE("run_dotnet_resolve", { config, debug })), runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })), /** Configuration templates: per-type defaults applied to new configurations. */ runTemplates: () => typedError(__TAURI_INVOKE("run_templates")), diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index 19fac437..d52afa22 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -321,3 +321,23 @@ describe("Go build configuration fields", () => { }); }); }); + +it("netcoredbg launches the evaluated DLL with resolved profile arguments, env and cwd", () => { + const c = rc({ + kind: "dotnet", + command: "dotnet", + debugAdapter: "netcoredbg", + cwd: "/repo/work", + env: { ASPNETCORE_URLS: "http://localhost:5165", DEMO: "override" }, + params: { targetPath: "/repo/custom/Renamed.dll", programArgs: "--name 'hello world'", profileArgs: "--profile" }, + }); + expect(launchConfigFor(c, "/repo")).toMatchObject({ + adapter: "netcoredbg", + program: "/repo/custom/Renamed.dll", + args: ["--name", "hello world"], + cwd: "/repo/work", + env: c.env, + }); + expect(launchConfigFor({ ...c, params: { ...c.params, programArgs: "" } }, "/repo")?.args).toEqual(["--profile"]); + expect(launchConfigFor({ ...c, params: {} }, "/repo")).toBeNull(); +}); diff --git a/src/features/debugger/__tests__/missingAdapter.test.ts b/src/features/debugger/__tests__/missingAdapter.test.ts index 656ba956..feb17d38 100644 --- a/src/features/debugger/__tests__/missingAdapter.test.ts +++ b/src/features/debugger/__tests__/missingAdapter.test.ts @@ -21,6 +21,9 @@ describe("missing debug adapters", () => { expect( missingAdapter("kotlin-debug-adapter", unavailable("debug adapter `kotlin-debug-adapter` not found (x)"))?.tool, ).toBe("kotlin-debug-adapter"); + expect(missingAdapter("netcoredbg", unavailable("debug adapter `netcoredbg` not found (x)"))?.tool).toBe( + "netcoredbg", + ); expect(missingAdapter("delve", unavailable("debug adapter `delve` not found (x)"))?.tool).toBe("delve"); }); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 098395a0..2db2c3e6 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -297,7 +297,9 @@ async function startConfig(arg: unknown) { await startJavaConfig(config, root, origin ?? config); return; } - const launch = launchConfigFor(config, root); + const dotnet = config.kind === "dotnet" && config.debugAdapter === "netcoredbg"; + const prepared = dotnet ? await call(commands.runDotnetResolve(config, true)) : config; + const launch = launchConfigFor(prepared, root); if (!launch) { notifications().notify({ group: "run", diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index 8f593baa..86b039a6 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -313,6 +313,15 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul if (adapter === "js-debug") return jsDebugLaunch(rc, cwd); if (adapter === "delve") return delveLaunch(rc, cwd, root); if (adapter === "godot") return godotLaunch(rc, cwd); + if (adapter === "netcoredbg" && rc.kind === "dotnet") { + if (!rc.params?.targetPath) return null; + return { + ...base(rc, adapter, cwd), + program: rc.params.targetPath, + args: parseArgs(rc.params.programArgs?.trim() ? rc.params.programArgs : rc.params.profileArgs || ""), + extraJson: JSON.stringify({ justMyCode: workflowSettings().debugger.skipLibraryCode }), + }; + } // Adapters without a template (hidden in Edit Configurations): generic arguments. return { ...base(rc, adapter, cwd), diff --git a/src/features/debugger/missingAdapter.ts b/src/features/debugger/missingAdapter.ts index 8106ea51..dabfb402 100644 --- a/src/features/debugger/missingAdapter.ts +++ b/src/features/debugger/missingAdapter.ts @@ -7,6 +7,7 @@ import { IpcError } from "@/platform/ipc"; /** Adapters the tools installer can provide (adapter id → tool name). */ const INSTALLABLE: Record = { "kotlin-debug-adapter": { tool: "kotlin-debug-adapter", title: "The Kotlin debugger is not installed" }, + netcoredbg: { tool: "netcoredbg", title: "netcoredbg is not installed" }, debugpy: { tool: "debugpy", title: "debugpy is not installed" }, "js-debug": { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed" }, delve: { tool: "delve", title: "Delve is not installed" }, diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 1d11ed2d..3c5e8d30 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -48,11 +48,12 @@ import styles from "./Run.module.css"; /** * Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`), - * verified against small programs. Others (netcoredbg, dart, php-debug, …) only got + * verified against small programs. Others (dart, php-debug, …) only got * generic launch arguments and are left out until they have one; a configuration that * already names one keeps it. */ const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [ + { id: "netcoredbg", label: "netcoredbg (.NET)" }, { id: "java-debug", label: "java-debug (Java)" }, { id: "kotlin-debug-adapter", label: "kotlin-debug-adapter (Kotlin)" }, { id: "debugpy", label: "debugpy (Python)" }, @@ -254,6 +255,9 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi })), }, { label: TASK_LABELS.build, icon: "build-project", run: () => addTask("build") }, + ...(config.kind === "dotnet" + ? [{ label: TASK_LABELS.dotnetBuild, icon: "build-project", run: () => addTask("dotnetBuild") }] + : []), { label: TASK_LABELS.tool, disabled: !externalTools.length, diff --git a/src/features/run/beforeLaunch.ts b/src/features/run/beforeLaunch.ts index fc0e632f..8ce13fbd 100644 --- a/src/features/run/beforeLaunch.ts +++ b/src/features/run/beforeLaunch.ts @@ -2,6 +2,7 @@ * Before launch tasks (IDEA's "Before launch" list): run in order before a * configuration starts; the first failure aborts the launch. */ +import { dotnetBuildConfig } from "./dotnetTypes"; import { executeAction, getAction } from "@/platform/actions"; import { getContext } from "@/platform/context"; import { tx, txTemplate } from "@/platform/i18n"; @@ -14,11 +15,12 @@ import { startRun, useRun } from "./store"; export const BUILD_ACTION = "build.buildProject"; const BUILD_STATUS = "build.lastStatus"; -export type BeforeLaunchKind = "run" | "build" | "tool" | "command" | "toolWindow"; +export type BeforeLaunchKind = "run" | "build" | "tool" | "command" | "toolWindow" | "dotnetBuild"; export const TASK_LABELS: Record = { run: "Run Another Configuration", build: "Build Project", + dotnetBuild: "Build .NET project", tool: "Run External Tool", command: "Run Shell Command", toolWindow: "Activate Tool Window", @@ -32,6 +34,8 @@ export function taskLabel(task: BeforeLaunchTask, configs: readonly RunConfig[] return `Run '${configs.find((c) => c.id === target)?.name ?? target}'`; case "build": return "Build Project"; + case "dotnetBuild": + return "Build .NET project"; case "tool": return `External tool '${workflowSettings().externalTools.find((t) => t.id === target)?.name ?? target}'`; case "command": @@ -58,6 +62,8 @@ async function runToEnd(config: RunConfig, chain: readonly string[]): Promise { const target = task.target ?? ""; switch (task.kind) { + case "dotnetBuild": + return (await runToEnd(dotnetBuildConfig(config), chain)) ? null : "Build failed"; case "build": { if (!getAction(BUILD_ACTION)) return "Building is not available"; if (!(await executeAction(BUILD_ACTION, { activate: false }))) return "A build is already running"; diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index 7669141b..5d94cb2a 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -67,6 +67,9 @@ export function configIcon(config: Pick): string { return "file-gradle"; case "maven": return "file-maven"; + case "dotnet": + case "dotnet-test": + return "file-csharp"; case "go": return "file-go"; case "node": @@ -220,7 +223,7 @@ export function validateConfig(config: RunConfig, all: readonly RunConfig[] = [] for (const t of config.beforeLaunch ?? []) { if (t.kind === "run" && (t.target === config.id || !all.some((c) => c.id === t.target))) errors.beforeLaunch = "A before launch task runs a missing configuration or this one"; - else if (t.kind !== "build" && t.kind !== "run" && !(t.target ?? "").trim()) + else if (t.kind !== "build" && t.kind !== "dotnetBuild" && t.kind !== "run" && !(t.target ?? "").trim()) errors.beforeLaunch = "A before launch task is incomplete"; } for (const key of Object.keys(config.env)) { diff --git a/src/features/run/dotnetTypes.test.tsx b/src/features/run/dotnetTypes.test.tsx new file mode 100644 index 00000000..93d5f16a --- /dev/null +++ b/src/features/run/dotnetTypes.test.tsx @@ -0,0 +1,123 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { commands } from "@/platform/ipc"; +import type { RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { dotnetBuildConfig, dotnetProjectType, dotnetTestType } from "./dotnetTypes"; +import { ConfigForm } from "./ConfigForm"; +import { validateConfig } from "./configs"; +import { newConfigOfType, registerBuiltinTypes, withParams } from "./types"; + +const resolveDotnet = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, commands: { ...real.commands, runDotnetResolve: resolveDotnet } }; +}); + +const params = { + project: "src/My App/App.csproj", + framework: "net10.0", + frameworks: "net10.0;net9.0", + launchProfile: "local web", + programArgs: "--name 'hello world'", + profiles: JSON.stringify({ + "local web": { applicationUrl: "http://localhost:5165", environmentVariables: { DEMO: "profile" } }, + alternate: {}, + }), +}; +let dispose: () => void; +beforeEach(() => { + dispose = registerBuiltinTypes(); +}); +afterEach(() => dispose()); + +function config(): RunConfig { + return withParams({ ...newConfigOfType(dotnetProjectType, []), name: "demo" }, params, dotnetProjectType); +} + +describe(".NET configurations", () => { + it("generates run/test commands with spaces, framework/profile and separated program arguments", () => { + const c = config(); + expect(c.args).toEqual([ + "run", + "--project", + "src/My App/App.csproj", + "--framework", + "net10.0", + "--launch-profile", + "local web", + "--", + "--name", + "hello world", + ]); + expect(c.debugAdapter).toBe("netcoredbg"); + expect(c.beforeLaunch).toEqual([{ kind: "dotnetBuild", target: "" }]); + expect(validateConfig(c, [c])).toEqual({}); + const t = withParams( + newConfigOfType(dotnetTestType, []), + { project: params.project, framework: "net9.0", programArgs: '--filter "Category=Unit"' }, + dotnetTestType, + ); + expect(t.isTest).toBe(true); + expect(t.debugAdapter).toBeNull(); + expect(t.args).toEqual(["test", params.project, "--framework", "net9.0", "--filter", "Category=Unit"]); + expect(dotnetProjectType.commandLine({ project: "App.csproj" })?.args).toEqual([ + "run", + "--project", + "App.csproj", + "--no-launch-profile", + ]); + }); + + it("builds edited project/framework from the open root instead of a profile cwd", () => { + useProject.setState({ project: { root: "/repo" } as never }); + const c = withParams( + { ...config(), cwd: "profile/work" }, + { ...params, project: "other/App.csproj", framework: "net9.0" }, + ); + const build = dotnetBuildConfig(c); + expect(build.args).toEqual(["build", "other/App.csproj", "--configuration", "Debug", "--framework", "net9.0"]); + expect(build.cwd).toBe("/repo"); + expect(build.beforeLaunch).toEqual([]); + }); + + it("edits project, framework, profile, arguments and environment in the shared form", () => { + const c = config(); + const change = vi.fn(); + render( + , + ); + expect(screen.getByText("http://localhost:5165")).toBeTruthy(); + expect(screen.getByText("DEMO=profile")).toBeTruthy(); + for (const [label, value] of [ + [".NET project", "other/App.csproj"], + ["Target framework", "net9.0"], + ["Launch profile", "alternate"], + ["Program arguments", "--edited"], + ] as const) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + expect(change).toHaveBeenCalled(); + } + fireEvent.change(screen.getByRole("textbox", { name: "Environment variables" }), { + target: { value: "DEMO=override" }, + }); + expect(change.mock.lastCall?.[0]).toMatchObject({ env: { DEMO: "override" } }); + }); + it("prepares Run from the freshly resolved profile while keeping explicit argument overrides", async () => { + const c = config(); + resolveDotnet.mockResolvedValue({ + status: "ok", + data: { + ...c, + cwd: "/repo/work", + env: { DEMO: "override" }, + params: { ...params, project: "/repo/App.csproj", profileArgs: "--from-profile" }, + }, + }); + const prepared = await dotnetProjectType.prepareLaunch!(c); + expect(commands.runDotnetResolve).toHaveBeenCalledWith(c, false); + expect(prepared).toMatchObject({ cwd: "/repo/work", env: { DEMO: "override" } }); + expect(prepared.args).toContain("--no-launch-profile"); + expect(prepared.args.slice(-3)).toEqual(["--", "--name", "hello world"]); + }); +}); diff --git a/src/features/run/dotnetTypes.ts b/src/features/run/dotnetTypes.ts new file mode 100644 index 00000000..534ed573 --- /dev/null +++ b/src/features/run/dotnetTypes.ts @@ -0,0 +1,134 @@ +import { call, commands, type RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { parseArgs } from "./configs"; + +function profile(p: Readonly>): { + applicationUrl?: string; + environmentVariables?: Record; +} { + try { + return JSON.parse(p.profiles || "{}")[p.launchProfile || ""] ?? {}; + } catch { + return {}; + } +} + +export function dotnetCommand(p: Readonly>, test = false) { + return { + command: p.executable || "dotnet", + args: [ + ...(test ? ["test", p.project || ""] : ["run", "--project", p.project || ""]), + ...(p.framework ? ["--framework", p.framework] : []), + ...(!test ? (p.launchProfile ? ["--launch-profile", p.launchProfile] : ["--no-launch-profile"]) : []), + ...(p.programArgs?.trim() ? ["--", ...parseArgs(p.programArgs)] : []), + ], + }; +} + +/** Build the current project/framework, independently of the run's profile working directory. */ +export function dotnetBuildConfig(config: RunConfig): RunConfig { + const p = config.params ?? {}; + return { + ...config, + id: `dotnet-build:${config.id}`, + name: `dotnet build (${p.project || ""})`, + kind: "shell", + args: ["build", p.project || "", "--configuration", "Debug", ...(p.framework ? ["--framework", p.framework] : [])], + cwd: useProject.getState().project?.root ?? null, + env: {}, + params: {}, + isTest: false, + debugAdapter: null, + beforeLaunch: [], + emulateTerminal: false, + stdinFile: undefined, + }; +} + +export const dotnetProjectType: RunConfigTypeContribution = { + id: "dotnet", + label: ".NET Project", + icon: "file-csharp", + description: "Run a .NET project with a launch profile", + debugAdapter: "netcoredbg", + fields: [ + { + key: "project", + label: ".NET project", + mono: true, + required: true, + suggestions: (configs) => [ + ...new Set( + configs.filter((c) => c.kind === "dotnet" || c.kind === "dotnet-test").map((c) => c.params?.project || ""), + ), + ], + }, + { + key: "framework", + label: "Target framework", + mono: true, + suggestions: (_configs, p) => (p.frameworks || "").split(";").filter(Boolean), + }, + { + key: "launchProfile", + label: "Launch profile", + mono: true, + hint: "Empty disables launch profiles", + suggestions: (_configs, p) => { + try { + return Object.keys(JSON.parse(p.profiles || "{}")); + } catch { + return []; + } + }, + }, + { key: "programArgs", label: "Program arguments", mono: true, hint: "Empty uses the launch profile's arguments" }, + { + key: "applicationUrl", + label: "Application URL", + control: "output", + value: (p) => profile(p).applicationUrl || "", + }, + { + key: "profileEnvironment", + label: "Profile environment variables", + control: "output", + value: (p) => + Object.entries(profile(p).environmentVariables ?? {}) + .map(([k, v]) => `${k}=${v}`) + .join("; "), + hint: "Environment variables below override the profile", + }, + ], + commandLine: (p) => dotnetCommand(p), + async prepareLaunch(config) { + const resolved = await call(commands.runDotnetResolve(config, false)); + const p = resolved.params ?? {}; + // Materialize the profile so explicit environment/cwd edits also apply on older SDKs. + const args = parseArgs(p.programArgs?.trim() ? p.programArgs : p.profileArgs || ""); + return { + ...resolved, + args: [ + ...dotnetCommand({ ...p, launchProfile: "", programArgs: "" }).args, + ...(args.length ? ["--", ...args] : []), + ], + }; + }, +}; + +export const dotnetTestType: RunConfigTypeContribution = { + id: "dotnet-test", + label: "dotnet test", + icon: "file-csharp", + description: "Run .NET tests", + fields: [...dotnetProjectType.fields.slice(0, 2), { key: "programArgs", label: "Test arguments", mono: true }], + commandLine(p) { + const line = dotnetCommand({ ...p, programArgs: "" }, true); + return { ...line, args: [...line.args, ...parseArgs(p.programArgs || "")] }; + }, + async prepareLaunch(config) { + const resolved = await call(commands.runDotnetResolve(config, false)); + return { ...resolved, ...dotnetTestType.commandLine(resolved.params ?? {}) }; + }, +}; diff --git a/src/features/run/types.ts b/src/features/run/types.ts index b0345cd2..1ad2edf8 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -22,6 +22,8 @@ import { useProject } from "@/platform/project"; import { tomcatType } from "./tomcat"; import { goBuildType, goTestType } from "./goTypes"; +import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; + export const COMPOUND = "compound"; const on = (v: string | undefined) => v === "true"; @@ -396,6 +398,8 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + dotnetProjectType, + dotnetTestType, goBuildType, goTestType, applicationType, @@ -444,6 +448,10 @@ export function withParams(config: RunConfig, params: Record, ty if (!line) return { ...rest, params, command: "", args: [] }; return { ...rest, + ...(config.kind === "dotnet" && config.beforeLaunch === undefined + ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } + : {}), + ...(config.kind === "dotnet-test" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), ...(params.goKind ? { isTest: params.goKind === "test" } : {}), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b92b2166..43edfc7f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5470,5 +5470,25 @@ "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}" + "CMake profile {0}": "CMake 配置 {0}", + ".NET Project": ".NET 项目", + ".NET project": ".NET 项目", + "Run a .NET project with a launch profile": "使用启动配置运行 .NET 项目", + "Target framework": "目标框架", + "Launch profile": "启动配置", + "Empty disables launch profiles": "留空则禁用启动配置", + "Empty uses the launch profile's arguments": "留空则使用启动配置中的参数", + "Application URL": "应用 URL", + "Profile environment variables": "启动配置中的环境变量", + "Environment variables below override the profile": "下方的环境变量会覆盖启动配置中的值", + "Build .NET project": "构建 .NET 项目", + "Run .NET tests": "运行 .NET 测试", + "Test arguments": "测试参数", + "netcoredbg is not installed": "未安装 netcoredbg", + ".NET Project requires the local machine": ".NET 项目需要在本机运行", + "Select a .NET project": "请选择 .NET 项目", + "Invalid .NET project path": ".NET 项目路径无效", + ".NET launch profile '{profile_name}' is missing or is not a Project profile": ".NET 启动配置“{profile_name}”不存在或不是 Project 类型", + ".NET output resolution timed out": ".NET 构建产物路径查询超时", + ".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 2079d23a..827d795f 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -79,6 +79,8 @@ const BUILT_AT_RUNTIME = new Set([]); /** Text that stays as written in every locale: names, code, formats, shortcuts. */ const UNTRANSLATED = new Set([ + "dotnet test", + "netcoredbg (.NET)", // Product names "Cargo", "CMake", @@ -486,6 +488,21 @@ function scanTypeScript(): Scan { return { translated, literals, untranslatable, spliced }; } +/** Read strings without letting a Rust character literal open a string match. */ +function backendLiterals(source: string): string[] { + const out: string[] = []; + for (const m of source.matchAll(/'(?:\\.|[^'\\\r\n])'|"((?:[^"\\]|\\.)*)"/g)) { + if (m[1] !== undefined) out.push(m[1]); + } + return out; +} + +it("reads backend messages after quote character literals", () => { + expect( + backendLiterals(`let quote = '"'; let message = "Select a .NET project"; let detail = "Expected 'x'";`), + ).toEqual(["Select a .NET project", "Expected 'x'"]); +}); + /** String literals of the Rust crates and language definitions (backend messages). */ function scanBackend(literals: Set) { const files = [ @@ -494,7 +511,7 @@ function scanBackend(literals: Set) { ...walk(`${ROOT}languages`, (p) => p.endsWith(".toml")), ]; for (const file of files) - for (const m of readFileSync(file, "utf8").matchAll(/"((?:[^"\\]|\\.)*)"/g)) literals.add(shape(m[1]!)); + for (const literal of backendLiterals(readFileSync(file, "utf8"))) literals.add(shape(literal)); } /** Text that needs no translation: no words, a path / glob / option, or listed in `UNTRANSLATED`. */ From b3abc8edd11a2869e3361c66c73e6464714a6c0f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:09:08 +0800 Subject: [PATCH 008/108] fix(remote): browse remote directories without an open project (#691) Browse in the Remote Development dialog failed with "no project is open in this window" on the welcome screen, and in untrusted project windows: remote_bootstrap_list was not classified in the restricted-mode allowlist, so IPC authorization required a trusted project before the command reached SSH. The listing itself already used the selected SSH configuration and never depended on a project. remote_bootstrap_list and deploy_test_connection are now allowed alongside the other application-level remote commands; both only connect or list remote paths and run no project code. Every other command used by the dialog and the welcome screen's remote entries was checked and is already classified. Fixes #672 --- crates/ide-host/Cargo.toml | 1 + crates/ide-host/src/ops/dependencies.rs | 1 + crates/ide-host/src/trust.rs | 2 + crates/ide-host/tests/trust.rs | 60 +++++++++ .../remote-dev/ConnectDialog.test.tsx | 118 ++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 src/features/remote-dev/ConnectDialog.test.tsx diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 39922852..a6e21c88 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -51,6 +51,7 @@ uuid.workspace = true tempfile.workspace = true [dev-dependencies] +ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } [target.'cfg(windows)'.dependencies] diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs index 96dfad9b..0dc62207 100644 --- a/crates/ide-host/src/ops/dependencies.rs +++ b/crates/ide-host/src/ops/dependencies.rs @@ -91,6 +91,7 @@ mod tests { 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(|_| {})), diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index b829bd63..7e8522e1 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "index_status" | "jvm_file_presentations" | "deploy_coverage" + | "deploy_test_connection" | "find_files" | "find_symbols" | "mark_recent" @@ -243,6 +244,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "remote_current" | "remote_disconnect" | "remote_connect" + | "remote_bootstrap_list" | "remote_list" | "remote_open" | "remote_of" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index aa41a315..eb2868ad 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -56,6 +56,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "db_console_path", "db_export_file", "vcs_write_patch", + "remote_bootstrap_list", + "deploy_test_connection", ] { assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); for window in ["main", "welcome"] { @@ -75,6 +77,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "vcs_git_version", "docker_compose_op", "db_execute", + "remote_delete", + "remote_mkdir", + "remote_rename", + "remote_import_local", + "remote_bootstrap_exec", + "deploy_upload", + "deploy_download", + "deploy_delete_remote", + "deploy_sync", "unknown_command", ] { assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); @@ -109,6 +120,55 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( host.shutdown().await; } +#[tokio::test] +async fn welcome_window_browses_selected_ssh_host_without_executing_commands() { + use ide_core::remote_settings::{SshAuth, SshConfiguration}; + use ide_ssh::{FsConnection, SshFiles, SshManager}; + + let tmp = tempfile::tempdir().unwrap(); + let server_root = tmp.path().join("server"); + std::fs::create_dir_all(server_root.join("projects/demo")).unwrap(); + std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap(); + let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await; + let ssh = Arc::new(SshManager::with_files( + tmp.path().join("secrets.json"), + SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }, + )); + ssh.set_configs(vec![SshConfiguration { + id: "selected-host".into(), + host: "127.0.0.1".into(), + port: Some(port), + user: "dev".into(), + auth: SshAuth::Password, + ..Default::default() + }]); + ssh.set_secret("selected-host", Some("secret".into()), false); + assert!(ssh.session("selected-host").await.is_err()); + ssh.trust_host("selected-host").await.unwrap(); + + let mut host = host(&tmp.path().join("app"), false); + let state = Arc::get_mut(&mut host).unwrap(); + state.ssh = ssh; + state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache"))); + assert!(host.project("welcome").is_err()); + host.authorize_command("welcome", "remote_bootstrap_list").unwrap(); + let connection = FsConnection::Sftp { ssh: "selected-host".into() }; + let entries = host.remote.list(&connection, "/projects").await.unwrap(); + assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir)); + assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir)); + assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty()); + host.authorize_command("welcome", "deploy_test_connection").unwrap(); + host.remote.test_connection(&connection, "/projects").await.unwrap(); + let local = tmp.path().join("local-deployment"); + std::fs::create_dir(&local).unwrap(); + ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into()) + .await + .unwrap(); + assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up"); + assert!(host.project("welcome").is_err()); + host.shutdown().await; +} + #[test] fn persists_user_decisions_locations_revocation_and_one_time_migration() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/features/remote-dev/ConnectDialog.test.tsx b/src/features/remote-dev/ConnectDialog.test.tsx new file mode 100644 index 00000000..b80ed3e8 --- /dev/null +++ b/src/features/remote-dev/ConnectDialog.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { RemoteEntry, Settings } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { useRemoteProgress } from "@/platform/remote"; +import { ConnectHost } from "./ConnectDialog"; +import { closeConnectDialog, openConnectDialog, useConnectDialog } from "./model"; + +const { list, openRemote, executeAction } = vi.hoisted(() => ({ + list: vi.fn(), + openRemote: vi.fn(), + executeAction: vi.fn(), +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, commands: { ...real.commands, remoteBootstrapList: list } }; +}); +vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote })); +vi.mock("@/platform/actions", async (importOriginal) => ({ + ...(await importOriginal()), + executeAction, +})); + +const entry = (path: string, isDir = true): RemoteEntry => ({ + name: path.slice(path.lastIndexOf("/") + 1), + path, + isDir, + isSymlink: false, + size: 0, + mtime: null, + permissions: "rwxr-xr-x", +}); + +beforeEach(() => { + vi.resetAllMocks(); + useProject.setState({ + project: null, + opening: false, + settings: { + ssh: { + configs: [ + { id: "first", name: "First host", host: "first.example", user: "dev" }, + { id: "selected", name: "Selected host", host: "selected.example", user: "dev" }, + ], + }, + } as Settings, + }); + useRemoteProgress.setState({ progress: null, location: null }); + openConnectDialog(); +}); + +afterEach(() => closeConnectDialog()); + +it("browses the selected SSH host, navigates folders and opens one without a current project", async () => { + let finish!: (result: { status: "ok"; data: RemoteEntry[] }) => void; + list.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))); + openRemote.mockResolvedValue(true); + render(); + fireEvent.click(screen.getByRole("button", { name: "SSH configuration" })); + fireEvent.click(screen.getByRole("option", { name: /Selected host/ })); + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + expect(list).toHaveBeenCalledWith("selected", "~"); + expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Open" })).toBeDisabled(); + finish({ + status: "ok", + data: [entry("/home/dev/.cache"), entry("/home/dev/projects"), entry("/home/dev/README.md", false)], + }); + const folders = await screen.findByRole("list", { name: "Remote folders" }); + expect( + within(folders) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["..", "projects", ".cache"]); + expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev"); + list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects/demo")] }); + fireEvent.click(within(folders).getByRole("button", { name: "projects" })); + await screen.findByRole("button", { name: "demo" }); + expect(list).toHaveBeenLastCalledWith("selected", "/home/dev/projects"); + list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects")] }); + fireEvent.click(within(folders).getByRole("button", { name: ".." })); + await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev")); + expect(list).toHaveBeenLastCalledWith("selected", "/home/dev"); + list.mockResolvedValueOnce({ status: "ok", data: [] }); + fireEvent.click(within(folders).getByRole("button", { name: "projects" })); + await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev/projects")); + expect(within(folders).getAllByRole("button")).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + await waitFor(() => expect(openRemote).toHaveBeenCalledWith("selected", "/home/dev/projects")); + expect(useConnectDialog.getState().open).toBe(false); + expect(useProject.getState().project).toBeNull(); +}); + +it("shows a connection error and lets the welcome window retry Browse", async () => { + list.mockRejectedValueOnce(new Error("Connection refused")); + render(); + fireEvent.change(screen.getByLabelText("Project directory:"), { target: { value: " /srv/projects " } }); + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Connection refused"); + expect(list).toHaveBeenCalledWith("first", "/srv/projects"); + expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled(); + list.mockResolvedValueOnce({ status: "ok", data: [] }); + fireEvent.click(screen.getByRole("button", { name: "Browse" })); + await screen.findByRole("list", { name: "Remote folders" }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(openRemote).not.toHaveBeenCalled(); +}); + +it("offers SSH configuration from the welcome window and disables Browse until one exists", () => { + useProject.setState({ settings: { ssh: { configs: [] } } as unknown as Settings }); + render(); + expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Open" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Configure…" })); + expect(executeAction).toHaveBeenCalledWith("file.settings", "tools.sshConfigurations"); + expect(list).not.toHaveBeenCalled(); +}); From 8d7ce812493157dc62bfa78f6c708754835043f2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:14:08 +0800 Subject: [PATCH 009/108] fix(macos): open project windows as tabs per the system tab preference (#693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project windows only joined a tab group when merged explicitly or restored at startup; the macOS "Prefer tabs when opening documents" setting was never read, so opening another project or a workspace always created a separate window. New project windows now follow that preference: with Always they join the active project window's tab group (forming one if needed) and are selected; with In Full Screen they do so only while the active window is full screen; with Manually they stay separate. This covers every path that creates a project window — Open, recent projects, workspaces, drag and drop, and opening from the command line or Finder — while LightEdit windows and restored groups keep their behaviour. New Workspace now opens through the same Open project in policy as Open Workspace, and a race when toggling full screen during the join is fixed. Fixes #678 --- crates/ide-workspace/src/window_groups.rs | 69 +++++++++++++ src-tauri/src/lib.rs | 7 +- src-tauri/src/macos.rs | 98 +++++++++++++++++++ src-tauri/src/window_groups.rs | 18 +++- src-tauri/src/windows.rs | 38 +++++-- src/features/app/WorkspaceDialog.tsx | 4 +- .../app/__tests__/WorkspaceDialog.test.tsx | 21 +++- 7 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 src-tauri/src/macos.rs diff --git a/crates/ide-workspace/src/window_groups.rs b/crates/ide-workspace/src/window_groups.rs index a2a088d4..5e948a79 100644 --- a/crates/ide-workspace/src/window_groups.rs +++ b/crates/ide-workspace/src/window_groups.rs @@ -2,6 +2,29 @@ use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; +/// Platform adapters supply the system's tab preference and active project state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabbingPreference { + Always, + InFullScreen, + Manually, +} + +pub fn should_group_new_project( + preference: TabbingPreference, + fullscreen: bool, + active_project: bool, + light_edit: bool, +) -> bool { + active_project + && !light_edit + && match preference { + TabbingPreference::Always => true, + TabbingPreference::InFullScreen => fullscreen, + TabbingPreference::Manually => false, + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WindowGroup { pub members: Vec, @@ -39,6 +62,13 @@ impl WindowGroups { Ok(()) } + /// Adds a window to the anchor's group without merging unrelated groups. + pub fn join(&mut self, anchor: &str, member: &str) -> IdeResult<()> { + let mut members = self.group(anchor).map(|g| g.members.clone()).unwrap_or_else(|| vec![anchor.to_owned()]); + members.push(member.to_owned()); + self.merge(members, anchor) + } + pub fn select(&mut self, caller: &str, target: &str) -> IdeResult { let group = self .groups @@ -94,6 +124,45 @@ mod tests { values.iter().map(|s| (*s).into()).collect() } + #[test] + fn new_project_grouping_follows_system_preference_and_excludes_light_edit() { + for preference in [TabbingPreference::Always, TabbingPreference::InFullScreen, TabbingPreference::Manually] { + for fullscreen in [false, true] { + for active_project in [false, true] { + for light_edit in [false, true] { + let expected = matches!( + (preference, fullscreen, active_project, light_edit), + (TabbingPreference::Always, _, true, false) + | (TabbingPreference::InFullScreen, true, true, false) + ); + assert_eq!( + should_group_new_project(preference, fullscreen, active_project, light_edit), + expected, + "{preference:?}, fullscreen={fullscreen}, active={active_project}, light={light_edit}" + ); + } + } + } + } + } + + #[test] + fn joining_new_windows_preserves_the_active_group_and_other_groups() { + let mut groups = WindowGroups::default(); + groups.merge(members(&["other-a", "other-b"]), "other-b").unwrap(); + groups.join("a", "b").unwrap(); + assert_eq!(groups.group("a").unwrap().members, members(&["a", "b"])); + let before = groups.select("a", "b").unwrap(); + assert_eq!(before.selected, "a"); + groups.join("b", "c").unwrap(); + let before = groups.select("b", "c").unwrap(); + assert_eq!(before.selected, "b"); + assert_eq!(groups.group("c").unwrap().members, members(&["a", "b", "c"])); + assert_eq!(groups.group("c").unwrap().selected, "c"); + assert_eq!(groups.group("other-a").unwrap().members, members(&["other-a", "other-b"])); + assert_eq!(groups.group("other-a").unwrap().selected, "other-b"); + } + #[test] fn selecting_and_detaching_preserve_the_other_sessions() { let mut groups = WindowGroups::default(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed795e66..e75910a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,8 @@ mod events; #[cfg(any(target_os = "linux", test))] mod linux_window_state; mod logging; +#[cfg(target_os = "macos")] +mod macos; mod menu; mod remote_dev; @@ -172,7 +174,10 @@ pub fn run() { tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false), tauri::WindowEvent::Destroyed => { #[cfg(target_os = "macos")] - titlebar::forget(window); + { + titlebar::forget(window); + macos::forget_fullscreen(window.label()); + } window_groups::destroyed(window.app_handle(), window.label()); if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) { let _ = settings.destroy(); diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs new file mode 100644 index 00000000..783e0a99 --- /dev/null +++ b/src-tauri/src/macos.rs @@ -0,0 +1,98 @@ +//! macOS window tab preferences, applied to application-drawn project tabs. +use crate::windows::{Launch, WindowsState}; +use ide_core::{IdeError, IdeResult}; +use ide_workspace::window_groups::{TabbingPreference, should_group_new_project}; +use objc2::{MainThreadMarker, rc::Retained, runtime::ProtocolObject}; +use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference}; +use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +fn tabbing_preference(app: &AppHandle) -> IdeResult { + fn read(mtm: MainThreadMarker) -> TabbingPreference { + match NSWindow::userTabbingPreference(mtm) { + NSWindowUserTabbingPreference::Always => TabbingPreference::Always, + NSWindowUserTabbingPreference::InFullScreen => TabbingPreference::InFullScreen, + _ => TabbingPreference::Manually, + } + } + if let Some(mtm) = MainThreadMarker::new() { + return Ok(read(mtm)); + } + let (tx, rx) = std::sync::mpsc::sync_channel(1); + app.run_on_main_thread(move || { + let _ = tx.send(read(MainThreadMarker::new().expect("AppKit main thread"))); + }) + .map_err(|e| IdeError::internal(e.to_string()))?; + rx.recv().map_err(|e| IdeError::internal(e.to_string())) +} + +/// Capture before building the new window, which takes focus itself. +pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult> { + let windows = app.webview_windows(); + let active = windows + .values() + .find(|w| w.is_focused().unwrap_or(false)) + .cloned() + .or_else(|| app.state::().last_focused().and_then(|label| windows.get(&label).cloned())); + let active = active.and_then(|w| { + let label = crate::windows::settings_parent(w.label()).unwrap_or(w.label()); + windows.get(label).cloned() + }); + let active = active.filter(|w| { + let host = app.state::(); + host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some() + }); + let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false)); + let light_edit = launch.project.is_none() && launch.remote.is_none(); + Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit) + .then_some(active) + .flatten()) +} + +struct FullscreenObserver(Retained>); + +impl Drop for FullscreenObserver { + fn drop(&mut self) { + let observer: &ProtocolObject = &self.0; + unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) }; + } +} + +thread_local! { + static FULLSCREEN_TRANSFERS: std::cell::RefCell> = std::cell::RefCell::default(); +} + +pub fn forget_fullscreen(label: &str) { + FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.remove(label)); +} + +/// AppKit cannot enter another window's fullscreen Space until the old one has exited. +pub fn transfer_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { + let source = from.label().to_owned(); + let to = to.clone(); + from.with_webview(move |webview| unsafe { + let native = &*webview.ns_window().cast::(); + forget_fullscreen(&source); + let label = source.clone(); + let block = block2::RcBlock::new(move |_| { + forget_fullscreen(&label); + let app = to.app_handle(); + let selected = app.state::().groups.lock().group(to.label()).map(|g| g.selected.clone()); + if let Some(selected) = selected { + // Exiting fullscreen can show the old window again; reapply group visibility. + let _ = crate::window_groups::select(app, &selected, &selected); + if selected == to.label() { + let _ = to.set_fullscreen(true); + } + } + }); + let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( + Some(objc2_app_kit::NSWindowDidExitFullScreenNotification), + Some(native), + None, + &block, + ); + FULLSCREEN_TRANSFERS.with_borrow_mut(|observers| observers.insert(source, FullscreenObserver(observer))); + native.toggleFullScreen(None); + }) +} diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 66fe0c88..fef00fa1 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -51,8 +51,13 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { let err = |e: tauri::Error| IdeError::internal(e.to_string()); let fullscreen = from.is_fullscreen().map_err(err)?; if fullscreen { - from.set_fullscreen(false).map_err(err)?; - to.set_fullscreen(true).map_err(err)?; + #[cfg(target_os = "macos")] + crate::macos::transfer_fullscreen(from, to).map_err(err)?; + #[cfg(not(target_os = "macos"))] + { + from.set_fullscreen(false).map_err(err)?; + to.set_fullscreen(true).map_err(err)?; + } return Ok(()); } to.set_fullscreen(false).map_err(err)?; @@ -109,6 +114,13 @@ pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> { result } +#[cfg(target_os = "macos")] +pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> { + let state = app.state::(); + state.groups.lock().join(anchor, new_window)?; + select(app, anchor, new_window) +} + pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> { let members = windows::project_window_labels(app) .into_iter() @@ -235,7 +247,7 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { } else if path.is_dir() { let window = match windows::find_project_window(app, path) { Some(w) => w, - None => windows::create_window(app, Some(path.clone()))?, + None => windows::create_restored_window(app, path.clone())?, }; Some(window.label().to_owned()) } else { diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 1599e060..b9d14c12 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -357,18 +357,35 @@ pub fn create_main_window(app: &AppHandle) -> IdeResult { /// Creates a new project window; `project` is opened by its frontend on load. pub fn create_window(app: &AppHandle, project: Option) -> IdeResult { - create_window_with(app, project, Vec::new()) + create_window_with(app, project, Vec::new(), false) } /// [`create_window`] that also opens `files` (without `project`: in LightEdit). -fn create_window_with(app: &AppHandle, project: Option, files: Vec) -> IdeResult { +fn create_window_with( + app: &AppHandle, + project: Option, + files: Vec, + restoring: bool, +) -> IdeResult { let title = project.as_deref().or(files.first().map(PathBuf::as_path)).and_then(Path::file_name); let title = title.map(|n| n.to_string_lossy().into_owned()); let files = files.into_iter().map(Into::into).collect(); - create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }) + create_window_for(app, title, Launch { project: project.map(Into::into), files, ..Default::default() }, restoring) } -fn create_window_for(app: &AppHandle, title: Option, launch: Launch) -> IdeResult { +/// Restoring a saved group preserves its membership and selected tab. +pub fn create_restored_window(app: &AppHandle, project: PathBuf) -> IdeResult { + create_window_with(app, Some(project), Vec::new(), true) +} + +fn create_window_for( + app: &AppHandle, + title: Option, + launch: Launch, + _restoring: bool, +) -> IdeResult { + #[cfg(target_os = "macos")] + let tab_target = if _restoring { None } else { crate::macos::new_project_tab_target(app, &launch)? }; let windows = app.webview_windows(); let label = next_label(windows.keys().map(String::as_str)); let mut config = window_config(app)?; @@ -388,6 +405,11 @@ fn create_window_for(app: &AppHandle, title: Option, launch: Launch) -> } app.state::().set_launch(&label, launch); let window = build_project_window(app, config)?; + #[cfg(target_os = "macos")] + if let Some(target) = tab_target { + crate::window_groups::join(app, target.label(), window.label())?; + return Ok(window); + } focus(&window); Ok(window) } @@ -401,7 +423,7 @@ pub fn spawn_window(app: &AppHandle, project: Option) { fn spawn_window_with(app: &AppHandle, project: Option, files: Vec) { let app = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(e) = create_window_with(&app, project, files) { + if let Err(e) = create_window_with(&app, project, files, false) { tracing::warn!("cannot open window: {}", e.message); } }); @@ -440,7 +462,7 @@ pub fn open_project_window( if !new_window { return Ok(OpenOutcome::Current); } - let window = create_window_with(app, Some(root), files)?; + let window = create_window_with(app, Some(root), files, false)?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } @@ -450,7 +472,7 @@ pub fn open_project_window( /// detach an editor tab out of its window. pub fn open_file_in_new_window(app: &AppHandle, project: Option, file: PathBuf) -> IdeResult { tracing::info!("open_file_in_new_window {} project={:?}", file.display(), project); - let window = create_window_with(app, project, vec![file])?; + let window = create_window_with(app, project, vec![file], false)?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } @@ -478,7 +500,7 @@ pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_win } let title = path.trim_end_matches('/').rsplit('/').next().filter(|n| !n.is_empty() && *n != "~").map(str::to_owned); let remote = RemoteLaunch { ssh_config: ssh_config.to_owned(), path: path.to_owned() }; - let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() })?; + let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() }, false)?; Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } diff --git a/src/features/app/WorkspaceDialog.tsx b/src/features/app/WorkspaceDialog.tsx index df5529f4..3e0f4fcd 100644 --- a/src/features/app/WorkspaceDialog.tsx +++ b/src/features/app/WorkspaceDialog.tsx @@ -5,7 +5,7 @@ import { basename, dirname, useProject } from "@/platform/project"; import { isRemoteWindow } from "@/platform/remote"; import { tx } from "@/platform/i18n"; import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui"; -import { openProjectInWindow } from "./windows"; +import { openProjectWithPolicy } from "./windows"; import styles from "./WorkspaceDialog.module.css"; const useWorkspaceDialog = create<{ mode: "create" | "manage" | null }>(() => ({ mode: null })); @@ -53,7 +53,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose const included = adding.trim() ? [...new Set([...projects, adding.trim()])] : projects; if (mode === "create") { const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included)); - await openProjectInWindow(root, !!project); + await openProjectWithPolicy(root); } else { const updated = await call(commands.workspaceUpdate(name.trim(), included)); useProject.setState({ project: updated, settings: updated.settings }); diff --git a/src/features/app/__tests__/WorkspaceDialog.test.tsx b/src/features/app/__tests__/WorkspaceDialog.test.tsx index 262cfb27..2095e3a4 100644 --- a/src/features/app/__tests__/WorkspaceDialog.test.tsx +++ b/src/features/app/__tests__/WorkspaceDialog.test.tsx @@ -3,13 +3,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { commands } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog"; -import { openProjectInWindow } from "../windows"; +import { openProjectWithPolicy } from "../windows"; -vi.mock("../windows", () => ({ openProjectInWindow: vi.fn(async () => {}) })); +vi.mock("../windows", () => ({ openProjectWithPolicy: vi.fn(async () => {}) })); vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => false })); beforeEach(() => { vi.restoreAllMocks(); + vi.mocked(openProjectWithPolicy).mockClear(); useProject.setState({ project: null, settings: null, lightEdit: false }); }); @@ -21,7 +22,21 @@ it("creates a workspace from the pending project field without an extra Add clic fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/frontend" } }); fireEvent.click(screen.getByRole("button", { name: /^Create$/ })); await waitFor(() => expect(save).toHaveBeenCalledWith("/work/full", "Workspace", ["/work/frontend"])); - expect(openProjectInWindow).toHaveBeenCalledWith("/work/full", false); + expect(openProjectWithPolicy).toHaveBeenCalledWith("/work/full"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); +}); + +it("uses the shared opening policy after creation from an already open project", async () => { + useProject.setState({ + project: { root: "/work/frontend", name: "frontend", trusted: true, isGit: false, settings: {} }, + settings: { general: { openProjectIn: "ask" } }, + } as never); + vi.spyOn(commands, "workspaceCreate").mockResolvedValue({ status: "ok", data: "/work/full" }); + showWorkspaceDialog("create"); + render(); + fireEvent.change(screen.getByLabelText("Workspace location"), { target: { value: "/work/full" } }); + fireEvent.click(screen.getByRole("button", { name: /^Create$/ })); + await waitFor(() => expect(openProjectWithPolicy).toHaveBeenCalledWith("/work/full")); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); }); From 74062497bdca11543247639511df3f891defc457 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 20:31:32 +0800 Subject: [PATCH 010/108] fix(core): read the Windows user PATH past REG_EXPAND_SZ quirks (#690) * fix(core): read the Windows user PATH past REG_EXPAND_SZ quirks `RegGetValueW` reports the unexpanded size of a `REG_EXPAND_SZ` value, so a user `Path` that grows when its `%VAR%` references are expanded was silently dropped. And when the terminating NUL leaked into the read buffer with a stray UTF-16 unit behind it, `trim_end_matches('\0')` left that unit in the string and `env::set_var("PATH", ...)` panicked ("strings passed to WinAPI cannot contain NULs", #683). Re-read at the size the failed call reports, and cut the decoded string at the first NUL instead of trimming trailing ones. Covered by a test against a throwaway key under HKCU\Software. * test: cover registry terminator corruption and refresh inspection groups --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/ide-core/Cargo.toml | 2 +- crates/ide-core/src/process.rs | 171 ++++++++++++++---- .../settings/__tests__/inspections.test.ts | 1 + 3 files changed, 135 insertions(+), 39 deletions(-) diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index a2a26d94..81e06570 100644 --- a/crates/ide-core/Cargo.toml +++ b/crates/ide-core/Cargo.toml @@ -19,4 +19,4 @@ tokio-util.workspace = true tempfile.workspace = true [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] } diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index 85ee20f5..9aaaff28 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -245,10 +245,7 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option #[cfg(windows)] pub fn user_path() -> Option { use windows_sys::Win32::Foundation::ERROR_SUCCESS; - use windows_sys::Win32::System::Registry::{ - HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW, - RegOpenKeyExW, - }; + use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW}; fn wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() @@ -259,45 +256,53 @@ pub fn user_path() -> Option { if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS { return None; } - let read = || { - // Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand. - let mut kind = 0u32; - let mut len = 0u32; - let name = wide("Path"); - let status = RegGetValueW( - key, - std::ptr::null(), - name.as_ptr(), - RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, - &mut kind, - std::ptr::null_mut(), - &mut len, - ); - if status != ERROR_SUCCESS || len == 0 { - return None; - } - let mut buf = vec![0u16; (len as usize).div_ceil(2)]; - let status = RegGetValueW( - key, - std::ptr::null(), - name.as_ptr(), - RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, - &mut kind, - buf.as_mut_ptr() as *mut _, - &mut len, - ); - if status != ERROR_SUCCESS { - return None; - } - let text = String::from_utf16_lossy(&buf); - Some(text.trim_end_matches('\0').to_owned()) - }; - let value = read(); + let value = read_registry_string(key, &wide("Path")); RegCloseKey(key); value.filter(|v| !v.trim().is_empty()).map(OsString::from) } } +/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded +/// against the current environment. `None` when the value is missing or unreadable. +/// +/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value +/// that grows when expanded is re-read at the size the failed call reports. And the +/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it — +/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)` +/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at +/// the first NUL, not just stripped of trailing ones. +#[cfg(windows)] +fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option { + use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}; + use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW}; + + const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ; + let mut kind = 0u32; + let mut len = 0u32; + // SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`. + let status = + unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) }; + if status != ERROR_SUCCESS || len == 0 { + return None; + } + let buf = loop { + let mut buf = vec![0u16; len as usize / 2 + 1]; + // SAFETY: as above; the buffer holds at least the `len` bytes the call may write. + let status = unsafe { + RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len) + }; + if status == ERROR_SUCCESS { + break buf; + } + if status != ERROR_MORE_DATA || len == 0 { + return None; + } + }; + let text = String::from_utf16_lossy(&buf); + let end = text.find('\0').unwrap_or(text.len()); + Some(text[..end].to_owned()) +} + /// The user's own `PATH` entries; `None` outside Windows (see the Windows impl). #[cfg(not(windows))] pub fn user_path() -> Option { @@ -334,6 +339,96 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn registry_strings_expand_and_stay_terminator_free() { + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW, + RegDeleteTreeW, RegSetValueExW, + }; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + struct TestState { + key: HKEY, + name: String, + variable: String, + previous: Option, + } + impl Drop for TestState { + fn drop(&mut self) { + // SAFETY: the key belongs to this test; Windows environment mutation is safe. + unsafe { + RegCloseKey(self.key); + RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr()); + match &self.previous { + Some(value) => std::env::set_var(&self.variable, value), + None => std::env::remove_var(&self.variable), + } + } + } + } + let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id()); + let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id()); + let long = "x".repeat(600); + let mut key: HKEY = std::ptr::null_mut(); + let opened = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + wide(&name).as_ptr(), + 0, + std::ptr::null(), + 0, + KEY_READ | KEY_WRITE, + std::ptr::null(), + &mut key, + std::ptr::null_mut(), + ) + }; + assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software"); + let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() }; + // SAFETY: Windows permits environment mutation. No other test uses this process-specific name. + unsafe { std::env::set_var(&variable, &long) }; + let set = |value: &str, kind: u32, data: &[u16]| { + // SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes. + assert_eq!( + unsafe { + RegSetValueExW( + key, + wide(value).as_ptr(), + 0, + kind, + data.as_ptr() as *const _, + (data.len() * 2) as u32, + ) + }, + ERROR_SUCCESS + ); + }; + let read = |value: &str| read_registry_string(key, &wide(value)); + set("Plain", REG_SZ, &wide("plain")); + set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin"))); + set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%"))); + // Reproduce the reported interior terminator followed by a stale UTF-16 unit. + set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n")); + set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n"))); + set("Empty", REG_SZ, &wide("")); + let plain = read("Plain"); + let expand = read("Expand"); + let expand_long = read("ExpandLong"); + assert_eq!(plain.as_deref(), Some("plain")); + assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref()); + // Longer than its unexpanded form: needs the re-read at the expanded size. + assert_eq!(expand_long.as_deref(), Some(long.as_str())); + assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin")); + assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref()); + assert_eq!(read("Empty").as_deref(), Some("")); + assert_eq!(read("Missing"), None); + } + #[test] fn append_path_entries_keeps_the_order_and_adds_what_is_missing() { let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap(); diff --git a/src/features/settings/__tests__/inspections.test.ts b/src/features/settings/__tests__/inspections.test.ts index c20644a0..32a13497 100644 --- a/src/features/settings/__tests__/inspections.test.ts +++ b/src/features/settings/__tests__/inspections.test.ts @@ -45,6 +45,7 @@ describe("inspections tree", () => { it("groups servers by language with seen and configured codes, built-ins by category", () => { expect(tree.map((g) => g.name)).toEqual([ + "Cargo", "General", "JavaScript", "Other", From 9ce3c4cd7e2e9fd723bf6303e419aed002124eb1 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 20:46:03 +0800 Subject: [PATCH 011/108] test(inspections): expect the Cargo group (#695) The dependency-update inspection added a Cargo built-in group; the inspections tree test still expected the previous group list. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> From 0d0de0183145b49f8f728c115b2b57f85fc41886 Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Sun, 1 Nov 2026 18:33:17 +0530 Subject: [PATCH 012/108] fix(java): re-sync jdtls after a Gradle sync so new dependencies resolve (#694) After a Gradle sync, build_sync did not send java/projectConfigurationsUpdate to jdtls (only the Maven path did), so jdtls kept its cached classpath and dependencies newly added to build.gradle stayed unresolved. The update is now sent for both Maven and Gradle, listing each project's build file. Co-authored-by: jasi381 <60892009+jasi381@users.noreply.github.com> --- crates/ide-host/src/ops/build.rs | 35 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index ad398330..767875df 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -133,22 +133,29 @@ pub async fn build_sync( host.end_request(request); let model = result??; if tool == BuildTool::Maven - && let Some(maven) = &model.maven + && model.maven.is_some() + && let Some(bridge) = &project.kotlin_bridge { - // jdtls re-imports on pom changes but misses artifacts added to ~/.m2 - // meanwhile ("missing artifact" stays until Invalidate Caches); asking it - // to update the project configurations re-resolves them (#436). - if let Some(bridge) = &project.kotlin_bridge { - bridge.configure_maven(offline, bridge_profiles); - } - let lsp = project.lsp.clone(); - let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect(); - tokio::spawn(async move { - if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await { - tracing::debug!("java/projectConfigurationsUpdate: {e}"); - } - }); + bridge.configure_maven(offline, bridge_profiles); } + // jdtls re-imports on pom changes but misses artifacts added to ~/.m2 + // meanwhile ("missing artifact" stays until Invalidate Caches); asking it + // to update the project configurations re-resolves them (#436). The same + // holds for Gradle: it reuses its cached classpath on restart, so a + // dependency added to `build.gradle` stays unresolved until told to sync. + let build_files: Vec = match tool { + BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(), + BuildTool::Gradle => { + model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect() + } + }; + let lsp = project.lsp.clone(); + let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(); + tokio::spawn(async move { + if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await { + tracing::debug!("java/projectConfigurationsUpdate: {e}"); + } + }); Ok(model) } From 88619505c3bcbc84346dbc91ff4e1e3471064a33 Mon Sep 17 00:00:00 2001 From: Dimas Victor Date: Sun, 1 Nov 2026 21:42:15 +0800 Subject: [PATCH 013/108] feat(terminal): paste clipboard images into remote SSH terminals (#699) In a remote-project window, pasting a clipboard image into the terminal uploads it to the SSH host that owns the terminal (new term_store_image command over the existing remote RPC) and inserts the POSIX-quoted path after a successful upload, without a newline. Images go to a private per-user terminal-images directory (0700/0600, UUID names, no-follow descriptor-relative writes) and expire after 24 hours on the next upload. Failures notify and insert nothing; text paste and local terminals are unchanged. Images over 20 MiB are rejected in both the frontend and the host. Co-authored-by: dvictor357 <187467850+dvictor357@users.noreply.github.com> --- Cargo.lock | 1 + crates/ide-host/Cargo.toml | 3 + crates/ide-host/src/ops/term.rs | 46 ++++ crates/ide-host/src/ops/term/images.rs | 220 +++++++++++++++++ crates/ide-host/src/rpc.rs | 3 + crates/ide-host/tests/terminal_images.rs | 47 ++++ crates/ide-terminal/src/lib.rs | 4 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/term.rs | 11 + src/bindings.ts | 1 + src/features/terminal/TerminalView.tsx | 3 +- src/features/terminal/paste.test.ts | 288 ++++++++++++++++++++++- src/features/terminal/paste.ts | 91 ++++++- src/features/terminal/session.ts | 25 +- src/platform/locales/text/zh.json | 6 +- 15 files changed, 737 insertions(+), 13 deletions(-) create mode 100644 crates/ide-host/src/ops/term/images.rs create mode 100644 crates/ide-host/tests/terminal_images.rs diff --git a/Cargo.lock b/Cargo.lock index ee5c7b81..b7590a3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4241,6 +4241,7 @@ dependencies = [ "ide-vfs", "ide-workspace", "parking_lot", + "rustix", "serde", "serde_json", "specta", diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index a6e21c88..6f2c1ce5 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -54,5 +54,8 @@ tempfile.workspace = true ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", features = ["fs", "process"] } + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index 50d8ac8c..c7a284c9 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -3,6 +3,21 @@ use crate::Host; use ide_core::{IdeResult, TerminalId}; use ide_terminal::{TerminalInfo, TerminalOptions}; +mod images; + +const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024; + +pub async fn store_image(host: &Host, id: TerminalId, data: Vec, mime: String) -> IdeResult { + if data.len() > MAX_IMAGE_BYTES { + return Err(ide_core::IdeError::invalid("image is too large to paste (limit 20 MB)")); + } + if !host.terminals.contains(id) { + return Err(ide_core::IdeError::invalid("terminal session not found")); + } + let data_dir = host.data_dir.clone(); + host.blocking(move || images::store(&data_dir, &data, &mime)).await +} + pub fn spawn( host: &Host, window: &str, @@ -47,3 +62,34 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> { pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult { host.terminals.is_busy(id) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::HostConfig; + use ide_core::{NullSink, error::ErrorKind}; + use std::sync::Arc; + + #[tokio::test] + async fn image_size_limit_is_checked_before_storage() { + let root = tempfile::tempdir().unwrap(); + let host = Host::new(HostConfig { + config_dir: root.path().join("config"), + data_dir: root.path().join("data"), + local_data_dir: root.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let id = TerminalId::next(); + let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err(); + assert_eq!(error.kind, ErrorKind::InvalidInput); + assert_eq!(error.message, "image is too large to paste (limit 20 MB)"); + let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err(); + assert_eq!(error.message, "terminal session not found"); + assert!(!host.data_dir.join("terminal-images").exists()); + } +} diff --git a/crates/ide-host/src/ops/term/images.rs b/crates/ide-host/src/ops/term/images.rs new file mode 100644 index 00000000..6a45926f --- /dev/null +++ b/crates/ide-host/src/ops/term/images.rs @@ -0,0 +1,220 @@ +use ide_core::{IdeError, IdeResult}; +use std::path::Path; + +#[cfg(unix)] +mod unix { + use super::*; + use rustix::fs::{self, AtFlags, Dir, Mode, OFlags}; + use std::fs::File; + use std::io::{self, Write}; + use std::os::unix::fs::MetadataExt; + use std::time::{Duration, SystemTime}; + + const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); + const DIRECTORY: &str = "terminal-images"; + + fn extension(mime: &str) -> IdeResult<&str> { + match mime { + "image/png" => Ok("png"), + "image/jpeg" => Ok("jpg"), + "image/gif" => Ok("gif"), + "image/webp" => Ok("webp"), + "image/bmp" => Ok("bmp"), + "image/svg+xml" => Ok("svg"), + "image/tiff" => Ok("tiff"), + "image/x-icon" => Ok("ico"), + _ => Err(IdeError::invalid("unsupported image type")), + } + } + + fn open_directory(data_dir: &Path) -> io::Result { + if !data_dir.is_absolute() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute")); + } + // The configured data directory and its ancestors are the host's trust boundary. + let parent = + fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?; + match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) { + Ok(()) | Err(rustix::io::Errno::EXIST) => (), + Err(e) => return Err(e.into()), + } + let dir: File = fs::openat( + &parent, + DIRECTORY, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )? + .into(); + let meta = dir.metadata()?; + if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "terminal image directory is not private or owned by the current user", + )); + } + Ok(dir) + } + + fn owned_name(name: &str) -> bool { + let name = name.strip_prefix(".partial-").unwrap_or(name); + let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false }; + id.len() == 32 + && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + && matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico") + } + + fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> { + for entry in Dir::read_from(dir)? { + let entry = entry?; + let name = entry.file_name(); + if !name.to_str().is_ok_and(owned_name) { + continue; + } + // Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO. + let fd = match fs::openat( + dir, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => fd, + Err(_) => continue, + }; + let file = File::from(fd); + let meta = file.metadata()?; + if meta.is_file() + && meta.uid() == rustix::process::geteuid().as_raw() + && meta + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= LIFETIME) + { + match fs::unlinkat(dir, name, AtFlags::empty()) { + Ok(()) | Err(rustix::io::Errno::NOENT) => (), + Err(error) => return Err(error.into()), + } + } + } + Ok(()) + } + + fn store_with( + data_dir: &Path, + data: &[u8], + mime: &str, + now: SystemTime, + write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>, + ) -> IdeResult { + let ext = extension(mime)?; + let dir = open_directory(data_dir)?; + cleanup(&dir, now)?; + let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext); + let temporary = format!(".partial-{name}"); + let fd = fs::openat( + &dir, + temporary.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_bits_truncate(0o600), + ) + .map_err(io::Error::from)?; + let mut file = File::from(fd); + if let Err(error) = write(&mut file, data) { + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + return Err(error.into()); + } + if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) { + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + return Err(io::Error::from(error).into()); + } + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned()) + } + + pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult { + store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes)) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::{PermissionsExt, symlink}; + + #[test] + fn stores_bytes_with_private_modes_and_unpredictable_names() { + let root = tempfile::tempdir().unwrap(); + let first = store(root.path(), b"image bytes", "image/png").unwrap(); + let second = store(root.path(), b"other", "image/jpeg").unwrap(); + assert_ne!(first, second); + assert_eq!(fs::read(&first).unwrap(), b"image bytes"); + assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700); + assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap())); + } + + #[test] + fn expiry_only_removes_owned_regular_files_at_boundary() { + let root = tempfile::tempdir().unwrap(); + let dir = open_directory(root.path()).unwrap(); + let path = root.path().join(DIRECTORY); + let now = SystemTime::now(); + let names = [ + "image-00000000000000000000000000000001.png", + "image-00000000000000000000000000000002.png", + "image-00000000000000000000000000000003.png", + "unrelated.png", + ]; + for (name, time) in names.iter().zip([ + now - LIFETIME, + now - LIFETIME + Duration::from_secs(1), + now + Duration::from_secs(1), + now - LIFETIME, + ]) { + let file = File::create(path.join(name)).unwrap(); + file.set_times(fs::FileTimes::new().set_modified(time)).unwrap(); + } + let link = path.join("image-00000000000000000000000000000004.png"); + symlink(path.join(names[0]), &link).unwrap(); + cleanup(&dir, now).unwrap(); + assert!(!path.join(names[0]).exists()); + for name in &names[1..] { + assert!(path.join(name).exists()); + } + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); + } + + #[test] + fn rejects_symlink_and_insecure_directory() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let target = root.path().join(DIRECTORY); + symlink(outside.path(), &target).unwrap(); + assert!(store(root.path(), b"x", "image/png").is_err()); + fs::remove_file(&target).unwrap(); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(store(root.path(), b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + } + + #[test] + fn failed_write_removes_partial_file() { + let root = tempfile::tempdir().unwrap(); + let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + Err(io::Error::other("simulated failure")) + }); + assert!(result.is_err()); + assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0); + } + } +} + +#[cfg(unix)] +pub(super) use unix::store; + +#[cfg(not(unix))] +pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult { + Err(IdeError::internal("private terminal image storage is unsupported on this platform")) +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 78605b1e..8b758d6b 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -231,6 +231,9 @@ impl Dispatcher { let on_output = c.channel("onOutput")?; ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output) }) + .add("term_store_image", |c| async move { + ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await + }) .add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::("data")?) }) .add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) }) .add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) }) diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs new file mode 100644 index 00000000..13605357 --- /dev/null +++ b/crates/ide-host/tests/terminal_images.rs @@ -0,0 +1,47 @@ +#![cfg(unix)] + +use ide_core::{NullSink, TerminalId}; +use ide_host::{Host, HostConfig, ops}; +use ide_terminal::TerminalOptions; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn storage_requires_a_registered_terminal() { + let root = tempfile::tempdir().unwrap(); + let data_dir = root.path().join("data"); + std::fs::create_dir(&data_dir).unwrap(); + let host = Host::new(HostConfig { + config_dir: root.path().join("config"), + data_dir: data_dir.clone(), + local_data_dir: data_dir.clone(), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err()); + assert!(!data_dir.join("terminal-images").exists()); + let terminal = host + .terminals + .spawn( + TerminalOptions { + cwd: root.path().into(), + shell: Some("/bin/sh".into()), + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + }, + Arc::new(NullSink), + Box::new(|_| {}), + ) + .unwrap(); + let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap(); + assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + host.terminals.kill(terminal.id).unwrap(); + assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err()); + assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1); +} diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 9caadf58..82df70cd 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -178,6 +178,10 @@ impl TerminalManager { let Some(pid) = session.info.pid else { return Ok(false) }; Ok(foreground_busy(&session, pid)) } + /// Whether a terminal session is still registered. + pub fn contains(&self, id: TerminalId) -> bool { + self.sessions.lock().contains_key(&id) + } pub fn list(&self) -> Vec { let mut out: Vec = self.sessions.lock().values().map(|s| s.info.clone()).collect(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ba282249..4457d138 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -298,6 +298,7 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_changelists_save, vcs::vcs_write_patch, term::term_spawn, + term::term_store_image, term::term_write, term::term_resize, term::term_kill, diff --git a/src-tauri/src/commands/term.rs b/src-tauri/src/commands/term.rs index 6f2e10ea..cf453707 100644 --- a/src-tauri/src/commands/term.rs +++ b/src-tauri/src/commands/term.rs @@ -24,6 +24,17 @@ pub async fn term_spawn( ) } +#[tauri::command] +#[specta::specta] +pub async fn term_store_image( + state: State<'_, AppState>, + id: TerminalId, + data: Vec, + mime: String, +) -> IdeResult { + ide_host::ops::term::store_image(&state.host, id, data, mime).await +} + #[tauri::command] #[specta::specta] pub fn term_write(state: State<'_, AppState>, id: TerminalId, data: String) -> IdeResult<()> { diff --git a/src/bindings.ts b/src/bindings.ts index f4603e36..b3ea339c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -551,6 +551,7 @@ export const commands = { * to start the shell takes tens of milliseconds, a frozen UI if run there. */ termSpawn: (options: TerminalOptions, onOutput: Channel) => typedError(__TAURI_INVOKE("term_spawn", { options, onOutput })), + termStoreImage: (id: TerminalId, data: number[], mime: string) => typedError(__TAURI_INVOKE("term_store_image", { id, data, mime })), termWrite: (id: TerminalId, data: string) => typedError(__TAURI_INVOKE("term_write", { id, data })), termResize: (id: TerminalId, cols: number, rows: number) => typedError(__TAURI_INVOKE("term_resize", { id, cols, rows })), termKill: (id: TerminalId) => typedError(__TAURI_INVOKE("term_kill", { id })), diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index f9868e1a..12d5b0ae 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -31,7 +31,6 @@ import { type PaneLayout, type TerminalTab, } from "./store"; -import { pasteTerminalClipboard } from "./paste"; import { TerminalFindBar } from "./FindBar"; import styles from "./Terminal.module.css"; @@ -103,7 +102,7 @@ function paneMenu(e: MouseEvent, key: string) { { label: "Paste", shortcut: mac ? "⌘V" : "Ctrl+Shift+V", - run: () => void pasteTerminalClipboard((text) => session.paste(text)), + run: () => void session.pasteClipboard(), }, { label: "Select All", run: () => session.term.selectAll() }, { kind: "separator" }, diff --git a/src/features/terminal/paste.test.ts b/src/features/terminal/paste.test.ts index 27dad6f5..726f1c8b 100644 --- a/src/features/terminal/paste.test.ts +++ b/src/features/terminal/paste.test.ts @@ -1,9 +1,16 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { Terminal } from "@xterm/xterm"; import { commands } from "@/platform/ipc"; -import { installTerminalPaste, pasteTerminalClipboard } from "./paste"; +import { installTerminalPaste, pasteTerminalClipboard, type RemotePasteTarget } from "./paste"; -beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +const notify = vi.hoisted(() => vi.fn()); +vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify }) })); + +beforeEach(() => { + vi.stubGlobal("__TAURI_INTERNALS__", {}); + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: [] }); + notify.mockClear(); +}); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -78,3 +85,280 @@ it("passes recovered multiline paths through xterm bracketed paste", async () => term.dispose(); } }); + +function image(buffer: () => Promise = async () => new Uint8Array([1, 2]).buffer, size = 2): Blob { + return { type: "image/png", size, arrayBuffer: buffer } as Blob; +} + +function imageEvent(blob: Blob, text = "") { + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => text, files: [blob], types: ["Files"] }, + }); + return event; +} + +function remoteTarget(current = () => true): RemotePasteTarget { + return { id: 42 as RemotePasteTarget["id"], supported: true, current }; +} + +it.each(["event", "menu"])("rejects oversized %s images before reading or uploading", async (route) => { + const read = vi.fn(async () => new ArrayBuffer(0)); + const blob = image(read, 20 * 1024 * 1024 + 1); + const store = vi.spyOn(commands, "termStoreImage"); + const paste = vi.fn(); + const host = document.createElement("div"); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + if (route === "event") { + const event = imageEvent(blob); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } else { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType: async () => blob }] }, + }); + await pasteTerminalClipboard(paste, () => remoteTarget()); + } + await vi.waitFor(() => + expect(notify).toHaveBeenCalledWith({ + level: "error", + title: "Cannot paste image", + body: "Image is too large to paste (limit 20 MB).", + }), + ); + expect(read).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + remove(); +}); + +it("allows images at the size limit", async () => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/remote/image.png" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + host.dispatchEvent(imageEvent(image(undefined, 20 * 1024 * 1024))); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("'/remote/image.png'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(notify).not.toHaveBeenCalled(); + remove(); +}); + +it("claims remote image events before xterm and pastes a quoted path only after upload", async () => { + let finish!: (result: { status: "ok"; data: string }) => void; + const store = vi.spyOn(commands, "termStoreImage").mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + host.addEventListener("paste", () => paste("xterm")); + const event = imageEvent(image()); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png")); + expect(paste).not.toHaveBeenCalled(); + finish({ status: "ok", data: "/tmp/a'b c.png" }); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("'/tmp/a'\\''b c.png'")); + remove(); +}); + +it("reads an image from the explicit paste menu after text and file recovery", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/local/stale.png"] }); + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/remote/image.png" }); + const getType = vi.fn(async () => image()); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType }] }, + }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => remoteTarget()); + expect(getType).toHaveBeenCalledWith("image/png"); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(paste).toHaveBeenCalledWith("'/remote/image.png'"); +}); + +it("recovers remote menu file paths when image clipboard reading is unavailable", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/local/file.txt"] }); + const store = vi.spyOn(commands, "termStoreImage"); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => remoteTarget()); + expect(paste).toHaveBeenCalledWith("/local/file.txt"); + expect(store).not.toHaveBeenCalled(); +}); + +it("uploads a menu image even if reading clipboard text is rejected", async () => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/remote/image.png" }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + readText: async () => { + throw new Error("no text"); + }, + read: async () => [{ types: ["image/png"], getType: async () => image() }], + }, + }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => remoteTarget()); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(paste).toHaveBeenCalledWith("'/remote/image.png'"); + expect(notify).not.toHaveBeenCalled(); +}); + +it("uploads image events instead of recovering a local image path", async () => { + const list = vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/local/photo.png"] }); + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/remote/photo.png" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + const event = imageEvent(image()); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("'/remote/photo.png'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(list).not.toHaveBeenCalled(); + remove(); +}); + +it("does not upload a menu image when the target changes while reading", async () => { + let valid = true; + let finish!: (value: Blob) => void; + const getType = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType }] }, + }); + const store = vi.spyOn(commands, "termStoreImage"); + const paste = vi.fn(); + const pending = pasteTerminalClipboard(paste, () => remoteTarget(() => valid)); + await vi.waitFor(() => expect(getType).toHaveBeenCalled()); + valid = false; + finish(image()); + await pending; + expect(store).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); +}); + +it.each(["read", "upload", "unsupported"])("reports image %s failure without pasting", async (failure) => { + const store = vi.spyOn(commands, "termStoreImage").mockRejectedValue(new Error("upload failed")); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => ({ ...remoteTarget(), supported: failure !== "unsupported" })); + host.dispatchEvent( + imageEvent( + image( + failure === "read" + ? async () => { + throw new Error("read failed"); + } + : undefined, + ), + ), + ); + await vi.waitFor(() => expect(notify).toHaveBeenCalled()); + expect(paste).not.toHaveBeenCalled(); + if (failure !== "upload") expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it.each(["read", "upload"])("ignores an image after its target is replaced during %s", async (stage) => { + let valid = true; + let finishRead!: (value: ArrayBuffer) => void; + let finishUpload!: (value: { status: "ok"; data: string }) => void; + const store = vi.spyOn(commands, "termStoreImage").mockImplementation( + () => + new Promise((resolve) => { + finishUpload = resolve; + }), + ); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget(() => valid)); + host.dispatchEvent( + imageEvent( + image( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ), + ), + ); + await vi.waitFor(() => expect(finishRead).toBeDefined()); + if (stage === "upload") { + finishRead(new Uint8Array([1]).buffer); + await vi.waitFor(() => expect(store).toHaveBeenCalled()); + } + valid = false; + if (stage === "read") finishRead(new Uint8Array([1]).buffer); + else finishUpload({ status: "ok", data: "/remote/image.png" }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(paste).not.toHaveBeenCalled(); + if (stage === "read") expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it("does not paste after the event handler is disposed during upload", async () => { + let finish!: (value: { status: "ok"; data: string }) => void; + const store = vi.spyOn(commands, "termStoreImage").mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + host.dispatchEvent(imageEvent(image())); + await vi.waitFor(() => expect(store).toHaveBeenCalled()); + remove(); + finish({ status: "ok", data: "/remote/image.png" }); + await Promise.resolve(); + await Promise.resolve(); + expect(paste).not.toHaveBeenCalled(); +}); + +it("keeps local image-only file recovery unchanged", async () => { + const list = vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/local/photo.png"] }); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste); + const event = imageEvent(image()); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("/local/photo.png")); + expect(list).toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it("leaves local image and remote text events to existing paste handlers", () => { + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + const event = imageEvent(image(), "plain"); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(store).not.toHaveBeenCalled(); + remove(); + const local = installTerminalPaste(host, paste); + const text = imageEvent(image(), "plain"); + host.dispatchEvent(text); + expect(text.defaultPrevented).toBe(false); + expect(store).not.toHaveBeenCalled(); + local(); +}); diff --git a/src/features/terminal/paste.ts b/src/features/terminal/paste.ts index 2ce06e9a..c0dd74e2 100644 --- a/src/features/terminal/paste.ts +++ b/src/features/terminal/paste.ts @@ -1,8 +1,50 @@ import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; +import { call, commands, type TerminalId } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { notifications } from "@/workbench/contributions"; -export function installTerminalPaste(host: HTMLElement, paste: (text: string) => void): () => void { +export interface RemotePasteTarget { + id: TerminalId; + supported: boolean; + current: () => boolean; +} + +type Target = () => RemotePasteTarget | null; + +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; + +async function pasteImage(image: Blob, target: RemotePasteTarget, paste: (text: string) => void) { + try { + if (image.size > MAX_IMAGE_BYTES) throw new Error(tx`Image is too large to paste (limit ${20} MB).`); + const data = Array.from(new Uint8Array(await image.arrayBuffer())); + if (!target.current()) return; + if (!target.supported) throw new Error(tx("Remote server does not support image paste.")); + const path = await call(commands.termStoreImage(target.id, data, image.type)); + if (target.current()) paste(`'${path.replaceAll("'", "'\\''")}'`); + } catch (error) { + if (target.current()) + notifications().notify({ + level: "error", + title: tx("Cannot paste image"), + body: error instanceof Error ? error.message : String(error), + }); + } +} + +export function installTerminalPaste(host: HTMLElement, paste: (text: string) => void, getTarget?: Target): () => void { let alive = true; const onPaste = (event: ClipboardEvent) => { + const transfer = event.clipboardData; + const target = getTarget?.(); + if (!event.defaultPrevented && target && transfer && !transfer.getData("text/plain")) { + const image = Array.from(transfer.files).find((file) => file.type.startsWith("image/")); + if (image) { + event.preventDefault(); + event.stopImmediatePropagation(); + void pasteImage(image, { ...target, current: () => alive && target.current() }, paste); + return; + } + } pasteClipboardFiles(event, (text) => { if (alive) paste(text); }); @@ -14,11 +56,48 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) => }; } -export async function pasteTerminalClipboard(paste: (text: string) => void) { +export async function pasteTerminalClipboard(paste: (text: string) => void, getTarget?: Target) { + const target = getTarget?.(); try { - const text = await readClipboardText(); - if (text) paste(text); - } catch { - /* Clipboard access may be denied. */ + // Native paste preserves clipboard blobs where Clipboard.read() is unavailable. + if (target && document.queryCommandSupported?.("paste") && document.execCommand("paste")) return; + const images = + target && navigator.clipboard?.read + ? navigator.clipboard.read().then( + (items) => ({ items }), + (error: unknown) => ({ error }), + ) + : null; + const text = target ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") : await readClipboardText(); + if (target && !target.current()) return; + if (text) { + paste(text); + return; + } + if (!target) return; + if (images) { + const result = await images; + if ("error" in result) throw result.error; + if (!target.current()) return; + for (const item of result.items) { + const mime = item.types.find((type) => type.startsWith("image/")); + if (mime) { + const image = await item.getType(mime); + if (target.current()) await pasteImage(image, target, paste); + return; + } + } + } + const paths = await readClipboardText(); + if (!target.current()) return; + if (paths) paste(paths); + else if (!images) throw new Error(tx("Clipboard image reading is unavailable.")); + } catch (error) { + if (target?.current()) + notifications().notify({ + level: "error", + title: tx("Cannot paste image"), + body: error instanceof Error ? error.message : String(error), + }); } } diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 7a393dd1..1f620980 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -17,6 +17,7 @@ import { ImeCommitGate } from "./imeCommit"; import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; import { useProject } from "@/platform/project"; +import { useRemote } from "@/platform/remote"; import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings"; import { editorPrefs } from "@/platform/editorSettings"; import { @@ -278,7 +279,7 @@ export class TerminalSession { }), ); this.disposables.push( - { dispose: installTerminalPaste(this.host, (text) => this.paste(text)) }, + { dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.remotePasteTarget) }, { dispose: installTerminalDrop(this.host, (text) => this.paste(text)) }, ); this.host.addEventListener("mousedown", this.onMiddleClick); @@ -287,9 +288,29 @@ export class TerminalSession { private onMiddleClick = (e: MouseEvent) => { if (e.button !== 1 || !workflowSettings().terminal.pasteOnMiddleClick) return; e.preventDefault(); - void pasteTerminalClipboard((text) => this.paste(text)); + void this.pasteClipboard(); }; + private remotePasteTarget = () => { + const remote = useRemote.getState().session; + const id = this.ptyId; + if (!remote || id === null || this.disposed) return null; + const generation = this.generation; + return { + id, + supported: remote.commands.includes("term_store_image"), + current: () => + !this.disposed && + this.ptyId === id && + this.generation === generation && + useRemote.getState().session === remote, + }; + }; + + pasteClipboard() { + if (this.remotePasteTarget()) this.focus(); + return pasteTerminalClipboard((text) => this.paste(text), this.remotePasteTarget); + } /** Applies Settings › Tools › Terminal changes to a live session. */ applySettings(t: TerminalSettingsValid) { const font = fontFor(t); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 43edfc7f..daeb595f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5490,5 +5490,9 @@ "Invalid .NET project path": ".NET 项目路径无效", ".NET launch profile '{profile_name}' is missing or is not a Project profile": ".NET 启动配置“{profile_name}”不存在或不是 Project 类型", ".NET output resolution timed out": ".NET 构建产物路径查询超时", - ".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build" + ".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build", + "Cannot paste image": "无法粘贴图片", + "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", + "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", + "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。" } From 4015a5b71ff428283680fcb712d8cba15bf98bba Mon Sep 17 00:00:00 2001 From: Jasmeet Singh Date: Sun, 1 Nov 2026 19:16:36 +0530 Subject: [PATCH 014/108] fix(maven): read Maven 4 as modules (#700) Maven 4 (model 4.1.0) declares child projects in / instead of /. The POM reader only read , so the children of a Maven 4 aggregator were missing. Projects and profiles now read both. Co-authored-by: jasi381 <60892009+jasi381@users.noreply.github.com> --- crates/ide-build/src/maven.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 7f256100..f9b926a9 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -285,6 +285,14 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec { e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect() } +/// Child modules of a project or profile: `` (model 4.0.0) plus ``, +/// which Maven 4 (model 4.1.0) uses instead. +fn module_names(e: &Element) -> Vec { + let mut names = texts(e, "modules", "module"); + names.extend(texts(e, "subprojects", "subproject")); + names +} + fn read_build(e: &Element, plugins: &mut Vec, managed: &mut Vec) { plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin)); managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin)); @@ -308,7 +316,7 @@ fn read_raw(project: &Element) -> RawPom { name: project.text_of("name"), parent, properties: read_properties(project.child("properties")), - modules: texts(project, "modules", "module"), + modules: module_names(project), deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(), managed: project .list("dependencyManagement/dependencies", "dependency") @@ -329,7 +337,7 @@ fn read_raw(project: &Element) -> RawPom { let mut profile = RawProfile { id: p.text_of("id").unwrap_or_else(|| "default".into()), active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"), - modules: texts(p, "modules", "module"), + modules: module_names(p), properties: read_properties(p.child("properties")), deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(), ..RawProfile::default() @@ -1276,6 +1284,26 @@ mod tests { "#; + #[test] + fn maven_4_subprojects_are_modules() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "pom.xml", + r#"4.1.0 + demoparent1.0pom + core + extraextra + "#, + ); + write(root, "core/pom.xml", "core"); + write(root, "extra/pom.xml", "extra"); + let model = load_with_repo(root, None); + assert_eq!(model.projects[0].modules, ["core/pom.xml"]); + assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated)); + } + #[test] fn multi_module_parent_properties_and_managed_versions() { let tmp = tempfile::tempdir().unwrap(); From e7f00ee7ce0487dbf35de719d905fa399685c43b Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 09:59:54 +0800 Subject: [PATCH 015/108] fix(tools): pick the platform archive for kotlin-lsp (#726) kotlin-lsp releases renamed their archives to kotlin-server-*, so no rule matched and the loose kotlin-*.zip fallback picked the Windows zip on every OS. Match the current names per platform: .sit (a zip archive) on macOS, .tar.gz on Linux, .win.zip on Windows; extract .sit as zip; drop the loose fallback. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-tools/src/archive.rs | 4 +- crates/ide-tools/src/registry.rs | 65 ++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/ide-tools/src/archive.rs b/crates/ide-tools/src/archive.rs index 5318c5f7..5afb3557 100644 --- a/crates/ide-tools/src/archive.rs +++ b/crates/ide-tools/src/archive.rs @@ -21,7 +21,8 @@ pub enum ArchiveKind { impl ArchiveKind { pub fn from_name(name: &str) -> Self { let n = name.to_ascii_lowercase(); - if n.ends_with(".zip") || n.ends_with(".vsix") { + // JetBrains' macOS .sit distributions are zip archives. + if n.ends_with(".zip") || n.ends_with(".vsix") || n.ends_with(".sit") { Self::Zip } else if n.ends_with(".tar.gz") || n.ends_with(".tgz") { Self::TarGz @@ -393,6 +394,7 @@ mod tests { fn kinds() { assert_eq!(ArchiveKind::from_name("a.vsix"), ArchiveKind::Zip); assert_eq!(ArchiveKind::from_name("a.ZIP"), ArchiveKind::Zip); + assert_eq!(ArchiveKind::from_name("kotlin-server-263.7084.0-aarch64.sit"), ArchiveKind::Zip); assert_eq!(ArchiveKind::from_name("a.tar.gz"), ArchiveKind::TarGz); assert_eq!(ArchiveKind::from_name("a.tgz"), ArchiveKind::TarGz); assert_eq!(ArchiveKind::from_name("zls.tar.xz"), ArchiveKind::TarXz); diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 5c05cbe7..6dfcd5df 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -614,15 +614,25 @@ pub static TOOLS: &[ToolSpec] = &[ method: Method::Release { source: ReleaseSource::GitHub { repo: "Kotlin/kotlin-lsp" }, assets: &[ + // Current releases (kotlin-server-*): the CLI distribution is a + // .sit on macOS, .tar.gz on Linux and .win.zip on Windows. x64 ships as the + // un-suffixed archive, aarch64 as `-aarch64`; the un-suffixed one + // comes first in the release notes, which is what the x64 pattern + // matches first. Without a matching rule the installer used to fall + // through to a loose `kotlin-*.zip` pattern and POSIX picked the + // Windows archive (#723). + rule(Mac, Arm64, "kotlin-server-*-aarch64.sit"), + rule(Mac, X64, "kotlin-server-*.sit"), + rule(Linux, Arm64, "kotlin-server-*-aarch64.tar.gz"), + rule(Linux, X64, "kotlin-server-*.tar.gz"), + rule(Windows, X64, "kotlin-server-*.win.zip"), + rule(Windows, Arm64, "kotlin-server-*-aarch64.win.zip"), + // Old releases named the archives kotlin-lsp-*. rule(Mac, Arm64, "kotlin-lsp-*-mac-aarch64.zip"), rule(Mac, X64, "kotlin-lsp-*-mac-x64.zip"), rule(Linux, X64, "kotlin-lsp-*-linux-x64.zip"), rule(Linux, Arm64, "kotlin-lsp-*-linux-aarch64.zip"), rule(Windows, X64, "kotlin-lsp-*-win-x64.zip"), - // Older releases ship one platform-independent archive. - any_arch(Mac, "kotlin-*.zip"), - any_arch(Linux, "kotlin-*.zip"), - any_arch(Windows, "kotlin-*.zip"), ], locate: &["kotlin-lsp", "kotlin-lsp.sh", "kotlin-lsp.cmd"], }, @@ -988,4 +998,51 @@ mod tests { } } } + + /// The v263 kotlin-server-* release notes: the CLI distribution archives plus + /// the VS Code extension and checksums; each platform must pick its native + /// distribution and the aarch64 archive must never be picked on x64. + #[test] + fn kotlin_lsp_picks_the_native_archive_for_each_platform() { + use crate::platform::{Platform, select_asset}; + let names = [ + "kotlin-server-0.0.27-mac-amd64.vsix", + "kotlin-server-0.0.27-mac-amd64.vsix.sha256", + "kotlin-server-0.0.27-mac-aarch64.vsix", + "kotlin-server-0.0.27-mac-aarch64.vsix.sha256", + "kotlin-server-0.0.27-linux-amd64.vsix", + "kotlin-server-0.0.27-linux-amd64.vsix.sha256", + "kotlin-server-0.0.27-linux-aarch64.vsix", + "kotlin-server-0.0.27-linux-aarch64.vsix.sha256", + "kotlin-server-0.0.27-win-amd64.vsix", + "kotlin-server-0.0.27-win-amd64.vsix.sha256", + "kotlin-server-0.0.27-win-aarch64.vsix", + "kotlin-server-0.0.27-win-aarch64.vsix.sha256", + "kotlin-server-263.7084.0.sit", + "kotlin-server-263.7084.0.sit.sha256", + "kotlin-server-263.7084.0-aarch64.sit", + "kotlin-server-263.7084.0-aarch64.sit.sha256", + "kotlin-server-263.7084.0.tar.gz", + "kotlin-server-263.7084.0.tar.gz.sha256", + "kotlin-server-263.7084.0-aarch64.tar.gz", + "kotlin-server-263.7084.0-aarch64.tar.gz.sha256", + "kotlin-server-263.7084.0.win.zip", + "kotlin-server-263.7084.0.win.zip.sha256", + "kotlin-server-263.7084.0-aarch64.win.zip", + "kotlin-server-263.7084.0-aarch64.win.zip.sha256", + ]; + let rules = find("kotlin-lsp").unwrap().asset_rules(); + let mac_arm = Platform { os: Mac, arch: Arm64 }; + assert_eq!(select_asset(&names, rules, mac_arm), Some("kotlin-server-263.7084.0-aarch64.sit")); + let mac_x64 = Platform { os: Mac, arch: X64 }; + assert_eq!(select_asset(&names, rules, mac_x64), Some("kotlin-server-263.7084.0.sit")); + let linux_x64 = Platform { os: Linux, arch: X64 }; + assert_eq!(select_asset(&names, rules, linux_x64), Some("kotlin-server-263.7084.0.tar.gz")); + let linux_arm = Platform { os: Linux, arch: Arm64 }; + assert_eq!(select_asset(&names, rules, linux_arm), Some("kotlin-server-263.7084.0-aarch64.tar.gz")); + let win_x64 = Platform { os: Windows, arch: X64 }; + assert_eq!(select_asset(&names, rules, win_x64), Some("kotlin-server-263.7084.0.win.zip")); + let win_arm = Platform { os: Windows, arch: Arm64 }; + assert_eq!(select_asset(&names, rules, win_arm), Some("kotlin-server-263.7084.0-aarch64.win.zip")); + } } From 26530213894552f4f493d477d9bb0c141797c879 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 10:00:32 +0800 Subject: [PATCH 016/108] fix(java): pass the IDE proxy to language-server JVMs (#745) A JVM reads none of HTTP_PROXY/HTTPS_PROXY, so jdtls went direct and a repository that accepts the connection but never answers stalled the Maven import. ProxySettings::jvm_args turns the proxy configuration into JVM system properties (useSystemProxies for Auto, http/https/nonProxyHosts for a manual HTTP proxy, socks* for SOCKS), passed to jdtls as --jvm-arg; a property the user set themselves (matched by exact name) wins. Maven sync keeps Maven's normal snapshot update policy. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- crates/ide-core/src/proxy_settings.rs | 80 +++++++++++++++++++++++++++ crates/ide-sdk/src/java.rs | 46 +++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/crates/ide-core/src/proxy_settings.rs b/crates/ide-core/src/proxy_settings.rs index 564c2df9..ebae548d 100644 --- a/crates/ide-core/src/proxy_settings.rs +++ b/crates/ide-core/src/proxy_settings.rs @@ -207,6 +207,58 @@ impl ProxySettings { env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy)))); env } + + /// System properties that make a JVM follow this configuration. A JVM reads none of + /// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own + /// HTTP client) goes direct without these, and a repository its proxy answers in + /// milliseconds can then hang it instead. + pub fn jvm_args(&self) -> Vec { + match self.mode { + // macOS and Windows JVMs read the system proxy with this one. + ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()], + ProxyMode::None => Vec::new(), + ProxyMode::Manual if self.manual() => self.manual_jvm_args(), + ProxyMode::Manual => Vec::new(), + } + } + + fn manual_jvm_args(&self) -> Vec { + let (host, port) = (self.host.trim(), self.port); + match self.kind { + ProxyKind::Http => { + // HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`. + let mut args = vec![ + format!("-Dhttp.proxyHost={host}"), + format!("-Dhttp.proxyPort={port}"), + format!("-Dhttps.proxyHost={host}"), + format!("-Dhttps.proxyPort={port}"), + ]; + let hosts = self.non_proxy_hosts(); + if !hosts.is_empty() { + args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|"))); + } + args + } + // SOCKS proxies every TCP connection, so there is one property pair. + ProxyKind::Socks => vec![ + format!("-DsocksProxyHost={host}"), + format!("-DsocksProxyPort={port}"), + "-Djava.net.useSystemProxies=false".into(), + ], + } + } + + /// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host + /// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped. + fn non_proxy_hosts(&self) -> Vec { + let mut hosts: Vec = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect(); + for entry in self.no_proxy_entries() { + if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) { + hosts.push(entry); + } + } + hosts + } } /// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters. @@ -292,6 +344,34 @@ mod tests { assert!(p.child_env().is_empty()); } + #[test] + fn jvm_args_carry_the_proxy_as_system_properties() { + // A JVM reads none of `child_env`: a Java language server needs these to not go + // direct while curl, git and Node follow the environment. + assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]); + assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty()); + assert_eq!( + manual("proxy.corp", 3128).jvm_args(), + [ + "-Dhttp.proxyHost=proxy.corp", + "-Dhttp.proxyPort=3128", + "-Dhttps.proxyHost=proxy.corp", + "-Dhttps.proxyPort=3128", + "-Dhttp.nonProxyHosts=localhost|127.0.0.1", + ] + ); + let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) }; + assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]); + // `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out. + let p = ProxySettings { + no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(), + ..manual("proxy.corp", 3128) + }; + assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com"); + // A manual proxy without a host connects directly, and so does its JVM. + assert!(manual(" ", 80).jvm_args().is_empty()); + } + #[test] fn manual_http_and_socks_urls() { let p = manual("proxy.corp", 3128); diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 9d104583..21b217dc 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -313,6 +313,19 @@ fn text_key(text: &str) -> String { format!("{h:016x}") } +fn add_jdtls_proxy_args(args: &mut Vec, proxy: &ide_core::proxy_settings::ProxySettings) { + for arg in proxy.jvm_args() { + let key = arg.split('=').next().unwrap_or(arg.as_str()); + if !args.iter().enumerate().any(|(i, a)| { + let value = + a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); + value.is_some_and(|a| a.split('=').next() == Some(key)) + }) { + args.push(format!("--jvm-arg={arg}")); + } + } +} + const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot"; /// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data @@ -367,6 +380,10 @@ pub fn configure_jdtls( cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy())); } crate::jdtls::add_default_vm_args(&mut cfg.args); + // jdtls resolves Maven dependencies through its own HTTP client, and a JVM reads none + // of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system + // properties. One the user set in their own JVM arguments wins. + add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy()); let managed = language_server_runtimes_dir(data_dir); let managed = dunce::canonicalize(&managed).unwrap_or(managed); let project_jdks: Vec<_> = project_jdk @@ -705,6 +722,35 @@ mod tests { assert!(other.args.is_empty() && other.initialization_options.is_none()); } + #[test] + fn jdtls_proxy_properties_respect_exact_user_overrides() { + use ide_core::proxy_settings::{ProxyMode, ProxySettings}; + let proxy = + ProxySettings { mode: ProxyMode::Manual, host: "ide.proxy".into(), port: 3128, ..Default::default() }; + for (user_arg, overridden) in [ + ("--jvm-arg=-Dhttp.proxyHost=user.proxy", true), + ("--jvm-arg=-Dhttp.proxyHost", true), + ("--jvm-arg=-Dhttp.proxyHostSuffix=unrelated", false), + ("--jvm-arg=-Dother=-Dhttp.proxyHost", false), + ("/workspace/-Dhttp.proxyHost", false), + ] { + let mut forms = vec![vec![user_arg.to_owned()]]; + if let Some(value) = user_arg.strip_prefix("--jvm-arg=") { + forms.push(vec!["--jvm-arg".into(), value.into()]); + } + for mut args in forms { + let user_args = args.clone(); + add_jdtls_proxy_args(&mut args, &proxy); + assert_eq!(args[..user_args.len()], user_args); + assert_eq!(args.contains(&"--jvm-arg=-Dhttp.proxyHost=ide.proxy".into()), !overridden, "{user_arg}"); + assert!(args.contains(&"--jvm-arg=-Dhttps.proxyHost=ide.proxy".into())); + let before = args.clone(); + add_jdtls_proxy_args(&mut args, &proxy); + assert_eq!(args, before, "idempotent: {user_arg}"); + } + } + } + #[test] fn jdtls_lombok_agent() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; From fb55847afa0c54de4854b4c8a558447adfb2d9a4 Mon Sep 17 00:00:00 2001 From: Bl0ck <36800583+Bl0ck154@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:01:04 +0100 Subject: [PATCH 017/108] feat(editor): recognize AsciiDoc files with basic highlighting (#731) Register .adoc/.asciidoc as AsciiDoc in the language catalog (backend registry and in-memory backend) with // line comments, and add a lightweight CodeMirror stream mode for headings, attributes, delimited blocks, emphasis, inline code, URLs and comments. Refs #708. Co-authored-by: Bl0ck154 <36800583+Bl0ck154@users.noreply.github.com> --- crates/ide-language/src/registry.rs | 8 +++++ languages/asciidoc.toml | 6 ++++ src/editor/core/backend.ts | 11 ++++++ src/editor/core/cm/asciidoc.ts | 50 ++++++++++++++++++++++++++++ src/editor/core/cm/languages.test.ts | 48 ++++++++++++++++++++++++++ src/editor/core/cm/languages.ts | 7 +++- 6 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 languages/asciidoc.toml create mode 100644 src/editor/core/cm/asciidoc.ts diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 31a9eda2..908d3ca5 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -8,6 +8,7 @@ use std::sync::Arc; /// Built-in definitions, embedded at compile time. Add new languages here. const BUILTIN: &[(&str, &str)] = &[ + ("asciidoc", include_str!("../../../languages/asciidoc.toml")), ("bat", include_str!("../../../languages/bat.toml")), ("c", include_str!("../../../languages/c.toml")), ("clojure", include_str!("../../../languages/clojure.toml")), @@ -373,6 +374,7 @@ mod tests { /// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts` /// (the UI resolves `ui-mode` against these). const UI_MODES: &[&str] = &[ + "AsciiDoc", "Batch", "C", "C++", @@ -532,6 +534,12 @@ mod tests { let reg = reg(); assert_eq!(detect(®, "src/main.rs"), "rust"); assert_eq!(detect(®, "a/b.TS"), "typescript"); + for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] { + assert_eq!(detect(®, path), "asciidoc"); + } + let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap(); + assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc")); + assert_eq!(asciidoc.line_comment.as_deref(), Some("//")); assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python"); assert_eq!(detect(®, "README"), "plaintext"); assert_eq!(detect(®, "unknown.zzz"), "plaintext"); diff --git a/languages/asciidoc.toml b/languages/asciidoc.toml new file mode 100644 index 00000000..e798cbbd --- /dev/null +++ b/languages/asciidoc.toml @@ -0,0 +1,6 @@ +id = "asciidoc" +name = "AsciiDoc" +extensions = ["adoc", "asciidoc"] +line-comment = "//" +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "AsciiDoc" diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 8e4b020c..55dfc59a 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -119,6 +119,8 @@ const EXT_LANG: Record = { go: "go", json: "json", md: "markdown", + adoc: "asciidoc", + asciidoc: "asciidoc", css: "css", html: "html", yml: "yaml", @@ -218,6 +220,15 @@ const MEM_LANGUAGES: LanguageInfo[] = [ blockComment: null, indent: { tabSize: 2, insertSpaces: true }, }, + { + id: "asciidoc", + name: "AsciiDoc", + extensions: ["adoc", "asciidoc"], + uiMode: "AsciiDoc", + lineComment: "//", + blockComment: null, + indent: { tabSize: 2, insertSpaces: true }, + }, { id: "markdown", name: "Markdown", diff --git a/src/editor/core/cm/asciidoc.ts b/src/editor/core/cm/asciidoc.ts new file mode 100644 index 00000000..b24a5f3a --- /dev/null +++ b/src/editor/core/cm/asciidoc.ts @@ -0,0 +1,50 @@ +import { StreamLanguage, type StreamParser } from "@codemirror/language"; + +type State = { block: string | null }; + +const parser: StreamParser = { + startState: () => ({ block: null }), + copyState: ({ block }) => ({ block }), + token(stream, state) { + if (stream.sol()) { + const line = stream.string; + const delimiter = line.trim(); + if (state.block) { + if (delimiter === state.block) { + state.block = null; + stream.skipToEnd(); + return "meta"; + } + stream.skipToEnd(); + return "monospace"; + } + if (/^={1,6}\s+\S/.test(line)) { + stream.skipToEnd(); + return "heading"; + } + if (/^:[\w-]+:/.test(line)) { + stream.skipToEnd(); + return "attributeName"; + } + if (["----", "....", "____", "++++"].includes(delimiter)) { + state.block = delimiter; + stream.skipToEnd(); + return "meta"; + } + if (/^\s*\/\//.test(line)) { + stream.skipToEnd(); + return "comment"; + } + } + if (stream.eatSpace()) return null; + if (stream.match(/^https?:\/\/[^\s[\]<>]+/)) return "link"; + if (stream.match(/^\*[^*\n]+\*/)) return "strong"; + if (stream.match(/^_[^_\n]+_/)) return "emphasis"; + if (stream.match(/^\x60[^\x60\n]+\x60/)) return "monospace"; + stream.next(); + return null; + }, + languageData: { commentTokens: { line: "//" } }, +}; + +export const asciidocLanguage = StreamLanguage.define(parser); diff --git a/src/editor/core/cm/languages.test.ts b/src/editor/core/cm/languages.test.ts index 26101398..72d674ea 100644 --- a/src/editor/core/cm/languages.test.ts +++ b/src/editor/core/cm/languages.test.ts @@ -3,6 +3,54 @@ import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; import { findLanguageDescription, loadSupport } from "./languages"; describe("language modes", () => { + it("recognizes AsciiDoc files and loads their lightweight syntax mode", async () => { + for (const path of ["/docs/guide.adoc", "/docs/guide.asciidoc"]) { + const description = findLanguageDescription("AsciiDoc", path); + expect(description?.name).toBe("AsciiDoc"); + const support = await loadSupport(description!); + expect(support?.language).toBeTruthy(); + } + }); + + it("assigns highlight tags to AsciiDoc syntax", async () => { + const support = await loadSupport(findLanguageDescription("AsciiDoc", "/docs/guide.adoc")!); + const doc = [ + "= Guide", + ":toc: left", + "----", + "const example = true;", + "----", + "*bold* _italic_ `code` https://example.com", + "// Comment", + ].join("\n"); + const highlighter = tagHighlighter([ + { tag: t.heading, class: "heading" }, + { tag: t.attributeName, class: "attribute" }, + { tag: t.meta, class: "meta" }, + { tag: t.monospace, class: "monospace" }, + { tag: t.strong, class: "strong" }, + { tag: t.emphasis, class: "emphasis" }, + { tag: t.link, class: "link" }, + { tag: t.comment, class: "comment" }, + ]); + const highlighted: [string, string][] = []; + highlightTree(support!.language.parser.parse(doc), highlighter, (from, to, style) => { + highlighted.push([doc.slice(from, to), style]); + }); + expect(highlighted).toEqual([ + ["= Guide", "heading"], + [":toc: left", "attribute"], + ["----", "meta"], + ["const example = true;", "monospace"], + ["----", "meta"], + ["*bold*", "strong"], + ["_italic_", "emphasis"], + ["`code`", "monospace"], + ["https://example.com", "link"], + ["// Comment", "comment"], + ]); + }); + it("styles `?.` like `.` in JavaScript and TypeScript", async () => { const highlighter = tagHighlighter([{ tag: t.derefOperator, class: "deref" }]); for (const [mode, path] of [ diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index bcd1d9bd..9be9fbe3 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -68,6 +68,11 @@ const OWN_MODES = [ filename: /^(?:CMakeLists\.txt|.*\.cmake(?:\.in)?)$/, load: async () => new LanguageSupport((await import("./cmake")).cmakeLanguage), }), + LanguageDescription.of({ + name: "AsciiDoc", + extensions: ["adoc", "asciidoc"], + load: () => import("./asciidoc").then((m) => new LanguageSupport(m.asciidocLanguage)), + }), LanguageDescription.of({ name: "Shell", load: async () => { @@ -245,7 +250,7 @@ const DEDENT_STATEMENT = /^\s*(?:pass|break|continue|return)\b/; const KEYWORD_OPEN_LANGS = new Set(["elixir", "ruby", "lua"]); const KEYWORD_OPEN = /\b(?:do(?:\s*\|[^|]*\|)?|then|->)$|^\s*(?:def|defp|defmodule|class|module|function|local\s+function|if|unless|elsif|elseif|else|while|until|for|case|begin|cond|with|receive|try)\b/; -const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "log", "diff", "ignore", "gitattributes"]); +const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "asciidoc", "log", "diff", "ignore", "gitattributes"]); function opensBlock(code: string, language: string | undefined): boolean { if (!code) return false; From c5ff0de1eeebdb7c7ea27852dcf7801c0a02e804 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 10:03:59 +0800 Subject: [PATCH 018/108] fix(terminal): open the file a link names, not a same-named one (#711) resolvePath only understood POSIX paths, so on Windows the direct lookup never matched and every click fell back to a project-wide search by bare file name, which opened any same-named file. Resolve Windows drive paths and backslashes, and make the fallback query include the link's directory and check the hit's project-relative path ends with it; a bare-name match is used only for links without a directory. Fixes #705. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/terminal/links.ts | 14 ++--- src/features/terminal/session.test.ts | 74 ++++++++++++++++++++++++++ src/features/terminal/session.ts | 27 ++++++---- src/features/terminal/terminal.test.ts | 8 +++ 4 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 src/features/terminal/session.test.ts diff --git a/src/features/terminal/links.ts b/src/features/terminal/links.ts index ca2a4acd..51c97189 100644 --- a/src/features/terminal/links.ts +++ b/src/features/terminal/links.ts @@ -60,14 +60,14 @@ export function findTerminalLinks(text: string): TerminalLink[] { return links.sort((a, b) => a.start - b.start); } -/** Resolves `path` against `cwd` (POSIX); `~` expands with `home` when known. */ +/** Resolves `path` against `cwd` (POSIX, or Windows drive paths); `~` expands with `home` when known. */ export function resolvePath(path: string, cwd: string, home?: string | null): string { - let p = path; + let p = path.replace(/\\/g, "/"); if (p === "~" || p.startsWith("~/")) { if (!home) return p; - p = home + p.slice(1); - } else if (!p.startsWith("/")) { - p = `${cwd.replace(/\/+$/, "")}/${p}`; + p = home.replace(/\\/g, "/") + p.slice(1); + } else if (!p.startsWith("/") && !/^[A-Za-z]:\//.test(p)) { + p = `${cwd.replace(/\\/g, "/").replace(/\/+$/, "")}/${p}`; } const parts: string[] = []; for (const seg of p.split("/")) { @@ -75,5 +75,7 @@ export function resolvePath(path: string, cwd: string, home?: string | null): st if (seg === "..") parts.pop(); else parts.push(seg); } - return "/" + parts.join("/"); + const joined = parts.join("/"); + // A Windows drive prefix is its own root; POSIX results keep the leading slash. + return /^[A-Za-z]:\//.test(joined) ? joined : `/${joined}`; } diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts new file mode 100644 index 00000000..10134ade --- /dev/null +++ b/src/features/terminal/session.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { findTerminalLinks, type TerminalLink } from "./links"; +import { openFileLink } from "./session"; + +const ipc = vi.hoisted(() => ({ + call: vi.fn(async (v: unknown) => v), + commands: { fsReadDir: vi.fn(), findFiles: vi.fn() }, +})); +const workbench = vi.hoisted(() => ({ + openFile: vi.fn(async () => undefined), + notify: vi.fn(), +})); + +vi.mock("@/platform/ipc", () => ({ call: ipc.call, commands: ipc.commands, isTauri: () => true })); +vi.mock("@/workbench/contributions", () => ({ + editorService: () => ({ openFile: workbench.openFile }), + notifications: () => ({ notify: workbench.notify }), + OWNS_FILE_DROP: { "data-owner": true }, +})); + +const fileLink = (text: string): Extract => { + const [l] = findTerminalLinks(text); + if (!l || l.kind !== "file") throw new Error(`not a file link: ${text}`); + return l; +}; + +describe("openFileLink", () => { + beforeEach(() => vi.clearAllMocks()); + + it("opens the resolved path when the directory lists it", async () => { + ipc.commands.fsReadDir.mockResolvedValue([{ name: "main.php", kind: "file" }]); + await openFileLink(fileLink("a/main.php:2"), "C:\\Users\\me\\proj"); + expect(ipc.commands.fsReadDir).toHaveBeenCalledWith("C:/Users/me/proj/a"); + expect(workbench.openFile).toHaveBeenCalledWith("C:/Users/me/proj/a/main.php", { + selection: { line: 1, col: 0 }, + focus: true, + }); + expect(ipc.commands.findFiles).not.toHaveBeenCalled(); + }); + + it("ranks the link's directory over the bare name in the project-wide fallback (#705)", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockImplementation(async (q: string) => + q === "a/main.php" + ? [ + { path: "/p/b/main.php", relPath: "b/main.php" }, + { path: "/p/a/main.php", relPath: "a/main.php" }, + ] + : [{ path: "/p/c/main.php", relPath: "c/main.php" }], + ); + await openFileLink(fileLink("a/main.php:2"), "/p"); + expect(ipc.commands.findFiles).toHaveBeenCalledWith("a/main.php", 5); + expect(workbench.openFile).toHaveBeenCalledWith("/p/a/main.php", { + selection: { line: 1, col: 0 }, + focus: true, + }); + }); + + it("falls back to the bare name for a basename-only link", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockResolvedValue([{ path: "/p/x/main.php", relPath: "x/main.php" }]); + await openFileLink(fileLink("main.php:1"), "/p"); + expect(ipc.commands.findFiles).toHaveBeenCalledTimes(1); + expect(workbench.openFile).toHaveBeenCalledWith("/p/x/main.php", { selection: { line: 0, col: 0 }, focus: true }); + }); + + it("warns when nothing matches", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockResolvedValue([]); + await openFileLink(fileLink("gone.php:1"), "/p"); + expect(workbench.openFile).not.toHaveBeenCalled(); + expect(workbench.notify).toHaveBeenCalledWith({ level: "warning", title: "File not found", body: "gone.php" }); + }); +}); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 1f620980..da94bad3 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -137,25 +137,32 @@ export const LINK_HANDLER: ILinkHandler = { export async function openFileLink(link: Extract, cwd: string) { const selection = link.line ? { line: link.line - 1, col: Math.max(0, (link.col ?? 1) - 1) } : undefined; const path = resolvePath(link.path, cwd); - const name = link.path.split("/").pop() ?? link.path; + const rel = link.path.replace(/\\/g, "/"); + const name = rel.split("/").pop() ?? rel; try { const slash = path.lastIndexOf("/"); - const entries = await call(commands.fsReadDir(path.slice(0, slash) || "/")); + const entries = await call(commands.fsReadDir(slash > 0 ? path.slice(0, slash) : "/")); if (entries.some((e) => e.name === name && e.kind !== "dir")) { await editorService().openFile(path, { selection, focus: true }); return; } } catch { - /* fall back to a project-wide lookup by file name */ + /* fall back to a project-wide lookup */ } - try { - const [hit] = await call(commands.findFiles(name, 1)); - if (hit && hit.path.endsWith(name)) { - await editorService().openFile(hit.path, { selection, focus: true }); - return; + // The basename alone is ambiguous (`a/x.ts` vs `b/x.ts`): rank the link's + // directory part too and verify the hit actually sits on that path, settling + // for a basename match only when the link names no directory. + for (const q of rel === name ? [name] : [rel, name]) { + try { + const hits = await call(commands.findFiles(q, 5)); + const hit = hits.find((h) => h.relPath === q || h.relPath.endsWith(`/${q}`)); + if (hit) { + await editorService().openFile(hit.path, { selection, focus: true }); + return; + } + } catch { + /* ignore */ } - } catch { - /* ignore */ } notifications().notify({ level: "warning", title: "File not found", body: link.path }); } diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 433749ce..548cb5ed 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -96,6 +96,14 @@ describe("terminal links", () => { expect(resolvePath("/abs/c.ts", "/p")).toBe("/abs/c.ts"); expect(resolvePath("~/d.ts", "/p", "/Users/me")).toBe("/Users/me/d.ts"); }); + + it("resolves paths against a Windows cwd (#705)", () => { + expect(resolvePath("a/main.php", "C:\\Users\\me\\proj")).toBe("C:/Users/me/proj/a/main.php"); + expect(resolvePath("a\\main.php", "C:/Users/me/proj")).toBe("C:/Users/me/proj/a/main.php"); + expect(resolvePath("C:\\abs\\d.ts", "C:/p")).toBe("C:/abs/d.ts"); + expect(resolvePath("~/e.ts", "C:/p", "C:\\Users\\me")).toBe("C:/Users/me/e.ts"); + expect(resolvePath("../f.ts", "C:/p/web")).toBe("C:/p/f.ts"); + }); }); describe("terminal keys", () => { From c8cc69a56ef251bb41170f7b9ae747e86a4145ba Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 10:04:03 +0800 Subject: [PATCH 019/108] feat(language): add D language support (#728) Register D (.d/.di) with comments, indentation, the built-in D highlighting mode, a file icon, and serve-d as its language server (dub.json/dub.sdl roots). Closes #727. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-language/src/registry.rs | 1 + languages/d.toml | 13 +++++++++++++ src/ui/icons.tsx | 2 ++ src/ui/openidea/fileTypes/d.svg | 3 +++ src/ui/openidea/fileTypes/d_dark.svg | 3 +++ 5 files changed, 22 insertions(+) create mode 100644 languages/d.toml create mode 100644 src/ui/openidea/fileTypes/d.svg create mode 100644 src/ui/openidea/fileTypes/d_dark.svg diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 908d3ca5..5ceaec74 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -16,6 +16,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("cpp", include_str!("../../../languages/cpp.toml")), ("csharp", include_str!("../../../languages/csharp.toml")), ("css", include_str!("../../../languages/css.toml")), + ("d", include_str!("../../../languages/d.toml")), ("dart", include_str!("../../../languages/dart.toml")), ("diff", include_str!("../../../languages/diff.toml")), ("dockerfile", include_str!("../../../languages/dockerfile.toml")), diff --git a/languages/d.toml b/languages/d.toml new file mode 100644 index 00000000..2fcdc2cf --- /dev/null +++ b/languages/d.toml @@ -0,0 +1,13 @@ +id = "d" +name = "D" +extensions = ["d", "di"] +line-comment = "//" +block-comment = ["/*", "*/"] +indent = { tab-size = 4, insert-spaces = true } +ui-mode = "D" + +[[language-servers]] +name = "serve-d" +command = "serve-d" +root-markers = ["dub.json", "dub.sdl", "dub.selections.json"] +install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and expose it as `serve-d`" diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index bcf50a27..34163014 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -130,6 +130,7 @@ const ICONS = { "file-gdscript": "openidea/fileTypes/gdscript", "file-godot": "openidea/fileTypes/godot", "file-gdshader": "openidea/fileTypes/gdshader", + "file-d": "openidea/fileTypes/d", "file-dart": "openidea/fileTypes/dart", "file-elixir": "openidea/fileTypes/elixir", "file-erlang": "openidea/fileTypes/erlang", @@ -878,6 +879,7 @@ const EXT_ICONS = table({ "file-gdscript": "gd", "file-godot": "tscn tres godot import", "file-gdshader": "gdshader gdshaderinc", + "file-d": "d di", "file-dart": "dart", "file-elixir": "ex exs", "file-erlang": "erl hrl escript app.src", diff --git a/src/ui/openidea/fileTypes/d.svg b/src/ui/openidea/fileTypes/d.svg new file mode 100644 index 00000000..861238ca --- /dev/null +++ b/src/ui/openidea/fileTypes/d.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/d_dark.svg b/src/ui/openidea/fileTypes/d_dark.svg new file mode 100644 index 00000000..216cac95 --- /dev/null +++ b/src/ui/openidea/fileTypes/d_dark.svg @@ -0,0 +1,3 @@ + + + From 239753df9f43374e72e89791c95b12b26acca4ca Mon Sep 17 00:00:00 2001 From: kkmihai Date: Mon, 2 Nov 2026 04:38:02 +0200 Subject: [PATCH 020/108] fix(editor): speed up rapid file opening (#734) Rapid double-clicks queued their opens one after another and an earlier open finishing late took the active tab and focus back. Each open now carries a sequence/activation generation: only the newest request activates and focuses, older ones still add their tab. Pending file probes are shared, document and syntax loading overlap, and repeated folder double-clicks are handled. A pending navigation selection survives a later plain open of any file; a newer selection still wins. Refs #724. Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com> --- src/editor/core/controller.ts | 116 +++++-- src/editor/core/file-open-order.test.ts | 300 ++++++++++++++++++ .../core/model/document-open-latency.test.ts | 120 +++++++ src/editor/core/model/document.ts | 14 +- .../core/model/documentLanguage.test.ts | 4 +- src/editor/core/model/tabs.ts | 3 +- .../project-tree/ProjectView.test.tsx | 29 ++ src/features/project-tree/ProjectView.tsx | 3 +- src/features/viewers/file-probe.test.ts | 56 ++++ src/features/viewers/index.ts | 10 +- 10 files changed, 627 insertions(+), 28 deletions(-) create mode 100644 src/editor/core/file-open-order.test.ts create mode 100644 src/editor/core/model/document-open-latency.test.ts create mode 100644 src/features/viewers/file-probe.test.ts diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 90e12af6..723a27d5 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -171,6 +171,14 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s type EditorKinds = ReturnType["editorKinds"]; +interface OpenRequest { + project: number; + admission: number; + sequence: number; + activation: number; + pane: string; +} + /** Tab title of an editor input: the file name, or what its editor kind calls it. */ export function tabTitle(input: EditorInput, kinds: EditorKinds): string { if (input.kind === "file") return basename(input.path ?? input.key); @@ -223,6 +231,10 @@ export class EditorController implements SlotHost { private closeCommitted = false; private projectGeneration = 0; private admissionGeneration = 0; + private openSequence = 0; + private selectionRequests = new Map(); + private activationGeneration = 0; + private admitting = false; private closeRequests = 0; private projectTransition: Promise = Promise.resolve(); @@ -246,6 +258,11 @@ export class EditorController implements SlotHost { useTabs.subscribe((s) => { const prev = prevTabs; prevTabs = s; + if ( + !this.admitting && + (s.activePane !== prev.activePane || s.panes[s.activePane]?.active !== prev.panes[prev.activePane]?.active) + ) + ++this.activationGeneration; this.stampActive(s); this.onTabsChanged(prev, s); }), @@ -347,6 +364,7 @@ export class EditorController implements SlotHost { this.preparedDocuments.clear(); for (const slot of this.slots.values()) slot.dispose(); this.slots.clear(); + this.selectionRequests.clear(); this.manager.stop(); useTabs.setState(initialTabsState(), true); void this.releaseDocuments(documents.values()).catch((e) => this.documentError(e)); @@ -361,6 +379,7 @@ export class EditorController implements SlotHost { private onTabsChanged(prev: TabsState, next: TabsState) { const live = new Set(); for (const p of Object.values(next.panes)) for (const t of p.tabs) live.add(t.id); + for (const id of this.selectionRequests.keys()) if (!live.has(id)) this.selectionRequests.delete(id); for (const [id, slot] of this.slots) { if (!live.has(id)) { this.slots.delete(id); @@ -529,7 +548,9 @@ export class EditorController implements SlotHost { if (active?.id === slot.tabId) this.setActiveSlot(slot); if (slot.wantsFocus) { slot.wantsFocus = false; - requestAnimationFrame(() => slot.focus()); + requestAnimationFrame(() => { + if (!slot.disposed && this.activeTabOf()?.id === slot.tabId) slot.focus(); + }); } } @@ -849,8 +870,15 @@ export class EditorController implements SlotHost { // --- opening --------------------------------------------------------------------------- - private openingGeneration() { - const generation = { project: this.projectGeneration, admission: this.admissionGeneration }; + private openingGeneration(o: OpenOptions): OpenRequest { + const s = useTabs.getState(); + const generation = { + project: this.projectGeneration, + admission: this.admissionGeneration, + sequence: ++this.openSequence, + activation: this.activationGeneration, + pane: o.pane && s.panes[o.pane] ? o.pane : s.activePane, + }; this.checkOpening(generation); return generation; } @@ -861,16 +889,28 @@ export class EditorController implements SlotHost { throw new Error("The project is closing"); } + private isCurrentOpening(generation: OpenRequest) { + return ( + generation.project === this.projectGeneration && + generation.admission === this.admissionGeneration && + generation.sequence === this.openSequence && + generation.activation === this.activationGeneration + ); + } + async openFile(path: string, o: OpenOptions = {}): Promise { - const generation = this.openingGeneration(); - if (isVirtualUri(path)) return this.openVirtual(path, o); + return this.openFileRequest(path, o, this.openingGeneration(o)); + } + + private async openFileRequest(path: string, o: OpenOptions, generation: OpenRequest): Promise { + if (isVirtualUri(path)) return this.openVirtualRequest(path, o, generation); if (isClassFile(path)) { await this.projectTransition; this.checkOpening(generation); try { const target = await call(commands.lspResolveClassFile(path)); this.checkOpening(generation); - return isVirtualUri(target) ? this.openVirtual(target, o) : this.openFile(target, o); + return this.openFileRequest(target, o, generation); } catch (e) { notifications().notify({ level: "warning", title: "Cannot open library class", body: String(e), group: "lsp" }); return; @@ -895,7 +935,10 @@ export class EditorController implements SlotHost { /** Opens the library class (or other server-provided document) behind `uri` read-only. */ async openVirtual(uri: string, o: OpenOptions = {}) { - const generation = this.openingGeneration(); + return this.openVirtualRequest(uri, o, this.openingGeneration(o)); + } + + private async openVirtualRequest(uri: string, o: OpenOptions, generation: OpenRequest) { await this.projectTransition; this.checkOpening(generation); let vd: VirtualDocument; @@ -915,13 +958,13 @@ export class EditorController implements SlotHost { async openInput(input: EditorInput, o: OpenOptions = {}) { if (input.path && isClassFile(input.path)) return this.openFile(input.path, o); - return this.admitInput(input, o, this.openingGeneration()); + return this.admitInput(input, o, this.openingGeneration(o)); } private async admitInput( input: EditorInput, o: OpenOptions, - generation: { project: number; admission: number }, + generation: OpenRequest, resolution?: FileEditorResolution, ) { await this.projectTransition; @@ -931,7 +974,14 @@ export class EditorController implements SlotHost { this.navSuppress++; try { let s = useTabs.getState(); - let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane; + const activate = this.isCurrentOpening(generation); + const preview = + !!o.preview && + editorPrefs().tabs.previewTab && + !documentLifecycle(input, useContributions.getState().editorKinds); + if (!activate && preview) return; + let paneId = s.panes[generation.pane] ? generation.pane : activate ? s.activePane : null; + if (!paneId) return; if (o.side) { const right = neighbourPane(s, paneId); if (right) paneId = right; @@ -942,34 +992,44 @@ export class EditorController implements SlotHost { // The new pane starts with only the requested input. const pane = s.panes[paneId]!; s = { ...s, panes: { ...s.panes, [paneId]: { ...pane, tabs: [], active: null, mru: [] } } }; + if (!activate) s = { ...s, activePane: useTabs.getState().activePane }; } } const found = findTab(s, input.key, paneId); let tabId: string; if (found && (!(o.side || o.pane) || found.paneId === paneId)) { paneId = found.paneId; - s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); + if (activate) s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); if (!o.preview || documentLifecycle(input, useContributions.getState().editorKinds)) s = promoteTab(s, paneId, found.tab.id); tabId = found.tab.id; - if (found.tab.input.data !== input.data && input.data !== undefined) + if (activate && found.tab.input.data !== input.data && input.data !== undefined) s = openTab(s, paneId, input, { preview: o.preview && !documentLifecycle(input, useContributions.getState().editorKinds), + activate, }).state; } else { const tabs = editorPrefs().tabs; - const preview = - !!o.preview && tabs.previewTab && !documentLifecycle(input, useContributions.getState().editorKinds); const r = openTab(s, paneId, input, { preview, + activate, + preservePreview: !activate, ...(tabs.openAtEnd ? { index: s.panes[paneId]?.tabs.length ?? 0 } : {}), }); s = r.state; tabId = r.tabId; if (tabs.sortAlphabetically) s = sortTabs(s, paneId, (t) => tabSortKey(t.input)); } - resolution?.admit(); - useTabs.setState(s, true); + if (o.selection) + this.selectionRequests.set(tabId, Math.max(generation.sequence, this.selectionRequests.get(tabId) ?? 0)); + this.admitting = true; + try { + resolution?.admit(); + useTabs.setState(s, true); + } finally { + this.admitting = false; + } + const canFocus = () => this.isCurrentOpening(generation) && this.activeTabOf()?.id === tabId; const tabPrefs = editorPrefs().tabs; await this.removeGuarded( (current) => @@ -982,19 +1042,26 @@ export class EditorController implements SlotHost { (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) ?? !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty), - tabId, + activate ? tabId : (current.panes[paneId]?.active ?? tabId), (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true, ) : current, { protect: (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true }, ); if (!isTextInput(input)) { - if (o.focus !== false) requestAnimationFrame(() => this.focus()); + if (o.focus !== false) + requestAnimationFrame(() => { + if (canFocus()) this.focus(); + }); return; } const slot = await this.ensureSlot(tabId); if (!slot) return; - if (o.selection) { + if ( + o.selection && + this.selectionRequests.get(tabId) === generation.sequence && + generation.activation === this.activationGeneration + ) { const state = slot.currentState(); const toOffset = (line: number, col: number) => { const l = state.doc.line(Math.min(Math.max(1, line + 1), state.doc.lines)); @@ -1006,7 +1073,7 @@ export class EditorController implements SlotHost { // Selection runs anchor→head; place the caret at the start like IDEA's navigation. slot.reveal({ anchor: head, head: anchor, center: true }); } - if (o.focus !== false) { + if (o.focus !== false && canFocus()) { if (slot.view) slot.focus(); else slot.wantsFocus = true; } @@ -1017,12 +1084,19 @@ export class EditorController implements SlotHost { } activate(paneId: string, tabId: string, focus = true) { + ++this.activationGeneration; useTabs.setState((s) => activateTab(setActivePane(s, paneId), paneId, tabId), true); if (focus) { const slot = this.slots.get(tabId); if (slot?.view) slot.focus(); else if (slot) slot.wantsFocus = true; - else void this.ensureSlot(tabId).then((sl) => sl && (sl.view ? sl.focus() : (sl.wantsFocus = true))); + else + void this.ensureSlot(tabId).then((sl) => { + if (sl && this.activeTabOf()?.id === tabId) { + if (sl.view) sl.focus(); + else sl.wantsFocus = true; + } + }); } } diff --git a/src/editor/core/file-open-order.test.ts b/src/editor/core/file-open-order.test.ts new file mode 100644 index 00000000..cb863097 --- /dev/null +++ b/src/editor/core/file-open-order.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerFileEditorProvider } from "@/workbench/contributions"; +import { EditorController, openFilePaths, useTabs } from "./controller"; +import { createMemoryBackend } from "./backend"; +import { initialTabsState } from "./model/tabs"; +import { useProject } from "@/platform/project"; + +let ctrl: EditorController | null = null; +let dispose: (() => void) | undefined; +const initialSettings = useProject.getState().settings; + +afterEach(() => { + dispose?.(); + dispose = undefined; + ctrl?.stop(); + ctrl = null; + useTabs.setState(initialTabsState(), true); + useProject.setState({ settings: initialSettings }); +}); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function activePath() { + const state = useTabs.getState(); + const pane = state.panes[state.activePane]!; + return pane.tabs.find((tab) => tab.id === pane.active)?.input.path; +} + +function setup() { + ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "a\nsecond", "/p/b.ts": "b", "/p/c.ts": "c" })); + ctrl.start(); + return ctrl; +} + +function delayProvider(path = "/p/a.ts") { + const started = deferred(); + const response = deferred(); + let delayed = false; + dispose = registerFileEditorProvider({ + id: "delayed-file", + resolve: async (requested) => { + if (requested === path && !delayed) { + delayed = true; + started.resolve(); + await response.promise; + } + return null; + }, + }); + return { started, response }; +} + +function tabFor(path: string) { + return Object.values(useTabs.getState().panes) + .flatMap((pane) => pane.tabs) + .find((tab) => tab.input.path === path); +} + +describe("file open order", () => { + it.each([1, 30])("keeps the newer file active after an earlier probe (tab limit: %s)", async (tabLimit) => { + useProject.setState({ settings: { editor: { tabs: { tabLimit } } } as never }); + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { focus: false }); + expect(activePath()).toBe("/p/b.ts"); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(tabLimit === 1 ? ["/p/b.ts"] : ["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + expect(controller.activeSlot?.model.path).toBe("/p/b.ts"); + }); + + it("activates the newer file after an earlier probe finishes while both are pending", async () => { + const a = { started: deferred(), response: deferred() }; + const b = { started: deferred(), response: deferred() }; + dispose = registerFileEditorProvider({ + id: "two-delayed-files", + resolve: async (path) => { + const pending = path === "/p/a.ts" ? a : b; + pending.started.resolve(); + await pending.response.promise; + return null; + }, + }); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await a.started.promise; + const second = controller.openFile("/p/b.ts", { focus: false }); + await b.started.promise; + a.response.resolve(); + await first; + b.response.resolve(); + await second; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("discards an earlier preview without replacing the newer preview", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState())).toEqual(["/p/b.ts"]); + expect(tabFor("/p/b.ts")?.preview).toBe(true); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps an earlier regular file without removing the newer preview", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(tabFor("/p/a.ts")?.preview).toBe(false); + expect(tabFor("/p/b.ts")?.preview).toBe(true); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps manual tab activation while an earlier probe is pending", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + await controller.openFile("/p/b.ts", { focus: false }); + await controller.openFile("/p/c.ts", { focus: false }); + const first = controller.openFile("/p/a.ts", { focus: false }); + await started.promise; + controller.activate(useTabs.getState().activePane, tabFor("/p/b.ts")!.id, false); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts", "/p/c.ts"]); + expect(activePath()).toBe("/p/b.ts"); + }); + + it("keeps a newer explicit open regular when the same file's preview resolves late", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const preview = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/a.ts", { focus: false }); + response.resolve(); + await preview; + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + expect(tabFor("/p/a.ts")?.preview).toBe(false); + expect(activePath()).toBe("/p/a.ts"); + }); + + it("promotes an admitted preview when the same file is opened explicitly", async () => { + const controller = setup(); + await controller.openFile("/p/a.ts", { preview: true, focus: false }); + const tabId = tabFor("/p/a.ts")!.id; + expect(tabFor("/p/a.ts")?.preview).toBe(true); + await controller.openFile("/p/a.ts", { focus: false }); + expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]); + expect(tabFor("/p/a.ts")).toMatchObject({ id: tabId, preview: false }); + expect(activePath()).toBe("/p/a.ts"); + }); + + it("keeps the newer selection when an earlier open of the same file resolves late", async () => { + const { started, response } = delayProvider(); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { selection: { line: 0, col: 0 }, focus: false }); + await started.promise; + await controller.openFile("/p/a.ts", { selection: { line: 1, col: 0 }, focus: false }); + response.resolve(); + await first; + const state = controller.slotFor(tabFor("/p/a.ts")!.id)!.currentState(); + expect(state.doc.lineAt(state.selection.main.head).number).toBe(2); + }); + + it.each([false, true])("keeps a pending navigation selection after a plain open (same file: %s)", async (same) => { + const backend = createMemoryBackend({ "/p/a.ts": "a\nsecond", "/p/b.ts": "b" }); + const started = deferred(); + const response = deferred(); + const open = backend.open.bind(backend); + backend.open = async (path) => { + if (path === "/p/a.ts") { + started.resolve(); + await response.promise; + } + return open(path); + }; + ctrl = new EditorController(backend); + ctrl.start(); + const navigation = ctrl.openFile("/p/a.ts", { selection: { line: 1, col: 2 } }); + await started.promise; + const plain = ctrl.openFile(same ? "/p/a.ts" : "/p/b.ts", { focus: false }); + if (!same) await plain; + response.resolve(); + await Promise.all([navigation, plain]); + const slot = ctrl.slotFor(tabFor("/p/a.ts")!.id)!; + expect(slot.caret()).toMatchObject({ line: 1, col: 2 }); + expect(slot.wantsFocus).toBe(false); + expect(activePath()).toBe(same ? "/p/a.ts" : "/p/b.ts"); + }); + + it.each([true, false])("keeps a delayed open in its origin pane (explicit: %s)", async (explicit) => { + const { started, response } = delayProvider(); + const controller = setup(); + await controller.openFile("/p/b.ts", { focus: false }); + const origin = useTabs.getState().activePane; + controller.split(origin, "row"); + const other = useTabs.getState().activePane; + expect(other).not.toBe(origin); + controller.activate(origin, tabFor("/p/b.ts")!.id, false); + const first = controller.openFile("/p/a.ts", { ...(explicit ? { pane: origin } : {}), focus: false }); + await started.promise; + await controller.openFile("/p/c.ts", { pane: other, focus: false }); + response.resolve(); + await first; + const state = useTabs.getState(); + expect(state.panes[origin]!.tabs.some((tab) => tab.input.path === "/p/a.ts")).toBe(true); + expect(state.panes[other]!.tabs.some((tab) => tab.input.path === "/p/a.ts")).toBe(false); + expect(activePath()).toBe("/p/c.ts"); + }); + + it("does not request focus when an earlier document finishes loading late", async () => { + const backend = createMemoryBackend({ "/p/a.ts": "a", "/p/b.ts": "b" }); + const started = deferred(); + const response = deferred(); + const open = backend.open.bind(backend); + backend.open = async (path) => { + if (path === "/p/a.ts") { + started.resolve(); + await response.promise; + } + return open(path); + }; + ctrl = new EditorController(backend); + ctrl.start(); + const first = ctrl.openFile("/p/a.ts"); + await started.promise; + await ctrl.openFile("/p/b.ts", { focus: false }); + response.resolve(); + await first; + expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(activePath()).toBe("/p/b.ts"); + expect(ctrl.slotFor(tabFor("/p/a.ts")!.id)?.wantsFocus).toBe(false); + }); + + it("does not run queued mount focus after a newer file becomes active", async () => { + const controller = setup(); + await controller.openFile("/p/a.ts"); + const slot = controller.slotFor(tabFor("/p/a.ts")!.id)!; + expect(slot.wantsFocus).toBe(true); + const focus = vi.spyOn(slot, "focus"); + const frames: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => frames.push(callback)); + try { + controller.onMounted(slot); + expect(frames.length).toBeGreaterThan(0); + await controller.openFile("/p/b.ts", { focus: false }); + for (const frame of frames) frame(0); + expect(focus).not.toHaveBeenCalled(); + expect(activePath()).toBe("/p/b.ts"); + } finally { + vi.unstubAllGlobals(); + focus.mockRestore(); + } + }); + + it("rolls back a superseded custom preview before provider admission", async () => { + const started = deferred(); + const response = deferred(); + const commit = vi.fn(); + const rollback = vi.fn(async () => {}); + dispose = registerFileEditorProvider({ + id: "delayed-custom-preview", + resolve: async (path, context) => { + if (path !== "/p/a.ts") return null; + context.onAdmission(commit, rollback); + started.resolve(); + await response.promise; + return { kind: "image", key: "image:/p/a.ts", path }; + }, + }); + const controller = setup(); + const first = controller.openFile("/p/a.ts", { preview: true, focus: false }); + await started.promise; + await controller.openFile("/p/b.ts", { preview: true, focus: false }); + response.resolve(); + await first; + expect(commit).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledOnce(); + expect(activePath()).toBe("/p/b.ts"); + expect(tabFor("/p/a.ts")).toBeUndefined(); + }); +}); diff --git a/src/editor/core/model/document-open-latency.test.ts b/src/editor/core/model/document-open-latency.test.ts new file mode 100644 index 00000000..7d6795a1 --- /dev/null +++ b/src/editor/core/model/document-open-latency.test.ts @@ -0,0 +1,120 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { LanguageSupport, StreamLanguage } from "@codemirror/language"; +import { createMemoryBackend } from "../backend"; +import { _resetLanguages, loadLanguageInfos, loadSupport } from "../cm/languages"; +import { DocumentManager } from "./document"; + +vi.mock("../cm/languages", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadSupport: vi.fn(actual.loadSupport) }; +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => { + _resetLanguages(); + vi.mocked(loadSupport).mockClear(); +}); + +it.each(["/p/a.ts", "jdt://contents/a.ts"])("starts reading %s before the catalog resolves", async (path) => { + const backend = createMemoryBackend({ [path]: "const a = 1" }); + const languages = await backend.languages(); + const catalog = deferred(); + vi.spyOn(backend, "languages").mockReturnValue(catalog.promise); + const open = vi.spyOn(backend, path.startsWith("jdt:") ? "openVirtual" : "open"); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + let exposed = false; + const pending = manager.acquire(path).then((model) => { + exposed = true; + return model; + }); + try { + expect(open).toHaveBeenCalledWith(path); + await open.mock.results[0]!.value; + expect(exposed).toBe(false); + expect(manager.get(path)).toBeUndefined(); + } finally { + catalog.resolve(languages); + const model = await pending; + model.dispose(); + manager.stop(); + } +}); + +it("starts the known mode while document content is still pending", async () => { + const path = "/p/a.ts"; + const backend = createMemoryBackend({ [path]: "const a = 1" }); + const info = await backend.open(path); + const content = deferred(); + vi.spyOn(backend, "open").mockReturnValue(content.promise); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + const pending = manager.acquire(path); + await loadLanguageInfos(backend); + try { + expect(loadSupport).toHaveBeenCalledWith(expect.objectContaining({ name: "TypeScript" })); + } finally { + content.resolve(info); + const model = await pending; + model.dispose(); + manager.stop(); + } +}); + +it("handles a speculative mode rejection when opening the document fails", async () => { + const backend = createMemoryBackend(); + const failure = new Error("cannot read document"); + vi.spyOn(backend, "open").mockRejectedValue(failure); + vi.mocked(loadSupport).mockRejectedValueOnce(new Error("cannot preload mode")); + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + try { + await expect(manager.acquire("/p/a.ts")).rejects.toBe(failure); + expect(loadSupport).toHaveBeenCalledWith(expect.objectContaining({ name: "TypeScript" })); + } finally { + manager.stop(); + } +}); + +it("opens within 50 ms with catalog, read and cold mode budgets of 20, 40 and 30 ms", async () => { + const path = "/p/cold.rs"; + const backend = createMemoryBackend({ [path]: "fn main() {}" }); + const catalog = await backend.languages(); + const info = await backend.open(path); + const originalSupport = vi.mocked(loadSupport).getMockImplementation()!; + const support = new LanguageSupport(StreamLanguage.define({ token: (stream) => (stream.skipToEnd(), null) })); + let loading: Promise | undefined; + let readyAt: number | undefined; + const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() }); + vi.useFakeTimers(); + const start = Date.now(); + vi.spyOn(backend, "languages").mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(catalog), 20)), + ); + vi.spyOn(backend, "open").mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(info), 40))); + vi.mocked(loadSupport).mockImplementation( + () => (loading ??= new Promise((resolve) => setTimeout(() => resolve(support), 30))), + ); + const pending = manager.acquire(path).then((model) => { + readyAt = Date.now() - start; + return model; + }); + try { + await vi.advanceTimersByTimeAsync(90); + expect(readyAt).toBeLessThanOrEqual(50); + expect((await pending).language).toBe(support); + } finally { + try { + await vi.runAllTimersAsync(); + (await pending).dispose(); + } finally { + manager.stop(); + vi.mocked(loadSupport).mockImplementation(originalSupport); + vi.useRealTimers(); + } + } +}); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 9f81ad20..ff94d564 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -36,6 +36,7 @@ import { loadSupport, loadedSupport, commentTokens, + uiModeForPath, } from "../cm/languages"; import { foldEffect } from "@codemirror/language"; import { backendFolds, setBackendFolds } from "../cm/folding"; @@ -683,8 +684,17 @@ export class DocumentManager { let p = this.opening.get(path); if (!p) { p = (async () => { - await loadLanguageInfos(this.backend); - const info = isVirtualUri(path) ? await this.backend.openVirtual(path) : await this.backend.open(path); + const catalog = loadLanguageInfos(this.backend); + void catalog + .then(() => { + const desc = findLanguageDescription(uiModeForPath(path) ?? null, path); + if (desc) return loadSupport(desc); + }) + .catch(() => {}); + const [info] = await Promise.all([ + isVirtualUri(path) ? this.backend.openVirtual(path) : this.backend.open(path), + catalog, + ]); const raced = this.byPath.get(path); if (raced) { void this.backend.close(info.id); diff --git a/src/editor/core/model/documentLanguage.test.ts b/src/editor/core/model/documentLanguage.test.ts index 0618acf2..0993edbd 100644 --- a/src/editor/core/model/documentLanguage.test.ts +++ b/src/editor/core/model/documentLanguage.test.ts @@ -9,7 +9,7 @@ const loading = vi.hoisted(() => { const ready = new Promise((resolve) => { finish = resolve; }); - return { ready, finish, started: false }; + return { ready, finish, started: false, modes: [] as string[] }; }); vi.mock("../cm/languages", async (importOriginal) => { @@ -18,6 +18,7 @@ vi.mock("../cm/languages", async (importOriginal) => { ...actual, loadSupport: async (...args: Parameters) => { loading.started = true; + loading.modes.push(args[0].name); await loading.ready; return actual.loadSupport(...args); }, @@ -49,6 +50,7 @@ it("waits for the lazy mode before exposing an Angular document to its first slo onContextMenu() {}, }); expect(slot.currentState().facet(languageFacet)?.name).toBe("angular"); + expect(loading.modes).toEqual(expect.arrayContaining(["HTML", "Angular Template"])); slot.dispose(); manager.stop(); }); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index 269e4b20..56cf6a83 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -113,6 +113,7 @@ export interface OpenTabOptions { /** Insert at this index instead of after the active tab. */ index?: number; activate?: boolean; + preservePreview?: boolean; } export function openTab( @@ -152,7 +153,7 @@ export function openTab( tabs.splice(index, 0, tab); } let active = pane.active; - if (!tab.preview && tabs.some((t) => t.preview)) { + if (!tab.preview && !opts.preservePreview && tabs.some((t) => t.preview)) { // A new regular editor closes the preview tab (IDEA trims previews on every open). const previews = new Set(tabs.filter((t) => t.preview).map((t) => t.id)); tabs.splice(0, tabs.length, ...tabs.filter((t) => !previews.has(t.id))); diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 05ce9b99..6c68edcf 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -172,6 +172,9 @@ describe("ProjectView", () => { act(() => setViewOption("previewTab", true)); fireEvent.click(content, { detail: 1 }); expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: true, focus: false }); + openFile.mockClear(); + fireEvent.click(content, { detail: 3 }); + expect(openFile).not.toHaveBeenCalled(); act(() => setViewOption("previewTab", false)); }); @@ -207,6 +210,32 @@ describe("ProjectView", () => { act(() => setViewOption("singleClickExpand", false)); }); + it("toggles folders once for each rapid double-click pair", async () => { + const { row, rows } = setup(); + await waitFor(() => expect(rows()).toContain("README.md")); + act(() => setViewOption("singleClickExpand", true)); + const content = () => row("src").querySelector("[data-project-path]")!; + const click = (detail: number) => { + fireEvent.mouseDown(row("src"), { detail }); + fireEvent.click(content(), { detail }); + }; + click(1); + click(2); + fireEvent.doubleClick(content(), { detail: 2 }); + await waitFor(() => expect(rows()).toContain("App.kt")); + click(3); + click(4); + fireEvent.doubleClick(content(), { detail: 4 }); + expect(rows()).not.toContain("App.kt"); + + act(() => setViewOption("singleClickExpand", false)); + click(5); + click(6); + expect(rows()).not.toContain("App.kt"); + fireEvent.doubleClick(content(), { detail: 6 }); + await waitFor(() => expect(rows()).toContain("App.kt")); + }); + it("cancels a new file when its name field loses focus", async () => { const { rows } = setup(); await waitFor(() => expect(rows()).toContain("README.md")); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 832cbb51..f4e140f8 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -529,10 +529,11 @@ export function ProjectView() { const dragged = useRef(false); const onRowClick = useCallback((node: ProjectNode, e: ReactMouseEvent) => { - if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || e.detail > 1) return; + if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return; if (node.placeholder || dragged.current) return; const { options: o, editing: ed } = useProjectTree.getState(); if (ed) return; + if (e.detail > 1 && !(node.dir && o.singleClickExpand && e.detail % 2 === 1)) return; if (node.dir) { if (o.singleClickExpand) toggleExpanded(node); return; diff --git a/src/features/viewers/file-probe.test.ts b/src/features/viewers/file-probe.test.ts new file mode 100644 index 00000000..49ae1bd4 --- /dev/null +++ b/src/features/viewers/file-probe.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { commands, type FileProbe } from "@/platform/ipc"; +import { resolveFileEditor } from "@/workbench/contributions"; +import { activate } from "./index"; + +let dispose: (() => void) | undefined; + +afterEach(() => { + dispose?.(); + dispose = undefined; + vi.restoreAllMocks(); +}); + +describe("file probes", () => { + it.each([false, true])("shares an in-flight probe without caching its result (binary: %s)", async (binary) => { + let finish!: (result: { status: "ok"; data: FileProbe }) => void; + const pending = new Promise<{ status: "ok"; data: FileProbe }>((resolve) => { + finish = resolve; + }); + const probe = vi.spyOn(commands, "fsProbe").mockReturnValue(pending); + dispose = activate(); + const first = resolveFileEditor("/p/a.ts"); + const second = resolveFileEditor("/p/a.ts"); + await vi.waitFor(() => expect(probe).toHaveBeenCalled()); + finish({ status: "ok", data: { size: 12, binary, tooLarge: false } }); + const resolutions = await Promise.all([first, second]); + expect(probe).toHaveBeenCalledTimes(1); + expect(resolutions.map((r) => r?.input.kind ?? "file")).toEqual(binary ? ["binary", "binary"] : ["file", "file"]); + if (binary) expect(resolutions[0]).not.toBe(resolutions[1]); + probe.mockResolvedValue({ status: "ok", data: { size: 20, binary: !binary, tooLarge: false } }); + expect((await resolveFileEditor("/p/a.ts"))?.input.kind ?? "file").toBe(binary ? "file" : "binary"); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it("retries a failed probe", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const probe = vi + .spyOn(commands, "fsProbe") + .mockRejectedValueOnce(new Error("read failed")) + .mockResolvedValue({ status: "ok", data: { size: 20, binary: true, tooLarge: false } }); + dispose = activate(); + expect(await resolveFileEditor("/p/a.ts")).toBeNull(); + expect((await resolveFileEditor("/p/a.ts"))?.input.kind).toBe("binary"); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it("probes different files independently", async () => { + const probe = vi.spyOn(commands, "fsProbe").mockResolvedValue({ + status: "ok", + data: { size: 20, binary: false, tooLarge: false }, + }); + dispose = activate(); + await Promise.all([resolveFileEditor("/p/a.ts"), resolveFileEditor("/p/b.ts")]); + expect(probe.mock.calls).toEqual([["/p/a.ts"], ["/p/b.ts"]]); + }); +}); diff --git a/src/features/viewers/index.ts b/src/features/viewers/index.ts index 92ac8c15..a0137a65 100644 --- a/src/features/viewers/index.ts +++ b/src/features/viewers/index.ts @@ -5,7 +5,7 @@ * Editor / Editor and Preview / Preview layouts around the text editor. */ import { registerActions, type Action } from "@/platform/actions"; -import { call, commands } from "@/platform/ipc"; +import { call, commands, type FileProbe } from "@/platform/ipc"; import { getContext } from "@/platform/context"; import { basename } from "@/platform/project"; import { fileIconName, lazyComponent } from "@/ui"; @@ -78,6 +78,7 @@ function markdownActions(): Action[] { } export function activate(): () => void { + const probes = new Map>(); const disposers = [ registerEditorKind({ kind: IMAGE_KIND, component: ImageViewer, title: titleOf, icon: iconOf }), registerEditorKind({ kind: BINARY_KIND, component: HexViewer, title: titleOf, icon: iconOf }), @@ -90,7 +91,12 @@ export function activate(): () => void { id: "viewers.hex", order: 50, resolve: async (path) => { - const probe = await call(commands.fsProbe(path)); + let pending = probes.get(path); + if (!pending) { + pending = call(commands.fsProbe(path)).finally(() => probes.delete(path)); + probes.set(path, pending); + } + const probe = await pending; return wantsHexView(probe) ? binaryInput(path, probe) : null; }, }), From ff97d7724c7cacf0db0ba8d4cfaee5170c67c0b6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:47:30 +0800 Subject: [PATCH 021/108] fix(ui): give AsciiDoc files their own icon (#763) AsciiDoc became a recognized language in #731 but .adoc/.asciidoc still used the generic text glyph, failing the icon catalog test that requires every detected language to have a specific icon. Add a dedicated AsciiDoc icon (light/dark) and map both extensions to it. --- src/ui/icons.tsx | 4 +++- src/ui/openidea/fileTypes/asciidoc.svg | 3 +++ src/ui/openidea/fileTypes/asciidoc_dark.svg | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/ui/openidea/fileTypes/asciidoc.svg create mode 100644 src/ui/openidea/fileTypes/asciidoc_dark.svg diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 34163014..bcaf888b 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -130,6 +130,7 @@ const ICONS = { "file-gdscript": "openidea/fileTypes/gdscript", "file-godot": "openidea/fileTypes/godot", "file-gdshader": "openidea/fileTypes/gdshader", + "file-asciidoc": "openidea/fileTypes/asciidoc", "file-d": "openidea/fileTypes/d", "file-dart": "openidea/fileTypes/dart", "file-elixir": "openidea/fileTypes/elixir", @@ -826,7 +827,7 @@ const EXT_ICONS = table({ "file-rst": "rst", "file-yaml": "yaml yml", "file-toml": "toml", - "file-text": "txt text adoc", + "file-text": "txt text", "file-log": "log", "file-csv": "csv tsv", "file-html": "html htm shtml html.tmpl", @@ -879,6 +880,7 @@ const EXT_ICONS = table({ "file-gdscript": "gd", "file-godot": "tscn tres godot import", "file-gdshader": "gdshader gdshaderinc", + "file-asciidoc": "adoc asciidoc", "file-d": "d di", "file-dart": "dart", "file-elixir": "ex exs", diff --git a/src/ui/openidea/fileTypes/asciidoc.svg b/src/ui/openidea/fileTypes/asciidoc.svg new file mode 100644 index 00000000..81076e49 --- /dev/null +++ b/src/ui/openidea/fileTypes/asciidoc.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/asciidoc_dark.svg b/src/ui/openidea/fileTypes/asciidoc_dark.svg new file mode 100644 index 00000000..2f60166f --- /dev/null +++ b/src/ui/openidea/fileTypes/asciidoc_dark.svg @@ -0,0 +1,3 @@ + + + From e2f8e8d5bc4215507cb9e58df352fb24e50fc976 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:14:27 +0800 Subject: [PATCH 022/108] fix(cpp): explain missing clang-tidy checks with Apple's clangd (#770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple's clangd (/usr/bin/clangd, Xcode and CommandLineTools toolchains) is built without clang-tidy, so on a Mac without a managed clangd the checks from .clang-tidy never appear. When a clangd server for C, C++, Objective-C or Objective-C++ resolves to Apple's binary and the project has a .clang-tidy, show one warning per session with Install (managed LLVM clangd, which already takes precedence once installed) and Language Servers… actions. Fixes #692. --- crates/ide-core/src/event.rs | 6 + crates/ide-lsp/src/clang_tidy.rs | 126 ++++++++++++++++++++ crates/ide-lsp/src/launch.rs | 27 ++++- crates/ide-lsp/src/lib.rs | 15 ++- crates/ide-lsp/src/server.rs | 2 + crates/ide-lsp/tests/mock_server.rs | 78 ++++++++++++ src/bindings.ts | 2 + src/features/tools/__tests__/tools.test.tsx | 48 +++++++- src/features/tools/index.ts | 3 +- src/features/tools/missing.ts | 28 +++++ src/platform/locales/text/zh.json | 1 + 11 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 crates/ide-lsp/src/clang_tidy.rs diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 5de98978..e2d45fec 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -82,6 +82,12 @@ pub enum IdeEvent { status: ServerStatus, message: Option, }, + /// A running server lacks a capability; the UI offers the managed tool as a replacement. + LanguageServerWarning { + name: String, + message: String, + install_tool: String, + }, /// A stopped language server left the project's list (disabled in settings, or a /// fallback that a preferred server took over from again). LanguageServerRemoved { diff --git a/crates/ide-lsp/src/clang_tidy.rs b/crates/ide-lsp/src/clang_tidy.rs new file mode 100644 index 00000000..351ae473 --- /dev/null +++ b/crates/ide-lsp/src/clang_tidy.rs @@ -0,0 +1,126 @@ +//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks. + +use crate::{LspManager, Server}; +use ide_core::IdeEvent; +use std::path::Path; + +pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them."; + +fn is_apple_clangd(path: &Path) -> bool { + if path == Path::new("/usr/bin/clangd") { + return true; + } + if !path.ends_with("usr/bin/clangd") { + return false; + } + path.starts_with("/Library/Developer/CommandLineTools") + || path.ancestors().any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "Toolchains") + && ancestor.parent().is_some_and(|developer| { + developer.ends_with("Contents/Developer") + && developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("Xcode") && name.ends_with(".app") + }) + }) + }) +} + +fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool { + server_root.join(".clang-tidy").is_file() + || file + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|dir| dir.starts_with(project_root)) + .any(|dir| dir.join(".clang-tidy").is_file()) +} + +impl LspManager { + pub(crate) async fn check_clang_tidy(&self, server: &Server) { + if server.name != "clangd" + || !cfg!(target_os = "macos") + || self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into())) + { + return; + } + let binary = server.resolved_binary.read().clone(); + let Some(binary) = binary else { return }; + let files: Vec<_> = self + .docs + .lock() + .values() + .filter(|doc| { + matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp") + && doc.servers.iter().any(|attached| attached.server.id == server.id) + }) + .map(|doc| doc.path.clone()) + .collect(); + let root = server.root.clone(); + let Some(me) = self.me.upgrade() else { return }; + let _ = tokio::task::spawn_blocking(move || { + if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) { + return; + } + if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire) + && files.iter().any(|file| has_config(&root, &me.project_root_for(file), file)) + && me.first_message("clangd", MESSAGE) + { + me.sink.emit(IdeEvent::LanguageServerWarning { + name: "clangd".into(), + message: MESSAGE.into(), + install_tool: "clangd".into(), + }); + } + }) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apple_paths() { + for path in [ + "/usr/bin/clangd", + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Library/Developer/CommandLineTools/usr/bin/clangd", + ] { + assert!(is_apple_clangd(Path::new(path)), "{path}"); + } + for path in [ + "/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", + "/opt/homebrew/opt/llvm/bin/clangd", + "/usr/local/bin/clangd", + "/usr/bin/clangd-custom", + "/Library/Developer/CommandLineTools-custom/usr/bin/clangd", + "/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd", + ] { + assert!(!is_apple_clangd(Path::new(path)), "{path}"); + } + } + + #[test] + fn config_in_server_root_or_file_ancestors_stays_inside_project() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let server = project.join("build"); + let source = project.join("src/nested"); + let file = source.join("main.cpp"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&server).unwrap(); + std::fs::write(dir.path().join(".clang-tidy"), "").unwrap(); + assert!(!has_config(&server, &project, &file)); + for root in [&server, &source, &project.join("src"), &project] { + let config = root.join(".clang-tidy"); + std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap(); + assert!(has_config(&server, &project, &file), "{}", root.display()); + std::fs::remove_file(config).unwrap(); + } + std::fs::create_dir(source.join(".clang-tidy")).unwrap(); + assert!(!has_config(&server, &project, &file)); + } +} diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 36ae556c..5c7d53c0 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static { self.launch(config, root, handler, on_stderr) } + /// Starts a server and reports its executable when the launcher runs a local process. + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None)) + } + /// Whether `config` could start without a `Missing` error. fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool { true @@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher { on_stderr: Box, cancel: &CancellationToken, ) -> Result { + self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer) + } + + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { let search = server_search_path(config); let binary = ide_core::tool_path::find_command(&config.command, &search, root) .and_then(|b| resolve_rustup_proxy(&config.command, b)); @@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher { handler, on_stderr, cancel, - ); + ) + .map(|peer| (peer, None)); } let binary = binary.ok_or(LaunchError::Missing)?; Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr) + .map(|peer| (peer, Some(binary.clone()))) .map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display()))) } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 52145184..d8ead994 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -8,6 +8,7 @@ mod angular; mod caps; +mod clang_tidy; mod convert; mod external_project; mod file_ops; @@ -472,13 +473,16 @@ impl LspManager { if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } - me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) + me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await .unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string()))); let peer = match launched { - Ok(peer) => peer, + Ok((peer, binary)) => { + *server.resolved_binary.write() = binary; + peer + } Err(LaunchError::Missing) => { let cfg = server.effective_config(); let message = @@ -587,6 +591,7 @@ impl LspManager { } } } + self.check_clang_tidy(&server).await; server.refresh_busy_status(&self.sink); server.announce_ready(generation); self.spawn_supervisor(server, generation, peer); @@ -810,6 +815,12 @@ impl LspManager { } for s in &servers { self.ensure_started(s); + if s.running().is_some() + && let Some(me) = self.me.upgrade() + { + let server = s.clone(); + self.runtime.spawn(async move { me.check_clang_tidy(&server).await }); + } } } diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 81c21b02..f15a893e 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -79,6 +79,7 @@ pub struct Server { pub config: LanguageServerConfig, /// `config` with the settings hook applied, as the current instance was launched. effective: Mutex>, + pub resolved_binary: RwLock>, languages: Mutex>, state: Mutex, /// Fired when the instance being started is abandoned (stop / restart while starting). @@ -114,6 +115,7 @@ impl Server { root, config, effective: Mutex::new(None), + resolved_binary: RwLock::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), start_cancel: Mutex::new(CancellationToken::new()), diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 7bfb45cc..d2136467 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -21,6 +21,7 @@ use std::time::Duration; struct MockState { caps: Mutex, missing: std::sync::atomic::AtomicBool, + binary: Mutex>, launches: AtomicUsize, notifications: Mutex>, requests: Mutex>, @@ -148,6 +149,18 @@ impl MockLauncher { } impl Launcher for MockLauncher { + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &ide_core::CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel) + .map(|peer| (peer, self.state(&config.name).binary.lock().clone())) + } + fn launch( &self, config: &LanguageServerConfig, @@ -2836,3 +2849,68 @@ async fn formatting_provider_selection_tracks_dynamic_registration() { .unwrap(); assert!(!h.mgr.can_format(doc).await.unwrap()); } + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() { + for language in ["c", "cpp", "objective-c", "objective-cpp"] { + let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("main.src", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + let id = h.mgr.servers()[0].id; + h.mgr.restart(id).await.unwrap(); + h.wait_status("clangd", ServerStatus::Ready).await; + h.open("other.src", "int *p = 0;"); + h.mgr.shutdown_all().await; + let events = h.events.lock(); + let warnings: Vec<_> = events + .iter() + .filter_map(|e| match e { + IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)), + _ => None, + }) + .collect(); + assert_eq!(warnings.len(), 1, "{language}"); + assert_eq!(warnings[0].0, "clangd"); + assert!(warnings[0].1.contains(".clang-tidy")); + assert_eq!(warnings[0].2, "clangd"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_warning_requires_apple_binary_and_a_config() { + for (binary, config) in [ + ("/usr/bin/clangd", false), + ("/opt/homebrew/opt/llvm/bin/clangd", true), + ("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true), + ] { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary)); + if config { + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + } + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + h.mgr.shutdown_all().await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))); + std::fs::create_dir(h.root.join("nested")).unwrap(); + std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("nested/other.cpp", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; +} diff --git a/src/bindings.ts b/src/bindings.ts index b3ea339c..e2eb8a2a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3619,6 +3619,8 @@ cancellable: boolean } | { type: "indexState"; indexing: boolean; files: number; symbols: number } | /** Git status / branch information should be refreshed. */ { type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } | +/** A running server lacks a capability; the UI offers the managed tool as a replacement. */ +{ type: "languageServerWarning"; name: string; message: string; installTool: string } | /** * A stopped language server left the project's list (disabled in settings, or a * fallback that a preferred server took over from again). diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 0d4bd57c..49c64365 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks"; import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend"; import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store"; import { filterTools, LanguageServersPage } from "../LanguageServersPage"; -import { installMissingServerPrompts, missingKey, promptInstall } from "../missing"; +import { installMissingServerPrompts, installServerWarningPrompts, missingKey, promptInstall } from "../missing"; import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm"; type Note = Parameters[0]; @@ -273,3 +273,49 @@ describe("tools.install action", () => { } }); }); + +describe("language server capability warning", () => { + const event = { + type: "languageServerWarning", + name: "clangd", + message: + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.", + installTool: "clangd", + } as const; + + it("offers a managed replacement for system clangd once and restarts it after installation", async () => { + backend.tools.push({ + ...sampleTools()[0]!, + name: "clangd", + displayName: "clangd", + command: "clangd", + status: "onPath", + path: "/usr/bin/clangd", + languages: ["C", "C++", "Objective-C", "Objective-C++"], + }); + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]).toMatchObject({ title: "clangd", level: "warning", sticky: true, body: event.message }); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]); + notes[0]!.actions![0]!.run(); + await waitFor(() => expect(backend.restarted).toEqual(["clangd"])); + expect(notes.at(-1)).toMatchObject({ title: "clangd installed", key: notes[0]!.key }); + } finally { + off(); + } + }); + + it("keeps the Settings action when the tool cannot be installed", async () => { + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Language Servers…"]); + } finally { + off(); + } + }); +}); diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index 76736a60..f9c79d9a 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { registerSettingsPage } from "@/workbench/contributions"; import { installJdkPrompts, registerJdkActions } from "./jdk"; -import { installMissingServerPrompts, promptInstall } from "./missing"; +import { installMissingServerPrompts, installServerWarningPrompts, promptInstall } from "./missing"; import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store"; /** The Language Servers page (also used by the Settings window). */ @@ -79,6 +79,7 @@ export function activate(): () => void { }, ]), installMissingServerPrompts(), + installServerWarningPrompts(), registerJdkActions(), installJdkPrompts(), ]; diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index c1c48d1a..6bd53191 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -56,3 +56,31 @@ export function installMissingServerPrompts(): () => void { void promptInstall(e.name).catch(() => {}); }); } + +/** A system server that lacks a capability can be replaced by the managed tool. */ +export function installServerWarningPrompts(): () => void { + const prompted = new Set(); + return onIdeEvent("languageServerWarning", (e) => { + const key = `lsp-warning-${e.name}-${e.installTool}`; + if (prompted.has(key)) return; + prompted.add(key); + void (async () => { + if (!useTools.getState().loaded) await refreshTools(); + const tool = toolByName(e.installTool); + notifications().notify({ + group: "lsp", + level: "warning", + title: e.name, + body: tx(e.message), + key, + sticky: true, + actions: [ + ...(tool?.canInstall + ? [{ label: "Install", run: () => void installTool(e.installTool, { notificationKey: key }) }] + : []), + { label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) }, + ], + }); + })(); + }); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index daeb595f..fec979fe 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。", "Newer dependency version available": "有更新的依赖版本可用", "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", "Update to {latest}": "更新至 {latest}", From 44c603f7dba0a785feffb01dc64dd571bfcd633d Mon Sep 17 00:00:00 2001 From: d0whc3r <1378986+d0whc3r@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:22:49 +0100 Subject: [PATCH 023/108] feat: add oxfmt/oxlint support for JS and TS projects (#717) Below an oxfmt configuration (.oxfmtrc.json/.jsonc, oxfmt.config.ts/.mts) Reformat Code runs oxfmt instead of Prettier, with the same external-formatter settings; oxfmt formats whole files, so a selection formats the file with a notice. oxlint --lsp serves JS/TS/React/Vue/Svelte files below an oxlint config. Both are managed npm tools; a missing oxfmt gets the same Install prompt and error handling as Prettier, and formatter messages are translated for either tool. Co-authored-by: d0whc3r <1378986+d0whc3r@users.noreply.github.com> --- crates/ide-host/src/formatter.rs | 50 ++++++++--- crates/ide-language/src/registry.rs | 20 ++++- crates/ide-tools/src/formatter.rs | 129 +++++++++++++++++++-------- crates/ide-tools/src/registry.rs | 30 +++++-- crates/ide-tools/tests/prettier.rs | 4 +- languages/javascript.toml | 8 ++ languages/javascriptreact.toml | 8 ++ languages/svelte.toml | 8 ++ languages/typescript.toml | 8 ++ languages/typescriptreact.toml | 8 ++ languages/vue.toml | 8 ++ src/editor/lsp/format.ts | 13 +-- src/editor/lsp/formatErrors.test.tsx | 13 +-- src/platform/locales/text/zh.json | 14 +-- 14 files changed, 246 insertions(+), 75 deletions(-) diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 7bc9af9d..054497b3 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; use ide_language::FormatterConfig; use ide_lsp::types::FormatOptions; use ide_tools::{Tools, formatter}; +use std::path::Path; + +const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"]; + +/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier +/// settings (languages, preference, on save) apply to both. +fn external_tool(file: &Path) -> &'static str { + let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); + if oxfmt { "oxfmt" } else { "prettier" } +} fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { config.is_some_and(|c| c.tool == "prettier") @@ -44,16 +54,19 @@ pub async fn format( return Err(IdeError::invalid("The file is read-only")); } let config = config.unwrap().clone(); - let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); + let tool = external_tool(&snap.path); + let name = formatter::display_name(tool); + let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier"); let tools = Tools::new(&host.local_data_dir); - let whole_selection = range.is_some() && !config.range; + let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); - let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?; - command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?); - command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets)); - let formatted = formatter::run(command, &snap.text, &Default::default())?; + let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?; + let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?; + command.current_dir(dir); + command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) .changes @@ -69,13 +82,13 @@ pub async fn format( if edits.is_ok() && whole_selection { project.sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, - title: "Prettier".into(), - body: Some( - "Prettier does not support selection formatting for this language; formatted the whole file".into(), - ), + title: name.into(), + body: Some(format!( + "{name} does not support selection formatting for this language; formatted the whole file" + )), }); } - edits.map_err(|e| e.with_tool("prettier")) + edits.map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -104,4 +117,19 @@ mod tests { assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); assert!(!external_selected(None, "rust", &settings, false)); } + + #[test] + fn oxfmt_config_selects_oxfmt_over_prettier() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("web/src")).unwrap(); + let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts")); + assert_eq!(external_tool(&web), "prettier"); + for config in OXFMT_CONFIGS { + std::fs::write(root.join("web").join(config), "{}").unwrap(); + assert_eq!(external_tool(&web), "oxfmt", "{config}"); + assert_eq!(external_tool(&api), "prettier", "{config}"); + std::fs::remove_file(root.join("web").join(config)).unwrap(); + } + } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 5ceaec74..58494359 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -576,7 +576,7 @@ mod tests { }; // After the TypeScript fallback (an available server before it would hide // it); before the HTML server (hover, rename and symbols take the first). - assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]); + assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]); assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]); for lang in ["typescript", "html"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -586,6 +586,24 @@ mod tests { } } + #[test] + fn oxlint_serves_js_files_only_below_its_config() { + let reg = reg(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); + std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + let cfg = reg.get(&LanguageId::new(lang)).unwrap(); + let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); + assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); + assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}"); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers); + assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}"); + assert_eq!(served("apps/api/main.ts"), None, "{lang}"); + } + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 9b599f0b..95dd011b 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option { file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) } -fn package_bin(package: &Path) -> IdeResult { +pub fn display_name(tool: &str) -> &str { + crate::registry::find(tool).map_or(tool, |t| t.display) +} + +fn package_bin(package: &Path, tool: &str) -> IdeResult { + let name = display_name(tool); let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) - .map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?; + .map_err(|e| IdeError::invalid(format!("{name}: {e}")))?; let bin = json["bin"] .as_str() - .or_else(|| json["bin"]["prettier"].as_str()) - .ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?; + .or_else(|| json["bin"][tool].as_str()) + .ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?; let path = package.join(bin); if !path.is_file() { - return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display()))); + return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display()))); } Ok(path) } +/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only. +pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool { + tool == "prettier" && config.range +} + impl Tools { /// Explicit executable/script, then the nearest project package, then managed tools and PATH. pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { @@ -34,22 +44,26 @@ impl Tools { } fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { - if tool != "prettier" { + if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) { return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); } + let package = Path::new("node_modules").join(tool); + let manifest = package.join("package.json"); let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) { - (package_bin(custom)?, true) + (package_bin(custom, tool)?, true) } else if let Some(custom) = custom { (custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs"))) - } else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) { - (package_bin(manifest.parent().unwrap())?, true) - } else if self.root.join("node_modules/prettier/package.json").is_file() { - (package_bin(&self.root.join("node_modules/prettier"))?, true) + } else if let Some(manifest) = find_up(file, &manifest) { + (package_bin(manifest.parent().unwrap(), tool)?, true) + } else if self.root.join(&manifest).is_file() { + (package_bin(&self.root.join(&package), tool)?, true) } else { - (self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false) + (self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false) }; if node { - let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?; + let name = display_name(tool); + let node = + self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?; let mut cmd = self.command(node); cmd.arg(path); Ok(cmd) @@ -59,34 +73,40 @@ impl Tools { } } -/// Project configuration overrides these defaults; Prettier resolves config and plugins itself. +/// Both tools resolve their configuration and plugins themselves. Prettier's +/// project configuration overrides the editor indentation passed here; oxfmt has +/// no such flags and reads its configuration and `.editorconfig` only. pub fn arguments( + tool: &str, config: &FormatterConfig, file: &Path, tab_size: u32, insert_spaces: bool, range: Option<(u32, u32)>, ) -> Vec { - let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { - "mdx" - } else { - &config.parser - }; - let mut args: Vec = [ - "--parser", - parser, - "--config-precedence", - "file-override", - "--tab-width", - &tab_size.max(1).to_string(), - "--use-tabs", - if insert_spaces { "false" } else { "true" }, - ] - .into_iter() - .map(Into::into) - .collect(); + let mut args: Vec = Vec::new(); + if tool == "prettier" { + let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { + "mdx" + } else { + &config.parser + }; + args.extend( + [ + "--parser", + parser, + "--config-precedence", + "file-override", + "--tab-width", + &tab_size.max(1).to_string(), + "--use-tabs", + if insert_spaces { "false" } else { "true" }, + ] + .map(OsString::from), + ); + } args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); - if config.range + if supports_range(tool, config) && let Some((start, end)) = range { args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); @@ -100,8 +120,8 @@ pub fn arguments( args } -pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { - crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier")) +pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -118,7 +138,7 @@ mod tests { assert_eq!(missing.kind, ErrorKind::NotFound); assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap(); - let failure = run(custom, "text", &Default::default()).unwrap_err(); + let failure = run("prettier", custom, "text", &Default::default()).unwrap_err(); assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -127,7 +147,7 @@ mod tests { fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() { let mut command = Command::new("sh"); command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]); - let failure = run(command, "", &Default::default()).unwrap_err(); + let failure = run("prettier", command, "", &Default::default()).unwrap_err(); assert_eq!(failure.message, "error\n".repeat(1000)); assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -161,7 +181,8 @@ mod tests { ("vue", true), ] { let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; - let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let args = + arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect(); assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"])); assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"])); @@ -171,4 +192,36 @@ mod tests { assert!(!args.contains(&"--write".into())); } } + + #[test] + fn oxfmt_runs_the_nearest_package_without_prettier_flags() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap(); + std::fs::write(root.join(".prettierignore"), "dist").unwrap(); + let file = root.join("packages/web/src/app.ts"); + let path = std::env::var_os("PATH").unwrap_or_default(); + let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current()); + + let command = tools.formatter_command("oxfmt", &file, None).unwrap(); + let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); + assert_eq!(program, "node"); + let script: Vec<_> = command.get_args().collect(); + assert_eq!(script, [root.join("node_modules/oxfmt/bin/oxfmt").as_os_str()]); + + let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; + let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); + let ignore = root.join(".prettierignore").into_os_string(); + assert_eq!( + args[..4], + ["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore] + ); + assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}"); + assert!(!supports_range("oxfmt", &config)); + + let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err(); + assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message); + } } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 6dfcd5df..661a7170 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[ needs: NODE, homepage: "https://prettier.io", }, + ToolSpec { + name: "oxfmt", + display: "oxfmt", + kind: Formatter, + commands: &["oxfmt"], + method: Method::Npm { packages: &["oxfmt"] }, + fallback: None, + version: ver("oxfmt"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/formatter", + }, + ToolSpec { + name: "oxlint", + display: "oxlint", + kind: LanguageServer, + commands: &["oxlint"], + method: Method::Npm { packages: &["oxlint"] }, + fallback: None, + version: ver("oxlint"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/linter", + }, ToolSpec { name: "rust-analyzer", display: "rust-analyzer", @@ -965,12 +987,8 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - for l in &langs { - if let Some(f) = &l.formatter { - known.insert((f.tool.clone(), f.tool.clone())); - } - } - for t in TOOLS.iter().filter(|t| t.kind != Plugin) { + // The host selects the formatter of each file (Prettier or oxfmt). + for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs index 3d622caa..c09f6d59 100644 --- a/crates/ide-tools/tests/prettier.rs +++ b/crates/ide-tools/tests/prettier.rs @@ -19,8 +19,8 @@ fn format( }; let mut cmd = tools.formatter_command("prettier", path, None)?; cmd.current_dir(path.parent().unwrap()); - cmd.args(formatter::arguments(&config, path, 4, true, range)); - formatter::run(cmd, text, &CancellationToken::new()) + cmd.args(formatter::arguments("prettier", &config, path, 4, true, range)); + formatter::run("prettier", cmd, text, &CancellationToken::new()) } #[test] diff --git a/languages/javascript.toml b/languages/javascript.toml index b5676537..5f19df86 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -27,6 +27,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 3c5a1776..be5b5ab9 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/svelte.toml b/languages/svelte.toml index 41095ee1..b441d410 100644 --- a/languages/svelte.toml +++ b/languages/svelte.toml @@ -13,3 +13,11 @@ command = "svelteserver" args = ["--stdio"] root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"] install-hint = "npm i -g svelte-language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/languages/typescript.toml b/languages/typescript.toml index 41817c5c..0dedc17a 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"] require-root-marker = true install-hint = "npm i -g @angular/language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ad238c6c..d7ecf0b8 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/vue.toml b/languages/vue.toml index e260bce7..247f0930 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -22,3 +22,11 @@ command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g @vtsls/language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 061e377c..5d3031c3 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -118,19 +118,22 @@ async function format( try { edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); } catch (e) { - if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") { + if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) { + const tool = e.data.tool; if (e.kind === "notFound" && e.data.missing) { - await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave }); + await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave }); } else if (e.kind !== "cancelled") { notifications().notify({ group: "tools", level: "error", - title: tx("Prettier formatting failed"), + title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"), body: e.kind === "timedOut" - ? tx("Prettier formatting timed out") + ? tool === "oxfmt" + ? tx("oxfmt formatting timed out") + : tx("Prettier formatting timed out") : createElement("span", { className: styles.diagnostics }, e.message), - key: `prettier-format-error:${doc.path}`, + key: `${tool}-format-error:${doc.path}`, sticky: false, }); } diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx index aa21c997..3107316b 100644 --- a/src/editor/lsp/formatErrors.test.tsx +++ b/src/editor/lsp/formatErrors.test.tsx @@ -18,7 +18,6 @@ import { import { formatDocument } from "./format"; import * as lsp from "./lsp"; -const tool = { type: "tool", tool: "prettier", missing: false } as const; const views: EditorView[] = []; const viewFor = (path = "/project/file.css") => { const view = new EditorView({ @@ -43,7 +42,9 @@ afterEach(() => { _resetI18nForTests(); }); -describe("external formatter errors", () => { +describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { + const tool = { type: "tool", tool: name, missing: false } as const; + const display = name === "prettier" ? "Prettier" : name; it("uses structured missing-tool information to request the shared install prompt", async () => { const prompt = vi.fn(); const dispose = registerAction({ @@ -54,9 +55,9 @@ describe("external formatter errors", () => { try { failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); await formatDocument(viewFor()); - expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true }); + expect(prompt).toHaveBeenCalledWith({ name, sticky: true }); await formatDocument(viewFor(), { explicit: false, onSave: true }); - expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false }); + expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false }); expect(useNotifications.getState().items).toHaveLength(0); } finally { dispose(); @@ -95,7 +96,7 @@ describe("external formatter errors", () => { for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true }); const notes = useNotifications.getState().items; expect(notes).toHaveLength(1); - expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" }); + expect(notes[0]).toMatchObject({ sticky: false, title: `${display} 格式化失败` }); expect(balloonTimeout(notes[0]!)).toBe(10000); render(); expect(screen.getByRole("alert")).toHaveTextContent(stderr); @@ -115,7 +116,7 @@ describe("external formatter errors", () => { const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool }); const view = viewFor(); await formatDocument(view); - expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false }); + expect(useNotifications.getState().items[0]).toMatchObject({ body: `${display} 格式化超时`, sticky: false }); clearNotifications(); request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool })); await formatDocument(view); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index fec979fe..811644d7 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1614,16 +1614,18 @@ "Install Missing Tool": "安装缺失的工具", "Prettier formatting failed": "Prettier 格式化失败", "Prettier formatting timed out": "Prettier 格式化超时", - "Prettier does not support selection formatting for this language; formatted the whole file": "Prettier 不支持此语言的选区格式化,已格式化整个文件", + "oxfmt formatting failed": "oxfmt 格式化失败", + "oxfmt formatting timed out": "oxfmt 格式化超时", + "{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件", "Formatting timed out": "格式化超时", - "Prettier: Node.js is required": "Prettier:需要 Node.js", + "{name}: Node.js is required": "{name}:需要 Node.js", "Formatter": "格式化工具", "JSON with Comments": "带注释的 JSON", "Unknown external formatter: {tool}": "未知的外部格式化工具:{tool}", - "Prettier: package has no CLI entry point": "Prettier:软件包未提供命令行入口", - "Prettier: CLI does not exist: {path}": "Prettier:命令行程序不存在:{path}", - "Prettier: file has no directory": "Prettier:文件路径不含目录", - "Prettier: {error}": "Prettier:{error}", + "{name}: package has no CLI entry point": "{name}:软件包未提供命令行入口", + "{name}: CLI does not exist: {path}": "{name}:命令行程序不存在:{path}", + "{name}: file has no directory": "{name}:文件路径不含目录", + "{name}: {error}": "{name}:{error}", "Reformat File": "重新格式化文件", "Reformat code": "重新格式化代码", "Reformat code on save": "保存时重新格式化代码", From 743612d4ec3adc6a16ac660f180e8c5643a276d3 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 12:05:25 +0800 Subject: [PATCH 024/108] fix(java): let jdtls read jars JDK 25 rejects (#771) jdtls runs on JDK 25, whose ZipFile validates the ZIP64 extra field and rejects jars written by older tools (aspectjweaver 1.8.10: "Invalid CEN header (invalid zip64 extra data field size)"). ECJ then reports the dependency as unreadable, the module gets a build path error and never compiles, and running its main class fails with a missing class. Pass -Djdk.util.zip.disableZip64ExtraFieldValidation=true to jdtls unless the user set the property; set_jvm_property now owns the set-once, user-value-wins rule shared with the proxy properties. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- crates/ide-sdk/src/java.rs | 59 ++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 21b217dc..814ccf7d 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -313,21 +313,36 @@ fn text_key(text: &str) -> String { format!("{h:016x}") } +/// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it +/// carries: a value of theirs wins, and a second call adds nothing. +fn set_jvm_property(args: &mut Vec, arg: &str) { + let key = arg.split('=').next().unwrap_or(arg); + let taken = args.iter().enumerate().any(|(i, a)| { + let value = + a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); + value.is_some_and(|a| a.split('=').next() == Some(key)) + }); + if !taken { + args.push(format!("--jvm-arg={arg}")); + } +} + fn add_jdtls_proxy_args(args: &mut Vec, proxy: &ide_core::proxy_settings::ProxySettings) { for arg in proxy.jvm_args() { - let key = arg.split('=').next().unwrap_or(arg.as_str()); - if !args.iter().enumerate().any(|(i, a)| { - let value = - a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); - value.is_some_and(|a| a.split('=').next() == Some(key)) - }) { - args.push(format!("--jvm-arg={arg}")); - } + set_jvm_property(args, &arg); } } const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot"; +/// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools +/// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is +/// not a valid ZIP file", the project using it gets a build path error, and every project +/// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven +/// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told +/// to behave the same. +const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true"; + /// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data /// directory, one per server root, and no Eclipse metadata is written into /// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its @@ -384,6 +399,10 @@ pub fn configure_jdtls( // of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system // properties. One the user set in their own JVM arguments wins. add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy()); + // ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25 + // refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21 + // accepts the same file. + set_jvm_property(&mut cfg.args, ZIP64_LENIENCY); let managed = language_server_runtimes_dir(data_dir); let managed = dunce::canonicalize(&managed).unwrap_or(managed); let project_jdks: Vec<_> = project_jdk @@ -664,6 +683,11 @@ mod tests { assert_eq!(cfg.args[n], "-data"); assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]); assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); + assert!( + cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"), + "jars JDK 25 rejects must still load: {:?}", + cfg.args + ); let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap(); let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" })); @@ -751,6 +775,25 @@ mod tests { } } + #[test] + fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() { + // aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the + // JVM that runs jdtls that is a build path error in every project using it. + let mut args = Vec::new(); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args.len(), 1, "added once"); + for user in [ + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(), + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(), + ] { + let mut own = vec![user.clone()]; + set_jvm_property(&mut own, ZIP64_LENIENCY); + assert_eq!(own, [user], "the user decides"); + } + } + #[test] fn jdtls_lombok_agent() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; From 8e8a998abe37758a073923424239df7b49e89815 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:11:12 +0800 Subject: [PATCH 025/108] feat(vcs): inline blame for the current line (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the current line's author, relative date and commit summary as a dimmed hint after the line end, with the full message, date and hash on hover and the commit opened on click. Uncommitted lines read "You, now · Uncommitted changes". Reuses the blame command with a per-version cache and a debounced caret; skipped outside repositories, for untracked/ignored and very large files. Off by default: Settings › Version Control › Git › Show inline blame for the current line. Fixes #742. --- crates/ide-core/src/workflow_settings.rs | 3 + src/bindings.ts | 2 + src/editor/api.ts | 3 + src/editor/core/cm/setup.ts | 5 +- src/features/settings/pages/vcs.tsx | 1 + .../vcs/__tests__/inlineBlame.test.ts | 255 ++++++++++++++++ src/features/vcs/index.ts | 3 +- src/features/vcs/inlineBlame.ts | 281 ++++++++++++++++++ src/platform/clientSettings.ts | 1 + src/platform/locales/text/zh.json | 5 +- src/platform/workflowSettings.test.ts | 6 + src/platform/workflowSettings.ts | 3 + 12 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 src/features/vcs/__tests__/inlineBlame.test.ts create mode 100644 src/features/vcs/inlineBlame.ts diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 64fb58fb..3b31a576 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -41,6 +41,8 @@ impl Default for VcsSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct GitSettings { + /// Show blame after the current editor line. + pub inline_blame: bool, /// Path to the git executable; empty = auto-detect. pub executable: String, /// Default of Update Project: `merge` | `rebase`. @@ -71,6 +73,7 @@ pub struct GitSettings { impl Default for GitSettings { fn default() -> Self { Self { + inline_blame: false, executable: String::new(), update_method: "merge".into(), auto_update_on_push_rejected: false, diff --git a/src/bindings.ts b/src/bindings.ts index e2eb8a2a..7c4a2d95 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3350,6 +3350,8 @@ export type GitOperation = { op: "interactiveRebase"; base: string | null; entries: RebaseEntry[] }; export type GitSettings = { + /** Show blame after the current editor line. */ + inlineBlame?: boolean, /** Path to the git executable; empty = auto-detect. */ executable?: string, /** Default of Update Project: `merge` | `rebase`. */ diff --git a/src/editor/api.ts b/src/editor/api.ts index 9f0251cb..9d1e81e0 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -32,6 +32,9 @@ import type { LanguageDescription } from "@codemirror/language"; import { create } from "zustand"; import { maybeEditorService } from "@/workbench/contributions"; +/** Documents above this size (UTF-16 units) skip per-line decorations. */ +export const HEAVY_LIMIT = 8_000_000; + export interface EditorDocContext { doc: number; path: string; diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index bcecc965..059b6c13 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -28,7 +28,7 @@ import type { EditorConfigProps } from "@/bindings"; import type { EditorSettings } from "@/platform/ipc"; import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings"; import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts"; -import { editorContributions, type EditorDocContext } from "@/editor/api"; +import { editorContributions, HEAVY_LIMIT, type EditorDocContext } from "@/editor/api"; import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree"; import { openideaHighlightStyle } from "./highlight"; import { openideaTheme } from "./theme"; @@ -78,8 +78,7 @@ export function prefsOf(settings: EditorSettings | undefined): EditorExtras { return editorPrefs({ editor: settings }); } -/** Documents above this size (UTF-16 units) skip per-line decorations. */ -export const HEAVY_LIMIT = 8_000_000; +export { HEAVY_LIMIT } from "@/editor/api"; export interface EffectiveIndent { tabSize: number; diff --git a/src/features/settings/pages/vcs.tsx b/src/features/settings/pages/vcs.tsx index 225095a0..db087571 100644 --- a/src/features/settings/pages/vcs.tsx +++ b/src/features/settings/pages/vcs.tsx @@ -492,6 +492,7 @@ export const VCS_PAGES: PageSpec[] = [ keywords: ["Test", "git version"], component: GitExecutableRow, }, + { kind: "toggle", key: "vcs.git.inlineBlame", label: "Show inline blame for the current line" }, ], }, { diff --git a/src/features/vcs/__tests__/inlineBlame.test.ts b/src/features/vcs/__tests__/inlineBlame.test.ts new file mode 100644 index 00000000..e49cb3de --- /dev/null +++ b/src/features/vcs/__tests__/inlineBlame.test.ts @@ -0,0 +1,255 @@ +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView, activateHover, closeHoverTooltips, hasHoverTooltips } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BlameLine, Settings } from "@/bindings"; +import { HEAVY_LIMIT, _emitEditorEvent } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import { useI18n } from "@/platform/i18n"; +import { formatInlineBlame, inlineBlame, relativeBlameDate } from "../inlineBlame"; +import { useVcs } from "../store"; + +const mocks = vi.hoisted(() => ({ blame: vi.fn(), file: vi.fn(), log: vi.fn(), action: vi.fn() })); +vi.mock("@/platform/actions", async (original) => ({ + ...(await original()), + executeAction: mocks.action, +})); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, vcsBlame: mocks.blame, vcsFileAt: mocks.file, vcsLog: mocks.log }, + }; +}); + +const now = Date.UTC(2026, 10, 2, 12); +const alice: BlameLine = { + commit: "a".repeat(40), + author: "Alice Smith", + timeMs: now - 86400_000, + summary: "First line", +}; +const bob: BlameLine = { commit: "b".repeat(40), author: "Bob Jones", timeMs: now - 3600_000, summary: "Second line" }; +const uncommitted: BlameLine = { commit: "0".repeat(40), author: "Not Committed Yet", timeMs: now, summary: "" }; +const ctx = { doc: 1, path: "/repo/file.ts", language: "typescript", uiMode: null, readOnly: false }; +let view: EditorView | undefined; + +function settings(enabled: boolean) { + useProject.setState({ settings: { vcs: { git: { inlineBlame: enabled } } } as Settings }); +} + +function editor(doc = "one\ntwo\nthree", context = ctx) { + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc, extensions: [inlineBlame(context)] }), + }); + return view; +} + +const hint = () => view?.dom.querySelector(".cm-vcs-inline-blame"); +const tick = () => vi.advanceTimersByTimeAsync(300); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + vi.clearAllMocks(); + useI18n.setState({ locale: "en", texts: {} }); + settings(true); + useVcs.setState({ available: true, roots: ["/repo"], statusLoaded: true, byPath: new Map(), version: 0 }); + mocks.file.mockResolvedValue({ status: "ok", data: "one\ntwo\nthree" }); + mocks.blame.mockResolvedValue({ status: "ok", data: [alice, bob, uncommitted] }); +}); + +afterEach(() => { + view?.destroy(); + view = undefined; + useProject.setState({ settings: null }); + vi.useRealTimers(); +}); + +describe("inline blame formatting", () => { + it("formats authors, relative dates, summaries and missing dates", () => { + expect(formatInlineBlame(alice, now)).toBe("Alice Smith, 1 day ago · First line"); + expect(formatInlineBlame({ ...bob, timeMs: null, summary: "Long\nmessage" }, now)).toBe("Bob Jones · Long message"); + expect(formatInlineBlame(uncommitted, now)).toBe("You, now · Uncommitted changes"); + expect(relativeBlameDate(now + 1000, now)).toBe("now"); + expect(relativeBlameDate(now - 120_000, now)).toBe("2 minutes ago"); + expect(relativeBlameDate(now - 400 * 86400_000, now)).toBe("1 year ago"); + }); +}); + +describe("current line decoration", () => { + it("debounces caret moves and reuses blame for the same document version", async () => { + const v = editor(); + await vi.advanceTimersByTimeAsync(299); + expect(mocks.blame).not.toHaveBeenCalled(); + v.dispatch({ selection: { anchor: 4 } }); + await vi.advanceTimersByTimeAsync(299); + expect(mocks.blame).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(hint()?.textContent).toContain("Bob Jones"); + v.dispatch({ selection: { anchor: 0 } }); + expect(hint()).toBeNull(); + await tick(); + expect(hint()?.textContent).toContain("Alice Smith"); + expect(mocks.blame).toHaveBeenCalledTimes(1); + expect(mocks.file).toHaveBeenCalledTimes(1); + expect(hint()?.contentEditable).toBe("false"); + hint()?.click(); + expect(mocks.action).toHaveBeenCalledWith("vcs.showCommit", alice.commit); + }); + + it("uses only the primary selection and hides multiline selections", async () => { + const v = editor(); + v.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(4)], 1) }); + await tick(); + expect(hint()?.textContent).toContain("Bob Jones"); + v.dispatch({ selection: { anchor: 0, head: 5 } }); + await tick(); + expect(hint()).toBeNull(); + v.dispatch({ selection: { anchor: 4, head: 6 } }); + await tick(); + expect(hint()?.textContent).toContain("Bob Jones"); + }); + + it("loads and caches the full commit message on hover and closes it on caret movement", async () => { + const message = "First line\n\nThe complete commit body."; + mocks.log.mockResolvedValue({ status: "ok", data: [{ message }] }); + const v = editor(); + await tick(); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(hasHoverTooltips(v.state)).toBe(true); + const tooltip = v.dom.querySelector(".cm-vcs-inline-blame-tooltip"); + expect(tooltip?.textContent).toContain(message); + expect(tooltip?.textContent).toContain(alice.author); + expect(tooltip?.textContent).toContain(alice.commit); + expect(tooltip?.textContent).toContain(new Date(alice.timeMs!).toLocaleString()); + v.dispatch({ effects: closeHoverTooltips }); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.log).toHaveBeenCalledTimes(1); + v.dispatch({ selection: { anchor: 4 } }); + expect(hasHoverTooltips(v.state)).toBe(false); + }); + + it("closes an open tooltip when the setting is disabled", async () => { + mocks.log.mockResolvedValue({ status: "ok", data: [{ message: "Full message" }] }); + const v = editor(); + await tick(); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(hasHoverTooltips(v.state)).toBe(true); + settings(false); + expect(hasHoverTooltips(v.state)).toBe(false); + }); + + it("refetches edits, saves and VCS refreshes", async () => { + const v = editor(); + await tick(); + mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted, bob, uncommitted] }); + v.dispatch({ changes: { from: 0, insert: "x" } }); + expect(hint()).toBeNull(); + await tick(); + expect(mocks.blame).toHaveBeenLastCalledWith(ctx.path, "xone\ntwo\nthree", null); + expect(hint()?.textContent).toBe("You, now · Uncommitted changes"); + _emitEditorEvent({ type: "docSaved", doc: 1, path: ctx.path }); + await tick(); + useVcs.setState({ version: 1 }); + await tick(); + expect(mocks.blame).toHaveBeenCalledTimes(4); + }); + + it("discards a response from an older document version", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + const v = editor(); + await tick(); + v.dispatch({ changes: { from: 0, insert: "x" } }); + resolve({ status: "ok", data: [alice] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()).toBeNull(); + mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted] }); + await tick(); + expect(hint()?.textContent).toBe("You, now · Uncommitted changes"); + }); + + it("coalesces an in-flight blame when the caret moves", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + const v = editor(); + await tick(); + v.dispatch({ selection: { anchor: 4 } }); + await tick(); + resolve({ status: "ok", data: [alice, bob] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()?.textContent).toContain("Bob Jones"); + expect(mocks.blame).toHaveBeenCalledTimes(1); + }); + + it("responds to the setting immediately", async () => { + settings(false); + editor(); + await tick(); + expect(mocks.blame).not.toHaveBeenCalled(); + settings(true); + await tick(); + expect(hint()).not.toBeNull(); + settings(false); + expect(hint()).toBeNull(); + await tick(); + expect(mocks.blame).toHaveBeenCalledTimes(1); + }); + + it("ignores a pending response after disabling", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + editor(); + await tick(); + settings(false); + resolve({ status: "ok", data: [alice] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()).toBeNull(); + await tick(); + expect(hint()).toBeNull(); + }); + + it.each(["untracked", "ignored"] as const)("skips %s paths", async (unstaged) => { + useVcs.setState({ + byPath: new Map([[ctx.path, { path: ctx.path, relPath: "file.ts", unstaged, staged: null, renamedFrom: null }]]), + }); + editor(); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + expect(hint()).toBeNull(); + }); + + it("skips ignored paths missing from status and HEAD", async () => { + mocks.file.mockResolvedValue({ status: "ok", data: null }); + editor(); + await tick(); + expect(mocks.blame).not.toHaveBeenCalled(); + expect(hint()).toBeNull(); + }); + + it("skips paths outside repositories", async () => { + editor("one", { ...ctx, path: "/elsewhere/file.ts" }); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + }); + + it("skips large, virtual and read-only documents", async () => { + editor("x".repeat(HEAVY_LIMIT + 1)); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + expect(inlineBlame({ ...ctx, virtual: true })).toEqual([]); + expect(inlineBlame({ ...ctx, readOnly: true })).toEqual([]); + }); + + it("cancels pending work when the view is destroyed", async () => { + editor().destroy(); + view = undefined; + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 80198517..9e74b30c 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -23,6 +23,7 @@ import { registerHistoryActions, trackOperationContext } from "./historyActions" import { registerLogPaneActions } from "./logPaneActions"; import { controlLabel, controlOperation, inProgress } from "./operations"; import { annotationsShown, blameSlot } from "./blame"; +import { inlineBlame } from "./inlineBlame"; import { BranchWidget, useBranchesPopup } from "./BranchWidget"; import { showConflictsDialog } from "./conflictsUi"; import { registerCompareActions } from "./compare"; @@ -212,7 +213,7 @@ export function activate(): () => void { registerEditorContribution({ id: "vcs.changeMarkers", order: 60, - extensions: (ctx) => [changeMarkers(ctx), blameSlot()], + extensions: (ctx) => [changeMarkers(ctx), blameSlot(), inlineBlame(ctx)], }), registerMenuGroup({ id: "vcs.gutter", diff --git a/src/features/vcs/inlineBlame.ts b/src/features/vcs/inlineBlame.ts new file mode 100644 index 00000000..047b3136 --- /dev/null +++ b/src/features/vcs/inlineBlame.ts @@ -0,0 +1,281 @@ +import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { + Decoration, + EditorView, + ViewPlugin, + WidgetType, + hoverTooltip, + type DecorationSet, + type ViewUpdate, +} from "@codemirror/view"; +import type { BlameLine } from "@/bindings"; +import { HEAVY_LIMIT, onEditorEvent, type EditorDocContext } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { tx, useI18n } from "@/platform/i18n"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings"; +import { isUncommitted } from "./blame"; +import { repoOf, statusOf, useVcs } from "./store"; + +export function relativeBlameDate(timeMs: number | null, now = Date.now()): string { + if (timeMs === null) return ""; + const seconds = Math.max(0, (now - timeMs) / 1000); + const units = [ + ["year", 365 * 86400], + ["month", 30 * 86400], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ] as const; + const unit = units.find(([, size]) => seconds >= size); + return unit + ? new Intl.RelativeTimeFormat(useI18n.getState().locale, { numeric: "always" }).format( + -Math.floor(seconds / unit[1]), + unit[0], + ) + : tx("now"); +} + +export function formatInlineBlame(line: BlameLine, now = Date.now()): string { + if (isUncommitted(line)) return tx("You, now · Uncommitted changes"); + const date = relativeBlameDate(line.timeMs, now); + return `${line.author}${date ? `, ${date}` : ""} · ${line.summary.replace(/\s+/g, " ")}`; +} + +/** Only the current document version is retained, including its in-flight request. */ +class BlameCache { + private doc: Text | undefined; + private pending: Promise | undefined; + + clear() { + this.doc = undefined; + this.pending = undefined; + } + + get(doc: Text, load: () => Promise): Promise { + if (doc !== this.doc || !this.pending) { + this.doc = doc; + this.pending = load(); + } + return this.pending; + } +} + +class BlameWidget extends WidgetType { + constructor(readonly line: BlameLine) { + super(); + } + + eq(other: BlameWidget) { + return formatInlineBlame(other.line) === formatInlineBlame(this.line) && other.line.commit === this.line.commit; + } + + toDOM() { + const el = document.createElement("span"); + el.className = "cm-vcs-inline-blame"; + el.textContent = formatInlineBlame(this.line); + el.contentEditable = "false"; + if (!isUncommitted(this.line)) { + el.setAttribute("role", "button"); + el.tabIndex = 0; + const open = () => void executeAction("vcs.showCommit", this.line.commit); + el.addEventListener("mousedown", (e) => e.preventDefault()); + el.addEventListener("click", open); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open(); + } + }); + } + return el; + } +} + +const setInlineBlame = StateEffect.define(); +const inlineBlameField = StateField.define({ + create: () => Decoration.none, + update(value, tr) { + if (tr.docChanged || tr.selection) return Decoration.none; + for (const effect of tr.effects) if (effect.is(setInlineBlame)) return effect.value; + return value; + }, + provide: (field) => EditorView.decorations.from(field), +}); + +export function inlineBlame(ctx: EditorDocContext): Extension { + if (ctx.virtual || ctx.readOnly) return []; + const plugin = ViewPlugin.fromClass( + class { + private timer: ReturnType | undefined; + private readonly cache = new BlameCache(); + private readonly messages = new Map>(); + private readonly off: (() => void)[]; + private generation = 0; + private destroyed = false; + line: BlameLine | undefined; + + constructor(readonly view: EditorView) { + this.off = [ + useVcs.subscribe((s, prev) => { + if (s.version !== prev.version) this.invalidate(); + }), + onEditorEvent((e) => { + if (e.type === "docSaved" && e.path === ctx.path) this.invalidate(); + }), + onWorkflowSettingsChange( + (s) => s.vcs.git.inlineBlame, + () => this.invalidate(), + ), + useI18n.subscribe(() => this.invalidate()), + ]; + this.schedule(); + } + + private enabled() { + const vcs = useVcs.getState(); + const status = statusOf(ctx.path); + return ( + workflowSettings().vcs.git.inlineBlame && + isTauri() && + vcs.available && + vcs.statusLoaded && + !!repoOf(ctx.path) && + status?.unstaged !== "untracked" && + status?.unstaged !== "ignored" && + this.view.state.doc.length <= HEAVY_LIMIT + ); + } + + private clear() { + this.line = undefined; + if (this.view.state.field(inlineBlameField).size) + this.view.dispatch({ effects: setInlineBlame.of(Decoration.none) }); + } + + private invalidate() { + this.cache.clear(); + this.clear(); + this.schedule(); + } + + update(update: ViewUpdate) { + if (update.docChanged) this.cache.clear(); + if (update.docChanged || update.selectionSet) { + this.line = undefined; + this.schedule(); + } + } + + private schedule() { + clearTimeout(this.timer); + ++this.generation; + this.timer = setTimeout(() => void this.refresh(), 300); + } + + private async refresh() { + if (this.destroyed || !this.enabled()) return; + const { doc, selection } = this.view.state; + const caret = doc.lineAt(selection.main.head); + if (doc.lineAt(selection.main.from).number !== doc.lineAt(selection.main.to).number) return; + const generation = this.generation; + try { + const lines = await this.cache.get(doc, async () => { + // Blame also returns synthetic entries for paths absent from HEAD (ignored / new files). + if ((await call(commands.vcsFileAt(ctx.path, "HEAD"))) === null) return []; + return call(commands.vcsBlame(ctx.path, doc.toString(), null)); + }); + if (this.destroyed || generation !== this.generation || doc !== this.view.state.doc || !this.enabled()) + return; + this.line = lines[caret.number - 1]; + if (this.line) { + const widget = Decoration.widget({ widget: new BlameWidget(this.line), side: 1 }).range(caret.to); + this.view.dispatch({ effects: setInlineBlame.of(Decoration.set([widget])) }); + } + } catch { + // Repository removed, unborn HEAD, inaccessible file, etc.: leave the line undecorated. + } + } + + message(line: BlameLine): Promise { + if (isUncommitted(line)) return Promise.resolve(tx("You, now · Uncommitted changes")); + let message = this.messages.get(line.commit); + if (!message) { + message = call( + commands.vcsLog(repoOf(ctx.path), { + branch: line.commit, + path: null, + author: null, + text: null, + skip: 0, + limit: 1, + }), + ).then( + (commits) => commits[0]?.message ?? line.summary, + () => line.summary, + ); + if (this.messages.size >= 100) this.messages.delete(this.messages.keys().next().value!); + this.messages.set(line.commit, message); + } + return message; + } + + destroy() { + this.destroyed = true; + clearTimeout(this.timer); + this.cache.clear(); + this.off.forEach((off) => off()); + } + }, + ); + return [ + inlineBlameField, + plugin, + hoverTooltip( + async (view, pos, side) => { + const instance = view.plugin(plugin); + const line = instance?.line; + const end = view.state.doc.lineAt(view.state.selection.main.head).to; + if (!instance || !line || pos !== end || side < 0) return null; + const message = await instance.message(line); + if (view.plugin(plugin)?.line !== line) return null; + return { + pos: end, + above: true, + create() { + const dom = document.createElement("div"); + dom.className = "cm-vcs-inline-blame-tooltip"; + dom.textContent = isUncommitted(line) + ? message + : `${line.author}\n${line.timeMs === null ? "" : new Date(line.timeMs).toLocaleString()}\n${line.commit}\n\n${message}`; + return { dom }; + }, + }; + }, + { + hideOnChange: true, + hideOn: (tr) => tr.effects.some((effect) => effect.is(setInlineBlame)), + }, + ), + EditorView.baseTheme({ + ".cm-vcs-inline-blame": { + color: "var(--fg-inlay)", + marginLeft: "3ch", + display: "inline-block", + maxWidth: "min(60ch, 50vw)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + verticalAlign: "bottom", + userSelect: "none", + }, + '.cm-vcs-inline-blame[role="button"]': { cursor: "pointer" }, + ".cm-vcs-inline-blame-tooltip": { + padding: "8px 12px", + maxWidth: "min(70ch, 80vw)", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + }, + }), + ]; +} diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index b2c2c83b..742e797d 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -77,6 +77,7 @@ export const CLIENT_SETTINGS_KEYS = [ "run.confirmTerminate", "run.showOnStart", "vcs.showDirtyDirectories", + "vcs.git.inlineBlame", "vcs.git.showPushDialog", "vcs.git.pushDialogOnlyProtected", "vcs.git.combineStashAndShelf", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 811644d7..844d5081 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5497,5 +5497,8 @@ "Cannot paste image": "无法粘贴图片", "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", - "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。" + "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", + "Show inline blame for the current line": "为当前行显示内联 Git Blame", + "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", + "now": "刚刚" } diff --git a/src/platform/workflowSettings.test.ts b/src/platform/workflowSettings.test.ts index 1721e2ca..d12da7d5 100644 --- a/src/platform/workflowSettings.test.ts +++ b/src/platform/workflowSettings.test.ts @@ -3,6 +3,12 @@ import { splitCommandLine } from "./commandLine"; import { DEFAULT_WORKFLOW, validateWorkflow } from "./workflowSettings"; describe("validateWorkflow", () => { + it("defaults inline blame off and accepts only boolean values", () => { + expect(DEFAULT_WORKFLOW.vcs.git.inlineBlame).toBe(false); + expect(validateWorkflow({ vcs: { git: { inlineBlame: true } } }).vcs.git.inlineBlame).toBe(true); + expect(validateWorkflow({ vcs: { git: { inlineBlame: "true" } } }).vcs.git.inlineBlame).toBe(false); + }); + it("falls back to defaults for missing or malformed values", () => { expect(validateWorkflow(undefined)).toEqual(DEFAULT_WORKFLOW); const v = validateWorkflow({ diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 42871480..559d20d9 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -18,6 +18,7 @@ export interface IssueLinkValid { } export interface GitSettingsValid { + inlineBlame: boolean; executable: string; updateMethod: UpdateMethod; autoUpdateOnPushRejected: boolean; @@ -183,6 +184,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { limitHistory: true, historyLimit: 1000, git: { + inlineBlame: false, executable: "", updateMethod: "merge", autoUpdateOnPushRejected: false, @@ -309,6 +311,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid { limitHistory: bool(v.limitHistory, d.limitHistory), historyLimit: num(v.historyLimit, d.historyLimit, L.historyLimit, true), git: { + inlineBlame: bool(g.inlineBlame, d.git.inlineBlame), executable: text(g.executable, d.git.executable).trim(), updateMethod: oneOf(g.updateMethod, ["merge", "rebase"] as const, d.git.updateMethod), autoUpdateOnPushRejected: bool(g.autoUpdateOnPushRejected, d.git.autoUpdateOnPushRejected), From 6184330876df74b9c3eade84fedbedaa11f847ec Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 14:33:11 +0800 Subject: [PATCH 026/108] test(tools): compare the oxfmt script path by components on every platform (#786) package_bin joins the manifest's bin entry (bin/oxfmt) onto a platform path, so on Windows the separators differ from the expected path and the oxfmt formatter test failed. Compare path components instead. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-tools/src/formatter.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 95dd011b..b6a7a692 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -209,7 +209,12 @@ mod tests { let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); assert_eq!(program, "node"); let script: Vec<_> = command.get_args().collect(); - assert_eq!(script, [root.join("node_modules/oxfmt/bin/oxfmt").as_os_str()]); + // Compare components: `package_bin` joins the manifest's `bin` entry + // (`bin/oxfmt`) onto a platform path, so separators differ on Windows. + assert_eq!( + Path::new(&script[0]).components().collect::>(), + root.join("node_modules/oxfmt/bin/oxfmt").components().collect::>() + ); let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); From 95188d9f40598172c698e2b19a136ec1fb9462d6 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 14:33:14 +0800 Subject: [PATCH 027/108] fix(vcs): fall back to plain blame when git rejects --contents with a revision (#787) Git before 2.41 refuses `blame --contents - ` ("cannot use --contents with final commit object name"), so blame failed outright on e.g. Ubuntu 22.04's git 2.34. On that error, blame the revision plainly and mark the edited lines as uncommitted from the diff against it, the same path restricted mode already uses. Fixes #755. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-vcs/src/write.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index e31701be..4e4f9283 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -89,15 +89,28 @@ pub(crate) fn blame( return Ok(uncommitted_lines(line_count)); } // Restricted blame reads committed blobs only. Feeding working-tree bytes to - // Git's --contents path would run configured clean filters. - let out = if crate::execution_allowed() { - run( - r.root(), - &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel], - Some(&contents), - )? + // Git's --contents path would run configured clean filters, so it blames the + // revision plain. Git learned to combine `--contents` with a revision in 2.41; + // older releases reject the pair (`fatal: cannot use --contents with final + // commit object name`) and take the same plain path — the edited lines are + // marked uncommitted below either way (issue #755). + let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel]; + let plain_args: &[&str] = if crate::execution_allowed() { + &["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel] } else { - run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)? + &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel] + }; + let (out, contents_blame) = if crate::execution_allowed() { + let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?; + if out.code == Some(0) { + (out, true) + } else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") { + (run(r.root(), plain_args, None)?, false) + } else { + return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code)); + } + } else { + (run(r.root(), plain_args, None)?, false) }; let mut lines = crate::blame::parse_porcelain(&out.stdout_str()); for line in &mut lines { @@ -106,7 +119,7 @@ pub(crate) fn blame( line.summary = String::new(); } } - if !crate::execution_allowed() { + if !contents_blame { let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default(); for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() { let start = (hunk.old_start as usize).min(lines.len()); From 193e6a4840c928f6d90b3ba4ddba6afca7476c6a Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:07 +0700 Subject: [PATCH 028/108] feat(run): colour the console of a Spring Boot run (#781) Spring Boot configurations get -Dspring.output.ansi.enabled=always: the Run console renders ANSI colours, but Spring only enables them for a terminal and the process runs on pipes. A configuration that sets the property keeps its value. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/run/java.test.ts | 15 +++++++++++++++ src/platform/javaLaunch.ts | 13 ++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 3aae4705..586f3f8c 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -252,6 +252,21 @@ describe("java command lines", () => { ).toEqual(["--module-path", "/m/x.jar"]); }); + it("colours a Spring Boot console unless the configuration sets the property", () => { + const boot = (args: string[]) => ({ + ...applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"), + kind: "spring-boot" as const, + args, + }); + expect(resolveJavaCommand(boot(["-Xmx1g", "a.Main"]), info).args[0]).toBe("-Dspring.output.ansi.enabled=always"); + expect(resolveJavaCommand(boot(["-Dspring.output.ansi.enabled=never", "a.Main"]), info).args).toEqual([ + "-Dspring.output.ansi.enabled=never", + "a.Main", + ]); + const plain = applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"); + expect(resolveJavaCommand(plain, info).args).not.toContain("-Dspring.output.ansi.enabled=always"); + }); + it("joins arguments for java-debug and reads JDWP's port", () => { expect(joinJavaArgs(["a", "b c", 'q"'])).toBe('a "b c" "q\\""'); expect(jdwpListeningPort("Listening for transport dt_socket at address: 50123")).toBe(50123); diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index 91d9cf6d..75c57eef 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -71,10 +71,21 @@ export function parseJavaArgs(args: readonly string[]): JavaCommandLine { return { vmArgs, mainClass: null, programArgs: [], classpath, explicitClasspath }; } +/** + * VM options a launch adds for the console, as IDEA's Spring Boot configurations do: the + * console renders ANSI colours, but Spring only detects a terminal, and the process runs on pipes. + * Kept out when the configuration sets the property itself. + */ +export function consoleVmArgs(config: Pick): string[] { + if (config.kind !== "spring-boot") return []; + const own = parseJavaArgs(config.args).vmArgs.some((a) => a.startsWith("-Dspring.output.ansi.enabled=")); + return own ? [] : ["-Dspring.output.ansi.enabled=always"]; +} + /** The configuration with its placeholders and launcher resolved. */ export function resolveJavaCommand(config: RunConfig, info: JavaLaunchInfo): RunConfig { const cp = info.classPaths.join(info.pathListSeparator); - const args: string[] = []; + const args: string[] = [...consoleVmArgs(config)]; if (info.modulePaths.length && !config.args.some((a) => a === "-p" || a === "--module-path")) args.push("--module-path", info.modulePaths.join(info.pathListSeparator)); args.push(...config.args.map((a) => (a === CLASSPATH || a === TEST_CLASSPATH ? cp : a))); From 47f68862c8bd6086556c652dcbc0fa3c427989e1 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:11 +0700 Subject: [PATCH 029/108] feat(project-view): restore the persisted expansion state as the user left it (#782) The Project view restored only expanded paths inside the project roots and always re-expanded the roots. Restore the persisted state as is (a collapsed root stays collapsed, nodes outside it such as content roots stay open); only a first open expands the roots. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/project-tree/store.test.ts | 8 ++++++++ src/features/project-tree/store.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/features/project-tree/store.test.ts b/src/features/project-tree/store.test.ts index 032924c3..8d60d4d8 100644 --- a/src/features/project-tree/store.test.ts +++ b/src/features/project-tree/store.test.ts @@ -68,6 +68,14 @@ describe("lazy loading", () => { expect([...useProjectTree.getState().expanded].sort()).toEqual(["/p", "/p/docs"]); }); + it("keeps a collapsed project root collapsed and an expanded node outside it open", async () => { + setExpanded(new Set(["/other/lib"])); + await new Promise((r) => setTimeout(r, 350)); + initProjectTree(null); + initProjectTree(ROOT, "proj"); + expect([...useProjectTree.getState().expanded]).toEqual(["/other/lib"]); + }); + it("drops a folder that vanished and collapses it", async () => { await loadDir(ROOT); setExpanded(new Set([ROOT, "/p/gone"])); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 3d90bfcb..cca259ea 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -301,11 +301,12 @@ function initialExpanded(root: string | null, mode: ViewMode, members = get().me r.scope === "scratches" && r.path ? [r.path] : [], ), ); - const persisted = mode === "project" ? (readJson(expandedKey(root)) ?? []) : []; - const roots = members ?? [root]; - const expanded = new Set(persisted.filter((p) => roots.some((r) => isSameOrInside(p, r)))); - roots.forEach((r) => expanded.add(r)); - return expanded; + // The tree as the user left it, like IDEA: a collapsed project root stays collapsed, and + // nodes outside it (content roots, contributed roots) stay open. + // Keys of nodes that no longer exist are inert. Only a first open expands the roots. + const persisted = mode === "project" ? readJson(expandedKey(root)) : null; + if (persisted) return new Set(persisted); + return new Set(members ?? [root]); } function persistExpanded() { From a8e12ff7c42784d3b9d2cecf3916f72c8dafec34 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:15 +0700 Subject: [PATCH 030/108] feat(run): reuse the configuration of a main class run from its context (#783) Running or debugging a main class from the editor, gutter or Project view now selects an existing saved Application or Spring Boot configuration of the same main class and module, keeping its VM options, environment and profiles, instead of creating a temporary one. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/run/java.test.ts | 19 +++++++++++++++++++ src/features/run/store.ts | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 586f3f8c..161e1017 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -395,6 +395,25 @@ describe("temporary configurations", () => { expect(useRun.getState().temporary).toEqual([]); }); + it("reuse a configuration of the same main class and module, whatever its id", () => { + const own = (module: string) => + cfg({ + id: `user:${module}`, + kind: "spring-boot", + name: `Mine ${module}`, + args: ["-Xmx1g", "-cp", "${classpath}", "a.Main"], + module, + detected: false, + }); + useRun.setState({ configs: [own("other"), own("app")] }); + expect(adoptTemporary(applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java")).name).toBe( + "Mine app", + ); + expect(useRun.getState().temporary).toEqual([]); + const fresh = adoptTemporary(applicationConfig({ className: "a.Other" }, "app/src/main/java/a/Other.java")); + expect(useRun.getState().temporary).toEqual([fresh.id]); + }); + it("offer Save in the chooser while selected", async () => { const app = adoptTemporary(applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java")); const { configs, temporary } = useRun.getState(); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 38a3c1b3..3d7f3552 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -256,6 +256,13 @@ function persistTemporary() { ); } +/** IDEA's `isConfigurationFromContext` for an Application: the same main class of the same module. */ +function sameApplication(a: RunConfig, b: RunConfig): boolean { + if (!isJvmApplication(a) || !isJvmApplication(b)) return false; + const main = parseJavaArgs(a.args).mainClass; + return main !== null && main === parseJavaArgs(b.args).mainClass && (a.module ?? a.cwd) === (b.module ?? b.cwd); +} + /** * Selects the configuration for a gutter / context run: a saved or detected * configuration with the same id is reused, otherwise `config` becomes a @@ -263,7 +270,10 @@ function persistTemporary() { */ export function adoptTemporary(config: RunConfig): RunConfig { const state = useRun.getState(); - const existing = state.configs.find((c) => c.id === config.id); + // IDEA's `findExistingConfiguration`: a configuration the user keeps for this context wins. + const existing = + state.configs.find((c) => c.id === config.id) ?? + state.configs.find((c) => !state.temporary.includes(c.id) && sameApplication(c, config)); if (existing && !state.temporary.includes(config.id)) { selectConfig(existing.id); return existing; From 7375f30422ab7367a09705b63904b85a17c06409 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:58:23 +0800 Subject: [PATCH 031/108] fix(vcs): keep merge editor panes scrollable while they scroll together (#758) Scrolling a pane set the other two panes' scrollTop; their scroll events arrived a frame later, after the sync guard was cleared, were taken as user scrolls and written back, so the pane being scrolled was pulled back. Sync now maps positions by content and ignores the echoes of its own writes; a pane that cannot scroll no longer drags the others. Next/Previous Difference (F7 / Shift+F7) go through the keymap instead of a fixed key handler. Fixes #744. --- src/features/vcs/MergeEditor.tsx | 92 ++++++++++------- .../vcs/__tests__/mergeScroll.test.ts | 28 ++++++ src/features/vcs/diffScroll.ts | 4 +- src/features/vcs/mergeScroll.ts | 98 +++++++++++++++++++ 4 files changed, 186 insertions(+), 36 deletions(-) create mode 100644 src/features/vcs/__tests__/mergeScroll.test.ts create mode 100644 src/features/vcs/mergeScroll.ts diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 4c899b98..b04e5167 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -8,13 +8,17 @@ import { keymap } from "@codemirror/view"; import { unifiedMergeView } from "@codemirror/merge"; import type { MergeSides } from "@/bindings"; import { embeddedEditorExtensions } from "@/editor/api"; +import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { call, commands } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; import { Button, IconButton, showContextMenu, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { blameSlot, toggleAnnotations } from "./blame"; import { languageFor, viewerBase } from "./cm"; import { confirmDialog } from "./dialogs"; +import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator"; +import { syncMergeScroll } from "./mergeScroll"; import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts"; import { baseName } from "./model"; import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops"; @@ -220,28 +224,10 @@ export function MergeEditor({ input }: { input: EditorInput }) { setLeft(result.state.field(conflictField).conflicts.length); oursView.current = ours; theirsView.current = theirs; - // Scroll the three panes together (by scroll ratio), like IDEA's merge view. - const views = [ours, result, theirs]; - let syncing = false; - const onScroll = (from: EditorView) => () => { - if (syncing) return; - syncing = true; - const s = from.scrollDOM; - const ratio = s.scrollTop / Math.max(1, s.scrollHeight - s.clientHeight); - for (const other of views) { - if (other === from) continue; - const o = other.scrollDOM; - o.scrollTop = ratio * Math.max(0, o.scrollHeight - o.clientHeight); - } - syncing = false; - }; - const offs = views.map((v) => { - const h = onScroll(v); - v.scrollDOM.addEventListener("scroll", h, { passive: true }); - return () => v.scrollDOM.removeEventListener("scroll", h); - }); + // Scroll the three panes together, lined up through their changes. + const stopSync = syncMergeScroll({ ours, result, theirs }); return () => { - offs.forEach((off) => off()); + stopSync(); ours.destroy(); theirs.destroy(); result.destroy(); @@ -313,17 +299,31 @@ export function MergeEditor({ input }: { input: EditorInput }) { } }; - // IDEA's merge view: F7 / Shift+F7 jump between conflicts; each side pane can - // annotate its version with Git Blame from the context menu. - // Each side pane blames on the branch it belongs to, so lines that only exist on - // the other branch show their real history instead of "Not Committed Yet". + // Next / Previous Difference (F7 / Shift+F7 by default) jump between conflicts through + // the keymap, as in the diff viewer. + // `goto` reads only refs, so the first render's one stays valid. + const [nav] = useState(() => ({ + next: () => goto(1), + prev: () => goto(-1), + jumpToSource: () => void editorService().openFile(path, { focus: true }), + applyChange: () => false, + file: () => {}, + showSettings: () => {}, + })); + useEffect( + () => () => { + if (focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }, + [nav], + ); + + // Each side pane can annotate its version with Git Blame from the context menu, on + // the branch it belongs to, so lines that only exist on the other branch show their + // real history instead of "Not Committed Yet". const otherRev = state?.sides.commit ?? "HEAD"; - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "F7") { - e.preventDefault(); - goto(e.shiftKey ? -1 : 1); - } - }; const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string, revision?: string) => { const view = viewRef.current; @@ -358,10 +358,34 @@ export function MergeEditor({ input }: { input: EditorInput }) { ); return ( -
+
{ + setFocusedDiffNavigator(nav); + setContext("diffEditorFocus", true); + }} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }} + >
- goto(-1)} /> - goto(1)} /> + goto(-1)} + /> + goto(1)} + />
); })} diff --git a/src/features/run/useConsoleSelection.test.tsx b/src/features/run/useConsoleSelection.test.tsx new file mode 100644 index 00000000..9e0058cf --- /dev/null +++ b/src/features/run/useConsoleSelection.test.tsx @@ -0,0 +1,94 @@ +import { act, fireEvent, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useConsoleSelection } from "./useConsoleSelection"; + +const viewport = { startIndex: 100, endIndex: 110, overscan: 2, count: 500 }; + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + document.body.replaceChildren(); +}); + +function setup() { + const host = document.createElement("div"); + document.body.append(host); + const rows = [5, 6, 7].map((index) => { + const row = document.createElement("div"); + row.dataset.index = String(index); + row.textContent = `output ${index}`; + host.append(row); + return row; + }); + const ref = { current: host }; + const pause = vi.fn(); + const hook = renderHook(({ pause }) => useConsoleSelection(ref, pause), { initialProps: { pause } }); + const select = (backward = false) => { + act(() => { + const [anchor, focus] = backward ? [rows[2]!, rows[0]!] : [rows[0]!, rows[2]!]; + window.getSelection()!.setBaseAndExtent(anchor.firstChild!, 1, focus.firstChild!, 3); + document.dispatchEvent(new Event("selectionchange")); + }); + }; + return { ...hook, host, rows, pause, select }; +} + +describe("console selection retention", () => { + it.each([false, true])("keeps the full selected interval mounted (backward=%s)", (backward) => { + const { result, select, pause } = setup(); + select(backward); + const indices = result.current.rangeExtractor(viewport); + expect(indices.slice(0, 3)).toEqual([5, 6, 7]); + expect(indices).not.toContain(50); + expect(indices).toContain(100); + expect(pause).toHaveBeenCalled(); + }); + + it("renders the intervening text when dragging beyond the viewport", () => { + const { result, rows, select } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 0 }); + select(); + const indices = result.current.rangeExtractor(viewport); + expect(indices[0]).toBe(5); + expect(indices).toHaveLength(108); + fireEvent.mouseUp(document); + expect(result.current.rangeExtractor(viewport)).not.toContain(50); + expect(result.current.rangeExtractor(viewport)).toContain(5); + }); + + it("releases pinned rows when the selection is cleared or moved outside the console", () => { + const { result, select } = setup(); + select(); + act(() => { + window.getSelection()!.removeAllRanges(); + document.dispatchEvent(new Event("selectionchange")); + }); + expect(result.current.selecting).toBe(false); + expect(result.current.rangeExtractor(viewport)[0]).toBe(98); + select(); + const other = document.createTextNode("other output"); + document.body.append(other); + act(() => { + window.getSelection()!.setBaseAndExtent(other, 0, other, 5); + document.dispatchEvent(new Event("selectionchange")); + }); + expect(result.current.selecting).toBe(false); + expect(result.current.rangeExtractor(viewport)[0]).toBe(98); + }); + + it("keeps a drag active when incoming output rerenders the parent", () => { + const { result, rows, select, rerender } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 0 }); + const pause = vi.fn(); + rerender({ pause }); + select(); + expect(result.current.rangeExtractor(viewport)).toContain(50); + expect(pause).toHaveBeenCalled(); + }); + + it("does not pin rows or pause output for a context click", () => { + const { result, rows, pause } = setup(); + fireEvent.mouseDown(rows[0]!, { button: 2 }); + expect(result.current.selecting).toBe(false); + expect(pause).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/run/useConsoleSelection.ts b/src/features/run/useConsoleSelection.ts new file mode 100644 index 00000000..6fa89724 --- /dev/null +++ b/src/features/run/useConsoleSelection.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useEffectEvent, useState, type RefObject } from "react"; +import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual"; + +type SelectedRows = { start: number; end: number; dragging: boolean }; + +/** Keep the browser's selection endpoints, and the text between them, mounted. */ +export function useConsoleSelection(host: RefObject, pauseFollow: () => void) { + const pause = useEffectEvent(pauseFollow); + const [selected, setSelected] = useState(null); + + useEffect(() => { + const el = host.current; + if (!el) return; + let dragging = false; + const indexOf = (node: Node | null) => { + const element = node instanceof Element ? node : node?.parentElement; + const row = element?.closest("[data-index]"); + return row && el.contains(row) ? Number(row.dataset.index) : null; + }; + const update = () => { + const selection = window.getSelection(); + const anchor = indexOf(selection?.anchorNode ?? null); + const focus = indexOf(selection?.focusNode ?? null); + const next = + selection && (!selection.isCollapsed || dragging) && anchor !== null && focus !== null + ? { start: Math.min(anchor, focus), end: Math.max(anchor, focus), dragging } + : null; + setSelected((prev) => + prev?.start === next?.start && prev?.end === next?.end && prev?.dragging === next?.dragging ? prev : next, + ); + if (next && !selection!.isCollapsed) pause(); + }; + const down = (event: MouseEvent) => { + if (event.button !== 0) return; + const index = indexOf(event.target as Node); + if (index === null) return; + dragging = true; + // Pin the anchor before a scroll can recycle it, even before selectionchange fires. + setSelected({ start: index, end: index, dragging: true }); + pause(); + }; + const up = () => { + dragging = false; + update(); + }; + el.addEventListener("mousedown", down); + document.addEventListener("mouseup", up); + document.addEventListener("selectionchange", update); + return () => { + el.removeEventListener("mousedown", down); + document.removeEventListener("mouseup", up); + document.removeEventListener("selectionchange", update); + }; + }, [host]); + + const rangeExtractor = useCallback( + (range: Range) => { + const visible = defaultRangeExtractor(range); + if (!selected || !visible.length) return visible; + // During a drag, scrolling must also render every line up to the pointer. + const start = Math.max(0, selected.dragging ? Math.min(selected.start, visible[0]!) : selected.start); + const end = Math.min( + range.count - 1, + selected.dragging ? Math.max(selected.end, visible[visible.length - 1]!) : selected.end, + ); + const indices = new Set(visible); + for (let i = start; i <= end; i++) indices.add(i); + return [...indices].sort((a, b) => a - b); + }, + [selected], + ); + return { rangeExtractor, selecting: selected !== null }; +} From 362ae3535cc0ee27cc5cc43e47e67689065620fb Mon Sep 17 00:00:00 2001 From: kkmihai Date: Mon, 2 Nov 2026 14:15:42 +0200 Subject: [PATCH 037/108] fix(trust): keep LightEdit sessions restricted (#857) LightEdit created its project as trusted and refresh_project_trust skips light projects, so opening a loose notebook detected Python environments in the file's folder and could run a repository-shipped .venv/bin/python without a trust prompt. LightEdit now starts restricted and stays restricted when folder trust changes, since one session can hold files from unrelated folders; notebooks still open and save, while interpreter discovery, kernel execution and external formatting are refused. Opening the folder as a trusted project enables execution as before. Fixes #849 Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com> --- crates/ide-host/src/project.rs | 2 +- crates/ide-host/tests/light_edit.rs | 101 ++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index faf28db0..0e00ae8b 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -513,7 +513,7 @@ impl Host { sink.clone(), ); let project = Arc::new(Project { - trusted: Arc::new(AtomicBool::new(true)), + trusted: Arc::new(AtomicBool::new(false)), sink, languages, settings, diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index 1eff9e38..99f2e96c 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -78,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() { assert!(!applied.project_scope); assert!(!dir.join(".openidea").exists(), "no project settings next to the file"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_windows_stay_restricted_when_folder_trust_changes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("loose"); + let other = tmp.path().join("other"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(root.join("one.py"), "print(1)\n").unwrap(); + std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap(); + let host = host(&tmp.path().join("app")); + host.global_settings + .set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true)) + .unwrap(); + let light = host.open_light("main", &root.join("one.py")).unwrap(); + assert!(!light.is_trusted()); + assert!(light.require_trust().is_err()); + + host.set_project_trust(&root, true, false).await.unwrap(); + assert!(!light.is_trusted()); + let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap(); + assert!(!trusted_folder.is_trusted()); + let again = host.open_light("main", &other.join("two.js")).unwrap(); + assert!(Arc::ptr_eq(&light, &again)); + assert!(!again.is_trusted()); + host.set_project_trust(&root, true, true).await.unwrap(); + assert!(!light.is_trusted()); + assert!(!trusted_folder.is_trusted()); + for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] { + assert!(host.authorize_command("main", command).is_err(), "{command}"); + } + + let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap(); + let error = ops::lsp::lsp_format( + &host, + "main", + doc.id, + None, + ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }, + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable); + assert!(error.message.contains("Restricted Mode")); + + host.set_project_trust(&root, false, false).await.unwrap(); + assert!(!light.is_trusted()); + assert!(!trusted_folder.is_trusted()); + host.set_project_trust(&root, true, false).await.unwrap(); + let project = host.open_project("main", &root).unwrap(); + assert!(!project.light); + assert!(project.is_trusted()); + assert!(project.require_trust().is_ok()); + host.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn light_notebooks_open_without_interpreter_detection_or_execution() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("loose"); + let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" }); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let python = bin.join("python"); + std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let file = root.join("analysis.ipynb"); + std::fs::write( + &file, + r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#, + ) + .unwrap(); + let host = host(&tmp.path().join("app")); + host.open_light("main", &file).unwrap(); + let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap(); + assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok()); + let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + assert!(ops::sdk::sdk_python_effective(&host, "main").is_err()); + assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err()); + let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + let error = ops::notebook::execute( + &host, + "main", + notebook.id, + vec![notebook.cells[0].id.clone()], + None, + ide_core::RequestId(849), + ops::Channel::new(|_| {}), + ) + .await + .unwrap_err(); + assert!(error.message.contains("Restricted Mode")); + assert!(!bin.join("python.probed").exists()); + host.shutdown().await; +} From 78b23df7b5da24df6b707f11db4bb72cda9a3864 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 20:15:51 +0800 Subject: [PATCH 038/108] fix(host): close a project's background processes with it (#852) Closing a project only cancelled its agents and notebooks; its window's requests, run processes, terminal shells and debug sessions kept running until app exit, and the language server shutdown was a detached task a closing app could drop (orphaned jdtls). Closing now cancels the window's requests, kills its run processes and shells, stops its debug sessions and waits for the language servers to shut down; other windows are untouched. Terminals are keyed by owner window so they can be closed per window. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- crates/ide-dap/src/lib.rs | 10 +++++ crates/ide-host/src/lib.rs | 23 +++++++++- crates/ide-host/src/ops/term.rs | 3 +- crates/ide-host/src/trust.rs | 4 +- crates/ide-host/tests/close_project.rs | 54 ++++++++++++++++++++++++ crates/ide-host/tests/terminal_images.rs | 1 + crates/ide-terminal/src/lib.rs | 16 ++++++- 7 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 crates/ide-host/tests/close_project.rs diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 638ec8d3..5b8786a1 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -1389,6 +1389,16 @@ impl DebugManager { }) } + /// Stops every session `key` started: the window that opened the project is closing. + pub async fn stop_key(&self, key: &str) { + let ids: Vec = self.sessions(Some(key)).into_iter().map(|s| s.id).collect(); + for id in ids { + if let Err(e) = self.stop(id).await { + tracing::debug!("debug session {id:?} did not stop: {e}"); + } + } + } + /// Stops a session and its child sessions. pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> { let session = self.session(s)?; diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 66e21c9b..d5fef1e5 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -176,12 +176,21 @@ impl Host { } project.agents.shutdown(); project.notebooks.stop_admission(); + // What the closing window started goes with it — the builds and searches it + // asked for, its runs, its shells and its debug sessions — and the language + // servers finish shutting down before the window is gone, as IDEA closes a + // project's processes. + for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { + self.cancel_request(request); + } + self.processes.kill_project(&project.root); + self.terminals.kill_key(self.owner_window(window)); self.shutdown_project_workers_blocking( vec![project.notebooks.clone()], project.kotlin_bridge.iter().cloned().collect(), + Some(project.lsp.clone()), + Some((self.debugger.clone(), window.to_owned())), ); - let lsp = project.lsp.clone(); - self.runtime.spawn(async move { lsp.shutdown_all().await }); } } @@ -191,6 +200,8 @@ impl Host { &self, notebooks: Vec>, bridges: Vec>, + lsp: Option>, + debug: Option<(Arc, String)>, ) { for bridge in &bridges { bridge.stop(); @@ -208,6 +219,12 @@ impl Host { for service in notebooks { service.shutdown().await; } + if let Some(lsp) = lsp { + lsp.shutdown_all().await; + } + if let Some((debug, key)) = debug { + debug.stop_key(&key).await; + } }); }); if let Err(error) = worker.join() { @@ -229,6 +246,8 @@ impl Host { self.shutdown_project_workers_blocking( projects.iter().map(|project| project.notebooks.clone()).collect(), projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), + None, + None, ); } diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index c7a284c9..02a34026 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -40,7 +40,8 @@ pub fn spawn( .ok() .filter(|p| p.is_trusted()) .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell)); - let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; + let key = host.owner_window(window).to_owned(); + let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; if let Some(line) = activation { let _ = host.terminals.write(info.id, &format!(" {line}\r")); } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 7e8522e1..5fd072bb 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -135,9 +135,7 @@ impl crate::Host { for request in requests { self.cancel_request(request); } - for session in self.debugger.sessions(Some(&window)) { - let _ = self.debugger.stop(session.id).await; - } + self.debugger.stop_key(&window).await; } } project.lsp.reconfigure().await; diff --git a/crates/ide-host/tests/close_project.rs b/crates/ide-host/tests/close_project.rs new file mode 100644 index 00000000..e0b07cd1 --- /dev/null +++ b/crates/ide-host/tests/close_project.rs @@ -0,0 +1,54 @@ +//! Closing a project takes the background processes of its window with it, as IDEA does, +//! and leaves the other windows' processes alone. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions { + ide_terminal::TerminalOptions { + cwd: root.to_path_buf().into(), + // A fixed shell: the test must not depend on the login shell of this machine. + shell: Some("/bin/sh".into()), + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + } +} + +#[test] +fn closing_a_project_closes_its_terminals_and_keeps_other_windows() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + runtime.block_on(async { + 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)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let project_shell = + ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal"); + let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal"); + assert_eq!(host.terminals.list().len(), 2); + + host.close_project("main"); + + let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect(); + assert!(!left.contains(&project_shell.id), "the closing window's shell is gone"); + assert!(left.contains(&other_shell.id), "another window keeps its shell"); + host.terminals.kill_key("other"); + }); +} diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs index 13605357..8bb4a3e1 100644 --- a/crates/ide-host/tests/terminal_images.rs +++ b/crates/ide-host/tests/terminal_images.rs @@ -35,6 +35,7 @@ async fn storage_requires_a_registered_terminal() { rows: 24, shell_integration: false, }, + "main".into(), Arc::new(NullSink), Box::new(|_| {}), ) diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 82df70cd..21ae833c 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -53,6 +53,8 @@ pub struct TerminalInfo { } struct Session { + /// The window that opened it: closing that window's project closes its terminals. + key: String, info: TerminalInfo, master: Mutex>, writer: Mutex>, @@ -83,6 +85,7 @@ impl TerminalManager { pub fn spawn( &self, options: TerminalOptions, + key: String, sink: SharedSink, on_output: Box, ) -> IdeResult { @@ -121,6 +124,7 @@ impl TerminalManager { let info = TerminalInfo { id, title: shell_title(&shell), pid: child.process_id() }; let exited = Arc::new(AtomicBool::new(false)); let session = Arc::new(Session { + key, info: info.clone(), master: Mutex::new(pair.master), writer: Mutex::new(writer), @@ -160,6 +164,15 @@ impl TerminalManager { .map_err(|e| IdeError::internal(format!("resize failed: {e}"))) } + /// Closes the terminals `key` opened: the project of that window is closing. + pub fn kill_key(&self, key: &str) { + let ids: Vec = + self.sessions.lock().iter().filter(|(_, s)| s.key == key).map(|(id, _)| *id).collect(); + for id in ids { + let _ = self.kill(id); + } + } + pub fn kill(&self, id: TerminalId) -> IdeResult<()> { let session = self.sessions.lock().remove(&id).ok_or_else(|| not_found(id))?; terminate(&session); @@ -636,6 +649,7 @@ mod tests { .manager .spawn( opts, + "test".into(), h.sink.clone(), Box::new(move |s| { let _ = tx.send(s); @@ -793,7 +807,7 @@ mod tests { let h = harness(); let (other_sink, other_events) = channel_sink(); let (mine, _rx) = spawn_collect(&h, sh("exit 1")); - let theirs = h.manager.spawn(sh("exit 2"), other_sink, Box::new(|_| {})).expect("spawn"); + let theirs = h.manager.spawn(sh("exit 2"), "other".into(), other_sink, Box::new(|_| {})).expect("spawn"); match h.events.recv_timeout(Duration::from_secs(10)).expect("exit event") { IdeEvent::TerminalExited { terminal, code } => assert_eq!((terminal, code), (mine.id, Some(1))), e => panic!("unexpected {e:?}"), From 502a9dc7dd7fe8db44cf0fee924aaaa0307bf54e Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:15:59 +0700 Subject: [PATCH 039/108] fix(kotlin): compile a folder of sibling Maven projects in the Kotlin bridge (#842) compile::plan only fell back to Gradle when the folder had no build file, so a folder of sibling Maven projects failed with "no supported build system" in the Kotlin editor compilation and Build Project. It now falls back to Maven when the model has Maven projects, after the existing Gradle fallback. Fixes #814 Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- crates/ide-build/src/compile/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 9cd0ea26..9cbd6e48 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -185,8 +185,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path None => None, }; let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf()); + // A folder of sibling projects has no build file itself; its Maven projects are built one by one. let system = detect_dir(&dir, model, root) .or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None }) + .or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven)) .ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?; let module = module_dir.as_deref().filter(|d| *d != root); let title = match module { @@ -814,6 +816,17 @@ mod tests { ("pnpm", &["run".to_owned(), "build".to_owned()][..]) ); + let siblings = tmp.path().join("siblings"); + write(&siblings, "core/pom.xml", "core"); + write(&siblings, "system/pom.xml", "system"); + let model = crate::load(&siblings, &tmp.path().join("cache3"), None); + let p = plan(&siblings, &model, BuildKind::Build, None).unwrap(); + assert_eq!(p.system, BuildSystem::Maven); + assert_eq!( + p.steps.iter().map(|s| s.cwd.clone()).collect::>(), + [siblings.join("core"), siblings.join("system")] + ); + let none = tmp.path().join("none"); std::fs::create_dir_all(&none).unwrap(); assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err()); From 4ea7d32074a984a315c44d5b8df767e07e84507b Mon Sep 17 00:00:00 2001 From: kevinh <45650311+kevinh458@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:16:07 +0800 Subject: [PATCH 040/108] fix(settings): confirm before exiting the IDE by default (#834) Confirm before exiting the IDE now defaults to on in both the Rust settings and the frontend schema; the exit dialog's "Don't ask again" still turns it off. Fixes #749 Co-authored-by: kevinh458 <45650311+kevinh458@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 2 +- src/features/settings/model/schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 7c2c17a3..453b1433 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -235,7 +235,7 @@ impl Default for GeneralSettings { fn default() -> Self { Self { reopen_last_project: true, - confirm_exit: false, + confirm_exit: true, open_project_in: "ask".into(), default_project_directory: String::new(), save_on_idle: false, diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index d92540d3..f9400f4c 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -118,7 +118,7 @@ function validateSpelling(raw: unknown): SpellingValid { export { ACCENT_PRESETS } from "@/workbench/theme"; export const DEFAULT_SETTINGS: ValidSettings = { - general: { reopenLastProject: true, confirmExit: false, ...DEFAULT_GENERAL_EXTRAS }, + general: { reopenLastProject: true, confirmExit: true, ...DEFAULT_GENERAL_EXTRAS }, appearance: { theme: "light", accent: "#3871e1", uiFontSize: 13, locale: "en", ...DEFAULT_APPEARANCE_EXTRAS }, editor: { fontFamily: "JetBrains Mono", From 53b5052bbbf3e0cce2fdf788e0c5c3861d708486 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:17:29 +0900 Subject: [PATCH 041/108] fix(editor): let the scrollbar thumb drag where overview marks cover it (#807) The overview lane lies over the vertical scrollbar and its marks take the pointer, so a mark under the thumb swallowed the press; a new or fully changed file is one lane-high mark, so its thumb could not be dragged at all. While the pointer is over the thumb (computed from the scroller's metrics and the 32px minimum thumb) the marks now let the pointer through; elsewhere clicking a mark still jumps to its line. Fixes #752 Co-authored-by: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> --- e2e/overviewLane.spec.ts | 30 +++++++++++++ src/editor/core/cm/ideaParity.test.ts | 12 +++++- src/editor/core/cm/lanes.ts | 61 +++++++++++++++++++++++++++ src/editor/core/cm/theme.ts | 2 + 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 e2e/overviewLane.spec.ts diff --git a/e2e/overviewLane.spec.ts b/e2e/overviewLane.spec.ts new file mode 100644 index 00000000..6c51df7c --- /dev/null +++ b/e2e/overviewLane.spec.ts @@ -0,0 +1,30 @@ +import { expect, openFromTree, test } from "./fixtures"; + +// Headless Chromium hides scrollbars by default; this test drags one. +test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } }); + +test("the scrollbar thumb drags even where overview marks cover it (#752)", async ({ workbench: page }) => { + const editor = await openFromTree(page, "build.gradle.kts"); + await editor.locator(".cm-content").click(); + await page.keyboard.press("Meta+a"); + await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val mark${i} = ${i}`).join("\n")); + // A match on every line covers the overview lane, as a new file's change mark does. + await page.keyboard.press("Meta+f"); + await page.getByRole("textbox", { name: "Find" }).fill("mark"); + await expect(editor.locator(".cm-openidea-overviewMark").first()).toBeVisible(); + const scroller = editor.locator(".cm-scroller"); + await scroller.evaluate((s) => (s.scrollTop = 0)); + const box = (await scroller.boundingBox())!; + // Over a mark and inside the thumb, which is at the top of the track. + const x = box.x + box.width - 5; + const y = box.y + 12; + await page.mouse.move(x - 40, y); + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x, y + 150, { steps: 10 }); + await page.mouse.up(); + expect(await scroller.evaluate((s) => s.scrollTop)).toBeGreaterThan(0); + // Away from the thumb the marks take the pointer again. + await page.mouse.move(x, box.y + box.height - 20); + await expect(editor.locator(".cm-openidea-overview")).not.toHaveClass(/cm-openidea-overview-onThumb/); +}); diff --git a/src/editor/core/cm/ideaParity.test.ts b/src/editor/core/cm/ideaParity.test.ts index 38640f8f..bad82ca4 100644 --- a/src/editor/core/cm/ideaParity.test.ts +++ b/src/editor/core/cm/ideaParity.test.ts @@ -23,7 +23,7 @@ import { foldSelectionEffects, } from "./folding"; import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode"; -import { nearestMark, unfoldAround } from "./lanes"; +import { nearestMark, scrollbarThumb, unfoldAround } from "./lanes"; import { lineRangeSelection } from "./gutterDrag"; import { matchBraceTarget } from "./caretCommands"; import { overwriteField, toggleOverwrite } from "./overwrite"; @@ -291,6 +291,16 @@ describe("error stripe", () => { expect(nearestMark(marks, 21)).toBeNull(); }); + it("finds the scrollbar thumb the marks must let through", () => { + // Nothing to scroll: no thumb. + expect(scrollbarThumb(0, 400, 400)).toBeNull(); + // A quarter of the document visible: a 100px thumb, at the top, then at the bottom. + expect(scrollbarThumb(0, 1600, 400)).toEqual({ top: 0, bottom: 100 }); + expect(scrollbarThumb(1200, 1600, 400)).toEqual({ top: 300, bottom: 400 }); + // A long document keeps the 32px minimum thumb; halfway down it sits mid-track. + expect(scrollbarThumb(49800, 100000, 400)).toEqual({ top: 184, bottom: 216 }); + }); + it("unfolds what hides the target line", () => { const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({ effects: foldEffect.of({ from: 3, to: 8 }), diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts index cb876831..9e6ac7c6 100644 --- a/src/editor/core/cm/lanes.ts +++ b/src/editor/core/cm/lanes.ts @@ -27,6 +27,27 @@ export function nearestMark(marks: re return best; } +/** The smallest scrollbar thumb (`::-webkit-scrollbar-thumb` min-height in global.css). */ +const MIN_THUMB = 32; + +/** + * Where the vertical scrollbar thumb of a scroller is, in px from the top of its track, or null + * when nothing scrolls. The overview lane lies over the scrollbar, so its marks must not take + * the pointer where the thumb is (#752). + */ +export function scrollbarThumb( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + minThumb = MIN_THUMB, +): { top: number; bottom: number } | null { + const max = scrollHeight - clientHeight; + if (max <= 0 || clientHeight <= 0) return null; + const length = Math.min(clientHeight, Math.max(minThumb, (clientHeight * clientHeight) / scrollHeight)); + const top = (Math.min(Math.max(scrollTop, 0), max) / max) * (clientHeight - length); + return { top, bottom: top + length }; +} + /** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */ export function unfoldAround(state: EditorState, pos: number): StateEffect[] { const lineEnd = state.doc.lineAt(pos).to; @@ -41,12 +62,17 @@ class OverviewLane { dom: HTMLElement; private scheduled = false; private lastKey = ""; + /** The last pointer position over the editor, to re-check the thumb when it scrolls. */ + private pointer: { x: number; y: number } | null = null; constructor(private view: EditorView) { this.dom = document.createElement("div"); this.dom.className = "cm-openidea-overview"; this.dom.setAttribute("aria-hidden", "true"); this.dom.addEventListener("mousedown", this.onMouseDown); + view.dom.addEventListener("mousemove", this.onPointerMove); + view.dom.addEventListener("mouseleave", this.onPointerLeave); + view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true }); view.dom.appendChild(this.dom); this.schedule(); } @@ -132,6 +158,38 @@ class OverviewLane { }); } + /** + * Over the scrollbar thumb the marks let the pointer through, so the thumb can be dragged + * even where a mark covers it (a new file is one mark the height of the lane, #752). + */ + private updateThumbPassThrough() { + const p = this.pointer; + let over = false; + if (p) { + const lane = this.dom.getBoundingClientRect(); + const s = this.view.scrollDOM; + const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight); + const y = p.y - s.getBoundingClientRect().top; + over = !!thumb && p.x >= lane.left && p.x <= lane.right && y >= thumb.top && y <= thumb.bottom; + } + this.dom.classList.toggle("cm-openidea-overview-onThumb", over); + } + + private onPointerMove = (e: MouseEvent) => { + this.pointer = { x: e.clientX, y: e.clientY }; + // A thumb drag in progress keeps the marks out of the way until the button is released. + if (e.buttons === 0) this.updateThumbPassThrough(); + }; + + private onPointerLeave = () => { + this.pointer = null; + this.updateThumbPassThrough(); + }; + + private onScroll = () => { + if (this.pointer) this.updateThumbPassThrough(); + }; + private onMouseDown = (e: MouseEvent) => { if (e.button !== 0) return; const marks = [...this.dom.querySelectorAll(".cm-openidea-overviewMark")].map((el) => { @@ -153,6 +211,9 @@ class OverviewLane { destroy() { this.dom.removeEventListener("mousedown", this.onMouseDown); + this.view.dom.removeEventListener("mousemove", this.onPointerMove); + this.view.dom.removeEventListener("mouseleave", this.onPointerLeave); + this.view.scrollDOM.removeEventListener("scroll", this.onScroll); this.dom.remove(); } } diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 0a95f917..0390d1f1 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -283,6 +283,8 @@ export const openideaTheme = EditorView.theme({ cursor: "pointer", }, ".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" }, + // The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752). + ".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" }, ".cm-openidea-overviewCaret": { position: "absolute", left: "2px", From f8105e5473111013f5818a20a9519874eb3a15c1 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 20:17:37 +0800 Subject: [PATCH 042/108] fix(maven): list every goal a plugin declares, not just the bound one (#841) Plugin goals came from a built-in table plus the goals bound in , so a plugin outside the table (maven-checkstyle-plugin) showed only its bound goal. The Plugins section now also reads META-INF/maven/plugin.xml from the plugin jar in the local repository (honouring of the settings file in use), cached per jar, with the table as fallback when the jar is not downloaded yet. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- Cargo.lock | 1 + crates/ide-build/Cargo.toml | 1 + crates/ide-build/src/maven.rs | 229 ++++++++++++++++++++++++++++++++-- crates/ide-build/src/sync.rs | 78 +++++++++++- 4 files changed, 294 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7590a3f..423882bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4086,6 +4086,7 @@ dependencies = [ "specta", "tempfile", "tracing", + "zip", ] [[package]] diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml index eff2f248..94686372 100644 --- a/crates/ide-build/Cargo.toml +++ b/crates/ide-build/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] dunce.workspace = true ide-core.workspace = true +zip.workspace = true serde.workspace = true serde_json.workspace = true specta.workspace = true diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index f9b926a9..b467c95a 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -11,7 +11,9 @@ use crate::xml::{self, Element}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; /// Phases shown under "Lifecycle" (IDEA's default list). pub const LIFECYCLE: &[&str] = @@ -456,13 +458,20 @@ struct Resolver { /// `~/.m2/repository`, or `` from `~/.m2/settings.xml`. pub(crate) fn default_local_repo() -> Option { let m2 = dirs::home_dir()?.join(".m2"); - if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t)) - && let Some(repo) = settings.text_of("localRepository") - { - let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); - return Some(PathBuf::from(repo.replace("${user.home}", &home))); + local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository"))) +} + +/// `` of a settings file, when it names one: the repository a build +/// with `-s ` resolves into, which is not necessarily the user's default. +pub fn local_repository_of(settings: &Path) -> Option { + let text = std::fs::read_to_string(settings).ok()?; + let root = xml::parse(&text)?; + let repo = root.text_of("localRepository")?.trim().to_owned(); + if repo.is_empty() { + return None; } - Some(m2.join("repository")) + let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(); + Some(PathBuf::from(repo.replace("${user.home}", &home))) } impl Resolver { @@ -688,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String { artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned() } -/// Goals of well-known plugins (their plugin descriptors are not read). +/// Goals of every plugin jar read so far, by the path of that jar. +static DESCRIPTOR: OnceLock>>> = OnceLock::new(); + +/// `` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`), +/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar +/// is not in the local repository yet. +fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec { + // Keyed by the jar itself, not by its coordinates: a repository can be replaced + // between runs, and a test's empty repository must not answer for another's. + // One `push` per group segment: the layout is `//…` on + // every platform, Windows' separator included. + let mut dir = repo.to_path_buf(); + dir.extend(group_id.split('.')); + dir.push(artifact_id); + dir.push(version); + let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() }; + let key = jar.to_string_lossy().into_owned(); + let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return cached.clone(); + } + // A failed read is not remembered: the jar may be half written by Maven right now. + let Some(goals) = read_descriptor(&jar) else { return Vec::new() }; + cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone()); + goals +} + +/// The plugin's jar in its version directory: `-.jar`, or — for a +/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest +/// `-*.jar` that is neither sources nor javadoc. +fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option { + let exact = dir.join(format!("{artifact_id}-{version}.jar")); + if exact.is_file() { + return Some(exact); + } + let prefix = format!("{artifact_id}-"); + let mut best: Option<(std::time::SystemTime, PathBuf)> = None; + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; + if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") { + continue; + } + if name.ends_with("-javadoc.jar") { + continue; + } + let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue }; + if best.as_ref().is_none_or(|(at, _)| modified > *at) { + best = Some((modified, path)); + } + } + best.map(|(_, p)| p) +} + +fn read_descriptor(jar: &Path) -> Option> { + let file = std::fs::File::open(jar).ok()?; + let mut archive = zip::ZipArchive::new(file).ok()?; + let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?; + let mut text = String::new(); + entry.read_to_string(&mut text).ok()?; + let root = xml::parse(&text)?; + let mut goals: Vec = + root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect(); + goals.retain(|g| !g.is_empty()); + goals.sort(); + goals.dedup(); + Some(goals) +} + +/// Goals of well-known plugins, for a jar that is not in the local repository yet. fn known_goals(artifact_id: &str) -> &'static [&'static str] { match artifact_id { "maven-clean-plugin" => &["clean", "help"], @@ -840,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] { } } -/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted. -pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option, goals: &[String]) -> MavenPlugin { +/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for +/// the common plugins, and the ones the project binds in ``, sorted. IDEA lists +/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say) +/// still shows every goal it offers instead of only the bound one. +pub(crate) fn plugin( + group_id: &str, + artifact_id: &str, + version: Option, + goals: &[String], + local_repo: Option<&Path>, +) -> MavenPlugin { let mut all: Vec = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect(); + let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo); + // An unresolved `${…}` version names no jar: the table and the bound goals stand in. + if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) { + for g in descriptor_goals(repo, group_id, artifact_id, version) { + if !all.contains(&g) { + all.push(g); + } + } + } for g in goals { if !all.contains(g) { all.push(g.clone()); @@ -1007,7 +1103,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenM .or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone())) .map(|v| i(&v)) .filter(|v| !v.contains("${")); - plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals) + plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref()) }) .collect(); @@ -1119,6 +1215,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec { /// Projects and their plugins / dependencies from `mvn help:effective-pom` output /// (`` or ``), keyed `groupId:artifactId`. pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec)> { + parse_effective_pom_in(text, None) +} + +/// The same, with the local repository the project actually builds against (a +/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there, +/// rather than from the user's default one. +pub fn parse_effective_pom_in( + text: &str, + local_repo: Option<&Path>, +) -> Vec<(String, Vec, Vec)> { let Some(root) = xml::parse(text) else { return Vec::new() }; let projects: Vec<&Element> = if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] }; @@ -1130,7 +1236,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec Vec<(String, Vec) mod tests { use super::*; + /// A plugin jar in `repo` whose descriptor declares `goals`. + fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) { + let dir = repo.join(group.replace('.', "/")).join(artifact).join(version); + std::fs::create_dir_all(&dir).unwrap(); + let mojos: String = goals.iter().map(|g| format!("{g}")).collect(); + let descriptor = format!("{mojos}"); + let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + + #[test] + fn a_plugin_lists_the_goals_its_jar_declares() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path(); + write_plugin_jar( + repo, + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + "3.3.1", + &["checkstyle", "checkstyle-aggregate", "help", "check"], + ); + let p = plugin( + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + Some("3.3.1".into()), + &["check".to_owned()], + Some(repo), + ); + assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers"); + assert_eq!(p.prefix, "checkstyle"); + } + + #[test] + fn a_settings_file_names_the_repository_to_read_goals_from() { + let tmp = tempfile::tempdir().unwrap(); + let settings = tmp.path().join("settings.xml"); + std::fs::write( + &settings, + "${user.home}/repo-from-settings", + ) + .unwrap(); + let repo = local_repository_of(&settings).unwrap(); + assert!(repo.ends_with("repo-from-settings"), "{}", repo.display()); + assert!(!repo.to_string_lossy().contains("${user.home}")); + // A settings file that names none leaves Maven's default alone. + let plain = tmp.path().join("plain.xml"); + std::fs::write(&plain, "true").unwrap(); + assert!(local_repository_of(&plain).is_none()); + assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none()); + } + + #[test] + fn effective_pom_goals_come_from_the_local_repository_it_is_given() { + let tmp = tempfile::tempdir().unwrap(); + write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]); + let text = "ga\ + org.apache.maven.pluginsmaven-demo-plugin\ + 1.0run\ + "; + let with_repo = parse_effective_pom_in(text, Some(tmp.path())); + assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first"); + // Without that repository the plugin is unknown: only what the project binds. + assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]); + } + + #[test] + fn a_snapshot_plugin_is_found_by_its_timestamped_jar() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT"); + std::fs::create_dir_all(&dir).unwrap(); + let descriptor = "run"; + let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap(); + zip.finish().unwrap(); + // A sibling sources jar must not win. + std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap(); + let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path())); + assert_eq!(p.goals, ["run"]); + } + + #[test] + fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() { + let tmp = tempfile::tempdir().unwrap(); + let p = plugin( + "org.apache.maven.plugins", + "maven-checkstyle-plugin", + Some("3.3.1".into()), + &["check".to_owned()], + Some(tmp.path()), + ); + assert_eq!(p.goals, ["check"], "nothing beyond what the project binds"); + let known = + plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path())); + assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table"); + } + fn write(dir: &Path, rel: &str, text: &str) { let p = dir.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index cbeed656..815c4631 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -48,6 +48,11 @@ struct CachedMavenProject { #[derive(Clone, Debug, Default, Serialize, Deserialize)] struct MavenCache { + /// Bumped whenever what the cache holds stops matching what the model would be + /// built as now (plugin goals read from a descriptor, say): an older cache is + /// dropped instead of shadowing the model. + #[serde(default)] + version: u32, hash: String, at_ms: f64, error: Option, @@ -66,6 +71,7 @@ struct GradleCache { } const MAVEN_CACHE: &str = "maven.json"; +const MAVEN_CACHE_VERSION: u32 = 1; /// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven /// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2. @@ -121,6 +127,9 @@ fn now_ms() -> f64 { /// Merges the cached effective build into `model`. pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) { let Some(cache) = read_cache::(cache_dir, MAVEN_CACHE) else { return }; + if cache.version != MAVEN_CACHE_VERSION { + return; // built by an older IDE: what it holds no longer matches the model + } model.synced_at = Some(cache.at_ms); model.sync_error = cache.error; let changed = cache.hash != hash_files(&maven::build_files(root, model)); @@ -211,8 +220,31 @@ pub fn sync_maven( let mut model = maven::load(root); maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model); std::fs::create_dir_all(cache_dir)?; - let mut cache = - MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() }; + let mut cache = MavenCache { + version: MAVEN_CACHE_VERSION, + hash: hash_files(&maven::build_files(root, &model)), + verbose: true, + ..MavenCache::default() + }; + // Where the build resolves its jars, and therefore where the plugin goals are read + // from: `-Dmaven.repo.local=` first, then `` of the `-s` settings, + // then the user's default repository (which `plugin()` resolves itself). + let local_repo: Option = + options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else( + || { + let mut args = options.maven_options.iter(); + while let Some(arg) = args.next() { + let path = match arg.as_str() { + "-s" | "--settings" => args.next().map(String::as_str), + _ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")), + }; + if let Some(path) = path { + return maven::local_repository_of(Path::new(path)); + } + } + None + }, + ); let mut errors: Vec = Vec::new(); for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() { let effective = cache_dir.join(format!("effective-pom-{n}.xml")); @@ -241,8 +273,9 @@ pub fn sync_maven( }); } })?; - let effective_projects = - std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default(); + let effective_projects = std::fs::read_to_string(&effective) + .map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref())) + .unwrap_or_default(); let trees = maven::parse_dependency_tree(&out.text); for (key, plugins, deps) in effective_projects { let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps); @@ -332,6 +365,7 @@ mod tests { let mut model = maven::load_with_repo(&root, None); assert!(model.synced_at.is_none()); let cache = MavenCache { + version: MAVEN_CACHE_VERSION, hash: hash_files(&maven::build_files(&root, &model)), at_ms: 5.0, error: None, @@ -343,6 +377,7 @@ mod tests { "maven-clean-plugin", Some("3.2.0".into()), &[], + None, )], dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }], }], @@ -368,6 +403,41 @@ mod tests { assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh"); } + #[test] + fn a_cache_from_an_older_ide_is_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("p"); + let cache_dir = tmp.path().join("cache"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("pom.xml"), "ga") + .unwrap(); + let mut model = maven::load_with_repo(&root, None); + let cache = MavenCache { + // What an older build wrote: no plugin descriptors were read back then, so + // its goal lists would shadow the ones the model now knows. + version: 0, + hash: hash_files(&maven::build_files(&root, &model)), + at_ms: 5.0, + error: None, + verbose: true, + projects: vec![CachedMavenProject { + key: "g:a".into(), + plugins: vec![maven::plugin( + "org.apache.maven.plugins", + "maven-clean-plugin", + Some("3.2.0".into()), + &[], + None, + )], + dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }], + }], + }; + write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap(); + apply_maven_cache(&root, &cache_dir, &mut model); + assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced"); + assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached")); + } + #[test] fn gradle_cache_replaces_tasks_per_project() { let tmp = tempfile::tempdir().unwrap(); From 91c15ff31bbf137fcd14977fee66f155a82fb886 Mon Sep 17 00:00:00 2001 From: Bernard Ograh <69661398+bograh@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:21:07 +0000 Subject: [PATCH 043/108] refactor(core): share process-tree kill helpers across tool crates (#853) Five crates (tools, build, vcs, github, docker) each carried their own copy of killing a child and what it started, and only the tools installer killed the whole tree on Windows, so cancelling git, gh, docker or Maven/Gradle there left descendants running. ide-core now provides own_group, kill_tree and terminate_tree (process-group signals on Unix, taskkill /T /F on Windows) and the copies are removed. Unix behaviour is unchanged; the Windows path was not run locally. Co-authored-by: bograh <69661398+bograh@users.noreply.github.com> --- Cargo.lock | 4 +- crates/ide-build/Cargo.toml | 3 - crates/ide-build/src/process.rs | 23 +----- crates/ide-core/Cargo.toml | 3 + crates/ide-core/src/process.rs | 137 ++++++++++++++++++++++++++++++- crates/ide-docker/Cargo.toml | 3 - crates/ide-docker/src/process.rs | 38 +++------ crates/ide-github/Cargo.toml | 3 - crates/ide-github/src/process.rs | 38 +++------ crates/ide-tools/Cargo.toml | 6 +- crates/ide-tools/src/process.rs | 46 ++--------- crates/ide-vcs/src/cli.rs | 44 +++------- 12 files changed, 185 insertions(+), 163 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 423882bf..5275a63f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4080,7 +4080,6 @@ dependencies = [ "dirs 6.0.0", "dunce", "ide-core", - "libc", "serde", "serde_json", "specta", @@ -4104,6 +4103,7 @@ version = "0.1.33" dependencies = [ "dunce", "encoding_rs", + "libc", "serde", "serde_json", "specta", @@ -4173,7 +4173,6 @@ name = "ide-docker" version = "0.1.33" dependencies = [ "ide-core", - "libc", "serde", "serde_json", "specta", @@ -4200,7 +4199,6 @@ version = "0.1.33" dependencies = [ "ide-core", "ide-vcs", - "libc", "serde", "serde_json", "specta", diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml index 94686372..bda630cf 100644 --- a/crates/ide-build/Cargo.toml +++ b/crates/ide-build/Cargo.toml @@ -15,8 +15,5 @@ specta.workspace = true tracing.workspace = true dirs.workspace = true -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index 8177089f..db169b4c 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -2,11 +2,12 @@ //! line to a callback, and cancellation kills the whole process group (the //! `mvnw` / `gradlew` wrapper scripts start the JVM as a child). +use ide_core::process::kill_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Read}; use std::path::Path; -use std::process::{Child, Command, Stdio}; +use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::Duration; @@ -22,18 +23,6 @@ pub(crate) struct Output { pub text: String, } -fn kill_tree(child: &mut Child) { - #[cfg(unix)] - if let Ok(pid) = i32::try_from(child.id()) { - // SAFETY: plain syscall; the group was created by `process_group(0)` at spawn. - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - /// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's /// search path — the login shell's `PATH` plus version-manager shims — since the /// app's own `PATH` can be minimal and `Command` would not see `mvn` there. @@ -80,12 +69,8 @@ pub(crate) fn run_with_env( let mut command = Command::new(&program); command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null()); command.stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - ide_core::process::prepare(&mut command); + // Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both. + ide_core::process::prepare(ide_core::process::own_group(&mut command)); if clear_env { command.env_clear().envs(env).envs(encoding.env); } diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index 81e06570..4f9b43a6 100644 --- a/crates/ide-core/Cargo.toml +++ b/crates/ide-core/Cargo.toml @@ -18,5 +18,8 @@ tokio-util.workspace = true [dev-dependencies] tempfile.workspace = true +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] } diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index 9aaaff28..b7a74e44 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -1,8 +1,11 @@ //! Child-process helpers shared by every crate that spawns tools. use std::ffi::{OsStr, OsString}; -use std::process::Command; +#[cfg(windows)] +use std::process::Stdio; +use std::process::{Child, Command}; use std::sync::{Arc, RwLock}; +use std::time::Duration; /// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it /// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise. @@ -25,6 +28,70 @@ pub fn prepare(cmd: &mut Command) -> &mut Command { host_env(cmd) } +/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and +/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` → +/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id. +pub fn own_group(cmd: &mut Command) -> &mut Command { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + cmd +} + +/// Kills the child and everything it started, then reaps it. +/// +/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`]. +/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id. +pub fn kill_tree(child: &mut Child) { + #[cfg(unix)] + signal_group(child, libc::SIGKILL); + #[cfg(windows)] + taskkill_tree(child); + let _ = child.kill(); + let _ = child.wait(); +} + +/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`. +/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next +/// git command fails. Windows has no such signal to send from outside the child's console, +/// so there it is [`kill_tree`]. +pub fn terminate_tree(child: &mut Child, grace: Duration) { + #[cfg(unix)] + { + signal_group(child, libc::SIGTERM); + // Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the + // group id) cannot be reused before the `SIGKILL` below. + std::thread::sleep(grace); + } + #[cfg(not(unix))] + let _ = grace; + kill_tree(child); +} + +#[cfg(unix)] +fn signal_group(child: &Child, signal: libc::c_int) { + if let Ok(pid) = libc::pid_t::try_from(child.id()) { + // SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or + // no group at all when it was not spawned with `own_group`, and the call fails). + unsafe { + libc::kill(-pid, signal); + } + } +} + +#[cfg(windows)] +fn taskkill_tree(child: &Child) { + let mut taskkill = Command::new("taskkill"); + prepare(&mut taskkill) + .args(["/T", "/F", "/PID", &child.id().to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = taskkill.status(); +} + /// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and /// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`, /// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that @@ -597,3 +664,71 @@ mod tests { assert_eq!(find_executable("nope", &search), None); } } + +#[cfg(all(test, unix))] +mod kill_tests { + use super::*; + use std::path::Path; + use std::time::Instant; + + /// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`. + fn start(script: &str, ready: &Path) -> Child { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(script); + let child = own_group(&mut cmd).spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!(Instant::now() < deadline, "the script never got ready"); + std::thread::sleep(Duration::from_millis(10)); + } + child + } + + fn assert_gone(pid: libc::pid_t) { + let deadline = Instant::now() + Duration::from_secs(5); + // SAFETY: signal 0 only checks that the process exists. + while unsafe { libc::kill(pid, 0) } == 0 { + assert!(Instant::now() < deadline, "process {pid} survived"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[test] + fn kill_tree_kills_what_the_child_started() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display()); + let mut child = start(&script, &pid_file); + let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap(); + kill_tree(&mut child); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + assert_gone(grandchild); + } + + #[test] + fn terminate_tree_lets_the_child_clean_up() { + let dir = tempfile::tempdir().unwrap(); + let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned")); + let script = format!( + "trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait", + cleaned.display(), + ready.display() + ); + let mut child = start(&script, &ready); + terminate_tree(&mut child, Duration::from_millis(500)); + assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL"); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + } + + #[test] + fn terminate_tree_still_kills_a_child_that_ignores_sigterm() { + let dir = tempfile::tempdir().unwrap(); + let ready = dir.path().join("ready"); + let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display()); + let mut child = start(&script, &ready); + let started = Instant::now(); + terminate_tree(&mut child, Duration::from_millis(50)); + assert!(child.try_wait().unwrap().is_some(), "the child is reaped"); + assert!(started.elapsed() < Duration::from_secs(5)); + } +} diff --git a/crates/ide-docker/Cargo.toml b/crates/ide-docker/Cargo.toml index 1431fa54..ae06863d 100644 --- a/crates/ide-docker/Cargo.toml +++ b/crates/ide-docker/Cargo.toml @@ -13,8 +13,5 @@ specta.workspace = true tracing.workspace = true which = "8" -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-docker/src/process.rs b/crates/ide-docker/src/process.rs index 6c08c005..3941991d 100644 --- a/crates/ide-docker/src/process.rs +++ b/crates/ide-docker/src/process.rs @@ -1,11 +1,16 @@ //! Child processes of the docker CLI: bounded runs and cancellable line streams. +use ide_core::process::terminate_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::Read; use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; +/// How long a cancelled or timed-out child's process group (CLI plugins such as compose) +/// gets to exit on `SIGTERM`, and clean up, before `SIGKILL`. +const KILL_GRACE: Duration = Duration::from_millis(20); + pub(crate) struct Output { pub stdout: String, pub stderr: String, @@ -20,36 +25,13 @@ pub(crate) enum Stream { fn prepare(cmd: &mut Command) { cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - ide_core::process::prepare(cmd); + ide_core::process::prepare(ide_core::process::own_group(cmd)); } fn spawn(cmd: &mut Command) -> IdeResult { cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run docker: {e}"))) } -/// Kills the child and, on Unix, its whole process group (CLI plugins such as compose). -pub(crate) fn kill(child: &mut Child) { - #[cfg(unix)] - { - let pid = child.id() as libc::pid_t; - // SAFETY: plain syscall; the child leads its own process group (`prepare`). - unsafe { - libc::kill(-pid, libc::SIGTERM); - } - std::thread::sleep(Duration::from_millis(20)); - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { std::thread::spawn(move || { let mut buf = Vec::new(); @@ -88,11 +70,11 @@ pub(crate) fn run_with_input( break status; } if cancel.is_some_and(CancellationToken::is_cancelled) { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } if Instant::now() >= deadline { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::unavailable("docker did not respond in time")); } std::thread::sleep(Duration::from_millis(10)); @@ -166,7 +148,7 @@ pub(crate) fn stream( let mut stderr_tail: Vec = Vec::new(); while open > 0 { if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(50)) { @@ -189,7 +171,7 @@ pub(crate) fn stream( break status; } if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } std::thread::sleep(Duration::from_millis(10)); diff --git a/crates/ide-github/Cargo.toml b/crates/ide-github/Cargo.toml index 0e33a8e8..5d67e56a 100644 --- a/crates/ide-github/Cargo.toml +++ b/crates/ide-github/Cargo.toml @@ -13,6 +13,3 @@ serde_json.workspace = true specta.workspace = true tracing.workspace = true which = "8" - -[target.'cfg(unix)'.dependencies] -libc = "0.2" diff --git a/crates/ide-github/src/process.rs b/crates/ide-github/src/process.rs index d5eec750..e945645b 100644 --- a/crates/ide-github/src/process.rs +++ b/crates/ide-github/src/process.rs @@ -1,11 +1,16 @@ //! Child processes of the gh CLI: bounded runs and cancellable line streams. +use ide_core::process::terminate_tree; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::Read; use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; +/// How long a cancelled or timed-out child's process group (gh spawns git and helpers) +/// gets to exit on `SIGTERM` before `SIGKILL`: git removes its lock files on `SIGTERM`. +const KILL_GRACE: Duration = Duration::from_millis(20); + pub(crate) struct Output { pub stdout: String, pub stderr: String, @@ -20,36 +25,13 @@ pub(crate) enum Stream { fn prepare(cmd: &mut Command) { cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - ide_core::process::prepare(cmd); + ide_core::process::prepare(ide_core::process::own_group(cmd)); } fn spawn(cmd: &mut Command) -> IdeResult { cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}"))) } -/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers). -pub(crate) fn kill(child: &mut Child) { - #[cfg(unix)] - { - let pid = child.id() as libc::pid_t; - // SAFETY: plain syscall; the child leads its own process group (`prepare`). - unsafe { - libc::kill(-pid, libc::SIGTERM); - } - std::thread::sleep(Duration::from_millis(20)); - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); - let _ = child.wait(); -} - fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { std::thread::spawn(move || { let mut buf = Vec::new(); @@ -92,11 +74,11 @@ pub(crate) fn run_with_input( break status; } if cancel.is_some_and(CancellationToken::is_cancelled) { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } if Instant::now() >= deadline { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::unavailable("gh did not respond in time")); } std::thread::sleep(Duration::from_millis(10)); @@ -170,7 +152,7 @@ pub(crate) fn stream( let mut stderr_tail: Vec = Vec::new(); while open > 0 { if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(50)) { @@ -193,7 +175,7 @@ pub(crate) fn stream( break status; } if cancel.is_cancelled() { - kill(&mut child); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } std::thread::sleep(Duration::from_millis(10)); diff --git a/crates/ide-tools/Cargo.toml b/crates/ide-tools/Cargo.toml index dadce894..dea069bd 100644 --- a/crates/ide-tools/Cargo.toml +++ b/crates/ide-tools/Cargo.toml @@ -20,8 +20,8 @@ zip.workspace = true sha2 = "0.10" hex = "0.4" -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [dev-dependencies] tempfile.workspace = true + +[target.'cfg(unix)'.dev-dependencies] +libc = "0.2" diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index 11bc37b4..8a7694fe 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -4,48 +4,16 @@ use crate::InstallEvent; use ide_core::error::ErrorKind; use ide_core::output_encoding::decode_tool_output; +use ide_core::process::{kill_tree, own_group}; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::{BufRead, BufReader, Read}; use std::path::Path; -use std::process::{Child, Command, Stdio}; +use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; pub type Emit<'a> = &'a dyn Fn(InstallEvent); -/// Starts the child as the leader of its own process group, so [`kill`] reaches what it -/// starts in turn (`npm` → `node`, `go install` → the compiler, `pip` → build backends). -fn own_group(cmd: &mut Command) -> &mut Command { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - cmd -} - -/// Kills the child and everything it started. -fn kill(child: &mut Child) { - #[cfg(unix)] - if let Ok(pid) = i32::try_from(child.id()) { - // SAFETY: plain syscall; the child leads its own process group (`own_group`) and is not reaped yet. - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - #[cfg(windows)] - { - let mut taskkill = Command::new("taskkill"); - ide_core::process::prepare(&mut taskkill) - .args(["/T", "/F", "/PID", &child.id().to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - let _ = taskkill.status(); - } - let _ = child.kill(); - let _ = child.wait(); -} - fn describe(cmd: &Command) -> String { let mut s = Path::new(cmd.get_program()).file_name().unwrap_or_default().to_string_lossy().into_owned(); for a in cmd.get_args() { @@ -100,7 +68,7 @@ pub fn run_collect(mut cmd: Command, cancel: &CancellationToken, emit: Emit) -> let mut output = String::new(); let status = loop { if cancel.is_cancelled() { - kill(&mut child); + kill_tree(&mut child); return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(100)) { @@ -144,7 +112,7 @@ pub fn run_quiet(mut cmd: Command, timeout: Duration) -> Option<(bool, String)> Ok(Some(s)) => break s, Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(15)), _ => { - kill(&mut child); + kill_tree(&mut child); return None; } } @@ -256,7 +224,7 @@ impl Http { }); let status = loop { if cancel.is_cancelled() { - kill(&mut child); + kill_tree(&mut child); return Err(IdeError::cancelled()); } match child.try_wait()? { @@ -308,7 +276,7 @@ impl Http { let mut last_bytes = 0; let status = loop { if cancel.is_cancelled() { - kill(&mut child); + kill_tree(&mut child); let _ = std::fs::remove_file(dest); return Err(IdeError::cancelled()); } @@ -413,7 +381,7 @@ pub(crate) fn run_input( } }; if result.is_err() { - kill(&mut child); + kill_tree(&mut child); } result?; let output = output.unwrap()?; diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 051984ed..0f865cea 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -1,13 +1,18 @@ //! Thin wrapper around the `git` executable used for every mutating operation. +use ide_core::process::{own_group, terminate_tree}; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Command, Stdio}; use std::sync::OnceLock; use std::sync::mpsc; use std::time::{Duration, Instant}; +/// How long a cancelled git's process group (remote helpers, `ssh`, credential helpers) gets +/// to exit on `SIGTERM` before `SIGKILL`: git removes its `index.lock` on `SIGTERM`. +const KILL_GRACE: Duration = Duration::from_millis(20); + /// Explicit git executable from Settings › Version Control › Git (`None` = auto-detect). static CONFIGURED: std::sync::RwLock> = std::sync::RwLock::new(None); @@ -361,11 +366,7 @@ pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &Cancellat } let mut cmd = command(root)?; cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } + own_group(&mut cmd); let mut session = CommandSession::start(&mut cmd); let mut child = cmd.spawn().map_err(spawn_error)?; let capture = |mut pipe: Box| { @@ -378,8 +379,7 @@ pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &Cancellat let stderr = capture(Box::new(child.stderr.take().expect("piped stderr"))); let status = loop { if cancel.is_cancelled() { - kill(&mut child); - let _ = child.wait(); + terminate_tree(&mut child, KILL_GRACE); let _ = stdout.join(); let _ = stderr.join(); return Err(IdeError::cancelled()); @@ -504,11 +504,7 @@ pub(crate) fn run_streaming_env( cmd.env(k, v); } cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } + own_group(&mut cmd); tracing::debug!(?args, "git (streaming)"); let mut session = CommandSession::start(&mut cmd); let mut child = cmd.spawn().map_err(spawn_error)?; @@ -529,8 +525,7 @@ pub(crate) fn run_streaming_env( let mut open = readers.len(); while open > 0 { if cancel.is_cancelled() { - kill(&mut child); - let _ = child.wait(); + terminate_tree(&mut child, KILL_GRACE); for r in readers { let _ = r.join(); } @@ -559,8 +554,7 @@ pub(crate) fn run_streaming_env( break status; } if cancel.is_cancelled() { - kill(&mut child); - let _ = child.wait(); + terminate_tree(&mut child, KILL_GRACE); return Err(IdeError::cancelled()); } std::thread::sleep(Duration::from_millis(10)); @@ -644,22 +638,6 @@ fn spawn_reader( }) } -fn kill(child: &mut Child) { - #[cfg(unix)] - { - let pid = child.id() as libc::pid_t; - // SAFETY: plain syscall; the child was spawned as leader of its own process group. - unsafe { - libc::kill(-pid, libc::SIGTERM); - } - std::thread::sleep(Duration::from_millis(20)); - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - let _ = child.kill(); -} - #[cfg(test)] mod command_session_tests { use super::*; From df9a9e0be0dadb159d7793f3481d1a0567a04fa0 Mon Sep 17 00:00:00 2001 From: kevinh <45650311+kevinh458@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:21:14 +0800 Subject: [PATCH 044/108] feat(terminal): reorder terminal tabs by dragging them (#836) The Terminal tool window's tab strip uses the drag and drop Tabs already supports for editor tabs, with its own drag type so terminal and editor tabs don't mix; dropping moves the session tab to the insertion point. Fixes #775 Co-authored-by: kevinh458 <45650311+kevinh458@users.noreply.github.com> --- src/features/terminal/TerminalView.tsx | 10 ++++++++++ src/features/terminal/store.ts | 14 ++++++++++++++ src/features/terminal/terminal.test.ts | 16 ++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index 12d5b0ae..37ec86b0 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -16,9 +16,11 @@ import { Tabs, type MenuItem, type TabItem, + type TabsDnd, } from "@/ui"; import { getSession, + moveTab, newTab, publishFocus, renameTab, @@ -36,6 +38,13 @@ import styles from "./Terminal.module.css"; const TAB_PREFIX = "openidea-terminal"; +/** Dragging a tab reorders the sessions, like IDEA's terminal tabs. */ +const TAB_DND: TabsDnd = { + type: "application/x-openidea-terminal-tab", + payload: (id) => id, + onDrop: (id, index) => moveTab(id, index), +}; + /** Full title when the label is trimmed, then the working directory. */ function tabTooltip(tab: TerminalTab, sessions: Parameters[1]): string | undefined { const full = tabFullTitle(tab, sessions); @@ -336,6 +345,7 @@ export function TerminalTabs() { onClose={(id) => void onClose(id)} onContextMenu={tabMenu} panelIdPrefix={TAB_PREFIX} + dnd={TAB_DND} trailing={
diff --git a/src/features/terminal/store.ts b/src/features/terminal/store.ts index 463bfb6b..16c02e9b 100644 --- a/src/features/terminal/store.ts +++ b/src/features/terminal/store.ts @@ -205,6 +205,20 @@ export function selectTab(id: string) { useTerminals.setState({ activeTab: id }); } +/** `tabs` with tab `id` moved to insertion `index`, an index into `tabs` before the move. */ +export function reorderTabs(tabs: readonly T[], id: string, index: number): T[] { + const from = tabs.findIndex((t) => t.id === id); + if (from < 0) return [...tabs]; + const rest = tabs.filter((t) => t.id !== id); + rest.splice(Math.max(0, Math.min(index > from ? index - 1 : index, rest.length)), 0, tabs[from]!); + return rest; +} + +/** Drag & drop in the tab strip. */ +export function moveTab(id: string, index: number) { + useTerminals.setState((s) => ({ tabs: reorderTabs(s.tabs, id, index) })); +} + export function renameTab(id: string, name: string) { const trimmed = name.trim(); useTerminals.setState((s) => ({ diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 548cb5ed..1a5edf3c 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -8,6 +8,7 @@ import { layoutPanes, nextTabName, removeFromLayout, + reorderTabs, splitLayout, tabFullTitle, tabTitle, @@ -230,6 +231,21 @@ describe("tab names", () => { }); }); +describe("tab order", () => { + const ids = (tabs: { id: string }[]) => tabs.map((t) => t.id).join(""); + const tabs = [{ id: "a" }, { id: "b" }, { id: "c" }]; + + it("moves a dragged tab to the insertion index", () => { + expect(ids(reorderTabs(tabs, "a", 3))).toBe("bca"); + expect(ids(reorderTabs(tabs, "a", 2))).toBe("bac"); + expect(ids(reorderTabs(tabs, "c", 0))).toBe("cab"); + expect(ids(reorderTabs(tabs, "b", 1))).toBe("abc"); + expect(ids(reorderTabs(tabs, "b", 2))).toBe("abc"); + }); + + it("ignores unknown tabs", () => expect(ids(reorderTabs(tabs, "x", 0))).toBe("abc")); +}); + describe("terminal find", () => { it("formats the match counter", () => { expect(matchCounter(null, "x")).toBe(""); From e584f044d7213715d63cc14587d35a8552b515cd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:21:21 +0800 Subject: [PATCH 045/108] fix(java): say when a configured JDK path no longer exists (#764) A JDK path chosen in settings that was later removed (a version manager replacing it on upgrade) was reported as "is not a JDK home", which reads as a wrong folder layout. A missing path is now reported as missing, with how to fix it; the project JDK's log warning uses the same wording. Fixes #741 --- crates/ide-sdk/src/apply.rs | 19 ++++++++++++++----- crates/ide-sdk/src/tests.rs | 7 ++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 8f60fa5d..8c484f9f 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -78,7 +78,7 @@ fn jdk_setting(root: &Path, value: &str) -> Option Option { match jdk_setting(root, &s.languages.java.jdk) { - Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {} is not a JDK home", p.display())).ok(), + Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {}", not_a_jdk(p))).ok(), None => pinned::pinned_install(root, Tool::Java).and_then(|(dir, _)| pinned::jdk_in_install(&dir)), } } @@ -92,10 +92,19 @@ pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result, Err(crate::java::server_jdk_incomplete_message(&jdk, jdk.major.max(crate::java::JDTLS_MIN_JAVA))) } Some(Ok(jdk)) => Ok(Some(jdk)), - Some(Err(p)) => Err(format!( - "The language server JDK {} is not a JDK home (Settings › Languages & Frameworks › Java).", - p.display() - )), + Some(Err(p)) => { + Err(format!("The language server JDK {} (Settings › Languages & Frameworks › Java).", not_a_jdk(&p))) + } + } +} + +/// Why the configured JDK path `p` is not used: gone (e.g. a version manager removed +/// it on upgrade) or not a JDK. +fn not_a_jdk(p: &Path) -> String { + if p.exists() { + format!("{} is not a JDK home", p.display()) + } else { + format!("{} does not exist; choose another JDK or clear the setting to detect one", p.display()) } } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 5f3ed5a0..dd4628b2 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -319,8 +319,13 @@ fn jdtls_jdk_setting() { .unwrap(); assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("full JDK")); // A chosen path that is not a JDK is an error, not a silent fallback. - s.languages.java.jdtls_jdk = "missing".into(); + std::fs::create_dir(tmp.path().join("not-a-jdk")).unwrap(); + s.languages.java.jdtls_jdk = "not-a-jdk".into(); assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("is not a JDK home")); + // A JDK removed after it was chosen (a version manager upgraded it) says so. + s.languages.java.jdtls_jdk = "jdk17.0.1".into(); + let err = jdtls_jdk(tmp.path(), &s).unwrap_err(); + assert!(err.contains("jdk17.0.1 does not exist") && err.contains("Languages & Frameworks › Java"), "{err}"); } #[test] From a623e5d3851f91e723cfd4ec05af04e1e9d28d86 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Mon, 2 Nov 2026 13:27:24 +0100 Subject: [PATCH 046/108] feat(python): add ruff language server for lint, format and organize imports (#859) In projects configured for Ruff (ruff.toml, .ruff.toml, or pyproject.toml with a [tool.ruff] table), ruff server runs next to basedpyright as a managed pip tool: its lint diagnostics, Reformat Code / format on save, Optimize Imports and Code Cleanup's fix-all. It is listed after the type checkers, so hover, navigation and rename stay with them; projects without a Ruff configuration never start or install it. Root markers accept file#table to require a TOML table, and a hand-defined server named ruff now only overrides how the built-in one launches. Verified with the real-servers end-to-end test against ruff and basedpyright. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- crates/ide-language/src/lib.rs | 4 +- crates/ide-language/src/markers.rs | 45 ++++++++++++++++++- crates/ide-language/src/registry.rs | 26 +++++++++++ crates/ide-lsp/tests/real_servers.rs | 56 ++++++++++++++++++++++++ crates/ide-sdk/src/tests.rs | 37 +++++++++++++--- crates/ide-tools/src/registry.rs | 11 +++++ crates/ide-tools/src/tests.rs | 1 + languages/python.toml | 10 +++++ src/features/tools/ServerSettings.tsx | 4 +- src/platform/locales/textCatalog.test.ts | 2 +- 10 files changed, 184 insertions(+), 12 deletions(-) diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index a42587cc..908e8315 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -96,7 +96,9 @@ pub struct LanguageServerConfig { pub args: Vec, #[serde(default)] pub env: BTreeMap, - /// Files whose presence marks a workspace root, e.g. `Cargo.toml`. + /// Files whose presence marks a workspace root, e.g. `Cargo.toml`; + /// `file#table` requires the TOML file to contain that dotted table, e.g. + /// `pyproject.toml#tool.ruff`. #[serde(default)] pub root_markers: Vec, /// Serve only files below a root marker, not the whole project: a framework's diff --git a/crates/ide-language/src/markers.rs b/crates/ide-language/src/markers.rs index 832dc0c6..05eb9b0f 100644 --- a/crates/ide-language/src/markers.rs +++ b/crates/ide-language/src/markers.rs @@ -19,9 +19,13 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) best } -/// `dir` contains `marker`: a relative path, or a file name pattern with one -/// `*` (`*.csproj`). +/// `dir` contains `marker`: a relative path, a file name pattern with one +/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it +/// must contain (`pyproject.toml#tool.ruff`). fn has_marker(dir: &Path, marker: &str) -> bool { + if let Some((file, table)) = marker.split_once('#') { + return has_toml_table(&dir.join(file), table); + } let Some((prefix, suffix)) = marker.split_once('*') else { return dir.join(marker).exists(); }; @@ -30,9 +34,23 @@ fn has_marker(dir: &Path, marker: &str) -> bool { }) } +/// `file` parses as TOML and has the dotted `table` (`tool.ruff`). +fn has_toml_table(file: &Path, table: &str) -> bool { + let Ok(text) = std::fs::read_to_string(file) else { return false }; + let value = match toml::from_str::(&text) { + Ok(value) => value, + Err(e) => { + tracing::debug!("root marker {}: {e}", file.display()); + return false; + } + }; + table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table) +} + /// `path` is (or, after its creation or removal, was) a root marker of the /// directory above it. pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool { + let marker = marker.split_once('#').map_or(marker, |(file, _)| file); match marker.split_once('*') { None => path.ends_with(marker), Some((prefix, suffix)) => { @@ -74,6 +92,29 @@ mod tests { assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf())); } + #[test] + fn toml_markers_need_their_table() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("svc/app/main.py"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let markers = vec!["ruff.toml".to_string(), "pyproject.toml#tool.ruff".to_string()]; + std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"\n[tool.black]\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), None); + std::fs::write(root.join("svc/pyproject.toml"), "[tool.ruff.lint]\nselect = [\"E\"]\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + // Outermost wins: an inline table counts; a value that is no table, or a + // file that does not parse, does not. + std::fs::write(root.join("pyproject.toml"), "tool = { ruff = { line-length = 100 } }\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.to_path_buf())); + std::fs::write(root.join("pyproject.toml"), "tool = { ruff = 1 }\n").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + std::fs::write(root.join("pyproject.toml"), "[tool.ruff]\n[broken").unwrap(); + assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc"))); + assert!(is_marker_path(&root.join("svc/pyproject.toml"), "pyproject.toml#tool.ruff")); + assert!(!is_marker_path(&root.join("svc/setup.cfg"), "pyproject.toml#tool.ruff")); + } + #[test] fn patterns_match_file_names() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 58494359..d777ba37 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -604,6 +604,32 @@ mod tests { } } + #[test] + fn ruff_serves_python_files_only_below_its_config() { + let reg = reg(); + let cfg = reg.get(&LanguageId::new("python")).unwrap(); + let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect(); + // After the fallbacks: hover, rename and symbols stay with the type checker. + assert_eq!(names, ["basedpyright", "pyright", "ty", "ruff"]); + let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap(); + assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice())); + assert!(ruff.require_root_marker && !ruff.fallback); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in ["lint", "dot", "pyproject", "plain"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + std::fs::write(root.join("lint/ruff.toml"), "").unwrap(); + std::fs::write(root.join("dot/.ruff.toml"), "").unwrap(); + std::fs::write(root.join("pyproject/pyproject.toml"), "[tool.ruff]\nline-length = 100\n").unwrap(); + std::fs::write(root.join("plain/pyproject.toml"), "[project]\nname = \"plain\"\n").unwrap(); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &ruff.root_markers); + for dir in ["lint", "dot", "pyproject"] { + assert_eq!(served(&format!("{dir}/pkg/main.py")), Some(root.join(dir)), "{dir}"); + } + assert_eq!(served("plain/main.py"), None); + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index f25f7774..3ce021f3 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -194,6 +194,62 @@ async fn ty_python_end_to_end() { .await; } +/// Ruff next to basedpyright in a project configured for Ruff: its lint +/// diagnostics, the only formatter and the only Organize Imports and Fix All. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires ruff and basedpyright on PATH"] +async fn ruff_next_to_basedpyright_end_to_end() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::write(root.join("pyproject.toml"), "[tool.ruff.lint]\nselect = [\"F\", \"I\"]\n").unwrap(); + let path = root.join("example.py"); + let source = "import sys\nimport os\n\nx = os.getcwd( )\n"; + std::fs::write(&path, source).unwrap(); + let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap(); + python.language_servers.retain(|server| matches!(server.name.as_str(), "basedpyright" | "ruff")); + assert_eq!(python.language_servers.len(), 2); + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python])); + let doc = DocId::next(); + mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source); + for name in ["basedpyright", "ruff"] { + retry(name, Duration::from_secs(60), || { + let status = last_status(&events, name); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status { + panic!("{name} failed: {msg:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + } + // F401 (`sys` unused) and I001 (unsorted imports). + let codes = retry("ruff diagnostics", Duration::from_secs(30), || { + let codes: Option> = events.lock().iter().rev().find_map(|e| match e { + IdeEvent::Diagnostics { source, items, .. } if source == "ruff" && !items.is_empty() => { + Some(items.iter().filter_map(|d| d.code.clone()).collect()) + } + _ => None, + }); + async move { codes } + }) + .await; + assert!(codes.iter().any(|c| c == "F401") && codes.iter().any(|c| c == "I001"), "{codes:?}"); + + let options = ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }; + let edits = mgr.format(doc, None, options).await.unwrap(); + assert!(!edits.is_empty() && edits.iter().all(|e| e.range.start.line == 3), "{edits:?}"); + + let whole = Range::new(Pos::new(0, 0), Pos::new(4, 0)); + for kind in ["source.organizeImports", "source.fixAll"] { + let actions = mgr.code_actions(doc, whole, vec![kind.into()]).await.unwrap(); + let kinds: Vec<&str> = actions.iter().filter_map(|a| a.kind.as_deref()).collect(); + assert_eq!(kinds, [format!("{kind}.ruff")], "{actions:?}"); + let action = mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap(); + let edit = action.edit.expect("an edit"); + assert!(!edited_lines(&edit, &path).is_empty(), "{edit:?}"); + } + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires rust-analyzer"] async fn rust_analyzer_end_to_end() { diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index dd4628b2..3e121f24 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -254,10 +254,10 @@ fn language_server_settings_disable_add_and_override() { let mut s = Settings::default(); s.language_servers.insert("pylsp".into(), LanguageServerSettings { enabled: false, ..Default::default() }); s.language_servers.insert( - "ruff".into(), + "pylyzer".into(), LanguageServerSettings { - command: "ruff".into(), - args: Some(vec!["server".into()]), + command: "pylyzer".into(), + args: Some(vec!["--server".into()]), languages: vec!["python".into()], root_markers: vec!["pyproject.toml".into()], ..Default::default() @@ -265,17 +265,42 @@ fn language_server_settings_disable_add_and_override() { ); let defined = [server("basedpyright"), server("pylsp")]; let names = |v: Vec| v.into_iter().map(|c| c.name).collect::>(); - assert_eq!(names(language_servers("python", &defined, &s)), ["basedpyright", "ruff"]); + assert_eq!(names(language_servers("python", &defined, &s)), ["basedpyright", "pylyzer"]); assert_eq!(names(language_servers("rust", &[server("rust-analyzer")], &s)), ["rust-analyzer"]); // Listed with the tools even while disabled. let mut langs = ide_language::LanguageRegistry::load(None).unwrap().all(); with_custom_servers(&mut langs, &s); let python = langs.iter().find(|l| l.id.as_str() == "python").unwrap(); + let mut pylyzer = python.language_servers.iter().find(|c| c.name == "pylyzer").unwrap().clone(); + assert_eq!(pylyzer.root_markers, ["pyproject.toml"]); + server_launch_overrides(&mut pylyzer, &s); + assert_eq!((pylyzer.command.as_str(), pylyzer.args.as_slice()), ("pylyzer", &["--server".to_string()][..])); + + // A hand-defined server named like a built-in one only overrides how it + // launches: languages and root markers stay the built-in definition's. + s.language_servers.insert( + "ruff".into(), + LanguageServerSettings { + command: "/opt/ruff".into(), + args: Some(vec!["server".into(), "--preview".into()]), + languages: vec!["python".into()], + root_markers: vec!["pyproject.toml".into()], + ..Default::default() + }, + ); + let mut langs = ide_language::LanguageRegistry::load(None).unwrap().all(); + with_custom_servers(&mut langs, &s); + let python = langs.iter().find(|l| l.id.as_str() == "python").unwrap(); + assert_eq!(python.language_servers.iter().filter(|c| c.name == "ruff").count(), 1); let mut ruff = python.language_servers.iter().find(|c| c.name == "ruff").unwrap().clone(); - assert_eq!(ruff.root_markers, ["pyproject.toml"]); + assert_eq!(ruff.root_markers, ["ruff.toml", ".ruff.toml", "pyproject.toml#tool.ruff"]); + assert!(ruff.require_root_marker); server_launch_overrides(&mut ruff, &s); - assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", &["server".to_string()][..])); + assert_eq!( + (ruff.command.as_str(), ruff.args.as_slice()), + ("/opt/ruff", &["server".to_string(), "--preview".to_string()][..]) + ); s.language_servers.insert( "rust-analyzer".into(), diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 661a7170..755da795 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -233,6 +233,17 @@ pub static TOOLS: &[ToolSpec] = &[ homepage: "https://docs.astral.sh/ty/", needs: &[], }, + ToolSpec { + name: "ruff", + display: "Ruff", + kind: LanguageServer, + commands: &["ruff"], + method: Method::PipVenv { package: "ruff" }, + fallback: None, + version: ver("ruff"), + homepage: "https://docs.astral.sh/ruff/", + needs: &[], + }, ToolSpec { name: "bash-language-server", display: "Bash Language Server", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 00a3c55e..ccb35e2b 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -157,6 +157,7 @@ fn interchangeable_servers_list_their_alternatives() { assert_eq!(find(&list, "vtsls").alternatives, ["vtsls", "typescript-language-server"]); // A regular server after a fallback starts no group with it. assert!(find(&list, "angular-language-server").alternatives.is_empty()); + assert!(find(&list, "ruff").alternatives.is_empty()); assert!(find(&list, "rust-analyzer").alternatives.is_empty()); } diff --git a/languages/python.toml b/languages/python.toml index b450c3dd..1d3b9504 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -40,6 +40,16 @@ args = ["server"] root-markers = ["ty.toml", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"] install-hint = "uv tool install ty (or: pip install ty)" +# Lint, format, organize imports and fix all next to the type checker, in +# projects configured for Ruff (formatting is Ruff's alone: basedpyright has none). +[[language-servers]] +name = "ruff" +command = "ruff" +args = ["server"] +root-markers = ["ruff.toml", ".ruff.toml", "pyproject.toml#tool.ruff"] +require-root-marker = true +install-hint = "pip install ruff (or: uv tool install ruff)" + [debugger] adapter = "debugpy" command = "python3" diff --git a/src/features/tools/ServerSettings.tsx b/src/features/tools/ServerSettings.tsx index 2e9710e4..3f10ea89 100644 --- a/src/features/tools/ServerSettings.tsx +++ b/src/features/tools/ServerSettings.tsx @@ -553,7 +553,7 @@ export function NewServerDialog({ const [commandLine, setCommandLine] = useState(""); const [name, setName] = useState(null); const [languages, setLanguages] = useState([]); - // "ruff server": the executable, then its arguments. + // "pylyzer --server": the executable, then its arguments. let words: string[] = []; let quoteError: string | null = null; try { @@ -600,7 +600,7 @@ export function NewServerDialog({ autoFocus className={styles.grow} value={commandLine} - placeholder="ruff server" + placeholder="pylyzer --server" onChange={(e) => setCommandLine(e.target.value)} /> ([ "NAME=value; OTHER=value", "node_modules/typescript", "PascalCase", + "pylyzer --server", "pyproject.toml, .git", - "ruff server", "run pytest", "snake_case", "tests", From 8ea8c98a39c5d862e030fc2faea93c282cb9d05b Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:37:03 +0700 Subject: [PATCH 047/108] feat(maven): reload a stale Maven build after external changes to its files (#780) When a pom.xml changes outside the IDE (a VCS update, another tool) and the model is stale afterwards, the Maven build now reloads on its own; edits saved from the editor still only show "Load Maven Changes". A change counts as external unless the IDE itself saved that file moments before, so a build file open in a tab that a tool rewrites also reloads. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/build-tools/autoReload.test.ts | 68 +++++++++++++++++++++ src/features/build-tools/index.ts | 14 ++++- src/features/build-tools/store.ts | 40 ++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/features/build-tools/autoReload.test.ts diff --git a/src/features/build-tools/autoReload.test.ts b/src/features/build-tools/autoReload.test.ts new file mode 100644 index 00000000..90eaa015 --- /dev/null +++ b/src/features/build-tools/autoReload.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { BuildModel, MavenModel, ProjectInfo } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { changedOutsideIde, loadModel, noteExternalBuildChange, noteIdeSave, useBuild } from "./store"; + +const mocks = vi.hoisted(() => ({ model: vi.fn(), sync: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + call: (value: unknown) => value, + commands: { + ...real.commands, + buildModel: mocks.model, + runConfigs: () => Promise.resolve([]), + buildSync: mocks.sync, + cancelRequest: () => Promise.resolve(), + }, + }; +}); +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage: unknown = null; + }, +})); + +const maven = (stale: boolean) => + ({ syncedAt: 1, stale, syncError: null, profiles: [], projects: [] }) as unknown as MavenModel; +const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: null }); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sync.mockImplementation(() => new Promise(() => {})); + useBuild.setState({ syncing: {} }); + useProject.setState({ project: { root: "/p", trusted: true } as ProjectInfo }); +}); + +it("reloads a build made stale by a change outside the editor, not by an edit in it", async () => { + mocks.model.mockResolvedValue(model(true)); + await loadModel(); + expect(mocks.sync).not.toHaveBeenCalled(); + + noteExternalBuildChange(); + await loadModel(); + expect(mocks.sync).toHaveBeenCalledTimes(1); + expect(mocks.sync.mock.calls[0]![0]).toBe("maven"); + expect(useBuild.getState().model?.maven?.stale).toBe(true); +}); + +it("leaves an up-to-date build alone after an external change", async () => { + mocks.model.mockResolvedValue(model(false)); + noteExternalBuildChange(); + await loadModel(); + expect(mocks.sync).not.toHaveBeenCalled(); +}); + +it("tells the IDE's own saves of a build file apart from changes made outside it", () => { + noteIdeSave("/p/pom.xml", 1000); + expect(changedOutsideIde(["/p/pom.xml"], 2000)).toBe(false); + // A tool rewrites the same file later, while it is still open in a tab. + expect(changedOutsideIde(["/p/pom.xml"], 10_000)).toBe(true); + // Never saved by the IDE: a VCS update. + expect(changedOutsideIde(["/p/core/pom.xml"], 2000)).toBe(true); + noteIdeSave("/p/pom.xml", 20_000); + expect(changedOutsideIde(["/p/pom.xml", "/p/core/pom.xml"], 20_500)).toBe(true); +}); diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index e80e103e..172d3e3a 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -3,6 +3,7 @@ * project has that build system, like IDEA. The model is re-read when build * files change; running goals / tasks goes through the run feature's actions. */ +import { onEditorEvent } from "@/editor/api"; import { executeAction, registerActions } from "@/platform/actions"; import { setContext } from "@/platform/context"; import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc"; @@ -25,6 +26,9 @@ import { unlinkProjects, cancelSync, loadModel, + changedOutsideIde, + noteExternalBuildChange, + noteIdeSave, openAnalyzer, resetBuild, setExpanded, @@ -158,8 +162,16 @@ export function activate(): () => void { resetBuild(); if (s.project) void loadModel(); }), + onEditorEvent((e) => { + if (e.type === "docSaved" && BUILD_FILE.test(e.path)) noteIdeSave(e.path); + }), onIdeEvent("fsChanged", (e) => { - if ([...e.created, ...e.modified, ...e.removed].some((p) => BUILD_FILE.test(p))) scheduleLoad(); + const changed = [...e.created, ...e.modified, ...e.removed].filter((p) => BUILD_FILE.test(p)); + if (!changed.length) return; + // Changes the IDE's own saves didn't make came from outside (a VCS update, another tool), + // also for a build file open in a tab. + if (changedOutsideIde(changed)) noteExternalBuildChange(); + scheduleLoad(); }), onIdeEvent("projectRootsChanged", scheduleLoad), // Project JDK / Maven home and options are applied to the model's launchers. diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 2b6c8a5d..f3555e4c 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -142,6 +142,41 @@ export function pushHistory(tool: BuildTool, line: string) { // Model let generation = 0; +let externalChange = false; + +/** + * A build file changed outside the editor (VCS update, another tool): the next model load + * reloads a build it finds stale, IDEA's default "Reload after external changes". Edits in + * the editor only show "Load … Changes". + */ +export function noteExternalBuildChange() { + externalChange = true; +} + +/** How long after the IDE saves a build file its file-system events still count as that save. */ +const IDE_SAVE_WINDOW_MS = 5000; +/** Build files the IDE saved, with when: their file-system events are edits made in the IDE. */ +const savedByIde = new Map(); + +/** The IDE saved `path` (an editor save, not a tool rewriting it). */ +export function noteIdeSave(path: string, now = Date.now()) { + savedByIde.set(path, now); +} + +/** + * Whether any of the changed build files was written by something other than the IDE's own + * save: a VCS update or a tool, also when the file is open in an editor tab. + */ +export function changedOutsideIde(paths: readonly string[], now = Date.now()): boolean { + let external = false; + for (const path of paths) { + const at = savedByIde.get(path); + if (at !== undefined && now - at <= IDE_SAVE_WINDOW_MS) continue; + savedByIde.delete(path); + external = true; + } + return external; +} /** Re-reads the build model (and the run configurations) of the open project. */ export async function loadModel(): Promise { @@ -167,6 +202,11 @@ export async function loadModel(): Promise { ]); if (gen !== generation) return; useBuild.setState({ model, configs, loading: false, error: null }); + if (externalChange) { + externalChange = false; + const maven = model.maven; + if (maven?.stale && maven.syncedAt !== null && !useBuild.getState().syncing.maven) void syncTool("maven"); + } } catch (e) { if (gen === generation) useBuild.setState({ loading: false, error: e instanceof Error ? e.message : String(e) }); } From 795f8e85f1598ce1c7f4a35e60db9df7e63b27ef Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 02:25:48 +0800 Subject: [PATCH 048/108] fix(windows): de-parent settings window on Windows so DWM paints its shadow An owned child (WS_POPUP) of a borderless parent gets no DWM shadow, leaving the frameless settings window flat against its project window. Ownership is already tracked by the settings- label (parent-close cleanup in on_window_event, menu modality), so make the settings window top-level on Windows; macOS and Linux keep the native parent link. --- src-tauri/src/windows.rs | 10 +++++++--- src/features/settings/window/bridge.ts | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index b9d14c12..a47340ad 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -796,9 +796,13 @@ pub fn open_settings_window( #[cfg(target_os = "linux")] crate::linux_window_state::prepare(app, &mut config); let builder = WebviewWindowBuilder::from_config(app, &config); - // AppKit child windows can disappear when dragged to another display's Space. - // The settings label already tracks ownership and parent-close cleanup. - #[cfg(not(target_os = "macos"))] + // Windows: an owned child (WS_POPUP) of a borderless parent gets no DWM + // shadow, leaving the frameless settings window flat against its parent. + // Ownership is tracked by the `settings-` label instead + // (parent-close cleanup, menu modality), so only Linux keeps the native + // parent link. AppKit child windows can disappear when dragged to another + // display's Space, so macOS keeps it too. + #[cfg(target_os = "linux")] let builder = builder.and_then(|b| b.parent(&parent)); let window = builder .and_then(|b| dev_font_preferences(b).build()) diff --git a/src/features/settings/window/bridge.ts b/src/features/settings/window/bridge.ts index 3aa20209..e78722cf 100644 --- a/src/features/settings/window/bridge.ts +++ b/src/features/settings/window/bridge.ts @@ -1,8 +1,10 @@ /** * Settings window ↔ its project window. * - * Settings live in a native child window `settings-` (see - * src-tauri/src/windows.rs) that only runs the settings pages. The action + * Settings live in a separate window `settings-` (see + * src-tauri/src/windows.rs) that only runs the settings pages (a native + * child of the parent on macOS/Linux; top-level on Windows so DWM paints + * its shadow). The action * registry stays in the project window: it sends the settings window a * description of every action (Keymap page, links that run actions) and runs * the actions the settings window forwards. From 58ff3e93d04dbce5091ff2b9984147086d9a4ed7 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 19:04:25 +0800 Subject: [PATCH 049/108] fix(linux): paint a hairline edge on the frameless settings window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux compositors draw no shadow for undecorated windows (Wayland has no server-side decorations at all), so the frameless settings window is indistinguishable from the desktop there — #595's follow-up after the Windows DWM-shadow fix. Paint a 1px --border-window inset ring around the window root: the webview draws the same hairline on every windowing system. --- .../settings/__tests__/dialog.test.tsx | 20 +++++++++++++++++++ .../settings/dialog/SettingsDialog.module.css | 5 +++++ .../settings/window/SettingsWindow.tsx | 6 ++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index a72f4752..a187bf41 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -22,6 +22,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { }); import { registerAction, executeAction } from "@/platform/actions"; +import { usePlatform } from "@/platform/platformInfo"; import { useProject } from "@/platform/project"; import { useKeymap } from "@/platform/keymap"; import { ContextMenuHost } from "@/ui"; @@ -29,6 +30,8 @@ import { PageContext, useSetting } from "../components/context"; import { DEFAULT_SETTINGS, setAt } from "../model/schema"; import { openSettings, stage, useSettingsDialog } from "../model/staging"; import { SettingsDialog } from "../dialog/SettingsDialog"; +import { SettingsWindow } from "../window/SettingsWindow"; +import dialogStyles from "../dialog/SettingsDialog.module.css"; import { activate } from ".."; import { MessagesHost } from "@/workbench/messages"; @@ -471,6 +474,23 @@ describe("Settings dialog", () => { }); }); +describe("native Settings window root", () => { + it("marks the window edge on Linux, whose frameless windows get no compositor shadow", () => { + const saved = usePlatform.getState(); + usePlatform.setState({ os: "linux" }); + let view = render(); + let root = document.querySelector(`.${dialogStyles.window}`); + expect(root).toHaveClass(dialogStyles.windowEdge!); + view.unmount(); + usePlatform.setState({ os: "windows" }); + view = render(); + root = document.querySelector(`.${dialogStyles.window}`); + expect(root).not.toHaveClass(dialogStyles.windowEdge!); + view.unmount(); + usePlatform.setState(saved); + }); +}); + describe("native Settings window background", () => { it.each([ ["a hex frame", "#26282c", "", "#26282c"], diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 24fdea93..0a1105ab 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -203,6 +203,11 @@ background: var(--bg-dialog, var(--bg-elev)); color: var(--fg); } +/* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated + windows no CSD), so mark its edge in CSS instead. */ +.windowEdge { + box-shadow: inset 0 0 0 1px var(--border-window); +} .windowBody { flex: 1; min-height: 0; diff --git a/src/features/settings/window/SettingsWindow.tsx b/src/features/settings/window/SettingsWindow.tsx index e5509490..231a6064 100644 --- a/src/features/settings/window/SettingsWindow.tsx +++ b/src/features/settings/window/SettingsWindow.tsx @@ -1,4 +1,5 @@ /** Content of the native Settings window: title bar, page tree + page, buttons. */ +import clsx from "clsx"; import { useEffect, useRef } from "react"; import { OverlayHost } from "@/workbench/OverlayHost"; import { usePlatform } from "@/platform/platformInfo"; @@ -10,6 +11,7 @@ import styles from "../dialog/SettingsDialog.module.css"; export function SettingsWindow() { useApplyTheme(); + const os = usePlatform((s) => s.os); const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar); const open = useSettingsDialog((s) => s.open); const title = useSettingsTitle(); @@ -20,8 +22,8 @@ export function SettingsWindow() { }, [open]); return ( -
- {/* macOS overlays traffic lights on it; Windows is frameless. Linux keeps the OS title bar. */} +
+ {/* macOS overlays traffic lights on it; Windows and Linux are frameless (tauri..conf.json). */} {ownTitleBar && } {open && ( <> From 4e9442b499079d85794d207729f4d8b4d7f369dd Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 22:18:11 +0800 Subject: [PATCH 050/108] feat(search): find a Spring endpoint by its request path (#820) Search Everywhere lists Spring Web endpoints matched by request path (context path tolerated) or handler method name, and Enter opens the handler. The host scans Java/Kotlin sources that name a mapping; http-client parses and indexes them and hands the entries to search through an endpoint contribution, so search does not import http-client. The index is invalidated when a Java/Kotlin file changes. Route source path assertions compare by path component so they hold on Windows. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/mod.rs | 1 + crates/ide-host/src/ops/routes.rs | 134 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/nav.rs | 10 ++ src/bindings.ts | 7 + src/features/http-client/index.ts | 2 + .../http-client/spring/routeIndex.test.ts | 78 ++++++++++ src/features/http-client/spring/routeIndex.ts | 80 +++++++++++ .../search/__tests__/controller.test.ts | 27 ++++ .../search/__tests__/everywhereModel.test.ts | 2 +- src/features/search/__tests__/routes.test.ts | 61 ++++++++ src/features/search/everywhere/controller.ts | 16 ++- src/features/search/everywhere/deps.ts | 3 +- src/features/search/everywhere/model.ts | 11 +- src/features/search/everywhere/routes.ts | 79 +++++++++++ src/workbench/contributions.ts | 35 +++++ src/workbench/endpointProviders.test.ts | 46 ++++++ 17 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 crates/ide-host/src/ops/routes.rs create mode 100644 src/features/http-client/spring/routeIndex.test.ts create mode 100644 src/features/http-client/spring/routeIndex.ts create mode 100644 src/features/search/__tests__/routes.test.ts create mode 100644 src/features/search/everywhere/routes.ts create mode 100644 src/workbench/endpointProviders.test.ts diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 93a0b28f..c29dc4e9 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -22,6 +22,7 @@ pub mod notebook; pub mod project; mod python; pub mod refactor; +pub mod routes; pub mod run; pub mod sdk; pub mod search; diff --git a/crates/ide-host/src/ops/routes.rs b/crates/ide-host/src/ops/routes.rs new file mode 100644 index 00000000..cbf2965e --- /dev/null +++ b/crates/ide-host/src/ops/routes.rs @@ -0,0 +1,134 @@ +//! Spring Web mappings, source side: the project's Java and Kotlin files that could +//! declare a route, with their text. The UI owns the annotation parser and the index +//! it feeds, so this side stays a cheap scan. + +use crate::Host; +use ide_core::IdeResult; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Files handed out at most, and bytes in total: a project's controllers fit well +/// inside these, and the UI indexes them once. +const MAX_FILES: usize = 1024; +const MAX_BYTES: usize = 48 << 20; +/// A single source bigger than this is not a controller. +const MAX_FILE_BYTES: u64 = 4 << 20; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RouteSource { + pub path: String, + pub text: String, +} + +/// The files Spring mappings can live in: Java and Kotlin sources. +fn declares_mappings(path: &Path) -> bool { + matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts")) +} + +/// Files under `root` whose text names a mapping: `ignore` rules and `excludes` +/// decide what counts, and the UI ranks the matches itself. +pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec { + let found: Mutex> = Mutex::new(Vec::new()); + let bytes = AtomicUsize::new(0); + ide_vfs::walk_files(root, excludes, |path| { + if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES { + return; + } + if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + // Only a file that names a mapping annotation is worth parsing. + if !text.contains("Mapping") { + return; + } + let Ok(mut found) = found.lock() else { return }; + if found.len() >= MAX_FILES { + return; + } + bytes.fetch_add(text.len(), Ordering::Relaxed); + found.push(RouteSource { path: path.to_string_lossy().into_owned(), text }); + }); + let mut files = found.into_inner().unwrap_or_default(); + files.sort_by(|a, b| a.path.cmp(&b.path)); + files +} + +/// The folders to scan: the project folder itself first, then content roots outside it +/// (`content_roots` lists only the ones outside, so the project folder is never implied — +/// forgetting it scans nothing). +fn scan_roots(root: &Path, content_roots: Vec) -> Vec { + let mut roots = vec![root.to_path_buf()]; + roots.extend(content_roots); + roots +} + +/// Every mapping source of the project, for the request-path index. +pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult> { + let project = host.project(window)?; + let excludes = project.search_excludes(); + let mut out = Vec::new(); + for root in scan_roots(&project.root, project.content_roots()) { + out.extend(sources_in(&root, &excludes)); + } + out.sort_by(|a, b| a.path.cmp(&b.path)); + out.dedup_by(|a, b| a.path == b.path); + match out.len() { + 0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()), + n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root), + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_project_folder_is_scanned_even_without_other_content_roots() { + let root = Path::new("/p/demo"); + assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]); + assert_eq!( + scan_roots(root, vec![PathBuf::from("/p/elsewhere")]), + [PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")] + ); + } + + #[test] + fn only_java_and_kotlin_sources_are_read() { + for (name, expected) in + [("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)] + { + assert_eq!(declares_mappings(Path::new(name)), expected, "{name}"); + } + } + + #[test] + fn files_that_name_no_mapping_are_left_out() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap(); + std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap(); + std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap(); + let files = sources_in(tmp.path(), &[]); + assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>()); + assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path); + assert!(files[0].text.contains("@GetMapping")); + } + + #[test] + fn excluded_folders_are_skipped() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("module/src")).unwrap(); + std::fs::create_dir_all(tmp.path().join("build")).unwrap(); + std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap(); + std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap(); + let files = sources_in(tmp.path(), &["build/**".to_owned()]); + assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>()); + assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4457d138..1ef4d111 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -182,6 +182,7 @@ pub fn builder() -> tauri_specta::Builder { caches::caches_schedule_invalidation, caches::app_restart, nav::index_status, + nav::spring_route_sources, nav::find_files, nav::find_symbols, nav::mark_recent, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index 669fe850..dd1e1d5f 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -22,6 +22,16 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim ide_host::ops::nav::find_files(&state.host, window.label(), query, limit) } +/// Spring Web mapping sources for the request-path index of Search Everywhere. +#[tauri::command] +#[specta::specta] +pub fn spring_route_sources( + window: Window, + state: State<'_, AppState>, +) -> IdeResult> { + ide_host::ops::routes::spring_route_sources(&state.host, window.label()) +} + #[tauri::command] #[specta::specta] pub fn find_symbols( diff --git a/src/bindings.ts b/src/bindings.ts index 7c4a2d95..e76c5885 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -310,6 +310,8 @@ export const commands = { /** Quits through every window's unsaved-changes check, then starts again. */ appRestart: () => __TAURI_INVOKE("app_restart"), indexStatus: () => typedError(__TAURI_INVOKE("index_status")), + /** Spring Web mapping sources for the request-path index of Search Everywhere. */ + springRouteSources: () => typedError(__TAURI_INVOKE("spring_route_sources")), findFiles: (query: string, limit: number) => typedError(__TAURI_INVOKE("find_files", { query, limit })), findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError(__TAURI_INVOKE("find_symbols", { query, filter, limit })), markRecent: (path: string) => typedError(__TAURI_INVOKE("mark_recent", { path })), @@ -5312,6 +5314,11 @@ export type RevisionChange = { export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded"; +export type RouteSource = { + path: string, + text: string, +}; + export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize; export type RunConfig_Deserialize = { diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index c4ec7c32..a745a95a 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -28,6 +28,7 @@ import { menuGroupItems, springEditorExtension, } from "./spring/editor"; +import { registerSpringEndpointProvider } from "./spring/routeIndex"; const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin"; @@ -112,6 +113,7 @@ async function newRequest(directory?: string) { export function activate(): () => void { const disposers = [ + registerSpringEndpointProvider(), useProject.subscribe((s, prev) => { if (s.project?.root !== prev.project?.root) resetHttpClient(); }), diff --git a/src/features/http-client/spring/routeIndex.test.ts b/src/features/http-client/spring/routeIndex.test.ts new file mode 100644 index 00000000..6299973a --- /dev/null +++ b/src/features/http-client/spring/routeIndex.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { commands, emitLocalIdeEvent } from "@/platform/ipc"; +import { ipcPath } from "@/platform/paths"; +import { searchEndpoints } from "@/workbench/contributions"; +import { clearRouteIndex, isRouteSource, registerSpringEndpointProvider, routeIndex } from "./routeIndex"; + +const scanSources = vi.spyOn(commands, "springRouteSources"); + +const source = (method = "searchMenus", url = "/menu/search") => ({ + path: "/p/DeepLinkController.java", + text: `@RestController +@RequestMapping("/deeplink") +class DeepLinkController { + @PostMapping("${url}") + String ${method}() { return "ok"; } +}`, +}); + +let dispose: (() => void) | undefined; +afterEach(() => { + dispose?.(); + dispose = undefined; + clearRouteIndex(); + scanSources.mockReset(); +}); + +describe("the request-path index follows the workspace", () => { + it("is invalidated by a change to a mapping source", () => { + for (const [path, expected] of [ + ["/p/A.java", true], + ["/p/B.kt", true], + ["/p/C.kts", true], + ["/p/pom.xml", false], + ["/p/notes.md", false], + ] as const) { + expect(isRouteSource(path), path).toBe(expected); + } + }); +}); + +describe("the Spring endpoint provider", () => { + it("scans once per root with method coordinates and rescans for another root", async () => { + scanSources.mockResolvedValue({ status: "ok", data: [source()] }); + expect(await routeIndex(null)).toEqual([]); + const first = await routeIndex("/p"); + expect(first).toEqual([ + { + method: "POST", + url: "/deeplink/menu/search", + controller: "DeepLinkController", + methodName: "searchMenus", + path: "/p/DeepLinkController.java", + rel: "DeepLinkController.java", + line: 4, + col: 11, + }, + ]); + expect(await routeIndex("/p")).toBe(first); + expect(scanSources).toHaveBeenCalledTimes(1); + expect(await routeIndex("/other")).toEqual([]); + expect(scanSources).toHaveBeenCalledTimes(2); + }); + + it.each(["created", "modified", "removed"] as const)("invalidates on %s mapping sources", async (kind) => { + scanSources.mockResolvedValue({ status: "ok", data: [source()] }); + dispose = registerSpringEndpointProvider(); + expect((await searchEndpoints("/p"))[0]?.methodName).toBe("searchMenus"); + emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [ipcPath("/p/pom.xml")], removed: [] }); + await searchEndpoints("/p"); + expect(scanSources).toHaveBeenCalledTimes(1); + scanSources.mockResolvedValue({ status: "ok", data: [source("listMenus", "/menu/list")] }); + emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed: [], [kind]: [ipcPath("/p/A.java")] }); + expect((await searchEndpoints("/p"))[0]?.url).toBe("/deeplink/menu/list"); + expect(scanSources).toHaveBeenCalledTimes(2); + dispose(); + expect(await searchEndpoints("/p")).toEqual([]); + }); +}); diff --git a/src/features/http-client/spring/routeIndex.ts b/src/features/http-client/spring/routeIndex.ts new file mode 100644 index 00000000..edbb8404 --- /dev/null +++ b/src/features/http-client/spring/routeIndex.ts @@ -0,0 +1,80 @@ +/** Spring request-path index, cached per project until a mapping source changes. */ +import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions"; +import { springControllers } from "./endpoints"; + +export function registerSpringEndpointProvider(): () => void { + clearRouteIndex(); + const unregister = registerEndpointProvider({ id: "http.spring", endpoints: routeIndex }); + const unsubscribe = onIdeEvent("fsChanged", (e) => { + if ([...e.created, ...e.modified, ...e.removed].some((p) => isRouteSource(String(p)))) clearRouteIndex(); + }); + return () => { + unsubscribe(); + unregister(); + clearRouteIndex(); + }; +} + +let cached: { root: string; promise: Promise } | null = null; + +/** Whether `path` is a source a mapping can live in (a Java or Kotlin file). */ +export function isRouteSource(path: string): boolean { + return /\.(java|kt|kts)$/i.test(path); +} + +/** Forgets the index, so the next search rescans the project. */ +export function clearRouteIndex() { + cached = null; +} + +/** The project's routes, scanned once per project and kept in memory. */ +export function routeIndex(root: string | null): Promise { + if (!root) return Promise.resolve([]); + if (cached?.root === root) return cached.promise; + const promise = scan(root); + cached = { root, promise }; + return promise; +} + +async function scan(root: string): Promise { + const sources = await call(commands.springRouteSources()); + const routes: EndpointEntry[] = []; + for (const source of sources) { + if (!source.path.startsWith(root)) continue; + for (const controller of springControllers(source.text)) { + for (const endpoint of controller.endpoints) { + const at = lineCol(source.text, endpoint.offset); + // A mapping without a path answers nothing to look up by URL; the method + // itself is still reachable through the symbol index. + for (const url of endpoint.paths) { + routes.push({ + method: endpoint.httpMethod, + url, + controller: controller.name, + methodName: endpoint.methodName, + path: source.path, + rel: source.path.slice(root.length + 1), + line: at.line, + col: at.col, + }); + } + } + } + } + return routes; +} + +function lineCol(text: string, offset: number): { line: number; col: number } { + let line = 0; + let col = 0; + for (let i = 0; i < offset && i < text.length; i++) { + if (text.charCodeAt(i) === 10 /* \n */) { + line++; + col = 0; + } else { + col++; + } + } + return { line, col }; +} diff --git a/src/features/search/__tests__/controller.test.ts b/src/features/search/__tests__/controller.test.ts index 65ba0ae3..dbd9061b 100644 --- a/src/features/search/__tests__/controller.test.ts +++ b/src/features/search/__tests__/controller.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { FileMatches, SearchQuery } from "@/platform/ipc"; import { SeController, type SeDeps, type SeSnapshot } from "../everywhere/controller"; +import { buildRows } from "../everywhere/model"; function deferred() { let resolve!: (v: T) => void; @@ -195,3 +196,29 @@ describe("SeController", () => { expect(searchText.mock.calls.at(-1)![1].scope).toEqual({ kind: "fileSet", set: "test" }); }); }); + +describe("Endpoints in the All tab", () => { + it("answers a request path with the controller method that serves it", async () => { + const routes = [ + { + method: "POST", + url: "/deeplink/menu/search", + controller: "DeepLinkController", + methodName: "searchMenus", + path: "/p/tg-tenant-server/src/main/java/DeepLinkController.java", + rel: "tg-tenant-server/src/main/java/DeepLinkController.java", + line: 45, + col: 4, + }, + ]; + const { c, last } = setup({ root: () => "/p", routes: async () => routes }); + c.run({ query: "/tg-tenant/deeplink/menu/search", tab: "all", includeNonProject: false, extra: {} }); + await flush(); + const section = last().sections.get("endpoints"); + expect(section?.items).toHaveLength(1); + expect(section?.items[0]).toMatchObject({ kind: "symbol", container: "DeepLinkController.searchMenus", line: 45 }); + // The All tab mixes its sections into one list; an endpoint has to be in it. + const rows = buildRows(last().sections, { headers: false, mix: "/tg-tenant/deeplink/menu/search" }); + expect(rows.map((r) => (r.kind === "symbol" ? r.container : r.kind))).toContain("DeepLinkController.searchMenus"); + }); +}); diff --git a/src/features/search/__tests__/everywhereModel.test.ts b/src/features/search/__tests__/everywhereModel.test.ts index 1d2eae94..f0616a02 100644 --- a/src/features/search/__tests__/everywhereModel.test.ts +++ b/src/features/search/__tests__/everywhereModel.test.ts @@ -250,7 +250,7 @@ describe("items and rows", () => { it("sectionsFor picks sources per tab and empty query", () => { expect(sectionsFor("all", true)).toEqual(["recent", "recentActions"]); expect(sectionsFor("classes", true)).toEqual([]); - expect(sectionsFor("all", false)).toEqual(["classes", "files", "symbols", "actions", "text"]); + expect(sectionsFor("all", false)).toEqual(["classes", "files", "symbols", "endpoints", "actions", "text"]); expect(sectionsFor("text", false)).toEqual(["text"]); }); }); diff --git a/src/features/search/__tests__/routes.test.ts b/src/features/search/__tests__/routes.test.ts new file mode 100644 index 00000000..efd0d1ec --- /dev/null +++ b/src/features/search/__tests__/routes.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { EndpointEntry } from "@/workbench/contributions"; +import { matchRoutes } from "../everywhere/routes"; + +const route = (over: Partial = {}): EndpointEntry => ({ + method: "POST", + url: "/deeplink/menu/search", + controller: "DeepLinkController", + methodName: "searchMenus", + path: "/p/tg-tenant-server/src/main/java/DeepLinkController.java", + rel: "tg-tenant-server/src/main/java/DeepLinkController.java", + line: 45, + col: 4, + ...over, +}); + +const routes: EndpointEntry[] = [ + route(), + route({ + method: "GET", + url: "/deeplink/menu/{menuId}", + methodName: "getMenuDetail", + line: 52, + col: 4, + }), +]; + +describe("endpoints in Search Everywhere", () => { + it("finds a controller method by the request path it answers", () => { + for (const query of [ + "/deeplink/menu/search", + "deeplink/menu/search", + "/tg-tenant/deeplink/menu/search", + "POST /tg-tenant/deeplink/menu/search", + "post /deeplink/menu/search", + ]) { + const { items } = matchRoutes(routes, query, 5); + expect(items[0]?.container, query).toBe("DeepLinkController.searchMenus"); + expect(items[0]?.display, query).toBe("POST /deeplink/menu/search"); + } + }); + + it("honours an HTTP method prefix", () => { + expect(matchRoutes(routes, "GET /deeplink/menu/x", 5).items).toEqual([]); + expect(matchRoutes(routes, "GET /deeplink/menu", 5).items[0]?.container).toBe("DeepLinkController.getMenuDetail"); + }); + + it("finds it by controller or method name too", () => { + expect(matchRoutes(routes, "searchMenus", 5).items[0]?.container).toBe("DeepLinkController.searchMenus"); + expect(matchRoutes(routes, "DeepLinkController.getMenuDetail", 5).items[0]?.container).toBe( + "DeepLinkController.getMenuDetail", + ); + expect(matchRoutes(routes, "deeplink", 5).items).toHaveLength(2); + }); + + it("says nothing for an empty or unrelated query", () => { + expect(matchRoutes(routes, "", 5).items).toEqual([]); + expect(matchRoutes(routes, " ", 5).items).toEqual([]); + expect(matchRoutes(routes, "unrelated-thing", 5).items).toEqual([]); + }); +}); diff --git a/src/features/search/everywhere/controller.ts b/src/features/search/everywhere/controller.ts index 9c32a6ed..b63c8979 100644 --- a/src/features/search/everywhere/controller.ts +++ b/src/features/search/everywhere/controller.ts @@ -16,8 +16,9 @@ import type { SymbolMatch, WorkspaceSymbol, } from "@/platform/ipc"; -import type { SettingsSearchEntry } from "@/workbench/contributions"; +import type { EndpointEntry, SettingsSearchEntry } from "@/workbench/contributions"; import { parseLocationQuery } from "../common/query"; +import { matchRoutes } from "./routes"; import { passesFilter, type ResolvedFilter } from "./filters"; import { ALL_TAB_LIMIT, @@ -56,6 +57,8 @@ export interface SeDeps { now(): number; /** Whether `path` is in a root-based named scope ("Project Test Files" …). */ inFileSet?(set: ProjectFileSet, path: string): boolean; + /** Endpoints of the project, for request-path matches. */ + routes?(): Promise; } export interface SeRequest { @@ -257,6 +260,9 @@ export class SeController { case "symbols": await this.startSymbols(id, gen, text, limit, req); break; + case "endpoints": + await this.startEndpoints(id, gen, text, limit, req); + break; case "actions": { const hits = matchActions(this.deps.actions(), text, { isEnabled: (a) => this.deps.isEnabled(a), @@ -284,6 +290,14 @@ export class SeController { } } + /** Project endpoints by request path (`/menu/search`), ranked in memory. */ + private async startEndpoints(id: SectionId, gen: number, text: string, limit: number, req: SeRequest) { + const routes = (await this.deps.routes?.().catch((): EndpointEntry[] => [])) ?? []; + const { items, hasMore } = matchRoutes(routes, text, limit); + const keep = this.scopeFilter(req); + this.set(id, gen, keep ? items.filter((i) => keep(i.path)) : items, hasMore, false); + } + private async startSymbols(id: "classes" | "symbols", gen: number, text: string, limit: number, req: SeRequest) { const typesOnly = id === "classes"; const opts = { diff --git a/src/features/search/everywhere/deps.ts b/src/features/search/everywhere/deps.ts index 73bff101..f3c5c9e3 100644 --- a/src/features/search/everywhere/deps.ts +++ b/src/features/search/everywhere/deps.ts @@ -2,7 +2,7 @@ import { allActions, type Action } from "@/platform/actions"; import { evaluateWhen, type ContextKeys } from "@/platform/context"; import { call, commands, nextRequestId, type LanguageInfo } from "@/platform/ipc"; import { inFileSet } from "@/platform/projectRoots"; -import { searchableSettings } from "@/workbench/contributions"; +import { searchEndpoints, searchableSettings } from "@/workbench/contributions"; import { projectRoot } from "../common/open"; import { cancelRequest, streamSearch } from "../common/textSearch"; import type { SeDeps } from "./controller"; @@ -33,6 +33,7 @@ export function ipcDeps(ctx: ContextKeys): SeDeps { root: projectRoot, now: () => performance.now(), inFileSet: (set, path) => inFileSet(set, path), + routes: () => searchEndpoints(projectRoot()), }; } diff --git a/src/features/search/everywhere/model.ts b/src/features/search/everywhere/model.ts index 3b085f0c..6f12712d 100644 --- a/src/features/search/everywhere/model.ts +++ b/src/features/search/everywhere/model.ts @@ -10,7 +10,7 @@ import { basename, isTypeKind, relDir, symbolDisplayName } from "../common/open" import { byScore, matchText } from "../common/rank"; export type SeTab = "all" | "classes" | "files" | "symbols" | "actions" | "text"; -export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "actions" | "text"; +export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "endpoints" | "actions" | "text"; export const TABS: ReadonlyArray<{ id: SeTab; label: string }> = [ { id: "all", label: "All" }, @@ -27,6 +27,7 @@ export const SECTION_ORDER: readonly SectionId[] = [ "classes", "files", "symbols", + "endpoints", "actions", "text", ]; @@ -37,6 +38,7 @@ export const SECTION_TITLES: Record = { classes: "Classes", files: "Files", symbols: "Symbols", + endpoints: "Endpoints", actions: "Actions", text: "Text", }; @@ -55,7 +57,7 @@ export function sectionsFor(tab: SeTab, emptyQuery: boolean): SectionId[] { return []; } } - return tab === "all" ? ["classes", "files", "symbols", "actions", "text"] : [tab]; + return tab === "all" ? ["classes", "files", "symbols", "endpoints", "actions", "text"] : [tab]; } export const ALL_TAB_LIMIT = 6; @@ -90,6 +92,8 @@ export interface SymbolItem { indices: number[]; /** From the language server rather than the index. */ lsp?: boolean; + /** Pre-computed relevance (endpoints: the request path matched `n` segments up). */ + score?: number; } export interface ActionItem { @@ -376,7 +380,7 @@ export function textItems(path: string, rel: string, matches: readonly LineMatch // Rows /** Sections the All tab mixes into one relevance-ordered list. */ -const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "actions", "text"]; +const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "endpoints", "actions", "text"]; /** * Weight of a text hit: a literal substring in a line ranks below name @@ -391,6 +395,7 @@ export function itemWeight(item: SeItem, query: string): number { case "file": return name(item.name) ?? (name(item.rel) ?? 0) - 200; case "symbol": + if (item.score !== undefined) return item.score; return name(item.name) ?? (item.container ? name(`${item.container}.${item.name}`) : undefined) ?? 0; case "action": case "setting": diff --git a/src/features/search/everywhere/routes.ts b/src/features/search/everywhere/routes.ts new file mode 100644 index 00000000..f6c68a0f --- /dev/null +++ b/src/features/search/everywhere/routes.ts @@ -0,0 +1,79 @@ +/** Request-path and controller-method ranking for Search Everywhere. */ +import type { EndpointEntry } from "@/workbench/contributions"; +import { matchText } from "../common/rank"; +import type { SymbolItem } from "./model"; + +const HTTP_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"]); + +const strip = (s: string) => s.replace(/^\/+|\/+$/g, ""); + +function item(r: EndpointEntry, indices: number[], score: number): SymbolItem { + const display = `${r.method} ${r.url}`; + return { + kind: "symbol", + key: `r:${r.path}:${r.line}:${r.method}:${r.url}`, + name: display, + display, + symbolKind: "method", + container: `${r.controller}.${r.methodName}`, + path: r.path, + rel: r.rel, + line: r.line, + col: r.col, + indices, + score, + }; +} + +/** + * Routes matching `query`: a request path (`/deeplink/menu/search`, with or without + * the servlet context path), an optional `POST ` prefix, or the controller / method + * name. Scores: exact path, then a path suffix (a context path in front of it), then + * a substring, then the symbol names, then a fuzzy path hit. + */ +export function matchRoutes( + routes: readonly EndpointEntry[], + query: string, + limit: number, +): { items: SymbolItem[]; hasMore: boolean } { + const text = query.trim(); + if (!text || !routes.length) return { items: [], hasMore: false }; + const words = text.split(/\s+/); + const head = (words[0] ?? "").toUpperCase(); + const method = HTTP_METHODS.has(head) ? head : null; + const rest = (method ? words.slice(1).join(" ") : text).trim(); + const wantPath = strip(rest).toLowerCase(); + const wantName = rest.toLowerCase(); + + const hits: { item: SymbolItem; score: number }[] = []; + for (const r of routes) { + if (method && r.method !== method) continue; + const target = strip(r.url).toLowerCase(); + const qualified = `${r.controller}.${r.methodName}`.toLowerCase(); + let score = 0; + let indices: number[] = []; + if (wantPath) { + if (target === wantPath) score = 1000; + else if (wantPath.includes("/") && (target.endsWith(`/${wantPath}`) || wantPath.endsWith(`/${target}`))) + score = 900; + else if (wantPath.length >= 3 && target.length >= 3 && (target.includes(wantPath) || wantPath.includes(target))) + score = 700; + if (score) { + const at = `${r.method} ${r.url}`.toLowerCase().lastIndexOf(wantPath); + if (at >= 0) indices = Array.from({ length: wantPath.length }, (_, i) => at + i); + } + } + if (!score && wantName) { + if (r.methodName.toLowerCase() === wantName) score = 800; + else if (qualified === wantName || qualified.includes(wantName)) score = 600; + else if (wantName.length >= 3 && target.includes(wantName)) score = 500; + else { + const fuzzy = matchText(target, wantName); + if (fuzzy) score = 300 + fuzzy.score; + } + } + if (score) hits.push({ item: item(r, indices, score), score }); + } + hits.sort((a, b) => b.score - a.score || a.item.rel.localeCompare(b.item.rel) || a.item.line - b.item.line); + return { items: hits.slice(0, limit).map((h) => h.item), hasMore: hits.length > limit }; +} diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 74f44beb..6331f20a 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1317,6 +1317,41 @@ export function runAnythingProviderList(): RunAnythingProvider[] { ); } +// --------------------------------------------------------------------------- +// Project endpoints supplied by framework features for request-path search. + +export interface EndpointEntry { + /** `GET`, `POST`, … */ + method: string; + /** Class-level and method-level mapping joined; `{var}` kept. */ + url: string; + controller: string; + methodName: string; + path: string; + rel: string; + line: number; + col: number; +} + +export interface EndpointProvider { + id: string; + endpoints(root: string): Promise; +} + +const endpointProviders = new Map(); + +export function registerEndpointProvider(p: EndpointProvider): () => void { + endpointProviders.set(p.id, p); + return () => { + if (endpointProviders.get(p.id) === p) endpointProviders.delete(p.id); + }; +} + +export async function searchEndpoints(root: string | null): Promise { + if (!root) return []; + return (await Promise.all([...endpointProviders.values()].map((p) => p.endpoints(root)))).flat(); +} + // --------------------------------------------------------------------------- // Status bar info text (IDEA's status bar message, e.g. "3 of 12" after Next // Occurrence). Cleared by the next message or after a while. diff --git a/src/workbench/endpointProviders.test.ts b/src/workbench/endpointProviders.test.ts new file mode 100644 index 00000000..7884faff --- /dev/null +++ b/src/workbench/endpointProviders.test.ts @@ -0,0 +1,46 @@ +import { expect, it, vi } from "vitest"; +import { registerEndpointProvider, searchEndpoints, type EndpointEntry } from "./contributions"; + +const endpoint: EndpointEntry = { + method: "GET", + url: "/menu/list", + controller: "MenuController", + methodName: "listMenus", + path: "/p/MenuController.java", + rel: "MenuController.java", + line: 4, + col: 11, +}; + +it("collects endpoints from all providers for the requested root", async () => { + const first = vi.fn(async () => [endpoint]); + const second = vi.fn(async () => [{ ...endpoint, url: "/other" }]); + const disposers = [ + registerEndpointProvider({ id: "test.first", endpoints: first }), + registerEndpointProvider({ id: "test.second", endpoints: second }), + ]; + try { + expect(await searchEndpoints(null)).toEqual([]); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + expect(await searchEndpoints("/p")).toEqual([endpoint, { ...endpoint, url: "/other" }]); + expect(first).toHaveBeenCalledWith("/p"); + expect(second).toHaveBeenCalledWith("/p"); + } finally { + disposers.forEach((dispose) => dispose()); + } + expect(await searchEndpoints("/p")).toEqual([]); +}); + +it("keeps a replacement provider registered when its predecessor is disposed", async () => { + const disposeOld = registerEndpointProvider({ id: "test.replaced", endpoints: async () => [] }); + const disposeNew = registerEndpointProvider({ id: "test.replaced", endpoints: async () => [endpoint] }); + try { + disposeOld(); + expect(await searchEndpoints("/p")).toEqual([endpoint]); + } finally { + disposeOld(); + disposeNew(); + } + expect(await searchEndpoints("/p")).toEqual([]); +}); From 7fdf1d62c3f68a8f162019687db5f34ac18b7b61 Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Mon, 2 Nov 2026 19:18:16 +0500 Subject: [PATCH 051/108] fix(refactor): preserve versions and recover multi-file workspace edits (#488) Multi-file refactorings could drop LSP document versions, overwrite content changed after a preview, or leave earlier files saved after a later write failed. Workspace edits now keep server versions and preview fingerprints, are validated on a staging copy, snapshot disk and open buffers, and roll back on commit failure (retaining recovery files if rollback fails). The whole edit is one Undo/Redo step in editors and in the notification. Version reads and transaction work run off the async workers. Closes #461. Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-core/src/event.rs | 9 + crates/ide-core/src/text.rs | 52 +- crates/ide-host/src/lib.rs | 9 + crates/ide-host/src/ops/build.rs | 6 +- crates/ide-host/src/ops/doc/commands.rs | 2 +- crates/ide-host/src/ops/lsp.rs | 132 +- crates/ide-host/src/ops/refactor.rs | 18 +- crates/ide-host/src/project.rs | 40 +- crates/ide-host/src/rpc.rs | 9 +- crates/ide-host/src/trust.rs | 4 + crates/ide-host/tests/rpc.rs | 34 + crates/ide-host/tests/workspace_edits.rs | 177 +++ crates/ide-lsp/src/convert.rs | 54 +- crates/ide-lsp/tests/mock_server.rs | 10 +- crates/ide-lsp/tests/real_servers.rs | 2 +- crates/ide-workspace/Cargo.toml | 1 + crates/ide-workspace/src/documents.rs | 31 +- .../src/documents/workspace_edit.rs | 1187 +++++++++++++++++ crates/ide-workspace/src/file_moves.rs | 19 +- crates/ide-workspace/src/text_refs.rs | 6 +- crates/ide-workspace/tests/workspace_edits.rs | 463 +++++++ src-tauri/src/commands/doc.rs | 18 +- src-tauri/src/commands/lsp.rs | 26 +- src-tauri/src/commands/mod.rs | 3 + src-tauri/src/lib.rs | 1 + src/bindings.ts | 20 +- src/editor/api.ts | 34 + src/editor/core/backend.ts | 7 +- src/editor/core/model/attachment.ts | 13 + src/editor/core/model/document.ts | 16 +- src/editor/core/model/slot.ts | 14 + .../core/model/workspaceHistory.test.ts | 217 +++ src/editor/core/model/workspaceHistory.ts | 176 +++ src/editor/core/sync/DocSync.ts | 5 +- src/editor/lsp/rename.ts | 3 +- src/editor/lsp/syntaxFallback.test.ts | 23 + src/features/analysis/AnalysisDialogs.tsx | 33 +- src/features/analysis/replacePreview.test.tsx | 77 ++ src/features/analysis/structural.ts | 3 +- src/features/code-insight/index.ts | 2 + .../code-insight/workspaceUndo.test.ts | 66 + src/features/code-insight/workspaceUndo.ts | 55 + .../project-tree/RefactoringPreview.tsx | 2 +- .../project-tree/fileRefactor.test.tsx | 20 +- src/features/project-tree/fileRefactor.ts | 16 +- src/platform/locales/text/zh.json | 3 + 46 files changed, 2982 insertions(+), 136 deletions(-) create mode 100644 crates/ide-host/tests/workspace_edits.rs create mode 100644 crates/ide-workspace/src/documents/workspace_edit.rs create mode 100644 crates/ide-workspace/tests/workspace_edits.rs create mode 100644 src/editor/core/model/workspaceHistory.test.ts create mode 100644 src/editor/core/model/workspaceHistory.ts create mode 100644 src/features/analysis/replacePreview.test.tsx create mode 100644 src/features/code-insight/workspaceUndo.test.ts create mode 100644 src/features/code-insight/workspaceUndo.ts diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index e2d45fec..6cef110c 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -36,12 +36,21 @@ pub enum IdeEvent { base_version: u32, version: u32, changes: Vec, + /// Part of a grouped transaction; the editor must not record a file-local undo. + #[serde(default)] + #[specta(optional)] + workspace_edit: bool, }, /// A document's dirty flag changed (saved / modified externally). DocDirty { doc: DocId, dirty: bool, }, + /// A complete workspace edit can be undone as one operation. + WorkspaceEditApplied { + id: u32, + paths: Vec, + }, /// An open document's language changed (Settings › File Types, Override File /// Type), or its UI mode (a framework's root marker appeared or went). DocLanguage { diff --git a/crates/ide-core/src/text.rs b/crates/ide-core/src/text.rs index 45648128..6e21958d 100644 --- a/crates/ide-core/src/text.rs +++ b/crates/ide-core/src/text.rs @@ -97,6 +97,16 @@ pub struct TextEdit { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] pub struct WorkspaceEdit { pub files: Vec, + /// Content fingerprints captured before presenting an edit for confirmation. + #[serde(default)] + #[specta(optional)] + pub preconditions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct WorkspaceEditPrecondition { + pub path: crate::IpcPath, + pub hash: String, } impl WorkspaceEdit { @@ -108,17 +118,26 @@ impl WorkspaceEdit { let mut resolve_aliases = true; for file in std::mem::take(&mut self.files) { match file { - FileEdit::Edit { path, edits } => { + FileEdit::Edit { path, edits, version } => { let path = if resolve_aliases { dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf()) } else { path.to_path_buf() }; let index = *batch.entry(path.clone()).or_insert_with(|| { - files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() }); + files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version }); files.len() - 1 }); - let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() }; + let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else { + unreachable!() + }; + if expected.is_some() && version.is_some() && *expected != version { + // Keep incompatible constraints so application rejects them + // before changing any file instead of silently losing one. + files.push(FileEdit::Edit { path: path.into(), edits, version }); + continue; + } + *expected = expected.or(version); for edit in edits { if !own.contains(&edit) { own.push(edit); @@ -140,10 +159,26 @@ impl WorkspaceEdit { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum FileEdit { - Edit { path: crate::IpcPath, edits: Vec }, - Create { path: crate::IpcPath, overwrite: bool }, - Rename { from: crate::IpcPath, to: crate::IpcPath }, - Delete { path: crate::IpcPath, recursive: bool }, + Edit { + path: crate::IpcPath, + edits: Vec, + /// LSP TextDocumentEdit version; null/unversioned edits use content preconditions. + #[serde(default)] + #[specta(optional)] + version: Option, + }, + Create { + path: crate::IpcPath, + overwrite: bool, + }, + Rename { + from: crate::IpcPath, + to: crate::IpcPath, + }, + Delete { + path: crate::IpcPath, + recursive: bool, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] @@ -254,7 +289,7 @@ mod workspace_edit_tests { std::fs::write(&real, "add()").unwrap(); std::os::unix::fs::symlink(&real, &alias).unwrap(); let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }; - let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] }; + let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None }; let mut workspace = WorkspaceEdit { files: vec![ edit(real.clone()), @@ -262,6 +297,7 @@ mod workspace_edit_tests { FileEdit::Create { path: dir.path().join("other").into(), overwrite: false }, edit(real), ], + ..Default::default() }; workspace.deduplicate(); assert_eq!(workspace.files.len(), 3); diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index d5fef1e5..898b2884 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -174,6 +174,7 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + project.documents.clear_workspace_edit_history(); project.agents.shutdown(); project.notebooks.stop_admission(); // What the closing window started goes with it — the builds and searches it @@ -251,6 +252,13 @@ impl Host { ); } + /// Called before desktop exit, which may bypass document destructors. + pub fn clear_workspace_edit_history_on_exit(&self) { + for project in self.projects() { + project.documents.clear_workspace_edit_history(); + } + } + /// Stops what this host started: notebook kernels, language servers, agents, runs and terminals. /// For a process that exits without dropping the host (the remote server). pub async fn shutdown(&self) { @@ -262,6 +270,7 @@ impl Host { if let Some(bridge) = &project.kotlin_bridge { bridge.stop(); } + project.documents.clear_workspace_edit_history(); project.notebooks.stop_admission(); project.agents.shutdown(); } diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 767875df..6ae0b0bd 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -210,8 +210,10 @@ pub async fn maven_exclude_dependency( let (path, text, range) = declaring_pom(&project, &pom, &dependency)?; match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) { Some(Exclusion::Edit(edit)) => { - let edit = - WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] }; + let edit = WorkspaceEdit { + files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }], + ..Default::default() + }; apply_workspace_edit(&project.documents, &edit)?; } Some(Exclusion::Present) => {} diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs index aa0f918b..e1fab8f9 100644 --- a/crates/ide-host/src/ops/doc/commands.rs +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -127,7 +127,7 @@ pub(crate) fn register(d: &mut Dispatcher) { doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await }); d.add("doc_set_line_ending", |c| async move { - doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?) + blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await }); d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await }); d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await }); diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index b57f54ef..f84ab3c7 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -1,9 +1,33 @@ +use super::doc::blocking; use crate::Host; use crate::apply_workspace_edit as apply_edit; use crate::rpc::Dispatcher; use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit}; use ide_lsp::types::*; +async fn prepare_actions( + project: std::sync::Arc, + doc: DocId, + version: u32, + mut actions: Vec, +) -> IdeResult> { + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing code actions; request them again", + )); + } + for action in &mut actions { + if let Some(edit) = &mut action.edit { + project.documents.prepare_workspace_edit(edit)?; + } + } + Ok(actions) + }) + .await +} + pub async fn lsp_completion( host: &Host, window: &str, @@ -138,7 +162,11 @@ pub async fn lsp_code_actions( range: Range, only: Vec, ) -> IdeResult> { - host.project(window)?.lsp.code_actions(doc, range, only).await + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let actions = project.lsp.code_actions(doc, range, only).await?; + prepare_actions(project, doc, version, actions).await } /// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability). @@ -150,11 +178,15 @@ pub async fn lsp_code_action_query( range: Range, only: Vec, ) -> IdeResult { - let lsp = host.project(window)?.lsp.clone(); + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; let cancel = host.begin_request(request); - let result = lsp.code_action_query(doc, range, only, cancel).await; + let result = project.lsp.code_action_query(doc, range, only, cancel).await; host.end_request(request); - result + let mut result = result?; + result.actions = prepare_actions(project, doc, version, result.actions).await?; + Ok(result) } /// Resolves a code action's edit without applying it (intention preview). @@ -167,19 +199,28 @@ pub async fn lsp_resolve_code_action( if action.edit.is_some() { return Ok(action); } - host.project(window)?.lsp.resolve_code_action(doc, action).await + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let action = project.lsp.resolve_code_action(doc, action).await?; + Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0)) } /// Resolves (if needed), applies the edit, then runs the command of a code action. pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> { let project = host.project(window)?; let action = if action.edit.is_none() && action.command.is_none() { - project.lsp.resolve_code_action(doc, action).await? + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; + let action = project.lsp.resolve_code_action(doc, action).await?; + prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0) } else { action }; if let Some(edit) = &action.edit { - apply_edit(&project.documents, edit)?; + let documents = project.documents.clone(); + let edit = edit.clone(); + blocking(move || apply_edit(&documents, &edit)).await?; } if let Some(command) = action.command { project.lsp.execute_command(doc, command).await?; @@ -200,11 +241,24 @@ pub async fn lsp_rename( pos: Pos, new_name: String, ) -> IdeResult { - let lsp = host.project(window)?.lsp.clone(); + let project = host.project(window)?; + let documents = project.documents.clone(); + let version = blocking(move || documents.version(doc)).await?; let cancel = host.begin_request(request); - let result = lsp.rename(doc, pos, new_name, cancel).await; + let result = project.lsp.rename(doc, pos, new_name, cancel).await; host.end_request(request); - result + let mut renamed = result?; + blocking(move || { + if project.documents.version(doc)? != version { + return Err(ide_core::IdeError::new( + ide_core::error::ErrorKind::OutOfSync, + "document changed while computing rename; request it again", + )); + } + project.documents.prepare_workspace_edit(&mut renamed.edit)?; + Ok(renamed) + }) + .await } pub async fn lsp_format( @@ -279,21 +333,43 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu } /// Applies a (previewed) workspace edit, e.g. after rename confirmation. -pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> { +pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> { let project = host.project(window)?; - // Multi-file refactorings leave a Local History point to go back to. - let paths: Vec<&std::path::Path> = edit - .files - .iter() - .filter_map(|f| match f { - ide_core::FileEdit::Edit { path, .. } => Some(&**path), - _ => None, - }) - .collect(); - if edit.files.len() > 1 { - project.record_before_refactoring(&paths, "Apply changes"); - } - apply_edit(&project.documents, &edit) + blocking(move || { + // Multi-file refactorings leave a Local History point to go back to. + let paths: Vec<&std::path::Path> = edit + .files + .iter() + .filter_map(|f| match f { + ide_core::FileEdit::Edit { path, .. } => Some(&**path), + _ => None, + }) + .collect(); + if edit.files.len() > 1 { + project.record_before_refactoring(&paths, "Apply changes"); + } + apply_edit(&project.documents, &edit) + }) + .await +} + +pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + project.documents.prepare_workspace_edit(&mut edit)?; + Ok(edit) + }) + .await +} + +pub async fn undo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.documents.undo_workspace_edit(id)).await +} + +pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || project.documents.redo_workspace_edit(id)).await } /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's @@ -441,7 +517,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) }); d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) }); d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await }); - d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) }); + d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?).await }); d.add("lsp_resolve_command", |c| async move { lsp_resolve_command( &c.host, @@ -465,6 +541,12 @@ pub(crate) fn register(d: &mut Dispatcher) { ) .await }); + d.add( + "prepare_workspace_edit", + |c| async move { prepare_workspace_edit(&c.host, &c.window, c.arg("edit")?).await }, + ); + d.add("redo_workspace_edit", |c| async move { redo_workspace_edit(&c.host, &c.window, c.arg("id")?).await }); + d.add("undo_workspace_edit", |c| async move { undo_workspace_edit(&c.host, &c.window, c.arg("id")?).await }); d.add("lsp_execute_command", |c| async move { lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await }); diff --git a/crates/ide-host/src/ops/refactor.rs b/crates/ide-host/src/ops/refactor.rs index 806f0f3d..f9a91ad6 100644 --- a/crates/ide-host/src/ops/refactor.rs +++ b/crates/ide-host/src/ops/refactor.rs @@ -1,7 +1,6 @@ //! File move / rename refactoring: reference updates from the language //! servers, the move itself (one undoable step) and its undo. use crate::Host; -use crate::apply_workspace_edit; use crate::rpc::Dispatcher; use ide_core::{IdeResult, RequestId, WorkspaceEdit}; @@ -26,7 +25,12 @@ pub async fn refactor_file_move_edits( let token = host.begin_request(request); let result = project.lsp.will_rename_files(&pairs(&moves), token).await; host.end_request(request); - result + let mut result = result?; + blocking(move || { + project.documents.prepare_workspace_edit(&mut result.edit)?; + Ok(result) + }) + .await } /// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the @@ -42,7 +46,7 @@ pub async fn refactor_move_files( let p = project.clone(); let moved = moves.clone(); let id = blocking(move || { - let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e); + let apply = |e: &WorkspaceEdit| p.documents.apply_file_move_edits(e); p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply) }) .await?; @@ -77,13 +81,17 @@ pub async fn refactor_file_move_text_edits( let cancel = token.clone(); let result = blocking(move || { let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves); - let edit = ide_workspace::text_refs::find_text_occurrences( + let mut edit = ide_workspace::text_refs::find_text_occurrences( &project.root, &project.exclude_globs(), &replacements, &|| cancel.is_cancelled(), ); - if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) } + if cancel.is_cancelled() { + return Err(ide_core::IdeError::cancelled()); + } + project.documents.prepare_workspace_edit(&mut edit)?; + Ok(edit) }) .await; host.end_request(request); diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 0e00ae8b..a42889a1 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -881,37 +881,7 @@ impl Project { /// Applies a multi-file edit through the document layer so open editors update live. pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> { - use ide_core::FileEdit; - let mut edit = edit.clone(); - edit.deduplicate(); - for file in &edit.files { - match file { - FileEdit::Edit { path, edits } => { - // Files not open in an editor are opened transiently and closed again. - let (doc, transient) = match documents.by_path(path) { - Some(doc) => (doc, false), - None => (documents.open(path)?.id, true), - }; - let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc)); - if transient { - documents.close(doc); - } - result?; - } - FileEdit::Create { path, overwrite } => { - if path.exists() { - if *overwrite { - ide_vfs::save_text(path, "", "utf-8", false)?; - } - } else { - ide_vfs::create_file(path)?; - } - } - FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?, - FileEdit::Delete { path, .. } => ide_vfs::delete(path)?, - } - } - Ok(()) + documents.apply_workspace_edit(edit) } /// Files the watcher reports per batch that Local History snapshots. @@ -1253,10 +1223,14 @@ mod workspace_edit_tests { let doc = documents.open(&alias).unwrap(); let changes = |path: PathBuf| FileEdit::Edit { path: path.into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }], }; - apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] }) - .unwrap(); + apply_workspace_edit( + &documents, + &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() }, + ) + .unwrap(); assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n"); assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n"); } diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 8b758d6b..fb0cc28e 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -215,9 +215,14 @@ impl Dispatcher { }) .add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await }) .add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await }) - .add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) }) + .add("doc_close", |c| async move { + ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await + }) .add("doc_edit", |c| async move { - ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + ops::doc::blocking(move || { + ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?) + }) + .await }) .add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await }) .add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await }) diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 5fd072bb..d0e0731d 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -209,6 +209,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "menu_model" | "menu_configure" | "menu_set_disabled" + | "prepare_workspace_edit" + | "apply_workspace_edit" + | "undo_workspace_edit" + | "redo_workspace_edit" | "lsp_servers" | "lsp_resend_diagnostics" | "recent_projects" diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 2a101b01..a0ec5004 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -88,6 +88,40 @@ async fn serves_projects_files_documents_search_and_runs() { } let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) }; assert!(peer.request::<_, Value>("invoke", call, None).await.is_err()); + // Manual workspace edits remain available in Restricted Mode, like doc/fs + // editing. They do not ask a language server to execute project commands. + let manual = project.join("src/manual.txt"); + std::fs::write(&manual, "old").unwrap(); + let edit = json!({ "files": [{ "kind": "edit", "path": manual, "edits": [{ + "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, + "new_text": "new" + }] }] }); + let edit = invoke(&peer, "prepare_workspace_edit", json!({ "edit": edit })).await.unwrap(); + assert_eq!(edit["preconditions"].as_array().unwrap().len(), 1); + invoke(&peer, "apply_workspace_edit", json!({ "edit": edit })).await.unwrap(); + until("workspace edit history", || { + client.events.lock().iter().any(|event| matches!(event.event, ide_core::IdeEvent::WorkspaceEditApplied { .. })) + }) + .await; + let undo_id = client + .events + .lock() + .iter() + .find_map(|event| match &event.event { + ide_core::IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new"); + invoke(&peer, "undo_workspace_edit", json!({ "id": undo_id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "old"); + invoke(&peer, "redo_workspace_edit", json!({ "id": undo_id })).await.unwrap(); + assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new"); + assert_eq!(invoke(&peer, "project_current", json!({})).await.unwrap()["trusted"], false); + for command in ["lsp_execute_command", "lsp_apply_code_action"] { + let error = invoke(&peer, command, json!({})).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}"); + } invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap(); let current = invoke(&peer, "project_current", json!({})).await.unwrap(); assert_eq!(current["name"], "demo"); diff --git a/crates/ide-host/tests/workspace_edits.rs b/crates/ide-host/tests/workspace_edits.rs new file mode 100644 index 00000000..7bdc5c57 --- /dev/null +++ b/crates/ide-host/tests/workspace_edits.rs @@ -0,0 +1,177 @@ +use ide_core::{IdeEvent, SharedSink, WorkspaceEdit, error::ErrorKind}; +use ide_host::{Host, HostConfig, ops}; +use parking_lot::Mutex; +use serde_json::json; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn preview_apply_and_grouped_undo_use_the_shared_host_commands() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let second = tmp.path().join("second.txt"); + for path in [&first, &second] { + std::fs::write(path, "old\n").unwrap(); + } + let events: Arc>> = Arc::default(); + let sink: SharedSink = { + let events = events.clone(); + Arc::new(move |event| events.lock().push(event)) + }; + let host = Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |_| sink.clone()), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.open_light("main", &first).unwrap(); + let info = ops::doc::open(&host, "main", first.clone()).await.unwrap(); + let edit: WorkspaceEdit = serde_json::from_value(json!({ "files": [ + { "kind": "edit", "path": first, "version": info.version, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] }, + { "kind": "edit", "path": second, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] } + ] })).unwrap(); + let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit.clone()).await.unwrap(); + assert_eq!(preview.preconditions.len(), 2); + std::fs::write(&second, "own\n").unwrap(); + let error = ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap_err(); + assert_eq!(error.kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + + let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit).await.unwrap(); + ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n"); + let id = events + .lock() + .iter() + .find_map(|event| match event { + IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "own\n"); + assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "old\n"); + ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap(); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n"); + host.clear_workspace_edit_history_on_exit(); + assert_eq!(ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap_err().kind, ErrorKind::NotFound); +} + +#[tokio::test(flavor = "current_thread")] +async fn contended_version_reads_do_not_block_the_async_worker() { + use ide_core::{LanguageId, Pos, Range, RequestId}; + use ide_workspace::documents::DocumentObserver; + use std::sync::mpsc; + use std::time::Duration; + + struct PausedWrite { + entered: Mutex>>, + release: Mutex>, + } + impl DocumentObserver for PausedWrite { + fn changed( + &self, + _: ide_core::DocId, + _: &std::path::Path, + _: u32, + _: &[ide_text::AppliedChange], + _: &dyn Fn() -> String, + ) { + if let Some(entered) = self.entered.lock().take() { + let _ = entered.send(()); + self.release.lock().recv_timeout(Duration::from_secs(10)).unwrap(); + } + } + } + + for request_kind in 0..5 { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("main.txt"); + std::fs::write(&file, "old").unwrap(); + 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("local-data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let project = host.open_light("main", &file).unwrap(); + let doc = project.documents.open(&file).unwrap().id; + assert_eq!(project.documents.snapshot(doc).unwrap().language, LanguageId::plain_text()); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = mpsc::channel(); + project.documents.add_observer(Arc::new(PausedWrite { + entered: Mutex::new(Some(entered_tx)), + release: Mutex::new(release_rx), + })); + let documents = project.documents.clone(); + let writer = std::thread::spawn(move || { + documents + .apply_workspace_edit( + &serde_json::from_value(json!({ "files": [{ + "kind": "edit", "path": file, "edits": [{ "range": { + "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } + }, "new_text": "new" }] + }] })) + .unwrap(), + ) + .unwrap(); + }); + entered_rx.await.unwrap(); + let (heartbeat_tx, heartbeat_rx) = mpsc::channel(); + // Always release the writer, even on the old blocking implementation. + let watchdog = std::thread::spawn(move || { + let responsive = heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + release_tx.send(()).unwrap(); + responsive + }); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let _ = started_tx.send(()); + let action = + || serde_json::from_value(json!({ "title": "test", "isPreferred": false, "server": 1 })).unwrap(); + match request_kind { + 0 => { + let _ = ops::lsp::lsp_code_actions(&host, "main", doc, Range::point(Pos::new(0, 0)), vec![]).await; + } + 1 => { + let _ = ops::lsp::lsp_code_action_query( + &host, + "main", + RequestId(1), + doc, + Range::point(Pos::new(0, 0)), + vec![], + ) + .await; + } + 2 => { + let _ = ops::lsp::lsp_resolve_code_action(&host, "main", doc, action()).await; + } + 3 => { + let _ = ops::lsp::lsp_apply_code_action(&host, "main", doc, action()).await; + } + _ => { + let _ = + ops::lsp::lsp_rename(&host, "main", RequestId(1), doc, Pos::new(0, 0), "other".into()).await; + } + } + }); + started_rx.await.unwrap(); + let _ = heartbeat_tx.send(()); + task.await.unwrap(); + writer.join().unwrap(); + assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker"); + } +} diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index 2e29724c..1071b695 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -621,7 +621,7 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { }) .map(|e| conv.text_edit(&path, e)) .collect(); - files.push(FileEdit::Edit { path: path.into(), edits }); + files.push(FileEdit::Edit { path: path.into(), edits, version: e.text_document.version }); }; if let Some(changes) = e.document_changes { match changes { @@ -657,11 +657,11 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { for (uri, edits) in changes { if let Some(path) = uri_path(&uri) { let edits = conv.text_edits(&path, edits); - files.push(FileEdit::Edit { path: path.into(), edits }); + files.push(FileEdit::Edit { path: path.into(), edits, version: None }); } } } - let mut edit = WorkspaceEdit { files }; + let mut edit = WorkspaceEdit { files, ..Default::default() }; edit.deduplicate(); edit } @@ -670,28 +670,36 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { /// `edit` where they leave its own alone: what only the other server sees /// (Angular templates next to vtsls's TypeScript). pub fn merge_workspace_edit(edit: &mut WorkspaceEdit, extra: WorkspaceEdit) { + edit.preconditions.extend(extra.preconditions); let overlap = |a: &TextEdit, b: &TextEdit| a.range == b.range || (a.range.start < b.range.end && b.range.start < a.range.end); for file in extra.files { - let FileEdit::Edit { path, edits } = file else { + let FileEdit::Edit { path, edits, version } = file else { if !edit.files.contains(&file) { edit.files.push(file); } continue; }; let own = edit.files.iter_mut().find_map(|f| match f { - FileEdit::Edit { path: p, edits } if *p == path => Some(edits), + FileEdit::Edit { path: p, edits, version } if *p == path => Some((edits, version)), _ => None, }); match own { - Some(own) => { + Some((own, own_version)) => { + // Conflicting versions cannot describe one consistent edit. Keep both + // operations so transaction preflight rejects them instead of dropping a guard. + if own_version.is_some() && version.is_some() && *own_version != version { + edit.files.push(FileEdit::Edit { path, edits, version }); + continue; + } + *own_version = own_version.or(version); let added: Vec = edits.into_iter().filter(|e| !own.iter().any(|o| overlap(o, e))).collect(); if !added.is_empty() { own.extend(added); own.sort_by_key(|e| e.range.start); } } - None => edit.files.push(FileEdit::Edit { path, edits }), + None => edit.files.push(FileEdit::Edit { path, edits, version }), } } } @@ -1036,16 +1044,20 @@ mod tests { range: Range::new(Pos::new(l, c), Pos::new(l, c + 5)), new_text: text.into(), }; - let file = |p: &str, edits: Vec| FileEdit::Edit { path: PathBuf::from(p).into(), edits }; + let file = + |p: &str, edits: Vec| FileEdit::Edit { path: PathBuf::from(p).into(), edits, version: None }; // vtsls renames a shorthand property with an alias; the Angular server // renames it plainly and adds the template. - let mut merged = - WorkspaceEdit { files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])] }; + let mut merged = WorkspaceEdit { + files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])], + ..Default::default() + }; let angular = WorkspaceEdit { files: vec![ file("/a.ts", vec![edit(9, 4, "entries"), edit(3, 2, "entries"), edit(1, 14, "entries")]), file("/a.html", vec![edit(0, 7, "entries")]), ], + ..Default::default() }; merge_workspace_edit(&mut merged, angular); assert_eq!( @@ -1306,7 +1318,7 @@ mod tests { assert_eq!(w.files.len(), 4); assert_eq!(w.files[0], FileEdit::Create { path: "/n.rs".into(), overwrite: true }); match &w.files[1] { - FileEdit::Edit { path, edits } => { + FileEdit::Edit { path, edits, .. } => { assert_eq!(path, Path::new("/n.rs")); assert_eq!(edits.len(), 2); assert_eq!(edits[1].new_text, "y"); @@ -1322,6 +1334,26 @@ mod tests { assert!(matches!(&workspace_edit(&conv, e).files[0], FileEdit::Edit { edits, .. } if edits[0].new_text == "q")); } + #[test] + fn workspace_edits_preserve_document_versions_and_merge_guards() { + let conv = Conv::utf16(); + let incoming = |version| { + serde_json::from_value(json!({ + "documentChanges": [{ + "textDocument": { "uri": "file:///a.rs", "version": version }, + "edits": [{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } }, "newText": "x" }] + }] + })).unwrap() + }; + let mut edit = workspace_edit(&conv, incoming(Some(7))); + assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. })); + merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(None::))); + assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. })); + merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(Some(8)))); + assert_eq!(edit.files.len(), 2, "inconsistent version guards must not be discarded"); + assert!(matches!(edit.files[1], FileEdit::Edit { version: Some(8), .. })); + } + #[test] fn semantic_token_decoding() { let data = [1, 2, 3, 0, 1, 0, 5, 2, 1, 0, 2, 0, 4, 2, 3]; diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index d2136467..a473e8cb 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -650,7 +650,7 @@ async fn diagnostics_become_events_and_feed_code_actions() { assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 })); let edit = resolved.edit.unwrap(); assert!( - matches!(&edit.files[0], FileEdit::Edit { path: p, edits } if p == &path && edits[0].range == rng(0, 0, 1, 0)) + matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0)) ); h.mgr.execute_command(doc, actions[1].command.clone().unwrap()).await.unwrap(); @@ -695,7 +695,9 @@ async fn apply_edit_hook_and_server_requests() { let edits = applied.lock().clone(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].files[0], FileEdit::Create { path: new_file.into(), overwrite: false }); - assert!(matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits } if p == &path && edits[0].new_text == "X")); + assert!( + matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].new_text == "X") + ); let peer = s.peer(); let cfg: Value = peer @@ -1391,7 +1393,7 @@ fn edits_in(edit: &WorkspaceEdit, path: &Path) -> Vec<(Range, String)> { edit.files .iter() .filter_map(|f| match f { - FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => { + FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => { Some(edits.iter().map(|e| (e.range, e.new_text.clone()))) } _ => None, @@ -1788,7 +1790,7 @@ async fn will_and_did_rename_files_honour_filters() { assert_eq!(result.servers, vec!["mock".to_owned()]); assert!(result.errors.is_empty()); match &result.edit.files[..] { - [FileEdit::Edit { path, edits }] => { + [FileEdit::Edit { path, edits, .. }] => { assert_eq!(path, &main); assert_eq!(edits[0].range, rng(0, 4, 0, 7)); assert_eq!(edits[0].new_text, "lib"); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 3ce021f3..ace06401 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -468,7 +468,7 @@ fn edited_lines(edit: &ide_core::WorkspaceEdit, path: &Path) -> Vec { .files .iter() .filter_map(|f| match f { - ide_core::FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => { + ide_core::FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => { Some(edits.iter().map(|e| e.range.start.line)) } _ => None, diff --git a/crates/ide-workspace/Cargo.toml b/crates/ide-workspace/Cargo.toml index 9036108e..bf51d793 100644 --- a/crates/ide-workspace/Cargo.toml +++ b/crates/ide-workspace/Cargo.toml @@ -19,6 +19,7 @@ semver = "1.0.28" parking_lot.workspace = true blake3.workspace = true tracing.workspace = true +tempfile.workspace = true globset.workspace = true [dev-dependencies] diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 2be27035..8ea241af 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -4,7 +4,7 @@ use ide_core::error::ErrorKind; use ide_core::event::NotifyLevel; use ide_core::{ChangeSet, DocId, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink, TextEdit}; use ide_text::{AppliedChange, ByteEdit, Document, LineEnding}; -use parking_lot::{Mutex, RwLock}; +use parking_lot::{Mutex, ReentrantMutex, RwLock}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; @@ -62,6 +62,11 @@ pub type DetectUiMode = dyn Fn(&Path, &LanguageId) -> Option + Send + Sy /// Every document has its own lock; operations on one document are serialized /// and observers see them in version order. pub struct Documents { + /// Workspace transactions hold this across preflight/commit/rollback; ordinary + /// operations re-enter it while accessing the individual document locks. + operations: ReentrantMutex<()>, + workspace_edits: Arc>, + workspace_edit_active: std::sync::atomic::AtomicBool, registry: Mutex, observers: RwLock>>, sink: SharedSink, @@ -96,6 +101,8 @@ pub trait DocumentObserver: Send + Sync { fn closed(&self, _doc: DocId, _path: &Path) {} } +mod workspace_edit; + type Slot = Arc>; #[derive(Default)] @@ -111,6 +118,7 @@ struct RegEntry { keys: Vec, } +#[derive(Clone)] struct DocState { path: PathBuf, doc: Document, @@ -163,6 +171,9 @@ fn read_only_error(path: &Path) -> IdeError { impl Documents { pub fn new(sink: SharedSink, detect_language: Box) -> Self { Self { + operations: ReentrantMutex::new(()), + workspace_edits: Arc::default(), + workspace_edit_active: std::sync::atomic::AtomicBool::new(false), registry: Mutex::default(), observers: RwLock::default(), sink, @@ -279,6 +290,7 @@ impl Documents { /// Locks a document, failing if it was closed concurrently. fn with_doc(&self, doc: DocId, f: impl FnOnce(&mut DocState) -> IdeResult) -> IdeResult { + let _operation = self.operations.lock(); let slot = self.slot(doc)?; let mut st = slot.lock(); if st.closed { @@ -326,6 +338,7 @@ impl Documents { /// Opens (or re-references) the document at `path`. pub fn open(&self, path: &Path) -> IdeResult { + let _operation = self.operations.lock(); if let Some(info) = self.reopen(path) { return Ok(info); } @@ -418,6 +431,7 @@ impl Documents { /// language server provides for a non-`file:` URI. It is keyed by the URI, /// never touches the disk and cannot be edited or saved. pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult { + let _operation = self.operations.lock(); let key = PathBuf::from(uri); if let Some(info) = self.reopen(&key) { return Ok(info); @@ -476,6 +490,7 @@ impl Documents { } pub fn close(&self, doc: DocId) { + let _operation = self.operations.lock(); let slot = { let mut reg = self.registry.lock(); let Some(entry) = reg.docs.get_mut(&doc) else { return }; @@ -558,7 +573,13 @@ impl Documents { edits: &[ByteEdit], ) { self.notify_changed(doc, st, applied, edits); - self.sink.emit(IdeEvent::DocPatched { doc, base_version, version: st.doc.version(), changes: set.changes }); + self.sink.emit(IdeEvent::DocPatched { + doc, + base_version, + version: st.doc.version(), + changes: set.changes, + workspace_edit: self.workspace_edit_active.load(std::sync::atomic::Ordering::Relaxed), + }); } /// Minimal-diff replacement; no event when the text is unchanged. @@ -693,6 +714,11 @@ impl Documents { self.with_doc(doc, |st| Ok(st.dirty)) } + /// Current generation without copying the document text. + pub fn version(&self, doc: DocId) -> IdeResult { + self.with_doc(doc, |st| Ok(st.doc.version())) + } + /// Ids of all open documents. pub fn open_documents(&self) -> Vec { self.registry.lock().docs.keys().copied().collect() @@ -705,6 +731,7 @@ impl Documents { /// for the observers, so it moves to the matching language servers and /// syntax tree. Returns the changed documents. pub fn redetect_languages(&self) -> Vec { + let _operation = self.operations.lock(); let slots: Vec<(DocId, Slot)> = self.registry.lock().docs.iter().map(|(id, e)| (*id, e.slot.clone())).collect(); let mut changed = Vec::new(); for (id, slot) in slots { diff --git a/crates/ide-workspace/src/documents/workspace_edit.rs b/crates/ide-workspace/src/documents/workspace_edit.rs new file mode 100644 index 00000000..46698708 --- /dev/null +++ b/crates/ide-workspace/src/documents/workspace_edit.rs @@ -0,0 +1,1187 @@ +//! Preflight, rollback and grouped undo for edits spanning documents and files. + +use super::{DocState, Documents, RegEntry, disk_len, keys_for}; +use ide_core::error::ErrorKind; +use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, NullSink, WorkspaceEdit, WorkspaceEditPrecondition}; +use parking_lot::Mutex; +use std::collections::VecDeque; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Once}; +use std::time::{Duration, SystemTime}; + +const MAX_HISTORY_BYTES: u64 = 256 * 1024 * 1024; +const MAX_HISTORY_AGE: Duration = Duration::from_secs(24 * 60 * 60); +const SNAPSHOT_PREFIX: &str = "openidea-workspace-edit-"; + +struct ActiveEdit<'a>(&'a AtomicBool); +impl<'a> ActiveEdit<'a> { + fn new(active: &'a AtomicBool) -> Self { + active.store(true, Ordering::Relaxed); + Self(active) + } +} +impl Drop for ActiveEdit<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Relaxed); + } +} + +pub(super) struct History { + next: u32, + reaper: bool, + records: VecDeque<(u32, Record)>, +} + +impl Default for History { + fn default() -> Self { + static CLEANUP: Once = Once::new(); + CLEANUP.call_once(|| cleanup_abandoned(&std::env::temp_dir())); + Self { next: 0, reaper: false, records: VecDeque::new() } + } +} + +impl History { + fn prune(&mut self, now: SystemTime, max_bytes: u64, max_age: Duration) { + self.records.retain(|(_, record)| now.duration_since(record.created).unwrap_or_default() < max_age); + let mut bytes: u64 = self.records.iter().map(|(_, record)| record.bytes).sum(); + while self.records.len() > 20 || bytes > max_bytes { + let Some((_, record)) = self.records.pop_front() else { break }; + bytes = bytes.saturating_sub(record.bytes); + } + } +} + +/// A locked owner file distinguishes live snapshots from abandoned undo/staging +/// data. Unknown legacy directories may contain recovery data, so leave them alone. +fn cleanup_abandoned(root: &Path) { + let Ok(entries) = fs::read_dir(root) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if !entry.file_name().to_string_lossy().starts_with(SNAPSHOT_PREFIX) + || !entry.file_type().is_ok_and(|kind| kind.is_dir()) + || path.join("recovery").exists() + { + continue; + } + let Ok(owner) = fs::OpenOptions::new().read(true).write(true).open(path.join("owner.lock")) else { continue }; + if owner.try_lock().is_ok() && !path.join("recovery").exists() { + // Windows cannot remove an open file. Once unlocked the abandoned + // directory cannot be acquired by a new transaction (names are unique). + drop(owner); + let _ = fs::remove_dir_all(path); + } + } +} + +fn snapshot_bytes(path: &Path) -> IdeResult { + let meta = fs::symlink_metadata(path)?; + if meta.is_dir() { + fs::read_dir(path)?.try_fold(0u64, |bytes, entry| Ok(bytes.saturating_add(snapshot_bytes(&entry?.path())?))) + } else { + Ok(meta.len()) + } +} + +struct SavedPath { + path: PathBuf, + backup: Option, +} + +struct Record { + // Drop the lock before TempDir removes the directory on Windows. + _owner: fs::File, + directory: tempfile::TempDir, + created: SystemTime, + bytes: u64, + undone: bool, + paths: Vec, + documents: Vec<(DocId, DocState)>, + after: Vec, +} + +fn stale(path: &Path) -> IdeError { + IdeError::new(ErrorKind::OutOfSync, format!("{} changed; request the refactoring again", path.display())) +} + +/// Absolute lexical spelling, without resolving links that resource operations may replace. +fn lexical_path(path: &Path) -> IdeResult { + let path = if path.is_absolute() { path.to_owned() } else { std::env::current_dir()?.join(path) }; + let mut clean = PathBuf::new(); + for part in path.components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + clean.pop(); + } + other => clean.push(other), + } + } + Ok(clean) +} + +/// Resolve parent aliases while retaining the last component of resource operations. +fn normalized(path: &Path) -> IdeResult { + let clean = lexical_path(path)?; + let Some(name) = clean.file_name() else { return Ok(clean) }; + let parent = clean.parent().expect("absolute path"); + let parent = dunce::canonicalize(parent).map_err(IdeError::from).or_else(|_| normalized(parent))?; + Ok(parent.join(name)) +} + +fn text_path(path: &Path) -> IdeResult { + dunce::canonicalize(path).map_err(IdeError::from).or_else(|_| normalized(path)) +} + +/// Resolve existing parent links one component at a time. Stop before entering +/// any resource operation's namespace, even through another spelling or link chain. +fn after_resources(path: &Path, changed: &[(PathBuf, PathBuf)], links: u32) -> IdeResult { + if links > 40 { + return Err(IdeError::invalid("too many symbolic links in workspace edit")); + } + let path = lexical_path(path)?; + let parts: Vec<_> = path.components().collect(); + let mut current = PathBuf::new(); + for (index, part) in parts.iter().enumerate() { + current.push(part); + if let Some((_, resolved)) = changed.iter().find(|(original, _)| *original == current) { + let suffix: PathBuf = parts[index + 1..].iter().collect(); + return Ok(if suffix.as_os_str().is_empty() { resolved.clone() } else { resolved.join(suffix) }); + } + if index + 1 == parts.len() || !matches!(part, std::path::Component::Normal(_)) { + continue; + } + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + let target = fs::read_link(¤t)?; + let target = if target.is_absolute() { target } else { current.parent().expect("parent").join(target) }; + let suffix: PathBuf = parts[index + 1..].iter().collect(); + return after_resources(&target.join(suffix), changed, links + 1); + } + Err(error) if !matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => { + return Err(error.into()); + } + _ => {} + } + if let Ok(canonical) = dunce::canonicalize(¤t) { + current = canonical; + } + } + Ok(current) +} + +fn normalize_edit(edit: &WorkspaceEdit) -> IdeResult { + // Later paths below a resource operation refer to its future filesystem + // state. Preserve that suffix, including links in the old directory tree. + let mut changed: Vec<(PathBuf, PathBuf)> = Vec::new(); + let mut files = Vec::new(); + for file in &edit.files { + let resolve = |path: &Path, text: bool| -> IdeResult { + if !changed.is_empty() { + return after_resources(path, &changed, 0); + } + if text { text_path(path) } else { normalized(path) } + }; + let next = match file { + FileEdit::Edit { path, edits, version } => { + FileEdit::Edit { path: resolve(path, true)?.into(), edits: edits.clone(), version: *version } + } + FileEdit::Create { path, overwrite } => { + FileEdit::Create { path: resolve(path, false)?.into(), overwrite: *overwrite } + } + FileEdit::Rename { from, to } => { + FileEdit::Rename { from: resolve(from, false)?.into(), to: resolve(to, false)?.into() } + } + FileEdit::Delete { path, recursive } => { + FileEdit::Delete { path: resolve(path, false)?.into(), recursive: *recursive } + } + }; + let resources: Vec<_> = match (file, &next) { + (FileEdit::Rename { from, to }, FileEdit::Rename { from: new_from, to: new_to }) => { + vec![(from, new_from), (to, new_to)] + } + (FileEdit::Create { path, .. }, FileEdit::Create { path: new, .. }) + | (FileEdit::Delete { path, .. }, FileEdit::Delete { path: new, .. }) => vec![(path, new)], + _ => Vec::new(), + }; + for (original, resolved) in resources { + changed.push((lexical_path(original)?, resolved.to_path_buf())); + changed.push((resolved.to_path_buf(), resolved.to_path_buf())); + } + files.push(next); + } + Ok(WorkspaceEdit { files, preconditions: edit.preconditions.clone() }) +} + +fn paths(edit: &WorkspaceEdit) -> IdeResult> { + let mut paths = Vec::new(); + for file in &edit.files { + match file { + FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => { + paths.push(path.to_path_buf()) + } + FileEdit::Rename { from, to } => { + if to != from && to.starts_with(from) { + return Err(IdeError::invalid("cannot move a directory into itself")); + } + paths.extend([from.to_path_buf(), to.to_path_buf()]); + } + } + } + // Include missing parents so rollback/undo also removes directories we create. + for path in &mut paths { + while let Some(parent) = path.parent() { + match fs::symlink_metadata(parent) { + Ok(_) => break, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => *path = parent.to_owned(), + Err(e) => return Err(e.into()), + } + } + if path.parent().is_none() { + return Err(IdeError::invalid("cannot refactor a filesystem root")); + } + } + paths.sort(); + paths.dedup(); + let mut roots: Vec = Vec::new(); + for path in paths { + if !roots.iter().any(|p| path.starts_with(p)) { + roots.push(path); + } + } + Ok(roots) +} + +fn hash_bytes(hash: &mut blake3::Hasher, bytes: &[u8]) { + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(bytes); +} + +fn hash_disk(hash: &mut blake3::Hasher, path: &Path) -> IdeResult<()> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + hash.update(b"missing"); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + hash.update(&[u8::from(meta.permissions().readonly())]); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + hash.update(&meta.permissions().mode().to_le_bytes()); + } + if meta.file_type().is_symlink() { + hash.update(b"link"); + hash_bytes(hash, fs::read_link(path)?.as_os_str().as_encoded_bytes()); + } else if meta.is_file() { + hash.update(b"file"); + hash.update(&meta.len().to_le_bytes()); + let mut file = fs::File::open(path)?; + let mut buffer = [0; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + } else if meta.is_dir() { + hash.update(b"directory"); + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + hash_bytes(hash, entry.file_name().as_encoded_bytes()); + hash_disk(hash, &entry.path())?; + } + } else { + return Err(IdeError::invalid(format!("{} is not a regular file or directory", path.display()))); + } + Ok(()) +} + +/// A reverted edit may leave the dirty flag set even when all saved bytes match. +fn differs_from_disk(state: &DocState) -> IdeResult { + let text = state.doc.text_for_save(); + let bytes = if state.native_to_ascii { + ide_vfs::encode_properties_ascii(&text).into_bytes() + } else { + ide_vfs::encode(&text, &state.encoding, state.has_bom)? + }; + match fs::read(&state.path) { + Ok(disk) => Ok(disk != bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(error.into()), + } +} + +impl Documents { + /// Capture disk and unsaved-buffer contents before returning an edit for preview. + /// Existing preconditions must survive preview filtering and code-action resolution. + pub fn prepare_workspace_edit(&self, edit: &mut WorkspaceEdit) -> IdeResult<()> { + let _operation = self.operations.lock(); + if edit.preconditions.is_empty() { + edit.preconditions = self.fingerprints(&paths(&normalize_edit(edit)?)?)?; + } + Ok(()) + } + + fn fingerprints(&self, paths: &[PathBuf]) -> IdeResult> { + paths + .iter() + .map(|path| { + let mut hash = blake3::Hasher::new(); + hash_disk(&mut hash, path)?; + let mut dirty = Vec::new(); + for id in self.open_documents() { + self.with_doc(id, |state| { + let file = text_path(&state.path)?; + if state.dirty && file.starts_with(path) && differs_from_disk(state)? { + dirty.push(( + file, + state.doc.text(), + state.encoding.clone(), + state.doc.line_ending(), + state.has_bom, + )); + } + Ok(()) + })?; + } + dirty.sort_by(|a, b| a.0.cmp(&b.0)); + for (file, text, encoding, ending, bom) in dirty { + hash_bytes(&mut hash, file.as_os_str().as_encoded_bytes()); + hash_bytes(&mut hash, text.as_bytes()); + hash_bytes(&mut hash, encoding.as_bytes()); + hash_bytes(&mut hash, ending.as_str().as_bytes()); + hash.update(&[u8::from(bom)]); + } + Ok(WorkspaceEditPrecondition { path: path.clone().into(), hash: hash.finalize().to_hex().to_string() }) + }) + .collect() + } + + fn validate_preconditions(&self, expected: &[WorkspaceEditPrecondition]) -> IdeResult<()> { + let current = self.fingerprints(&expected.iter().map(|p| p.path.to_path_buf()).collect::>())?; + for (before, after) in expected.iter().zip(current) { + if before.hash != after.hash { + return Err(stale(&before.path)); + } + } + Ok(()) + } + + /// Preflight every operation on a private copy, then apply with rollback and grouped undo. + pub fn apply_workspace_edit(&self, edit: &WorkspaceEdit) -> IdeResult<()> { + self.apply_workspace_edit_inner(edit, true, &mut |_| Ok(())) + } + + /// File moves already own a combined move/reference undo record. + pub fn apply_file_move_edits(&self, edit: &WorkspaceEdit) -> IdeResult<()> { + self.apply_workspace_edit_inner(edit, false, &mut |_| Ok(())) + } + + fn apply_workspace_edit_inner( + &self, + edit: &WorkspaceEdit, + remember: bool, + before_operation: &mut dyn FnMut(usize) -> IdeResult<()>, + ) -> IdeResult<()> { + let _operation = self.operations.lock(); + if edit.files.is_empty() { + return Ok(()); + } + let mut edit = normalize_edit(edit)?; + self.prepare_workspace_edit(&mut edit)?; + self.validate_preconditions(&edit.preconditions)?; + for file in &edit.files { + if let FileEdit::Edit { path, version: Some(version), .. } = file { + let doc = self.by_path(path).ok_or_else(|| stale(path))?; + if i64::from(self.version(doc)?) != i64::from(*version) { + return Err(stale(path)); + } + } + } + edit.deduplicate(); + let roots = paths(&edit)?; + // The watcher may not have delivered an external write yet. Never save + // an edit over disk contents that differ from a supposedly clean buffer. + for id in self.open_documents() { + self.with_doc(id, |state| { + if !state.dirty + && roots.iter().any(|root| text_path(&state.path).is_ok_and(|path| path.starts_with(root))) + { + let loaded = ide_vfs::load_text_as(&state.path, &state.encoding)?; + if ide_text::normalize_line_endings(&loaded.text) != state.doc.text() { + return Err(stale(&state.path)); + } + } + Ok(()) + })?; + } + self.workspace_edits.lock().prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + let mut record = self.backup(&roots)?; + self.preflight(&record, &edit)?; + fs::remove_dir_all(record.directory.path().join("stage"))?; + self.validate_preconditions(&edit.preconditions)?; + // A crash during commit can still need these files. Only a completed + // commit turns this into an expendable undo snapshot. + fs::write(record.directory.path().join("recovery"), b"commit in progress")?; + let _active = ActiveEdit::new(&self.workspace_edit_active); + if let Err(error) = self.apply_sequence(&edit, before_operation) { + return Err(self.recover(record, error)); + } + record.after = match self.fingerprints(&roots) { + Ok(after) => after, + Err(error) => return Err(self.recover(record, error)), + }; + if let Err(error) = fs::remove_file(record.directory.path().join("recovery")) { + return Err(self.recover(record, error.into())); + } + if remember { + let mut history = self.workspace_edits.lock(); + history.next += 1; + let id = history.next; + let mut affected: Vec = roots.into_iter().map(Into::into).collect(); + affected.extend(record.documents.iter().map(|(_, state)| state.path.clone().into())); + affected.sort(); + affected.dedup(); + history.records.push_back((id, record)); + if !history.reaper { + history.reaper = true; + let weak = Arc::downgrade(&self.workspace_edits); + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_secs(60)); + let Some(history) = weak.upgrade() else { break }; + let mut history = history.lock(); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + if history.records.is_empty() { + history.reaper = false; + break; + } + } + }); + } + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected }); + } + Ok(()) + } + + fn backup(&self, roots: &[PathBuf]) -> IdeResult { + let bytes = roots.iter().try_fold(0u64, |bytes, path| { + let size = if fs::symlink_metadata(path).is_ok() { snapshot_bytes(path)? } else { 0 }; + Ok::<_, IdeError>(bytes.saturating_add(size)) + })?; + if bytes > MAX_HISTORY_BYTES { + return Err(IdeError::invalid("workspace edit exceeds the 256 MiB recovery snapshot limit")); + } + let directory = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir()?; + // Publish the owner path only after acquiring its lock, so another + // starting process cannot mistake a new snapshot for an abandoned one. + let owner = tempfile::NamedTempFile::new_in(directory.path())?; + owner.as_file().lock()?; + let owner = owner + .persist_noclobber(directory.path().join("owner.lock")) + .map_err(|error| IdeError::from(error.error))?; + let mut paths = Vec::new(); + for (index, path) in roots.iter().enumerate() { + let backup = match fs::symlink_metadata(path) { + Ok(_) => { + let backup = directory.path().join(format!("before-{index}")); + copy_snapshot(path, &backup)?; + Some(backup) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }; + paths.push(SavedPath { path: path.clone(), backup }); + } + let mut documents = Vec::new(); + for id in self.open_documents() { + self.with_doc(id, |state| { + if roots.iter().any(|p| text_path(&state.path).is_ok_and(|file| file.starts_with(p))) { + documents.push((id, state.clone())); + } + Ok(()) + })?; + } + let manifest: Vec<_> = paths + .iter() + .map(|saved| { + ( + ide_core::IpcPath::from(saved.path.clone()), + saved.backup.as_ref().map(|path| ide_core::IpcPath::from(path.clone())), + ) + }) + .collect(); + fs::write( + directory.path().join("paths.json"), + serde_json::to_vec(&manifest).map_err(|error| IdeError::internal(error.to_string()))?, + )?; + let bytes = snapshot_bytes(directory.path())? + .saturating_add(documents.iter().map(|(_, state)| u64::from(state.doc.len_utf16()) * 2).sum::()); + if bytes > MAX_HISTORY_BYTES { + return Err(IdeError::invalid("workspace edit exceeds the 256 MiB recovery snapshot limit")); + } + Ok(Record { + _owner: owner, + directory, + created: SystemTime::now(), + bytes, + undone: false, + paths, + documents, + after: Vec::new(), + }) + } + + fn preflight(&self, record: &Record, edit: &WorkspaceEdit) -> IdeResult<()> { + let stage = dunce::canonicalize(record.directory.path())?.join("stage"); + let map = |path: &Path| -> PathBuf { + let volume = path.ancestors().last().expect("absolute path"); + let key = blake3::hash(volume.as_os_str().as_encoded_bytes()).to_hex(); + stage.join(key.as_str()).join(path.strip_prefix(volume).expect("inside volume")) + }; + for root in &record.paths { + if let Some(backup) = &root.backup { + let target = map(&root.path); + if fs::symlink_metadata(&target).is_err() { + copy_snapshot(backup, &target)?; + } + } + } + let staged = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + staged.set_file_defaults(self.file_defaults()); + let mut seed = record.documents.clone(); + // Closed files need the original path's encoding/EditorConfig too, not + // the settings of the temporary staging directory. + for file in &edit.files { + if let FileEdit::Edit { path, .. } = file + && path.is_file() + && self.by_path(path).is_none() + { + let info = self.open(path)?; + let state = self.with_doc(info.id, |state| Ok(state.clone())); + self.close(info.id); + seed.push((info.id, state?)); + } + } + for (id, state) in &seed { + let mut state = state.clone(); + state.path = map(&text_path(&state.path)?); + state.observed = false; + let keys = keys_for(&state.path); + let mut registry = staged.registry.lock(); + for key in &keys { + registry.by_key.insert(key.clone(), *id); + } + registry.docs.insert(*id, RegEntry { slot: Arc::new(Mutex::new(state)), refs: 1, keys }); + } + let files = edit + .files + .iter() + .map(|file| match file { + FileEdit::Edit { path, edits, version } => { + FileEdit::Edit { path: map(path).into(), edits: edits.clone(), version: *version } + } + FileEdit::Create { path, overwrite } => { + FileEdit::Create { path: map(path).into(), overwrite: *overwrite } + } + FileEdit::Rename { from, to } => FileEdit::Rename { from: map(from).into(), to: map(to).into() }, + FileEdit::Delete { path, recursive } => { + FileEdit::Delete { path: map(path).into(), recursive: *recursive } + } + }) + .collect(); + staged.apply_sequence(&WorkspaceEdit { files, preconditions: Vec::new() }, &mut |_| Ok(())).map_err( + |mut error| { + for root in &record.paths { + error.message = error + .message + .replace(&map(&root.path).to_string_lossy().to_string(), &root.path.to_string_lossy()); + } + error + }, + ) + } + + fn apply_sequence( + &self, + edit: &WorkspaceEdit, + before_operation: &mut dyn FnMut(usize) -> IdeResult<()>, + ) -> IdeResult<()> { + for (index, file) in edit.files.iter().enumerate() { + before_operation(index)?; + match file { + FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => { + check_link_parents(path)?; + } + FileEdit::Rename { from, to } => { + check_link_parents(from)?; + check_link_parents(to)?; + } + } + match file { + FileEdit::Edit { path, edits, .. } => { + if fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(IdeError::invalid("cannot edit a symbolic link introduced by a workspace edit")); + } + let info = self.open(path)?; + let result: IdeResult<()> = (|| { + // In-place rename restores its temporary typing before the + // server request. A dirty flag alone can therefore describe + // a buffer whose encoded bytes already match the saved file. + let unsaved = self.with_doc(info.id, |state| Ok(state.dirty && differs_from_disk(state)?))?; + self.apply_edits(info.id, edits)?; + // Do not save unrelated unsaved work as a side effect of a refactoring. + if !unsaved { + self.save(info.id)?; + } else { + self.with_doc(info.id, |state| { + ide_vfs::encode(&state.doc.text_for_save(), &state.encoding, state.has_bom).map(|_| ()) + })?; + } + Ok(()) + })(); + self.close(info.id); + result?; + } + FileEdit::Create { path, overwrite } => { + if path.exists() { + if !overwrite { + return Err(IdeError::new( + ErrorKind::AlreadyExists, + format!("{} already exists", path.display()), + )); + } + self.check_resource_buffers(path)?; + if fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(IdeError::invalid("cannot overwrite a symbolic link in a workspace edit")); + } + if !path.is_file() { + return Err(IdeError::invalid("cannot overwrite a directory")); + } + ide_vfs::save_text(path, "", "utf-8", false)?; + } else { + ide_vfs::create_file(path)?; + } + self.reload_resource_buffers(&[path.to_path_buf()])?; + } + FileEdit::Rename { from, to } => { + self.check_resource_buffers(from)?; + ide_vfs::rename(from, to)?; + } + FileEdit::Delete { path, recursive } => { + self.check_resource_buffers(path)?; + if fs::symlink_metadata(path)?.is_dir() && !recursive && fs::read_dir(path)?.next().is_some() { + return Err(IdeError::invalid("recursive deletion was not requested")); + } + ide_vfs::delete_permanently(path)?; + } + } + } + Ok(()) + } + + fn check_resource_buffers(&self, path: &Path) -> IdeResult<()> { + for id in self.open_documents() { + self.with_doc(id, |state| { + if state.dirty && text_path(&state.path)?.starts_with(path) { + return Err(IdeError::invalid(format!( + "save the unsaved changes in {} before changing its file", + state.path.display() + ))); + } + Ok(()) + })?; + } + Ok(()) + } + + fn restore(&self, record: &Record) -> IdeResult<()> { + let mut errors = Vec::new(); + let mut failed_paths = Vec::new(); + for saved in &record.paths { + let result = (|| { + match fs::symlink_metadata(&saved.path) { + Ok(_) => ide_vfs::delete_permanently(&saved.path)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + if let Some(backup) = &saved.backup { + copy_snapshot(backup, &saved.path)?; + } + Ok::<_, IdeError>(()) + })(); + if let Err(error) = result { + failed_paths.push(saved.path.clone()); + errors.push(format!("{}: {error}", saved.path.display())); + } + } + for (id, before) in &record.documents { + if self.slot(*id).is_err() { + continue; + } + let result = self.with_doc(*id, |state| { + self.replace_locked(*id, state, &before.doc.text())?; + state.encoding = before.encoding.clone(); + state.has_bom = before.has_bom; + state.doc.set_line_ending(before.doc.line_ending()); + state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0); + state.disk_len = disk_len(&state.path); + let disk_failed = + text_path(&state.path).is_ok_and(|path| failed_paths.iter().any(|failed| path.starts_with(failed))); + let dirty = disk_failed || (before.dirty && differs_from_disk(state)?); + self.set_dirty(*id, state, dirty); + Ok(()) + }); + if let Err(error) = result { + errors.push(error.to_string()); + } + } + // A file may have been opened after the refactoring; its clean buffer must follow undo too. + let paths: Vec = self + .open_documents() + .into_iter() + .filter(|id| !record.documents.iter().any(|(saved, _)| saved == id)) + .filter_map(|id| self.with_doc(id, |state| Ok(state.path.clone())).ok()) + .filter(|path| { + text_path(path).is_ok_and(|path| record.paths.iter().any(|saved| path.starts_with(&saved.path))) + }) + .collect(); + if let Err(error) = self.reload_resource_buffers(&paths) { + errors.push(error.to_string()); + } + if errors.is_empty() { Ok(()) } else { Err(IdeError::internal(errors.join("; "))) } + } + + fn reload_resource_buffers(&self, paths: &[PathBuf]) -> IdeResult<()> { + for path in paths { + let Some(id) = self.by_path(path) else { continue }; + if !path.is_file() { + continue; + } + self.with_doc(id, |state| { + if state.dirty { + return Err(stale(path)); + } + let defaults = self.file_defaults(); + let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?; + self.replace_locked(id, state, &loaded.text)?; + state.encoding = loaded.encoding; + state.has_bom = loaded.has_bom; + state.disk_mtime = loaded.mtime_ms; + state.disk_len = disk_len(path); + Ok(()) + })?; + } + Ok(()) + } + + fn recover(&self, record: Record, error: IdeError) -> IdeError { + match self.restore(&record) { + Ok(()) => error, + Err(rollback) => { + let directory = record.directory.keep(); + IdeError::new( + error.kind, + format!("{error}; rollback failed: {rollback}. Recovery files: {}", directory.display()), + ) + } + } + } + + /// Explicitly release disposable snapshots before an exit that bypasses Drop. + /// Failed-rollback recovery directories are detached and remain untouched. + pub fn clear_workspace_edit_history(&self) { + let _operation = self.operations.lock(); + self.workspace_edits.lock().records.clear(); + } + + /// Refuse to overwrite work made after the refactoring; keep the undo record on failure. + pub fn undo_workspace_edit(&self, id: u32) -> IdeResult<()> { + self.workspace_edit_history(id, false) + } + + pub fn redo_workspace_edit(&self, id: u32) -> IdeResult<()> { + self.workspace_edit_history(id, true) + } + + fn workspace_edit_history(&self, id: u32, redo: bool) -> IdeResult<()> { + let _operation = self.operations.lock(); + let mut history = self.workspace_edits.lock(); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + let index = history + .records + .iter() + .position(|(key, _)| *key == id) + .ok_or_else(|| IdeError::not_found("the refactoring can no longer be undone"))?; + let record = &history.records[index].1; + if record.undone != redo { + return Err(IdeError::invalid("the refactoring is already in the requested history state")); + } + self.validate_preconditions(&record.after)?; + let roots = record.paths.iter().map(|p| p.path.clone()).collect::>(); + let mut current = self.backup(&roots)?; + fs::write(current.directory.path().join("recovery"), b"history restore in progress")?; + let _active = ActiveEdit::new(&self.workspace_edit_active); + if let Err(error) = self.restore(record) { + return Err(self.recover(current, error)); + } + current.after = match self.fingerprints(&roots) { + Ok(after) => after, + Err(error) => return Err(self.recover(current, error)), + }; + current.undone = !redo; + current.created = record.created; + if let Err(error) = fs::remove_file(current.directory.path().join("recovery")) { + return Err(self.recover(current, error.into())); + } + history.records[index].1 = current; + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + Ok(()) + } +} + +fn check_link_parents(path: &Path) -> IdeResult<()> { + for parent in path.ancestors().skip(1) { + match fs::symlink_metadata(parent) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(IdeError::invalid(format!("workspace edit traverses symbolic link {}", parent.display()))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +/// Snapshot links themselves, including dangling links, instead of their targets. +fn copy_snapshot(from: &Path, to: &Path) -> IdeResult<()> { + let metadata = fs::symlink_metadata(from)?; + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + if metadata.file_type().is_symlink() { + let target = fs::read_link(from)?; + #[cfg(unix)] + std::os::unix::fs::symlink(target, to)?; + #[cfg(windows)] + { + use std::os::windows::fs::FileTypeExt; + if metadata.file_type().is_symlink_dir() { + std::os::windows::fs::symlink_dir(target, to)?; + } else { + std::os::windows::fs::symlink_file(target, to)?; + } + } + } else if metadata.is_dir() { + fs::create_dir(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + copy_snapshot(&entry.path(), &to.join(entry.file_name()))?; + } + fs::set_permissions(to, metadata.permissions())?; + } else if metadata.is_file() { + fs::copy(from, to)?; + } else { + return Err(IdeError::invalid("cannot snapshot a special file")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::{ChangeSet, Pos, Range, TextChange, TextEdit}; + + #[test] + fn snapshot_size_age_and_exit_cleanup_release_files() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("file.txt"); + fs::write(&path, "old").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let mut first = docs.backup(std::slice::from_ref(&path)).unwrap(); + let old_dir = first.directory.path().to_owned(); + let second = docs.backup(std::slice::from_ref(&path)).unwrap(); + let new_dir = second.directory.path().to_owned(); + let max_bytes = second.bytes; + first.created = SystemTime::now() - MAX_HISTORY_AGE; + let mut history = docs.workspace_edits.lock(); + history.records.push_back((1, first)); + history.records.push_back((2, second)); + history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE); + assert!(!old_dir.exists(), "expired snapshots are deleted, not just unindexed"); + assert!(new_dir.exists()); + let third = docs.backup(std::slice::from_ref(&path)).unwrap(); + let last_dir = third.directory.path().to_owned(); + history.records.push_back((3, third)); + history.prune(SystemTime::now(), max_bytes, MAX_HISTORY_AGE); + assert!(!new_dir.exists(), "the byte budget evicts the oldest record"); + assert!(last_dir.exists()); + drop(history); + docs.clear_workspace_edit_history(); + assert!(!last_dir.exists(), "normal exit explicitly releases snapshots"); + } + + #[test] + fn oversized_snapshot_is_refused_before_mutating_any_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("huge.bin"); + fs::File::create(&path).unwrap().set_len(MAX_HISTORY_BYTES + 1).unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let error = docs + .apply_workspace_edit(&WorkspaceEdit { + files: vec![FileEdit::Delete { path: path.clone().into(), recursive: false }], + ..Default::default() + }) + .unwrap_err(); + assert!(error.message.contains("256 MiB")); + assert_eq!(fs::metadata(path).unwrap().len(), MAX_HISTORY_BYTES + 1); + } + + #[test] + fn snapshot_crash_child() { + let Some(root) = std::env::var_os("OPENIDEA_SNAPSHOT_CRASH_FIXTURE") else { return }; + let root = PathBuf::from(root); + for (name, recovery) in [("undo", false), ("recovery", true)] { + let directory = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir_in(&root).unwrap(); + let owner = fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(directory.path().join("owner.lock")) + .unwrap(); + owner.lock().unwrap(); + fs::write(directory.path().join("paths.json"), b"[]").unwrap(); + if recovery { + fs::write(directory.path().join("recovery"), b"rollback required").unwrap(); + } + fs::write(root.join(name), directory.path().to_string_lossy().as_bytes()).unwrap(); + // Simulate process death: neither TempDir nor the owner lock is dropped. + std::mem::forget(owner); + std::mem::forget(directory); + } + std::process::exit(0); + } + + #[test] + fn startup_cleanup_after_crash_keeps_live_and_recovery_snapshots() { + let root = tempfile::tempdir().unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "documents::workspace_edit::tests::snapshot_crash_child"]) + .env("OPENIDEA_SNAPSHOT_CRASH_FIXTURE", root.path()) + .status() + .unwrap(); + assert!(status.success()); + let undo = PathBuf::from(fs::read_to_string(root.path().join("undo")).unwrap()); + let recovery = PathBuf::from(fs::read_to_string(root.path().join("recovery")).unwrap()); + assert!(undo.exists()); + let live = tempfile::Builder::new().prefix(SNAPSHOT_PREFIX).tempdir_in(root.path()).unwrap(); + let owner = fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(live.path().join("owner.lock")) + .unwrap(); + owner.lock().unwrap(); + let unknown = root.path().join(format!("{SNAPSHOT_PREFIX}legacy-recovery")); + fs::create_dir(&unknown).unwrap(); + cleanup_abandoned(root.path()); + assert!(!undo.exists()); + assert!(recovery.exists(), "crash recovery files are never expired or size-evicted"); + assert!(live.path().exists(), "another live app's owner lock protects its snapshots"); + assert!(unknown.exists(), "legacy recovery files have no safe deletion marker"); + drop(owner); + cleanup_abandoned(root.path()); + assert!(!live.path().exists()); + } + + #[test] + fn java_mid_apply_failure_rolls_back_declaration_and_closed_usage() { + let tmp = tempfile::tempdir().unwrap(); + let declaration = tmp.path().join("Main.java"); + let usage = tmp.path().join("Use.java"); + fs::write(&declaration, "class Original {}\n").unwrap(); + fs::write(&usage, "class Use { Original value; }\n").unwrap(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = events.clone(); + let docs = + Documents::new(Arc::new(move |e| sink.lock().push(e)), Box::new(|_, _| ide_core::LanguageId::new("java"))); + let info = docs.open(&declaration).unwrap(); + let edit = WorkspaceEdit { + files: vec![(declaration.clone(), 6, Some(info.version as i32)), (usage.clone(), 12, None)] + .into_iter() + .map(|(path, col, version)| FileEdit::Edit { + path: path.into(), + version, + edits: vec![TextEdit { + range: Range::new(Pos::new(0, col), Pos::new(0, col + 8)), + new_text: "Renamed".into(), + }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 1 { Err(IdeError::internal("injected Java usage write failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert!(error.message.contains("injected Java")); + assert_eq!(fs::read_to_string(&declaration).unwrap(), "class Original {}\n"); + assert_eq!(fs::read_to_string(&usage).unwrap(), "class Use { Original value; }\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "class Original {}\n"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + assert!( + events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::DocPatched { workspace_edit, .. } => Some(workspace_edit), + _ => None, + }) + .all(|grouped| *grouped) + ); + } + + #[test] + fn failed_second_write_rolls_back_disk_and_dirty_buffers() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + fs::write(&a, "old\r\n").unwrap(); + fs::write(&b, "two\n").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&a).unwrap(); + docs.edit(info.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }) + .unwrap(); + let edit = WorkspaceEdit { + files: [&a, &b] + .into_iter() + .map(|path| FileEdit::Edit { + path: path.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 1 { Err(IdeError::new(ErrorKind::Io, "injected write failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert!(error.message.contains("injected")); + assert_eq!(fs::read(&a).unwrap(), b"old\r\n"); + assert_eq!(fs::read(&b).unwrap(), b"two\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "own\n"); + assert!(after.dirty); + assert!(after.version > info.version); + assert!(docs.workspace_edits.lock().records.is_empty()); + } + + #[test] + fn failed_commit_restores_encoded_files_and_resource_operations() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let created = tmp.path().join("new/deep/file.txt"); + let source = tmp.path().join("source.bin"); + let renamed = tmp.path().join("renamed.bin"); + let deleted = tmp.path().join("deleted.bin"); + ide_vfs::save_text(&first, "old\r\n", "utf-16le", true).unwrap(); + let bytes = fs::read(&first).unwrap(); + fs::write(&source, [0, 255, 128]).unwrap(); + fs::write(&deleted, [255, 1, 128]).unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&first).unwrap(); + let edit = WorkspaceEdit { + files: vec![ + FileEdit::Edit { + path: first.clone().into(), + version: Some(info.version as i32), + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }, + FileEdit::Create { path: created.clone().into(), overwrite: false }, + FileEdit::Rename { from: source.clone().into(), to: renamed.clone().into() }, + FileEdit::Delete { path: deleted.clone().into(), recursive: false }, + FileEdit::Create { path: tmp.path().join("last").into(), overwrite: false }, + ], + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index == 4 { Err(IdeError::new(ErrorKind::Io, "injected commit failure")) } else { Ok(()) } + }) + .unwrap_err(); + assert_eq!(error.message, "injected commit failure"); + assert_eq!(fs::read(&first).unwrap(), bytes); + assert_eq!(fs::read(&source).unwrap(), [0, 255, 128]); + assert_eq!(fs::read(&deleted).unwrap(), [255, 1, 128]); + assert!(!renamed.exists()); + assert!(!tmp.path().join("new").exists()); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "old\n"); + assert_eq!(after.encoding, info.encoding); + assert_eq!(after.line_ending, info.line_ending); + assert!(!after.dirty); + } + + #[test] + fn failed_rollback_keeps_recovery_files_and_marks_the_restored_buffer_dirty() { + let tmp = tempfile::tempdir().unwrap(); + let parent = tmp.path().join("subdir"); + fs::create_dir(&parent).unwrap(); + let first = parent.join("first.txt"); + let second = tmp.path().join("second.txt"); + fs::write(&first, "old\n").unwrap(); + fs::write(&second, "two\n").unwrap(); + let docs = Documents::new(Arc::new(NullSink), Box::new(|_, _| ide_core::LanguageId::plain_text())); + let info = docs.open(&first).unwrap(); + let edit = WorkspaceEdit { + files: [&first, &second] + .into_iter() + .map(|path| FileEdit::Edit { + path: path.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "new".into() }], + }) + .collect(), + ..Default::default() + }; + let error = docs + .apply_workspace_edit_inner(&edit, true, &mut |index| { + if index != 1 { + return Ok(()); + } + fs::remove_file(&first)?; + fs::remove_dir(&parent)?; + fs::write(&parent, "blocks restoration")?; + Err(IdeError::new(ErrorKind::Io, "injected commit failure")) + }) + .unwrap_err(); + assert!(error.message.contains("rollback failed")); + let recovery = PathBuf::from(error.message.split("Recovery files: ").last().unwrap()); + let manifest: Vec<(ide_core::IpcPath, Option)> = + serde_json::from_slice(&fs::read(recovery.join("paths.json")).unwrap()).unwrap(); + let backup = manifest + .iter() + .find(|(path, _)| path.file_name().is_some_and(|name| name == "first.txt")) + .unwrap() + .1 + .as_ref() + .unwrap(); + assert_eq!(fs::read(backup).unwrap(), b"old\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "old\n"); + assert!(after.dirty, "a buffer whose disk restoration failed must not look saved"); + fs::remove_dir_all(recovery).unwrap(); + } +} diff --git a/crates/ide-workspace/src/file_moves.rs b/crates/ide-workspace/src/file_moves.rs index dbac6285..ad6444b8 100644 --- a/crates/ide-workspace/src/file_moves.rs +++ b/crates/ide-workspace/src/file_moves.rs @@ -89,7 +89,7 @@ fn write_text(documents: &Documents, path: &Path, text: &str) -> IdeResult<()> { fn edited_paths(edit: &WorkspaceEdit) -> Vec { let mut out: Vec = Vec::new(); for f in &edit.files { - if let FileEdit::Edit { path, edits } = f + if let FileEdit::Edit { path, edits, .. } = f && !edits.is_empty() && !out.iter().any(|p| p == path) { @@ -245,17 +245,7 @@ mod tests { } fn apply(documents: &Documents) -> impl Fn(&WorkspaceEdit) -> IdeResult<()> + '_ { - move |edit| { - for f in &edit.files { - if let FileEdit::Edit { path, edits } = f { - let doc = documents.open(path)?.id; - documents.apply_edits(doc, edits)?; - documents.save(doc)?; - documents.close(doc); - } - } - Ok(()) - } + move |edit| documents.apply_file_move_edits(edit) } #[test] @@ -272,8 +262,10 @@ mod tests { let edit = WorkspaceEdit { files: vec![FileEdit::Edit { path: root.join("main.ts").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 21), Pos::new(0, 22)), new_text: "b".into() }], }], + ..Default::default() }; let id = moves.perform(&docs, Some(&history), &mv, &edit, "Move", &apply(&docs)).unwrap(); assert!(root.join("b/util.ts").exists() && !root.join("a").exists()); @@ -303,13 +295,16 @@ mod tests { files: vec![ FileEdit::Edit { path: root.join("Foo.java").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 6), Pos::new(0, 9)), new_text: "Bar".into() }], }, FileEdit::Edit { path: root.join("Use.java").into(), + version: None, edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "Bar".into() }], }, ], + ..Default::default() }; let mv = vec![FileMove { from: root.join("Foo.java").into(), to: root.join("Bar.java").into() }]; let id = moves.perform(&docs, None, &mv, &edit, "Rename", &apply(&docs)).unwrap(); diff --git a/crates/ide-workspace/src/text_refs.rs b/crates/ide-workspace/src/text_refs.rs index ab344780..1ea4daa1 100644 --- a/crates/ide-workspace/src/text_refs.rs +++ b/crates/ide-workspace/src/text_refs.rs @@ -201,7 +201,11 @@ pub fn find_text_occurrences( let mut files = found.into_inner(); files.sort_by(|a, b| a.0.cmp(&b.0)); WorkspaceEdit { - files: files.into_iter().map(|(path, edits)| FileEdit::Edit { path: path.into(), edits }).collect(), + files: files + .into_iter() + .map(|(path, edits)| FileEdit::Edit { path: path.into(), edits, version: None }) + .collect(), + ..Default::default() } } diff --git a/crates/ide-workspace/tests/workspace_edits.rs b/crates/ide-workspace/tests/workspace_edits.rs new file mode 100644 index 00000000..78bd905b --- /dev/null +++ b/crates/ide-workspace/tests/workspace_edits.rs @@ -0,0 +1,463 @@ +use ide_core::{ChangeSet, FileEdit, LanguageId, NullSink, Pos, Range, TextChange, TextEdit, WorkspaceEdit}; +use ide_core::{IdeEvent, error::ErrorKind}; +use ide_workspace::Documents; +use parking_lot::Mutex; +use std::path::Path; +use std::sync::Arc; + +fn documents() -> Documents { + Documents::new(Arc::new(NullSink), Box::new(|_, _| LanguageId::plain_text())) +} + +fn edit(path: &Path, text: &str) -> FileEdit { + FileEdit::Edit { + path: path.to_owned().into(), + version: None, + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: text.into() }], + } +} + +#[test] +fn later_failure_preserves_disk_and_unsaved_buffer() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let missing = tmp.path().join("missing.txt"); + std::fs::write(&first, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&first).unwrap(); + docs.edit(info.id, info.version, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }) + .unwrap(); + let result = docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![edit(&first, "new"), edit(&missing, "bad")], + ..Default::default() + }); + assert!(result.is_err()); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + let after = docs.snapshot(info.id).unwrap(); + assert_eq!(after.text, "own\n"); + assert!(after.dirty); +} + +#[test] +fn create_conflict_does_not_modify_earlier_files() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("first.txt"); + let existing = tmp.path().join("exists.txt"); + std::fs::write(&first, "old\n").unwrap(); + std::fs::write(&existing, "keep").unwrap(); + let docs = documents(); + let result = docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![edit(&first, "new"), FileEdit::Create { path: existing.clone().into(), overwrite: false }], + ..Default::default() + }); + assert!(result.is_err()); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n"); + assert_eq!(std::fs::read_to_string(existing).unwrap(), "keep"); +} + +fn recorded_documents() -> (Documents, Arc>>) { + let events = Arc::new(Mutex::new(Vec::new())); + let sink = events.clone(); + (Documents::new(Arc::new(move |event| sink.lock().push(event)), Box::new(|_, _| LanguageId::plain_text())), events) +} + +fn undo_id(events: &Mutex>) -> u32 { + events + .lock() + .iter() + .rev() + .find_map(|event| match event { + IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), + _ => None, + }) + .expect("successful transaction has one undo record") +} + +#[test] +fn stale_document_version_rejects_the_whole_edit() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + std::fs::write(&a, "old\n").unwrap(); + std::fs::write(&b, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&b).unwrap(); + docs.edit(info.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "own ".into() }] }).unwrap(); + let mut second = edit(&b, "new"); + if let FileEdit::Edit { version, .. } = &mut second { + *version = Some(0); + } + let result = + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&a, "new"), second], ..Default::default() }); + assert_eq!(result.unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "old\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "own old\n"); + assert!(docs.is_dirty(info.id).unwrap()); +} + +#[test] +fn matching_document_version_is_applied() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + std::fs::write(&a, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&a).unwrap(); + let mut operation = edit(&a, "new"); + if let FileEdit::Edit { version, .. } = &mut operation { + *version = Some(info.version as i32); + } + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![operation], ..Default::default() }).unwrap(); + assert_eq!(docs.snapshot(info.id).unwrap().text, "new\n"); + assert_eq!(std::fs::read_to_string(a).unwrap(), "new\n"); +} + +#[test] +fn unversioned_preview_rejects_changed_disk_and_dirty_buffers() { + let tmp = tempfile::tempdir().unwrap(); + for open in [false, true] { + let path = tmp.path().join(if open { "open.txt" } else { "closed.txt" }); + std::fs::write(&path, "old\n").unwrap(); + let docs = documents(); + let info = open.then(|| docs.open(&path).unwrap()); + let mut change = WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }; + docs.prepare_workspace_edit(&mut change).unwrap(); + if let Some(info) = &info { + docs.edit( + info.id, + info.version, + ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }, + ) + .unwrap(); + } else { + std::fs::write(&path, "own\n").unwrap(); + } + let original = change.preconditions.clone(); + docs.prepare_workspace_edit(&mut change).unwrap(); + assert_eq!(change.preconditions, original, "resolution must not refresh an old preview's guard"); + assert_eq!(docs.apply_workspace_edit(&change).unwrap_err().kind, ErrorKind::OutOfSync); + if let Some(info) = info { + assert_eq!(docs.snapshot(info.id).unwrap().text, "own\n"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\n"); + } else { + assert_eq!(std::fs::read_to_string(&path).unwrap(), "own\n"); + } + } +} + +#[test] +fn grouped_undo_restores_open_closed_and_unsaved_files() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + let c = tmp.path().join("c.txt"); + for path in [&a, &b, &c] { + std::fs::write(path, "old\r\n").unwrap(); + } + let (docs, events) = recorded_documents(); + let dirty = docs.open(&a).unwrap(); + let clean = docs.open(&b).unwrap(); + docs.edit(dirty.id, 0, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: "own".into() }] }).unwrap(); + let change = + WorkspaceEdit { files: [&a, &b, &c].into_iter().map(|p| edit(p, "new")).collect(), ..Default::default() }; + docs.apply_workspace_edit(&change).unwrap(); + assert_eq!(docs.snapshot(dirty.id).unwrap().text, "new\n"); + assert!(docs.is_dirty(dirty.id).unwrap()); + assert_eq!(std::fs::read(&a).unwrap(), b"old\r\n", "unrelated unsaved work is not saved"); + assert_eq!(std::fs::read(&c).unwrap(), b"new\r\n"); + let opened_later = docs.open(&c).unwrap(); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(docs.snapshot(dirty.id).unwrap().text, "own\n"); + assert!(docs.is_dirty(dirty.id).unwrap()); + for info in [clean, opened_later] { + assert_eq!(docs.snapshot(info.id).unwrap().text, "old\n"); + assert!(!docs.is_dirty(info.id).unwrap()); + } + for path in [&a, &b, &c] { + assert_eq!(std::fs::read(path).unwrap(), b"old\r\n"); + } + assert!(docs.undo_workspace_edit(id).is_err()); +} + +#[test] +fn undo_refuses_later_changes_and_retains_the_record() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + std::fs::write(&a, "old\n").unwrap(); + let (docs, events) = recorded_documents(); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&a, "new")], ..Default::default() }).unwrap(); + let id = undo_id(&events); + std::fs::write(&a, "own\n").unwrap(); + assert_eq!(docs.undo_workspace_edit(id).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "own\n"); + std::fs::write(&a, "new\n").unwrap(); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(a).unwrap(), "old\n"); +} + +#[test] +fn ordered_resource_operations_and_binary_deletion_are_undoable() { + let tmp = tempfile::tempdir().unwrap(); + let created = tmp.path().join("new/deep/file.txt"); + let renamed = tmp.path().join("renamed.txt"); + let deleted = tmp.path().join("binary.dat"); + let overwritten = tmp.path().join("overwrite.txt"); + std::fs::write(&deleted, [0, 255, 1, 128]).unwrap(); + std::fs::write(&overwritten, [255, 254, 65, 0]).unwrap(); + let (docs, events) = recorded_documents(); + let change = WorkspaceEdit { + files: vec![ + FileEdit::Create { path: created.clone().into(), overwrite: false }, + edit(&created, "hello"), + FileEdit::Rename { from: created.clone().into(), to: renamed.clone().into() }, + FileEdit::Delete { path: deleted.clone().into(), recursive: false }, + FileEdit::Create { path: overwritten.clone().into(), overwrite: true }, + ], + ..Default::default() + }; + docs.apply_workspace_edit(&change).unwrap(); + assert_eq!(std::fs::read_to_string(&renamed).unwrap(), "hello"); + assert!(!deleted.exists()); + assert!(std::fs::read(&overwritten).unwrap().is_empty()); + docs.undo_workspace_edit(undo_id(&events)).unwrap(); + assert!(!tmp.path().join("new").exists()); + assert!(!renamed.exists()); + assert_eq!(std::fs::read(deleted).unwrap(), [0, 255, 1, 128]); + assert_eq!(std::fs::read(overwritten).unwrap(), [255, 254, 65, 0]); +} + +#[test] +fn nonrecursive_directory_delete_is_rejected_before_any_write() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.txt"); + let dir = tmp.path().join("directory"); + std::fs::write(&a, "old\n").unwrap(); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("keep.txt"), "keep").unwrap(); + let change = WorkspaceEdit { + files: vec![edit(&a, "new"), FileEdit::Delete { path: dir.clone().into(), recursive: false }], + ..Default::default() + }; + assert!(documents().apply_workspace_edit(&change).is_err()); + assert_eq!(std::fs::read_to_string(a).unwrap(), "old\n"); + assert!(dir.join("keep.txt").exists()); +} + +#[test] +fn external_write_not_yet_seen_by_watcher_is_not_overwritten() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("a.txt"); + std::fs::write(&path, "old\n").unwrap(); + let docs = documents(); + let info = docs.open(&path).unwrap(); + std::fs::write(&path, "own\n").unwrap(); + let mut change = WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }; + docs.prepare_workspace_edit(&mut change).unwrap(); + assert_eq!(docs.apply_workspace_edit(&change).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(path).unwrap(), "own\n"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "old\n"); +} + +#[cfg(unix)] +#[test] +fn undo_restores_deleted_directory_with_symlinks_and_permissions() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("tree"); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("script"), "old\n").unwrap(); + std::fs::set_permissions(dir.join("script"), std::fs::Permissions::from_mode(0o751)).unwrap(); + symlink("missing-target", dir.join("link")).unwrap(); + let (docs, events) = recorded_documents(); + docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![FileEdit::Delete { path: dir.clone().into(), recursive: true }], + ..Default::default() + }) + .unwrap(); + assert!(!dir.exists()); + docs.undo_workspace_edit(undo_id(&events)).unwrap(); + assert_eq!(std::fs::read(dir.join("script")).unwrap(), b"old\n"); + assert_eq!(std::fs::metadata(dir.join("script")).unwrap().permissions().mode() & 0o777, 0o751); + assert_eq!(std::fs::read_link(dir.join("link")).unwrap(), Path::new("missing-target")); +} + +#[cfg(unix)] +#[test] +fn preflight_never_writes_through_copied_or_moved_symlinks() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("target.txt"); + let link = tmp.path().join("link.txt"); + let moved = tmp.path().join("moved.txt"); + std::fs::write(&target, "old\n").unwrap(); + symlink(&target, &link).unwrap(); + let docs = documents(); + for files in [ + vec![FileEdit::Create { path: link.clone().into(), overwrite: true }], + vec![FileEdit::Rename { from: link.clone().into(), to: moved.clone().into() }, edit(&moved, "new")], + ] { + assert!(docs.apply_workspace_edit(&WorkspaceEdit { files, ..Default::default() }).is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old\n"); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert!(!moved.exists()); + } +} + +#[test] +fn rename_after_restoring_inplace_typing_saves_but_real_unsaved_work_stays_dirty() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.ts"); + std::fs::write(&path, "old").unwrap(); + let (docs, events) = recorded_documents(); + let info = docs.open(&path).unwrap(); + for (version, text) in [(0, "own"), (1, "old")] { + docs.edit(info.id, version, ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: text.into() }] }) + .unwrap(); + } + assert!(docs.snapshot(info.id).unwrap().dirty); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + assert!(!docs.snapshot(info.id).unwrap().dirty, "restoring saved bytes must clear a stale dirty flag"); + docs.redo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + assert!(!docs.snapshot(info.id).unwrap().dirty); + docs.edit( + info.id, + docs.version(info.id).unwrap(), + ChangeSet { changes: vec![TextChange { from: 3, to: 3, insert: " own".into() }] }, + ) + .unwrap(); + assert_eq!(docs.undo_workspace_edit(id).unwrap_err().kind, ErrorKind::OutOfSync); +} + +#[test] +fn duplicate_edits_keep_versions_and_apply_one_replacement() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.cpp"); + std::fs::write(&path, "old").unwrap(); + let docs = documents(); + let info = docs.open(&path).unwrap(); + let mut versioned = edit(&path, "new_long"); + if let FileEdit::Edit { version, .. } = &mut versioned { + *version = Some(info.version as i32); + } + let mut workspace = WorkspaceEdit { files: vec![edit(&path, "new_long"), versioned], ..Default::default() }; + workspace.deduplicate(); + assert_eq!(workspace.files.len(), 1); + assert!(matches!(workspace.files[0], FileEdit::Edit { version: Some(0), .. })); + docs.apply_workspace_edit(&workspace).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new_long"); + + let mut conflicting = workspace.files[0].clone(); + if let FileEdit::Edit { version, .. } = &mut conflicting { + *version = Some(99); + } + workspace.files.push(conflicting); + workspace.deduplicate(); + assert_eq!(workspace.files.len(), 2, "deduplication must not discard incompatible versions"); + assert_eq!(docs.apply_workspace_edit(&workspace).unwrap_err().kind, ErrorKind::OutOfSync); + assert_eq!(std::fs::read_to_string(path).unwrap(), "new_long"); +} + +#[test] +fn undo_accepts_typing_that_was_reverted_to_the_saved_refactoring() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("main.ts"); + std::fs::write(&path, "old").unwrap(); + let (docs, events) = recorded_documents(); + let info = docs.open(&path).unwrap(); + docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap(); + let id = undo_id(&events); + for text in ["own", "new"] { + docs.edit( + info.id, + docs.version(info.id).unwrap(), + ChangeSet { changes: vec![TextChange { from: 0, to: 3, insert: text.into() }] }, + ) + .unwrap(); + } + assert!(docs.snapshot(info.id).unwrap().dirty); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(path).unwrap(), "old"); + assert_eq!(docs.snapshot(info.id).unwrap().text, "old"); + assert!(!docs.snapshot(info.id).unwrap().dirty); +} + +#[cfg(unix)] +#[test] +fn replacing_a_symlink_then_editing_the_new_file_never_writes_its_old_target() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("precious.txt"); + let link = tmp.path().join("new.txt"); + std::fs::write(&target, "old target\n").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let (docs, events) = recorded_documents(); + let target_doc = docs.open(&target).unwrap(); + let mut edit = WorkspaceEdit { + files: vec![ + FileEdit::Delete { path: link.clone().into(), recursive: false }, + FileEdit::Create { path: link.clone().into(), overwrite: false }, + FileEdit::Edit { + path: link.clone().into(), + version: None, + edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: "new file\n".into() }], + }, + ], + ..Default::default() + }; + docs.prepare_workspace_edit(&mut edit).unwrap(); + docs.apply_workspace_edit(&edit).unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old target\n"); + assert_eq!(docs.snapshot(target_doc.id).unwrap().text, "old target\n"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new file\n"); + assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + let id = undo_id(&events); + docs.undo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_link(&link).unwrap(), target); + docs.redo_workspace_edit(id).unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old target\n"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new file\n"); +} + +#[cfg(unix)] +#[test] +fn replacing_a_directory_symlink_does_not_resolve_later_child_paths_through_its_old_target() { + for alias in 0..3 { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("precious"); + let link = tmp.path().join("new"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("file.txt"), "old target\n").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let alternate = tmp.path().join("alternate"); + std::os::unix::fs::symlink(tmp.path(), &alternate).unwrap(); + let shortcut = tmp.path().join("shortcut"); + std::os::unix::fs::symlink(&link, &shortcut).unwrap(); + let edited = match alias { + 0 => link.join("file.txt"), + 1 => alternate.join("new/file.txt"), + _ => shortcut.join("file.txt"), + }; + let docs = documents(); + let mut edit = WorkspaceEdit { + files: vec![ + FileEdit::Delete { path: link.clone().into(), recursive: false }, + FileEdit::Create { path: link.join("file.txt").into(), overwrite: false }, + FileEdit::Edit { + path: edited.into(), + version: None, + edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: "new file\n".into() }], + }, + ], + ..Default::default() + }; + docs.prepare_workspace_edit(&mut edit).unwrap(); + docs.apply_workspace_edit(&edit).unwrap(); + assert_eq!(std::fs::read_to_string(target.join("file.txt")).unwrap(), "old target\n"); + assert_eq!(std::fs::read_to_string(link.join("file.txt")).unwrap(), "new file\n"); + } +} diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index 91e0781e..5d366faa 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -20,22 +20,26 @@ pub async fn doc_open(window: Window, state: State<'_, AppState>, path: PathBuf) #[tauri::command] #[specta::specta] -pub fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> { - ide_host::ops::doc::close(&state.host, window.label(), doc) +pub async fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> { + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::close(&host, &window, doc)).await } /// Applies a CodeMirror change set; returns the new version. Rejects with /// `outOfSync` when `base_version` is stale; the UI then calls `doc_snapshot`. #[tauri::command] #[specta::specta] -pub fn doc_edit( +pub async fn doc_edit( window: Window, state: State<'_, AppState>, doc: DocId, base_version: u32, changes: ChangeSet, ) -> IdeResult { - ide_host::ops::doc::edit(&state.host, window.label(), doc, base_version, changes) + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::edit(&host, &window, doc, base_version, changes)).await } #[tauri::command] @@ -92,13 +96,15 @@ pub async fn doc_set_encoding( #[tauri::command] #[specta::specta] -pub fn doc_set_line_ending( +pub async fn doc_set_line_ending( window: Window, state: State<'_, AppState>, doc: DocId, line_ending: LineEnding, ) -> IdeResult<()> { - ide_host::ops::doc::doc_set_line_ending(&state.host, window.label(), doc, line_ending) + let host = state.host.clone(); + let window = window.label().to_owned(); + blocking(move || ide_host::ops::doc::doc_set_line_ending(&host, &window, doc, line_ending)).await } /// Structure view: LSP document symbols when available (with tree-sitter diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 765d7e75..0092cdcf 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -337,8 +337,30 @@ pub async fn lsp_restart(window: Window, state: State<'_, AppState>, server: Ser /// Applies a (previewed) workspace edit, e.g. after rename confirmation. #[tauri::command] #[specta::specta] -pub fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> { - ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit) +pub async fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> { + ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit).await +} + +#[tauri::command] +#[specta::specta] +pub async fn prepare_workspace_edit( + window: Window, + state: State<'_, AppState>, + edit: WorkspaceEdit, +) -> IdeResult { + ide_host::ops::lsp::prepare_workspace_edit(&state.host, window.label(), edit).await +} + +#[tauri::command] +#[specta::specta] +pub async fn undo_workspace_edit(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult<()> { + ide_host::ops::lsp::undo_workspace_edit(&state.host, window.label(), id).await +} + +#[tauri::command] +#[specta::specta] +pub async fn redo_workspace_edit(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult<()> { + ide_host::ops::lsp::redo_workspace_edit(&state.host, window.label(), id).await } /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1ef4d111..4a8f3c06 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -228,6 +228,9 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_resend_diagnostics, lsp::lsp_restart, lsp::apply_workspace_edit, + lsp::prepare_workspace_edit, + lsp::undo_workspace_edit, + lsp::redo_workspace_edit, lsp::lsp_execute_command, lsp::lsp_resolve_command, lsp::lsp_execute_code_action, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e75910a3..478b25f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -210,6 +210,7 @@ pub fn run() { let _ = &early_opens; if let tauri::RunEvent::Exit = event { if let Some(state) = app.try_state::() { + state.host.clear_workspace_edit_history_on_exit(); state.host.shutdown_notebooks_on_exit(); } // Cleanup must finish before update/restart replaces this process. diff --git a/src/bindings.ts b/src/bindings.ts index e76c5885..4f191324 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -426,6 +426,9 @@ export const commands = { lspRestart: (server: ServerId) => typedError(__TAURI_INVOKE("lsp_restart", { server })), /** Applies a (previewed) workspace edit, e.g. after rename confirmation. */ applyWorkspaceEdit: (edit: WorkspaceEdit) => typedError(__TAURI_INVOKE("apply_workspace_edit", { edit })), + prepareWorkspaceEdit: (edit: WorkspaceEdit) => typedError(__TAURI_INVOKE("prepare_workspace_edit", { edit })), + undoWorkspaceEdit: (id: number) => typedError(__TAURI_INVOKE("undo_workspace_edit", { id })), + redoWorkspaceEdit: (id: number) => typedError(__TAURI_INVOKE("redo_workspace_edit", { id })), /** * Runs a server command (`workspace/executeCommand`), e.g. a completion item's * follow-up command. Edits the server requests arrive via `workspace/applyEdit`. @@ -3088,7 +3091,9 @@ export type FileCoverage = { branchesCovered: number, }; -export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[] } | { kind: "create"; path: IpcPath; overwrite: boolean } | { kind: "rename"; from: IpcPath; to: IpcPath } | { kind: "delete"; path: IpcPath; recursive: boolean }; +export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[]; +/** LSP TextDocumentEdit version; null/unversioned edits use content preconditions. */ +version?: number | null } | { kind: "create"; path: IpcPath; overwrite: boolean } | { kind: "rename"; from: IpcPath; to: IpcPath } | { kind: "delete"; path: IpcPath; recursive: boolean }; export type FileEntry = { name: string, @@ -3603,9 +3608,13 @@ export type IdeEvent = * The backend changed a document (reload from disk, formatting, refactoring). * The UI applies `changes` on top of `base_version`, yielding `version`. */ -{ type: "docPatched"; doc: DocId; baseVersion: number; version: number; changes: TextChange[] } | +{ type: "docPatched"; doc: DocId; baseVersion: number; version: number; changes: TextChange[]; +/** Part of a grouped transaction; the editor must not record a file-local undo. */ +workspaceEdit?: boolean } | /** A document's dirty flag changed (saved / modified externally). */ { type: "docDirty"; doc: DocId; dirty: boolean } | +/** A complete workspace edit can be undone as one operation. */ +{ type: "workspaceEditApplied"; id: number; paths: IpcPath[] } | /** * An open document's language changed (Settings › File Types, Override File * Type), or its UI mode (a framework's root marker appeared or went). @@ -6480,6 +6489,13 @@ export type Workspace = Workspace_Serialize | Workspace_Deserialize; /** Edits to many files, applied atomically from the user's point of view. */ export type WorkspaceEdit = { files: FileEdit[], + /** Content fingerprints captured before presenting an edit for confirmation. */ + preconditions?: WorkspaceEditPrecondition[], +}; + +export type WorkspaceEditPrecondition = { + path: IpcPath, + hash: string, }; export type WorkspaceSymbol = { diff --git a/src/editor/api.ts b/src/editor/api.ts index 9d1e81e0..671f0d9b 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -619,3 +619,37 @@ export function codePreviewExtensions(path: string): Promise { export function _provideCodePreview(provider: ((path: string) => Promise) | null) { codePreviewProvider = provider; } + +let workspaceHistory: ((id: number, redo: boolean) => Promise) | null = null; + +/** Undo/redo a refactoring through the same history used by editor commands. */ +export function workspaceEditHistory(id: number, redo = false): Promise { + return workspaceHistory ? workspaceHistory(id, redo) : Promise.reject(new Error("Editor history is unavailable")); +} + +/** @internal editor/core only. */ +export function _provideWorkspaceHistory(run: (id: number, redo: boolean) => Promise): () => void { + workspaceHistory = run; + return () => { + if (workspaceHistory === run) workspaceHistory = null; + }; +} + +export interface WorkspaceEditHistoryState { + id: number; + undone: boolean; +} +const workspaceHistoryListeners = new Set<(state: WorkspaceEditHistoryState) => void>(); + +/** Successful workspace Undo/Redo, including commands invoked from an editor. */ +export function onWorkspaceEditHistory(listener: (state: WorkspaceEditHistoryState) => void): () => void { + workspaceHistoryListeners.add(listener); + return () => { + workspaceHistoryListeners.delete(listener); + }; +} + +/** @internal editor/core only. */ +export function _emitWorkspaceEditHistory(state: WorkspaceEditHistoryState) { + for (const listener of workspaceHistoryListeners) listener(state); +} diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 55dfc59a..8c1cfbef 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -28,6 +28,7 @@ import { customRegions } from "./cm/regions"; export type LanguageInfo = LanguageInfo_Serialize; export interface DocPatchedEvent { + workspaceEdit?: boolean; doc: DocId; baseVersion: number; version: number; @@ -335,7 +336,7 @@ function lineIndex(text: string) { export interface MemoryBackend extends DocBackend { /** Test/dev hook: apply a backend-originated change and emit `docPatched`. */ - patch(doc: DocId, changes: TextChange[]): void; + patch(doc: DocId, changes: TextChange[], workspaceEdit?: boolean): void; /** Seed file contents before opening. */ files: Map; docs: Map; @@ -582,13 +583,13 @@ export function createMemoryBackend(files: Record = {}): MemoryB dirtyHandlers.add(h); return () => dirtyHandlers.delete(h); }, - patch(doc, changes) { + patch(doc, changes, workspaceEdit = false) { const d = get(doc); const before = d.text !== d.saved; const baseVersion = d.version; d.text = applyChanges(d.text, changes); d.version++; - const e = { doc, baseVersion, version: d.version, changes }; + const e = { doc, baseVersion, version: d.version, changes, workspaceEdit }; patched.forEach((h) => h(e)); setDirty(d, before); }, diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index daefba9e..0ac7bd14 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -29,9 +29,19 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr const target = new AttachedView(); model.attach(target); let released = false; + const historyTarget = { + path, + state: () => target.view!.state, + dispatch: (tr: Transaction) => target.view?.dispatch(tr), + }; const plugin = ViewPlugin.define((view) => { target.view = view; + let detachHistory = () => {}; + let destroyed = false; + queueMicrotask(() => { + if (!destroyed) detachHistory = model.manager.workspaceHistory.attach(historyTarget); + }); const handle: TextEditorHandle = { view, ctx: model.ctx() }; _addEditor(handle); // The model may have moved on between `text()` and the view's creation. @@ -46,6 +56,8 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr }); return { destroy() { + destroyed = true; + detachHistory(); _removeEditor(handle); if (target.view === view) target.view = null; }, @@ -57,6 +69,7 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr text: () => model.sync.localText.toString(), readOnly: model.getMeta().readOnly || model.virtual, extension: [ + model.manager.workspaceHistory.extension(historyTarget), plugin, documentSynced.of(() => model.sync.whenIdle()), EditorView.updateListener.of((u) => { diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index ff94d564..9c70e8f6 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -62,6 +62,7 @@ import { import { setMethodLines, softWrapActive } from "../cm/prefs"; import type { EditorSlot } from "./slot"; import { tx } from "@/platform/i18n"; +import { WorkspaceHistory } from "./workspaceHistory"; export type Banner = "binary" | "tooLarge" | "readOnly" | "library" | null; @@ -261,7 +262,7 @@ export class DocModel { snapshot: () => manager.backend.snapshot(this.doc), }, { - applyRemote: (cs) => this.applyRemote(cs), + applyRemote: (cs, grouped) => this.applyRemote(cs, grouped), onDiscarded: () => manager.notify({ level: "warning", @@ -344,14 +345,14 @@ export class DocModel { this.scheduleRefresh(); } - private applyRemote(cs: ChangeSet) { + private applyRemote(cs: ChangeSet, grouped = false) { this.modified.map(cs); this.changeCounter++; const primary = this.primarySlot(); for (const s of this.slots) - s.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(s === primary)]); + s.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(!grouped && s === primary)]); // Without a tab, the first attached view keeps backend edits (Replace in Files…) undoable. - let first = !primary; + let first = !grouped && !primary; for (const a of this.attachments) { a.applyExternal(cs, [remoteChange.of(true), Transaction.addToHistory.of(first)]); first = false; @@ -625,6 +626,12 @@ export interface ManagerHost { } export class DocumentManager { + readonly workspaceHistory = new WorkspaceHistory( + async () => { + await Promise.all([...this.byPath.values()].map((m) => m.sync.whenIdle())); + }, + (n) => this.notify(n), + ); readonly byPath = new Map(); readonly byId = new Map(); private opening = new Map>(); @@ -647,6 +654,7 @@ export class DocumentManager { start() { this.disposers.push( + this.workspaceHistory.start(), this.backend.onPatched((e) => this.byId.get(e.doc)?.sync.remote(e)), this.backend.onLanguage?.((e) => this.byId.get(e.doc)?.setLanguage(e.language, e.uiMode)) ?? (() => {}), this.backend.onDirty((e) => { diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index fdd5684f..bb818b1c 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -30,6 +30,7 @@ import { showContextMenu } from "@/ui"; import { baseExtensions, compartments, contributionExtensions } from "../cm/setup"; import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu"; import type { DocModel } from "./document"; +import type { WorkspaceHistoryTarget } from "./workspaceHistory"; import { isNavigation } from "./navHistory"; export interface SlotHost { @@ -52,6 +53,8 @@ export interface Reveal { export class EditorSlot { state: EditorState; + private historyTarget: WorkspaceHistoryTarget; + private detachHistory: () => void; view: EditorView | null = null; handle: TextEditorHandle | null = null; private scroll: StateEffect | null = null; @@ -71,6 +74,14 @@ export class EditorSlot { private host: SlotHost, init?: { from?: EditorSlot; reveal?: Reveal }, ) { + this.historyTarget = { + path: model.path, + state: () => this.currentState(), + dispatch: (tr) => { + if (this.view) this.view.dispatch(tr); + else this.state = tr.state; + }, + }; const source = init?.from; this.state = EditorState.create({ doc: model.sync.localText, @@ -91,6 +102,7 @@ export class EditorSlot { else if (source) this.scroll = source.scroll; if (init?.reveal) this.pendingReveal = init.reveal; model.addSlot(this); + this.detachHistory = model.manager.workspaceHistory.attach(this.historyTarget); } get ctx(): EditorDocContext { @@ -103,6 +115,7 @@ export class EditorSlot { private slotExtensions() { return [ + this.model.manager.workspaceHistory.extension(this.historyTarget), documentSynced.of(() => this.model.sync.whenIdle()), EditorView.updateListener.of((u) => { for (const l of this.listeners) l(u); @@ -170,6 +183,7 @@ export class EditorSlot { dispose() { if (this.disposed) return; + this.detachHistory(); this.unmount(); this.disposed = true; this.model.removeSlot(this); diff --git a/src/editor/core/model/workspaceHistory.test.ts b/src/editor/core/model/workspaceHistory.test.ts new file mode 100644 index 00000000..001d76f7 --- /dev/null +++ b/src/editor/core/model/workspaceHistory.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { redo, redoDepth, undo, undoDepth, history } from "@codemirror/commands"; +import { EditorView } from "@codemirror/view"; +import { workspaceEditHistory } from "@/editor/api"; +import { installWorkspaceEditUndo } from "@/features/code-insight/workspaceUndo"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { emitLocalIdeEvent } from "@/platform/ipc"; +import { createMemoryBackend, type MemoryBackend } from "../backend"; +import { _resetLanguages } from "../cm/languages"; +import { DocumentManager } from "./document"; +import { EditorSlot, type SlotHost } from "./slot"; +import { attachDocument } from "./attachment"; + +const commands = vi.hoisted(() => ({ undoWorkspaceEdit: vi.fn(), redoWorkspaceEdit: vi.fn() })); +vi.mock("@/bindings", () => ({ commands })); +const host: SlotHost = { onFocus() {}, onBlur() {}, onSelection() {}, onUserEdit() {}, onContextMenu() {} }; +let manager: DocumentManager; +let backend: MemoryBackend; +let notify = vi.fn<(n: { level: "info" | "warning" | "error"; title: string; body?: string }) => void>(); +let slots: EditorSlot[]; +let attached: EditorView | null; + +beforeEach(() => { + _resetLanguages(); + backend = createMemoryBackend({ "/p/A.java": "old", "/p/B.java": "old", "/p/closed.java": "old" }); + notify = vi.fn(); + manager = new DocumentManager(backend, { settings: () => ({ files: { autoSave: false } }), notify }); + manager.start(); + slots = []; + attached = null; + commands.undoWorkspaceEdit.mockReset().mockImplementation(async () => { + await replace("old"); + return { status: "ok", data: null }; + }); + commands.redoWorkspaceEdit.mockReset().mockImplementation(async () => { + await replace("new"); + return { status: "ok", data: null }; + }); +}); +afterEach(() => { + attached?.destroy(); + slots.forEach((s) => s.dispose()); + manager.stop(); +}); + +async function open(path: string, from?: EditorSlot) { + const slot = new EditorSlot(await manager.acquire(path), String(slots.length), host, from ? { from } : undefined); + slots.push(slot); + return slot; +} +async function replace(text: string) { + for (const d of backend.docs.values()) { + backend.patch(d.id, [{ from: 0, to: 3, insert: text }], true); + await backend.save(d.id); + } + backend.files.set("/p/closed.java", text); +} +async function rename() { + await replace("new"); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 9, paths: ["/p/A.java", "/p/B.java", "/p/closed.java"] }); +} +function historyStep(slot: EditorSlot, forward = false) { + return (forward ? redo : undo)({ + state: slot.currentState(), + dispatch: (tr) => { + slot.state = tr.state; + }, + }); +} + +it("editor Undo and Redo restore the entire Java rename, with one step in every split and background tab", async () => { + const a = await open("/p/A.java"); + const split = await open("/p/A.java"); + const b = await open("/p/B.java"); + await rename(); + for (const s of slots) expect(undoDepth(s.currentState())).toBe(1); + expect(historyStep(split)).toBe(true); + await vi.waitFor(() => expect(backend.files.get("/p/closed.java")).toBe("old")); + for (const s of slots) { + expect(s.currentState().doc.toString()).toBe("old"); + expect(undoDepth(s.currentState())).toBe(0); + expect(redoDepth(s.currentState())).toBe(1); + } + expect(commands.undoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9); + historyStep(b, true); + await vi.waitFor(() => expect(backend.files.get("/p/closed.java")).toBe("new")); + expect(a.currentState().doc.toString()).toBe("new"); + expect(undoDepth(a.currentState())).toBe(1); + expect(commands.redoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9); +}); + +it("notification Undo consumes the same marker and never makes Cmd+Z reapply a single file", async () => { + const a = await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + expect(a.currentState().doc.toString()).toBe("old"); + expect(historyStep(a)).toBe(false); + expect(redoDepth(a.currentState())).toBe(1); + expect(commands.undoWorkspaceEdit).toHaveBeenCalledOnce(); +}); + +it("preserves earlier local history and refuses the notification until later typing is undone", async () => { + const a = await open("/p/A.java"); + a.dispatchLocal({ changes: { from: 3, insert: "!" }, userEvent: "input.type" }); + await a.model.sync.whenIdle(); + await rename(); + a.dispatchLocal({ changes: { from: 4, insert: "?" }, userEvent: "input.type" }); + await expect(workspaceEditHistory(9)).rejects.toThrow("Undo newer editor changes"); + expect(commands.undoWorkspaceEdit).not.toHaveBeenCalled(); + undo({ state: a.currentState(), dispatch: (tr) => a.dispatchLocal(tr) }); + await a.model.sync.whenIdle(); + await workspaceEditHistory(9); + expect(a.currentState().doc.toString()).toBe("old!"); + expect(undoDepth(a.currentState())).toBe(1); +}); + +it("restores the editor marker when backend guards refuse Undo", async () => { + const a = await open("/p/A.java"); + await rename(); + commands.undoWorkspaceEdit.mockResolvedValue({ + status: "error", + error: { kind: "outOfSync", message: "external write" }, + }); + historyStep(a); + await vi.waitFor(() => expect(notify).toHaveBeenCalled()); + expect(a.currentState().doc.toString()).toBe("new"); + expect(undoDepth(a.currentState())).toBe(1); + expect(redoDepth(a.currentState())).toBe(0); +}); + +it("supports direct CodeMirror Undo from an attached diff view", async () => { + const att = await attachDocument(manager, "/p/A.java"); + attached = new EditorView({ doc: att.text(), extensions: [att.extension, history()] }); + await Promise.resolve(); + await rename(); + undo(attached); + await vi.waitFor(() => expect(attached!.state.doc.toString()).toBe("old")); + expect(commands.undoWorkspaceEdit).toHaveBeenCalledOnce(); + expect(undoDepth(attached.state)).toBe(0); + attached.destroy(); + attached = null; + att.release(); +}); + +it.each(["closed file", "new split"])("can redo after a %s opens following Undo", async (kind) => { + const a = await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + const next = await open(kind === "closed file" ? "/p/B.java" : "/p/A.java", kind === "new split" ? a : undefined); + expect(next.currentState().doc.toString()).toBe("old"); + historyStep(a, true); + await vi.waitFor(() => expect(commands.redoWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(9)); + await vi.waitFor(() => expect(next.currentState().doc.toString()).toBe("new")); + expect(undoDepth(next.currentState())).toBe(1); + expect(notify).not.toHaveBeenCalled(); +}); + +it("keeps the refactoring notification in sync with editor Undo and Redo", async () => { + const notices: Parameters[0][] = []; + provideNotificationService({ + notify: (n) => { + notices.push(n); + }, + }); + const stop = installWorkspaceEditUndo(); + try { + const a = await open("/p/A.java"); + await rename(); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo"); + historyStep(a); + await vi.waitFor(() => expect(notices.at(-1)?.actions?.[0]?.label).toBe("Redo")); + historyStep(a, true); + await vi.waitFor(() => expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo")); + await notices.at(-1)!.actions![0]!.run(); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Redo"); + await notices.at(-1)!.actions![0]!.run(); + expect(a.currentState().doc.toString()).toBe("new"); + expect(notices.at(-1)?.actions?.[0]?.label).toBe("Undo"); + } finally { + stop(); + } +}); + +it("rebuilds multiple redo markers in order when a closed file opens", async () => { + const a = await open("/p/A.java"); + await rename(); + await replace("alt"); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"] }); + commands.undoWorkspaceEdit.mockImplementation(async (id: number) => { + await replace(id === 10 ? "new" : "old"); + return { status: "ok", data: null }; + }); + commands.redoWorkspaceEdit.mockImplementation(async (id: number) => { + await replace(id === 10 ? "alt" : "new"); + return { status: "ok", data: null }; + }); + await workspaceEditHistory(10); + await workspaceEditHistory(9); + const b = await open("/p/B.java"); + expect(redoDepth(b.currentState())).toBe(2); + historyStep(b, true); + await vi.waitFor(() => expect(a.currentState().doc.toString()).toBe("new")); + historyStep(a, true); + await vi.waitFor(() => expect(b.currentState().doc.toString()).toBe("alt")); + expect(commands.redoWorkspaceEdit.mock.calls.map(([id]) => id)).toEqual([9, 10]); +}); + +it("refuses redo when a newly opened affected editor has newer typing", async () => { + await open("/p/A.java"); + await rename(); + await workspaceEditHistory(9); + const b = await open("/p/B.java"); + b.dispatchLocal({ changes: { from: 3, insert: " own" }, userEvent: "input.type" }); + await expect(workspaceEditHistory(9, true)).rejects.toThrow("Undo newer editor changes"); + expect(commands.redoWorkspaceEdit).not.toHaveBeenCalled(); + expect(b.currentState().doc.toString()).toBe("old own"); +}); diff --git a/src/editor/core/model/workspaceHistory.ts b/src/editor/core/model/workspaceHistory.ts new file mode 100644 index 00000000..3a05ea44 --- /dev/null +++ b/src/editor/core/model/workspaceHistory.ts @@ -0,0 +1,176 @@ +/** Workspace edits contribute history markers; their text patches never enter local history. */ +import { invertedEffects, isolateHistory, redo, undo } from "@codemirror/commands"; +import { EditorState, Prec, StateEffect, type Transaction, type Extension } from "@codemirror/state"; +import { EditorView, keymap } from "@codemirror/view"; +import { _emitWorkspaceEditHistory, _provideWorkspaceHistory, isExternalChange } from "@/editor/api"; +import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; + +interface Marker { + id: number; + redo: boolean; +} +const marker = StateEffect.define(); + +export interface WorkspaceHistoryTarget { + path: string; + state(): EditorState; + dispatch(tr: Transaction): void; +} + +export class WorkspaceHistory { + private targets = new Set(); + private records = new Map(); + private project = useProject.getState().project; + private busy = false; + private moving = false; + + constructor( + private synced: () => Promise, + private notify: (n: { level: "error"; title: string; body: string }) => void, + ) {} + + start(): () => void { + const stop = onIdeEvent("workspaceEditApplied", ({ id, paths }) => { + if (this.project !== useProject.getState().project) { + this.records.clear(); + this.project = useProject.getState().project; + } + // A new edit invalidates redo for any overlapping workspace transaction. + for (const [key, record] of this.records) + if ( + record.undone && + record.paths.some((path) => this.affects(paths, path) || paths.some((root) => root.startsWith(`${path}/`))) + ) + this.records.delete(key); + this.records.set(id, { paths, undone: false }); + while (this.records.size > 20) this.records.delete(this.records.keys().next().value!); + for (const target of this.targets) if (this.affects(paths, target.path)) this.mark(target, id); + }); + const unprovide = _provideWorkspaceHistory((id, redo) => this.run(id, redo)); + return () => { + stop(); + unprovide(); + this.records.clear(); + }; + } + + private affects(paths: string[], path: string) { + return paths.some((root) => path === root || path.startsWith(`${root}/`)); + } + + attach(target: WorkspaceHistoryTarget): () => void { + this.targets.add(target); + const records = [...this.records].filter(([, record]) => this.affects(record.paths, target.path)); + for (const [id] of records) this.mark(target, id); + // Build the same done/undone marker stacks as existing editors, without + // replaying any backend operation or changing the newly opened text. + for (const [id, record] of records.reverse()) { + if (!record.undone) continue; + const tr = this.step(target, id, false); + if (tr) target.dispatch(tr); + } + return () => this.targets.delete(target); + } + + extension(target: WorkspaceHistoryTarget): Extension { + return [ + Prec.highest( + keymap.of([ + { key: "Mod-z", run: () => this.busy }, + { key: "Mod-Shift-z", run: () => this.busy }, + { key: "Mod-y", run: () => this.busy }, + ]), + ), + Prec.highest( + EditorView.domEventHandlers({ + beforeinput: (event) => { + if (this.busy && (event.inputType === "historyUndo" || event.inputType === "historyRedo")) { + event.preventDefault(); + return true; + } + return false; + }, + }), + ), + invertedEffects.of((tr) => + tr.effects.filter((e) => e.is(marker)).map((e) => marker.of({ id: e.value.id, redo: !e.value.redo })), + ), + // History commands disable transaction filters. An extender observes the + // effect-only history step, including native beforeinput and direct CM undo. + EditorState.transactionExtender.of((tr) => { + if (!this.moving && (tr.isUserEvent("undo") || tr.isUserEvent("redo"))) { + const entry = tr.effects.find((e) => e.is(marker)); + if (entry) + void this.run(entry.value.id, entry.value.redo, target).catch((error: unknown) => { + this.notify({ + level: "error", + title: "Cannot undo refactoring", + body: String(error instanceof Error ? error.message : error), + }); + }); + } + return null; + }), + EditorState.changeFilter.of((tr) => !this.busy || isExternalChange(tr)), + ]; + } + + private mark(target: WorkspaceHistoryTarget, id: number) { + target.dispatch( + target.state().update({ effects: marker.of({ id, redo: true }), annotations: isolateHistory.of("full") }), + ); + } + + /** Inspect the next history step without dispatching it or starting another request. */ + private step(target: WorkspaceHistoryTarget, id: number, forward: boolean): Transaction | null { + let result: Transaction | null = null; + this.moving = true; + try { + (forward ? redo : undo)({ + state: target.state(), + dispatch: (tr) => { + if (tr.effects.some((e) => e.is(marker) && e.value.id === id && e.value.redo === forward)) result = tr; + }, + }); + } finally { + this.moving = false; + } + return result; + } + + async run(id: number, forward = false, source?: WorkspaceHistoryTarget): Promise { + if (this.busy) throw new Error("A refactoring history operation is in progress"); + this.busy = true; + try { + // Yield before reading a source: its marker transaction is still being built. + await this.synced(); + if (this.project !== useProject.getState().project) + throw new Error("The project for this refactoring is no longer open"); + const record = this.records.get(id); + if (!record || record.undone !== forward) throw new Error("The refactoring can no longer be undone"); + const targets = [...this.targets].filter((t) => t !== source && this.affects(record.paths, t.path)); + for (const target of targets) + if (!this.step(target, id, forward)) + throw new Error("Undo newer editor changes before undoing this refactoring"); + await call(forward ? commands.redoWorkspaceEdit(id) : commands.undoWorkspaceEdit(id)); + await this.synced(); + for (const target of targets) { + if (!this.targets.has(target)) continue; + const tr = this.step(target, id, forward); + if (tr) target.dispatch(tr); + } + record.undone = !forward; + _emitWorkspaceEditHistory({ id, undone: record.undone }); + } catch (error) { + // A refused backend restore must leave the initiating editor's history intact. + if (source && this.targets.has(source)) { + const tr = this.step(source, id, !forward); + if (tr) source.dispatch(tr); + } + throw error; + } finally { + this.busy = false; + } + } +} diff --git a/src/editor/core/sync/DocSync.ts b/src/editor/core/sync/DocSync.ts index d262f7ad..b0bb67c3 100644 --- a/src/editor/core/sync/DocSync.ts +++ b/src/editor/core/sync/DocSync.ts @@ -35,7 +35,7 @@ export interface SyncTransport { export interface SyncHost { /** Apply a change set (in local-text coordinates) to every view, annotated as remote. */ - applyRemote(changes: ChangeSet): void; + applyRemote(changes: ChangeSet, workspaceEdit?: boolean): void; /** Called after a hard resync discarded local changes the backend never saw. */ onDiscarded?(reason: unknown): void; /** Non-recoverable transport errors (logged by default). */ @@ -73,6 +73,7 @@ export function toTextChanges(cs: ChangeSet): TextChange[] { } interface RemotePatch { + workspaceEdit?: boolean; baseVersion: number; version: number; changes: TextChange[]; @@ -298,7 +299,7 @@ export class DocSync { } if (p.empty) return; this.localText = p.apply(this.localText); - this.host.applyRemote(p); + this.host.applyRemote(p, patch.workspaceEdit); } /** Last resort: adopt the backend text, dropping unacknowledged local changes. */ diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index aaeed754..7a5ec9ba 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -23,7 +23,7 @@ import { type WorkspaceEdit, } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; -import { whenSynced, type EditorDocContext } from "@/editor/api"; +import { whenAllSynced, whenSynced, type EditorDocContext } from "@/editor/api"; import { structure } from "@/editor/structure"; import { docOf, incompleteNote, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { comparePos, offsetToPos, rangeToOffsets } from "./positions"; @@ -44,6 +44,7 @@ import { tx } from "@/platform/i18n"; async function applyEdit(view: EditorView, edit: WorkspaceEdit) { try { + await whenAllSynced(); await lspRequest(() => commands.applyWorkspaceEdit(edit)); } catch (e) { reportLspError(view, e, true); diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 366cbb6d..5c31bd84 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -417,6 +417,29 @@ describe("rename wiring", () => { expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("only local variables"); }); + it.each(["typescript", "java"])("previews a %s multi-file rename and retains version guards", async (language) => { + cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 3))); + const edit = { + files: ["/p/Main.java", "/p/Use.java"].map((path) => ({ + kind: "edit", + path, + version: 7, + edits: [{ range: r(0, 0, 3), new_text: "new" }], + })), + preconditions: [{ path: "/p/Main.java", hash: "original" }], + }; + cmd.lspRename.mockReturnValueOnce(ok({ edit, incomplete: null })); + const view = editor("old", language, 0); + await renameNamePart(view, { from: 0, to: 3 }, "new"); + const preview = useLspPopup.getState().popup; + expect(preview?.kind).toBe("preview"); + expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled(); + if (preview?.kind !== "preview") throw new Error("missing rename preview"); + expect(preview.summary.files).toBe(2); + preview.onConfirm(); + await vi.waitFor(() => expect(cmd.applyWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(edit)); + }); + describe("Typo: Rename to (issue #42)", () => { it("renames the whole name through the server, references included", async () => { cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 12))); diff --git a/src/features/analysis/AnalysisDialogs.tsx b/src/features/analysis/AnalysisDialogs.tsx index 55cffd85..cd9a8ce3 100644 --- a/src/features/analysis/AnalysisDialogs.tsx +++ b/src/features/analysis/AnalysisDialogs.tsx @@ -7,6 +7,7 @@ import { type DuplicateOptions, type StructuralFile, type StructuralLanguage, + type WorkspaceEdit, } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { activeProfileId, listProfiles, validateInspections, type ProfileId } from "@/platform/inspections"; @@ -29,6 +30,7 @@ import { } from "./structural"; import styles from "./Analysis.module.css"; import { tx } from "@/platform/i18n"; +import { notifications } from "@/workbench/contributions"; function InspectCodeDialog({ onClose }: { onClose: () => void }) { const [scope, setScope] = useState(() => defaultScope()); @@ -254,7 +256,7 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: () } else if (!result.files.length) { await showStructuralResults(form.pattern, result.files, result.summary); } else { - showReplacePreview(result.files); + await showReplacePreview(result.files); } }; @@ -322,7 +324,15 @@ function StructuralDialog({ replace, onClose }: { replace: boolean; onClose: () ); } -function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onClose: () => void }) { +function ReplacePreviewDialog({ + files, + preconditions, + onClose, +}: { + files: StructuralFile[]; + preconditions: WorkspaceEdit["preconditions"]; + onClose: () => void; +}) { const [excluded, setExcluded] = useState>(() => new Set()); const edit = useMemo(() => buildReplaceEdit(files, excluded), [files, excluded]); const count = edit.files.reduce((n, f) => n + (f.kind === "edit" ? f.edits.length : 0), 0); @@ -351,7 +361,7 @@ function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onC disabled: count === 0, onClick: () => { onClose(); - void applyStructuralReplace(edit); + void applyStructuralReplace({ ...edit, preconditions }); }, }, { label: "Cancel", role: "cancel" }, @@ -389,5 +399,18 @@ export const showDuplicatesDialog = () => openDialog((close) => openDialog((close) => ); export const showStructuralDialog = (replace: boolean) => openDialog((close) => ); -export const showReplacePreview = (files: StructuralFile[]) => - openDialog((close) => ); +export const showReplacePreview = async (files: StructuralFile[]) => { + try { + const edit = buildReplaceEdit(files, new Set()); + const prepared = isTauri() ? await call(commands.prepareWorkspaceEdit(edit)) : edit; + openDialog((close) => ( + + )); + } catch (error) { + notifications().notify({ + level: "error", + title: "Cannot preview replacements", + body: error instanceof Error ? error.message : String(error), + }); + } +}; diff --git a/src/features/analysis/replacePreview.test.tsx b/src/features/analysis/replacePreview.test.tsx new file mode 100644 index 00000000..e1af8f8c --- /dev/null +++ b/src/features/analysis/replacePreview.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { StructuralFile, WorkspaceEdit } from "@/platform/ipc"; +import { provideNotificationService } from "@/workbench/contributions"; +import { AnalysisDialogHost } from "./dialogs"; +import { showReplacePreview } from "./AnalysisDialogs"; + +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), apply: vi.fn(), synced: vi.fn(), notify: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, prepareWorkspaceEdit: mocks.prepare, applyWorkspaceEdit: mocks.apply }, + }; +}); +vi.mock("@/editor/api", async (original) => ({ + ...(await original()), + whenAllSynced: mocks.synced, +})); +const files: StructuralFile[] = ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + matches: [ + { + range: { start: { line: 0, col: 0 }, end: { line: 0, col: 3 } }, + text: "old", + lineText: "old", + replacement: "new", + }, + ], +})); +const preconditions = files.map((f) => ({ path: f.path, hash: `preview:${f.path}` })); +beforeEach(() => { + mocks.prepare + .mockReset() + .mockImplementation(async (edit: WorkspaceEdit) => ({ status: "ok", data: { ...edit, preconditions } })); + mocks.apply.mockReset().mockResolvedValue({ status: "ok", data: null }); + mocks.synced.mockReset().mockResolvedValue(undefined); + mocks.notify.mockReset(); + provideNotificationService({ notify: mocks.notify }); +}); +afterEach(() => { + if (screen.queryByRole("button", { name: "Cancel" })) fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + cleanup(); +}); + +it("captures guards before preview and preserves them when the user excludes a file", async () => { + render(); + await act(() => showReplacePreview(files)); + expect(mocks.prepare).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("checkbox", { name: "Replace in b.ts:1" })); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + await waitFor(() => expect(mocks.apply).toHaveBeenCalledOnce()); + expect(mocks.apply.mock.calls[0]![0]).toEqual({ + files: [{ kind: "edit", path: "/p/a.ts", edits: [{ range: files[0]!.matches[0]!.range, new_text: "new" }] }], + preconditions, + }); + expect(mocks.synced).toHaveBeenCalledOnce(); +}); + +it("reports a stale preview without refreshing guards or retrying the edits", async () => { + mocks.apply.mockResolvedValue({ + status: "error", + error: { kind: "outOfSync", message: "a.ts changed; request again" }, + }); + render(); + await act(() => showReplacePreview(files)); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + await waitFor(() => + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "error", title: "Replace Structurally", body: "a.ts changed; request again" }), + ), + ); + expect(mocks.prepare).toHaveBeenCalledOnce(); + expect(mocks.apply).toHaveBeenCalledOnce(); +}); diff --git a/src/features/analysis/structural.ts b/src/features/analysis/structural.ts index 5d35e0ea..54ba8825 100644 --- a/src/features/analysis/structural.ts +++ b/src/features/analysis/structural.ts @@ -4,7 +4,7 @@ * one workspace edit. */ import { Channel } from "@tauri-apps/api/core"; -import { activeEditor } from "@/editor/api"; +import { activeEditor, whenAllSynced } from "@/editor/api"; import { executeAction } from "@/platform/actions"; import { call, @@ -160,6 +160,7 @@ export async function showStructuralResults( export async function applyStructuralReplace(edit: WorkspaceEdit): Promise { try { + await whenAllSynced(); await call(commands.applyWorkspaceEdit(edit)); } catch (e) { notifications().notify({ diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index 1ad1f06e..410c12ee 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -22,6 +22,7 @@ import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; import { goConstraintExcluded } from "./goConstraints"; import { ServerStatus } from "./ServerStatus"; +import { installWorkspaceEditUndo } from "./workspaceUndo"; const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)"; @@ -60,6 +61,7 @@ export function activate(): () => void { installProblemsEvents(), installProblemDecorations(), installServerEvents(), + installWorkspaceEditUndo(), registerToolWindow({ id: "problems", title: "Problems", diff --git a/src/features/code-insight/workspaceUndo.test.ts b/src/features/code-insight/workspaceUndo.test.ts new file mode 100644 index 00000000..a972b9ed --- /dev/null +++ b/src/features/code-insight/workspaceUndo.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { emitLocalIdeEvent } from "@/platform/ipc"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { useProject } from "@/platform/project"; +import { installWorkspaceEditUndo } from "./workspaceUndo"; + +const mocks = vi.hoisted(() => ({ undo: vi.fn(), synced: vi.fn(), history: vi.fn(() => () => {}) })); +vi.mock("@/editor/api", () => ({ + whenAllSynced: mocks.synced, + workspaceEditHistory: mocks.undo, + onWorkspaceEditHistory: mocks.history, +})); + +let stop: () => void; +let notices: Parameters[0][]; + +beforeEach(() => { + mocks.undo.mockReset().mockResolvedValue(undefined); + mocks.synced.mockReset().mockResolvedValue(undefined); + notices = []; + provideNotificationService({ + notify: (notice) => { + notices.push(notice); + }, + }); + stop = installWorkspaceEditUndo(); +}); + +afterEach(() => stop()); + +it("waits for pending typing before undoing the entire backend transaction", async () => { + let release: () => void = () => {}; + mocks.synced.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 17, paths: [] }); + const action = notices[0]!.actions![0]!; + expect(action.label).toBe("Undo"); + const pending = action.run(); + expect(mocks.undo).not.toHaveBeenCalled(); + release(); + await pending; + expect(mocks.undo).toHaveBeenCalledExactlyOnceWith(17); +}); + +it("reports a refused undo instead of discarding newer work", async () => { + mocks.undo.mockRejectedValue(new Error("a.txt changed")); + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 18, paths: [] }); + await notices[0]!.actions![0]!.run(); + expect(notices[1]).toMatchObject({ level: "error", title: "Cannot undo refactoring", body: "a.txt changed" }); +}); + +it("does not apply an old notification's id to a different project", async () => { + emitLocalIdeEvent({ type: "workspaceEditApplied", id: 19, paths: [] }); + const previous = useProject.getState().project; + useProject.setState({ project: { ...previous, root: "/different-project" } as NonNullable }); + try { + await notices[0]!.actions![0]!.run(); + expect(mocks.undo).not.toHaveBeenCalled(); + expect(notices[1]).toMatchObject({ level: "error", body: "The project for this refactoring is no longer open" }); + } finally { + useProject.setState({ project: previous }); + } +}); diff --git a/src/features/code-insight/workspaceUndo.ts b/src/features/code-insight/workspaceUndo.ts new file mode 100644 index 00000000..a9564370 --- /dev/null +++ b/src/features/code-insight/workspaceUndo.ts @@ -0,0 +1,55 @@ +import { onWorkspaceEditHistory, whenAllSynced, workspaceEditHistory } from "@/editor/api"; +import { onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { notifications } from "@/workbench/contributions"; + +/** The notification follows the same history state as editor Undo/Redo. */ +export function installWorkspaceEditUndo(): () => void { + let current: { id: number; project: ReturnType["project"]; undone: boolean } | null = + null; + const show = () => { + if (!current) return; + const entry = current; + const { id, project, undone } = entry; + notifications().notify({ + level: "info", + title: undone ? "Refactoring undone" : "Refactoring applied", + key: "refactor.workspaceEdit", + actions: [ + { + label: undone ? "Redo" : "Undo", + run: async () => { + try { + await whenAllSynced(); + if (project !== useProject.getState().project) + throw new Error("The project for this refactoring is no longer open"); + if (current !== entry || current.undone !== undone) return; + if (undone) await workspaceEditHistory(id, true); + else await workspaceEditHistory(id); + } catch (error) { + notifications().notify({ + level: "error", + title: "Cannot undo refactoring", + body: error instanceof Error ? error.message : String(error), + }); + } + }, + }, + ], + }); + }; + const stopApplied = onIdeEvent("workspaceEditApplied", ({ id }) => { + current = { id, project: useProject.getState().project, undone: false }; + show(); + }); + const stopHistory = onWorkspaceEditHistory(({ id, undone }) => { + if (!current || current.id !== id || current.project !== useProject.getState().project) return; + current = { ...current, undone }; + show(); + }); + return () => { + stopApplied(); + stopHistory(); + current = null; + }; +} diff --git a/src/features/project-tree/RefactoringPreview.tsx b/src/features/project-tree/RefactoringPreview.tsx index 00886ca9..fbcf8020 100644 --- a/src/features/project-tree/RefactoringPreview.tsx +++ b/src/features/project-tree/RefactoringPreview.tsx @@ -138,7 +138,7 @@ function PreviewDialog({ request }: { request: PreviewRequest }) { label: "Do Refactor", role: "default", autoFocus: true, - onClick: () => request.resolve({ files: kept }), + onClick: () => request.resolve({ ...request.edit, files: kept }), }, { label: "Cancel", role: "cancel" }, ]} diff --git a/src/features/project-tree/fileRefactor.test.tsx b/src/features/project-tree/fileRefactor.test.tsx index 34eb34f9..1e6baf07 100644 --- a/src/features/project-tree/fileRefactor.test.tsx +++ b/src/features/project-tree/fileRefactor.test.tsx @@ -22,6 +22,16 @@ const edit = (path: string, ...ranges: [number, number, number, string][]): Work }); describe("file refactoring edits", () => { + it("retains version guards and both sources' preview fingerprints", () => { + const lsp: WorkspaceEdit = { + files: [{ kind: "edit", path: "/p/main.ts", edits: [{ range: r(0, 0, 3), new_text: "new" }], version: 7 }], + preconditions: [{ path: "/p/main.ts", hash: "source" }], + }; + const text: WorkspaceEdit = { files: [], preconditions: [{ path: "/p/README.md", hash: "text" }] }; + const merged = mergeEdits(lsp, text); + expect(merged.files[0]).toMatchObject({ version: 7 }); + expect(merged.preconditions).toEqual([...lsp.preconditions!, ...text.preconditions!]); + }); it("merges text occurrences that no server edit covers and drops file operations", () => { const lsp: WorkspaceEdit = { files: [edit("/p/main.ts", [0, 20, 26, "./b/util"]), { kind: "rename", from: "/p/a", to: "/p/b" }], @@ -58,7 +68,10 @@ describe("runFileRefactoring", () => { backend = { calls: [], edits: vi.fn(async () => ({ - edit: { files: [edit("/p/main.ts", [0, 20, 26, "./b/util"])] }, + edit: { + files: [edit("/p/main.ts", [0, 20, 26, "./b/util"])], + preconditions: [{ path: "/p/main.ts", hash: "preview" }], + }, errors: ["tsserver: busy"], })), move: vi.fn(async (moves: FileMove[], e: WorkspaceEdit) => { @@ -86,6 +99,11 @@ describe("runFileRefactoring", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Do Refactor" })); expect(await status).toBe("done"); expect(backend.calls).toEqual(["move 1 1"]); + expect(backend.move).toHaveBeenCalledWith( + moves, + expect.objectContaining({ preconditions: [{ path: "/p/main.ts", hash: "preview" }] }), + "Move", + ); expect(lastFileRefactoring()?.id).toBe(7); // Server failures are reported; the result offers Undo. expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "warning", body: "tsserver: busy" })); diff --git a/src/features/project-tree/fileRefactor.ts b/src/features/project-tree/fileRefactor.ts index 63e3a65e..6676af2d 100644 --- a/src/features/project-tree/fileRefactor.ts +++ b/src/features/project-tree/fileRefactor.ts @@ -129,9 +129,15 @@ function overlaps(a: { start: { line: number; col: number }; end: { line: number * Only text edits are kept: the move itself is the refactoring's file operation. */ export function mergeEdits(lsp: WorkspaceEdit, text: WorkspaceEdit): WorkspaceEdit { + const versions = new Map(); + const guards: WorkspaceEdit["files"] = []; const byPath = new Map["edits"]>(); for (const f of lsp.files) { if (f.kind !== "edit") continue; + const previous = versions.get(f.path); + if (previous != null && f.version != null && previous !== f.version) + guards.push({ ...f, edits: [], version: previous }); + versions.set(f.path, f.version ?? previous); byPath.set(f.path, [...(byPath.get(f.path) ?? []), ...f.edits]); } for (const f of text.files) { @@ -140,7 +146,15 @@ export function mergeEdits(lsp: WorkspaceEdit, text: WorkspaceEdit): WorkspaceEd const extra = f.edits.filter((t) => !existing.some((e) => overlaps(e.range, t.range))); if (extra.length) byPath.set(f.path, [...existing, ...extra]); } - return { files: [...byPath].filter(([, e]) => e.length).map(([path, edits]) => ({ kind: "edit", path, edits })) }; + return { + files: [ + ...guards, + ...[...byPath] + .filter(([, e]) => e.length) + .map(([path, edits]) => ({ kind: "edit" as const, path, edits, version: versions.get(path) })), + ], + preconditions: [...(lsp.preconditions ?? []), ...(text.preconditions ?? [])], + }; } /** Whether `edit` changes files other than the moved ones (IDEA previews those refactorings). */ diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f12eafbd..4240050a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -326,6 +326,8 @@ "Cannot send the request": "无法发送请求", "Cannot stop process": "无法停止进程", "Cannot undo": "无法撤消", + "Cannot undo refactoring": "无法撤消重构", + "Cannot preview replacements": "无法预览替换", "Caret blinking (ms):": "光标闪烁(毫秒):", "Caret column number (1-based)": "光标所在列号(从 1 开始)", "Caret line number (1-based)": "光标所在行号(从 1 开始)", @@ -1603,6 +1605,7 @@ "Refactor This": "重构此项", "Refactor This…": "重构此项…", "Refactoring undone": "重构已撤消", + "Refactoring applied": "重构已应用", "Reformat Code": "重新格式化代码", "Prettier path": "Prettier 路径", "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找最近的项目 node_modules、受管安装和 PATH。指定路径将覆盖自动检测。", From d981603f3c9b7b618d745a3514256f5f63d18339 Mon Sep 17 00:00:00 2001 From: qiaojianlong <109515705+qjlqyf@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:23:09 +0800 Subject: [PATCH 052/108] feat(java): navigate Spring configuration keys and usages (#823) YAML/properties keys stayed inside the generic YAML language server, so Spring configuration bindings and their consumers were unreachable from the editor. A new command in the bundled JDT LS extension (java-usages 1.2.0) resolves keys to @ConfigurationProperties members (prefixes, nested/inherited members, records, constructor aliases, collections), @Value placeholders and PropertyResolver calls, and back from Java to configuration entries; Find Usages merges configuration consumers and declarations with the native Java results. Several profiles or modules are offered as candidates. Closes #796. Co-authored-by: qjlqyf <109515705+qjlqyf@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../ide-agent/src/native/claude/translate.rs | 2 +- crates/ide-host/src/data_dirs.rs | 2 + crates/ide-language/src/lib.rs | 6 +- .../ide-language/src/syntax/configuration.rs | 242 ++++++++ crates/ide-language/src/syntax/mod.rs | 2 + crates/ide-lsp/src/jdtls.rs | 1 + crates/ide-lsp/src/jdtls/configuration.rs | 416 ++++++++++++++ crates/ide-lsp/src/lib.rs | 49 +- crates/ide-lsp/tests/mock_server.rs | 89 +++ crates/ide-sdk/src/java.rs | 2 +- crates/ide-syntax/src/jvm.rs | 2 +- plugins/java-usages/CONFIGURATION.md | 114 ++++ plugins/java-usages/MANIFEST.MF | 4 +- plugins/java-usages/README.md | 10 +- plugins/java-usages/openidea-java-usages.jar | Bin 31030 -> 82033 bytes plugins/java-usages/plugin.xml | 3 + .../configuration/ConfigurationCommand.java | 525 ++++++++++++++++++ plugins/java-usages/tests/configuration.py | 220 ++++++++ 18 files changed, 1674 insertions(+), 15 deletions(-) create mode 100644 crates/ide-language/src/syntax/configuration.rs create mode 100644 crates/ide-lsp/src/jdtls/configuration.rs create mode 100644 plugins/java-usages/CONFIGURATION.md create mode 100644 plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java create mode 100644 plugins/java-usages/tests/configuration.py diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index ac8158f0..066be5f0 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -169,7 +169,7 @@ impl Translator { Some("tool_use") => { // The input streams after this: shown by name until the message has it. let (id, name) = (str_of(&block, "id"), str_of(&block, "name")); - if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) { + if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) { out.extend(self.ensure_call(&id, &name, &json!({}), &session)); } } diff --git a/crates/ide-host/src/data_dirs.rs b/crates/ide-host/src/data_dirs.rs index 9c96c415..b76b168c 100644 --- a/crates/ide-host/src/data_dirs.rs +++ b/crates/ide-host/src/data_dirs.rs @@ -57,6 +57,7 @@ fn migrate_entries(data: &Path, local: &Path) { "notebook-runtime", "server", "java-usages-1.1.1.jar", + "java-usages-1.2.0.jar", "agents/registry.json", ] .into_iter() @@ -298,6 +299,7 @@ mod tests { "agents/tools", "agents/registry.json", "java-usages-1.1.1.jar", + "java-usages-1.2.0.jar", ] { assert!(is_local(Path::new(entry)), "{entry}"); assert_eq!( diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 908e8315..bd4fa6d5 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -15,9 +15,9 @@ mod syntax; pub use markers::marked_root; pub use registry::{FileTypes, LanguageRegistry}; pub use syntax::{ - CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, - ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, - SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, + CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, + MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, + SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. diff --git a/crates/ide-language/src/syntax/configuration.rs b/crates/ide-language/src/syntax/configuration.rs new file mode 100644 index 00000000..43c1cb5c --- /dev/null +++ b/crates/ide-language/src/syntax/configuration.rs @@ -0,0 +1,242 @@ +//! Source ranges for Spring-style configuration keys. This is a syntax index, +//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are +//! intentionally left to the YAML server / application. +use super::lines::LineIndex; +use super::{ParsedTree, Syntax, TreeError}; +use ide_core::{LanguageId, Range}; +use tree_sitter::Node; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfigurationKey { + pub name: String, + pub range: Range, +} + +impl Syntax { + pub fn configuration_keys( + &self, + language: &LanguageId, + text: &str, + cancelled: &dyn Fn() -> bool, + ) -> Result, TreeError> { + if language.as_str() == "properties" { + return Ok(properties(text)); + } + self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled)) + } +} + +impl ParsedTree<'_> { + pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec { + let mut out = Vec::new(); + if self.grammar == "yaml" { + yaml(self.tree.root_node(), "", self.text, &LineIndex::new(self.text), &mut out, cancelled, 0); + } + out + } +} + +fn yaml( + node: Node<'_>, + prefix: &str, + text: &str, + lines: &LineIndex<'_>, + out: &mut Vec, + cancelled: &dyn Fn() -> bool, + depth: usize, +) { + if depth > 64 || cancelled() || out.len() >= 20_000 { + return; + } + match node.kind() { + "block_mapping_pair" | "flow_pair" => { + let Some(key) = node.child_by_field_name("key") else { return }; + let raw = &text[key.byte_range()]; + if raw.contains('\n') { + return; + } + let name = if raw.starts_with('\'') && raw.ends_with('\'') { + raw[1..raw.len() - 1].replace("''", "'") + } else if raw.starts_with('"') { + // JSON escapes cover ordinary quoted configuration keys. Unsupported + // YAML-only escapes are skipped rather than indexed under a wrong key. + let Ok(name) = serde_json::from_str::(raw) else { return }; + name + } else { + if raw.contains(['\n', '{', '[', '*', '&', '!']) || raw == "<<" { + return; + } + raw.to_owned() + }; + if name.is_empty() { + return; + } + let name = if prefix.is_empty() { + name + } else if name.starts_with('[') { + format!("{prefix}{name}") + } else { + format!("{prefix}.{name}") + }; + if name.len() > 1024 { + return; + } + out.push(ConfigurationKey { name: name.clone(), range: lines.range(key.start_byte(), key.end_byte()) }); + if let Some(value) = node.child_by_field_name("value") { + yaml(value, &name, text, lines, out, cancelled, depth + 1); + } + } + "block_sequence" | "flow_sequence" => { + let mut cursor = node.walk(); + for (i, child) in node.named_children(&mut cursor).filter(|n| n.kind() != "comment").enumerate() { + yaml(child, &format!("{prefix}[{i}]"), text, lines, out, cancelled, depth + 1); + } + } + // Do not descend into scalars, anchors or aliases. + "stream" + | "document" + | "block_node" + | "flow_node" + | "block_mapping" + | "flow_mapping" + | "block_sequence_item" => { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + yaml(child, prefix, text, lines, out, cancelled, depth + 1); + } + } + _ => {} + } +} + +fn properties(text: &str) -> Vec { + let lines = LineIndex::new(text); + let mut out = Vec::new(); + let mut logical = String::new(); + let mut offsets = Vec::new(); + let mut base = 0; + for line in text.split_inclusive('\n') { + let physical = line.trim_end_matches(['\r', '\n']); + let start = physical.len() - physical.trim_start_matches([' ', '\t', '\u{c}']).len(); + let slice = &physical[start..]; + if logical.is_empty() && (slice.is_empty() || slice.starts_with(['#', '!'])) { + base += line.len(); + continue; + } + let continued = slice.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1; + let end = physical.len() - usize::from(continued); + for (i, ch) in physical[start..end].char_indices() { + offsets.extend((0..ch.len_utf8()).map(|byte| base + start + i + byte)); + logical.push(ch); + } + if !continued || base + line.len() == text.len() { + let mut escaped = false; + let key_end = logical + .char_indices() + .find_map(|(i, ch)| { + if escaped { + escaped = false; + return None; + } + if ch == '\\' { + escaped = true; + return None; + } + matches!(ch, '=' | ':' | ' ' | '\t' | '\u{c}').then_some(i) + }) + .unwrap_or(logical.len()); + if key_end > 0 + && key_end <= 1024 + && let Some(name) = unescape_property(&logical[..key_end]) + { + out.push(ConfigurationKey { name, range: lines.range(offsets[0], offsets[key_end - 1] + 1) }); + } + logical.clear(); + offsets.clear(); + } + base += line.len(); + } + out +} + +fn unescape_property(raw: &str) -> Option { + let mut chars = raw.chars(); + let mut out = String::new(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + match chars.next()? { + 'u' => { + let hex: String = chars.by_ref().take(4).collect(); + if hex.len() != 4 { + return None; + } + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + 't' => out.push('\t'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 'f' => out.push('\u{c}'), + ch => out.push(ch), + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn yaml_structures_and_utf16() { + let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n"; + let keys = Syntax::new().configuration_keys(&LanguageId::new("yaml"), text, &|| false).unwrap(); + let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect(); + assert_eq!( + names, + [ + "app", + "app.time-out", + "app.hosts", + "app.hosts[0].url", + "app.hosts[1].url", + "app.map", + "app.map.east", + "app.map.east.size", + "app.name", + "😀", + "😀.port" + ] + ); + assert_eq!(keys.last().unwrap().range.start.col, 7); + } + #[test] + fn aliases_and_values_are_not_configuration_keys() { + let keys = Syntax::new() + .configuration_keys( + &LanguageId::new("yaml"), + "base: &base {size: 1}\napp:\n <<: *base\n text: |\n fake: key\n", + &|| false, + ) + .unwrap(); + assert_eq!(keys.iter().map(|k| k.name.as_str()).collect::>(), ["base", "base.size", "app", "app.text"]); + } + #[test] + fn properties_unicode_ranges_and_eof_continuation() { + let keys = properties("app.中文=1\nlast\\\n"); + assert_eq!(keys[0].range.end.col, 6); + assert_eq!(keys[1].name, "last"); + } + #[test] + fn properties_escapes_continuations_and_comments() { + let keys = properties( + "# comment\r\napp.time\\\n -out : 1\napp\\.name=ok\napp.\\u0061=2\nspace\\ key value\n!ignored=x\n", + ); + assert_eq!( + keys.iter().map(|k| k.name.as_str()).collect::>(), + ["app.time-out", "app.name", "app.a", "space key"] + ); + assert_eq!(keys[0].range.end.line, 2); + } +} diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index 5e22c169..c578dbba 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -1,6 +1,7 @@ //! Tree-sitter driven structure: outline, folding, symbol tags, selection ranges, highlighting. mod cache; +mod configuration; mod folding; mod grammars; mod highlight; @@ -12,6 +13,7 @@ mod web; use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag}; pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats}; +pub use configuration::ConfigurationKey; use grammars::{SPECS, Spec}; pub use highlight::{SyntaxHighlightKind, SyntaxHighlights}; use ide_core::{LanguageId, OutlineItem, Pos, Range}; diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 82a52ef4..8198a443 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -18,6 +18,7 @@ use std::time::{Duration, Instant}; const SERVER: &str = "jdtls"; +pub(crate) mod configuration; mod formatter; pub use formatter::configure_formatter; /// First start imports the Maven / Gradle model, which can take minutes. diff --git a/crates/ide-lsp/src/jdtls/configuration.rs b/crates/ide-lsp/src/jdtls/configuration.rs new file mode 100644 index 00000000..b11260ff --- /dev/null +++ b/crates/ide-lsp/src/jdtls/configuration.rs @@ -0,0 +1,416 @@ +//! Bridges configuration syntax to the app-owned JDT extension. No additional +//! language server, build, or network request is needed. Disk parsing runs on a +//! blocking worker; open buffers always win over cached disk contents. +use super::*; +use crate::text::Encoding; +use crate::types::{References, ReferencesIncomplete}; +use crate::{DocSnap, Target}; +use ide_core::{Location, Pos}; +use ide_language::{ConfigurationKey, Syntax}; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::sync::OnceLock; +use std::time::SystemTime; + +const COMMAND: &str = "openidea.java.configuration"; +const MAX_BYTES: u64 = 2 * 1024 * 1024; +const MAX_FILES: usize = 2000; +const MAX_CACHED_KEYS: usize = 100_000; + +#[derive(Default)] +pub(crate) struct ConfigurationCache { + files: HashMap, +} +struct CachedFile { + stamp: (SystemTime, u64), + keys: Vec, +} +#[derive(Deserialize)] +struct Pattern { + name: String, + relaxed: bool, +} +#[derive(Deserialize)] +struct Response { + #[serde(default)] + locations: Vec, + #[serde(default)] + keys: Vec, + #[serde(default)] + roots: Vec, + #[serde(default)] + incomplete: bool, +} +fn syntax() -> &'static Syntax { + static SYNTAX: OnceLock = OnceLock::new(); + SYNTAX.get_or_init(Syntax::new) +} +fn language(path: &Path) -> Option { + match path.extension()?.to_str()? { + "yaml" | "yml" => Some(LanguageId::new("yaml")), + "properties" => Some(LanguageId::new("properties")), + _ => None, + } +} +fn parts(key: &str) -> Vec { + let mut out = Vec::new(); + let mut part = String::new(); + let mut chars = key.chars().peekable(); + let mut bracket = false; + while let Some(ch) = chars.next() { + match ch { + '[' => { + if !part.is_empty() { + out.push(std::mem::take(&mut part)); + } + for ch in chars.by_ref() { + if ch == ']' { + break; + } + if !matches!(ch, '-' | '_') { + part.extend(ch.to_lowercase()); + } + } + out.push(std::mem::take(&mut part)); + bracket = true; + } + '.' => { + if !bracket { + out.push(std::mem::take(&mut part)); + } + bracket = false; + } + '-' | '_' => {} + ch => { + part.extend(ch.to_lowercase()); + bracket = false; + } + } + } + if !part.is_empty() { + out.push(part); + } + out +} +fn matches(pattern: &Pattern, key: &str) -> bool { + if !pattern.relaxed { + return pattern.name == key; + } + let (a, b) = (parts(&pattern.name), parts(key)); + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a == "*" || *a == b) +} + +impl LspManager { + pub(crate) async fn configuration_navigation( + &self, + snap: &DocSnap, + pos: Pos, + usages: bool, + include_declaration: bool, + cancel: &CancellationToken, + ) -> IdeResult> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let config = language(&snap.path).is_some(); + if !config && snap.language.as_str() != "java" { + return Ok(None); + } + // Definition in ordinary Java code remains JDT's definition. Reverse + // configuration navigation is offered on string expressions; field/config + // accessor usages are merged into the ordinary Find Usages response. + if !config && !usages { + let text = snap.text.as_str(); + if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") { + return Ok(None); + } + } + let key = if config { + let text = snap.text.as_str().to_owned(); + if text.len() as u64 > MAX_BYTES { + return Ok(None); + } + let lang = language(&snap.path).expect("configuration language"); + let cancelled = cancel.clone(); + tokio::task::spawn_blocking(move || { + syntax() + .configuration_keys(&lang, &text, &|| cancelled.is_cancelled()) + .ok()? + .into_iter() + .find(|key| key.range.start <= pos && pos < key.range.end) + .map(|key| key.name) + }) + .await + .ok() + .flatten() + } else { + None + }; + if config && key.is_none() { + return Ok(None); + } + if config { + // Avoid starting a JVM for unrelated YAML (CI, Kubernetes, etc.). + let path = snap.path.clone(); + let root = self.project_root.clone(); + let java_project = tokio::task::spawn_blocking(move || { + path.ancestors().take_while(|p| p.starts_with(&root)).any(|p| { + ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| p.join(name).is_file()) + }) + }) + .await + .unwrap_or(false); + if !java_project { + return Ok(None); + } + } + let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await; + match result { + Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()), + Err(e) => { + tracing::debug!("Configuration navigation unavailable; retaining native language navigation: {e}"); + Ok(None) + } + result => result, + } + } + + async fn configuration_query( + &self, + snap: &DocSnap, + pos: Pos, + key: Option, + usages: bool, + include_declaration: bool, + cancel: &CancellationToken, + ) -> IdeResult> { + let (server, run) = self.jdtls(&snap.path, cancel).await?; + if !run.caps.read().commands().iter().any(|command| command == COMMAND) { + return Ok(None); + } + let target = Target { server: server.clone(), run }; + let position = snap.text.ide_to_lsp(Encoding::Utf16, pos); + let response: Response = self + .call( + &target, + "workspace/executeCommand", + json!({ + "command": COMMAND, "arguments": [snap.uri, key, position.line, position.character, usages, include_declaration] + }), + Some(cancel), + crate::LONG_TIMEOUT, + ) + .await?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if response.keys.is_empty() { + return Ok(response + .incomplete + .then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() })); + } + let mut result = References { + locations: response.locations.iter().filter_map(|l| self.conv(Encoding::Utf16).location(l)).collect(), + incomplete: response.incomplete.then_some(ReferencesIncomplete::FilesSkipped), + ..Default::default() + }; + self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref())); + if key.is_none() || usages { + let roots: Vec = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect(); + let overlays: HashMap = self + .docs + .lock() + .values() + .filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES) + .map(|d| (d.canonical.clone(), d.text.as_str().to_owned())) + .collect(); + let cache = self.configuration_cache.clone(); + let cancelled = cancel.clone(); + let keys = response.keys; + let files = + tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled)) + .await + .map_err(|_| IdeError::unavailable("configuration search worker stopped"))?; + result.locations.extend(files.locations); + result.incomplete = result.incomplete.or(files.incomplete); + } + if cancel.is_cancelled() || !self.docs.lock().values().any(|d| d.uri == snap.uri && d.version == snap.version) { + return Err(IdeError::cancelled()); + } + if usages && key.is_some() && !include_declaration { + let source_path = self + .docs + .lock() + .values() + .find(|d| d.uri == snap.uri) + .map(|d| d.canonical.clone()) + .unwrap_or_else(|| snap.path.clone()); + result.locations.retain(|location| { + location.path.to_path_buf() != source_path || !(location.range.start <= pos && pos < location.range.end) + }); + } + crate::convert::dedup_locations(&mut result.locations); + Ok(Some(result)) + } +} + +fn in_module(root: &Path, path: &Path) -> bool { + if !path.starts_with(root) { + return false; + } + for parent in path.parent().into_iter().flat_map(Path::ancestors).take_while(|p| *p != root) { + if matches!( + parent.file_name().and_then(|n| n.to_str()), + Some("target" | "build" | "node_modules" | ".git" | ".gradle") + ) { + return false; + } + if ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"].iter().any(|name| parent.join(name).is_file()) + { + return false; + } + } + true +} + +fn configuration_files( + roots: &[PathBuf], + patterns: &[Pattern], + overlays: &HashMap, + cache: &Mutex, + cancel: &CancellationToken, +) -> References { + let mut result = References::default(); + let mut paths = std::collections::BTreeSet::new(); + // Do not scan a source root twice when its enclosing module is also present. + let roots: Vec<_> = + roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect(); + for root in &roots { + let module = (*root).clone(); + let walker = ignore::WalkBuilder::new(root) + .follow_links(false) + .filter_entry(move |entry| { + if !entry.file_type().is_some_and(|t| t.is_dir()) { + return true; + } + if entry.path() == module { + return true; + } + if matches!(entry.file_name().to_str(), Some("target" | "build" | "node_modules" | ".git" | ".gradle")) + { + return false; + } + // A sibling application's identical key must not leak into this module. + !["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| entry.path().join(name).is_file()) + }) + .build(); + for (visited, entry) in walker.enumerate() { + if cancel.is_cancelled() { + return result; + } + if visited >= 50_000 || paths.len() >= MAX_FILES { + result.incomplete = Some(ReferencesIncomplete::TooManyFiles); + break; + } + match entry { + Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => { + paths.insert(entry.into_path()); + } + Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped), + _ => {} + } + } + } + paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned()); + let mut cache = cache.lock(); + if cache.files.len() > MAX_FILES { + cache.files.clear(); + } + for path in paths { + if cancel.is_cancelled() { + return result; + } + let lang = language(&path).expect("configuration file"); + let keys = if let Some(text) = overlays.get(&path) { + syntax().configuration_keys(&lang, text, &|| cancel.is_cancelled()).ok() + } else { + (|| { + let metadata = std::fs::metadata(&path).ok()?; + if metadata.len() > MAX_BYTES { + return None; + } + let stamp = (metadata.modified().ok()?, metadata.len()); + if let Some(file) = cache.files.get(&path).filter(|f| f.stamp == stamp) { + return Some(file.keys.clone()); + } + let text = std::fs::read_to_string(&path).ok()?; + let keys = syntax().configuration_keys(&lang, &text, &|| cancel.is_cancelled()).ok()?; + cache.files.remove(&path); + if cache.files.len() >= MAX_FILES + || cache.files.values().map(|file| file.keys.len()).sum::() + keys.len() > MAX_CACHED_KEYS + { + cache.files.clear(); + } + cache.files.insert(path.clone(), CachedFile { stamp, keys: keys.clone() }); + Some(keys) + })() + }; + match keys { + Some(keys) => result.locations.extend( + keys.into_iter() + .filter(|key| patterns.iter().any(|p| matches(p, &key.name))) + .map(|key| Location::file(path.clone(), key.range)), + ), + None => result.incomplete = Some(ReferencesIncomplete::FilesSkipped), + } + if result.locations.len() >= MAX_FILES { + result.locations.truncate(MAX_FILES); + result.incomplete = Some(ReferencesIncomplete::TooManyFiles); + break; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn file_cache_tracks_edits_overlays_removals_and_limits() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().to_path_buf(); + let file = root.join("application.properties"); + std::fs::write(&file, "app.port=1\n").unwrap(); + let patterns = [Pattern { name: "app.port".into(), relaxed: true }]; + let cache = Mutex::new(ConfigurationCache::default()); + let cancel = CancellationToken::new(); + let query = |overlays: &HashMap| { + configuration_files(std::slice::from_ref(&root), &patterns, overlays, &cache, &cancel) + }; + assert_eq!(query(&HashMap::new()).locations.len(), 1); + std::fs::write(&file, "app.other=12\n").unwrap(); + assert!(query(&HashMap::new()).locations.is_empty()); + assert_eq!(query(&HashMap::from([(file.clone(), "app.port=unsaved\n".into())])).locations.len(), 1); + std::fs::remove_file(&file).unwrap(); + assert!(query(&HashMap::new()).locations.is_empty()); + std::fs::write(&file, "x".repeat(MAX_BYTES as usize + 1)).unwrap(); + assert_eq!(query(&HashMap::new()).incomplete, Some(ReferencesIncomplete::FilesSkipped)); + cancel.cancel(); + assert!(query(&HashMap::new()).locations.is_empty()); + } + + #[test] + fn relaxed_binding_keeps_boundaries() { + let pattern = Pattern { name: "app.clients[*].timeOut".into(), relaxed: true }; + assert!(matches(&pattern, "app.clients[2].time-out")); + assert!(!matches(&pattern, "app.clientsTimeOut")); + let map = Pattern { name: "app.routes.*.url".into(), relaxed: true }; + assert!(matches(&map, "app.routes[us.east].url")); + assert!(!matches(&map, "app.routes.us.east.url")); + assert!(!matches(&Pattern { name: "app.time-out".into(), relaxed: false }, "app.timeOut")); + } +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index d8ead994..45bef903 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -179,6 +179,7 @@ pub struct LspManager { shutting_down: AtomicBool, execution_allowed: AtomicBool, java_usages_warned: AtomicBool, + configuration_cache: Arc>, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, /// Virtual (non-`file:`) URIs a server returned, by the server that owns them. @@ -313,6 +314,7 @@ impl LspManager { shutting_down: AtomicBool::new(false), execution_allowed: AtomicBool::new(true), java_usages_warned: AtomicBool::new(false), + configuration_cache: Arc::new(Mutex::new(Default::default())), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), virtual_docs: Mutex::new(HashMap::new()), @@ -1122,6 +1124,15 @@ impl LspManager { } pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { + if matches!(kind, GotoKind::Definition) { + let snap = self.snapshot(doc)?; + if let Some(result) = + self.configuration_navigation(&snap, pos, false, false, &CancellationToken::new()).await? + && !result.locations.is_empty() + { + return Ok(result.locations); + } + } let (method, cap, feature) = goto_method(kind); let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; self.goto_in(&snap, &targets, method, pos, None).await @@ -1200,6 +1211,36 @@ impl LspManager { include_declaration: bool, project_wide: bool, cancel: CancellationToken, + ) -> IdeResult { + let snap = self.snapshot(doc)?; + // Scoped probes back code lenses and bulk analysis. Keep their native cost; + // configuration indexing is reserved for explicit, project-wide usages. + let configuration = if project_wide || snap.language.as_str() != "java" { + self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await? + } else { + None + }; + if snap.language.as_str() != "java" + && let Some(result) = configuration + { + return Ok(result); + } + let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?; + if let Some(configuration) = configuration { + result.locations.extend(configuration.locations); + result.incomplete = result.incomplete.or(configuration.incomplete); + convert::dedup_locations(&mut result.locations); + } + Ok(result) + } + + async fn native_references( + &self, + doc: DocId, + pos: Pos, + include_declaration: bool, + project_wide: bool, + cancel: CancellationToken, ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; // The extension preserves method identity before JDT collapses references to locations. @@ -1212,7 +1253,7 @@ impl LspManager { let t = &targets[0]; let extension_error = Mutex::new(None); let query = || async { - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } let params = snap.pos_params(t, pos); @@ -1240,7 +1281,7 @@ impl LspManager { *extension_error.lock() = Some(e.clone()); } let rows = result?; - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } let conv = self.conv(t.enc()); @@ -1266,7 +1307,7 @@ impl LspManager { scope::Widened::only(first, empty, &t.server) }; // A changed source also invalidates an earlier answer retained by scope widening. - if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) { + if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } // Scope widening can retain the first answer after a failed second query. @@ -1292,7 +1333,7 @@ impl LspManager { Err(e) => { if e.kind == ErrorKind::Cancelled || cancel.is_cancelled() - || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) + || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index a473e8cb..b33110a2 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2916,3 +2916,92 @@ async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { assert_eq!(h.mgr.servers().len(), 1); h.mgr.shutdown_all().await; } + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_definition_uses_java_without_yaml_definition_capability() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + let s = h.launcher.state("jdtls"); + let target = uri_of(&h.root.join("Settings.java")); + s.respond("workspace/executeCommand", json!({ + "locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}], + "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [] + })); + let path = h.root.join("application.yml"); + let doc = DocId::next(); + h.mgr.did_open(doc, &path, &LanguageId::new("yaml"), 1, "app:\n time-out: 5\n"); + let locations = h.mgr.goto(doc, Pos::new(1, 4), GotoKind::Definition).await.unwrap(); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].range.start, Pos::new(2, 4)); + assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "app.time-out"); + assert!(s.requested("textDocument/definition").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_modules() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let (doc, path) = h.open("Settings.java", "int timeOut;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let root = uri_of(&h.root); + s.respond( + "workspace/executeCommand", + json!({ + "locations": [], "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [root] + }), + ); + s.respond( + "textDocument/references", + json!([{"uri": uri_of(&path), "range": { + "start": {"line": 3, "character": 0}, "end": {"line": 3, "character": 7} + }}]), + ); + std::fs::write(h.root.join("application.yml"), "app:\n time-out: 1\n").unwrap(); + std::fs::write(h.root.join("application-prod.properties"), "app.time-out=2\n").unwrap(); + let overlay = h.root.join("application-dev.yml"); + std::fs::write(&overlay, "app:\n old: 3\n").unwrap(); + h.mgr.did_open(DocId::next(), &overlay, &LanguageId::new("yaml"), 1, "app:\n time-out: 3\n"); + let sibling = h.root.join("other"); + std::fs::create_dir(&sibling).unwrap(); + std::fs::write(sibling.join("pom.xml"), "").unwrap(); + std::fs::write(sibling.join("application.yml"), "app:\n time-out: wrong\n").unwrap(); + h.mgr.did_open( + DocId::next(), + &sibling.join("application.yml"), + &LanguageId::new("yaml"), + 1, + "app:\n time-out: still-wrong\n", + ); + let scoped = h.mgr.references(doc, Pos::new(0, 6), false, false, Default::default()).await.unwrap(); + assert_eq!(scoped.locations.len(), 1); + assert!(s.requested("workspace/executeCommand").is_empty()); + let refs = h.mgr.references(doc, Pos::new(0, 6), false, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 4, "{refs:?}"); + assert!(refs.locations.iter().all(|l| !l.path.to_path_buf().starts_with(&sibling))); + assert!(refs.locations.iter().any(|l| l.path.to_path_buf() == overlay)); + assert_eq!(refs.incomplete, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_extension_failure_preserves_native_java_navigation() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let (doc, path) = h.open("Settings.java", "@Value(\"${app.port}\") int port;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into())); + s.respond( + "textDocument/definition", + json!({"uri": uri_of(&path), "range": { + "start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6} + }}), + ); + let result = h.mgr.goto(doc, Pos::new(0, 2), GotoKind::Definition).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(s.requested("textDocument/definition").len(), 1); +} diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 56d81ecf..9120b0e5 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1173,7 +1173,7 @@ mod kotlin_bridge_bundle_tests { /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { - let extension = data_dir.join("java-usages-1.1.1.jar"); + let extension = data_dir.join("java-usages-1.2.0.jar"); let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar"); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { clean_usages_bundles(data_dir, &extension); diff --git a/crates/ide-syntax/src/jvm.rs b/crates/ide-syntax/src/jvm.rs index 9da0f7b4..88e3c7ca 100644 --- a/crates/ide-syntax/src/jvm.rs +++ b/crates/ide-syntax/src/jvm.rs @@ -159,7 +159,7 @@ impl StructureService { let runnable = body.is_some_and(|b| { members(*b).into_iter().any(|n| { if !matches!(n.kind(), "method_declaration" | "function_declaration") - || !n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == "main") + || n.child_by_field_name("name").is_none_or(|id| text(id, snap.text) != "main") { return false; } diff --git a/plugins/java-usages/CONFIGURATION.md b/plugins/java-usages/CONFIGURATION.md new file mode 100644 index 00000000..fae42f40 --- /dev/null +++ b/plugins/java-usages/CONFIGURATION.md @@ -0,0 +1,114 @@ +# Spring configuration navigation + +`openidea.java.configuration` bridges YAML / properties to the existing JDT LS +Java model. It is bundled with the Java usages extension; it does not install a +second language server, run a build, change the application's dependencies, or +connect to external configuration services. + +## Editor behavior + +- **Go to Definition** on a YAML / properties key opens binding members and + literal consumers in Java. Multiple matches use the existing location chooser. +- **Go to Definition** inside a Java `@Value` placeholder or a constant + `Environment.getProperty` / `getRequiredProperty` argument opens matching + configuration keys. Ordinary Java definition navigation remains native. +- **Find Usages** joins configuration declarations, property consumers, and Java + references to associated binding members. Java's existing reference results and + read/write classifications are retained. +- Open editor contents take precedence over disk. All matching profiles/documents + are shown; navigation does not claim to know the runtime effective value. +- Missing or older extension bundles preserve native language-server behavior. + YAML aliases still use the YAML server's anchor navigation. + +## Association sources + +The Java command resolves annotation and method identities with JDT bindings: + +- `@ConfigurationProperties` on classes and factory methods, including inherited + members, nested objects, records, constructor-only parameters / `@Name`, arrays, + collections and maps. Binder keys use relaxed spelling while retaining path + boundaries. Indexed collection positions and arbitrary map keys use wildcards. +- `@Value("${key:default}")`, including constant annotation values and nested + default placeholders. +- Literal or compile-time constant keys passed to Spring's `PropertyResolver` + methods, including through `Environment`. Unrelated methods with the same name + and non-Spring annotations are excluded. +- Dependency JAR `META-INF/spring-configuration-metadata.json` and + `additional-spring-configuration-metadata.json`. The closest group, source + type, and source method locate a member or its owning class. Source attachments + and JDT class-file/decompiler navigation use the existing virtual-document path. +- The same metadata files in source roots and `src/main/resources`. + +YAML key ranges come from the bundled tree-sitter grammar, including nested/flow +mappings, sequences, quoted keys and multiple documents. Properties support +escaped keys, Unicode escapes and logical-line continuations. Ranges crossing +IPC are UTF-16. No new IPC type or frontend provider is required. + +## Scope and costs + +The command uses the selected JDT project's resolved package roots, including +source dependencies and its dependency JARs. Configuration search includes those +modules, skips nested independent modules and build output, and honors ignore +files for disk discovery. It does not guess a global association from matching +text in unrelated projects. + +Indexing is lazy. The first explicit navigation/search builds a compact snapshot; +ordinary typing, code-lens probes and bulk scoped reference analysis do not start indexing. At most eight project snapshots are +retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations; +ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST +construction for ordinary Java units, but every accepted association is resolved +semantically. Warm requests reuse the snapshot. Java model/reconcile changes +invalidate it conservatively, including dependent-type changes; open working-copy +content fingerprints also prevent stale answers before reconciliation. This is +**snapshot invalidation**, not a fully incremental Java semantic index. + +Metadata/JAR timestamps are checked before reuse. The client caches parsed disk +configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and +performs bounded discovery/parsing on a blocking worker. It does not continuously +watch or parse an additional copy of the entire workspace. + +Bounds: 20,000 Java units, 100,000 associations per snapshot, 2 MiB per candidate +metadata/configuration file (Java source: 2M UTF-16 units), 1,024-character property names, 2,000 configuration files/results, 50,000 filesystem +entries per root, and depth limits on recursive bindings/YAML. Java requests and +references are cancellable. File/association scan limits and unreadable files carry the +existing incomplete-result indicator; unsupported syntax keys are omitted. First navigation can take seconds on a +cold project; classpath import and source download remain owned by JDT LS. + +## Deliberate limits + +This is Java/Spring property navigation, not full IntelliJ Spring support. It +cannot infer dynamically constructed names, custom configuration APIs/annotations, +remote-only configuration, arbitrary SpEL evaluation, YAML merge/alias expansion, +or custom Spring converters that change the binding shape. Kotlin is not indexed +by this Java bundle. YAML-only quoted-string escape forms that cannot be decoded +safely are skipped. Properties surrogate-pair escapes are not decoded as keys. +A missing source attachment may navigate to the decompiled owning class when JDT +cannot provide an exact member range. Binding members are navigation candidates; +the feature does not validate whether Spring will successfully bind a bean at +runtime or resolve profile/precedence rules. + +## Verification + +Use the JDT LS distribution / Java version documented in [README](README.md). + +```sh +python3 plugins/java-usages/build.py /path/to/jdtls +python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +OPENIDEA_CONFIGURATION_STRESS=1 python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +OPENIDEA_SPRING_BOOT_JAR=/path/to/spring-boot-autoconfigure.jar python3 plugins/java-usages/tests/configuration.py /path/to/jdtls +cargo test -p ide-language -p ide-lsp +``` + +The isolated real-server harness covers custom bindings, inheritance, nesting, +collections/maps, records, constructor aliases, factory methods, consumers, +constant keys, negative identity matches, dependency metadata/source locations, +reverse queries, and unsaved-change/close invalidation. Optional stress fixtures +add 3,000 ordinary units and 100 binding classes; output includes cold/warm/edit +latencies for the JDT command only, excluding server import, UI and configuration-file discovery. The optional actual Spring Boot JAR checks `server.port`, +`spring.datasource.url` and `spring.jackson.date-format` through dependency +metadata. No user's project is compiled or modified by the harness. + +Rust tests cover configuration syntax/ranges and the actual manager bridge: +YAML navigation without YAML definition capabilities, Java native fallback, +reference merging, profiles, open-buffer overlays and sibling-module exclusion. +Existing Java usages / read-write integration harnesses remain regression checks. diff --git a/plugins/java-usages/MANIFEST.MF b/plugins/java-usages/MANIFEST.MF index 1775ddec..7ec5b7f3 100644 --- a/plugins/java-usages/MANIFEST.MF +++ b/plugins/java-usages/MANIFEST.MF @@ -2,9 +2,9 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: OpenIDEA Java usages Bundle-SymbolicName: ai.openidea.usages;singleton:=true -Bundle-Version: 1.1.1 +Bundle-Version: 1.2.0 Bundle-RequiredExecutionEnvironment: JavaSE-21 Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime, - org.eclipse.lsp4j + org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson diff --git a/plugins/java-usages/README.md b/plugins/java-usages/README.md index 5be95510..6f820321 100644 --- a/plugins/java-usages/README.md +++ b/plugins/java-usages/README.md @@ -1,4 +1,6 @@ -# Semantic Java usages +# Semantic Java usages and configuration navigation + +Spring YAML / properties navigation is described in [CONFIGURATION.md](CONFIGURATION.md). The app-owned OSGi command `openidea.java.usages` returns `{location, kind, access}`. `kind` retains field/getter/setter/unknown identity for compatibility. Optional @@ -55,7 +57,7 @@ is not a claim of full IntelliJ feature parity. ## Rebuild and delivery The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as -`java-usages-1.1.1.jar`, and added alongside existing initialization bundles. +`java-usages-1.2.0.jar`, and added alongside existing initialization bundles. Users need no compiler, Maven or Python. Manifest/install version must change when replacing the bundle because OSGi caches bundles. @@ -64,8 +66,10 @@ Use its shipped ECJ and plugin jars, with Java 25; emitted bytecode targets Java The build uses sorted inputs and fixed zip timestamps and does not resolve Maven artifacts. Check the extracted distribution against `build-dependencies.sha256` from its root (`sha256sum -c /path/to/build-dependencies.sha256`). Build twice -and compare SHA-256. The 1.1.1 output is +and compare SHA-256. The prior 1.1.1 output was `1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`. +Version 1.2.0 adds the configuration command; its reproducible SHA-256 is +`9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`. ```sh export PATH=/path/to/jdk25/bin:$PATH diff --git a/plugins/java-usages/openidea-java-usages.jar b/plugins/java-usages/openidea-java-usages.jar index 0eee593542b614d0580af64f0a9732b3042f96a6..3f521b3a3218b8f52fc4fa0808705773eec01462 100644 GIT binary patch literal 82033 zcmdpf31C#!+4g(Ro#aktZWa;(3~Rt3kQJgvO%T}v1PCBuF`&qh3@{Rsm`PY%YsI>2 ztF|@N4Yf_JRz)QwU{Tu&w$*A|TkZ07|604-)mG8`&wK8@Gk4Z3>i7NsuVm)Vxo2PA z{k-q0WtwXwng30`VD;~Rbo~v2i2pdXu4c`=(%Kb^%IoH>s9jW3zox8iQJudfxHS;! zC|wt54+UFWstRV6&GgLgY-wr^l-e)D?<(v!EBreHRRt^C0xh)*YvvU!_3!c*bcX!f z0wFs`{ob8hTAPE7W=?;wtQDDqO#y$InXft&Y}wWv=s=U_bhLK{>~i6@W|dV$da^oj zNoTM<&{VT0(Ae35e%7??3bwbl>0Jo=?Lx<dS94z-Bn6!{+}d<<2|5LH zS8mN|Ip!4oX%Cb)weBpRSHEUmFcj=)Z5Nc@u{{_noQ1}g53jXqLGE4t=EBaFV27Y- z%M-R*ivhO<&81n3j3|^oJBWulg0@T_Zp(vgYsNZ3t_7`4feaFqX;2!a^Ha7#I=MZh z(FlVwC|8g+owH^y54HqWbne^|XkX*s(j36}+S&tZ!URp7p14tKT34Wz>KW@YXf*kB z8Y8H3c#~?-SQ;lt-`R>e;$EZv;(`?m$#M&Kl74=_#_-D*#p(;1zWi+mO1ab^i7pF$FwSOr<)Nq&!^V0T@(9 z<$}gwZCaam1tLw)54JR+PlC#)&xl&rs1er26{z;oEILi63hal3$BcJjd@+Mgr!xeN z*cRxBRJmqv8+P`LlpWwaR$tNdnRK>JXJM(5H|;23P!-{n=b+K^I{nSTt-(MOqZmOW z(dNa#L*>o>mTl$r9qpKkYB!xDXaQ<23$<~=)^_CG-P*pRY)flv2XH}4M_^A!Sz9}x zYkNm95Gsp;r#J#ux`)oCc{hG@lj#@Bl^mn?r)~r#t$wa?8bm#t!U8H`O2y zPW;y39zIEn4O&9A01N)`!mjf-cVZy*DfV3aDpWffvj&}D9=Q9<3|dZgIQ_v;&Ca%t zy$Ud)Un}{9ano)(50nCL#(IO+&{{zmnC4afb|6_ng{jsQpkKi#oz@GQnRF%RZ3%U> z`x`rWK^Fpt_)R3{p>@LWGSZwju)!HqADQ(X{>B}3 z{x(Im>GYr=<`a^Vzzhcmpa2x$sBPKR+8Ci#zCd5psXNs+G=WB>m_ZLSq#0=e%qW<` z=kgI=N?^b*3-V%`EnK$OO^*x8LxRmy*)O=vL4%&Ok-7=lfHW5{58!8|gJ>r4zCcF|I?5+8 z!{5HGlfht!&)qWqIA+kZ^c;X3M$34))`=OzN93!ic5DPnc<6chxRuBSmS z)3<@3+x+cZPf?P|i(`a%=Nh#wErE8mdI6ji-Qo%XcvgVpU(y=t0AY?Ef;E&mIn2Kh z<>WH=f)pKp86XC*dGIuiq{^3FRD6hxqLM?DRdkqg4&oIy6F$@NO=1*2M^e63=oEZQ zyz>@4O~%tSs+Y#c78s2zQ;0KiE0d6&3+P3~Jyhs=6pzl@$EeKH2bbBWGS3)X=CNoN ze1e%-PR3NAJ7N`jQB+rQf?Nkn`e>%0KB^S7qBM7Q56$VK`$}>b_R^v%m(O*aygpYC zE$t$2N$v_O@dW9)tEyb6WOY@VFYS3+*F~rJ(w?JrRp~qZYG@zAat!I_KL`rTg?AYC29YTKV}`AA!Q}p`B4lmk1mJM?Ie{&FxT~yw&bcaaCIJ z^R%mr@`|6MT~+Da_MWP=ZaN>ymv_;5NWR=`@X9{=xS*V5Z4xoib@}4A*PP5*l89JTI|J)~;y?Wq3wD>(LQfUwL(>Tr~ zbSg$xD<3_8ZGSobZXhb_$M#Q8kc0k%A@Ht!$l6;+$i*jz_F|WP| zn4A4H!us`?nyIz;^#tBMWi3}P9qOXFDB0IX&tT^0bbi+Q=qr+*qvuzY_R!aOhdoc{ zcF`E0>p42NDoxERe`ewJadl42d7X&9lQ;wOT1ivInY2ipMQg;_n8zxRRkPLncuAh5 zsXA?cm(D}azo8c8oAfPwy8yRdqF3;gi-j5u@ck+T1~}O2zwh8v(u8P1)!xjzpS*q^ z5&z9bhM`FYY(Z7Dm`Wa0Vya=vIFyje!K9gR9^h|SLJyP!N@)jY2+q%ee-wcSq*EW< z*5U`v3)s9Q=Jo8M%Aab`(BA4fAdAwr0#TsFY)6%#K=9_G=#<&!&z4|oITtBkL8Bl#dp+YxADW)RcDbcV`1*#O)&xYQ|1 z9G6Jw_5m3l4^3wfG9#fJpqNEC>CJ(bZ5`Xy_-eh7)y&jcTocCv%;LvXg!Laa+#ZjF(Ag)Q-eVnPOOVeyEkqt#v>L5l}Voz{X9 zPqEHA0rVu6g)9Y?Tn7%Txn@seppAz#EaKQh6%;h+V#-5tC71S4Gqvcn6A;JQvnbIi z*g_VYVD1de-O}at%l#doq&;*Ag>(WNKP6RB2+~3U2unIxGLq2}*li|Yq|BHMToN4i zCA8b1Jxl;*gs`%Tr{L5}4Z4gj2U&qsi~F*KOW0tp={PqYx{^Mo(^Z1bj%_J^i805^ zAw=zjKCeE|zAMnas=0Gp5F(?inJ{E#vR3gujaa6063AEvT_c8cCG9ilTKa?uR@Or_ z%N%EG+uoJC!MvmpCC6q}({sH6+7058JWNDvhUDB+Y)+_Jcvf#V=oX$;uFRg!)q%!V zOvqk-f15#{q)%Zj8ruWd2-qEhRz@Fz0hAjlSKSh*-jPaz6C=`Kgz#yDa%hB3pZx%e zJ`a6{x)^MOnExNpqj?~xxZ9w6=w6^teO(T{nMkK^w_J9FW{y_%BElq(v zD_WO2wu_~0ek16VHb|$0)F$bL0_Ur_^frZ+#36m1$7L&;;1yt1a3(V;gOvW`uliK|c&8#8@XdvGTZ)2;xgf5o6AQMc7I3_+C$MU9WrG$pjzToMs7L*;Ll50#*6k1gTMu$DM)nMJ3{M&Ov5GIHbBcS zqnBKAPZ{^}6$6YCnn*`4mjxif{4Oof>3g6=)JCzVm0S6~K|ioa1f_)G;%^xAL;BGN z5a$c}36n&pa%Dd?=x6kEpaS&8l;8!LOi>b0LCN$P8&m9vC=nvy=`ZQmI{nHfb=5Ew zfez&V-{8F|9j7AT2g)p)zR__eY@L^bRWUQNRu1{AVbnlx8uUB*J&ponP*5~c0`RS! zEsc!Z%U8kXBM2#J0HW|e(w}wulOP8h5LK-~f1$U~DKJ8yReKB^+s z*53^JJN*OIfxrPjf(ec4p(WriqkkFnHvM~W!u)sWq)zXms>rs82=m{g_c0r(g!$mW zl2AoTK^wJtUg{Up5SnnA`zoTL#Wt9kdK*OY+8U}{wEQMkK1n!=spdd!@UbWuXl}w>m!;k|>-_COHf*sc z8Zsm?4X0d8haxZmYI2^x1jm!NTah7(dAnt_w6+A)e&bmwHAESNe&o8iH3;%Qq4F|r zJ0(Ec*kSYrZ?g(RoF-0>*_@0XG3cVygLWcO5CNjn5NC?Bu%0adYk?*~tqDe+0t7qj zOPtNgkS9=Dd+8lfWr%8pWGwZWE?@hTz#z)bY=|W+OL7N8U`{)NU?Z94+Q3~|W{Bk$%}~F$rDJ;luQ`2% zAyzVqwe^xn!`k7+YD3iX$>bM2dAy$U`D2|S)^kmn!H`nZw0E+?ncu8%vsg<5RnI37 z?C_Vwg$#uf5q55I5wfCgF6cMJ7H%tA)9n#WVyiB|3z>o}1;m(!jW!$wg6Fq}S5g|i#DB#l`*a@yi7rQWq1fW$h2fC@%BU${v%S4_w`o~-&Hvz`OF=A)s}1pSbr6FgML6Zc zfN~!%`L%*hP0+^#>zWH;{yv7x*BjynUR*Z%unazUzcbLe$)JPuq+8r#QJiH=@{}pb zXoa}V5T6v_Qa>6EY%Zmn-MiBepJo`!WH$F}o~@0ALQ(8@2;qO`c?iCM-FX-a)&=NZ(E%7-G(Jj8H z2uv2hW9DoE6T*B>srHC3iAQzu2pC+2&a8Apd|5oE%9(u%+m1wQ0eHMm7~&xBKMy#n zJ*EJUlbpVn{V#e^58D8<6_^K|Rk7C1cC=Bs6@pS<8woXv> zQN)AAxH#Z#G%KSZyOpGik{rUYb91Hp`=H=D88wSZ`c4ToP`$_VE0?%C54$* z2e!eoyxrUYHjxz0th6~6JPs#D8i9aV7vBcvQoT@Y&ZWV&@(sZ@uFor8<$d@ao81Uk zz_ohB%i?ZZkHDJ0bqbMsRBgqB?lt97?{12!SBAXBns ze{<=;MIgTyKQhFR#ZMsEZruv~g%b=8v;b<1iuKBc-gqC6;%A2Vx%dTO2ZKdcpfQ*0 zSV6Fy5ZqKdiA`&gOm&+_{7U?{E`AMZP24o99ESLf_^k;8>Y=PrGX14#N+VhTnLN7i z2SfZ(fH7srT-$}oF2Hq84sTYLbb~WDCb)b{w_Arn<%A;fBr%#dDPdZ1QGkXe{lCF2~v% zO+h!ui}A@wdBuTPh*g&pagaC8*JVMH^|goU30EC*%Ts{Awzao*wuL-$lALPDQ(5$; zfnVX@(+xR87FmJ|wLpq`iX14_)$qwqv|_42wJxEn@g;rlk;OEMKmO&GGg0?+ z0SG`qB-{n@Vpg_66*ck_L$~kRO-VfTG}|VI>KA$=Q9Yq*=LX&=M+K-rBmOv#mNF zL^x8S3Mv>mte0k!&ZgokcA^05?E-&ub3LvORA;BlD|2WeBmnX{L$=6PBzz)AwpwX7 z8!{w2kakm!?6A^4WyrnqQlx#7#koUTxEy(hM}9`$rAug*3*+YBL^{|#5MeDc?{+}Y zA-e?K_0i9{h4Gw#-6Girn7>cE<=wy@Lb&Ce#KPVAVR^*o8T0J%tEJv1y zIBOa3Pcp;mG)3L(^5LQD-kD_sJt~|{Gy{Y2G-4E579V0PQc@&LwExt@+qb%c%drmBJ5$9pT3WJ`)MqmiS?OilWW|L z{%{Y^fobft5iWX=M!@!UTpyK!w>|v;WfZv%(HRF}c{_pAkJ6b9#W1ry8~QTWVLG>u z=1W>{8Xt3WSzMWL;Ua4qjE&Q&25D)ejY*q;2WarERv*F~pkLIOx>dYgvY+ycbC>tf ziv461=dQ*>7i`AEeaT%@0z2Jx_}kb^o3wD3x+!D5*>yB|7B$cX)P$+yK8`|8>97Tx5}o zun|aPL710KSt199QzLK@IhUr0JeX~dgb8Cl?pltbrQ%$4;lA)RK8P-{b+lj2(EXLJ zZmQ3BrERWE>!$R5X(B(3=l22f73af7{Gptx`zXtLyyE`;U)Znnii)`u=lLSf=A)>N z6ME>eN>{$??*7Lj3p|z^6!Sr*E}#)&A&mBGaLI8#Z5E5w1m;lsNxVD>$)lY34vpcf z2aum)>dhVh6z{t94(vMIY|yq_vx)6B5xeSXHqy?=LmxjFcz8w-FE^f^s}^x}TX#M4W7;vD&|9OFUCEk#a9ZTjdt5;eFEV*Ib|$9)Rkv#+yh|BvzTUxIq+ z7f0x~gp7ZX_<%rwt@iuqpKOocP5FuU3`aJpGiYmjW=X?7^mZXNNoMV;QT4h!@o#D&L&#I`( z^kw#mTx(!?Vswx2b2@Ra9GT#(n~NC;#2$rtu<5`$11&ArKg*A_>6o{MO6kaB#3j-53z7Rt>GU_ zdDWKsbbhW2F9K$!ve-&O@2WD3e3|@nmDxq}$9Z5S&9rdeJR%wfb2GShp+ z#;PnUO{=KN?xxeY)FxGGx?O6LU1~p#bQao-wz7R$$7zKx3&;PVUeV~wa-q&6B1lw~ zRqV^+vF8-|a<~R_c`#F1YNvM5Xnv7xy^5QHJYED4=zLY=I8EmX!AZyyZMq5$j->vz~q+&ZnP<23W3afYNRwRQ(r-iBR!PgW7MF*dopl zjad68u?af6E3pJOh}Gf_5fpceiy^4qA)bLo?zb4rKST>CgH}jLcF00;iJXIeo-abu zFFL>rb%LSUC9e~^<$ke8J|p(Zm&IlByW(>BCd5QMyPqD<5a!_Jq*IZrpB>{Zvmz>ipT9jz#wdi8UXmwnwxDLOz(uYKE^@nB8pZCw!37dImUcf~&u>0M za5Unou&U~&5Xyfxu6$Qqc>t;f}vj`7BnEP>)EZstpqw&Nj637xb z(C&R03RHn$!(|7kL9c`E;CkFtxPg|78{uf+Cb~%6OnbyFfB?6`-t{)RPka)v{!{b| z4DAo%PKE7p6C`c=Nm`C&JhvP*eNNnM(o5PsAWcBWiZ5CizPL}^1C$^h1k@J~ufR{- zl-*Bdcz9CKahhsA^obt+HQMnjTn052l$Q32qkZC8jnpi|6W`AhulI@{hSB=h7;-AKJ{>EP?$eKm|049~r;N-_Fyc7i%jglm z94X$s^Ilx7WYV{rZV^>pn0+8W-u<4DcAofikN6AFMb@!A@y~}qigQk1=5g`^QHXza zQ^Qf3-r&o6QvBzz=GM8|`n@N}1N08kVT8}?Gd_=B z`09~acwOZ4l=R44#sdmVpY2O$T$A1-M|G=flKAOXKYgxeK_a=huCyu_*X8o1ab5W0 zy6~E>yvfmBlz}U6a`dwnnGC=Jo53;I8*dA*`u+xN^rorb)71B+V!dd>?;Ro}Lii4d z+r{nDXJM2LAgl?STNj_jox{688FqnS-A@hTbI5r&T`KOOE^#l+f$zgD>HC4f9soFg z5ZL4)wEG3L^$=SAJlg&OEM2;RMead+598@eyw`xE@=wxIUA#KBpQf2GK-{a#aY(`G zR{vhM!TMwqtjE1q;N~4&{E8^ApHAbuJpc`{wM25+6~CoRFfa_)vIbj1!JK!8sxvW!;i@Mn8prrF; zd9R$shb&K?!B3!EPJm#?&oj-R`fJEt(GtVf>B_4LVeG^Sorzq#4F z(205Ijw0yQ<217m;)Mi#KoxQ9Yl?p5uEF8g`S6R~qROL2XLM7o&j^ps>+|xk_~4tv z!ofdI)0liaA{PLFFbguqmj%KSAcCengd59Qs5KKu-mu2-6pugG=Q)l^@PsEJDlIZk z07QRCFed;RfDw54Z8VLBUh5)V!^)^enV{duP{e20xI`}GKA)glxY_Hu*(I3!<8%c- zs@59>ieaf9LzjCXo~^bXUjq8A)qAN@rf0T?+7 z^6eRs4J(LIu!ATN&%y}rIY^P85%sW8*ajPg%ft)f8rT`!B~FO@VNq}d1_j^5|38EM z!0*IMP?)|98{cosN#YgRAigVih(oedyrziE3ux@fMY=2wUt^Y&7!X0(Ws#M$%|mP_ z>k@R{J2>Dbxx>eNib?&f~Mh`fX*QB+Q zXJTli2J|X8o`Y|a`z_F7KcMGOifLvs43%+$x&&|c z(426<-wWj?lZ=w9L7uW~$Qwh&;i(P_qi3Jb1_7ZpmZ{vl#WTM zFnwa?SpQKL-FZMpWut>6TAlgu;`QYFnxl-n#jO7h-vD8 zd!DZVb#>7b>N|)#RWGUEFRAa9v>AdOAN&=3MtyhDAJn(W`_566=?@?Pd>yxk-+<$> zA3}!pV_doX33Y%Xyi)uWRPWE>Zs(_RgoKKSVRfu4!?<|Fix@1f8tEs*cDMkh1x(djYUv>KahKD z7jrzU&M@~E{9p3L2Z99I5}xGYfki5^Z9nPQWiE|9g_!eZ`XM<_jX#Z~Z>~UHP=i=? ztwlDjs|;0yg<7%`m|(xEKa#dvr7`YuxgynjP@cpZ8Y$M=O_Zo6pjEM&I0ac)e(eQ+ z00~nO>f!Rxnc&mxUL(0A7)8)Fm&+^gy^ajIk2HC$yk6d*o^F)4$XnIZZSr<`r`!+u z_~+E;J&w=&)U)~NOh2H~AC#X*-94Z#jB&>n1sL@lm?3n0w|*&)w|b!*)GvDxT1_AlkH@e`Y7~Ho(_0 zbJ>4Ex@lVER4T_b$yxIau=yFvz-$Bm9%BHmdeQBUR&y+TZ)%(Y`NnubONZ>(5Zsp6>3<`IM&;_3fmLAV!_`C{EfsP%o(GUu5j1` z1@_Q(49tQ|^(?`jX^=`)z8x_Pr7Cuy37E*%uDT!!HMOhkU@ILvABOKr-vmomkh+9l z=%CWGvOaYJelem_UVa!ZW7FYN;SZb`K&XUSMVbd+wv90z7;Pdj zX9q?A3b9U@v$ESR?yFvO1V{$tPIPaviWtts1eqaBh^?73n-0Oa<^l_sx*=0!{28BH z;#f{cF3w$nZz#WLEHuwESjU{jj><+9l^&vzMfnhajW*Spk#WG(2l=5==3>BaO3Vsc z7QajJDcRF5zWMrNrJ8#`zrns8|V z=+KKl{EI<}mBT=Z4qGEOm>Dcu#Hs#J(4ve&i|lujFpyUYnCCFl3j~K9#2t-|Ymty$<`X@^!qo{} zX)r@;kp_+#8X`I~Iovtb+}30czTx3(w(-?QkcWlA%_4;c$Ve0!5mm&Te1B}(hwgci zJ3ZZi*e^%nP9*^-W*>US-_FEgM60|65y5twD*_*V=^BB0LK02B?=O=lPYE5I)B zs4~PAv04}$9aCihUIDWVT^3+AGXM>WCxOYIQBL2_)Yb{Ef$70sc#yEG+R8I4~P6m@0_cgC2cD&!^>RSW$fsM>M(G_ywJK;un9H9Bhs zBrCAnKyeAWHnvDXq9U1&B2(guoRX+WHf$8_R!)s8GA*{q4D>#i_%pUcMJ7cY83Sh; z2{BB3*3$T_BY^4bnQ;>FGi@S1QZLixr-kcvW(Iv6TQg@q6NIwai6|oe>_oYx5&&t| zw9iqatO3Ae`|(l`^XC@fn!^|tJi&AXf~SHaU)mv9?D^6U(X^s`{SXxu<+~42sY&WP zdp)1`hmOr+3Z%G3<^=e@g%V-lI}M^HUhaiT1;t+Rmn(*;4(5sQE=#Ln+KKQzP%9B+ zX)g8}i*Pw(alN2{HsKpq5p)s0H^YT%6BvVls;vPx3HbH~w+%5AzL_11-uBU(K6cCV zhF9A?nw@y=tmU`f+75+a(B&_1aci6MfgSQf&Ra45CRSx(VEUpbBQU0S;k z7pAx2O5XxAxSlCjZwGU@YW2!hHLKUu*3>6aJY}lObnxS&qC&V?moubGeJCDrylspKtHaosb9H#UCnAYE=UY9st2HS-MsTJTDzil4aO~MHKG*- zfQ+12vtrF^OoztKsjYnT8`f|_0rTT%c3o|K?MmQ5OKqWO{YG3G>uhfa3M@x7oSnhO z@_FiOc`F<@0B$arw_r(4B96%EeZ3(eP2^N_4UcwZckJ%j2q9@k6=bL7789h?DPS)m zTw1DOcyJJ>ax6N`l%@aq?)){I=CwysZGIhoQ8z`HKoIGjdj=rF9O39;uuT^L`Ao7dQ} z0R!UiGhs3Xp9hI+QXbwCR#BU{0J{^7=N*~ooQf6ys9eJ691d>S^odeBC3pG4Dgd2z zOQsvJu&9*j8>sF8t?>$X$Cvv<9hN7wCYF$^h;M53!$k&k{9G?XAYQeRIdy>d4NO)*BK*$lX$zT;H@?s z736p7E|c+N&P}EEq^%i^i%;pi>S@Wa0i?qnf`@MGIERb2@Ebv$1NX$qhgr4HiM~xg zC-tBo+I{!8E(`AOy6h#)nlu<;mBQKBU7lr zUD(Ev2m=`uq#Zh43{pt#MFlx=0lOK|+6-zYTE_NFBP9N(pzf_C4+%>DRfqZqs8g)Yrzh} z1f7{+4(=J-DQu0 z&-et(?4>*C(>mRW!>y*>mQHdCiYeR41EQV zC6%vgz6(7p@e`lW=MDNQ-yTgHUc~4OxRHOsz!j|%7^)312Qc~LGRgzIn+FENBu;qA zioOT0H(_f)?%%5heFyh~5K@X=V6(O+k)t68BiLH4!n+v7);PU-Jy|I0VAl_8?A)NHat_Y{B7p7RL!`5LP?;8Ab3% zO$JA97a0cZ*dZ`+L`s?*y@#UP2f}~{X-w5NFRpNA86sPqC(8pizj$3|lkY@sut(5T zWDxlVZsNewFdWukT$Iu;q=FYjVi*YFGsIXi4gkZ9VX$t<>aiLX@y$8ui5#KBC;TKG zH`tvrf~d|IqCiZ>R1L)`KM)R_z0*zf6hA>WaSMIhjVtn&3&y6kkavXm)eJ)vu@2G- z1LqMXqD&X1_Drgkt3`Au&<`LZML83XQJ%D|m(^}|W#G2;A6)fm29i$4?po>*mEtTN zcb_d*NY%>nAFdL1DS6YEI_Xg*8Nvw=)i?oSwxHiRch>&`o#}w}A9Cx?&A}9`F6P8= zPAN$nzCNFdV6nIukC-nO>S6)3ND4T$z&q&L)wRGqaO)1Q$Byw?=ho_%l@Y#*=Toa2 ze%;B%$}6sKse${j00|sttR84`CtT9jiIuv5_|J@h5f(DAyn|N~vV=mbExTg79Y`0r z;&LS#;fmPtJ6{7qfDV?hfaI}HE3$G5`m|1*uj7KQH7G0H5Di?89aX~}W@}5Z(cjGZ zk==xK7Z`9Bu?c$>Iaaf1jf1o$0@3Ovn%OM2=mMPgXeY8X!IR2jz~KbMb6g)}BJCYu z6%*EHM`=TqTg7%AcL1Z^5t>!nxeU1NK-bvuJ)2-~nH`2`<}?mZ23eo!fu8%vp5eI* z44!LRn!E@IaET$>8AQT2wCxpC7wFn>yoh>m*Lo^X_ih8O;`gdW;e}#YkZ=J#xZ8;P z(3eAb>6|ljD~DZ3+a9MwR{Sw|>$u7s97pU5wIL2T&O*#o#?(<8&I-TPSBq@hDgv=gdbcsZ;)0z3?Rem;s*_U@{ee;5!{A=sYE6V@HariytO5 zMc<#~zJt#^H(7Th*;95F>JR~D^aA#z!DeN=evjy)&n7Aq1-+&{fS50B9$cD+=;O4o z!*~YVe5lPhfl42iELuP$n;A~9I_4lb#)*)(!Di(nnJFI}UfX$m6s$;Ez;Y_jzF|40 znVoYO;6B)(<9$NbCYVgB&KHQpy)G2mGKJEm|W zc79WgIW{ZE!S4aK)-Rb?T5(3DM|@v^KK}tQt)f3d0cZq+9ec~`0-+E@QwxLJ5Ko6k z^hPqb;3>*M_;E+r>%5y~BzwWWVYAuDN$odwLCk!@AsrX&Ek%KPflX+|$`wBqzc9ov zS^8ncddm>M7QfNOe}l*}2`3O4jY-|B|5m&Ssm5U3t8Ws&H^d*{S6)#KmZSHHHf`i+ zgwcG6ohNUc&y)CPL;M9k;3>Vc4S_TO*wWyg9g+Lc^S{A&JolW*5Tyd(J^oKa{3}eP znejx$zXk3C|0ik+mIU@JYHf$OgQ>K4LFq)zT=*>rm+y=B3^o#gmsW$;xVR(`eW?9qLg@y0#y2cO4Mj;{fV$$UeOlHei7Vv->gND!?A z2t9cqtwcdc;6d8Qo*Tx-5E2n^lAsksj<<=!Wst#g6%!4(RxdCCf=yYmFl^U~{?b)EpCyJYm1V$| z3b0zLnrQ>{IYK|&A`8)PaXT-P?bZE5c9zXkTWQEM*`GVpZ40&so0}k3=Y5+L-7slj zqjgy&=zMniqb$=Rtai zxT9R@219O?7hw18gsbR}D*TXJf}_k0wKtaUMBMLk)?{+noe+pnx!HhUj4i;1oZ7q# zlbuMOoRE?7QiE(VWI#e`m)^)>lks4ejuS+ha8@NNYiBI7^zuFkauv}17FZa@#(TDL zc=$JOiy0ok)13f$5}W+Pva04@v>Eae2Keb_JTguS8M1?8#iT=x2?UKKdYwNY<-0@f zQQoZaQt2(R`^Q9~C5H&sD=$aTM|lNo4HcS>nHgM{kyjb=WAbW18h=|`^WJ$Oh^-v= z6;*+-aBfX2-Y&9&l!AJjAg_T32YD^;A%B~CE5feQQx&z=RAsS5dZ-y977ml@mDkA| zbO{D>Y^2k%xeR$DpH3~*xl@-nW9_5amgFZwm*5kV1!zl%hjjKDUYM+YCrD%Tdb=U- zkYFbHsH*!f$%RO2_-aGCCDh2G2u3=C$n7-Au%uFR5b~qT7L+=K%JVw`lmqP^c^C5% zo8;YwyoWu0#{_-G>(`M-BAA4b4;b=6cKMyrXiJG=ND-?|Cn;hj;Z)MwTv4|nzsTOe zIb8elK+853iEyk#=rT6RM-2I>1lEg0sYWCp#E0g0j~ns{d5{Z6VoxWIP{_?ZWyq)H zAq)q5H_!q(PigY$i=P$HK%3-YZm?GgZ=ge<6RROQ2_xVCvW^aISnB3Qvaz&4>F`qr!R zJD3U0WoT&{5dKg@>S%7Pww94X;MK9c6^hae(7t9sCa7g{fY$_F-r&}<8GC}XKz~LU zS}w0Yhk1?!P~}a6$er?CUCYM?NWBwv4cyy8(INRkH*2bkbereZVg=CFoLgXQ?+Tjb}9~GqSmYup?)CwKKFcb*&P{0?Ef2 z)0kD((9Yr+42Mb9&Ok6htr}{bB-=JsKX7I_#5O$FNXUFXngFtbE=q#cRL>Yl+0zog zXl9DS(n%5A2Cp_>Tc~Rbk}=t_?XZCF%Uo!Yp)Jki?6T=gJS!ZY~G;qHhPMVeE?FduE#ze_^__#J0oStWBbF_20!g@nn!yV=d zBj8Kd)+J{P;%Wpsi3Gv|8JTbcT1Oc)8nmNLHM<~ERgn&-Xd4V|BTMzOVPOg`4lm}l zG%aRft6l9ZK}&{dDB)OcR)upgDZAE+4|Y5G19gdDnr(=IWz3$zQ7)&`2)!pG|Pn(LXPVf#nnXDVxK5u;Z%$rp_#uk@jKh`I|skbKvU-Xk@%gjoST`y;cy98 zG4sOZ$KW^aSE25)mX8*=WI;OdP`s_-eAc9dq|srV@> zIRYnOX7Ku4z0?k$;Km7fB**g}b`}o5<}eL0J%f+W{dh0!%e|gIZsd=fk?Yo8 zy8Q`XW@#^dwkqo=eGY4NPgS=4kR#ybJM9D&`3yvD$oF(n0iyA^^D}zsUKDw#D%Y2L zoD5%XAAJ$cjPPYWO^>P2PE~o`qRf}qhbR)5onr`D(@W3!a-XKJSB>6I?HIL71mdgurHX&JY)Nd4}mA64c1@{iMIUw$9`l;d@*J4(MmH-A+%>L~rX z0X;cPzwzb!MwRA!d+2uvtuv~R{)Cn4rV`)C6Y$j@t~b-F^6%)wNOsPi;~UvW|Kb=r z*}4DWmoMhN!(ZqTCgxr9T{o?bRFR_|eIt74eGa{WTF&x~?4f?lxNvn*FOiYyk@38;>mN5u$)Y0ATNM<#e= zH$BPSwyoYu-uA^hn=)YTuF-&K4l3)qtqhQ#WIAr zSPozGb>c-hmi{%JhX_Ke5kaC}o=t0IEzObjP%Uhu4YG|k%A25xy^SuE57B1%DEZ~r zX^Z?RHOe=sNxn@1+yUOIjihbbM7mfjqm5ufc4~{MMcYcP+U2y9VU#>d<4>~x^i6Z| z*AI789_40ylTHn~I0tF?N3i@QjNv5w+07O2!PkvWS1i)Sxk$igKW?h!CN2f1>I922 zxIBDZr$4KIsEu9Ij~qe)9f8(R=wqg1ckWVj-x9T=(=il-p)+FMWKA4BVI0T*8AhWS zLmgW2(0ci2;951h5*ju5DN!@Y1H|}7ze^1~IOX+zu#*#RcF5zKF{BAW$Pp|TRzCdM zfaiX!%20NU+fm-Ei)#T7$hPbj-u?^@FS~79S=1}$)fMAo1ws;vMO~EUdNfZgF6j}q z0JL3{gCBUoKlW3a_Gn43Sl%a~-s}-;5hN)5+#@zTx*krr@^% zlOIW)c)JU4cPorPf?V%mmx=lK=M%U_x)Oba#k|$WtE@gQga7f3E5@N07xjq1gXFUx z@26Sg&D4tz(5ald6B!Pe8Tj#j%Ha%ny!ZhgnB`=hsSgOl@Odf7m&-tZT#l>Fm#IGC z`u0gWRTrBJFkyU!$Lh>!=nNl^TzFjvt=dK2qap;%(D^JANG2pS38X7kJ)jOppn^+L zt)i5~<;cVi^S;b89yM{hmUmP7dYH7;$vM5^(msxj)h9l|fj{(PmFXIXnY;wy4bv(( zq)?xLhOCP&88L73089Ba(ob)`w^tygof*S5-zg>A=Tj9Mb4>T(D3D3A~#l z8TvR7-u<{Z{}5&Q+;!HkIr})GWq$gB{##3)K&RnUA6{S9!u>`owTIWWOgRp@8no%h zL6=?w8f_oI;kBUJK7pLqW0h}!ul5`10MZ|UVLnrMd&#{{r?n>$(?Qhr!{MOXsiF=b z57KMgvv`mJ*92#>E^cwWKzNT$D9ZWm7kK9Q5GzlTrYbFRmbNS9na9Ny48|fV#pjur zq^qztybb1nV*SzbV$f5?3eRGvizjd>y6X_GY)bL-;*l;ISNxoKWCh}0K{N#d#(d}% z2kS6N_6ATMFwgn4nt*KHTsX=P1364%Xj_C;*dOT@W*Oab zPe6Caf%pwPNz;17)AO+94ui&X9TmOUaYw4s1maCJRAn5ibZeD5M}^7A*DFBprXf%! zVz6NMK(WNn#}UA5pANNgz8g_tdc`+;#J7i6Hcx!FSG>k;^a+SQ`^0~tNA7M~#<9Mb z5JGe>y_hLY1eoX(KWEx=)05&?J>qvg;!m9PSI+cYA3_om#{oM|IUMRMk_8)-$*}%| zz2a?z*b*muB;u4j2~yJ4CtXbGKW+wQ+~sqF3@NyCKQ-e?BIcWdR2R7_Gx)d1(LpbQ zV*0${4ti7vk%9X{vwe8tj*7Zonb{+=_fv*ei35{~B4%HX=Vu_$rO*AuQJK?Fd|2jI zW!MCv$EMT~tJn_asuz48P-#uoXkJRBA$@> zN+tBjX%=yeL}-%xWHEn~bJj^_)~M8|{Hp$vaCI}e&;%dCB&nwRBoZtDv8$*@&f@el zxbNX0Lw^hh8G2rx-98Ph-`F0xFn7#Dkvx^Yv3YWFkF4EKh5Wc&J^J`@g?h~5$5nXj;^y?c@FMr_2wqCiNzZdYwj>EFKD%0BJt%`M;h+p{EC)>GR z95c0j`y{_r>7C4&%vO)PpyzWx(EqCXu^UbT`2LJ+y=FrK|CM6Yg%`LHEPg>;Zb3 z9-wc~gY+YMi2jJQf6*6kd9GXJf*UOZe49mILS)lN5Mty}u?No|r^m#NxEg#X9YhSe zC&k0K>ibm$B0i45Qr`it`aK<%91bNPeV&C8ETyBeiJrkoz9Mg-V+i%}tbBx?lP}Qo z@`vembALv2Rr!k3+)1nJPGzRdnsBz|MkTsd_(1q@2Wc z=rYIT3lz4*yZ~F)44fIU=y(9gUXJGB&TXLD!v(n5ZQROqgu-;rNVw0DF4=_`yKylc zOqexq{owe9Q(hO(SvC0FCoxXil#5tk>BFka?u!aoHRKsUsf?jabU&Q24SEq#`7Xo> z8)$xUyPn3r3%_Y@zPSy`g(;XXLkyXY@15g;yowNp`~63YdgZ596yu}HRjS}B@Fz>S z?ql*@5tEgr`L;xCQ=a^^BJC$R(l~ypCFL@Senkq-0fy%&sQo~75ROKMU*q#byl|)Z|mWKcy-;A#QswLW3epKFt z2#EXZv^m{$U(tjf`8f`cp0n|Osz=DH`}WhQVtn7f$6X{db;sx7;pW^%P%ls5sQL*#5ii#ZqG zM34K2>|m85A-*;0(gwX48!2d+8h;1MvYxllqa9blgE1Gb9wUX=EIZnc#nLE`v^HWKGX3{ z^dFRmT?5KR2xW7vzRPO`@auw{hs3_3Z0VeqktzrdU!Q#=u zzT% z;F&l?qW6g~*V9ncr)g1rUl!>*m<$yj91D-&=H&?8rg?fa?|x>W^0X{GGF#o~F&GWL3DsbVx%W>XPNRJa z&5*Fafu!lTR3P4jJnVPy$n<*(ia+4O(;w;M;!n6d@@Jslzt9WfEx3Jn6ZhQyM*qZR ziuXZeohJT<%as2THR5f!A^$gSmi|X<7VqGe;YqdcUZJc!h}M{2ldmaU#bO19s1TR| zJ4}myq03)buihu{dz*A^6v~}smyi$7!yn5t64nGjI3~dx$W!|vTg-4au4`^?V+{8L zZ=nluzExdAF;ut=f!Q3LR{6=Jjn&u(C$=;Ys2S_50MJt#kJaPv$vs+O7h06DsOT`n1zw zqM`Qwc>Fk9Z8oL3$n@!!=Hdy4FZr$m{jWG$RzeFYX_VAxHoj}5OZ8$jX^e5wb*-HH z!ZT9^<`VE$n=Rk7dc`5R6*pf5Nk6E(V!$%Qq|weX9gjLh7&6@+n|F~pHYn#&RzU)7 zu9~)o0VCLt{}u}pX!9bFKzkGeufcDv)*dH>FGyR?BTYY6nWk0Z)FEhgzN=zIDevc1 zO7OJLCGvrL4)ni?%&SqB-$byDIea(e7jMft2#P?((?oY4Ua&~UTr>l3@S-Y z!djvnnd4y{t~Ci^C#uX^YYhi60w??{KxQa+t;{>X$n%WNM>vKTi*x^Dv7wvr^IK4y z?5CpW#22AJD$adJWrKo9WpPH|V>oMSy*5EKaFIR9@6ue3zsE{ptKlj57MvL^L8$}e z&6D8l=rhX-w7uG;+Q*=IzB=mj8nYa3 zN(gNq)QiXPeJ#Fc&sI~) zVS?@QKve`A(wS7ru>z2Vt%Oa(dRE4tt*-RJDyW?ela>Yc!peY6>X*WFgw8Rb=!dYB zedw?y|I(-l4V&^tubj^Q?`I_KuW4CP+p-H5Nn&QrZPf>N zwlxRXCahYAhK|aTHl|Fv;#*wAZbz3wDI2l-U~4k?V__%KbQL^xdT0ezLeLxI7$9oQ zk$!OQ^9)!LV1D_s=%Qx-wh$-6$ST^YC(sC;uhaSjZd)4IuVuucU?K-D9=d?h&4qT? zozTGmh~JFn;Z(N+_ob4V1;OJFd|2xUi|h2wg!68|t2N4d?ef?QoOPQ)+d~Wd%%dLhIGd zG4tkrjsqQ(@dg0As%dF#ZVlmjyt!8lXpHvYAmAD(U%0JH0kL9l%{k`6GCsF%L4<9# z;>?uXY$&v(HPk@`ICD@8DLZKBAEr?z)#1Z?lj~x-k*#}FSuqKzOg(Vg6&~&G9U z;>qgiT9xHm|DE@OW>zUydH{AVs&yWx1f|2|3D40?fah7T6sUkDz-bU zHrNAP3wwZ1!XDrekQC2@FJ=xDk)Nl(hv3n!ksz{HoTSzGM3sPn)?}T9$zu6OH|8dH zQZKFTp^f{=br3&C^3Ogf`Y~69k>6Vpz5DZ64l9G`gSwyAA9Pi^N~_YkVR?a3wx|IY z^J94_{vL$XouAc^G0B{EEk5vg=iRI{05zMQuq;Bx%Bik3plRztT^9en{4z%z3$d9ust zJzNymX=X)Q`a9(6hj4+z+T#!8jnrui+yyhU6-m_0L}b904yn#`Kq<=`d=i9Ed-23* zhG|d!?^66BBttZP*E{9uLqEm_<-cNNNKFG20y2aGO)*5?3Pt2i9aK?8={Xz%hr=az zf~o*wN{j;It_p+lGq07~n1E8kg~b32mlIdVSS}W_0E`9p_!~PIrX{5QoW9)` zMALH4i%iNBSn_6VsL5X32k(x#pW1c6=RuyE^aX$vh*w-+z&Rd>HpziUaWizaSr%j^Lh9-q4tn5_p0Jz+!Sw%n z@asx9Z1rdy1K?^Cs8%!dtd4-gY!Jlw%g3Jz_Y^~8=^RL40PYbx+r%9z#MU4!!kLK} zyhV7J&_i1(gIzN{W>I|jW{MBQxo^@4yjBS=dSlCd4JqAodW9|2%#cO)znL-6ZKb{L{aW-KK$ z@>W-znG5Z}LeZHyrl;BO0(44Z_$v7Ij6}I5PGc#kXLB~oRM(wBu*;c(9>GDd^O=ku zqsZv-CI#(mwF0d;f?%^TJqMIZ1EI+@41&(%n}es$R%>w%2&lOryXJ%7;&h2O3qf{` z1=&>#p|KA}YFut1NG`(YBSElvD{w?1uHUf@qG_ZzT77H(g{kB}Ows@}QZ#WSb}Qm& zfYT966`2SDJ8>&6R18RsSWyIR7RU)sZHzEDRL4Zg@Z>uz;ov*-@Zl`zylAwNs6R?F zpgPQ67G_6btQbFXbE>&pVZ4a&)lR&aQi5dNK({AG;kTW`FQ2lT@o0FCYEOyyScnJ0 zEz=5}2|Y@FoQwe^lm~}6mr0^AmS_%~Bntr{q{Gc(MT~K{9fa4$RT0dIA41nQP|a}G zmwJJ1>PXE**lEc8AZ{bUO$XxccyTHURs5jXI0{o)Xs5$JR*k1%)z;Z6Ho*HEixl#odS})^*133EWJBEzjjz=NSw+afAoqg};Ut>oDC< z?1g^`4EYD}!oLh#+IfDX54#7vah(CC7=ppUI=hA9rCz7i@D>nXuj%X-EcRNnzCk*> zG(c6N(07Z`LQr$sxvh1}4uv*>TSvwx{}qXF?AFYIJh7Zd5Jqq10a>ndnZ<{%V#vxpxT(>s(~bnf&z*!S1`>Xr6BYuR66u8Z9wFeY zA$Z;Eq+O`d@@B3Ga0ur2K$M;u5*5z!K>Bwn!@|o1eMpKF59as^28vfIuN6t>Iig2W zpgCG}tkcyXb0Q*Z6Ln(WG_qUL;8uw4=SM@7=)If3-AMuuEpECN)7IItqos8>g!3Px z>kQaeL++Xm^yzO#7@ZsGW}R+|k@2TQbL=V|i{Vg|=ycmK{U$>!mCL8_b_14jcbc0_ zxynx%Lz{sQ-0Xgq2jG-!dmyFz94rrGM!}mtF33V8q|)7V4}_);A0(!eGCW@Q#NmeclSY!p>`! zLX}{-QDh}&k`6?LNb=GPbV8@&FbzrATW4m2UZii}456i!Z47Zi$L5A(PK7B*?(??{ zdWl}fYxLRNN`ZDqnP}n{fQHMwV$iGf9gNcqngT#1Xk|1sNqNLUz$F}lwaQ8nqto{= zGXsEMlL=>>@qHGZlzL!%h*&R~^o9yLm#~qKR^-Gm8 zdMlOAlsCd6)~n&%{*C?)R09{5?A%uQxEQDf4p}nrKJ&oP@t+2)7(wcUwXtscw}~tu zc5~A^JpX|XJnM8a73E_J-NJ!-bXc$rLAed{y&;<$g#J4ufVxUancjC>gKpO8GLTmnb0cpgG=hz8r&0^MO^?|BsW{(sC~}t9z@=$;^~6ur!pC`=Lw1}7#=RB=^Qq~2kTpr zyqhu2hD#e_u9%1M)~;Bvx~2|Mq!BzW`Fy4B!~#PsRFy5Pu`1IAIOs%yUv-XGlh~nO zn6w4Zjrc=?Pc-M?2IffCL24?N8wfOkEs&->%R)A zM!0zH>@-T6u`Wk6g0K`@4N)tW zX6qu5BbMe+1*ahn&>FEeJ6&9yBOtnn@g!uchQuWxU?Y3Ua-3w+2xbVsP~q~{)*YQ~ zx&VDX1l5oYJh561zd?LypdN8F5Wrns-aSH`4wM@M&s>DjUk9` z!bCD?H(_A$Id+#ru`FRs;K1HFDASggKP`h`0?i=)!Pp7ul-UpNmlMeRd`mYv5ncm? z^ajYsnC;+=(-aZYAxj|#E7WL3hpfpw&p$i6Lq1d;wD;?y_c^oobBcnu%IG2v_sB39 zN!WHN3f6bZrcP-DXtP#R;EUeU{dvFgYO+fA%l368&?>@N`($T_TpG^Oy|RF1UNibB&=IJLL;nYlq~HcDW#&bx`h5@K%?4&3#(d>DfIBw*L}!%?J2O zr|eZ?v{q_ohkTtgLL5Y8?UHY*rK0bIgPL}SJbO^ig}^M&5r+~}OTsyTm|oZ?FV$q) zOeje*kkk!jH16lLoT|9ktb?}!%jFqUdGu4sU_JXSaaoDO*#;Fee0 zWd-lp+__~1f486DtZ-0`AuI{!9Fl)}Vf6I9Ta@y9NV?l)nD( z7SKCAI=rPgubd8(qYU=L-5nG?96V(5+Tk$=51Kr^-C^?m=!42q(!el{4w-^>a-Mt8 z6sV=B4l^_yx>uI#gAx9N5o#=|!;I7iGxWhI|G}u5LE%9iX0$#yMjwp99DznPrY1j} z-(iZwq5B{>=hlSwbl(`4;)Hg1<^-GIr8rT8jnK3w?=|K3X)!ZwF{eGKkcvL=Nh0=45 zIpy!-zANR9JQp}BwGL*H41DQIM~>tNhP(?)NREW^2Q4JH6ew;mFw9!y$Ws#NYx5I;$m8bAz*O7S%YPbGj+7^23T)o90_o99rps}EOR!; zVVtd(47?C-GUwZ~M$=?An`WMspbl*3sQ+N+nid&}p_r|v4dzS*Q@L%T(~Ft*coAWA zVBRjzCE^CkDuac;&lb_YN&=-@nrmehaWnJ839GCN;T33Ai68>Olpc+&bN~^gLlii; zp<`;wSlg4|5|41)5U0Uga9{j1BesmKtFjTdF=_XplghW+<{)ub-@|W3_sa9^UbiFp z5zh%$yT_^Ig$?yer?GqGgCtM)#o1R+1Bmo?v1TVuk<#Qnrgi#r4VX5%*ADe&1mAoB zQ>9T#jbwczc_V!c7R3ICW>1bHCCmHSI=t!+S}sp?A1cDm>9POilW_dqDKKCu<^t+O zyjV)@dNMVIAH4dc_NDBqT&&3AjAhcS$pLo`&Qr5VDg&$ zpn?ik_ow?!9-!Z3bN0z7JdoLElz|eN zZ|cM%alWa@It8LJK}G6JWsKCJY%udlmr3Lx<`;uO&c%>&p2gR%FlYQKb4Hy64uv=E zz5QX{R(wrO>0ysNF{Jb{Mmr`MBXwvk#>8sDxDJ_sVHj1uOPCK-*ijCV7!Nk`ha<(nr4Wo!SRBBj{`5)a zkteZ5qaRJo6*iO5=FJQ6<_d}^_y z17Nc3AIs8WBAjWAOmI>1CbAmX4?NN&DRGn~>iO@s6!uJpw8NSd!Ea=zZV<;}xvqPxp zRXvZVtzyKm$Y6v>Aws0+QIEwo!jmK;dF)A&|DX#q9R`jd=-0;(xia5si9qF$ zGOjRMi!rjyyI9q-7vpu+;&_8VffAKfFvVM((HK2-`+lqQ()GqJa4aZTLA?Pw^K_0X z=x|1X)EJ4((EBqI`~PJVr~DuQ%2)p;5}>dCpV;LC`L@gVS?j5Lxg+cb&Pwi1J%B5< zCnF0su`}qa@7@aKR&=E03s%>zS#<7-r3;pyyXuS;D^6dzdJ*eqs>uZBZfI%*Y0tJq zg3e1bbNLh~yoEUU!TpHN0@w)K(z4a^)d`H2MJM&SJUuz!z9`VH4RgE@`*fsS8Inoy>A*MF_J&F-_(GEqWDulve)|7IeBT@Q6b&183I#=-$ z(NAgKoPRB+F`1MEv~*cfL@0TH%_J9RQMoZ8h1{_G_Bd&>e49E`2_eiTxvWW+s6=b_ zJl)!_Uz@|~WCfigB73dKgpktIP}l6DE+d9ASFbz+mCH9*5FY?|O_@~r#PmM&^AO|p zEJtSIlcLXNeHv4`rLfhvVbx((!`6!$w))QQ8QM`9ipgX-_s~LDsZrK39y1A{ko*#9#LVH*!i!_t$0Vd3* z{FblDvJ033B^!aBhM+Z^rZJ%MT39G=EMs01U*4pQTGZGMkYCvH)i+nrvecM_ng>{^ zH&z)*LdN%2hQzMtg^c7b)w5`Y`S}19h3VMdCiknQD8y;05aY5enF-i_3K8!%JY&l+~e!^+D~1qxi>S`CCVkJLGNE6wIW-$ z2-qd|Bm33^Qs=JKg_2E$6*S_wbwks(df!A&%U0yYwt+3?skv<|#qH?bnV=&VvwfOe znOaJK8wZH?XwYxMf?DE&P;I zEGmsT7U9llYix2cg!*aJ5en!%!YoU=4s9Vx>5!PU-Zu4|AiE_Nnok0n(HmUMNBb#w z$p$gs7J|7wc`Hul$S?wZW8)?qQr3I;E}D0GiypP_Gs!t}y9*rnPI|dY;pk{WQs*UB zP13OEjn9b{(5sY7AWB(vSk=VcK03TV6ViKSkAwN#)DzM#VNSJ=3F((p4*e02^+D@P z&tJC9#i-Xq4t99sXVol?yR6eZOg96jgZzqDu6^}2PTltJq=e+$D~~Gr8zC0qdF0TG zJ6+~|k8!Up-A;n$>+7yOE(d7mSk!_R*qbM8W{|rbIh4i|wj)mtbnPYwOaD)~?Cu>V zmjha_Q6B-{cI7+rpM0iX)-yDP@4A@s1P+?8Dx>}R`zq#sVC2EXrl}q8-_UDSJ@?g+ z6pWL2yJNJ30kwQovgaeyHM@`!LsPK_#`yCR!$rDzxR36X7v*JDCx6sKjP~B`;wGV* zZEx@8C$9WdUZD-u`8>xD_-)zd&YP_Eo}Yy79L2RI@E*S=KX>I-d5xwrFp^_3d+3d% zsX|Q9#(grCCcww9lIgkZ7ryCY6ZW^vBpH1?+%7hfJm(+DlPyyfB zs*g7Az)uAEj`!B=i+XK%?@+;Jgloc9xHq;g+PtN0r`{Xo0?lTbFVu>3Z);uKsQ%$o z&Qj0P;K#bASl2bOo2)B&y*17?m5%6U`LYOa;)K}N^USi+g z>J<+=(*>P88x1V2zv<&A`6hqt3QVLu#f_u>Eh^W{am`%)cs+b}TQG8JbZ>MoASl70 zf`tQ(ty)Zr9rlWU#uj=&r1*r z>#SzEMsj-6E@5wFKVm-Mf)QRt`>_pIeO}W~#-^7czS5ivD0Ho3)}$k|`O39x&a|s& zMDCdYWJUEH$DEy{GDSs{4depp1y~pAvT7qeE z6>35&X|+v07Nyo#8j0)I7A7#&Yf(by_CJdQMn%T=Lt;Bd_e4?&M8{mj6fa#ANpcEu z1hdj((}&h3FeeDkONPhPc7M6fTeAaB0;U9 zacaYk#W1Mq+FbKV^C_QYKb-N|W;ZbiMZFeHV%4b&%ByBnhs^cnM#tR1yxoRvWi=l1 z#Lg+pV5hKbdU7N7bBXpQbF(+cu5Cu{vaFiU*?BLq*kXRxHJ>v~C0*rd;QxE!PWYbM z;3uEGP&3o+np@OyRCc{hnUhIpFSO6DHn-!I-Y~_=K@Fm?l$sfyKJM9mgJw4yywfT- zZk$n9Id#L#`s&&BGiGh5uA9BSx^i~I#>$xu(;7BRuUubQxqim<88c^8uCJS3HLJRE zR^80%4I3(_O|P6@l?J z!XF>dHtdl3nt8~6$LXv`nkuH0B5c<;t3`n*)w{JgveQ7pIG7po2(Mor`VH%AB4;PDRSkG)3Q%a!|e#G_O&2 zIvZ_;fB&L+-8FBRUlL}+ztcp7R?M$;ta;PON8&q{Uoml6{Wsrpe-Zu}7G$9z&qomC zZ2lH9|1|G8=H31}qDRp``hVIHz4=Elz$Bl)V1NmGCc`ObS*&uVa!ub0$}fPj*H4sR zfR%;*bIcNmWiglX%uOs>N8xh8IEde|tQ6`$I6v;gGT6i8DB{>d&s@V}FdY0U9x!Hy zaGwlK4myND!!RzG0a96Q{{qqi!lRg)WAx?BlSm&ZUtqL4Ss$&$ukoIfb$fnXha6vR zovOz!v-e7=`c>ynhg4RR#Thlh{W51=IJi$vP}0$f>-NjSb>Yl?a`JvTWu1B$-6ysC zW$C&iXP+$JCm*N@?U&Ucj@H1J$?TN1o=0^DDo#GG&j}w6LXG-h4Zb&}&ZkjrB>eeL zDD_84Vb%CiZ{yBI@M7`~$@X?RH=K7+w$}^}yN6_FJC0Iu%YKQ{s21;)!R>NlI3I7= zR}?{=`dHDRcDYBdJLT$vYcsgxU0h#qlfB5ndv|`Pe8xL`VUKLbRbU7L)oqrURa3AB zQV#Ce(M}J_{di>04G$@Jpi920ZuYqUh?3P_^Yb;w%hwQ+7!sHM1ig+=pNmNPgoPLu z&VNMqb*X)nF13Q9sm_e1`i+_)J?kbXJOrQP>NovcdiG2>ms-pQeey%xpHrG}!DG0|BjKN6!`d3R48VF6?BGn|cPN0eBAU@Ks1Uf&acqW}`CKm?zO!e$4qBa)S8_ z7R-*y`~Yx;z(`quZ!+OM2rSnKG!=v4I!DG>YuMwB;qn4UK$DzkU2k71vc!?st*h48 z-@{6RBW>zkrm)W!c}oM+N9A6a0NoTHw{?A_7JW$xh67DQ-7@w69Y?-lbJopQ>i=6_ zX}bB611JoVh%G-vN=(EcS{(4jdMBgZ;NK0hW2CXccP#0GLI(M;OMXqm&fWv)(q1*%2A$8?y@cvqqQ>6^SE_s_b zpx!AT@SnHfeby~_*PdnXF*tD5L{&N*#kp8s^x2ef-qZ3{IPU-`#$Si?)E!r$3GiCG zysVd*Rep42_i*Msl2H>O>uQD^s2-eAoxexE93E1XU!?@TF0}+wX!7jKW6KWXl$Cq= z8oKb1l(pk_w(y{o(bWo;MMD?Z&>O;;hvh?l@S36Fp@(FAJ5;`*DwvBhAD8j#GQvfx zYBKl8EFPMn?J|{zW+>iQhaKQ?O{R6&S_qGJcz762TX}Jnb%o0F-QB;tGT#*C7Y*Ls zz52==1JO3L`{=i|VpV1xy=%0!sNZFV*$p=t@_|R8YBZ7O>L51GUVMNWAuzg4JG^XAip#m^2Vw?xN7j<$+3^7`tQ(i zkJHmlC_YvGYj96H&4ul_yXJK}x?P!TigJtG-QA6^Wu= zu2NcXMt?01S3b`w$8PL4ep%}Aan@iuadh>hY{Dn(`Q|5hIQgwyfPb?K@lVwhD3{HF zMbaENRn8Brlr4cX<->tS>jetKWWf#K$aEnik)_U})j;^_ zS^rSyeK3-fGwKEKepQ3e22#v%A%B09Av%OZ#Z4@n7ux%^$-_uhm&LsXbl8!gL%BhOdM zOBnGp)@77(>tQ=JgZaFVF(I?d>_>Y+m9hh2mOtv`9HNQh%ouM5SONSggcpSqmM$m95qsm^pW#R0C2Xq?5wg`q9g1fsrVgp5m%`P?&7@&ru z^G(Ka_$-Elc_6UPlsl=pyW|qtjoyC;|J`kCu~IUR$~?z>%{JB}>X$lXRdCy&Bex^X z?Vqxc7N^@^vDz|+Y_c!W_LOjTX^9;emzEZqCvBc_Ia%6ip6WE;TE-(5bC;KucbRAW z67sYh7Mp5&)(`7ER2u4HW;j!IJgdLBdY)OeM-5I`KdkohG4nl<#E58=kuh_kW!Q8g z>AFznAEQC-r+IuGV$0)FgW<}>@&r?%1GWl|5x8(@;p5*^{x9MO?)_wh?g}pQXd6I+ zC|gW@wlj3|ecR*irp#)C{u)czizw0AC8a1yS))754^&~ki*Fp2{>(5G^vVsR} z?F_+6ic{8QUSxKgVW{y4D>Q&auI75jUKJ28G7qlqq#5GOZ#OA%TLZ6%lhsX!%dubnb;?b*phc`_ zTW0+tRdcQ)6?*j?GPT=krqxxkIhwoiMJlV(PeJ2pui; zAdpTNcgE_)<+H}kpBI|zy)iFTTs#*%Z9}sLFW%Au&h`Z58&p8mZrsw^P{E#iMN?~q z?gmt_cawq_HBKWTn$%B=?GpM4NE&A(sB+aqg68#q&L;L_Wxh zB_=R5V<;b0|BXFo1D*0`T9gPbCoW63T!lEe`@5UlSAjUD$|2*yOZ!=H0gUngvKa{^lm6 zRZr&Q_i_54mIo(DPANO^^3`Z#s*xeHWB+!U#cKwf(xT?JtvgevbNjfrr&iHX^)IpZ z?*HI`)6xAF&?=j^q)zKM?@svhJ17~DKb4q*Ck#?K8&3Y>$u8|;ao)^eAK$i8#0h{W>fkOp;$%NC zv55D*+*ib@i=S*ECKlr#{+2$*2xqv5y_n<-CzI%7(U&qdrK^dg=sgxf>ij3ObmJ0W n;jFI&QpcG*X2s(CFc7`i2)1HLmX3?4CHUz?XFQATN|FBtD_wK$ delta 249 zcmey^z`E@dqj-QfGm8iV1SrP%TP~gz$H>4CH&J{$v!R~h#EW~GxVScNK6<8@)AB*n zL0O=j?Dh^zMsddJ&en|ToNx`(i>(95!$f(dj2aYB(I=A{^6Y9T>&z u;4T5Wh70O;5Y@owSIWRpkW-qTnWtBgn-k#8$_5hW0K#4-28PLHARYi9&^=xN diff --git a/plugins/java-usages/plugin.xml b/plugins/java-usages/plugin.xml index f654f9de..dad7e8d8 100644 --- a/plugins/java-usages/plugin.xml +++ b/plugins/java-usages/plugin.xml @@ -4,5 +4,8 @@ + + + diff --git a/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java b/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java new file mode 100644 index 00000000..39fce3a9 --- /dev/null +++ b/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java @@ -0,0 +1,525 @@ +package ai.openidea.configuration; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.zip.ZipFile; +import com.google.gson.*; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.core.*; +import org.eclipse.jdt.core.dom.*; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; +import org.eclipse.jdt.ls.core.internal.JDTUtils; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler; +import org.eclipse.lsp4j.Location; + +/** On-demand configuration index, scoped by JDT's resolved source/class path. + * Cached records contain strings and locations, never ASTs or bindings. Java model + * changes invalidate snapshots, including unsaved working copies and classpath edits. + */ +public final class ConfigurationCommand implements IDelegateCommandHandler { + private static final String PROPERTIES = "org.springframework.boot.context.properties.ConfigurationProperties"; + private static final String VALUE = "org.springframework.beans.factory.annotation.Value"; + private static final String RESOLVER = "org.springframework.core.env.PropertyResolver"; + private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024; + private static final AtomicLong REVISION = new AtomicLong(); + private static final Map CACHE = new LinkedHashMap<>(); + static { JavaCore.addElementChangedListener(event -> REVISION.incrementAndGet(), ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); } + + private record Entry(String name, String handle, Location location, boolean relaxed) {} + private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {} + private static FileStamp stamp(Path path) { + try { return new FileStamp(Files.getLastModifiedTime(path), Files.size(path)); } + catch (java.io.IOException e) { return new FileStamp(null, -1); } + } + private record Index(long revision, String workingCopies, List entries, List roots, + Map files, boolean incomplete, int parsed) {} + + @Override + public Object executeCommand(String command, List args, IProgressMonitor monitor) throws Exception { + String uri = (String) args.get(0); + String key = args.get(1) instanceof String s ? s : null; + int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue(); + boolean references = args.size() > 4 && Boolean.TRUE.equals(args.get(4)); + boolean declarations = args.size() > 5 && Boolean.TRUE.equals(args.get(5)); + ITypeRoot selectedRoot = JDTUtils.resolveTypeRoot(uri); + IJavaProject project = selectedRoot == null ? project(uri) : selectedRoot.getJavaProject(); + if (project == null) return Map.of("locations", List.of(), "keys", List.of(), "roots", List.of(), "incomplete", true); + Index index = index(project, monitor); + Set> keys = new LinkedHashSet<>(); + Set locations = new LinkedHashSet<>(); + String selected = null; + if (key == null && selectedRoot != null && references) { + var element = JDTUtils.findElementAtSelection(selectedRoot, line, column, + JavaLanguageServerPlugin.getPreferencesManager(), monitor); + if (element != null) selected = element.getHandleIdentifier(); + } + for (Entry entry : index.entries) { + check(monitor); + Location location = entry.location; + if (key != null) { + if (matches(entry.name, key, entry.relaxed)) { + if (!entry.handle.isEmpty() && references) { + var element = JavaCore.create(entry.handle); + if (element != null && element.exists()) { + List usages = new ArrayList<>(); + new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager()) + .search(element, usages, monitor, declarations); + locations.addAll(usages); + } + } else { + if (location == null && !entry.handle.isEmpty()) location = location(entry.handle); + if (location != null) locations.add(location); + } + keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed)); + } + } else if ((!entry.handle.isEmpty() && entry.handle.equals(selected)) || + (location != null && sameUri(location.getUri(), uri) && contains(location, line, column))) { + keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed)); + } + if (locations.size() >= 2000) break; + } + // Reverse Find Usages joins property consumers with the binding member's + // ordinary Java usages. Definition on a string only returns config keys. + if (key == null && references && !keys.isEmpty()) { + Set searched = new HashSet<>(); + for (Entry entry : index.entries) { + check(monitor); + boolean related = keys.stream().anyMatch(pattern -> + matches(entry.name, (String) pattern.get("name"), entry.relaxed) || + matches((String) pattern.get("name"), entry.name, Boolean.TRUE.equals(pattern.get("relaxed")))); + if (!related) continue; + if (entry.location != null) locations.add(entry.location); + else if (!entry.handle.isEmpty() && searched.add(entry.handle)) { + IJavaElement element = JavaCore.create(entry.handle); + if (element != null && element.exists()) { + List usages = new ArrayList<>(); + new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager()).search(element, usages, monitor, declarations); + locations.addAll(usages); + } + } + if (locations.size() >= 2000) break; + } + } + return Map.of("locations", locations, "keys", keys, "roots", index.roots, + "incomplete", index.incomplete || locations.size() >= 2000, + "parsedUnits", index.parsed, "revision", index.revision); + } + + private static boolean sameUri(String a, String b) { + URI left = URI.create(a), right = URI.create(b); + if (left.equals(right)) return true; + if (!"file".equals(left.getScheme()) || !"file".equals(right.getScheme())) return false; + try { return Path.of(left).toFile().getCanonicalFile().equals(Path.of(right).toFile().getCanonicalFile()); } + catch (java.io.IOException e) { return false; } + } + private static boolean contains(Location l, int line, int col) { + var r = l.getRange(); + return (line > r.getStart().getLine() || line == r.getStart().getLine() && col >= r.getStart().getCharacter()) && + (line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter()); + } + private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); } + private static IJavaProject project(String uri) { + Path path; + try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; } + IJavaProject best = null; + int length = -1; + for (var p : ResourcesPlugin.getWorkspace().getRoot().getProjects()) { + if (!p.isOpen() || p.getLocation() == null) continue; + var candidate = JavaCore.create(p); + Path root; + try { root = p.getLocation().toFile().getCanonicalFile().toPath(); } catch (java.io.IOException e) { continue; } + if (candidate.exists() && path.startsWith(root) && root.getNameCount() > length) { + best = candidate; length = root.getNameCount(); + } + } + return best; + } + private static Location location(String handle) throws JavaModelException { + IJavaElement element = JavaCore.create(handle); + if (element == null || !element.exists()) return null; + Location result = JDTUtils.toLocation(element); + if (result == null && element instanceof IMember member && member.getClassFile() != null) + result = JDTUtils.toLocation(member.getClassFile()); + return result; + } + + private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception { + long revision = REVISION.get(); + String id = project.getHandleIdentifier(); + Map copies = new TreeMap<>(); + for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy); + for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner())) + copies.put(copy.getPrimary().getHandleIdentifier(), copy); + var digest = java.security.MessageDigest.getInstance("SHA-256"); + for (var entry : copies.entrySet()) { + check(monitor); + digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String source = entry.getValue().getSource(); + if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + String workingCopies = HexFormat.of().formatHex(digest.digest()); + Index cached = CACHE.get(id); + if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) { + boolean valid = true; + for (var entry : cached.files.entrySet()) { + check(monitor); + if (!entry.getValue().equals(stamp(entry.getKey()))) { valid = false; break; } + } + if (valid) return cached; + } + Builder builder = new Builder(monitor); + Set units = new HashSet<>(), roots = new LinkedHashSet<>(); + int visited = 0; + for (IPackageFragmentRoot root : project.getAllPackageFragmentRoots()) { + check(monitor); + if (root.getKind() == IPackageFragmentRoot.K_SOURCE) { + if (root.getResource() != null && root.getResource().getLocationURI() != null) + roots.add(root.getResource().getLocationURI().toString()); + if (root.getJavaProject().getProject().getLocationURI() != null) + roots.add(root.getJavaProject().getProject().getLocationURI().toString()); + if (root.getResource() != null && root.getResource().getLocation() != null) + builder.metadataDirectory(root.getResource().getLocation().toFile().toPath(), project); + for (IJavaElement child : root.getChildren()) { + if (!(child instanceof IPackageFragment pkg)) continue; + for (ICompilationUnit unit : pkg.getCompilationUnits()) { + check(monitor); + unit = copies.getOrDefault(unit.getPrimary().getHandleIdentifier(), unit); + if (!units.add(unit.getHandleIdentifier())) continue; + if (++visited > MAX_UNITS || builder.entries.size() >= MAX_ENTRIES) { builder.incomplete = true; break; } + String source = unit.getSource(); + if (source == null) continue; + if (source.length() > MAX_BYTES) { builder.incomplete = true; continue; } + // Cheap lexical filter only chooses candidates; all accepted symbols + // below are verified by JDT bindings, including constants and imports. + if (source.contains("ConfigurationProperties") || source.contains("Value") || source.contains("getProperty") || source.contains("getRequiredProperty")) + builder.parse(unit); + } + } + } else if (root.isArchive()) { + builder.metadata(root, project); + } + } + // Resource roots are not necessarily Java package roots (e.g. Maven resources). + // Return project locations as well; the client excludes nested independent modules. + roots.add(project.getProject().getLocationURI().toString()); + builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project); + Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed); + CACHE.remove(id); + while (!CACHE.isEmpty() && (CACHE.size() >= 8 || + CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES)) + CACHE.remove(CACHE.keySet().iterator().next()); + CACHE.put(id, result); + return result; + } + + /** Relaxed matching is reserved for binder/metadata entries. @Value and + * Environment lookups retain their literal names; dots remain boundaries. */ + static boolean matches(String pattern, String key, boolean relaxed) { + if (!relaxed) return pattern.equals(key); + List a = parts(pattern), b = parts(key); + if (a.size() != b.size()) return false; + for (int i = 0; i < a.size(); i++) if (!a.get(i).equals("*") && !a.get(i).equals(b.get(i))) return false; + return true; + } + private static List parts(String key) { + List result = new ArrayList<>(); + StringBuilder part = new StringBuilder(); + for (int i = 0; i < key.length(); i++) { + char ch = key.charAt(i); + if (ch == '[') { + if (!part.isEmpty()) { result.add(canonical(part.toString())); part.setLength(0); } + int end = key.indexOf(']', i + 1); + if (end < 0) { part.append(key.substring(i)); break; } + result.add(canonical(key.substring(i + 1, end))); i = end; + } else if (ch == '.') { + if (!part.isEmpty() || i == 0 || key.charAt(i - 1) != ']') result.add(canonical(part.toString())); + part.setLength(0); + } else part.append(ch); + } + if (!part.isEmpty()) result.add(canonical(part.toString())); + return result; + } + private static String canonical(String key) { + return key.replace("-", "").replace("_", "").toLowerCase(Locale.ROOT); + } + + private static final class Builder { + final IProgressMonitor monitor; + final List entries = new ArrayList<>(); + final Map files = new LinkedHashMap<>(); + boolean incomplete; + int parsed; + Builder(IProgressMonitor monitor) { this.monitor = monitor; } + void add(String key, IJavaElement element) { + if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + if (element != null) entries.add(new Entry(key, element.getHandleIdentifier(), null, true)); + } + void use(String key, ICompilationUnit unit, ASTNode node) { + if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + if (key.isBlank() || key.contains("${") || key.contains("#{")) return; + try { entries.add(new Entry(key, "", JDTUtils.toLocation(unit, node.getStartPosition(), node.getLength()), false)); } + catch (JavaModelException e) { incomplete = true; } + } + void parse(ICompilationUnit unit) { + check(monitor); + ASTParser parser = ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(unit); + parser.setResolveBindings(true); + parser.setWorkingCopyOwner(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()); + parser.setBindingsRecovery(false); + CompilationUnit tree = (CompilationUnit) parser.createAST(monitor); + parsed++; + tree.accept(new ASTVisitor() { + @Override public void preVisit(ASTNode node) { check(monitor); } + @Override public boolean visit(NormalAnnotation node) { annotation(node); return true; } + @Override public boolean visit(SingleMemberAnnotation node) { annotation(node); return true; } + @Override public boolean visit(MarkerAnnotation node) { annotation(node); return true; } + private void annotation(Annotation node) { + IAnnotationBinding binding = node.resolveAnnotationBinding(); + if (binding == null) return; + String name = binding.getAnnotationType().getQualifiedName(); + if (name.equals(PROPERTIES)) { + String prefix = annotationValue(binding, "prefix"); + if (prefix == null || prefix.isEmpty()) prefix = annotationValue(binding, "value"); + if (prefix == null) prefix = ""; + ASTNode owner = node.getParent(); + if (owner instanceof AbstractTypeDeclaration type) { + bind(prefix, type.resolveBinding(), new HashSet<>(), 0); + } else if (owner instanceof MethodDeclaration method && method.resolveBinding() != null) { + add(prefix, method.resolveBinding().getJavaElement()); + bind(prefix, method.resolveBinding().getReturnType(), new HashSet<>(), 0); + } + } else if (name.equals(VALUE)) { + String value = annotationValue(binding, "value"); + ASTNode expression = node; + if (node instanceof SingleMemberAnnotation single) expression = single.getValue(); + else if (node instanceof NormalAnnotation normal) { + for (Object pair : normal.values()) if (pair instanceof MemberValuePair p && p.getName().getIdentifier().equals("value")) expression = p.getValue(); + } + if (value != null) placeholders(value, unit, expression); + } + } + @Override public boolean visit(MethodInvocation node) { + IMethodBinding method = node.resolveMethodBinding(); + if (method != null && (method.getName().equals("getProperty") || method.getName().equals("getRequiredProperty")) && + subtype(method.getDeclaringClass(), RESOLVER, new HashSet<>()) && !node.arguments().isEmpty()) { + Expression arg = (Expression) node.arguments().get(0); + Object constant = arg.resolveConstantExpressionValue(); + if (constant instanceof String key) use(key, unit, arg); + } + return true; + } + }); + } + void placeholders(String text, ICompilationUnit unit, ASTNode node) { + // Include nested default placeholders, but never a dynamically composed name. + for (int at = text.indexOf("${"); at >= 0; at = text.indexOf("${", at + 2)) { + int end = at + 2; + while (end < text.length() && text.charAt(end) != ':' && text.charAt(end) != '}') end++; + if (end < text.length()) use(text.substring(at + 2, end), unit, node); + } + } + void bind(String prefix, ITypeBinding type, Set visiting, int depth) { + check(monitor); + if (type == null || type.isRecovered() || type.isPrimitive() || type.isEnum()) return; + String qualified = type.getErasure().getQualifiedName(); + if (qualified.startsWith("java.") || qualified.startsWith("javax.")) return; + if (depth > 12 || !visiting.add(type.getKey())) return; + try { + if (!prefix.isEmpty()) add(prefix, type.getJavaElement()); + List constructors = Arrays.stream(type.getDeclaredMethods()) + .filter(m -> m.isConstructor() && !Modifier.isPrivate(m.getModifiers())).toList(); + for (IMethodBinding constructor : constructors) { + boolean explicit = Arrays.stream(constructor.getAnnotations()).anyMatch(a -> + a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.ConstructorBinding") || + a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.ConstructorBinding")); + if (!explicit && constructors.size() != 1) continue; + String[] parameters = constructor.getParameterNames(); + for (int i = 0; i < parameters.length; i++) { + String name = parameters[i]; + for (IAnnotationBinding annotation : constructor.getParameterAnnotations(i)) { + if (annotation.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.Name")) { + String alias = annotationValue(annotation, "value"); + if (alias != null) name = alias; + } + } + // Prefer fields/accessors for ordinary parameters; aliases and + // constructor-only properties still have a navigable declaration. + final String parameterName = name; + if (Arrays.stream(type.getDeclaredFields()).noneMatch(f -> f.getName().equals(parameterName))) { + add(join(prefix, name), constructor.getJavaElement()); + nested(join(prefix, name), constructor.getParameterTypes()[i], constructor.getJavaElement(), visiting, depth + 1); + } + } + } + Set names = new HashSet<>(); + for (ITypeBinding current = type; current != null && !current.getQualifiedName().equals("java.lang.Object"); current = current.getSuperclass()) { + for (IVariableBinding field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic() || !names.add(field.getName())) continue; + String key = join(prefix, field.getName()); + add(key, field.getJavaElement()); + nested(key, field.getType(), field.getJavaElement(), visiting, depth + 1); + } + for (IMethodBinding method : current.getDeclaredMethods()) { + if (Modifier.isStatic(method.getModifiers()) || method.isConstructor()) continue; + String name = method.getName(); + String property = null; + ITypeBinding propertyType = null; + if (name.startsWith("set") && name.length() > 3 && method.getParameterTypes().length == 1) { + property = name.substring(3); propertyType = method.getParameterTypes()[0]; + } else if (name.startsWith("get") && name.length() > 3 && method.getParameterTypes().length == 0) { + property = name.substring(3); propertyType = method.getReturnType(); + } + if (property != null) { + property = Character.toLowerCase(property.charAt(0)) + property.substring(1); + String key = join(prefix, property); + add(key, method.getJavaElement()); + if (names.add(property)) nested(key, propertyType, method.getJavaElement(), visiting, depth + 1); + } + } + } + } finally { visiting.remove(type.getKey()); } + } + void nested(String key, ITypeBinding type, IJavaElement owner, Set visiting, int depth) { + if (type == null) return; + if (type.isArray()) { add(key + "[*]", owner); bind(key + "[*]", type.getElementType(), visiting, depth); return; } + String raw = type.getErasure().getQualifiedName(); + if (subtype(type, "java.util.Map", new HashSet<>())) { + add(key + ".*", owner); + var args = type.getTypeArguments(); + if (args.length == 2) bind(key + ".*", args[1], visiting, depth); + } else if (subtype(type, "java.util.Collection", new HashSet<>())) { + add(key + "[*]", owner); + var args = type.getTypeArguments(); + if (args.length == 1) bind(key + "[*]", args[0], visiting, depth); + } else if (!type.isPrimitive() && !type.isEnum() && !raw.startsWith("java.") && !raw.startsWith("javax.")) { + bind(key, type, visiting, depth); + } + } + void metadata(IPackageFragmentRoot root, IJavaProject project) { + check(monitor); + Path path = root.getPath().toFile().toPath(); + if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null) + path = root.getResource().getLocation().toFile().toPath(); + files.put(path, stamp(path)); + if (!Files.isRegularFile(path)) return; + try (ZipFile zip = new ZipFile(path.toFile())) { + for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) { + var entry = zip.getEntry("META-INF/" + filename); + if (entry == null) continue; + if (entry.getSize() > MAX_BYTES) { incomplete = true; continue; } + JsonObject metadata; + try (var stream = zip.getInputStream(entry)) { + byte[] bytes = stream.readNBytes(MAX_BYTES + 1); + if (bytes.length > MAX_BYTES) { incomplete = true; continue; } + metadata = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + } + metadataObject(metadata, project); + } + } catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; } + } + void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException { + JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray(); + for (JsonElement value : groups) { + check(monitor); + JsonObject group = value.getAsJsonObject(); + String name = string(group, "name"), source = string(group, "sourceType"); + if (source == null) source = string(group, "type"); + if (name != null && source != null) add(name, project.findType(source.replace('$', '.'), monitor)); + } + if (!metadata.has("properties")) return; + for (JsonElement value : metadata.getAsJsonArray("properties")) { + check(monitor); + if (entries.size() >= MAX_ENTRIES) { incomplete = true; return; } + JsonObject property = value.getAsJsonObject(); + String key = string(property, "name"), source = string(property, "sourceType"), prefix = ""; + if (key == null) continue; + JsonObject owner = null; + for (JsonElement groupValue : groups) { + JsonObject group = groupValue.getAsJsonObject(); + String name = string(group, "name"); + if (name != null && key.startsWith(name + ".") && name.length() > prefix.length()) { + prefix = name; owner = group; + } + } + if (source == null && owner != null) source = string(owner, "type"); + if (source == null && owner != null) source = string(owner, "sourceType"); + if (source == null) continue; + IType type = project.findType(source.replace('$', '.'), monitor); + if (type == null) continue; + String leaf = key.substring(prefix.isEmpty() ? 0 : prefix.length() + 1); + IJavaElement target = member(type, leaf, new HashSet<>()); + if (target == null && owner != null && string(owner, "sourceMethod") != null) { + String declaring = string(owner, "sourceType"); + IType factory = declaring == null ? null : project.findType(declaring.replace('$', '.'), monitor); + if (factory != null) { + String methodName = string(owner, "sourceMethod").split("\\(", 2)[0]; + for (IMethod method : factory.getMethods()) if (method.getElementName().equals(methodName)) { target = method; break; } + } + } + add(key, target == null ? type : target); + } + } + void metadataDirectory(Path directory, IJavaProject project) { + for (String name : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) { + Path file = directory.resolve("META-INF").resolve(name); + files.put(file, stamp(file)); + if (!Files.isRegularFile(file)) continue; + try { + if (Files.size(file) > MAX_BYTES) { incomplete = true; continue; } + try (var stream = Files.newInputStream(file)) { + byte[] bytes = stream.readNBytes(MAX_BYTES + 1); + if (bytes.length > MAX_BYTES) { incomplete = true; continue; } + metadataObject(JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(), project); + } + } catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; } + } + } + } + private static IJavaElement member(IType type, String key, Set seen) throws JavaModelException { + if (type == null || !seen.add(type.getHandleIdentifier())) return null; + String part = key.split("\\.", 2)[0]; + for (IField field : type.getFields()) if (canonical(field.getElementName()).equals(canonical(part))) { + if (!key.contains(".")) return field; + String signature = Signature.toString(Signature.getTypeErasure(field.getTypeSignature())); + String[][] resolved = type.resolveType(signature); + IType nested = type.getJavaProject().findType(signature); + if (nested == null && resolved != null && resolved.length == 1) + nested = type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]); + IJavaElement found = member(nested, key.substring(key.indexOf('.') + 1), seen); + return found == null ? field : found; + } + for (IMethod method : type.getMethods()) { + String name = method.getElementName(); + if (name.length() > 3 && (name.startsWith("get") || name.startsWith("set")) && canonical(name.substring(3)).equals(canonical(part))) return method; + } + String parent = type.getSuperclassName(); + if (parent != null) { + String[][] resolved = type.resolveType(parent); + if (resolved != null && resolved.length == 1) + return member(type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]), key, seen); + } + return null; + } + private static String string(JsonObject object, String key) { + return object.has(key) && object.get(key).isJsonPrimitive() ? object.get(key).getAsString() : null; + } + private static String annotationValue(IAnnotationBinding binding, String name) { + for (IMemberValuePairBinding pair : binding.getAllMemberValuePairs()) + if (pair.getName().equals(name) && pair.getValue() instanceof String s) return s; + return null; + } + private static String join(String prefix, String name) { return prefix.isEmpty() ? name : prefix + "." + name; } + private static boolean subtype(ITypeBinding type, String name, Set seen) { + if (type == null || !seen.add(type.getKey())) return false; + if (type.getErasure().getQualifiedName().equals(name)) return true; + for (ITypeBinding face : type.getInterfaces()) if (subtype(face, name, seen)) return true; + return subtype(type.getSuperclass(), name, seen); + } +} diff --git a/plugins/java-usages/tests/configuration.py b/plugins/java-usages/tests/configuration.py new file mode 100644 index 00000000..9a42f4a0 --- /dev/null +++ b/plugins/java-usages/tests/configuration.py @@ -0,0 +1,220 @@ +"""Real JDT LS configuration-navigation regression and warm-query benchmark. +Run with Java 25 on PATH: python3 configuration.py /path/to/jdtls +Fixtures use small Spring API stubs; no Maven/network/build of a user's project. +""" +import os +import json +import pathlib +import platform +import queue +import subprocess +import sys +import tempfile +import threading +import time +import zipfile + +home = pathlib.Path(sys.argv[1]).resolve() +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar' +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-configuration-')) +project = work / 'project' +source_dir = project / 'src' +source_dir.mkdir(parents=True) + +def write(relative, text): + file = source_dir / relative + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(text) + return file + +write('org/springframework/boot/context/properties/ConfigurationProperties.java', '''package org.springframework.boot.context.properties; +public @interface ConfigurationProperties { String value() default ""; String prefix() default ""; }''') +write('org/springframework/boot/context/properties/bind/Name.java', 'package org.springframework.boot.context.properties.bind; public @interface Name { String value(); }') +write('org/springframework/beans/factory/annotation/Value.java', '''package org.springframework.beans.factory.annotation; +public @interface Value { String value(); }''') +write('org/springframework/core/env/PropertyResolver.java', '''package org.springframework.core.env; +public interface PropertyResolver { String getProperty(String key); String getRequiredProperty(String key); }''') +write('org/springframework/core/env/Environment.java', '''package org.springframework.core.env; +public interface Environment extends PropertyResolver {}''') +source = '''package demo; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +@ConfigurationProperties(prefix="app") +class Settings extends Parent { + int timeOut; + Nested nested; + java.util.List clients; + java.util.Map routes; + java.util.List tags; + java.util.Map labels; + public int getTimeOut() { return timeOut; } + public void setTimeOut(int value) { timeOut = value; } +} +class Parent { boolean enabled; } +class Nested { String url; } +@ConfigurationProperties("record") +record RecordSettings(String name, int count) {} +@ConfigurationProperties("constructor") +class ConstructorSettings { + ConstructorSettings(@org.springframework.boot.context.properties.bind.Name("remote-name") String name) {} +} +class Consumers { + static final String KEY = "app.time-out"; + @Value("${app.time-out:30}") String timeout; + @Value("${app.missing:${app.backup:42}}") String fallback; + String call(Environment env) { return env.getProperty(KEY); } + String required(Environment env) { return env.getRequiredProperty("app.required"); } + String dynamic(Environment env, String key) { return env.getProperty(key); } + String unrelated(Fake env) { return env.getProperty("app.time-out"); } + @ConfigurationProperties("bean") Nested bean() { return new Nested(); } +} +class Fake { String getProperty(String name) { return name; } } +''' +file = write('demo/Settings.java', source) +if os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1': + for i in range(3000): + write(f'noise/Plain{i}.java', f'package noise; class Plain{i} {{ int number; }}') + for i in range(100): + write(f'noise/Config{i}.java', f'package noise; @org.springframework.boot.context.properties.ConfigurationProperties(prefix="stress{i}") class Config{i} {{ int value; }}') +write('demo/NotSpring.java', '''package demo; +@interface ConfigurationProperties { String prefix(); } +@ConfigurationProperties(prefix="fake") class NotSpring { int timeOut; } +''') +local_metadata = project / 'src/main/resources/META-INF' +local_metadata.mkdir(parents=True) +(local_metadata / 'additional-spring-configuration-metadata.json').write_text(json.dumps({ + 'groups': [{'name': 'extra', 'sourceType': 'demo.Nested', 'type': 'demo.Nested'}], + 'properties': [{'name': 'extra.url', 'sourceType': 'demo.Nested'}] +})) +# A dependency with metadata and attached source exercises actual library locations. +libsource = work / 'libsrc/vendor/LibrarySettings.java' +libsource.parent.mkdir(parents=True) +libsource.write_text('package vendor; public class LibrarySettings { public int port; public int getPort() { return port; } }') +classes = work / 'libclasses' +classes.mkdir() +subprocess.run(['javac', '--release', '21', '-d', str(classes), str(libsource)], check=True) +library = work / 'library.jar' +metadata = {'groups': [{'name': 'vendor', 'type': 'vendor.LibrarySettings', 'sourceType': 'vendor.LibrarySettings'}], + 'properties': [{'name': 'vendor.port', 'type': 'java.lang.Integer', 'sourceType': 'vendor.LibrarySettings'}]} +with zipfile.ZipFile(library, 'w') as z: + z.write(classes / 'vendor/LibrarySettings.class', 'vendor/LibrarySettings.class') + z.writestr('META-INF/spring-configuration-metadata.json', json.dumps(metadata)) +sourcejar = work / 'library-sources.jar' +with zipfile.ZipFile(sourcejar, 'w') as z: + z.write(libsource, 'vendor/LibrarySettings.java') +(project / '.project').write_text('configurationorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature') +spring_jar = os.environ.get('OPENIDEA_SPRING_BOOT_JAR') +extra_classpath = f'' if spring_jar else '' +(project / '.classpath').write_text(f'{extra_classpath}') +config = work / 'config' +config.mkdir() +name = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux') +names = ([name + '_arm'] if platform.machine().lower() in ('arm64', 'aarch64') else []) + [name] +(config / 'config.ini').write_bytes(next(home / n / 'config.ini' for n in names if (home / n / 'config.ini').exists()).read_bytes()) +log = (work / 'stderr.log').open('w') +proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log) +messages = queue.Queue() +lock = threading.Lock() +def send(message): + data = json.dumps(message).encode() + with lock: + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + proc.stdin.flush() +def read(): + while True: + headers = {} + while True: + line = proc.stdout.readline() + if not line: return + if line == b'\r\n': break + k, v = line.decode().split(':', 1) + headers[k.lower()] = v.strip() + message = json.loads(proc.stdout.read(int(headers['content-length']))) + if 'method' in message and 'id' in message: + send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None}) + else: messages.put(message) +threading.Thread(target=read, daemon=True).start() +sequence = 0 +transcript = [] +def request(method, params): + global sequence + sequence += 1 + send({'jsonrpc': '2.0', 'id': sequence, 'method': method, 'params': params}) + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + msg = messages.get(timeout=max(1, deadline - time.monotonic())) + transcript.append(msg) + if msg.get('id') == sequence: + if 'error' in msg: raise RuntimeError(msg['error']) + return msg.get('result') + raise TimeoutError(method) +def query(key, uri=None, line=0, col=0): + return request('workspace/executeCommand', {'command': 'openidea.java.configuration', 'arguments': [uri or (project / 'application.yml').as_uri(), key, line, col, key is None, False]}) +def names(result): + return {p['name'] for p in result['keys']} +try: + init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'configuration'}], 'capabilities': {'textDocument': {'definition': {'linkSupport': False}}}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}}) + send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}}) + send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}}) + request('java/buildWorkspace', True) + assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init + results = {} + for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']: + start = time.perf_counter() + result = query(key) + results[key] = {'seconds': time.perf_counter() - start, 'response': result} + assert result['locations'], (key, result) + attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']}) + assert attached and 'LibrarySettings' in attached and 'port' in attached, attached + if spring_jar: + for key in ['server.port', 'spring.datasource.url', 'spring.jackson.date-format']: + result = query(key) + assert result['locations'], (key, result) + assert all(loc['uri'].startswith('jdt://') for loc in result['locations']), result + results[key] = {'response': result} + if key == 'server.port': + decompiled = request('java/classFileContents', {'uri': result['locations'][0]['uri']}) + assert decompiled and 'ServerProperties' in decompiled and 'port' in decompiled, decompiled + assert not query('app.tags[0].value')['locations'] + assert not query('app.labels.region.coder')['locations'] + assert not query('fake.time-out')['locations'] + assert not query('app.does-not-exist')['locations'] + metadata_file = local_metadata / 'additional-spring-configuration-metadata.json' + metadata_file.write_text(metadata_file.read_text().replace('extra', 'newer')) + assert query('newer.url')['locations'] + assert not query('extra.url')['locations'] + # Property references include member usages and consumers, without declarations. + references = request('workspace/executeCommand', {'command': 'openidea.java.configuration', + 'arguments': [(project / 'application.yml').as_uri(), 'app.time-out', 0, 0, True, False]}) + assert references['locations'], references + assert any(loc['range']['start']['line'] == next(i for i, line in enumerate(source.splitlines()) if 'getProperty(KEY)' in line) for loc in references['locations']), references + # 3 binding targets + @Value + Environment constant; Fake.getProperty excluded. + timeout = query('app.time-out') + assert len(timeout['locations']) == 5, timeout + for needle, expected in [('int timeOut;', 'app.timeOut'), ('"${app.time-out:30}"', 'app.time-out'), ('env.getProperty(KEY)', 'app.time-out')]: + line, text = next((i, s) for i, s in enumerate(source.splitlines()) if needle in s) + col = text.index(needle) + (4 if needle == 'int timeOut;' else (len('env.getProperty(') if needle.startswith('env.') else 4)) + reverse = query(None, file.as_uri(), line, col) + assert expected in names(reverse), (needle, reverse) + warm = [] + for _ in range(20): + start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start) + # Unsaved changes must replace old keys, and closing must restore disk keys. + changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}') + send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}}) + edit_start = time.perf_counter() + updated = query('edited.time-out') + edit_ms = (time.perf_counter() - edit_start) * 1000 + assert updated['locations'], updated + assert not query('app.nested.url')['locations'] + send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}}) + restored = query('app.nested.url') + assert restored['locations'], restored + evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000} + (work / 'evidence.json').write_text(json.dumps(evidence, indent=2)) + print(json.dumps(evidence, indent=2)) +finally: + (work / 'transcript.json').write_text(json.dumps(transcript, indent=2)) + proc.terminate(); proc.wait(timeout=20) + print('Evidence:', work, file=sys.stderr) From d939a197470a7cead514c5aa78d41fa099e1abe0 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Mon, 2 Nov 2026 15:49:04 +0100 Subject: [PATCH 053/108] feat(run): Django test configurations with results in the test tree (#869) Django projects get a detected `manage.py test` configuration next to runserver and a Django tests configuration type (command, runner arguments, target, settings module, options). Results stream into the test tree with failures, errors, skips and subtests, failed tests navigate to their source line, Rerun Failed Tests narrows the command to the failed labels, and single tests run from the gutter. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-run/src/detect.rs | 13 + crates/ide-run/src/process.rs | 4 +- crates/ide-run/tests/detect.rs | 9 +- crates/ide-run/tests/process.rs | 7 +- crates/ide-sdk/src/apply.rs | 4 +- crates/ide-sdk/src/tests.rs | 7 + src/features/run/configs.test.ts | 24 ++ src/features/run/context.ts | 16 +- src/features/run/djangoTypes.ts | 121 +++++++ src/features/run/producers.test.ts | 50 +++ src/features/run/producers.ts | 14 +- src/features/run/runConfigTypes.test.tsx | 54 ++++ src/features/run/targets.test.ts | 39 +++ src/features/run/targets.ts | 49 ++- src/features/run/testing/index.ts | 28 +- src/features/run/testing/parsers.test.ts | 11 +- src/features/run/testing/rerun.test.ts | 45 +++ src/features/run/testing/rerun.ts | 7 + src/features/run/testing/states.test.ts | 13 + src/features/run/testing/states.ts | 13 +- src/features/run/testing/unittest.test.ts | 371 ++++++++++++++++++++++ src/features/run/testing/unittest.ts | 169 ++++++++++ src/features/run/types.ts | 4 +- src/platform/locales/text/zh.json | 10 +- src/platform/locales/textCatalog.test.ts | 3 + 25 files changed, 1049 insertions(+), 36 deletions(-) create mode 100644 src/features/run/djangoTypes.ts create mode 100644 src/features/run/testing/unittest.test.ts create mode 100644 src/features/run/testing/unittest.ts diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 82f36594..0f56ad50 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -890,6 +890,19 @@ fn detect_python(root: &Path, out: &mut Vec) { if uses_pytest(root) || env.has_tool(root, "pytest") { out.push(py_config("pytest".into(), "pytest".into(), "pytest", env.tool(root, "pytest"), &[]).test()); } + // After pytest: a project set up for pytest (pytest-django) keeps it as the default test runner. + if root.join("manage.py").is_file() { + out.push( + py_config( + "django:test".into(), + "manage.py test".into(), + "django-test", + env.python(), + &["manage.py", "test"], + ) + .test(), + ); + } } // ---------------------------------------------------------------- make / just diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 0808e952..969114d2 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -317,7 +317,7 @@ impl ProcessManager { return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal")); } let mut env = launch_env(root, config)?; - if matches!(config.kind.as_str(), "python" | "pytest") { + if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") { crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?; } let on_output: OutputFn = Arc::from(on_output); @@ -745,7 +745,7 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { let paths: Vec = extras.env_files.iter().map(|f| resolve_cwd(root, Some(Path::new(f)))).collect(); crate::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))? }; - if matches!(config.kind.as_str(), "python" | "pytest") + if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") && !config.env.contains_key("PYTHONUNBUFFERED") && !vars.contains_key("PYTHONUNBUFFERED") { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 57801933..48db7c56 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -372,13 +372,19 @@ fn python_projects() { ("manage.py", ""), ]); let c = detect(dir.path()); - assert_eq!(ids(&c), ["django:runserver", "python:module:mypkg", "pytest"]); + // pytest stays ahead of `manage.py test`: the first test configuration is the default for context runs. + assert_eq!(ids(&c), ["django:runserver", "python:module:mypkg", "pytest", "django:test"]); let pytest = find(&c, "pytest"); assert_eq!(cmdline(pytest), "uv run pytest"); assert!(pytest.is_test); assert_eq!(pytest.debug_adapter.as_deref(), Some("debugpy")); assert_eq!(cmdline(find(&c, "python:module:mypkg")), "uv run python -m mypkg"); assert_eq!(cmdline(find(&c, "django:runserver")), "uv run python manage.py runserver"); + let django = find(&c, "django:test"); + assert_eq!(cmdline(django), "uv run python manage.py test"); + assert_eq!(django.kind, "django-test"); + assert!(django.is_test); + assert_eq!(django.debug_adapter.as_deref(), Some("debugpy")); let dir = project(&[("pytest.ini", "[pytest]\n"), ("poetry.lock", "")]); assert_eq!(cmdline(find(&detect(dir.path()), "pytest")), "poetry run pytest"); @@ -398,6 +404,7 @@ fn python_virtualenv() { let c = detect(dir.path()); assert_eq!(cmdline(find(&c, "pytest")), ".venv/bin/pytest"); assert_eq!(cmdline(find(&c, "django:runserver")), ".venv/bin/python manage.py runserver"); + assert_eq!(cmdline(find(&c, "django:test")), ".venv/bin/python manage.py test"); } #[cfg(unix)] diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index c4af2bca..176be79c 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -447,7 +447,7 @@ fn build_tools_get_the_console_encoding_for_every_jvm() { #[test] fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { - for (kind, source_dir) in [("python", ""), ("pytest", "src")] { + for (kind, source_dir) in [("python", ""), ("pytest", "src"), ("django-test", "")] { let h = harness(); let root = h.root.path(); let source = root.join(source_dir); @@ -465,6 +465,11 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { let mut config = cfg("python-imports", "python3", &["app/main.py"]); config.kind = kind.into(); config.extras.env_files = vec![".env".into()]; + // Unbuffered, so output printed by a test stays next to its result line. + assert_eq!( + ide_run::resolved_env(root, &config).unwrap().get("PYTHONUNBUFFERED").map(String::as_str), + Some("1") + ); let options = StartOptions { python_paths: vec![root.to_owned(), source], ..Default::default() }; let chunks: Chunks = Arc::default(); let sink = chunks.clone(); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 8c484f9f..ec173c95 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -629,10 +629,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings resolve_run_command(c, root, s); let mut path_dirs: Vec = Vec::new(); match c.kind.as_str() { - "python" | "pytest" => { + "python" | "pytest" | "django-test" => { if let Some(py) = &explicit_python { let runner = PY_RUNNERS.contains(&c.command.as_str()); - if c.kind == "python" && (runner || is_python_command(&c.command)) { + if (c.kind == "python" || c.kind == "django-test") && (runner || is_python_command(&c.command)) { if runner && c.args.first().map(String::as_str) == Some("run") { // `uv run python …` → ` …` let skip = if c.args.get(1).is_some_and(|a| is_python_command(a)) { 2 } else { 1 }; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 3e121f24..e777b10b 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -152,6 +152,8 @@ fn run_configs_use_selected_interpreter_and_package_manager() { run("pytest", "uv", &["run", "pytest", "-x"]), run("npm", "npm", &["run", "dev"]), run("shell", "python3", &["x.py"]), + run("django-test", "uv", &["run", "python", "manage.py", "test"]), + run("django-test", ".venv/bin/python", &["manage.py", "test", "polls"]), ]; apply_to_run_configs(&mut configs, tmp.path(), &s); assert_eq!(configs[0].command, "/opt/py/bin/python3"); @@ -161,6 +163,11 @@ fn run_configs_use_selected_interpreter_and_package_manager() { assert_eq!(configs[2].args, ["-m", "pytest", "-x"]); assert_eq!(configs[3].command, "pnpm"); assert_eq!(configs[4].command, "python3", "shell configs are left alone"); + // Django tests run on the selected interpreter, like `manage.py runserver`. + assert_eq!(configs[5].command, "/opt/py/bin/python3"); + assert_eq!(configs[5].args, ["manage.py", "test"]); + assert_eq!(configs[6].command, "/opt/py/bin/python3"); + assert_eq!(configs[6].args, ["manage.py", "test", "polls"]); } fn fake_jdk(dir: &Path) { diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 0f61d66c..ce70a443 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -158,6 +158,9 @@ describe("context configuration", () => { expect(fileRole("src/a.test.ts")).toBe("test"); expect(fileRole("pkg/x_test.go")).toBe("test"); expect(fileRole("tests/test_api.py")).toBe("test"); + // Django's default test module (pattern `test*.py`); a script outside Django projects. + expect(fileRole("polls/tests.py", undefined, false, true)).toBe("test"); + expect(fileRole("polls/tests.py")).toBe("script"); expect(fileRole("src/test/kotlin/OrderControllerTest.kt")).toBe("test"); expect(fileRole("tests/integration.rs")).toBe("test"); expect(fileRole("src/main.rs")).toBe("main"); @@ -185,6 +188,27 @@ describe("context configuration", () => { expect(contextConfig("internal/calc/calc_test.go", configs)!.args).toEqual(["test", "./internal/calc"]); expect(contextConfig("tests/api.rs", configs)!.args).toEqual(["test", "--test", "api"]); expect(contextConfig("tests/test_math.py", configs)!.args).toEqual(["run", "pytest", "tests/test_math.py"]); + // `tests.py` is a test module in Django projects only. + expect(contextConfig("polls/tests.py", configs)).toMatchObject({ + kind: "python", + isTest: false, + command: ".venv/bin/python", + args: ["polls/tests.py"], + }); + const django = cfg({ + id: "django:test", + kind: "django-test", + command: "uv", + args: ["run", "python", "manage.py", "test"], + isTest: true, + }); + expect(contextConfig("polls/tests.py", [django])!.args).toEqual([ + "run", + "python", + "manage.py", + "test", + "polls.tests", + ]); expect(contextConfig("src/test/kotlin/OrderControllerTest.kt", configs)!.args).toEqual([ "test", "--tests", diff --git a/src/features/run/context.ts b/src/features/run/context.ts index acab57de..768e3c01 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -4,17 +4,20 @@ * direct interpreter invocation for main files. Best effort, IDEA-style. */ import type { RunConfig } from "@/platform/ipc"; +import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes"; import { goTestParts } from "./goArguments"; import { isWindows } from "./jvmTypes"; export type FileRole = "test" | "main" | "script" | null; -export function fileRole(relPath: string, content?: string, windows = isWindows()): FileRole { +/** `django`: a Django project, whose default test module is `tests.py` (elsewhere a script). */ +export function fileRole(relPath: string, content?: string, windows = isWindows(), django = false): FileRole { const name = relPath.split("/").pop() ?? relPath; if ( /\.(test|spec)\.[cm]?[jt]sx?$/.test(name) || /(^|\/)__tests__\//.test(relPath) || /^test_.*\.py$|_test\.py$/.test(name) || + (django && name === "tests.py") || /_test\.go$/.test(name) || /(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) || (/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs") @@ -35,7 +38,7 @@ const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, ""); /** Kinds whose test configs can run a file of this extension. */ function testKinds(relPath: string): string[] { if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"]; - if (/\.py$/.test(relPath)) return ["pytest", "python"]; + if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"]; if (/\.go$/.test(relPath)) return ["go"]; if (/\.rs$/.test(relPath)) return ["cargo"]; if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"]; @@ -61,7 +64,8 @@ function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { return allClosest(configs, relPath)[0]; } -function relToCwd(relPath: string, config: RunConfig): string { +/** `relPath` relative to the working directory of `config`. */ +export function relToCwd(relPath: string, config: RunConfig): string { const cwd = (config.kind === "go" ? (config.params?.module ?? config.cwd ?? "") : (config.cwd ?? "")) .replace(/^\.\/?/, "") .replace(/\/+$/, ""); @@ -156,6 +160,10 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC case "pytest": case "python": return derived(base, relPath, { args: [...base.args, file] }); + case "django-test": { + const tests = djangoTestsOf(base, [djangoLabel(file)]); + return tests ? derived(base, relPath, tests) : null; + } case "go": { const dir = dirOf(file); const target = dir ? `./${dir}` : "."; @@ -248,7 +256,7 @@ export function contextConfig( content?: string, windows = isWindows(), ): RunConfig | null { - const role = fileRole(relPath, content, windows); + const role = fileRole(relPath, content, windows, hasDjangoTests(configs)); if (role === "test") return testConfigFor(relPath, configs); if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows); if (role === "script") return scriptConfigFor(relPath, configs, windows); diff --git a/src/features/run/djangoTypes.ts b/src/features/run/djangoTypes.ts new file mode 100644 index 00000000..f5817ead --- /dev/null +++ b/src/features/run/djangoTypes.ts @@ -0,0 +1,121 @@ +/** + * Django tests (PyCharm's "Django tests" configuration): `manage.py test` with a + * target (app labels, modules or dotted test paths), a settings module and + * further options. The command is the interpreter (`python3`, `.venv/bin/python`, + * `uv`), the runner args its leading arguments (`run python` for uv / poetry). + */ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { formatArgs, parseArgs } from "./configs"; + +/** A Django project: it has a Django tests configuration (detected from `manage.py`, or made in the form). */ +export const hasDjangoTests = (configs: readonly RunConfig[]) => configs.some((c) => c.kind === "django-test"); + +/** Index of `manage.py` when `test` follows it, else -1. */ +function manageTest(args: readonly string[]): number { + return args.findIndex((a, i) => /(^|[/\\])manage\.py$/.test(a) && args[i + 1] === "test"); +} + +/** Number of leading positional arguments (the test labels). */ +const labelCount = (args: readonly string[]) => { + const n = args.findIndex((a) => a.startsWith("-")); + return n < 0 ? args.length : n; +}; + +/** Dotted test label of a module file: `polls/tests.py` → `polls.tests`, `polls/tests/__init__.py` → `polls.tests`. */ +export function djangoLabel(relPath: string): string { + return relPath + .replace(/\.py$/, "") + .replace(/(^|\/)__init__$/, "") + .replace(/\//g, "."); +} + +/** + * `args` of a `manage.py test` command line running `labels` in place of its + * own target (the labels right after `test`); null for other command lines. + */ +function withDjangoLabels(args: readonly string[], labels: readonly string[]): string[] | null { + const at = manageTest(args); + if (at < 0) return null; + const rest = args.slice(at + 2); + return [...args.slice(0, at + 2), ...labels, ...rest.slice(labelCount(rest))]; +} + +/** + * The command line (and, for a configuration made in the form, the Target field) + * of `config` running `labels`; null when it doesn't run `manage.py test`. + */ +export function djangoTestsOf(config: RunConfig, labels: readonly string[]): Pick | null { + const args = withDjangoLabels(config.args, labels); + if (!args) return null; + return config.params?.target === undefined + ? { args } + : { args, params: { ...config.params, target: formatArgs([...labels]) } }; +} + +export const djangoTestType: RunConfigTypeContribution = { + id: "django-test", + label: "Django tests", + icon: "file-python", + description: "Run Django tests with manage.py test", + defaults: { command: "python3" }, + debugAdapter: "debugpy", + fields: [ + { + key: "command", + label: "Command", + mono: true, + placeholder: "python3", + required: true, + hint: "e.g. python3, .venv/bin/python, uv, poetry", + }, + { + key: "runnerArgs", + label: "Runner arguments", + mono: true, + placeholder: "run python", + hint: "e.g. `run python` for uv/poetry; empty calls the interpreter itself", + }, + { + key: "target", + label: "Target", + mono: true, + placeholder: "polls.tests", + hint: "App labels, modules or dotted test paths; empty runs every test", + }, + { + key: "settings", + label: "Settings module", + mono: true, + placeholder: "mysite.settings_test", + hint: "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py", + }, + { key: "options", label: "Options", mono: true, placeholder: "e.g. --keepdb --parallel" }, + ], + commandLine(p) { + const args = [...parseArgs(p.runnerArgs ?? ""), "manage.py", "test", ...parseArgs(p.target ?? "")]; + if (p.settings?.trim()) args.push("--settings", p.settings.trim()); + args.push(...parseArgs(p.options ?? "")); + return { command: p.command?.trim() || "python3", args }; + }, + paramsFrom(c) { + if (c.kind !== "django-test") return null; + const at = manageTest(c.args); + if (at < 0) return null; + const rest = c.args.slice(at + 2); + const n = labelCount(rest); + let options = rest.slice(n); + let settings = ""; + if (options[0] === "--settings" && options[1] !== undefined) { + settings = options[1]; + options = options.slice(2); + } + return { + command: c.command, + runnerArgs: formatArgs(c.args.slice(0, at)), + target: formatArgs(rest.slice(0, n)), + settings, + options: formatArgs(options), + }; + }, +}; diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 64a10251..2fb9cb44 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -498,6 +498,56 @@ describe("project producers", () => { expect(produceContextChoices({ ...input, configs }, ALL)).toHaveLength(1); }); + it("django: tests.py, TestCase classes and test packages run through manage.py test", () => { + const django = [ + cfg({ id: "django:runserver", kind: "python", command: ".venv/bin/python", args: ["manage.py", "runserver"] }), + cfg({ + id: "django:test", + kind: "django-test", + command: ".venv/bin/python", + args: ["manage.py", "test", "--keepdb"], + isTest: true, + }), + ]; + const src = [ + "from django.test import TestCase", + "", + "class QuestionTests(TestCase):", + " def test_recent(self):", + " pass", + ].join("\n"); + const input = { path: `${ROOT}/polls/tests.py`, root: ROOT, configs: django, content: src }; + expect(produceContext({ ...input, line: 4 }, ALL)).toMatchObject({ + name: "QuestionTests.test_recent", + config: { kind: "django-test", args: ["manage.py", "test", "polls.tests.QuestionTests.test_recent", "--keepdb"] }, + }); + expect(produceContext({ ...input, line: 2 }, ALL)!.config!.args).toEqual([ + "manage.py", + "test", + "polls.tests.QuestionTests", + "--keepdb", + ]); + expect(produceContext(input, ALL)).toMatchObject({ + name: "tests.py", + config: { args: ["manage.py", "test", "polls.tests", "--keepdb"] }, + }); + expect(produceDirectory({ path: `${ROOT}/polls/tests`, root: ROOT, configs: django })).toMatchObject({ + name: "Tests in 'tests'", + config: { args: ["manage.py", "test", "polls.tests", "--keepdb"] }, + }); + expect(produceDirectory({ path: `${ROOT}/polls/migrations`, root: ROOT, configs: django })).toBeNull(); + // Outside Django projects `tests.py` is a script: no test targets, and the file runs as one. + const plain = { ...input, configs: [configs[1]!] }; + expect(fileTargets(plain, src)).toEqual([]); + expect(produceContext(plain, ALL)).toMatchObject({ name: "tests.py", config: { kind: "python", isTest: false } }); + // pytest detected as well (pytest-django): the user chooses, pytest first. + const both = [configs[1]!, ...django]; + expect(produceContextChoices({ ...input, configs: both, line: 4 }, ALL).map((c) => c.detail)).toEqual([ + "pytest", + "django:test", + ]); + }); + it("python: test function > test class > file; __main__ runs the file", () => { const src = [ "class TestMath:", diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 6a784cad..c0425628 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -17,6 +17,7 @@ import { formatArgs } from "./configs"; import { goGenerateConfig } from "./goGenerate"; import { goTestParts } from "./goArguments"; import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context"; +import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes"; import { isWindows } from "./jvmTypes"; import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets"; import { tx } from "@/platform/i18n"; @@ -590,7 +591,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null } if (/\.t?scn$/i.test(rel)) return godotScene(rel, configs); // Project-aware: test files through the project's test runner, Cargo / Go / Gradle / Maven mains. - const role = fileRole(rel, input.content); + const role = fileRole(rel, input.content, undefined, hasDjangoTests(configs)); if (role === "test") { const found = testConfigFor(rel, configs); const test = found && goTestNamed(found, input); @@ -823,7 +824,7 @@ export function fileTargets(input: Pick t.kind === "main" && (!t.className || /\.(java|kt)$/.test(input.path)), ); } - return detectRunTargets(content, s.rel); + return detectRunTargets(content, s.rel, hasDjangoTests(input.configs)); } function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget): Produced | null { @@ -969,10 +970,19 @@ export function produceDirectory(input: ContextInput): Produced | null { } const js = closest(["npm", "bun", "deno"]); const py = closest(["pytest"]); + const django = closest(["django-test"]); const go = closest(["go"]); const cargo = closest(["cargo"]); if (cargo && /(^|\/)tests$/.test(rel) && !js && !py) return make(cargo, "All Tests", [...cargo.args, "--tests"]); if (py && (!js || /(^|\/)tests?$/.test(rel))) return make(py, testsIn, [...py.args, relToCwd(py)]); + // Django runs a test package by its dotted label (`polls/tests` → `polls.tests`). + if (django && !js && relToCwd(django)) { + const tests = djangoTestsOf(django, [djangoLabel(relToCwd(django))]); + if (tests) { + const produced = make(django, testsIn, tests.args); + return { ...produced, config: { ...produced.config!, ...tests } }; + } + } if (js) { const dir = relToCwd(js); if (js.kind === "deno") return make(js, testsIn, [...js.args, dir]); diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index d37509ca..fd207b13 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -172,6 +172,60 @@ describe("configuration types", () => { expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull(); }); + it("Django tests round-trip commandLine / paramsFrom and always report to the test tree", () => { + const django = typeFor("django-test")!; + expect(django.debugAdapter).toBe("debugpy"); + expect(newConfigOfType(django, [])).toMatchObject({ + kind: "django-test", + command: "python3", + args: ["manage.py", "test"], + env: {}, + isTest: true, + debugAdapter: "debugpy", + }); + // No settings module: neither `--settings` nor DJANGO_SETTINGS_MODULE, manage.py's default applies. + expect(django.commandLine({ command: "python3", settings: " " })).toEqual({ + command: "python3", + args: ["manage.py", "test"], + }); + + const cases: Array<{ command: string; args: string[]; params: Record }> = [ + { + command: ".venv/bin/python", + args: ["manage.py", "test"], + params: { command: ".venv/bin/python", runnerArgs: "", target: "", settings: "", options: "" }, + }, + { + command: "uv", + args: ["run", "python", "manage.py", "test", "polls", "blog.tests.PostTests", "--settings", "mysite.test"], + params: { + command: "uv", + runnerArgs: "run python", + target: "polls blog.tests.PostTests", + settings: "mysite.test", + options: "", + }, + }, + { + command: "python3", + args: ["manage.py", "test", "--keepdb", "--parallel", "4"], + params: { command: "python3", runnerArgs: "", target: "", settings: "", options: "--keepdb --parallel 4" }, + }, + ]; + for (const { command, args, params } of cases) { + const c = cfg("django:test", { kind: "django-test", command, args, detected: true }); + expect(django.paramsFrom!(c)).toEqual(params); + expect(formParams(c)).toEqual(params); + expect(django.commandLine(params)).toEqual({ command, args }); + } + // Saved from the form, a plain command line becomes a test configuration. + expect(withParams(cfg("d", { kind: "django-test" }), cases[1]!.params).isTest).toBe(true); + // Not `manage.py test`: edited as a plain command. + expect( + django.paramsFrom!(cfg("d", { kind: "django-test", command: "python3", args: ["manage.py", "check"] })), + ).toBeNull(); + }); + it("pytest round-trips commandLine / paramsFrom and opens implicit context configs in the form", () => { const pytest = typeFor("pytest")!; expect(pytest.debugAdapter).toBe("debugpy"); diff --git a/src/features/run/targets.test.ts b/src/features/run/targets.test.ts index e0053b2e..263b34a4 100644 --- a/src/features/run/targets.test.ts +++ b/src/features/run/targets.test.ts @@ -89,6 +89,24 @@ describe("detectRunTargets", () => { ]); }); + it("python: unittest TestCase subclasses in tests.py (Django's default test module)", () => { + const src = [ + "from django.test import TestCase", + "class QuestionTests(TestCase):", + " def test_recent(self):", + " pass", + "class Helper(Mixin):", + " def test_no(self): pass", + ].join("\n"); + expect(pick(detectRunTargets(src, "polls/tests.py", true))).toEqual([ + [1, "suite", "QuestionTests"], + [2, "test", "QuestionTests/test_recent"], + ]); + expect(pick(detectRunTargets(src, "polls/models.py", true))).toEqual([]); + // Outside Django projects `tests.py` is a script: no test targets. + expect(pick(detectRunTargets(src, "polls/tests.py"))).toEqual([]); + }); + it("go and jvm", () => { expect( pick(detectRunTargets("package x\n\nfunc TestAdd(t *testing.T) {}\nfunc helper() {}", "pkg/add_test.go")), @@ -160,6 +178,27 @@ describe("targetConfig", () => { expect(first("class TestC:\n def test_a(self): pass", "tests/test_c.py").args).toEqual([ "tests/test_c.py::TestC::test_a", ]); + // pytest also collects TestCase subclasses. + expect(first("class QTests(TestCase):\n def test_a(self): pass", "app/test_q.py").args).toEqual([ + "app/test_q.py::QTests::test_a", + ]); + const django = [cfg({ id: "django:test", kind: "django-test", command: "python3", args: ["manage.py", "test"] })]; + const test = detectRunTargets("class QTests(TestCase):\n def test_a(self): pass", "polls/tests.py", true)[1]!; + expect(targetConfig(test, "polls/tests.py", django)!.args).toEqual([ + "manage.py", + "test", + "polls.tests.QTests.test_a", + ]); + // A module-level function or a plain class: unittest's loader would call it, so no Django run. + const fn = detectRunTargets("def test_top():\n pass", "polls/tests.py", true)[0]!; + expect(targetConfig(fn, "polls/tests.py", django)).toBeNull(); + // Relative to the working directory, where manage.py is. + const nested = [{ ...django[0]!, cwd: "backend" }]; + expect(targetConfig(test, "backend/polls/tests/test_q.py", nested)!.args).toEqual([ + "manage.py", + "test", + "polls.tests.test_q.QTests.test_a", + ]); expect(first("func TestAdd(t *testing.T) {}", "pkg/add_test.go").args).toEqual([ "test", "./pkg", diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index 2d4efff4..cb28903d 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -1,7 +1,8 @@ /** * Run targets inside a file, for the gutter ▶ icons and "Run test" code lenses: * `fn main`, `#[test]` functions and `mod tests`, JS/TS `test`/`it`/`describe`, - * pytest `def test_*` / `class Test*`, Go `func TestX(t *testing.T)`, JUnit `@Test`. + * pytest `def test_*` / `class Test*`, unittest `TestCase` subclasses, Go `func TestX(t *testing.T)`, + * JUnit `@Test`. * Line-based regex detection (cheap, no parser), plus the run configuration for * one target derived from the project's detected test configurations. */ @@ -9,7 +10,8 @@ import { formatArgs } from "./configs"; import type { RunConfig } from "@/platform/ipc"; import { applicationConfig, jvmTestConfig } from "./application"; import { goTestParts, withoutGoSelection } from "./goArguments"; -import { contextConfig, testConfigFor } from "./context"; +import { contextConfig, relToCwd, testConfigFor } from "./context"; +import { djangoLabel, djangoTestsOf } from "./djangoTypes"; import { scanJvmTargets, simpleClassName } from "./jvmTargets"; import { escapeRegExp } from "./testing/rerun"; @@ -29,6 +31,8 @@ export interface RunTarget { className?: string; /** JVM: a `@SpringBootApplication` main class. */ spring?: boolean; + /** Python: a unittest `TestCase` subclass or one of its methods (what Django's runner can run). */ + unittest?: boolean; } /** Above this size we skip detection (generated / minified files). */ @@ -142,23 +146,33 @@ function jsTargets(lines: string[]): RunTarget[] { function pythonTargets(lines: string[]): RunTarget[] { const out: RunTarget[] = []; - const classes: Array<{ indent: number; name: string; test: boolean }> = []; + const classes: Array<{ indent: number; name: string; test: boolean; unittest: boolean }> = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]!; if (!line.trim() || /^\s*#/.test(line)) continue; const indent = indentOf(line); while (classes.length && classes[classes.length - 1]!.indent >= indent) classes.pop(); - const cls = /^\s*class\s+(\w+)/.exec(line); + const cls = /^\s*class\s+(\w+)\s*(?:\(([^)]*))?/.exec(line); if (cls) { - const test = /^Test/.test(cls[1]!) && classes.every((c) => c.test); - classes.push({ indent, name: cls[1]!, test }); - if (test) out.push({ line: i, kind: "suite", name: cls[1]!, path: classes.map((c) => c.name) }); + // pytest collects `Test*` classes; unittest (and Django) any `TestCase` subclass. + const unittest = /TestCase\b/.test(cls[2] ?? ""); + const test = (/^Test/.test(cls[1]!) || unittest) && classes.every((c) => c.test); + classes.push({ indent, name: cls[1]!, test, unittest }); + if (test) + out.push({ + line: i, + kind: "suite", + name: cls[1]!, + path: classes.map((c) => c.name), + ...(unittest ? { unittest } : {}), + }); continue; } const def = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/.exec(line); if (def && classes.every((c) => c.test)) { const path = [...classes.map((c) => c.name), def[1]!]; - out.push({ line: i, kind: "test", name: path.join("."), path }); + const unittest = classes.length > 0 && classes[classes.length - 1]!.unittest; + out.push({ line: i, kind: "test", name: path.join("."), path, ...(unittest ? { unittest } : {}) }); continue; } if (indent === 0 && /^if\s+__name__\s*==\s*['"]__main__['"]\s*:/.test(line)) @@ -298,8 +312,11 @@ function jvmTargets(lines: string[], relPath: string): RunTarget[] { return out; } -/** Run targets of a file (relative path picks the language). */ -export function detectRunTargets(text: string, relPath: string): RunTarget[] { +/** + * Run targets of a file (relative path picks the language). `django`: a Django project, where + * `tests.py` (Django's default test module) holds tests too. + */ +export function detectRunTargets(text: string, relPath: string, django = false): RunTarget[] { if (text.length > MAX_SCAN) return []; const lines = text.split("\n"); switch (targetLanguage(relPath)) { @@ -309,7 +326,10 @@ export function detectRunTargets(text: string, relPath: string): RunTarget[] { return /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)__tests__\//.test(relPath) ? jsTargets(lines) : []; case "python": return pythonTargets(lines).filter( - (t) => t.kind === "main" || /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath), + (t) => + t.kind === "main" || + /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath) || + (django && /(^|\/)tests\.py$/.test(relPath)), ); case "go": return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath)); @@ -372,6 +392,13 @@ export function targetConfig( case "pytest": case "python": return named(withLast(file.args, (f) => `${f}::${target.path.join("::")}`)); + case "django-test": { + // unittest's loader would call a module-level `test_*` function (or a plain class) to build the suite. + if (!target.unittest) return null; + const label = [djangoLabel(relToCwd(relPath, file)), ...target.path].join("."); + const tests = djangoTestsOf(file, [label]); + return tests ? { ...named(tests.args), ...(tests.params ? { params: tests.params } : {}) } : null; + } case "go": { const pattern = `^${target.path.map(escapeRegExp).join("$/^")}$`; const benchmark = target.path[0]?.startsWith("Benchmark"); diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 93579630..56fcc76c 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -5,12 +5,13 @@ import { goParser } from "./go"; import { jsParser } from "./js"; import { jvmParser } from "./jvm"; import { pytestParser } from "./pytest"; +import { unittestParser } from "./unittest"; import type { TestEvent, TestOutputParser } from "./types"; export type { TestEvent, TestOutputParser, TestStatus } from "./types"; export { parseDuration } from "./types"; export { TestResults, nodeStatus, type TestNode } from "./results"; -export { cargoParser, goParser, jsParser, jvmParser, pytestParser }; +export { cargoParser, goParser, jsParser, jvmParser, pytestParser, unittestParser }; /** Runs several parsers side by side (unknown tool: e.g. `make test`). */ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { @@ -20,7 +21,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { }; } -export type Framework = "cargo" | "js" | "pytest" | "go" | "jvm" | "auto"; +export type Framework = "cargo" | "js" | "pytest" | "unittest" | "go" | "jvm" | "auto"; export function frameworkFor(config: Pick): Framework { const cmd = [config.command, ...config.args].join(" "); @@ -32,8 +33,12 @@ export function frameworkFor(config: Pick>(config: T): T { if (frameworkFor(config) === "go" && config.args[0] === "test") { @@ -64,6 +70,10 @@ export function withTestReporting /^(-v|--verbosity)(=?\d)?$/.test(a)); + return chosen ? config : { ...config, args: [...config.args, "-v", "2"] }; + } if (frameworkFor(config) !== "pytest") return config; const cmd = [config.command, ...config.args]; const pytestAt = cmd.findIndex((a) => /(^|[/\\])pytest(\.exe)?$/.test(a)); @@ -80,12 +90,14 @@ export function createTestParser(config: Pick { expect(frameworkFor(cfg("cargo", "cargo", ["test"]))).toBe("cargo"); expect(frameworkFor(cfg("npm", "pnpm", ["run", "test"]))).toBe("js"); expect(frameworkFor(cfg("pytest", "uv", ["run", "pytest"]))).toBe("pytest"); + expect(frameworkFor(cfg("django-test", ".venv/bin/python", ["manage.py", "test"]))).toBe("unittest"); + expect(frameworkFor(cfg("shell", "python3 manage.py test polls"))).toBe("unittest"); + expect(frameworkFor(cfg("python", ".venv/bin/python", ["manage.py", "test"]))).toBe("unittest"); + expect(frameworkFor(cfg("python", ".venv/bin/python", ["-m", "pytest"]))).toBe("pytest"); expect(frameworkFor(cfg("go", "go", ["test", "./..."]))).toBe("go"); expect(frameworkFor(cfg("gradle", "./gradlew", ["test"]))).toBe("jvm"); expect(frameworkFor(cfg("shell", "npx vitest run"))).toBe("js"); @@ -417,9 +421,14 @@ describe("parser selection", () => { it("the combined parser understands any supported format", () => { const r = results( createTestParser(cfg("make", "make", ["test"])), - "test a ... ok\n--- PASS: TestB (0.00s)\nok \tpkg\t0.1s", + "test a ... ok\n--- PASS: TestB (0.00s)\nok \tpkg\t0.1s\ntest_c (app.tests.CTests.test_c) ... ok\n" + + "Finished (0.5s)\ncompile (gcc.exe)", ); expect(leaf(r, "a")?.own).toBe("passed"); + expect(leaf(r, "app.tests", "CTests", "test_c")?.own).toBe("passed"); + // Lines of other tools that only look like a unittest header make no tests. + expect(r.roots.map((n) => n.name)).not.toContain("0"); + expect(r.roots.map((n) => n.name)).not.toContain("gcc"); expect(leaf(r, "pkg", "TestB")?.own).toBe("passed"); }); }); diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index ea426180..4379d855 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -103,6 +103,32 @@ describe("rerunFailedConfig", () => { expect(c.args).toEqual(["-q", "tests/test_x.py::TestC::test_y[1]", "tests/test_z.py::test_a"]); }); + it("django: dotted labels in place of the run's target, options kept", () => { + const c = rerunFailedConfig( + cfg("django-test", ".venv/bin/python", ["manage.py", "test", "polls", "--keepdb", "-v", "2"]), + [["polls.tests", "QuestionTests", "test_a"], ["polls.tests", "BrokenSetupTests"], ["broken.tests"]], + )!; + expect(c.args).toEqual([ + "manage.py", + "test", + "polls.tests.QuestionTests.test_a", + "polls.tests.BrokenSetupTests", + "broken.tests", + "--keepdb", + "-v", + "2", + ]); + // A configuration made in the form keeps its Target field in step. + const form = { + ...cfg("django-test", "uv", ["run", "python", "manage.py", "test", "polls"]), + params: { command: "uv", runnerArgs: "run python", target: "polls", settings: "", options: "" }, + }; + expect(rerunFailedConfig(form, [["polls.tests", "QuestionTests", "test_a"]])).toMatchObject({ + args: ["run", "python", "manage.py", "test", "polls.tests.QuestionTests.test_a"], + params: { target: "polls.tests.QuestionTests.test_a" }, + }); + }); + it("go: -run with top-level names and packages", () => { const c = rerunFailedConfig(cfg("go", "go", ["test", "-v", "./..."]), [ ["example.com/m/pkg", "TestAdd"], @@ -152,6 +178,25 @@ describe("withTestReporting", () => { expect(withTestReporting(cfg("pytest", "uv", ["run", "pytest"])).args).toEqual(["run", "pytest", "-v"]); expect(withTestReporting(cfg("pytest", ".venv/bin/pytest", ["-q"])).args).toEqual(["-q"]); expect(withTestReporting(cfg("pytest", "pytest", ["-vv"])).args).toEqual(["-vv"]); + // Django prints one line per test from verbosity 2. + expect(withTestReporting(cfg("django-test", "python3", ["manage.py", "test", "--keepdb"])).args).toEqual([ + "manage.py", + "test", + "--keepdb", + "-v", + "2", + ]); + expect(withTestReporting(cfg("django-test", "python3", ["manage.py", "test", "-v", "3"])).args).toEqual([ + "manage.py", + "test", + "-v", + "3", + ]); + expect(withTestReporting(cfg("shell", "python3", ["manage.py", "test", "--verbosity=0"])).args).toEqual([ + "manage.py", + "test", + "--verbosity=0", + ]); // A plain script run (no pytest) and other frameworks are left alone. expect(withTestReporting(cfg("python", "python3", ["main.py"])).args).toEqual(["main.py"]); expect(withTestReporting(cfg("cargo", "cargo", ["test"])).args).toEqual(["test"]); diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index 2d0bc69a..46415d2a 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -4,6 +4,7 @@ */ import type { RunConfig } from "@/platform/ipc"; import { formatArgs } from "../configs"; +import { djangoTestsOf } from "../djangoTypes"; import { goTestParts, withoutGoSelection } from "../goArguments"; import { frameworkFor } from "./index"; import { nodeStatus, type TestNode, type TestResults } from "./results"; @@ -157,6 +158,12 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string): case "pytest": args = pytestArgs(config.args, paths); break; + case "unittest": { + // Django: dotted labels (`polls.tests.QuestionTests.test_x`) in place of the run's own target. + const tests = djangoTestsOf(config, unique(paths.map((p) => p.join(".")))); + if (!tests) return null; + return { ...config, ...tests, name, detected: true, env: { ...config.env } }; + } case "go": args = goArgs(config.args, paths); break; diff --git a/src/features/run/testing/states.test.ts b/src/features/run/testing/states.test.ts index dca6da2e..8ff5698d 100644 --- a/src/features/run/testing/states.test.ts +++ b/src/features/run/testing/states.test.ts @@ -45,6 +45,19 @@ describe("test states for the run gutter", () => { expect(testState({ path: ["math", "adds"], file: "/work/app/src/math.test.ts" })).toBe("passed"); }); + it("matches unittest results rooted at the dotted module of the file", () => { + run([ + { type: "result", path: ["polls.tests", "QuestionTests", "test_recent"], status: "failed" }, + { type: "result", path: ["polls.tests.test_views", "ViewTests", "test_ok"], status: "passed" }, + { type: "result", path: ["città.tests", "QTests", "test_a"], status: "passed" }, + ]); + expect(testState({ path: ["QuestionTests", "test_recent"], file: "/work/app/polls/tests.py" })).toBe("failed"); + expect(testState({ path: ["QuestionTests"], file: "/work/app/polls/tests/__init__.py" })).toBe("failed"); + expect(testState({ path: ["QuestionTests", "test_recent"], file: "/work/app/blog/tests.py" })).toBeNull(); + expect(testState({ path: ["ViewTests", "test_ok"], file: "/work/app/polls/tests/test_views.py" })).toBe("passed"); + expect(testState({ path: ["QTests", "test_a"], file: "/work/app/città/tests.py" })).toBe("passed"); + }); + it("newer runs win and listeners hear about changes", () => { const listener = vi.fn(); const off = onTestStatesChanged(listener); diff --git a/src/features/run/testing/states.ts b/src/features/run/testing/states.ts index bc23a1f9..4f255bb6 100644 --- a/src/features/run/testing/states.ts +++ b/src/features/run/testing/states.ts @@ -2,7 +2,7 @@ * Last known result per test, across runs, for the gutter ▶ icons: like IDEA, a * test (or suite) that failed last time shows a red run icon, a passed one green. * Results are matched to gutter targets by their test path (the trailing name - * parts), plus the file for frameworks whose results are rooted at the file. + * parts), plus the file for frameworks whose results are rooted at the file (or its module). */ import { nodeStatus, type TestNode, type TestResults } from "./results"; import type { TestStatus } from "./types"; @@ -70,8 +70,17 @@ function segmentMatches(result: string, target: string): boolean { const cleanPath = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, ""); function sameFile(resultRoot: string, relPath: string): boolean { - const a = cleanPath(resultRoot); const b = cleanPath(relPath); + // unittest / Django results are rooted at the dotted module (`polls.tests`). + if ( + /^[\p{L}_][\p{L}\p{N}_]*(\.[\p{L}_][\p{L}\p{N}_]*)*$/u.test(resultRoot) && + !resultRoot.endsWith(".py") && + b.endsWith(".py") + ) { + const module = resultRoot.replace(/\./g, "/"); + return [`${module}.py`, `${module}/__init__.py`].some((f) => b === f || b.endsWith(`/${f}`)); + } + const a = cleanPath(resultRoot); return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`); } diff --git a/src/features/run/testing/unittest.test.ts b/src/features/run/testing/unittest.test.ts new file mode 100644 index 00000000..009639e4 --- /dev/null +++ b/src/features/run/testing/unittest.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from "vitest"; +import { parseAll } from "./index"; +import { TestResults, nodeStatus } from "./results"; +import { unittestParser } from "./unittest"; + +// Captured from `manage.py test` (Django 4.2 on Python 3.10, Django 6.1 on Python 3.12) as the Run +// console splits it into lines: stdout and stderr on two pipes, never sharing a line. +const PY312 = `System check identified no issues (0 silenced). +test_documented (polls.tests.QuestionModelTests.test_documented) +Explains what is checked. ... ok +test_errors (polls.tests.QuestionModelTests.test_errors) ... ERROR +test_expected_failure (polls.tests.QuestionModelTests.test_expected_failure) ... expected failure +test_fails (polls.tests.QuestionModelTests.test_fails) ... FAIL +test_passes (polls.tests.QuestionModelTests.test_passes) ... ok +test_prints (polls.tests.QuestionModelTests.test_prints) ... hello from stderr +ok +hello from stdout +test_skipped (polls.tests.QuestionModelTests.test_skipped) ... skipped 'not ready' +test_subtests (polls.tests.QuestionModelTests.test_subtests) ... + test_subtests (polls.tests.QuestionModelTests.test_subtests) (i=1) ... FAIL +test_unexpected_success (polls.tests.QuestionModelTests.test_unexpected_success) ... unexpected success +test_ok (polls.more.test_views.ViewTests.test_ok) ... ok +setUpClass (polls.tests.BrokenSetupTests) ... ERROR + +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests.test_errors) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests.test_fails) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests.test_subtests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +====================================================================== +UNEXPECTED SUCCESS: test_unexpected_success (polls.tests.QuestionModelTests.test_unexpected_success) +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...`; + +const PY310 = `System check identified no issues (0 silenced). +test_documented (polls.tests.QuestionModelTests) +Explains what is checked. ... ok +test_errors (polls.tests.QuestionModelTests) ... ERROR +test_expected_failure (polls.tests.QuestionModelTests) ... expected failure +test_fails (polls.tests.QuestionModelTests) ... FAIL +test_passes (polls.tests.QuestionModelTests) ... ok +test_prints (polls.tests.QuestionModelTests) ... hello from stderr +ok +test_skipped (polls.tests.QuestionModelTests) ... skipped 'not ready' +test_subtests (polls.tests.QuestionModelTests) ... test_unexpected_success (polls.tests.QuestionModelTests) ... unexpected success +test_ok (polls.more.test_views.ViewTests) ... ok +ERROR + +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')... +hello from stdout`; + +/** Default verbosity: progress characters only, failures in the sections. */ +const PY310_DOTS = `System check identified no issues (0 silenced). +.ExF. +hello from stdout +hello from stderr +.su.E +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default'...`; + +/** `--parallel`: tracebacks rebuilt by tblib start in unittest's own frames. */ +const PARALLEL = `====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests.test_fails) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 58, in testPartExecutor + yield + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 634, in run + self._callTestMethod(testMethod) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 589, in _callTestMethod + if method() is not None: + ^^^^^^^^^^^^^^^^^ + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 885, in assertEqual + assertion_func(first, second, msg=msg) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 1251, in assertMultiLineEqual + self.fail(self._formatMessage(msg, standardMsg)) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 715, in fail + raise self.failureException(msg) + ^^^^^^^^^^^^^^^^^ +AssertionError: 'left' != 'right' +- left ++ right`; + +/** Django 4.2 / Python 3.10: `setUpTestData` fails inside Django's own `setUpClass`; a non-ASCII test name. */ +const SETUP_TEST_DATA = `System check identified no issues (0 silenced). +ERROR +test_città (polls.test_extra.UnicodeTests) ... FAIL + +====================================================================== +ERROR: setUpClass (polls.test_extra.DataTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/venvs/py310/lib/python3.10/site-packages/django/test/testcases.py", line 1466, in setUpClass + cls.setUpTestData() + File "/e2e/proj/polls/test_extra.py", line 7, in setUpTestData + raise ValueError("bad data") +ValueError: bad data + +====================================================================== +FAIL: test_città (polls.test_extra.UnicodeTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/test_extra.py", line 15, in test_città + self.assertEqual(1, 2) +AssertionError: 1 != 2 + +---------------------------------------------------------------------- +Ran 1 test in 0.001s + +FAILED (failures=1, errors=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...`; + +/** Python 3.12: no test matched (`-k nomatch`). */ +const NO_TESTS = `System check identified no issues (0 silenced). + +---------------------------------------------------------------------- +Ran 0 tests in 0.000s + +NO TESTS RAN`; + +const IMPORT_ERROR = `broken.tests (unittest.loader._FailedTest.broken.tests) ... ERROR +test_ok (polls.more.test_views.ViewTests.test_ok) ... ok + +====================================================================== +ERROR: broken.tests (unittest.loader._FailedTest.broken.tests) +---------------------------------------------------------------------- +ImportError: Failed to import test module: broken.tests +Traceback (most recent call last): + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/loader.py", line 396, in _find_test_path + module = self._get_module_from_name(name) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/loader.py", line 339, in _get_module_from_name + __import__(name) + File "/e2e/proj/broken/tests.py", line 1, in + import does_not_exist +ModuleNotFoundError: No module named 'does_not_exist' + + +---------------------------------------------------------------------- +Ran 2 tests in 0.000s + +FAILED (errors=1)`; + +function tree(text: string) { + const results = new TestResults(); + results.apply(parseAll(unittestParser(), text)); + results.finish(1); + const status = (...path: string[]) => { + const node = results.get(path.join("\0")); + return node ? nodeStatus(node) : undefined; + }; + return { results, status }; +} + +const Q = ["polls.tests", "QuestionModelTests"]; + +describe("unittest (Django) output", () => { + it.each([ + ["Python 3.12", PY312], + ["Python 3.10", PY310], + ])("reports every test of a verbose run under its module and class (%s)", (_, text) => { + const { results, status } = tree(text); + expect(results.roots.map((n) => n.name)).toEqual(["polls.tests", "polls.more.test_views"]); + expect(status(...Q, "test_documented")).toBe("passed"); + expect(status(...Q, "test_errors")).toBe("failed"); + expect(status(...Q, "test_expected_failure")).toBe("passed"); + expect(status(...Q, "test_fails")).toBe("failed"); + expect(status(...Q, "test_passes")).toBe("passed"); + // Its output split the result line in two. + expect(status(...Q, "test_prints")).toBe("passed"); + expect(status(...Q, "test_skipped")).toBe("skipped"); + expect(status(...Q, "test_subtests")).toBe("failed"); + expect(status(...Q, "test_unexpected_success")).toBe("failed"); + expect(status("polls.more.test_views", "ViewTests", "test_ok")).toBe("passed"); + // `setUpClass` failed: the class itself. + expect(status("polls.tests", "BrokenSetupTests")).toBe("failed"); + expect(results.counts()).toMatchObject({ passed: 5, failed: 5, skipped: 1, running: 0, total: 11 }); + expect(results.summary).toMatchObject({ failed: 5, skipped: 1, durationMs: 1 }); + + const fails = results.get([...Q, "test_fails"].join("\0"))!; + expect(fails.location).toEqual({ path: "/e2e/proj/polls/tests.py", line: 12, resolve: "path" }); + expect(fails.output).toContain("AssertionError: 'left' != 'right'"); + expect(results.get(["polls.tests", "BrokenSetupTests"].join("\0"))!.location).toMatchObject({ line: 50 }); + expect(results.get([...Q, "test_subtests"].join("\0"))!.output).toContain("AssertionError: 1 == 1"); + }); + + it("reports failures of a run without -v 2 from their sections", () => { + const { results, status } = tree(PY310_DOTS); + expect(status(...Q, "test_fails")).toBe("failed"); + expect(status(...Q, "test_errors")).toBe("failed"); + expect(status(...Q, "test_subtests")).toBe("failed"); + expect(status("polls.tests", "BrokenSetupTests")).toBe("failed"); + // The summary line counts the unexpected success, which has no section in Python 3.10. + expect(results.counts()).toMatchObject({ passed: 4, failed: 5, skipped: 1 }); + }); + + it("locates a failure at the test's own frame, past unittest's frames", () => { + const { results } = tree(PARALLEL); + expect(results.get([...Q, "test_fails"].join("\0"))!.location).toMatchObject({ + path: "/e2e/proj/polls/tests.py", + line: 12, + }); + }); + + it("locates fixture failures in the project, past Django's frames, and reads non-ASCII names", () => { + const { results, status } = tree(SETUP_TEST_DATA); + expect(status("polls.test_extra", "DataTests")).toBe("failed"); + expect(results.get(["polls.test_extra", "DataTests"].join("\0"))!.location).toEqual({ + path: "/e2e/proj/polls/test_extra.py", + line: 7, + resolve: "path", + }); + expect(status("polls.test_extra", "UnicodeTests", "test_città")).toBe("failed"); + expect(results.get(["polls.test_extra", "UnicodeTests", "test_città"].join("\0"))!.location).toMatchObject({ + line: 15, + }); + }); + + it("keeps a project's own unittest/ folder as a location", () => { + const { results } = tree( + [ + "=".repeat(70), + "FAIL: test_a (tests.unittest.test_x.C.test_a)", + "-".repeat(70), + "Traceback (most recent call last):", + ' File "/e2e/proj/tests/unittest/test_x.py", line 9, in test_a', + " self.assertTrue(False)", + "AssertionError: False is not true", + "", + ].join("\n"), + ); + expect(results.get(["tests.unittest.test_x", "C", "test_a"].join("\0"))!.location).toMatchObject({ + path: "/e2e/proj/tests/unittest/test_x.py", + line: 9, + }); + }); + + it("reports a run where no test matched", () => { + const { results } = tree(NO_TESTS); + expect(results.isEmpty).toBe(true); + expect(results.summary).toMatchObject({ passed: 0, failed: 0, skipped: 0 }); + }); + + it("ignores other tools' lines that look like a name and a dotted word", () => { + const { results } = tree("Finished (0.5s)\ncompile (gcc.exe)\nbuilding (app.main) ... done"); + expect(results.isEmpty).toBe(true); + }); + + it("reports a test module that fails to import as the module", () => { + const { results, status } = tree(IMPORT_ERROR); + expect(status("broken.tests")).toBe("failed"); + expect(results.get("broken.tests")!.location).toMatchObject({ path: "/e2e/proj/broken/tests.py", line: 1 }); + expect(status("polls.more.test_views", "ViewTests", "test_ok")).toBe("passed"); + }); +}); diff --git a/src/features/run/testing/unittest.ts b/src/features/run/testing/unittest.ts new file mode 100644 index 00000000..510657d1 --- /dev/null +++ b/src/features/run/testing/unittest.ts @@ -0,0 +1,169 @@ +/** + * unittest's text runner, as Django's `manage.py test` prints it: verbose result + * lines (`-v 2`), failure sections and the summary. Tests are reported as + * `[module, Class, test]`; class and module fixtures (`setUpClass`, a test module + * that fails to import) as their class or module. + */ +import { parseDuration, type TestEvent, type TestOutputParser, type TestStatus } from "./types"; + +/** A Python identifier (Unicode letters, not starting with a digit). */ +const ID = String.raw`[\p{L}_][\p{L}\p{N}_]*`; +// `test_x (polls.tests.QuestionTests.test_x)` (Python ≥ 3.11 adds the test name), +// `test_x (polls.tests.QuestionTests)`, then a subtest's `[msg]` / `(i=1)`. +const HEADER_RE = new RegExp( + String.raw`^\s*(${ID}(?:\.${ID})*) \((${ID}(?:\.${ID})+)\)(?: \[.*?\])?(?: \(.*?\))?(?= \.\.\.|$)`, + "u", +); +const OUTCOME_RE = /^(ok|FAIL|ERROR|skipped(?: .*)?|expected failure|unexpected success)$/; +// A docstring's first line follows the name when the test has one: `Checks x. ... ok`. +const TRAILING_OUTCOME_RE = / \.\.\. (ok|FAIL|ERROR|skipped(?: .*)?|expected failure|unexpected success)$/; +const SECTION_RE = /^(FAIL|ERROR|UNEXPECTED SUCCESS): (.+)$/; +const FRAME_RE = /^\s*File "([^"]+)", line (\d+), in (\S+)/; +// Installed packages and the standard library with its unittest (`lib/python3.12/…`, Windows `Lib\…`); +// a project's own `unittest/` folder is not a library. +const LIBRARY_RE = + /[/\\](?:site-packages|dist-packages)[/\\]|\/lib\/python[\d.]*\/(?:unittest\/|[^/]+\.py$)|\\Lib\\(?:unittest\\|[^\\]+\.py$)/; +const RAN_RE = /^Ran (\d+) tests? in ([\d.]+s)$/; +// Python ≥ 3.12 prints `NO TESTS RAN` when nothing matched the labels. +const RESULT_RE = /^(OK|FAILED|NO TESTS RAN)(?: \((.*)\))?$/; +/** unittest's `separator1` / `separator2`. */ +const RULE1 = "=".repeat(70); +const RULE2 = "-".repeat(70); + +function status(outcome: string): Exclude { + if (outcome === "ok" || outcome === "expected failure") return "passed"; + if (outcome.startsWith("skipped")) return "skipped"; + return "failed"; +} + +/** Tree path of a reported test: its module, class and name. */ +function testPath(name: string, dotted: string): string[] { + // A test module that failed to import: `polls.tests (unittest.loader._FailedTest.polls.tests)`. + if (/^unittest\.loader\._Failed/.test(dotted)) return [name]; + if (name === "setUpModule" || name === "tearDownModule") return [dotted]; + const id = dotted.endsWith(`.${name}`) ? dotted.slice(0, -name.length - 1) : dotted; + const dot = id.lastIndexOf("."); + const parts = dot < 0 ? [id] : [id.slice(0, dot), id.slice(dot + 1)]; + return name === "setUpClass" || name === "tearDownClass" ? parts : [...parts, name]; +} + +export function unittestParser(): TestOutputParser { + /** A test whose outcome hasn't been printed yet (output, a docstring or subtests came first). */ + let pending: string[] | null = null; + let section: { + path: string[]; + /** The function whose frame locates the failure (the test, `setUpClass`, a module's ``). */ + name: string; + ruled: boolean; + location: TestEvent | null; + fallback: TestEvent | null; + } | null = null; + let ran: { count: number; durationMs?: number } | null = null; + + const closeSection = (): TestEvent[] => { + const s = section; + section = null; + const location = s && (s.location ?? s.fallback); + return location ? [location] : []; + }; + + /** Result lines; a line may hold several (Python < 3.11 prints nothing after a test with failed subtests). */ + const results = (line: string, events: TestEvent[]): boolean => { + let rest = line; + let matched = false; + let m: RegExpExecArray | null; + while ((m = HEADER_RE.exec(rest))) { + const after = rest.slice(m[0].length).replace(/^ \.\.\. ?/, ""); + const outcome = OUTCOME_RE.exec(after); + // A result that comes later (after a docstring or the test's output) only for test methods + // (unittest's `test` prefix); fixtures and modules print theirs at once. Lines of other tools + // such as `Finished (0.5s)` or `compile (gcc.exe)` are no tests. + if (!outcome && !m[1]!.startsWith("test")) break; + matched = true; + const path = testPath(m[1]!, m[2]!); + rest = after; + if (outcome) { + events.push({ type: "result", path, status: status(outcome[1]!) }); + pending = null; + return true; + } + events.push({ type: "start", path }); + pending = path; + } + return matched; + }; + + return { + feed(line) { + const events: TestEvent[] = []; + let m: RegExpExecArray | null; + if (line === RULE1) { + events.push(...closeSection()); + pending = null; + return events; + } + // An unexpected success's section has no traceback: the summary follows its rule. + if (section && RAN_RE.test(line)) events.push(...closeSection()); + if (section) { + if (line === RULE2) { + if (!section.ruled) section.ruled = true; + else events.push(...closeSection()); + return events; + } + events.push({ type: "output", path: section.path, line }); + const frame = FRAME_RE.exec(line); + // Library frames never locate a failure, even when named like the test or fixture + // (Django's own `setUpClass` runs the test's `setUpTestData`). + if (frame && !LIBRARY_RE.test(frame[1]!)) { + const location: TestEvent = { + type: "location", + path: section.path, + location: { path: frame[1]!, line: Number(frame[2]), resolve: "path" }, + }; + if (frame[3] === section.name) section.location ??= location; + else section.fallback ??= location; + } + return events; + } + if ((m = SECTION_RE.exec(line))) { + const header = HEADER_RE.exec(m[2]!); + if (header) { + const path = testPath(header[1]!, header[2]!); + const name = /^unittest\.loader\._Failed/.test(header[2]!) ? "" : header[1]!; + section = { path, name, ruled: false, location: null, fallback: null }; + events.push({ type: "result", path, status: "failed" }, { type: "output", path, line }); + return events; + } + } + if ((m = RAN_RE.exec(line))) { + ran = { count: Number(m[1]), durationMs: parseDuration(m[2]) }; + return events; + } + if (ran && (m = RESULT_RE.exec(line))) { + const counts: Record = {}; + for (const c of (m[2] ?? "").matchAll(/([a-z ]+)=(\d+)/g)) counts[c[1]!.trim()] = Number(c[2]); + const failed = (counts.failures ?? 0) + (counts.errors ?? 0) + (counts["unexpected successes"] ?? 0); + const skipped = counts.skipped ?? 0; + events.push({ + type: "summary", + passed: Math.max(0, ran.count - failed - skipped), + failed, + skipped, + durationMs: ran.durationMs, + }); + ran = null; + return events; + } + if (results(line, events)) return events; + if (pending) { + const outcome = OUTCOME_RE.exec(line) ?? TRAILING_OUTCOME_RE.exec(line); + if (outcome) { + events.push({ type: "result", path: pending, status: status(outcome[1]!) }); + pending = null; + } + } + return events; + }, + end: closeSection, + }; +} diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 1ad2edf8..a5b51f4f 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -23,6 +23,7 @@ import { tomcatType } from "./tomcat"; import { goBuildType, goTestType } from "./goTypes"; import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; +import { djangoTestType } from "./djangoTypes"; export const COMPOUND = "compound"; @@ -398,6 +399,7 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + djangoTestType, dotnetProjectType, dotnetTestType, goBuildType, @@ -451,7 +453,7 @@ export function withParams(config: RunConfig, params: Record, ty ...(config.kind === "dotnet" && config.beforeLaunch === undefined ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } : {}), - ...(config.kind === "dotnet-test" ? { isTest: true } : {}), + ...(config.kind === "dotnet-test" || config.kind === "django-test" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), ...(params.goKind ? { isTest: params.goKind === "test" } : {}), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4240050a..ed9b9131 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5504,5 +5504,13 @@ "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", "Show inline blame for the current line": "为当前行显示内联 Git Blame", "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", - "now": "刚刚" + "now": "刚刚", + "Django tests": "Django 测试", + "Run Django tests with manage.py test": "使用 manage.py test 运行 Django 测试", + "e.g. python3, .venv/bin/python, uv, poetry": "例如 python3、.venv/bin/python、uv、poetry", + "e.g. `run python` for uv/poetry; empty calls the interpreter itself": "例如 uv/poetry 使用 `run python`;留空则直接调用解释器本身", + "App labels, modules or dotted test paths; empty runs every test": "应用标签、模块或以点分隔的测试路径;留空则运行全部测试", + "Settings module": "设置模块", + "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", + "e.g. --keepdb --parallel": "例如 --keepdb --parallel" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index e23a830d..1bba83ce 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -212,13 +212,16 @@ const UNTRANSLATED = new Set([ "go env GOPATH", "kebab-case", "KEY=value; OTHER=value", + "mysite.settings_test", "NAME", "NAME=value; OTHER=value", "node_modules/typescript", "PascalCase", + "polls.tests", "pylyzer --server", "pyproject.toml, .git", "run pytest", + "run python", "snake_case", "tests", '{ "section": { "key": "value" } }', From edfe6c7b6502027e2944a183da7f1379c4ae2d6d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:11:56 +0800 Subject: [PATCH 054/108] chore: v0.1.34 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 70 +++++++++++++++++++-------------------- Cargo.toml | 2 +- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5275a63f..56a55e9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ide-agent" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4061,7 +4061,7 @@ dependencies = [ [[package]] name = "ide-analysis" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ast-grep-core", "ide-core", @@ -4075,7 +4075,7 @@ dependencies = [ [[package]] name = "ide-build" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "dunce", @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "ide-clipboard" -version = "0.1.33" +version = "0.1.34" dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -4099,7 +4099,7 @@ dependencies = [ [[package]] name = "ide-core" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4116,7 +4116,7 @@ dependencies = [ [[package]] name = "ide-coverage" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-run", @@ -4130,7 +4130,7 @@ dependencies = [ [[package]] name = "ide-dap" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-jsonrpc", @@ -4148,7 +4148,7 @@ dependencies = [ [[package]] name = "ide-db" -version = "0.1.33" +version = "0.1.34" dependencies = [ "bb8", "dirs 6.0.0", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "ide-docker" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "serde", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "ide-fonts" -version = "0.1.33" +version = "0.1.34" dependencies = [ "objc2-core-foundation", "objc2-core-text", @@ -4195,7 +4195,7 @@ dependencies = [ [[package]] name = "ide-github" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-vcs", @@ -4208,7 +4208,7 @@ dependencies = [ [[package]] name = "ide-host" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "dunce", @@ -4255,7 +4255,7 @@ dependencies = [ [[package]] name = "ide-http" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "parking_lot", @@ -4273,7 +4273,7 @@ dependencies = [ [[package]] name = "ide-index" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "criterion", @@ -4295,7 +4295,7 @@ dependencies = [ [[package]] name = "ide-jsonrpc" -version = "0.1.33" +version = "0.1.34" dependencies = [ "encoding_rs", "futures", @@ -4311,7 +4311,7 @@ dependencies = [ [[package]] name = "ide-language" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "criterion", @@ -4366,7 +4366,7 @@ dependencies = [ [[package]] name = "ide-lsp" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "futures", @@ -4391,7 +4391,7 @@ dependencies = [ [[package]] name = "ide-notebook" -version = "0.1.33" +version = "0.1.34" dependencies = [ "futures", "ide-core", @@ -4414,7 +4414,7 @@ dependencies = [ [[package]] name = "ide-plugin" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-language", @@ -4432,7 +4432,7 @@ dependencies = [ [[package]] name = "ide-profiler" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-run", @@ -4445,7 +4445,7 @@ dependencies = [ [[package]] name = "ide-remote" -version = "0.1.33" +version = "0.1.34" dependencies = [ "globset", "ide-core", @@ -4462,7 +4462,7 @@ dependencies = [ [[package]] name = "ide-run" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4485,7 +4485,7 @@ dependencies = [ [[package]] name = "ide-sdk" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "ide-core", @@ -4504,7 +4504,7 @@ dependencies = [ [[package]] name = "ide-search" -version = "0.1.33" +version = "0.1.34" dependencies = [ "criterion", "globset", @@ -4524,7 +4524,7 @@ dependencies = [ [[package]] name = "ide-spell" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "parking_lot", @@ -4537,7 +4537,7 @@ dependencies = [ [[package]] name = "ide-ssh" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dirs 6.0.0", "flate2", @@ -4556,7 +4556,7 @@ dependencies = [ [[package]] name = "ide-syntax" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "ide-language", @@ -4571,7 +4571,7 @@ dependencies = [ [[package]] name = "ide-terminal" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "libc", @@ -4584,7 +4584,7 @@ dependencies = [ [[package]] name = "ide-text" -version = "0.1.33" +version = "0.1.34" dependencies = [ "criterion", "ide-core", @@ -4596,7 +4596,7 @@ dependencies = [ [[package]] name = "ide-tools" -version = "0.1.33" +version = "0.1.34" dependencies = [ "flate2", "hex", @@ -4617,7 +4617,7 @@ dependencies = [ [[package]] name = "ide-vcs" -version = "0.1.33" +version = "0.1.34" dependencies = [ "dunce", "encoding_rs", @@ -4636,7 +4636,7 @@ dependencies = [ [[package]] name = "ide-vfs" -version = "0.1.33" +version = "0.1.34" dependencies = [ "chardetng", "dunce", @@ -4658,7 +4658,7 @@ dependencies = [ [[package]] name = "ide-web" -version = "0.1.33" +version = "0.1.34" dependencies = [ "ide-core", "open", @@ -4674,7 +4674,7 @@ dependencies = [ [[package]] name = "ide-workspace" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "dunce", @@ -6146,7 +6146,7 @@ dependencies = [ [[package]] name = "openidea" -version = "0.1.33" +version = "0.1.34" dependencies = [ "blake3", "block2 0.6.2", diff --git a/Cargo.toml b/Cargo.toml index 9da1d526..9906e994 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "src-tauri"] [workspace.package] -version = "0.1.33" +version = "0.1.34" edition = "2024" rust-version = "1.96" license = "Apache-2.0" diff --git a/package.json b/package.json index 9fb5ec26..6bad3b8c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openidea", "private": true, "description": "An open-source IDE inspired by IntelliJ IDEA", - "version": "0.1.33", + "version": "0.1.34", "license": "Apache-2.0", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9a1c84fb..0083fc07 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenIDEA", - "version": "0.1.33", + "version": "0.1.34", "identifier": "ai.l0ng.openidea", "build": { "beforeDevCommand": "pnpm dev", From ece2b2a59ea939014350a0a732253d45c596796b Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 23:37:41 +0800 Subject: [PATCH 055/108] feat(templates): New Go Module template for go.mod files (#884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ▸ Go Module creates go.mod with `module $PROJECT_NAME$` and a `go 1.20` directive. The go.mod language, icon and gopls were already registered; only the file template was missing. It also appears in Search Everywhere and Settings ▸ File and Code Templates. Fixes #882 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/templates/__tests__/files.test.ts | 6 ++++-- src/features/templates/files/builtins.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/features/templates/__tests__/files.test.ts b/src/features/templates/__tests__/files.test.ts index f1aa7872..431bbddb 100644 --- a/src/features/templates/__tests__/files.test.ts +++ b/src/features/templates/__tests__/files.test.ts @@ -70,15 +70,17 @@ describe("file naming", () => { expect(withKind(css, kind("css"))).toBe(css); }); - it("prefills fixed names (Dockerfile, .editorconfig, .gitignore)", () => { + it("prefills fixed names (Dockerfile, .editorconfig, .gitignore, go.mod)", () => { for (const [id, name] of [ ["dockerfile", "Dockerfile"], ["editorconfig", ".editorconfig"], ["gitignore", ".gitignore"], + ["go-mod", "go"], ] as const) { const t = tpl(id); expect(t.defaultName).toBe(name); - expect(planFile(t, "/p", t.defaultName!)?.path).toBe(`/p/${name}`); + const ext = t.extension ? `.${t.extension}` : ""; + expect(planFile(t, "/p", t.defaultName!)?.path).toBe(`/p/${name}${ext}`); } }); diff --git a/src/features/templates/files/builtins.ts b/src/features/templates/files/builtins.ts index 7f92c16a..6e008311 100644 --- a/src/features/templates/files/builtins.ts +++ b/src/features/templates/files/builtins.ts @@ -140,6 +140,15 @@ export const BUILTIN_FILE_TEMPLATES: readonly FileTemplate[] = [ { id: "main", label: "Main", body: "package main\n\nfunc main() {\n\t$END$\n}\n" }, ], }, + { + id: "go-mod", + name: "Go Module", + icon: "file-gomod", + extension: "mod", + nameStyle: "asis", + defaultName: "go", + kinds: [{ id: "module", label: "Module", body: "module $PROJECT_NAME$\n\ngo 1.20\n" }], + }, { id: "java", name: "Java Class", From 51988985c5b2d87d79950d00a113b8c1068c9b10 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Mon, 2 Nov 2026 16:37:47 +0100 Subject: [PATCH 056/108] fix(run): run a Python script with the project interpreter, not the runserver command line (#897) Root cause: projectPython built the interpreter prefix from the first Python configuration by dropping only the .py arguments, so a Django `manage.py runserver` config left `runserver` in the prefix. The prefix now ends where the program starts (`-m` or the script), so program arguments such as `runserver` and `test` never reach it; `uv run python` is kept. Covers both plain scripts and pytest files derived from the same prefix. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- src/features/run/configs.test.ts | 22 ++++++++++++++++++++++ src/features/run/context.ts | 7 +++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index ce70a443..162e0adf 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -271,4 +271,26 @@ describe("context configuration", () => { const uv = [cfg({ id: "pytest", kind: "pytest", command: "uv", args: ["run", "pytest"], isTest: true })]; expect(contextConfig("tools/gen.py", uv)).toMatchObject({ command: "uv", args: ["run", "python", "tools/gen.py"] }); }); + + it("takes the interpreter from a Django runserver config without its arguments", () => { + const runserver = (command: string, args: string[]) => + cfg({ id: "django:runserver", kind: "python", command, args: [...args, "manage.py", "runserver"] }); + expect(contextConfig("tools/seed.py", [runserver(".venv/bin/python", [])])).toMatchObject({ + command: ".venv/bin/python", + args: ["tools/seed.py"], + }); + expect(contextConfig("tools/seed.py", [runserver("uv", ["run", "python"])])).toMatchObject({ + command: "uv", + args: ["run", "python", "tools/seed.py"], + }); + expect(contextConfig("worker/test_worker.py", [runserver(".venv/bin/python", [])])).toMatchObject({ + args: ["-m", "pytest", "worker/test_worker.py"], + }); + }); + + it("keeps the interpreter of a plain python config", () => { + const plain = [cfg({ id: "python:module:app", kind: "python", command: "python3", args: ["-m", "app"] })]; + expect(contextConfig("tools/gen.py", plain)).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); + expect(contextConfig("tools/gen.py", [])).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); + }); }); diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 768e3c01..c71bf6e6 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -91,8 +91,11 @@ function derived(base: RunConfig, relPath: string, patch: Partial): R export function projectPython(configs: RunConfig[]): { command: string; prefix: string[] } | null { const py = configs.find((c) => c.kind === "python"); if (py) { - const m = py.args.indexOf("-m"); - return { command: py.command, prefix: m >= 0 ? py.args.slice(0, m) : py.args.filter((a) => !a.endsWith(".py")) }; + // The interpreter part ends where the program starts: `-m`, or the script (`manage.py`). What follows + // (`runserver`, `test`…) belongs to that program, never to the interpreter. + const end = py.args.findIndex((a) => a === "-m" || a.endsWith(".py")); + const python = py.args.findIndex((a) => /^python[\d.]*$/.test(a)); + return { command: py.command, prefix: py.args.slice(0, end >= 0 ? end : python + 1) }; } const pytest = configs.find((c) => c.kind === "pytest"); if (!pytest) return null; From c0c56dac8a8dc670c335dcd694e4e98ced781baf Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:37:52 +0800 Subject: [PATCH 057/108] fix(run): enable Spring console colours with manual classpaths (#888) Spring Boot runs with a manual classpath (or no classpath placeholder) skip Java classpath resolution and so also skipped the console colour VM option. The shared console VM options are now applied on that launch path too; explicit spring.output.ansi.enabled settings are kept and resolved classpaths get the option once. --- src/features/run/javaModule.test.ts | 38 +++++++++++++++++++++++++++++ src/features/run/store.ts | 10 ++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/features/run/javaModule.test.ts b/src/features/run/javaModule.test.ts index d0193958..e5b270f6 100644 --- a/src/features/run/javaModule.test.ts +++ b/src/features/run/javaModule.test.ts @@ -87,6 +87,44 @@ it("finds the source module for an old saved configuration without a module", as expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/outside/modules" }), expect.anything()); }); +it.each([["-cp", "lib/*"], []])("colours Spring Boot runs without classpath resolution (%j)", async (...classpath) => { + const args = ["-Xmx256m", ...classpath, "a.Main", "--spring.profiles.active=dev"]; + const boot = { ...config, kind: "spring-boot", args }; + const session = await startRun(boot); + expect(session?.status).toBe("running"); + expect(ipc.runStart).toHaveBeenCalledWith( + expect.objectContaining({ args: ["-Dspring.output.ansi.enabled=always", ...args] }), + expect.anything(), + ); + expect(ipc.javaResolveLaunch).not.toHaveBeenCalled(); + expect(boot.args).toBe(args); + expect(args).not.toContain("-Dspring.output.ansi.enabled=always"); +}); + +it.each(["never", "detect", "always"])("preserves an explicit Spring Boot console setting (%s)", async (value) => { + const args = [`-Dspring.output.ansi.enabled=${value}`, "-cp", "lib/*", "a.Main"]; + await startRun({ ...config, kind: "spring-boot", args }); + expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ args }), expect.anything()); + expect(ipc.javaResolveLaunch).not.toHaveBeenCalled(); +}); + +it("keeps plain Java runs with an explicit classpath free of Spring Boot options", async () => { + const args = ["-cp", "lib/*", "a.Main"]; + await startRun({ ...config, args }); + expect(ipc.runStart).toHaveBeenCalledWith(expect.objectContaining({ args }), expect.anything()); + expect(ipc.javaResolveLaunch).not.toHaveBeenCalled(); +}); + +it("adds the Spring Boot console option once when resolving the module classpath", async () => { + await startRun({ ...config, kind: "spring-boot", module: "app" }); + expect(ipc.runStart).toHaveBeenCalledWith( + expect.objectContaining({ + args: ["-Dspring.output.ansi.enabled=always", "-cp", "/p/app/target/classes", "a.Main"], + }), + expect.anything(), + ); +}); + it("keeps an explicitly stored root module", async () => { expect(await findJavaModule({ ...config, module: "", cwd: "sibling" })).toBe(""); expect(ipc.findFiles).not.toHaveBeenCalled(); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 3d7f3552..0e038ae6 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -17,7 +17,13 @@ import { type RunId, type TestCaseReport, } from "@/platform/ipc"; -import { findJavaModule, isJvmApplication, parseJavaArgs, prepareJavaLaunch } from "@/platform/javaLaunch"; +import { + consoleVmArgs, + findJavaModule, + isJvmApplication, + parseJavaArgs, + prepareJavaLaunch, +} from "@/platform/javaLaunch"; import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { executeAction } from "@/platform/actions"; @@ -721,7 +727,7 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom say(`No main class in '${config.name}': expected java [options] -cp \${classpath}
[args]\n`, "stderr"); return null; } - if (!parsed.classpath) return config; + if (!parsed.classpath) return { ...config, args: [...consoleVmArgs(config), ...config.args] }; const rt = runtime(sessionId)!; const request = { id: nextRequestId(), controller: new AbortController() }; rt.resolveRequest = request; From b3c0efd80419c854ab9aa5adbad35d4110358071 Mon Sep 17 00:00:00 2001 From: Jonas Zuberbuehler Date: Mon, 2 Nov 2026 16:39:32 +0100 Subject: [PATCH 058/108] fix(web): keep the built-in server inside the served root (#887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the built-in web server rejected `..` segments but followed symlinks, so a committed link such as `x -> /` served any file on disk, also in Restricted Mode. The served root is canonicalized per request, and the requested file — and the index.html served for a directory — is refused with 404 when its real path lies outside it. The server opens the checked real path, and Content-Type still follows the requested name. Links inside the project (e.g. pnpm node_modules) keep working. Fixes #886 Co-authored-by: jonaszuberbuehler <3812998+jonaszuberbuehler@users.noreply.github.com> --- crates/ide-web/src/server.rs | 74 +++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/ide-web/src/server.rs b/crates/ide-web/src/server.rs index b909bcdb..cfb80193 100644 --- a/crates/ide-web/src/server.rs +++ b/crates/ide-web/src/server.rs @@ -173,7 +173,12 @@ async fn read_head(sock: &mut TcpStream) -> std::io::Result> { } enum Response { - File { path: PathBuf, len: u64 }, + /// `path` is the resolved file to open; `mime` comes from the requested name. + File { + path: PathBuf, + len: u64, + mime: &'static str, + }, Redirect(String), Error(u16, &'static str), } @@ -191,9 +196,9 @@ async fn serve(mut sock: TcpStream, shared: &Shared, port: u16) -> std::io::Resu }; let mut out = String::new(); match &response { - Response::File { path, len } => { + Response::File { len, mime, .. } => { out.push_str("HTTP/1.1 200 OK\r\n"); - out.push_str(&format!("Content-Type: {}\r\nContent-Length: {len}\r\n", content_type(path))); + out.push_str(&format!("Content-Type: {mime}\r\nContent-Length: {len}\r\n")); } Response::Redirect(to) => { out.push_str(&format!("HTTP/1.1 301 Moved Permanently\r\nLocation: {to}\r\nContent-Length: 0\r\n")); @@ -233,7 +238,9 @@ async fn resolve(shared: &Shared, target: &str) -> Response { return NOT_FOUND; } let Some(key) = segments.next().and_then(dec) else { return NOT_FOUND }; - let Some(mut file) = shared.roots.read().get(&key).cloned() else { return NOT_FOUND }; + let Some(root) = shared.roots.read().get(&key).cloned() else { return NOT_FOUND }; + let Ok(real_root) = tokio::fs::canonicalize(&root).await else { return NOT_FOUND }; + let mut file = root; let mut trailing_slash = true; for seg in segments { trailing_slash = seg.is_empty(); @@ -245,19 +252,29 @@ async fn resolve(shared: &Shared, target: &str) -> Response { _ => return NOT_FOUND, } } - let Ok(meta) = tokio::fs::metadata(&file).await else { return NOT_FOUND }; + let Some(real) = confined(&real_root, &file).await else { return NOT_FOUND }; + let Ok(meta) = tokio::fs::metadata(&real).await else { return NOT_FOUND }; if meta.is_dir() { // Relative URLs in an index page resolve against the directory. if !trailing_slash { return Response::Redirect(format!("{path}/")); } - file.push("index.html"); - return match tokio::fs::metadata(&file).await { - Ok(m) if m.is_file() => Response::File { path: file, len: m.len() }, + let index = file.join("index.html"); + let Some(real_index) = confined(&real_root, &index).await else { return NOT_FOUND }; + return match tokio::fs::metadata(&real_index).await { + Ok(m) if m.is_file() => Response::File { path: real_index, len: m.len(), mime: content_type(&index) }, _ => NOT_FOUND, }; } - Response::File { path: file, len: meta.len() } + Response::File { path: real, len: meta.len(), mime: content_type(&file) } +} + +/// `path` with every symlink resolved, if it still lies inside `real_root` (already +/// canonical). Rejecting `..` segments is not enough: a project can commit symlinks +/// that point anywhere on disk. +async fn confined(real_root: &Path, path: &Path) -> Option { + let real = tokio::fs::canonicalize(path).await.ok()?; + real.starts_with(real_root).then_some(real) } fn dec(s: &str) -> Option { @@ -340,6 +357,45 @@ mod tests { assert!(get(&url, Some("evil.example:80")).await.starts_with("HTTP/1.1 403")); } + #[cfg(unix)] + #[tokio::test] + async fn symlinks_cannot_escape_the_served_root() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("repo"); + std::fs::create_dir_all(root.join("site/escape")).unwrap(); + std::fs::create_dir_all(root.join("site/assets")).unwrap(); + std::fs::write(root.join("site/index.html"), "

hi

").unwrap(); + std::fs::write(root.join("site/assets/app.js"), "export {}").unwrap(); + std::fs::write(root.join("site/raw"), "

raw

").unwrap(); + std::fs::write(dir.path().join("secret.txt"), "outside").unwrap(); + symlink("index.html", root.join("site/inside.html")).unwrap(); + symlink("assets", root.join("site/lib")).unwrap(); + symlink("raw", root.join("site/raw.html")).unwrap(); + symlink("../../secret.txt", root.join("site/leak.txt")).unwrap(); + symlink("../..", root.join("site/up")).unwrap(); + symlink("/", root.join("site/fsroot")).unwrap(); + symlink("../../../secret.txt", root.join("site/escape/index.html")).unwrap(); + + let server = WebServer::new(); + let url = server.url_for(Some(&root), &root.join("site/index.html")).await.unwrap(); + let base = url.trim_end_matches("index.html"); + let page = get(&format!("{base}inside.html"), None).await; + assert!(page.starts_with("HTTP/1.1 200") && page.ends_with("

hi

"), "{page}"); + let page = get(&format!("{base}lib/app.js"), None).await; + assert!(page.starts_with("HTTP/1.1 200") && page.contains("text/javascript"), "{page}"); + // The type follows the requested name, not the link target. + let page = get(&format!("{base}raw.html"), None).await; + assert!(page.contains("text/html") && page.ends_with("

raw

"), "{page}"); + + let secret = dir.path().join("secret.txt"); + let via_fsroot = format!("fsroot{}", secret.display()); + for escape in ["leak.txt", "up/secret.txt", via_fsroot.as_str(), "escape/"] { + let page = get(&format!("{base}{escape}"), None).await; + assert!(page.starts_with("HTTP/1.1 404"), "{escape}: {page}"); + } + } + #[tokio::test] async fn files_outside_the_project_get_their_own_root() { let dir = tempfile::tempdir().unwrap(); From 7c7d656a50ee579393c322c5580cd0a90ca258a6 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:41:21 +0700 Subject: [PATCH 059/108] fix(run): select and copy the whole console output with Select All (#903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Cmd/Ctrl+A in the Run console fell through to the browser and selected the whole window, and copying only saw the rows the virtualizer had mounted. Select All now selects the console only (also in its context menu), and copying after it — keyboard, context menu or native copy — takes the whole buffer. copySelectionOnKeyDown accepts a textOf callback for other views that do not mount all their text. Refs #746 Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- e2e/console-selection.spec.ts | 21 ++++++++++ .../app/__tests__/consoleCopy.test.ts | 27 +++++++++++++ src/features/run/ConsoleView.tsx | 40 +++++++++++++++++-- src/ui/selectableText.ts | 4 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/e2e/console-selection.spec.ts b/e2e/console-selection.spec.ts index cfec2c29..c0e9c070 100644 --- a/e2e/console-selection.spec.ts +++ b/e2e/console-selection.spec.ts @@ -82,3 +82,24 @@ test("preserves all lines in a drag selection across virtual windows", async ({ expect(text).toContain("output 250\noutput 251"); expect(text).toContain("output 284"); }); + +test("Select All takes the whole buffer, not just the mounted rows", async ({ page }) => { + const log = await consoleOutput(page); + await page.evaluate(() => { + (window as unknown as { copied: string[] }).copied = []; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: async (text: string) => (window as unknown as { copied: string[] }).copied.push(text) }, + }); + }); + await log.focus(); + await page.keyboard.press("Meta+A"); + // Selection stays inside the console: nothing from the workbench behind it is selected. + const selected = await page.evaluate(() => window.getSelection()?.toString() ?? ""); + expect(selected).not.toContain("Project"); + await page.keyboard.press("Meta+C"); + const [copied] = await page.evaluate(() => (window as unknown as { copied: string[] }).copied); + expect(copied?.split("\n")).toHaveLength(500); + expect(copied).toContain("output 0\n"); + expect(copied).toMatch(/output 499$/); +}); diff --git a/src/features/app/__tests__/consoleCopy.test.ts b/src/features/app/__tests__/consoleCopy.test.ts index 3f449702..b82d12f6 100644 --- a/src/features/app/__tests__/consoleCopy.test.ts +++ b/src/features/app/__tests__/consoleCopy.test.ts @@ -108,6 +108,33 @@ describe("view-local console copy with the Windows keymap (#372)", () => { expect(writeText).toHaveBeenCalledWith("selected application output"); }); + it("Ctrl+A selects only the console and copies the whole buffer, not just the mounted rows (#746)", () => { + const model = new ConsoleModel(); + model.append({ kind: "stdout", text: "first\nsecond\n" }); + vi.spyOn(model, "text").mockReturnValue("whole buffer, including unmounted rows"); + const outside = document.createElement("p"); + outside.textContent = "workbench chrome"; + document.body.append(outside); + render( + createElement(ConsoleView, { + model, + version: 1, + softWrap: false, + follow: false, + onFollowChange: () => {}, + cwd: "/", + }), + ); + const output = screen.getByRole("log"); + const selectAll = copy(output, { key: "a", code: "KeyA", metaKey: true }); + expect(selectAll.defaultPrevented).toBe(true); + const selection = window.getSelection()!; + expect(output.contains(selection.anchorNode) && output.contains(selection.focusNode)).toBe(true); + expect(selection.toString()).not.toContain("workbench chrome"); + copy(output); + expect(writeText).toHaveBeenCalledWith("whole buffer, including unmounted rows"); + }); + it.each([false, true])("copies build output and selected message detail (detail=%s)", (detail) => { useBuildRun.setState({ status: "failed", diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 24cc2287..8c5aad24 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -179,6 +179,7 @@ export function ConsoleView({ ...aria }: ConsoleViewProps) { const scrollRef = useRef(null); + const innerRef = useRef(null); const lines = model.lines; const patterns = useWorkflowSettings((s) => s.console.foldPatterns); const [expanded, setExpanded] = useState>(() => new Set()); @@ -377,9 +378,30 @@ export function ConsoleView({ scrolledMatch.current = key; }, [findOpen, activeMatch, rowOfLine, rows, virtualizer]); + /** Rows are virtualized, so "everything" is a selection of the container, not of the mounted rows. */ + const selectAll = () => { + const inner = innerRef.current; + if (inner) window.getSelection()?.selectAllChildren(inner); + }; + const allSelected = () => { + const inner = innerRef.current; + const s = window.getSelection(); + return ( + !!inner && + !!s && + !s.isCollapsed && + s.anchorNode === inner && + s.focusNode === inner && + s.anchorOffset === 0 && + s.focusOffset === inner.childNodes.length + ); + }; + /** The selected text; after Select All the whole buffer, including rows that are not mounted. */ + const selectedOutput = () => (allSelected() ? model.text() : (window.getSelection()?.toString() ?? "")); + const onContextMenu = (e: MouseEvent) => { e.preventDefault(); - const selection = window.getSelection()?.toString() ?? ""; + const selection = selectedOutput(); showContextMenu(e, [ { label: "Copy", @@ -388,6 +410,7 @@ export function ConsoleView({ disabled: !selection, run: () => void navigator.clipboard?.writeText(selection).catch(() => undefined), }, + { label: "Select All", shortcut: formatBinding(hostBinding("Meta+A")), run: selectAll }, { label: "Copy All", run: () => void navigator.clipboard?.writeText(model.text()).catch(() => undefined) }, { label: "Find…", @@ -410,7 +433,12 @@ export function ConsoleView({ openFind(); return; } - if (copySelectionOnKeyDown(e)) return; + if (hasPrimaryModifier(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "a") { + e.preventDefault(); + selectAll(); + return; + } + if (copySelectionOnKeyDown(e, selectedOutput)) return; if (!input) return; if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); @@ -496,10 +524,16 @@ export function ConsoleView({ onScroll={onScroll} onContextMenu={onContextMenu} onKeyDown={onKeyDown} + onCopy={(e) => { + // Native copy (menu, macOS) would take only the mounted rows. + if (!allSelected()) return; + e.preventDefault(); + e.clipboardData.setData("text/plain", model.text()); + }} onPaste={onPaste} data-autofocus="" > -
+
{items.map((item) => { const row = rows[item.index]; if (!row) { diff --git a/src/ui/selectableText.ts b/src/ui/selectableText.ts index d285d3bf..e8489a48 100644 --- a/src/ui/selectableText.ts +++ b/src/ui/selectableText.ts @@ -70,6 +70,8 @@ export function copySelectionOnKeyDown( | "preventDefault" | "stopPropagation" >, + /** Text to copy instead of `selection.toString()`, for views that do not mount all their text. */ + textOf: (selection: Selection) => string = (selection) => selection.toString(), ): boolean { if (e.defaultPrevented || e.nativeEvent.isComposing || e.altKey || e.shiftKey) return false; const copy = @@ -87,6 +89,6 @@ export function copySelectionOnKeyDown( return false; e.preventDefault(); e.stopPropagation(); - void copyText(selection.toString()); + void copyText(textOf(selection)); return true; } From eb633f6ca60fcae71033c3fdc3df9a834f583e62 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 00:37:14 +0800 Subject: [PATCH 060/108] feat(run): run npm scripts from package.json gutter icons (#898) Every entry under the top-level `scripts` of a package.json gets a run gutter icon with Run/Debug for that script. It uses the detected npm run configuration (re-detected at click time) and otherwise a temporary configuration built with the same package-manager precedence as backend detection: the `packageManager` field, then the lockfile, inheriting the workspace root's manager for members. Workspace member configuration ids use the full relative directory, as the backend does. The "Show run icons" preference hides these icons too, and the markers are real buttons with translated titles. Fixes #894 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/editor/core/cm/theme.ts | 2 +- src/features/run/index.ts | 2 + src/features/run/npmGutter.test.ts | 311 +++++++++++++++++++++++ src/features/run/npmGutter.ts | 392 +++++++++++++++++++++++++++++ 4 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 src/features/run/npmGutter.test.ts create mode 100644 src/features/run/npmGutter.ts diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 0390d1f1..3bfd8427 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -105,7 +105,7 @@ export const openideaTheme = EditorView.theme({ boxShadow: "inset 0 1px 0 var(--border-method-separator, var(--border))", }, "&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": { - display: "none", + display: "none !important", }, // Inheritance markers share one gutter: each group hides its own icons. "&.cm-openidea-hideGutter-inheritors :is(.cm-openidea-lineMarker-implemented, .cm-openidea-lineMarker-overridden), &.cm-openidea-hideGutter-supers :is(.cm-openidea-lineMarker-implementing, .cm-openidea-lineMarker-overriding)": diff --git a/src/features/run/index.ts b/src/features/run/index.ts index a07662b1..cd2fcd0d 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -45,6 +45,7 @@ import { compoundChildren } from "./configs"; import { remoteDebugBlocked } from "./remote"; import { isDebugOnly, registerBuiltinTypes } from "./types"; import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate"; +import { npmGutter } from "./npmGutter"; import { runGutter } from "./runGutter"; import { activateStackTrace, useStackTraces } from "./stackTrace"; import { clearRuntimeCache } from "./runtimes"; @@ -283,6 +284,7 @@ export function activate(): () => void { listenForApplicationDeactivation(), useDebugRunSessions.subscribe(() => setContext("run.updatableRunning", updatableSessions().length > 0)), registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }), + registerEditorContribution({ id: "run.npmScripts", order: 58, extensions: npmGutter }), registerMenuGroup({ id: "run.context", menus: ["editor", "projectView", "editorTab"], diff --git a/src/features/run/npmGutter.test.ts b/src/features/run/npmGutter.test.ts new file mode 100644 index 00000000..cff1da5c --- /dev/null +++ b/src/features/run/npmGutter.test.ts @@ -0,0 +1,311 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorView } from "@codemirror/view"; +import { commands, type RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { + npmGutter, + scriptConfigFor, + scriptConfigIds, + scriptEntries, + scriptMenuItems, + scriptPackageManager, +} from "./npmGutter"; +import * as ui from "@/ui"; +import * as store from "./store"; + +function cfg(over: Partial): RunConfig { + return { + id: "x", + name: "x", + kind: "npm", + command: "npm", + args: [], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + ...over, + }; +} + +describe("scriptEntries", () => { + it("lists scripts with their lines in document order", () => { + const text = [ + "{", + ' "name": "app",', + ' "scripts": {', + ' "dev": "vite",', + ' "build": "tsc && vite build",', + ' "test": "vitest run"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text)).toEqual([ + { name: "dev", line: 3 }, + { name: "build", line: 4 }, + { name: "test", line: 5 }, + ]); + }); + + it("parses names with escapes and values containing braces or colons", () => { + const text = [ + "{", + ' "scripts": {', + ' "say \\"hi\\"": "echo \\"{\\" done",', + ' "a\\u0041b": "node -e \\"x:{y}\\""', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text).map((e) => e.name)).toEqual(['say "hi"', "aAb"]); + expect(scriptEntries(text).map((e) => e.line)).toEqual([2, 3]); + }); + + it("ignores scripts nested in other objects", () => { + const text = [ + "{", + ' "a": {', + ' "scripts": {', + ' "inner": "x"', + " }", + " },", + ' "scripts": {', + ' "outer": "y"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text)).toEqual([{ name: "outer", line: 7 }]); + }); + + it("tracks lines through object, array and scalar values", () => { + const text = [ + "{", + ' "scripts": {', + ' "a": 1,', + ' "b": true,', + ' "c": null,', + ' "d": [1, 2],', + ' "e": { "x": 1 },', + ' "f": "ok"', + " }", + "}", + ].join("\n"); + expect(scriptEntries(text).map((e) => e.name)).toEqual(["a", "b", "c", "d", "e", "f"]); + expect(scriptEntries(text).map((e) => e.line)).toEqual([2, 3, 4, 5, 6, 7]); + }); + + it("returns nothing without a scripts key", () => { + expect(scriptEntries('{\n "name": "x"\n}\n')).toEqual([]); + }); + + it("returns an empty scripts object as no entries", () => { + expect(scriptEntries('{\n "scripts": {}\n}\n')).toEqual([]); + }); + + it("ignores a scripts value that is not an object", () => { + expect(scriptEntries('{\n "scripts": "dev"\n}\n')).toEqual([]); + }); + + it("keeps entries parsed before malformed text", () => { + const text = ["{", ' "scripts": {', ' "a": "x",', ' "b": '].join("\n"); + expect(scriptEntries(text)).toEqual([{ name: "a", line: 2 }]); + }); + + it("tolerates a trailing comma", () => { + expect(scriptEntries('{\n "scripts": {\n "a": "x",\n },\n}\n')).toEqual([{ name: "a", line: 2 }]); + }); + + it("skips a BOM", () => { + expect(scriptEntries('{\n "scripts": {\n "dev": "vite"\n }\n}\n')).toEqual([{ name: "dev", line: 2 }]); + }); + + it("tracks CRLF lines after non-ASCII and astral characters", () => { + const text = [ + "{", + ' "description": "中文 🚀 café",', + ' "scripts": {', + ' "构建🚀": "echo 完成",', + ' "test": "echo ok"', + " }", + "}", + ].join("\r\n"); + expect(scriptEntries(text)).toEqual([ + { name: "构建🚀", line: 3 }, + { name: "test", line: 4 }, + ]); + }); + + it("decodes escaped unicode and backslashes in keys and values", () => { + const text = String.raw`{"scripts":{"\u6784\u5efa\ud83d\ude80":"echo \"ok\" \\path","say\\hi":"echo ok"}}`; + expect(scriptEntries(text)).toEqual([ + { name: "构建🚀", line: 0 }, + { name: "say\\hi", line: 0 }, + ]); + }); + + it("produces no entries for scripts nested in an object or array", () => { + expect(scriptEntries('{"config":{"scripts":{"nested":"echo no"}}}')).toEqual([]); + expect(scriptEntries('{"config":[{"scripts":{"nested":"echo no"}}]}')).toEqual([]); + }); + + it("skips long numeric metadata before scripts", () => { + expect(scriptEntries('{"metadata":1234567890123456,"scripts":{"dev":"echo ok"}}')).toEqual([ + { name: "dev", line: 0 }, + ]); + }); +}); + +describe("scriptPackageManager", () => { + it.each(["npm", "pnpm", "yarn", "bun"])("prefers the declared %s over lockfiles", (pm) => { + expect(scriptPackageManager({ packageManager: `${pm}@1.0.0` }, ["pnpm-lock.yaml", "yarn.lock", "bun.lock"])).toBe( + pm, + ); + }); + + it.each([ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], + ])("uses %s when no manager is declared", (file, pm) => { + expect(scriptPackageManager({}, [file])).toBe(pm); + expect(scriptPackageManager({ packageManager: "unknown@1" }, [file])).toBe(pm); + }); + + it("uses backend lockfile precedence and defaults to npm", () => { + expect(scriptPackageManager(null, ["bun.lock", "yarn.lock", "pnpm-lock.yaml"])).toBe("pnpm"); + expect(scriptPackageManager(null, ["bun.lock", "yarn.lock"])).toBe("yarn"); + expect(scriptPackageManager(null, [])).toBe("npm"); + }); +}); + +describe("scriptMenuItems", () => { + beforeEach(() => { + vi.spyOn(store, "loadConfigs").mockResolvedValue(); + useProject.setState({ project: { root: "/project" } as never }); + store.useRun.setState({ configs: [] }); + vi.spyOn(commands, "fsReadDir").mockResolvedValue({ + status: "ok", + data: [{ name: "pnpm-lock.yaml", path: "/project/pnpm-lock.yaml", kind: "file" } as never], + }); + vi.spyOn(commands, "fsReadText").mockRejectedValue(new Error("not found")); + }); + afterEach(() => { + vi.restoreAllMocks(); + useProject.setState({ project: null }); + store.useRun.setState({ configs: [] }); + }); + + it("uses the package lockfile for an undetected script", async () => { + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/"); + }); + + it("inherits the backend's workspace manager from a sibling script", async () => { + store.useRun.setState({ configs: [cfg({ command: "yarn", cwd: "packages/web" })] }); + const items = await scriptMenuItems( + { name: "new", line: 0 }, + "/project/packages/web/package.json", + '{"packageManager":"pnpm@9"}', + ); + expect(items).toMatchObject([{ label: "Run 'yarn run new'" }, { label: "Debug 'yarn run new'" }]); + expect(commands.fsReadDir).not.toHaveBeenCalled(); + }); + + it("does not inherit an unrelated package's manager or a custom executable", async () => { + store.useRun.setState({ + configs: [cfg({ command: "yarn", cwd: "other" }), cfg({ command: "echo", cwd: null })], + }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + }); + + it("uses root detection for a workspace member's first script", async () => { + vi.mocked(commands.fsReadText).mockResolvedValue({ status: "ok", data: '{"packageManager":"yarn@4"}' }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/packages/web/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'yarn run new'" }, { label: "Debug 'yarn run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/"); + }); + + it("uses local detection for a polyglot package without a root manifest", async () => { + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/web/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'pnpm run new'" }, { label: "Debug 'pnpm run new'" }]); + expect(commands.fsReadDir).toHaveBeenCalledWith("/project/web/"); + }); + + it("preserves a matching user configuration", async () => { + store.useRun.setState({ configs: [cfg({ id: "npm:new", name: "Custom new", command: "custom" })] }); + const items = await scriptMenuItems({ name: "new", line: 0 }, "/project/package.json", "{}"); + expect(items).toMatchObject([{ label: "Run 'Custom new'" }, { label: "Debug 'Custom new'" }]); + expect(commands.fsReadDir).not.toHaveBeenCalled(); + }); + + it("renders a native button and opens its menu on keyboard-style activation after Unicode text", async () => { + const show = vi.spyOn(ui, "showContextMenu").mockImplementation(() => {}); + const text = '{\r\n "description": "中文 🚀",\r\n "scripts": {\r\n "dev": "echo ok"\r\n }\r\n}'; + const view = new EditorView({ + doc: text, + parent: document.body, + extensions: npmGutter({ doc: 1, path: "/project/package.json", language: "json", uiMode: null, readOnly: false }), + }); + try { + await vi.waitFor(() => expect(view.dom.querySelector("button[aria-label=\"Run 'dev'\"]")).not.toBeNull()); + expect(view.state.doc.line(4).from).toBe(text.replaceAll("\r\n", "\n").indexOf(' "dev"')); + view.dom.querySelector("button[aria-label=\"Run 'dev'\"]")!.click(); + await vi.waitFor(() => + expect(show).toHaveBeenCalledWith(expect.anything(), [ + expect.objectContaining({ label: "Run 'pnpm run dev'" }), + expect.objectContaining({ label: "Debug 'pnpm run dev'" }), + ]), + ); + } finally { + view.destroy(); + } + }); +}); + +describe("scriptConfigFor", () => { + const root = cfg({ id: "npm:dev", name: "pnpm run dev", command: "pnpm", args: ["run", "dev"], cwd: null }); + + it("matches the root package's detected id", () => { + expect(scriptConfigFor([root], "dev", "", null)).toBe(root); + }); + + it("matches a workspace member by its package name or directory", () => { + const web = cfg({ + id: "npm:web:dev", + name: "web: pnpm run dev", + command: "pnpm", + args: ["run", "dev"], + cwd: "web", + }); + expect(scriptConfigFor([web], "dev", "web", "web")).toBe(web); + expect(scriptConfigFor([web], "dev", "web", null)).toBe(web); + }); + + it("falls back to npm run args in the same directory", () => { + const custom = cfg({ id: "user-cfg", name: "my dev", args: ["run", "dev"], cwd: "./web/" }); + expect(scriptConfigFor([custom], "dev", "web", null)).toBe(custom); + }); + + it("matches an unnamed nested member's overridden config by its full relative directory", () => { + const custom = cfg({ id: "npm:packages/web:dev", command: "custom", cwd: "packages/web" }); + expect(scriptConfigFor([custom], "dev", "packages/web", null)).toBe(custom); + }); + + it("prefers an id match over an args match", () => { + const byArgs = cfg({ id: "user-cfg", args: ["run", "dev"], cwd: null }); + expect(scriptConfigFor([byArgs, root], "dev", "", null)).toBe(root); + }); + + it("finds nothing in another directory", () => { + expect(scriptConfigFor([root], "dev", "web", null)).toBeUndefined(); + }); + + it("derives the backend configuration ids", () => { + expect(scriptConfigIds("dev", null, "")).toEqual(["npm:dev"]); + expect(scriptConfigIds("dev", "web", "packages/web")).toEqual(["npm:dev", "npm:web:dev", "npm:packages/web:dev"]); + }); +}); diff --git a/src/features/run/npmGutter.ts b/src/features/run/npmGutter.ts new file mode 100644 index 00000000..cf75cbb2 --- /dev/null +++ b/src/features/run/npmGutter.ts @@ -0,0 +1,392 @@ +/** + * Gutter ▶ icons on `package.json` `scripts` entries (like IDEA's package.json + * gutter): Run / Debug launch the npm run configuration the project detected + * for the script, or a temporary one when it has none. + */ +import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { tx } from "@/platform/i18n"; +import { call, commands, type RunConfig } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; +import { useProject } from "@/platform/project"; +import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui"; +import { loadConfigs, useRun } from "./store"; +import styles from "./Run.module.css"; + +/** One `scripts` entry: the script name and its 0-based line. */ +export interface ScriptEntry { + name: string; + line: number; +} + +/** + * The top-level `scripts` entries of `package.json` text, in document order + * (a small JSON scanner: script values may contain braces, colons, escapes). + * Entries parsed so far survive malformed text, so a trailing comma keeps + * earlier scripts visible. + */ +export function scriptEntries(text: string): ScriptEntry[] { + const entries: ScriptEntry[] = []; + let i = 0; + let line = 0; + const BROKEN = new Error("broken"); + + const ws = () => { + while (i < text.length) { + const c = text[i]!; + if (c === "\n") { + line++; + i++; + } else if (c === " " || c === "\t" || c === "\r" || c === "") { + i++; + } else { + break; + } + } + }; + /** The JSON string at `i`, with escapes; advances past it. */ + const str = (): string => { + if (text[i] !== '"') throw BROKEN; + i++; + let out = ""; + for (;;) { + const c = text[i]; + if (c === undefined) throw BROKEN; + if (c === '"') { + i++; + return out; + } + if (c === "\\") { + const e = text[i + 1]; + if (e === undefined) throw BROKEN; + if (e === "\n") line++; + if (e === "u") { + const hex = text.slice(i + 2, i + 6); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw BROKEN; + out += String.fromCodePoint(Number.parseInt(hex, 16)); + i += 6; + } else { + out += { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f" }[e] ?? e; + i += 2; + } + continue; + } + if (c === "\n") line++; + out += c; + i++; + } + }; + /** Skips one JSON value (objects and arrays recursively), tracking lines. */ + const value = () => { + ws(); + const c = text[i]; + if (c === '"') { + str(); + return; + } + if (c === "{" || c === "[") { + const close = c === "{" ? "}" : "]"; + i++; + ws(); + if (text[i] === close) { + i++; + return; + } + for (;;) { + if (c === "{") { + ws(); + str(); + ws(); + if (text[i] !== ":") throw BROKEN; + i++; + } + value(); + ws(); + if (text[i] === ",") { + i++; + continue; + } + if (text[i] === close) { + i++; + return; + } + throw BROKEN; + } + } + const m = /^(?:-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?|true|false|null)/.exec(text.slice(i)); + if (!m) throw BROKEN; + i += m[0].length; + }; + try { + ws(); + if (text[i] !== "{") return entries; + i++; + ws(); + if (text[i] !== "}") { + for (;;) { + ws(); + const key = str(); + ws(); + if (text[i] !== ":") break; + i++; + ws(); + if (key === "scripts" && text[i] === "{") { + i++; + ws(); + if (text[i] !== "}") { + for (;;) { + ws(); + const scriptLine = line; + const name = str(); + ws(); + if (text[i] !== ":") throw BROKEN; + i++; + value(); + entries.push({ name, line: scriptLine }); + ws(); + if (text[i] === ",") { + i++; + continue; + } + break; + } + } + i++; // the closing `}` of `scripts` + } else { + value(); + } + ws(); + if (text[i] === ",") { + i++; + continue; + } + break; + } + } + } catch (e) { + if (e !== BROKEN) throw e; + } + return entries; +} + +type PackageJson = { name?: unknown; packageManager?: unknown }; + +function safeParse(text: string): PackageJson | null { + try { + return JSON.parse(text) as PackageJson; + } catch { + return null; + } +} + +/** Same precedence as backend detection for a package: declaration, then lockfile. */ +export function scriptPackageManager(pkg: PackageJson | null, files: readonly string[]): string { + const pm = typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0] : ""; + if (pm === "pnpm" || pm === "yarn" || pm === "bun" || pm === "npm") return pm; + if (files.includes("pnpm-lock.yaml")) return "pnpm"; + if (files.includes("yarn.lock")) return "yarn"; + if (files.includes("bun.lock") || files.includes("bun.lockb")) return "bun"; + return "npm"; +} + +const normCwd = (cwd: string | null) => (cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); + +/** Backend ids for a script: the root package's, the member's by its `name` or its directory. */ +export function scriptConfigIds(name: string, pkgName: string | null, dir: string): string[] { + const ids = new Set([`npm:${name}`]); + if (pkgName) ids.add(`npm:${pkgName}:${name}`); + if (dir) ids.add(`npm:${dir}:${name}`); + return [...ids]; +} + +/** + * The project's configuration for the script in the package at `dir` (relative + * to the project root): the detected id first (a user override keeps it), then + * `npm run ` args in the same working directory. + */ +export function scriptConfigFor( + configs: readonly RunConfig[], + name: string, + dir: string, + pkgName: string | null, +): RunConfig | undefined { + const inDir = (c: RunConfig) => normCwd(c.cwd) === dir; + const ids = new Set(scriptConfigIds(name, pkgName, dir)); + return ( + configs.find((c) => inDir(c) && ids.has(c.id)) ?? + configs.find((c) => inDir(c) && c.kind === "npm" && c.args[0] === "run" && c.args[1] === name) + ); +} + +/** What the backend would detect (`detect.rs`): test scripts render results in the test tree. */ +const isTestScript = (name: string) => + name === "test" || name === "vitest" || name === "jest" || name.startsWith("test:"); + +export async function scriptMenuItems(entry: ScriptEntry, path: string, content: string): Promise { + const root = useProject.getState().project?.root ?? null; + const rel = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path; + const dir = rel.endsWith("/package.json") ? rel.slice(0, -"/package.json".length) : ""; + try { + // Detection reads the disk: a script typed moments ago is already there. + await loadConfigs(); + } catch { + // Fall back to a temporary configuration below. + } + const pkg = safeParse(content.slice(0, 400_000)); + const matched = scriptConfigFor( + useRun.getState().configs, + entry.name, + dir, + typeof pkg?.name === "string" ? pkg.name : null, + ); + // Reuse detection for sibling scripts, including a workspace's inherited manager. + const sibling = useRun + .getState() + .configs.find( + (c) => + c.kind === "npm" && c.detected && normCwd(c.cwd) === dir && ["npm", "pnpm", "yarn", "bun"].includes(c.command), + ); + let pm = matched?.command ?? sibling?.command; + if (!pm) { + // Workspace detection uses the root package's manager, even for a member's first script. + const rootPkg = + root && dir + ? await call(commands.fsReadText(`${root}/package.json`)) + .then(safeParse) + .catch(() => null) + : null; + const packageDir = rootPkg ? `${root}/` : path.slice(0, -"package.json".length); + const files = await call(commands.fsReadDir(packageDir)).catch(() => []); + pm = scriptPackageManager( + rootPkg ?? pkg, + files.filter((f) => f.kind === "file" || f.targetKind === "file").map((f) => f.name), + ); + } + const label = matched?.name ?? `${pm} run ${entry.name}`; + const config = matched ?? { + id: `context:npm:${dir || "root"}:${entry.name}`, + name: label, + kind: "npm", + command: pm, + args: ["run", entry.name], + cwd: dir || null, + env: {}, + detected: true, + isTest: isTestScript(entry.name), + debugAdapter: "js-debug", + }; + return [ + { + label: tx`Run '${label}'`, + icon: "run", + shortcut: shortcutLabel("run.runContext"), + run: () => + matched + ? void executeAction("run.runConfig", matched.id) + : void executeAction("run.runConfig", { config, temporary: true }), + }, + { + label: tx`Debug '${label}'`, + icon: "debug", + shortcut: shortcutLabel("run.debugContext"), + run: () => void executeAction("run.debugConfig", config), + }, + ]; +} + +interface Placed extends ScriptEntry { + pos: number; +} + +const setEntries = StateEffect.define(); + +const entriesField = StateField.define({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setEntries)) return e.value; + if (!tr.docChanged) return value; + return value.map((p) => ({ ...p, pos: tr.state.doc.lineAt(tr.changes.mapPos(p.pos, -1)).from })); + }, +}); + +class ScriptMarker extends GutterMarker { + constructor( + readonly entry: ScriptEntry, + readonly path: string, + ) { + super(); + } + override eq(other: GutterMarker): boolean { + return other instanceof ScriptMarker && other.entry.name === this.entry.name; + } + override toDOM(view: EditorView): Node { + const el = document.createElement("button"); + el.type = "button"; + el.className = styles.runMarker ?? ""; + el.title = tx`Run '${this.entry.name}'`; + el.setAttribute("aria-label", el.title); + el.innerHTML = gutterIconMarkup("gutter-run") ?? ""; + el.addEventListener("click", () => { + const rect = el.getBoundingClientRect(); + void scriptMenuItems(this.entry, this.path, view.state.doc.toString()).then((items) => + showContextMenu({ x: rect.left, y: rect.bottom + 2 }, items), + ); + }); + return el; + } +} + +const DEBOUNCE = 300; + +export function npmGutter(ctx: EditorDocContext): Extension { + const path = ctx.path; + if (ctx.virtual || !/(^|\/)package\.json$/.test(path) || !useProject.getState().project?.root) return []; + const detector = ViewPlugin.fromClass( + class { + timer: ReturnType | undefined; + constructor(readonly view: EditorView) { + this.schedule(0); + } + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(DEBOUNCE); + } + schedule(delay: number) { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + const view = this.view; + const entries = scriptEntries(view.state.doc.toString()) + .filter((e) => e.line < view.state.doc.lines) + .map((e) => ({ ...e, pos: view.state.doc.line(e.line + 1).from })); + view.dispatch({ effects: setEntries.of(entries) }); + }, delay); + } + destroy() { + clearTimeout(this.timer); + } + }, + ); + return [ + entriesField, + detector, + gutter({ + // Shares the run gutter's class so "Show run icons" hides these too. + class: `${styles.runGutter} cm-openidea-gutter-run`, + markers: (view) => + RangeSet.of( + view.state.field(entriesField).map((e) => new ScriptMarker(e, path).range(e.pos)), + true, + ), + initialSpacer: () => new ScriptMarker({ name: "", line: 0 }, path), + domEventHandlers: { + mousedown: (view, block, event) => { + const entry = view.state.field(entriesField).find((e) => e.pos === block.from); + if (!entry || (event as MouseEvent).button !== 0) return false; + event.preventDefault(); + return true; + }, + }, + }), + ]; +} From db5251efe645b8e4136ac35554d8cf760c356aac Mon Sep 17 00:00:00 2001 From: Florent V Date: Mon, 2 Nov 2026 18:44:28 +0100 Subject: [PATCH 061/108] fix(plugins): install plugin packages from a .zip (#896) Root cause: Install Plugin from Disk only opened a folder picker and the host could only copy folders, so a zipped plugin could not be installed. The picker now accepts a .zip or a package's plugin.toml, and the host's install_from_path unpacks a zip to a temporary folder and runs it through the existing install_from_dir validation. The package may sit at the archive root or in its only folder (archiver metadata such as __MACOSX is ignored). Archives with unsafe paths or symbolic links, more than 10,000 entries or over 1 GB unpacked are refused, and a zip without plugin.toml (e.g. an IntelliJ plugin) gets a clear error. Fixes #892 Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com> --- Cargo.lock | 1 + crates/ide-plugin/Cargo.toml | 5 +- crates/ide-plugin/src/lib.rs | 15 +++++ crates/ide-plugin/src/package.rs | 74 +++++++++++++++++++++++++ crates/ide-plugin/src/types.rs | 2 +- crates/ide-plugin/tests/host.rs | 91 +++++++++++++++++++++++++++++++ docs/plugins/themes.md | 4 +- src-tauri/src/commands/plugin.rs | 4 +- src/bindings.ts | 4 +- src/features/plugins/backend.ts | 14 +++-- src/features/plugins/store.ts | 12 ++-- src/platform/locales/text/zh.json | 3 +- 12 files changed, 208 insertions(+), 21 deletions(-) create mode 100644 crates/ide-plugin/src/package.rs diff --git a/Cargo.lock b/Cargo.lock index 56a55e9b..a55ceac5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4428,6 +4428,7 @@ dependencies = [ "tracing", "wasmtime", "wasmtime-wasi", + "zip", ] [[package]] diff --git a/crates/ide-plugin/Cargo.toml b/crates/ide-plugin/Cargo.toml index 06b54ab5..d6bd3055 100644 --- a/crates/ide-plugin/Cargo.toml +++ b/crates/ide-plugin/Cargo.toml @@ -15,8 +15,7 @@ toml.workspace = true thiserror.workspace = true tracing.workspace = true parking_lot.workspace = true +tempfile.workspace = true +zip.workspace = true wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation"] } wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] } - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 72446576..dc6a3ed4 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -15,6 +15,7 @@ //! (dev plugins, loaded by [`PluginHost::sync_project`]). mod manifest; +mod package; mod runtime; mod sandbox; mod theme; @@ -263,6 +264,20 @@ impl PluginHost { self.info_of(&new_id) } + /// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. + pub fn install_from_path(&self, path: &Path) -> IdeResult { + if package::is_zip(path) && path.is_file() { + // Outside the plugins directory: a crash must not leave a package that loads. + let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?; + let root = package::unpack_zip(path, unpacked.path())?; + return self.install_from_dir(&root); + } + if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() { + return self.install_from_dir(path.parent().unwrap_or(path)); + } + self.install_from_dir(path) + } + /// Copies a plugin package (manifest + referenced files) into the plugins /// directory, replacing an installed version with the same id. pub fn install_from_dir(&self, src: &Path) -> IdeResult { diff --git a/crates/ide-plugin/src/package.rs b/crates/ide-plugin/src/package.rs new file mode 100644 index 00000000..cefb3fa9 --- /dev/null +++ b/crates/ide-plugin/src/package.rs @@ -0,0 +1,74 @@ +//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder. + +use crate::MANIFEST_FILE; +use ide_core::{IdeError, IdeResult}; +use std::fs::{self, File}; +use std::io::{self, BufReader, Read}; +use std::path::{Path, PathBuf}; + +/// Far above any real package; stops a zip bomb before it fills the disk. +const MAX_ENTRIES: usize = 10_000; +const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024; + +pub(crate) fn is_zip(path: &Path) -> bool { + path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip")) +} + +fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError { + IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display())) +} + +/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the +/// whole archive when an entry would leave `dest` or is a symbolic link. +pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult { + let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?; + if archive.len() > MAX_ENTRIES { + return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries"))); + } + let mut budget = MAX_UNPACKED_BYTES; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?; + let name = entry.name().to_owned(); + let Some(rel) = entry.enclosed_name() else { + return Err(invalid_zip(zip, format!("unsafe path {name:?}"))); + }; + if entry.is_symlink() { + return Err(invalid_zip(zip, format!("symbolic link {name:?}"))); + } + let out = dest.join(rel); + if entry.is_dir() { + fs::create_dir_all(&out)?; + continue; + } + if let Some(parent) = out.parent() { + fs::create_dir_all(parent)?; + } + // One byte over the budget tells a full budget from a file that exactly fits. + let written = + io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?; + if written > budget { + return Err(invalid_zip(zip, "it unpacks to more than 1 GB")); + } + budget -= written; + } + package_root(dest).ok_or_else(|| { + IdeError::invalid(format!( + "{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", + zip.display() + )) + }) +} + +/// `dir` itself, or its only folder as when a zip holds the package folder. +/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder. +fn package_root(dir: &Path) -> Option { + if dir.join(MANIFEST_FILE).is_file() { + return Some(dir.to_path_buf()); + } + let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + p.is_dir() && !name.starts_with('.') && name != "__MACOSX" + }); + let only = folders.next().filter(|_| folders.next().is_none())?; + only.join(MANIFEST_FILE).is_file().then_some(only) +} diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index be25b241..47890f3d 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -89,7 +89,7 @@ pub struct CatalogPlugin { pub authors: Vec, pub homepage: Option, pub change_notes: Option, - /// Package directory; install with `install_from_dir(path)`. + /// Package directory; install with `install_from_path(path)`. pub path: ide_core::IpcPath, pub has_code: bool, pub permissions: Vec, diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs index a644cca9..2c2572c0 100644 --- a/crates/ide-plugin/tests/host.rs +++ b/crates/ide-plugin/tests/host.rs @@ -8,6 +8,7 @@ use ide_plugin::*; use parking_lot::Mutex; +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -510,3 +511,93 @@ fn restricted_dev_plugin_has_no_activation_registration() { f.host.sync_project(&f.project); assert!(f.host.list(Some(&f.project)).plugins.is_empty()); } + +/// Zips the files of `dir` under `prefix` (recursively). +fn add_dir(zip: &mut zip::ZipWriter, dir: &Path, prefix: &str) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let name = format!("{prefix}{}", entry.file_name().to_string_lossy()); + if entry.path().is_dir() { + add_dir(zip, &entry.path(), &format!("{name}/")); + } else { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(&std::fs::read(entry.path()).unwrap()).unwrap(); + } + } +} + +fn zip_of(dir: &Path, prefix: &str, out: &Path) -> PathBuf { + let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap()); + add_dir(&mut zip, dir, prefix); + zip.finish().unwrap(); + out.to_path_buf() +} + +#[test] +fn installs_zips_with_the_package_at_the_root_or_in_one_folder() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + for (name, prefix) in [("root.ZIP", ""), ("folder.zip", "hello-1.0/")] { + let zip = zip_of(&sample("hello"), prefix, &tmp.path().join(name)); + let info = f.host.install_from_path(&zip).unwrap(); + assert_eq!((info.id.as_str(), info.status), ("hello", PluginStatus::Active), "{:?}", info.error); + assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file()); + } + // Still runs from the installed copy, not the unpacked archive. + assert!(f.run("hello", "insert-date", None).unwrap().is_some()); +} + +#[test] +fn installs_from_a_manifest_or_a_folder() { + let f = fixture(Limits::default()); + assert_eq!(f.host.install_from_path(&sample("hello").join("plugin.toml")).unwrap().id, "hello"); + assert_eq!(f.host.install_from_path(&sample("hello")).unwrap().id, "hello"); +} + +#[test] +fn ignores_archiver_metadata_next_to_the_package_folder() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let out = tmp.path().join("mac.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap()); + add_dir(&mut zip, &sample("hello"), "hello/"); + zip.start_file("__MACOSX/hello/._plugin.toml", zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(b"metadata").unwrap(); + zip.finish().unwrap(); + assert_eq!(f.host.install_from_path(&out).unwrap().id, "hello"); +} + +#[test] +fn refuses_zips_that_are_not_safe_plugin_packages() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let options = zip::write::SimpleFileOptions::default; + + let no_manifest = tmp.path().join("idea-plugin.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&no_manifest).unwrap()); + zip.start_file("idea-plugin/lib/plugin.jar", options()).unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&no_manifest).unwrap_err(); + assert!(error.message.contains("no plugin.toml"), "{}", error.message); + + let escape = tmp.path().join("escape.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&escape).unwrap()); + zip.start_file("../../evil.txt", options()).unwrap(); + zip.write_all(b"evil").unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&escape).unwrap_err(); + assert!(error.message.contains("unsafe path"), "{}", error.message); + + let link = tmp.path().join("link.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&link).unwrap()); + zip.add_symlink("plugin.toml", "/etc/passwd", options()).unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&link).unwrap_err(); + assert!(error.message.contains("symbolic link"), "{}", error.message); + + let garbage = tmp.path().join("garbage.zip"); + std::fs::write(&garbage, "not a zip").unwrap(); + let error = f.host.install_from_path(&garbage).unwrap_err(); + assert!(error.message.contains("not a valid plugin archive"), "{}", error.message); + + assert!(!f.plugins_dir.join("hello").exists()); +} diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 6b038fc5..013041bf 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -109,8 +109,8 @@ There is no build step. The package *is* the folder. 1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files. 2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds). 3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the - *Install Plugin from Disk…* action) and pick the package folder. The - manifest and the files it references are copied to + *Install Plugin from Disk…* action) and pick the package's `plugin.toml`, or + a `.zip` of the folder. The manifest and the files it references are copied to `/plugins//`. 4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next to the theme. *Use Default Theme* switches back. diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 0f94dc2a..6c91ad07 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -91,12 +91,12 @@ pub async fn plugin_set_enabled(state: State<'_, AppState>, id: String, enabled: blocking(move || host.set_enabled(&id, enabled)).await } -/// Installs the plugin package in `path` (a folder with `plugin.toml`). +/// Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. #[tauri::command] #[specta::specta] pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf) -> IdeResult { let host = state.plugins.clone(); - blocking(move || host.install_from_dir(&path)).await + blocking(move || host.install_from_path(&path)).await } /// Plugin packages available locally for installation: `/plugins` diff --git a/src/bindings.ts b/src/bindings.ts index 4f191324..6d71d1a3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -853,7 +853,7 @@ export const commands = { */ pluginList: () => typedError(__TAURI_INVOKE("plugin_list")), pluginSetEnabled: (id: string, enabled: boolean) => typedError(__TAURI_INVOKE("plugin_set_enabled", { id, enabled })), - /** Installs the plugin package in `path` (a folder with `plugin.toml`). */ + /** Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. */ pluginInstallDir: (path: string) => typedError(__TAURI_INVOKE("plugin_install_dir", { path })), /** * Plugin packages available locally for installation: `/plugins` @@ -1688,7 +1688,7 @@ export type CatalogPlugin = { authors: string[], homepage: string | null, changeNotes: string | null, - /** Package directory; install with `install_from_dir(path)`. */ + /** Package directory; install with `install_from_path(path)`. */ path: IpcPath, hasCode: boolean, permissions: Permission[], diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index d3a4b131..4030550d 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -5,6 +5,7 @@ */ import { Channel } from "@tauri-apps/api/core"; import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } from "@/bindings"; +import { tx } from "@/platform/i18n"; import { call, commands, IpcError, isTauri } from "@/platform/ipc"; export interface PluginsBackend { @@ -21,7 +22,8 @@ export interface PluginsBackend { /** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */ setThemeFrame(color: string | null): Promise; /** Folder picker; `null` when cancelled. */ - pickFolder(): Promise; + /** A package to install: a `.zip`, or the `plugin.toml` of a folder. */ + pickPackage(): Promise; /** Subscribes to backend plugin events (this window only). */ onEvent(handler: (e: PluginEvent) => void): () => void; } @@ -45,9 +47,13 @@ export const tauriPluginsBackend: PluginsBackend = { setThemeFrame: async (color) => { await call(commands.pluginSetThemeFrame(color)); }, - pickFolder: async () => { + pickPackage: async () => { const { open } = await import("@tauri-apps/plugin-dialog"); - const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" }); + const picked = await open({ + multiple: false, + title: tx("Install Plugin from Disk"), + filters: [{ name: tx("Plugin package"), extensions: ["zip", "toml"] }], + }); return (Array.isArray(picked) ? picked[0] : picked) ?? null; }, onEvent: (handler) => { @@ -247,7 +253,7 @@ export function createMemoryPluginsBackend( return t; }, setThemeFrame: async () => {}, - pickFolder: async () => null, + pickPackage: async () => null, onEvent: (handler) => { listeners.add(handler); return () => void listeners.delete(handler); diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts index 01913365..08ec18a6 100644 --- a/src/features/plugins/store.ts +++ b/src/features/plugins/store.ts @@ -124,25 +124,25 @@ export function compareVersions(a: string, b: string): number { return 0; } -/** Asks for a plugin folder (containing plugin.toml) and installs it. Returns the new plugin id. */ +/** Asks for a plugin package (a `.zip` or a `plugin.toml`) and installs it. Returns the new plugin id. */ export async function installFromDisk(path?: string): Promise { const backend = pluginsBackend(); - let dir: string | null = path ?? null; + let source: string | null = path ?? null; try { - dir ??= await backend.pickFolder(); + source ??= await backend.pickPackage(); } catch (e) { notifications().notify({ group: "plugins", level: "error", - title: "Could not open the folder picker", + title: "Could not open the file picker", body: errorText(e), }); return null; } - if (!dir) return null; + if (!source) return null; usePlugins.setState({ installing: true }); try { - const info = await backend.installDir(dir); + const info = await backend.installDir(source); notifications().notify({ group: "plugins", level: info.status === "failed" ? "warning" : "info", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ed9b9131..048353eb 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -523,7 +523,8 @@ "Could not install plugin": "无法安装插件", "Could not invalidate caches": "无法清除缓存", "Could not open project": "无法打开项目", - "Could not open the folder picker": "无法打开文件夹选择器", + "Could not open the file picker": "无法打开文件选择器", + "Plugin package": "插件包", "Could not reach GitHub": "无法连接到 GitHub", "Could not save": "无法保存", "Coverage": "覆盖率", From 7af2b098cc3910d349c22180b96769abe53decda Mon Sep 17 00:00:00 2001 From: kkmihai Date: Mon, 2 Nov 2026 20:09:17 +0200 Subject: [PATCH 062/108] feat(terminal): paste clipboard media as file paths (#880) Pasting an image, audio or video from the clipboard into a local terminal saves the bytes to the per-user terminal cache (20 MB limit, files older than 24 h cleaned up on the next paste, private permissions) and inserts the shell-quoted path without running anything. Copied files paste their original paths, plain text pastes unchanged, and SSH sessions keep the existing remote image-paste route. Paths are quoted per shell (zsh/bash, fish, PowerShell, cmd.exe); fish quoting now escapes backslashes and single quotes. Fixes #838 Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com> --- crates/ide-host/src/ops/term/images.rs | 367 +++++++++++++++++++++-- crates/ide-host/tests/terminal_images.rs | 15 +- e2e/terminal-clipboard.spec.ts | 47 +++ src/features/terminal/paste.test.ts | 173 ++++++++++- src/features/terminal/paste.ts | 114 +++++-- src/features/terminal/session.ts | 14 +- src/platform/clipboard.ts | 13 +- src/platform/locales/text/zh.json | 6 +- 8 files changed, 670 insertions(+), 79 deletions(-) create mode 100644 e2e/terminal-clipboard.spec.ts diff --git a/crates/ide-host/src/ops/term/images.rs b/crates/ide-host/src/ops/term/images.rs index 6a45926f..e0c3261d 100644 --- a/crates/ide-host/src/ops/term/images.rs +++ b/crates/ide-host/src/ops/term/images.rs @@ -1,6 +1,61 @@ use ide_core::{IdeError, IdeResult}; use std::path::Path; +const LIFETIME: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); +const DIRECTORY: &str = "terminal-images"; + +fn extension(mime: &str) -> IdeResult<&str> { + match mime { + "image/png" => Ok("png"), + "image/jpeg" => Ok("jpg"), + "image/gif" => Ok("gif"), + "image/webp" => Ok("webp"), + "image/bmp" => Ok("bmp"), + "image/svg+xml" => Ok("svg"), + "image/tiff" => Ok("tiff"), + "image/x-icon" => Ok("ico"), + "audio/wav" | "audio/x-wav" => Ok("wav"), + "audio/mpeg" => Ok("mp3"), + "audio/mp4" => Ok("m4a"), + "audio/ogg" => Ok("ogg"), + "audio/webm" | "video/webm" => Ok("webm"), + "audio/flac" => Ok("flac"), + "audio/aac" => Ok("aac"), + "video/mp4" => Ok("mp4"), + "video/ogg" => Ok("ogv"), + "video/quicktime" => Ok("mov"), + _ => Err(IdeError::invalid("unsupported clipboard media type")), + } +} + +fn owned_name(name: &str) -> bool { + let name = name.strip_prefix(".partial-").unwrap_or(name); + let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false }; + id.len() == 32 + && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + && matches!( + ext, + "png" + | "jpg" + | "gif" + | "webp" + | "bmp" + | "svg" + | "tiff" + | "ico" + | "wav" + | "mp3" + | "m4a" + | "ogg" + | "webm" + | "flac" + | "aac" + | "mp4" + | "ogv" + | "mov" + ) +} + #[cfg(unix)] mod unix { use super::*; @@ -8,24 +63,7 @@ mod unix { use std::fs::File; use std::io::{self, Write}; use std::os::unix::fs::MetadataExt; - use std::time::{Duration, SystemTime}; - - const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); - const DIRECTORY: &str = "terminal-images"; - - fn extension(mime: &str) -> IdeResult<&str> { - match mime { - "image/png" => Ok("png"), - "image/jpeg" => Ok("jpg"), - "image/gif" => Ok("gif"), - "image/webp" => Ok("webp"), - "image/bmp" => Ok("bmp"), - "image/svg+xml" => Ok("svg"), - "image/tiff" => Ok("tiff"), - "image/x-icon" => Ok("ico"), - _ => Err(IdeError::invalid("unsupported image type")), - } - } + use std::time::SystemTime; fn open_directory(data_dir: &Path) -> io::Result { if !data_dir.is_absolute() { @@ -55,14 +93,6 @@ mod unix { Ok(dir) } - fn owned_name(name: &str) -> bool { - let name = name.strip_prefix(".partial-").unwrap_or(name); - let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false }; - id.len() == 32 - && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) - && matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico") - } - fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> { for entry in Dir::read_from(dir)? { let entry = entry?; @@ -140,6 +170,7 @@ mod unix { use super::*; use std::fs; use std::os::unix::fs::{PermissionsExt, symlink}; + use std::time::Duration; #[test] fn stores_bytes_with_private_modes_and_unpredictable_names() { @@ -211,10 +242,292 @@ mod unix { } } +#[cfg(windows)] +mod windows { + use super::*; + use std::fs::{self, File, OpenOptions}; + use std::io::{self, Write}; + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + use std::time::SystemTime; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + + fn open_directory(path: &Path) -> io::Result { + if !path.is_absolute() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute")); + } + let dir = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let meta = dir.metadata()?; + if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "terminal media directory must not be a reparse point", + )); + } + Ok(dir) + } + + fn cleanup(path: &Path, now: SystemTime) -> io::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + if !entry.file_name().to_str().is_some_and(owned_name) { + continue; + } + let file = match OpenOptions::new().read(true).custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).open(entry.path()) + { + Ok(file) => file, + Err(_) => continue, + }; + let meta = file.metadata()?; + if meta.is_file() + && meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 + && meta + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= LIFETIME) + { + match fs::remove_file(entry.path()) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::NotFound => (), + Err(error) => return Err(error), + } + } + } + Ok(()) + } + + fn store_with( + data_dir: &Path, + data: &[u8], + mime: &str, + now: SystemTime, + write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>, + ) -> IdeResult { + let ext = extension(mime)?; + let _parent = open_directory(data_dir)?; + let path = data_dir.join(DIRECTORY); + match fs::create_dir(&path) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (), + Err(error) => return Err(error.into()), + } + let _dir = open_directory(&path)?; + cleanup(&path, now)?; + let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext); + let target = path.join(&name); + let temporary = path.join(format!(".partial-{name}")); + let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?; + let result = write(&mut file, data); + drop(file); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + if let Err(error) = fs::hard_link(&temporary, &target) { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + let _ = fs::remove_file(&temporary); + Ok(target.to_string_lossy().into_owned()) + } + + pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult { + store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes)) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::time::Duration; + + fn junction(path: &Path, target: &Path) { + let output = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:OPENIDEA_TEST_JUNCTION -Target $env:OPENIDEA_TEST_TARGET -ErrorAction Stop | Out-Null", + ]) + .env("OPENIDEA_TEST_JUNCTION", path) + .env("OPENIDEA_TEST_TARGET", target) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + } + + #[test] + fn rejects_reparse_data_and_storage_directories() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let link = root.path().join("data"); + junction(&link, outside.path()); + assert!(store(&link, b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + fs::remove_dir(&link).unwrap(); + let link = root.path().join(DIRECTORY); + junction(&link, outside.path()); + assert!(store(root.path(), b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + fs::remove_dir(&link).unwrap(); + } + + #[test] + fn expiry_only_removes_owned_regular_media_at_boundary() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + fs::create_dir(&path).unwrap(); + let _dir = open_directory(&path).unwrap(); + let now = SystemTime::now(); + let names = [ + "image-00000000000000000000000000000001.png", + "image-00000000000000000000000000000002.mp3", + ".partial-image-00000000000000000000000000000003.mp4", + "image-00000000000000000000000000000004.webm", + "image-00000000000000000000000000000005.wav", + "unrelated.png", + "image-00000000000000000000000000000006.exe", + ]; + for (name, time) in names.iter().zip([ + now - LIFETIME, + now - LIFETIME, + now - LIFETIME, + now - LIFETIME + Duration::from_secs(1), + now + Duration::from_secs(1), + now - LIFETIME, + now - LIFETIME, + ]) { + let file = File::create(path.join(name)).unwrap(); + file.set_times(fs::FileTimes::new().set_modified(time)).unwrap(); + } + let link = path.join("image-00000000000000000000000000000007.png"); + junction(&link, outside.path()); + let directory = path.join("image-00000000000000000000000000000008.png"); + fs::create_dir(&directory).unwrap(); + cleanup(&path, now).unwrap(); + for name in &names[..3] { + assert!(!path.join(name).exists()); + } + for name in &names[3..] { + assert!(path.join(name).exists()); + } + assert!(directory.is_dir()); + assert!(fs::symlink_metadata(&link).unwrap().file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0); + fs::remove_dir(link).unwrap(); + } + + #[test] + fn failed_write_removes_partial_file() { + let root = tempfile::tempdir().unwrap(); + let result = store_with(root.path(), b"partial", "audio/ogg", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + Err(io::Error::other("simulated failure")) + }); + assert!(result.is_err()); + assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0); + } + + #[test] + fn publication_preserves_existing_target() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + let result = store_with(root.path(), b"new", "video/mp4", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + let temporary = fs::read_dir(&path)?.next().unwrap()?.file_name(); + let target = temporary.to_str().unwrap().strip_prefix(".partial-").unwrap(); + fs::write(path.join(target), b"existing") + }); + assert!(result.is_err()); + let files: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(files.len(), 1); + assert_eq!(fs::read(&files[0]).unwrap(), b"existing"); + assert!(!files[0].file_name().unwrap().to_str().unwrap().starts_with(".partial-")); + } + + #[test] + fn storage_handles_prevent_directory_replacement() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + let result = store_with(root.path(), b"x", "image/png", SystemTime::now(), |file, bytes| { + assert!(fs::rename(&path, root.path().join("replaced")).is_err()); + file.write_all(bytes) + }); + assert_eq!(fs::read(result.unwrap()).unwrap(), b"x"); + } + } +} + #[cfg(unix)] pub(super) use unix::store; -#[cfg(not(unix))] +#[cfg(windows)] +pub(super) use windows::store; + +#[cfg(not(any(unix, windows)))] pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult { Err(IdeError::internal("private terminal image storage is unsupported on this platform")) } + +#[cfg(all(test, any(unix, windows)))] +mod tests { + use super::*; + + #[test] + fn stores_supported_media_with_expected_extensions_and_unique_names() { + let root = tempfile::tempdir().unwrap(); + let types = [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/gif", "gif"), + ("image/webp", "webp"), + ("image/bmp", "bmp"), + ("image/svg+xml", "svg"), + ("image/tiff", "tiff"), + ("image/x-icon", "ico"), + ("audio/wav", "wav"), + ("audio/x-wav", "wav"), + ("audio/mpeg", "mp3"), + ("audio/mp4", "m4a"), + ("audio/ogg", "ogg"), + ("audio/webm", "webm"), + ("audio/flac", "flac"), + ("audio/aac", "aac"), + ("video/mp4", "mp4"), + ("video/webm", "webm"), + ("video/ogg", "ogv"), + ("video/quicktime", "mov"), + ]; + let mut paths = std::collections::HashSet::new(); + for (mime, ext) in types { + let path = store(root.path(), mime.as_bytes(), mime).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), mime.as_bytes()); + assert_eq!(Path::new(&path).extension().unwrap(), ext); + assert!(owned_name(Path::new(&path).file_name().unwrap().to_str().unwrap())); + assert!(paths.insert(path)); + } + assert_eq!(std::fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), types.len()); + } + + #[test] + fn rejects_unsupported_mime_before_creating_storage() { + let root = tempfile::tempdir().unwrap(); + for mime in ["text/plain", "application/octet-stream", "image/unknown", "audio/unknown", "video/unknown", ""] { + let error = store(root.path(), b"x", mime).unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + assert_eq!(error.message, "unsupported clipboard media type"); + } + assert!(!root.path().join(DIRECTORY).exists()); + } + + #[test] + fn rejects_relative_data_directory() { + assert!(store(Path::new("relative-data"), b"x", "image/png").is_err()); + } +} diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs index 8bb4a3e1..014d1430 100644 --- a/crates/ide-host/tests/terminal_images.rs +++ b/crates/ide-host/tests/terminal_images.rs @@ -1,4 +1,4 @@ -#![cfg(unix)] +#![cfg(any(unix, windows))] use ide_core::{NullSink, TerminalId}; use ide_host::{Host, HostConfig, ops}; @@ -28,8 +28,8 @@ async fn storage_requires_a_registered_terminal() { .spawn( TerminalOptions { cwd: root.path().into(), - shell: Some("/bin/sh".into()), - args: vec![], + shell: Some(if cfg!(windows) { "powershell.exe" } else { "/bin/sh" }.into()), + args: if cfg!(windows) { vec!["-NoLogo".into(), "-NoProfile".into()] } else { vec![] }, env: Default::default(), cols: 80, rows: 24, @@ -40,9 +40,12 @@ async fn storage_requires_a_registered_terminal() { Box::new(|_| {}), ) .unwrap(); - let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap(); - assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + for mime in ["image/png", "audio/mpeg", "video/mp4"] { + let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], mime.into()).await.unwrap(); + assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + } + assert!(ops::term::store_image(&host, terminal.id, vec![1], "application/octet-stream".into()).await.is_err()); host.terminals.kill(terminal.id).unwrap(); assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err()); - assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 3); } diff --git a/e2e/terminal-clipboard.spec.ts b/e2e/terminal-clipboard.spec.ts new file mode 100644 index 00000000..1127762f --- /dev/null +++ b/e2e/terminal-clipboard.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; + +test("local terminal stores clipboard media and pastes one shell-quoted argument", async ({ page }) => { + await page.goto("/"); + const observed = await page.evaluate(async () => { + const ipcPath = "/src/platform/ipc.ts"; + const sessionPath = "/src/features/terminal/session.ts"; + const { commands } = await import(ipcPath); + const { TerminalSession } = await import(sessionPath); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { transformCallback: () => 1 }, + }); + const stored: { id: number; data: number[]; mime: string }[] = []; + const writes: string[] = []; + commands.termSpawn = async () => ({ status: "ok", data: { id: 838, title: "pwsh", pid: 1 } }); + commands.clipboardFileList = async () => ({ status: "ok", data: [] }); + commands.termStoreImage = async (id: number, data: number[], mime: string) => { + stored.push({ id, data, mime }); + return { status: "ok", data: "C:\\Temp\\a'b c\\image.png" }; + }; + commands.termWrite = async (_id: number, data: string) => writes.push(data); + commands.termResize = async () => undefined; + commands.termKill = async () => undefined; + const container = document.createElement("div"); + container.style.cssText = "position:fixed;inset:0;z-index:99999"; + document.body.append(container); + const session = new TerminalSession({ key: "clipboard", cwd: "C:/Temp", onChange() {}, onFocus() {} }); + try { + session.attach(container); + await session.start(); + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" })); + const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer }); + container.querySelector("textarea")!.dispatchEvent(event); + const deadline = Date.now() + 5000; + while (!writes.length && Date.now() < deadline) await new Promise((resolve) => requestAnimationFrame(resolve)); + return { stored, writes, claimed: event.defaultPrevented }; + } finally { + session.dispose(); + container.remove(); + } + }); + expect(observed.claimed).toBe(true); + expect(observed.stored).toEqual([{ id: 838, data: [137, 80, 78, 71], mime: "image/png" }]); + expect(observed.writes).toEqual(["'C:\\Temp\\a''b c\\image.png'"]); +}); diff --git a/src/features/terminal/paste.test.ts b/src/features/terminal/paste.test.ts index 726f1c8b..bb01772b 100644 --- a/src/features/terminal/paste.test.ts +++ b/src/features/terminal/paste.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { Terminal } from "@xterm/xterm"; import { commands } from "@/platform/ipc"; -import { installTerminalPaste, pasteTerminalClipboard, type RemotePasteTarget } from "./paste"; +import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste"; const notify = vi.hoisted(() => vi.fn()); vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify }) })); @@ -98,8 +98,8 @@ function imageEvent(blob: Blob, text = "") { return event; } -function remoteTarget(current = () => true): RemotePasteTarget { - return { id: 42 as RemotePasteTarget["id"], supported: true, current }; +function remoteTarget(current = () => true): TerminalPasteTarget { + return { id: 42 as TerminalPasteTarget["id"], supported: true, remote: true, shell: "sh", current }; } it.each(["event", "menu"])("rejects oversized %s images before reading or uploading", async (route) => { @@ -123,8 +123,8 @@ it.each(["event", "menu"])("rejects oversized %s images before reading or upload await vi.waitFor(() => expect(notify).toHaveBeenCalledWith({ level: "error", - title: "Cannot paste image", - body: "Image is too large to paste (limit 20 MB).", + title: "Cannot paste clipboard media", + body: "Clipboard media is too large to paste (limit 20 MB).", }), ); expect(read).not.toHaveBeenCalled(); @@ -362,3 +362,166 @@ it("leaves local image and remote text events to existing paste handlers", () => expect(store).not.toHaveBeenCalled(); local(); }); + +function localTarget(shell = "sh", current = () => true): TerminalPasteTarget { + return { ...remoteTarget(current), remote: false, shell }; +} + +it.each(["event", "menu"])("stores raw local clipboard media through %s paste", async (route) => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/tmp/a'b c.png" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + if (route === "event") { + host.addEventListener("paste", () => paste("xterm")); + const event = imageEvent(image()); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } else { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType: async () => image() }] }, + }); + await pasteTerminalClipboard(paste, () => localTarget()); + } + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/a'\\''b c.png'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(notify).not.toHaveBeenCalled(); + remove(); +}); + +it.each(["event", "menu"])( + "keeps existing copied files on their original quoted paths during %s paste", + async (route) => { + const paths = ["/a b/photo.png", "/it's here/clip.mp4"]; + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: paths }); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + if (route === "event") host.dispatchEvent(imageEvent(image(), paths.join("\n"))); + else { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + readText: async () => paths.join("\r\n"), + read: async () => [{ types: ["image/png"], getType: async () => image() }], + }, + }); + await pasteTerminalClipboard(paste, () => localTarget()); + } + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/a b/photo.png' '/it'\\''s here/clip.mp4'")); + expect(store).not.toHaveBeenCalled(); + remove(); + }, +); + +it("reads file URI lists without relying on a native file-list backend", async () => { + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + files: [], + types: ["text/uri-list"], + getData: (type: string) => + type === "text/uri-list" + ? "# files\r\nfile:///tmp/a%20b.png\r\nhttps://example.com\r\nfile:///tmp/%E4%B8%AD%E6%96%87.mp4" + : "", + }, + }); + host.dispatchEvent(event); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/a b.png' '/tmp/中文.mp4'")); + expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it.each([ + { shell: "pwsh", path: "C:\\Users\\a'b $c\\image.png", expected: "'C:\\Users\\a''b $c\\image.png'" }, + { shell: "powershell", path: "C:\\a b\\image.png", expected: "'C:\\a b\\image.png'" }, + { shell: "zsh", path: "/tmp/a'b $中文.png", expected: "'/tmp/a'\\''b $中文.png'" }, + { shell: "bash", path: "/tmp/a'b $中文.png", expected: "'/tmp/a'\\''b $中文.png'" }, + { shell: "fish", path: "/tmp/a'b $中文.png", expected: "'/tmp/a\\'b $中文.png'" }, + { shell: "fish", path: "/tmp/a\\\\b\\'中文.png", expected: "'/tmp/a\\\\\\\\b\\\\\\'中文.png'" }, + { shell: "cmd", path: "C:\\a&b\\image.png", expected: '"C:\\a&b\\image.png"' }, + { shell: "cmd.exe", path: "C:\\a b $中文\\image.png", expected: '"C:\\a b $中文\\image.png"' }, +])("quotes a stored path for $shell", async ({ shell, path, expected }) => { + vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: path }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget(shell)); + host.dispatchEvent(imageEvent(image())); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith(expected)); + remove(); +}); + +it.each(["audio/wav", "video/mp4"])("stores raw %s clipboard data", async (type) => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/tmp/media" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + const event = imageEvent({ ...image(), type } as Blob); + host.dispatchEvent(event); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/media'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], type); + remove(); +}); + +it("ignores local media when its terminal is replaced during file recovery", async () => { + let current = true; + let finish!: (value: { status: "ok"; data: string[] }) => void; + vi.spyOn(commands, "clipboardFileList").mockImplementation(() => new Promise((resolve) => (finish = resolve))); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget("sh", () => current)); + host.dispatchEvent(imageEvent(image())); + await vi.waitFor(() => expect(finish).toBeDefined()); + current = false; + finish({ status: "ok", data: [] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(store).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + remove(); +}); + +it.each(["event", "menu"])("rejects cmd expansion in copied file paths through %s paste", async (route) => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["C:\\clips\\%OS%!clip!.png"] }); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget("cmd")); + if (route === "event") host.dispatchEvent(imageEvent(image())); + else { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } }); + await pasteTerminalClipboard(paste, () => localTarget("cmd")); + } + await vi.waitFor(() => + expect(notify).toHaveBeenCalledWith({ + level: "error", + title: "Cannot paste clipboard media", + body: "Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.", + }), + ); + expect(paste).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it("recovers quoted local file paths when clipboard text permission is denied", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["C:\\a b\\image.png"] }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + readText: async () => { + throw new Error("denied"); + }, + }, + }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => localTarget("pwsh")); + expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'"); + expect(notify).not.toHaveBeenCalled(); +}); diff --git a/src/features/terminal/paste.ts b/src/features/terminal/paste.ts index c0dd74e2..5029cd48 100644 --- a/src/features/terminal/paste.ts +++ b/src/features/terminal/paste.ts @@ -3,31 +3,75 @@ import { call, commands, type TerminalId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { notifications } from "@/workbench/contributions"; -export interface RemotePasteTarget { +export interface TerminalPasteTarget { id: TerminalId; supported: boolean; + remote: boolean; + shell: string; current: () => boolean; } -type Target = () => RemotePasteTarget | null; +type Target = () => TerminalPasteTarget | null; -const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +const MAX_MEDIA_BYTES = 20 * 1024 * 1024; -async function pasteImage(image: Blob, target: RemotePasteTarget, paste: (text: string) => void) { +function pathsText(paths: readonly string[], target: TerminalPasteTarget): string { + const shell = target.shell.toLowerCase().replace(/\.exe$/, ""); + return paths + .map((path) => { + if (shell === "cmd") { + if (/[%!"\r\n]/.test(path)) + throw new Error(tx("Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.")); + return `"${path}"`; + } + if (shell === "powershell" || shell === "pwsh") return `'${path.replaceAll("'", "''")}'`; + if (shell === "fish") return `'${path.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; + return `'${path.replaceAll("'", "'\\''")}'`; + }) + .join(" "); +} + +function isMedia(type: string): boolean { + return /^(image|audio|video)\//.test(type); +} + +function pasteError(error: unknown, target: TerminalPasteTarget | null | undefined) { + if (target?.current()) + notifications().notify({ + level: "error", + title: tx("Cannot paste clipboard media"), + body: error instanceof Error ? error.message : String(error), + }); +} + +function filePaths(transfer: DataTransfer): string[] { + const paths: string[] = []; + for (const line of transfer.getData("text/uri-list").split(/\r?\n/)) { + if (!line || line.startsWith("#")) continue; + try { + const url = new URL(line); + if (url.protocol !== "file:") continue; + let path = decodeURIComponent(url.pathname); + if (url.hostname && url.hostname !== "localhost") path = `//${url.hostname}${path}`; + else if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1); + paths.push(path); + } catch { + continue; + } + } + return paths; +} + +async function pasteMedia(media: Blob, target: TerminalPasteTarget, paste: (text: string) => void) { try { - if (image.size > MAX_IMAGE_BYTES) throw new Error(tx`Image is too large to paste (limit ${20} MB).`); - const data = Array.from(new Uint8Array(await image.arrayBuffer())); + if (media.size > MAX_MEDIA_BYTES) throw new Error(tx`Clipboard media is too large to paste (limit ${20} MB).`); + const data = Array.from(new Uint8Array(await media.arrayBuffer())); if (!target.current()) return; if (!target.supported) throw new Error(tx("Remote server does not support image paste.")); - const path = await call(commands.termStoreImage(target.id, data, image.type)); - if (target.current()) paste(`'${path.replaceAll("'", "'\\''")}'`); + const path = await call(commands.termStoreImage(target.id, data, media.type)); + if (target.current()) paste(pathsText([path], target)); } catch (error) { - if (target.current()) - notifications().notify({ - level: "error", - title: tx("Cannot paste image"), - body: error instanceof Error ? error.message : String(error), - }); + pasteError(error, target); } } @@ -36,12 +80,29 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) => const onPaste = (event: ClipboardEvent) => { const transfer = event.clipboardData; const target = getTarget?.(); - if (!event.defaultPrevented && target && transfer && !transfer.getData("text/plain")) { - const image = Array.from(transfer.files).find((file) => file.type.startsWith("image/")); - if (image) { + if (!event.defaultPrevented && target && transfer) { + const media = Array.from(transfer.files).find((file) => isMedia(file.type)); + const files = + transfer.files.length || + Array.from(transfer.types).some((type) => type === "Files" || type === "text/uri-list"); + if ((!target.remote && files) || (media && !transfer.getData("text/plain"))) { event.preventDefault(); event.stopImmediatePropagation(); - void pasteImage(image, { ...target, current: () => alive && target.current() }, paste); + const captured = { ...target, current: () => alive && target.current() }; + void (async () => { + if (!captured.remote) { + const paths = filePaths(transfer); + const text = paths.length + ? pathsText(paths, captured) + : await readClipboardText(transfer, (paths) => pathsText(paths, captured)); + if (!captured.current()) return; + if (text) { + paste(text); + return; + } + } + if (media) await pasteMedia(media, captured, paste); + })().catch((error) => pasteError(error, captured)); return; } } @@ -68,7 +129,9 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT (error: unknown) => ({ error }), ) : null; - const text = target ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") : await readClipboardText(); + const text = target?.remote + ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") + : await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined); if (target && !target.current()) return; if (text) { paste(text); @@ -80,24 +143,19 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT if ("error" in result) throw result.error; if (!target.current()) return; for (const item of result.items) { - const mime = item.types.find((type) => type.startsWith("image/")); + const mime = item.types.find(isMedia); if (mime) { const image = await item.getType(mime); - if (target.current()) await pasteImage(image, target, paste); + if (target.current()) await pasteMedia(image, target, paste); return; } } } - const paths = await readClipboardText(); + const paths = await readClipboardText(undefined, target.remote ? undefined : (paths) => pathsText(paths, target)); if (!target.current()) return; if (paths) paste(paths); else if (!images) throw new Error(tx("Clipboard image reading is unavailable.")); } catch (error) { - if (target?.current()) - notifications().notify({ - level: "error", - title: tx("Cannot paste image"), - body: error instanceof Error ? error.message : String(error), - }); + pasteError(error, target); } } diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index da94bad3..dce99c6c 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -286,7 +286,7 @@ export class TerminalSession { }), ); this.disposables.push( - { dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.remotePasteTarget) }, + { dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.clipboardTarget) }, { dispose: installTerminalDrop(this.host, (text) => this.paste(text)) }, ); this.host.addEventListener("mousedown", this.onMiddleClick); @@ -298,14 +298,16 @@ export class TerminalSession { void this.pasteClipboard(); }; - private remotePasteTarget = () => { + private clipboardTarget = () => { const remote = useRemote.getState().session; const id = this.ptyId; - if (!remote || id === null || this.disposed) return null; + if (id === null || this.disposed) return null; const generation = this.generation; return { id, - supported: remote.commands.includes("term_store_image"), + supported: !remote || remote.commands.includes("term_store_image"), + remote: !!remote, + shell: this.snapshot.title, current: () => !this.disposed && this.ptyId === id && @@ -315,8 +317,8 @@ export class TerminalSession { }; pasteClipboard() { - if (this.remotePasteTarget()) this.focus(); - return pasteTerminalClipboard((text) => this.paste(text), this.remotePasteTarget); + if (this.clipboardTarget()) this.focus(); + return pasteTerminalClipboard((text) => this.paste(text), this.clipboardTarget); } /** Applies Settings › Tools › Terminal changes to a live session. */ applySettings(t: TerminalSettingsValid) { diff --git a/src/platform/clipboard.ts b/src/platform/clipboard.ts index bab48c0a..f3b25716 100644 --- a/src/platform/clipboard.ts +++ b/src/platform/clipboard.ts @@ -9,16 +9,19 @@ export async function readClipboardText( transfer?: DataTransfer | null, formatFiles: FormatFiles = rawPaths, ): Promise { - const text = transfer ? transfer.getData("text/plain") : ((await navigator.clipboard?.readText()) ?? ""); + const text = transfer + ? transfer.getData("text/plain") + : ((await navigator.clipboard?.readText().catch(() => "")) ?? ""); if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text; + let paths: string[]; try { - const paths = await call(commands.clipboardFileList()); - // Windows also supplies the file list as text; unrelated text still wins. - if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text; - return formatFiles(paths); + paths = await call(commands.clipboardFileList()); } catch { return text; } + // Windows also supplies the file list as text; unrelated text still wins. + if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text; + return formatFiles(paths); } function hasClipboardFiles(transfer: DataTransfer): boolean { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 048353eb..be3187ac 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5499,10 +5499,12 @@ ".NET launch profile '{profile_name}' is missing or is not a Project profile": ".NET 启动配置“{profile_name}”不存在或不是 Project 类型", ".NET output resolution timed out": ".NET 构建产物路径查询超时", ".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build", - "Cannot paste image": "无法粘贴图片", + "Cannot paste clipboard media": "无法粘贴剪贴板媒体", + "Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.": "无法在 cmd.exe 中安全粘贴此路径。请使用 PowerShell 或 POSIX Shell。", "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", - "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", + "Clipboard media is too large to paste (limit {limit} MB).": "剪贴板媒体过大,无法粘贴(上限 {limit} MB)。", + "unsupported clipboard media type": "不支持的剪贴板媒体类型", "Show inline blame for the current line": "为当前行显示内联 Git Blame", "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", "now": "刚刚", From bbb1be69a2d07c6d09c21609d28be03d04b1806f Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 07:05:16 +0800 Subject: [PATCH 063/108] fix(linux): make the frameless settings window edge readable #595 follow-up: the 1px --border-window hairline is nearly invisible on the dark theme (#40434a against a dark desktop), which fuqiuluo confirmed on Fedora 44 GNOME Wayland after the first edge commit. Blend the foreground tone into the ring so the boundary reads on every theme; still CSS-only, since Wayland gives undecorated windows no compositor shadow to paint outside the surface. --- src/features/settings/dialog/SettingsDialog.module.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 0a1105ab..a9ab4292 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -204,9 +204,10 @@ color: var(--fg); } /* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated - windows no CSD), so mark its edge in CSS instead. */ + windows no CSD), so mark its edge in CSS instead. --border-window alone is + near-invisible on the dark theme, so blend the foreground tone into it. */ .windowEdge { - box-shadow: inset 0 0 0 1px var(--border-window); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 32%, var(--border-window)); } .windowBody { flex: 1; From fd8161a5a70f2abd322184f2cc18c126b07dd43c Mon Sep 17 00:00:00 2001 From: fuqiuluo Date: Tue, 3 Nov 2026 07:15:24 +0800 Subject: [PATCH 064/108] Revert "fix(linux): make the frameless settings window edge readable" This reverts commit bbb1be69a2d07c6d09c21609d28be03d04b1806f. --- src/features/settings/dialog/SettingsDialog.module.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index a9ab4292..0a1105ab 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -204,10 +204,9 @@ color: var(--fg); } /* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated - windows no CSD), so mark its edge in CSS instead. --border-window alone is - near-invisible on the dark theme, so blend the foreground tone into it. */ + windows no CSD), so mark its edge in CSS instead. */ .windowEdge { - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 32%, var(--border-window)); + box-shadow: inset 0 0 0 1px var(--border-window); } .windowBody { flex: 1; From d830d21d466801d3affc10fb423cf4c03d150620 Mon Sep 17 00:00:00 2001 From: fuqiuluo Date: Tue, 3 Nov 2026 07:20:03 +0800 Subject: [PATCH 065/108] fix(linux): keep the settings edge above content --- src/features/settings/dialog/SettingsDialog.module.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 0a1105ab..7661a3a6 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -206,6 +206,14 @@ /* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated windows no CSD), so mark its edge in CSS instead. */ .windowEdge { + position: relative; +} +.windowEdge::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; box-shadow: inset 0 0 0 1px var(--border-window); } .windowBody { From d9929bbe44ce86b354cfb4c355b50dbcdb8ff11d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:52:55 +0800 Subject: [PATCH 066/108] fix(vcs): resolve shelf files against their owning repository (#919) Root cause: shelved file paths are relative to the repository they were shelved from, but Compare with Local, Show Diff, navigation and Unshelve joined them to the selected VCS root. In a project folder that is not itself a repository (or holds several), the local file was not found and Compare with Local stayed disabled. Shelves now record their repository (older shelves fall back to the primary repository), imported patches record the current one, and every shelf action resolves paths against it, with Windows separators handled. Stash and Apply Patch already kept their repository. Fixes #875 Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/vcs/commands.rs | 62 ++++++--- crates/ide-host/tests/vcs_shelf_roots.rs | 123 ++++++++++++++++++ crates/ide-vcs/src/lib.rs | 3 + crates/ide-vcs/src/shelf.rs | 31 ++++- crates/ide-vcs/tests/history_ops.rs | 6 +- src-tauri/src/commands/vcs.rs | 3 +- src/bindings.ts | 4 +- .../vcs/__tests__/repositoryPaths.test.ts | 21 +++ src/features/vcs/__tests__/shelf.test.tsx | 70 +++++++++- src/features/vcs/diffInput.ts | 3 +- src/features/vcs/logChanges.ts | 3 +- src/features/vcs/operations.tsx | 5 +- src/features/vcs/repositoryPaths.ts | 10 ++ src/features/vcs/shelf.tsx | 23 +++- src/platform/locales/text/zh.json | 4 +- 15 files changed, 327 insertions(+), 44 deletions(-) create mode 100644 crates/ide-host/tests/vcs_shelf_roots.rs create mode 100644 src/features/vcs/__tests__/repositoryPaths.test.ts create mode 100644 src/features/vcs/repositoryPaths.ts diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 64d47e42..eae55b6b 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -123,18 +123,18 @@ fn per_repo( Ok(()) } -/// Shelf and patches work in the primary repository only: changes of a nested one -/// fail instead of being left out silently. -fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> { +/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files. +fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult { let repos = project.repos(); - let primary = repos.primary().map(|r| r.root()); - match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) { - Some(p) => Err(ide_core::IdeError::invalid(format!( - "{} is in another git repository; shelving and patches support the project's main repository only", - p.display() - ))), - None => Ok(()), + let root = paths + .first() + .and_then(|p| repos.for_path(p)) + .map(|r| r.root()) + .ok_or_else(|| ide_core::IdeError::invalid("select files in a git repository to shelve"))?; + if paths.iter().any(|p| repos.for_path(p).map(|r| r.root()) != Some(root)) { + return Err(ide_core::IdeError::invalid("select files from a single git repository to shelve")); } + Ok(root.to_path_buf()) } async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { @@ -854,16 +854,23 @@ pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec IdeResult> { let project = host.project(window)?; - ide_vcs::shelf::list(&shelf_dir(&project.root)) + let mut shelves = ide_vcs::shelf::list(&shelf_dir(&project.root))?; + let primary = project.repos().primary().map(|r| ide_core::IpcPath::from(r.root().to_path_buf())); + for entry in &mut shelves { + if entry.repo.is_none() { + entry.repo = primary.clone(); + } + } + Ok(shelves) } /// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the @@ -878,7 +885,8 @@ pub async fn vcs_unshelve( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| { + let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?; + project.with_repo_recorded(repo.as_deref(), Some("Unshelve"), |r| { ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref()) }) }) @@ -902,12 +910,30 @@ pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option IdeResult> { let project = host.project(window)?; - blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await + blocking(move || { + let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?; + project.with_repo_at(repo.as_deref(), |r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id)) + }) + .await } -pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> { +pub async fn vcs_shelf_import( + host: &Host, + window: &str, + repo: Option, + name: String, + patch: String, +) -> IdeResult<()> { let project = host.project(window)?; - blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await + blocking(move || { + if repo.is_none() && project.repos().is_empty() { + return ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, None); + } + project.with_repo_at(repo.as_deref(), |r| { + ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, Some(r.root())) + }) + }) + .await } pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult { @@ -1107,7 +1133,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) }); d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await }); d.add("vcs_shelf_import", |c| async move { - vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await + vcs_shelf_import(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("patch")?).await }); d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) }); diff --git a/crates/ide-host/tests/vcs_shelf_roots.rs b/crates/ide-host/tests/vcs_shelf_roots.rs new file mode 100644 index 00000000..cb716546 --- /dev/null +++ b/crates/ide-host/tests/vcs_shelf_roots.rs @@ -0,0 +1,123 @@ +//! Shelves retain their repository even when the project root is above or below it. +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +fn git(root: &Path, args: &[&str]) { + let output = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +async fn check_shelves(layout: &str) { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let project_root = base.join("project"); + let roots = match layout { + "same" => vec![project_root.clone()], + "below" => vec![base.clone()], + "two" => vec![project_root.join("项目A"), project_root.join("repoB")], + _ => vec![project_root.join("项目A")], + }; + std::fs::create_dir_all(project_root.join("dirB")).unwrap(); + for (i, root) in roots.iter().enumerate() { + std::fs::create_dir_all(root.join("src")).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["config", "user.name", "Test"]); + git(root, &["config", "user.email", "test@example.com"]); + std::fs::write(root.join("src/same.txt"), format!("base {i}\n")).unwrap(); + git(root, &["add", "src"]); + git(root, &["commit", "-m", "initial"]); + std::fs::write(root.join("src/same.txt"), format!("shelved {i}\n")).unwrap(); + } + 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)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&project_root, true, false).unwrap(); + let project = host.open_project("main", &project_root).unwrap(); + for _ in 0..500 { + if project.repos().all().len() == roots.len() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(project.repos().all().len(), roots.len()); + let primary = project.repos().primary().unwrap().root().to_path_buf(); + if roots.len() == 2 { + assert!( + vcs::vcs_shelve(&host, "main", "mixed".into(), roots.iter().map(|r| r.join("src/same.txt")).collect()) + .await + .is_err() + ); + assert!(vcs::vcs_shelves(&host, "main").unwrap().is_empty()); + } + for (i, root) in roots.iter().enumerate() { + let entry = vcs::vcs_shelve(&host, "main", "work".into(), vec![root.join("src/same.txt")]).await.unwrap(); + assert_eq!(entry.repo.as_deref(), Some(root.as_path())); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n")); + let listed = vcs::vcs_shelves(&host, "main").unwrap(); + assert_eq!(listed.iter().find(|s| s.id == entry.id).unwrap().repo, entry.repo); + let diffs = vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.unwrap(); + assert_eq!(diffs[0].before.as_deref(), Some(format!("base {i}\n").as_str())); + assert_eq!(diffs[0].after.as_deref(), Some(format!("shelved {i}\n").as_str())); + vcs::vcs_shelf_rename(&host, "main", entry.id.clone(), "renamed".into()).unwrap(); + let patch = vcs::vcs_shelf_patch(&host, "main", entry.id.clone(), None).unwrap(); + vcs::vcs_shelf_import(&host, "main", Some(root.clone()), "imported".into(), patch).await.unwrap(); + let imported = vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.name == "imported").unwrap(); + assert_eq!(imported.repo, entry.repo); + vcs::vcs_shelf_delete(&host, "main", imported.id, None).unwrap(); + if root != &primary { + let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json"); + let original = std::fs::read(&info).unwrap(); + let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap(); + metadata["repo"] = serde_json::json!(base.join("missing-repository")); + std::fs::write(&info, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert!(vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.is_err()); + assert!(vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, None).await.is_err()); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n")); + std::fs::write(info, original).unwrap(); + } + vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, Some("src/same.txt".into())).await.unwrap(); + assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("shelved {i}\n")); + // Old shelves were always made in the primary repository; missing ownership keeps that meaning. + if root == &primary { + let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json"); + let mut metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(&info).unwrap()).unwrap(); + metadata.as_object_mut().unwrap().remove("repo"); + std::fs::write(info, serde_json::to_vec(&metadata).unwrap()).unwrap(); + assert_eq!( + vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.id == entry.id).unwrap().repo, + entry.repo + ); + assert_eq!( + vcs::vcs_shelf_diff(&host, "main", entry.id).await.unwrap()[0].after.as_deref(), + Some(format!("shelved {i}\n").as_str()) + ); + } + } + host.close_project("main"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_at_repo_root() { + check_shelves("same").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_in_non_git_project() { + check_shelves("above").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelves_in_two_repositories() { + check_shelves("two").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shelf_in_project_below_repository() { + check_shelves("below").await; +} diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index cca5669a..7e8cd352 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -297,6 +297,9 @@ pub struct ShelvedChangeList { pub time_ms: f64, /// Branch checked out when the changes were shelved. pub branch: Option, + /// Repository owning the patch; absent in legacy shelves. + #[serde(default)] + pub repo: Option, /// Repository-relative paths. pub files: Vec, } diff --git a/crates/ide-vcs/src/shelf.rs b/crates/ide-vcs/src/shelf.rs index 50286713..cdd5f882 100644 --- a/crates/ide-vcs/src/shelf.rs +++ b/crates/ide-vcs/src/shelf.rs @@ -19,6 +19,8 @@ struct Info { #[serde(default)] branch: Option, files: Vec, + #[serde(default)] + repo: Option, } fn now_ms() -> f64 { @@ -67,6 +69,7 @@ pub fn list(shelf: &Path) -> IdeResult> { name: id.clone(), time_ms: 0.0, branch: None, + repo: None, files: std::fs::read_to_string(dir.join(PATCH)) .map(|p| { crate::patch::parse_patch(&p) @@ -81,6 +84,7 @@ pub fn list(shelf: &Path) -> IdeResult> { name: info.name, time_ms: info.time_ms, branch: info.branch, + repo: info.repo, files: crate::patch::parse_patch(&std::fs::read_to_string(dir.join(PATCH))?) .into_iter() .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) @@ -96,6 +100,11 @@ pub fn patch_of(shelf: &Path, id: &str) -> IdeResult { Ok(std::fs::read_to_string(entry_dir(shelf, id)?.join(PATCH))?) } +/// The repository captured when the shelf was made; legacy shelves used the primary repository. +pub fn repository(shelf: &Path, id: &str) -> IdeResult> { + Ok(read_info(&entry_dir(shelf, id)?).and_then(|info| info.repo).map(|repo| repo.into_path_buf())) +} + pub fn delete(shelf: &Path, id: &str) -> IdeResult<()> { std::fs::remove_dir_all(entry_dir(shelf, id)?)?; Ok(()) @@ -107,8 +116,13 @@ pub fn rename(shelf: &Path, id: &str, name: &str) -> IdeResult<()> { return Err(IdeError::invalid("name must not be empty")); } let dir = entry_dir(shelf, id)?; - let mut info = - read_info(&dir).unwrap_or(Info { name: String::new(), time_ms: now_ms(), branch: None, files: vec![] }); + let mut info = read_info(&dir).unwrap_or(Info { + name: String::new(), + time_ms: now_ms(), + branch: None, + repo: None, + files: vec![], + }); info.name = name.to_string(); std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; Ok(()) @@ -134,7 +148,13 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - } let dir = shelf.join(&id); std::fs::create_dir_all(&dir)?; - let info = Info { name: name.to_string(), time_ms: now_ms(), branch: r.branch_info()?.head, files: files.clone() }; + let info = Info { + name: name.to_string(), + time_ms: now_ms(), + branch: r.branch_info()?.head, + files: files.clone(), + repo: Some(r.root().to_path_buf().into()), + }; let write = || -> IdeResult<()> { std::fs::write(dir.join(PATCH), patch.as_bytes())?; std::fs::write( @@ -148,7 +168,7 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - return Err(e); } revert_files(r, &files)?; - Ok(ShelvedChangeList { id, name: info.name, time_ms: info.time_ms, branch: info.branch, files }) + Ok(ShelvedChangeList { id, name: info.name, time_ms: info.time_ms, branch: info.branch, repo: info.repo, files }) } /// Restores `files` to HEAD in both index and working tree; files absent from HEAD are removed. @@ -318,7 +338,7 @@ pub fn diff_files(r: &Repo, shelf: &Path, id: &str) -> IdeResult IdeResult<()> { +pub fn import_patch(shelf: &Path, name: &str, patch: &str, repo: Option<&Path>) -> IdeResult<()> { let files = crate::patch::parse_patch(patch); if files.is_empty() || sections(patch).len() != files.len() { return Err(IdeError::invalid("no file changes found in the patch")); @@ -334,6 +354,7 @@ pub fn import_patch(shelf: &Path, name: &str, patch: &str) -> IdeResult<()> { name: name.to_string(), time_ms: now_ms(), branch: None, + repo: repo.map(|r| r.to_path_buf().into()), files: files .into_iter() .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index dd854c94..2d86ebc4 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -614,7 +614,7 @@ fn shelf_diff_and_scoped_operations_preserve_siblings() { assert!(!shelf::list(&shelf).unwrap()[0].files.iter().any(|f| f.path == "b.txt")); shelf::delete_path(&shelf, &entry.id, Some(QUOTED)).unwrap(); assert!(shelf::selected_patch(&shelf, &entry.id, Some("a.txt")).is_ok()); - shelf::import_patch(&shelf, "imported", &patch).unwrap(); + shelf::import_patch(&shelf, "imported", &patch, None).unwrap(); assert_eq!(shelf::list(&shelf).unwrap().len(), 2); assert_eq!(fx.read("a.txt"), "local edits\n"); } @@ -629,7 +629,7 @@ fn shelf_imported_rename_diff_and_last_file_delete() { fx.git(&["reset", "--hard", "HEAD"]); let repo = fx.repo(); let shelf = fx._tmp.path().join("shelf"); - shelf::import_patch(&shelf, "rename", &patch).unwrap(); + shelf::import_patch(&shelf, "rename", &patch, None).unwrap(); let id = &shelf::list(&shelf).unwrap()[0].id; let diffs = shelf::diff_files(&repo, &shelf, id).unwrap(); assert_eq!(diffs.len(), 1); @@ -655,7 +655,7 @@ fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() { assert_eq!(diffs.iter().find(|f| f.path == "old.txt").unwrap().before.as_deref(), Some("renamed content\n")); assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().after.as_deref(), Some("renamed content\n")); let plain = "--- a/one.txt\n+++ b/one.txt\n@@ -1 +1 @@\n--- removed\n+++ added\n--- a/two.txt\n+++ b/two.txt\n@@ -1 +1 @@\n-old\n+new\n"; - shelf::import_patch(&shelf, "plain", plain).unwrap(); + shelf::import_patch(&shelf, "plain", plain, None).unwrap(); let selected = shelf::selected_patch(&shelf, "plain", Some("one.txt")).unwrap(); assert!(selected.contains("--- removed\n+++ added")); assert!(!selected.contains("two.txt")); diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 9f3aaf18..305fd0e8 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -675,10 +675,11 @@ pub async fn vcs_shelf_diff( pub async fn vcs_shelf_import( window: Window, state: State<'_, AppState>, + repo: Option, name: String, patch: String, ) -> IdeResult<()> { - ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), name, patch).await + ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), repo, name, patch).await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 6d71d1a3..22f176d2 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -546,7 +546,7 @@ export const commands = { vcsShelfRename: (id: string, name: string) => typedError(__TAURI_INVOKE("vcs_shelf_rename", { id, name })), vcsShelfPatch: (id: string, path: string | null) => typedError(__TAURI_INVOKE("vcs_shelf_patch", { id, path })), vcsShelfDiff: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_diff", { id })), - vcsShelfImport: (name: string, patch: string) => typedError(__TAURI_INVOKE("vcs_shelf_import", { name, patch })), + vcsShelfImport: (repo: string | null, name: string, patch: string) => typedError(__TAURI_INVOKE("vcs_shelf_import", { repo, name, patch })), vcsChangelistsLoad: () => typedError(__TAURI_INVOKE("vcs_changelists_load")), vcsChangelistsSave: (changelists: ChangelistState) => typedError(__TAURI_INVOKE("vcs_changelists_save", { changelists })), /** Create Patch › Save to file. */ @@ -5783,6 +5783,8 @@ export type ShelvedChangeList = { timeMs: number | null, /** Branch checked out when the changes were shelved. */ branch: string | null, + /** Repository owning the patch; absent in legacy shelves. */ + repo?: IpcPath | null, /** Repository-relative paths. */ files: ChangedFile[], }; diff --git a/src/features/vcs/__tests__/repositoryPaths.test.ts b/src/features/vcs/__tests__/repositoryPaths.test.ts new file mode 100644 index 00000000..e1451849 --- /dev/null +++ b/src/features/vcs/__tests__/repositoryPaths.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { repositoryPath } from "../repositoryPaths"; + +describe("repository-relative paths", () => { + it.each([ + ["/repo", "src/a.txt", "/repo/src/a.txt"], + ["/project/项目A", "src/a.txt", "/project/项目A/src/a.txt"], + ["/project/repoB/", "src/a.txt", "/project/repoB/src/a.txt"], + ["/repo", "subproject/a.txt", "/repo/subproject/a.txt"], + ["C:\\project\\项目A", "src\\a.txt", "C:/project/项目A/src/a.txt"], + ["C:/project/repoB", "src\\a.txt", "C:/project/repoB/src/a.txt"], + ["C:\\", "a.txt", "C:/a.txt"], + ["\\\\server\\share\\repo", "src\\a.txt", "//server/share/repo/src/a.txt"], + ["\\\\?\\C:\\repo", "src\\a.txt", "C:/repo/src/a.txt"], + ["/", "a.txt", "/a.txt"], + ["/repo", "src/a\\b.txt", "/repo/src/a\\b.txt"], + [null, "src/a.txt", "src/a.txt"], + ])("%s + %s → %s", (root, path, expected) => { + expect(repositoryPath(root, path)).toBe(expected); + }); +}); diff --git a/src/features/vcs/__tests__/shelf.test.tsx b/src/features/vcs/__tests__/shelf.test.tsx index 69ce6d02..3d962364 100644 --- a/src/features/vcs/__tests__/shelf.test.tsx +++ b/src/features/vcs/__tests__/shelf.test.tsx @@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor, cleanup } from "@testing-libra import { afterEach } from "vitest"; import type { ShelvedChangeList } from "@/bindings"; -const mocks = vi.hoisted(() => ({ openDiff: vi.fn(), execute: vi.fn(), ipc: vi.fn(), patch: vi.fn() })); +const mocks = vi.hoisted(() => ({ openDiff: vi.fn(), execute: vi.fn(), ipc: vi.fn(), patch: vi.fn(), probe: vi.fn() })); vi.mock("../diffInput", async (original) => ({ ...(await original()), openDiff: mocks.openDiff, @@ -19,13 +19,16 @@ vi.mock("@/platform/ipc", async (original) => ({ ...(await original()).commands, vcsShelves: () => Promise.resolve(null), vcsShelfDiff: () => Promise.resolve(null), - fsProbe: () => Promise.resolve(null), + fsProbe: mocks.probe, vcsShelfPatch: mocks.patch, + vcsUnshelve: () => Promise.resolve(null), }, })); -import { ShelfPanel, copyShelfPatch, shelfDiffs, shelfTarget, showShelfDiff, useShelf } from "../shelf"; +import { ShelfPanel, copyShelfPatch, shelfDiffs, shelfTarget, showShelfDiff, unshelve, useShelf } from "../shelf"; import { useCommit } from "../commitStore"; import { useVcs } from "../store"; +import { useChangelists } from "../changelists"; +import { VcsDialogHost } from "../dialogs"; import { ContextMenuHost } from "@/ui"; import { registerVcsActions } from "../actions"; import { getAction, isActionKeyEnabled } from "@/platform/actions"; @@ -37,6 +40,7 @@ const entry: ShelvedChangeList = { name: "Work", timeMs: 0, branch: null, + repo: null, files: [ { path: "a.txt", kind: "modified", oldPath: null }, { path: "b.txt", kind: "added", oldPath: null }, @@ -51,7 +55,7 @@ const diffs = [ beforeEach(() => { vi.clearAllMocks(); mocks.patch.mockResolvedValue(null); - useVcs.setState({ root: "/repo" }); + useVcs.setState({ root: "/repo", roots: [], repo: null, available: false, status: [] }); useCommit.setState({ selectedShelf: "work\u0000a.txt" }); useShelf.setState({ shelves: [entry], loaded: true, diffs: { work: diffs } }); mocks.ipc.mockResolvedValueOnce([entry]).mockResolvedValue(diffs); @@ -103,6 +107,64 @@ describe("shelf scope and diffs", () => { await showShelfDiff({ id: "work", path: "a.txt" }, true); expect(mocks.openDiff).toHaveBeenCalledTimes(1); }); + it.each([ + ["same root", "/repo", ["/repo"], "/repo", "src/a.txt", "/repo/src/a.txt"], + ["项目A under a non-git project", "/project", ["/project/项目A"], null, "src/a.txt", "/project/项目A/src/a.txt"], + [ + "second repo with the same file name", + "/project", + ["/project/A", "/project/B"], + "/project/B", + "src/a.txt", + "/project/B/src/a.txt", + ], + ["project below the repo", "/repo/subproject", ["/repo"], null, "src/a.txt", "/repo/src/a.txt"], + [ + "Windows stored separators", + "C:/project", + ["C:/project/项目A"], + "C:/project/项目A", + "src\\a.txt", + "C:/project/项目A/src/a.txt", + ], + ])("maps saved and local diffs to the owning repo: %s", async (_name, root, roots, repo, path, expected) => { + useVcs.setState({ root, roots, repo: roots[0] ?? null }); + useShelf.setState({ + shelves: [{ ...entry, repo }], + diffs: { work: [{ path, before: "base", after: "shelved", patch: "patch" }] }, + }); + expect(shelfDiffs({ id: "work" })[0]?.path).toBe(expected); + mocks.ipc.mockReset().mockImplementation(async () => { + if (mocks.probe.mock.lastCall?.[0] !== expected) throw new Error("wrong local file"); + return { binary: false }; + }); + await showShelfDiff({ id: "work" }, true); + expect(mocks.probe).toHaveBeenLastCalledWith(expected); + expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: expected }), { preview: true }); + }); + it("keeps legacy shelves on the primary repo after selecting another repo", () => { + useVcs.setState({ root: "/project", roots: ["/project/A", "/project/B"], repo: "/project/B" }); + expect(shelfDiffs({ id: "work", path: "a.txt" }, true)[0]?.path).toBe("/project/A/a.txt"); + }); + it.each([ + ["/project", "/project/项目A", "src/a.txt", "项目A/src/a.txt"], + ["/repo/subproject", "/repo", "src/a.txt", "/repo/src/a.txt"], + ["C:/project", "C:/project/项目A", "src\\a.txt", "项目A/src/a.txt"], + ])("assigns unshelved paths to the project changelist from %s", async (root, repo, path, expected) => { + useVcs.setState({ root, roots: [repo] }); + useChangelists.setState({ + loaded: false, + state: { lists: [{ id: "target", name: "Target", comment: "" }], active: "target", files: {} }, + }); + mocks.ipc + .mockReset() + .mockResolvedValueOnce({ applied: [path], conflicts: [] }) + .mockResolvedValue([]); + render(); + act(() => unshelve({ ...entry, repo })); + fireEvent.click(screen.getByRole("button", { name: "Unshelve" })); + await waitFor(() => expect(useChangelists.getState().state.files).toEqual({ [expected]: "target" })); + }); it("copies only the selected file patch through the scoped patch command", async () => { const previous = Object.getOwnPropertyDescriptor(navigator, "clipboard"); const writeText = vi.fn().mockResolvedValue(undefined); diff --git a/src/features/vcs/diffInput.ts b/src/features/vcs/diffInput.ts index 6d7cf6ec..583c8bb9 100644 --- a/src/features/vcs/diffInput.ts +++ b/src/features/vcs/diffInput.ts @@ -6,6 +6,7 @@ import { baseName, effectiveKind } from "./model"; import { readWorkingText } from "./ops"; import { renamedFromPath } from "./changes"; import { currentRepoRoot, statusOf } from "./store"; +import { repositoryPath } from "./repositoryPaths"; export type DiffSide = /** File content at a git revision (`HEAD`, `:0` = index, a commit id, `^`). */ @@ -144,7 +145,7 @@ export function workingDiffData(path: string, line?: number): DiffData { /** A path of the selected repository's log (repository-relative) made absolute. */ function joinRoot(rel: string, root = currentRepoRoot()): string { - return root ? `${root}/${rel}` : rel; + return repositoryPath(root, rel); } /** Diff of one file changed by `commit` (first parent ↔ commit). */ diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts index 12dfe3a6..87e1b0d2 100644 --- a/src/features/vcs/logChanges.ts +++ b/src/features/vcs/logChanges.ts @@ -14,6 +14,7 @@ import { baseName } from "./model"; import { readWorkingText, runOp, saveAll, writeFileText } from "./ops"; import { reportApply } from "./shelf"; import { currentRepoRoot } from "./store"; +import { repositoryPath } from "./repositoryPaths"; import { confirmDialog } from "./dialogs"; /** A file of the pane, with the revisions holding its content before and after the change. */ @@ -109,7 +110,7 @@ export function filterPatch(patch: string, paths: ReadonlySet): string { /** Absolute path of a repository-relative one. */ export function absolutePath(rel: string, root = currentRepoRoot()): string { - return root ? `${root}/${rel}` : rel; + return repositoryPath(root, rel); } /** Pane state the changed-files actions work on when they get no argument. */ diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index b18a7ae1..b57298b4 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -21,6 +21,7 @@ import { ComparisonTree } from "./ComparisonTree"; import { buildFileTree, type FileTreeNode } from "./fileTree"; import { tx } from "@/platform/i18n"; import { displayPath } from "./compareData"; +import { repositoryPath } from "./repositoryPaths"; // --------------------------------------------------------------------------- // Operation in progress @@ -580,7 +581,7 @@ export function ChangesBetweenDialog({ const diffFor = (f: ComparisonFile): DiffData => { const missing = (label: string): DiffSide => ({ type: "text", text: "", label }); return { - path: `${f.root}/${f.path}`, + path: repositoryPath(f.root, f.path), left: f.kind === "added" || f.kind === "untracked" ? missing("(does not exist)") @@ -588,7 +589,7 @@ export function ChangesBetweenDialog({ type: "rev", rev: f.revision, label: from.label, - path: f.oldPath ? `${f.root}/${f.oldPath}` : undefined, + path: f.oldPath ? repositoryPath(f.root, f.oldPath) : undefined, }, right: f.kind === "deleted" diff --git a/src/features/vcs/repositoryPaths.ts b/src/features/vcs/repositoryPaths.ts new file mode 100644 index 00000000..686a9b5d --- /dev/null +++ b/src/features/vcs/repositoryPaths.ts @@ -0,0 +1,10 @@ +/** Stored Git paths are relative to their repository, independently of the open project. */ +import { ipcPath } from "@/platform/paths"; + +export function repositoryPath(root: string | null | undefined, path: string): string { + if (!root) return path; + const base = ipcPath(root).replace(/\/$/, ""); + // Backslashes are separators on Windows, but can be literal filename characters on POSIX. + const relative = /^[A-Za-z]:\//.test(`${base}/`) || base.startsWith("//") ? path.replace(/\\/g, "/") : path; + return `${base}/${relative}`; +} diff --git a/src/features/vcs/shelf.tsx b/src/features/vcs/shelf.tsx index 0979e2b4..9e808b91 100644 --- a/src/features/vcs/shelf.tsx +++ b/src/features/vcs/shelf.tsx @@ -22,7 +22,8 @@ import { showLocalChanges } from "./commitUi"; import { confirmDialog, openDialog, promptText } from "./dialogs"; import { KIND_COLOR, baseName, dirName, effectiveKind, fileCount, formatTime, relTo, stripSlash } from "./model"; import { errorMessage, logConsole, runOp, saveAll } from "./ops"; -import { conflictedFiles, refreshNow, useVcs } from "./store"; +import { conflictedFiles, currentRepo, refreshNow, useVcs } from "./store"; +import { repositoryPath } from "./repositoryPaths"; import styles from "./common.module.css"; import viewStyles from "./CommitView.module.css"; import { tx } from "@/platform/i18n"; @@ -212,7 +213,7 @@ function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; pa const lists = useChangelists((s) => s.state); const [target, setTarget] = useState(lists.active); const [remove, setRemove] = useState(() => workflowSettings().vcs.shelf.removeApplied); - const root = useVcs((s) => s.root) ?? ""; + const repo = shelfRepository(entry); const run = async () => { onClose(); await saveAll(); @@ -225,7 +226,7 @@ function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; pa ); if (!outcome) return; await refreshNow(); - const rels = [...outcome.applied, ...outcome.conflicts].map((p) => relOf(`${root}/${p}`)); + const rels = [...outcome.applied, ...outcome.conflicts].map((p) => relOf(repositoryPath(repo, p))); updateChangelists((s) => moveFiles(s, rels, target)); void loadShelves().catch(() => undefined); await reportApply(outcome, `Unshelved “${entry.name}”`); @@ -325,13 +326,20 @@ export function shelfTarget(arg?: unknown): { entry: ShelvedChangeList; path?: s return { entry, path: target?.path }; } +/** Legacy shelves were created in the primary repository, regardless of the selected one. */ +function shelfRepository(entry?: ShelvedChangeList): string | null { + const state = useVcs.getState(); + return entry?.repo ?? state.roots[0] ?? state.root; +} + export function shelfDiffs(target: ShelfTarget, local = false): DiffData[] { - const root = useVcs.getState().root; + const state = useShelf.getState(); + const root = shelfRepository(state.shelves.find((entry) => entry.id === target.id)); if (!root) return []; - return (useShelf.getState().diffs[target.id] ?? []) + return (state.diffs[target.id] ?? []) .filter((f) => (!target.path || f.path === target.path) && f.before !== null && f.after !== null) .map((f) => ({ - path: `${root}/${f.path}`, + path: repositoryPath(root, f.path), left: { type: "text", text: (local ? f.after : f.before)!, @@ -371,6 +379,7 @@ export async function showShelfDiff(arg?: unknown, local = false, newTab = false } export async function importShelfPatches(paths?: string[]) { + const repo = currentRepo(); const picked = paths ?? (await import("@tauri-apps/plugin-dialog").then(({ open }) => @@ -380,7 +389,7 @@ export async function importShelfPatches(paths?: string[]) { for (const path of typeof picked === "string" ? [picked] : picked) { await runOp( "Import patches", - async () => call(commands.vcsShelfImport(baseName(path), await call(commands.fsReadText(path)))), + async () => call(commands.vcsShelfImport(repo, baseName(path), await call(commands.fsReadText(path)))), { refresh: false }, ); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index be3187ac..5d0f755f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5515,5 +5515,7 @@ "App labels, modules or dotted test paths; empty runs every test": "应用标签、模块或以点分隔的测试路径;留空则运行全部测试", "Settings module": "设置模块", "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", - "e.g. --keepdb --parallel": "例如 --keepdb --parallel" + "e.g. --keepdb --parallel": "例如 --keepdb --parallel", + "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", + "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改" } From 0a24b79b50bdb780173228809ac8ef36708d8289 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:54:34 +0800 Subject: [PATCH 067/108] fix(editor): skip clean saves and refresh buffers after disk operations (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause, two parts: - An explicit save (Save All before a checkout, before running, before Commit) also wrote documents that were not modified, running save actions such as stripping trailing whitespace and adding a final newline on them — files the user never touched gained whitespace-only changes. Saves now skip clean documents, as autosave already did. - After our own VCS operations rewrote files (checkout, rollback, stash, patch, ignore, commit/worktree hooks, Directory Compare), clean editor buffers were only refreshed by the file watcher, which can miss a rewrite that keeps the length and the timestamp's millisecond. Those operations now reload clean buffers synchronously before returning, keeping each document's chosen encoding; modified buffers keep the existing conflict handling. Fixes #893 Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/fs.rs | 18 +- crates/ide-host/src/ops/vcs/commands.rs | 25 ++- crates/ide-host/src/project.rs | 13 +- crates/ide-host/tests/vcs_documents.rs | 240 ++++++++++++++++++++++++ crates/ide-workspace/src/documents.rs | 30 ++- crates/ide-workspace/tests/documents.rs | 31 +++ src/editor/api.ts | 2 +- src/editor/core/model/document.test.ts | 56 +++++- src/editor/core/model/document.ts | 5 +- 9 files changed, 390 insertions(+), 30 deletions(-) create mode 100644 crates/ide-host/tests/vcs_documents.rs diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index fb55f6f0..7c56cd3a 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -56,14 +56,16 @@ pub async fn copy_merge( if overwrite { project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT); } - ide_vfs::copy_merge( - &from, - &to, - overwrite, - Some(&project.root), - &project.exclude_globs(), - (&source_root, &destination_root), - ) + project.with_disk_sync(|| { + ide_vfs::copy_merge( + &from, + &to, + overwrite, + Some(&project.root), + &project.exclude_globs(), + (&source_root, &destination_root), + ) + }) }) .await } diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index eae55b6b..d3913c0a 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -281,7 +281,7 @@ pub async fn vcs_add_to_ignore( } } apply_git_executable(&project.settings.effective()); - let result = ide_vcs::add_to_ignore(&paths, destination); + let result = project.with_disk_sync(|| ide_vcs::add_to_ignore(&paths, destination)); // Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly. project.watcher.reload_ignore_rules(); for (_, watcher) in project.content_watchers.lock().iter() { @@ -322,7 +322,7 @@ pub async fn vcs_commit( // Commit hooks may run for a long time. let project = host.project(window)?; blocking(move || { - project.with_repo_at(repo.as_deref(), |r| { + project.with_repo_recorded(repo.as_deref(), None, |r| { r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None)) }) }) @@ -729,7 +729,7 @@ pub async fn vcs_add_worktree( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) + project.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) }) .await } @@ -846,7 +846,7 @@ pub async fn vcs_apply_patch( ) -> IdeResult { host.require_trust(window)?; let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await + blocking(move || project.with_repo_recorded(repo.as_deref(), None, |r| r.apply_patch(&patch))).await } /// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. @@ -948,13 +948,20 @@ pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistSt } /// Create Patch › Save to file. -pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> { +pub async fn vcs_write_patch(host: &Host, window: &str, path: PathBuf, patch: String) -> IdeResult<()> { + let project = host.project(window).ok(); blocking(move || { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; + let write = || { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, patch)?; + Ok(()) + }; + match project { + Some(project) => project.with_disk_sync(write), + None => write(), } - std::fs::write(&path, patch)?; - Ok(()) }) .await } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index a42889a1..9b4dda80 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -682,17 +682,24 @@ impl Project { self.with_repo_at(None, f) } + pub(crate) fn with_disk_sync(&self, f: impl FnOnce() -> IdeResult) -> IdeResult { + let result = f(); + // Failed operations can also leave changed files (for example merge conflicts). + self.documents.sync_after_disk_change(); + result + } + /// Runs a VCS operation that rewrites the working tree (checkout, merge, /// rebase, pull, reset, stash, rollback): Local History gets a label and a /// snapshot of every locally changed file before, and a label after. - /// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree). + /// Clean buffers follow the resulting files before returning; `None` omits the history labels. pub fn with_repo_recorded( &self, at: Option<&Path>, operation: Option<&str>, f: impl FnOnce(&Repo) -> IdeResult, ) -> IdeResult { - let Some(name) = operation else { return self.with_repo_at(at, f) }; + let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) }; self.history_label(&format!("Before {name}")); if let Ok(changed) = self.with_repo_at(at, |r| r.status()) { for file in changed.iter().take(HISTORY_BATCH_LIMIT) { @@ -701,7 +708,7 @@ impl Project { } } } - let result = self.with_repo_at(at, f); + let result = self.with_disk_sync(|| self.with_repo_at(at, f)); self.history_label(&format!("After {name}")); result } diff --git a/crates/ide-host/tests/vcs_documents.rs b/crates/ide-host/tests/vcs_documents.rs new file mode 100644 index 00000000..b6ca6fbe --- /dev/null +++ b/crates/ide-host/tests/vcs_documents.rs @@ -0,0 +1,240 @@ +use ide_core::{ChangeSet, NullSink, TextChange}; +use ide_host::{Host, HostConfig, Project, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +const ORIGINAL: &str = "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0"; +const OTHER: &str = "SELECT id\nFROM patients\n\tLIMIT 20 OFFSET 0"; + +fn git(root: &Path, args: &[&str]) -> String { + let out = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap() +} + +struct Fixture { + host: Host, + project: Arc, + path: std::path::PathBuf, + doc: ide_core::DocId, + _temp: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + // The command must synchronize documents even when watcher delivery is excluded. + let path = root.join("node_modules/query.sql"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + git(&root, &["init", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + git(&root, &["config", "commit.gpgsign", "false"]); + git(&root, &["config", "core.autocrlf", "false"]); + std::fs::write(&path, ORIGINAL).unwrap(); + git(&root, &["add", "-f", "node_modules/query.sql"]); + git(&root, &["commit", "-m", "original"]); + git(&root, &["switch", "-c", "other"]); + std::fs::write(&path, OTHER).unwrap(); + git(&root, &["commit", "-am", "other"]); + git(&root, &["switch", "main"]); + 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)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&root, true, false).unwrap(); + let project = host.open_project("main", &root).unwrap(); + for _ in 0..500 { + if !project.repos().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let doc = project.documents.open(&path).unwrap().id; + Self { host, project, path, doc, _temp: temp } + } + + fn saved_edit(&self, text: &str) { + self.project.documents.replace_all(self.doc, text).unwrap(); + self.project.documents.save(self.doc).unwrap(); + } + + fn assert_clean(&self, text: &str) { + let snapshot = self.project.documents.snapshot(self.doc).unwrap(); + assert_eq!(snapshot.text, text); + assert!(!snapshot.dirty); + self.project.documents.save_all().unwrap(); + assert_eq!(std::fs::read_to_string(&self.path).unwrap(), text); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.host.close_project("main"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn checkout_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rollback_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + f.saved_edit(&format!("{ORIGINAL}\n")); + vcs::vcs_discard(&f.host, "main", vec![f.path.clone()]).await.unwrap(); + f.assert_clean(ORIGINAL); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stash_and_unstash_reload_clean_open_documents_before_returning() { + let f = Fixture::new().await; + let edited = format!("{ORIGINAL}\n"); + f.saved_edit(&edited); + vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap(); + f.assert_clean(ORIGINAL); + vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap(); + f.assert_clean(&edited); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn applying_patch_reloads_clean_open_documents_before_returning() { + let f = Fixture::new().await; + std::fs::write(&f.path, OTHER).unwrap(); + let patch = git(&f.project.root, &["diff"]); + git(&f.project.root, &["restore", "."]); + vcs::vcs_apply_patch(&f.host, "main", None, patch).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn writing_patch_file_reloads_its_clean_open_document() { + let f = Fixture::new().await; + vcs::vcs_write_patch(&f.host, "main", f.path.clone(), OTHER.into()).await.unwrap(); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn directory_compare_overwrite_reloads_clean_open_documents() { + let f = Fixture::new().await; + let base = f.project.root.parent().unwrap(); + let source = base.join("source"); + let destination = base.join("destination"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(source.join("query.sql"), OTHER).unwrap(); + let path = destination.join("query.sql"); + std::fs::write(&path, ORIGINAL).unwrap(); + let doc = f.project.documents.open(&path).unwrap(); + ide_host::ops::fs::copy_merge(&f.host, "main", source, destination, "query.sql".into(), true).await.unwrap(); + let snapshot = f.project.documents.snapshot(doc.id).unwrap(); + assert_eq!(snapshot.text, OTHER); + assert!(!snapshot.dirty); + f.project.documents.save_all().unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_working_tree_operation_synchronizes_partial_disk_changes() { + let f = Fixture::new().await; + let result: ide_core::IdeResult<()> = f.project.with_repo_recorded(None, Some("Git merge"), |_| { + std::fs::write(&f.path, OTHER).unwrap(); + Err(ide_core::IdeError::invalid("merge stopped with conflicts")) + }); + assert!(result.is_err()); + f.assert_clean(OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn working_tree_operation_reloads_same_size_same_timestamp_changes() { + let f = Fixture::new().await; + let modified = std::fs::metadata(&f.path).unwrap().modified().unwrap(); + let replacement = ORIGINAL.replace("LIMIT 10", "LIMIT 20"); + f.project + .with_repo_recorded(None, Some("Git checkout"), |_| { + std::fs::write(&f.path, &replacement).unwrap(); + std::fs::File::options() + .write(true) + .open(&f.path) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(modified)) + .unwrap(); + Ok(()) + }) + .unwrap(); + f.assert_clean(&replacement); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn working_tree_operation_does_not_discard_unsaved_edits() { + let f = Fixture::new().await; + let version = f.project.documents.snapshot(f.doc).unwrap().version; + f.project + .documents + .edit(f.doc, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "-- local\n".into() }] }) + .unwrap(); + vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap(); + let snapshot = f.project.documents.snapshot(f.doc).unwrap(); + assert_eq!(snapshot.text, format!("-- local\n{ORIGINAL}")); + assert!(snapshot.dirty); + assert_eq!(std::fs::read_to_string(&f.path).unwrap(), OTHER); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn adding_local_exclude_reloads_open_ignore_document() { + let f = Fixture::new().await; + let path = f.project.root.join(".git/info/exclude"); + let doc = f.project.documents.open(&path).unwrap(); + let ignored = f.project.root.join("generated.txt"); + std::fs::write(&ignored, "generated\n").unwrap(); + vcs::vcs_add_to_ignore(&f.host, "main", vec![ignored], ide_vcs::IgnoreDestination::LocalExclude).await.unwrap(); + let disk = std::fs::read_to_string(&path).unwrap(); + assert!(disk.contains("/generated.txt")); + assert_eq!(f.project.documents.snapshot(doc.id).unwrap().text, disk); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn commit_hook_reloads_clean_open_documents() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new().await; + f.saved_edit(&format!("{ORIGINAL}\n")); + git(&f.project.root, &["add", "-u"]); + let hook = f.project.root.join(".git/hooks/pre-commit"); + std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > node_modules/query.sql\n").unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + vcs::vcs_commit( + &f.host, + "main", + None, + "commit with hook".into(), + ide_vcs::CommitOptions { run_hooks: true, ..Default::default() }, + ) + .await + .unwrap(); + f.assert_clean("SELECT hook;"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn worktree_checkout_hook_reloads_clean_open_documents() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new().await; + let hook = f.project.root.join(".git/hooks/post-checkout"); + std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > ../project/node_modules/query.sql\n").unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + let linked = f.project.root.parent().unwrap().join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked, "other".into(), None).await.unwrap(); + f.assert_clean("SELECT hook;"); +} diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 8ea241af..dbcee862 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -821,6 +821,17 @@ impl Documents { /// Re-checks open file buffers, including files outside the watched roots. /// Call from a blocking worker; unchanged files only need their metadata. pub fn sync_from_disk(&self) { + self.sync_open_documents(false); + } + + /// Refreshes clean buffers after an IDE operation rewrites files. Such an + /// operation can preserve both file length and the timestamp's millisecond. + /// The open document's selected codec is retained. + pub fn sync_after_disk_change(&self) { + self.sync_open_documents(true); + } + + fn sync_open_documents(&self, force_clean: bool) { let slots: Vec = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect(); let paths: Vec = slots .iter() @@ -830,13 +841,17 @@ impl Documents { .then(|| st.path.clone()) }) .collect(); - self.on_disk_changed(&paths); + self.reload_from_disk(&paths, force_clean); } /// Re-reads from disk after an external change. Clean documents reload /// silently; dirty ones are left alone and reported via `IdeEvent::Notify`. /// Changes matching our own last save are ignored. pub fn on_disk_changed(&self, paths: &[PathBuf]) { + self.reload_from_disk(paths, false); + } + + fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) { for path in paths { let Some(id) = self.by_path(path) else { continue }; let result = self.with_doc(id, |st| { @@ -845,7 +860,7 @@ impl Documents { } let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) }; let mtime = ide_vfs::mtime_ms(&meta); - if mtime == st.disk_mtime && meta.len() == st.disk_len { + if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) { return Ok(()); } if st.dirty { @@ -863,9 +878,14 @@ impl Documents { }); return Ok(()); } - let defaults = self.file_defaults(); - let loaded = Self::load_with_defaults(&st.path, &defaults)?; - st.native_to_ascii = defaults.native_to_ascii_for(&st.path); + let loaded = if force_clean { + Self::load_as(&st.path, &st.encoding, st.native_to_ascii)? + } else { + let defaults = self.file_defaults(); + let loaded = Self::load_with_defaults(&st.path, &defaults)?; + st.native_to_ascii = defaults.native_to_ascii_for(&st.path); + loaded + }; st.disk_mtime = loaded.mtime_ms; st.disk_len = disk_len(&st.path); if loaded.binary || st.binary { diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 02370eed..16670ce1 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -57,6 +57,37 @@ fn fixture() -> Fixture { Fixture { _tmp: tmp, root, docs, events, rec } } +#[test] +fn own_disk_changes_preserve_the_open_documents_encoding() { + let f = fixture(); + let path = f.root.join("query.sql"); + let original = "SELECT '中文';\r\n"; + fs::write(&path, ide_vfs::encode(original, "gbk", false).unwrap()).unwrap(); + let mut defaults = f.docs.file_defaults(); + defaults.project_fallback = "gbk".into(); + f.docs.set_file_defaults(defaults.clone()); + let doc = f.docs.open(&path).unwrap(); + assert_eq!(doc.encoding, "gbk"); + defaults.project_fallback = "windows-1252".into(); + f.docs.set_file_defaults(defaults); + f.docs.sync_after_disk_change(); + let unchanged = f.docs.snapshot(doc.id).unwrap(); + assert_eq!(unchanged.text, doc.text); + assert_eq!(unchanged.encoding, "gbk"); + assert!(!unchanged.dirty); + let changed = "SELECT '世界';\r\n"; + let bytes = ide_vfs::encode(changed, "gbk", false).unwrap(); + fs::write(&path, &bytes).unwrap(); + f.docs.sync_after_disk_change(); + let reloaded = f.docs.snapshot(doc.id).unwrap(); + assert_eq!(reloaded.text, changed.replace("\r\n", "\n")); + assert_eq!(reloaded.encoding, "gbk"); + assert_eq!(reloaded.line_ending, LineEnding::CrLf); + assert!(!reloaded.dirty); + f.docs.save_all().unwrap(); + assert_eq!(fs::read(&path).unwrap(), bytes); +} + fn change(from: u32, to: u32, insert: &str) -> ChangeSet { ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] } } diff --git a/src/editor/api.ts b/src/editor/api.ts index 671f0d9b..fb5554b0 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -433,7 +433,7 @@ export function _emitEditorEvent(e: EditorEvent) { // --- save participants ---------------------------------------------------------- /** - * Runs before a document is written (format on save, organise imports…). + * Runs before a modified document is written (format on save, organise imports…). * Participants may edit the document through `handle.view` (normal * transactions); editor/core waits for them, then trims whitespace / adds the * final newline per settings, flushes the edit pipeline and saves. `handle` diff --git a/src/editor/core/model/document.test.ts b/src/editor/core/model/document.test.ts index 6ee9a7d9..1b066027 100644 --- a/src/editor/core/model/document.test.ts +++ b/src/editor/core/model/document.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { language as languageFacet } from "@codemirror/language"; -import { remoteChange } from "@/editor/api"; +import { registerSaveParticipant, remoteChange } from "@/editor/api"; import { createMemoryBackend, type MemoryBackend } from "../backend"; import { _resetLanguages } from "../cm/languages"; import { DocumentManager, ModifiedRanges, saveCleanupChanges, useDocMeta } from "./document"; @@ -88,6 +88,60 @@ describe("DocumentManager + slots", () => { expect(useDocMeta.getState()["/p/a.txt"]?.dirty).toBe(false); }); + it.each(["explicit", "auto"] as const)( + "%s save leaves untouched documents byte-for-byte unchanged", + async (reason) => { + const files = { + "/p/src/main/resources/sql/no-final.sql": "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0", + "/p/trailing.sql": "SELECT id \nFROM patients;\t \n", + "/p/crlf.sql": "SELECT id\r\nFROM patients;\r\n", + "/p/Untouched.java": "class Untouched { \n\tint id;\n}", + "/p/blank.sql": "SELECT 1;\n\n\n", + "/p/edited.java": "class Edited {}\n", + }; + const { backend, manager } = setup(files, { + editor: { general: { stripTrailingScope: "all", removeTrailingBlankLines: true } }, + }); + const participant = vi.fn(); + const unregister = registerSaveParticipant({ id: "test.save", beforeSave: participant }); + const save = vi.spyOn(backend, "save"); + try { + const slots = []; + for (const path of Object.keys(files)) slots.push(new EditorSlot(await manager.acquire(path), path, host)); + const edited = slots.at(-1)!; + edited.dispatchLocal({ changes: { from: 0, insert: "// edited\n" } }); + await manager.saveAll(reason); + for (const [path, text] of Object.entries(files)) { + if (path !== "/p/edited.java") expect(backend.files.get(path), path).toBe(text); + } + expect(backend.files.get("/p/edited.java")).toBe("// edited\nclass Edited {}\n"); + expect(save.mock.calls).toEqual([[edited.model.doc]]); + expect(participant).toHaveBeenCalledTimes(reason === "explicit" ? 1 : 0); + if (reason === "explicit") expect(participant.mock.calls[0]![0].path).toBe("/p/edited.java"); + await manager.saveAll(reason); + expect(save).toHaveBeenCalledTimes(1); + } finally { + unregister(); + manager.stop(); + } + }, + ); + + it("checks dirty state when each queued save starts", async () => { + const { backend, manager } = setup({ "/p/query.sql": "SELECT 1" }); + const model = await manager.acquire("/p/query.sql"); + const slot = new EditorSlot(model, "t1", host); + slot.dispatchLocal({ changes: { from: 7, to: 8, insert: "2" } }); + const save = vi.spyOn(backend, "save"); + try { + await Promise.all([model.save("explicit"), model.save("explicit"), model.save("auto")]); + expect(backend.files.get(model.path)).toBe("SELECT 2\n"); + expect(save).toHaveBeenCalledTimes(1); + } finally { + manager.stop(); + } + }); + it("closes the backend document when the last slot goes away", async () => { const { backend, manager } = setup({ "/p/a.txt": "x" }); const model = await manager.acquire("/p/a.txt"); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 9c70e8f6..a6bd1f18 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -569,8 +569,7 @@ export class DocModel { save(reason: "explicit" | "auto"): Promise { const run = async () => { - if (this.closed || this.meta.readOnly) return; - if (reason === "auto" && !this.meta.dirty) return; + if (this.closed || this.meta.readOnly || !this.meta.dirty) return; const slot = this.primarySlot(); if (reason === "explicit") { for (const p of _saveParticipants()) { @@ -764,7 +763,7 @@ export class DocumentManager { for (const m of this.byPath.values()) m.applySettings(); } - /** Saves every document; failures are notified, and an explicit Save All also rejects with them. */ + /** Saves dirty documents; failures are notified, and an explicit Save All also rejects with them. */ async saveAll(reason: "explicit" | "auto") { const errors: unknown[] = []; await Promise.all( From 416b65d65e7eb45406e42a1011cb4f8d86c1fb9b Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 08:31:19 +0800 Subject: [PATCH 068/108] test(editor): make the detach-tab save-failure document dirty (#924) Since #920, saves skip clean documents, so the detach-tab save-failure test never reached its mocked failing save. The test now edits the document first, keeping its intent: a failing save keeps the tab and opens no window. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/editor/core/detachTab.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/editor/core/detachTab.test.ts b/src/editor/core/detachTab.test.ts index e919c6dd..7786c23d 100644 --- a/src/editor/core/detachTab.test.ts +++ b/src/editor/core/detachTab.test.ts @@ -82,6 +82,9 @@ describe("detachTab", () => { ctrl.start(); await ctrl.openFile(path, { focus: false }); const [pane, id] = current(); + // Clean documents skip saving entirely, so the document must be dirty for + // the mocked save failure to be reached. + ctrl.slotFor(id)!.dispatchLocal({ changes: { from: 0, to: 8, insert: "edited" } }); vi.spyOn(backend, "save").mockRejectedValue(new Error("save failed")); expect(await ctrl.detachTab(pane, id)).toBe(false); expect(hasTab(id)).toBe(true); From 93712a1588d0b5dc3b4cd30c359a3a67832a2bcc Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:39:11 +0800 Subject: [PATCH 069/108] chore: add bug report and feature request issue forms (#878) Structured issue forms for bug reports and feature requests: environment, steps, expected/actual behaviour and logs for bugs; problem, proposal and alternatives for features. Both ask reporters to search existing issues first and keep one problem per issue; blank issues are disabled and the latest release is linked from the chooser. Forms do not require a particular language. Refs #860 --- .github/ISSUE_TEMPLATE/bug_report.yml | 69 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 38 ++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..b7008354 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Something in OpenIDEA doesn't work as expected +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! Keep logs and error messages exactly as they appear. + + One problem per issue, please. If you have several, open one issue for each. + - type: checkboxes + id: checks + attributes: + label: Before you start + options: + - label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I'm on the [latest release](https://github.com/l0ng-ai/openidea/releases/latest), or I noted below that I can't update. + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: | + Open **About OpenIDEA** (macOS: OpenIDEA menu; Windows/Linux: Help menu), click **Copy Info**, and paste it here. Also mention the project type if it matters (e.g. Gradle multi-module, Spring Boot, Go workspace) and whether you use remote development. + render: text + placeholder: | + About OpenIDEA + Version: 0.1.33 + Build: ... + Platform: macOS aarch64 + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Start from opening OpenIDEA or a project. A small sample project or snippet helps a lot. + placeholder: | + 1. Open a Gradle project whose settings.gradle includes modules in a loop + 2. Open the Gradle tool window + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead. Screenshots or a recording are welcome. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: | + Use **Help → Show Log in Finder / Explorer / Files** to find `openidea.log` and paste the lines around the problem, or attach the file. Include console output or error messages too. Check for tokens or passwords before posting. + render: text + - type: textarea + id: extra + attributes: + label: Additional context + description: Anything else, such as whether it worked in an earlier version. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c6bb3f1a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Download the latest release + url: https://github.com/l0ng-ai/openidea/releases/latest + about: Many problems are already fixed. Please check the latest version before reporting. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..bc9ad784 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Suggest a new feature or an improvement +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + One request per issue, please. + - type: checkboxes + id: checks + attributes: + label: Before you start + options: + - label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been requested yet. + required: true + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what gets in the way today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How you'd like it to work. Mockups or examples from other tools are welcome. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives and workarounds + description: Other approaches you considered, or what you do now instead. + - type: textarea + id: extra + attributes: + label: Additional context From 4f64ca3149c0c91a0c8c92060c20ef50dad5139f Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Mon, 2 Nov 2026 18:10:31 -0800 Subject: [PATCH 070/108] feat(agents): enable 1-click ACP registry install and updates for GitHub Copilot (#773) GitHub Copilot, Qwen Code and Mistral Vibe were listed in the ACP Registry under their registry ids (github-copilot-cli, qwen-code, mistral-vibe), so they showed as unrelated third-party agents and could not be installed over the built-in providers. Registry ids now map to the built-in ids: the rows use the built-in name and icon, install and update into /agents/.toml, show "x.y.z available" when the registry has a newer version, and fall back to the built-in command on PATH after Remove. Installing never overwrites a hand-written agent config, and Remove only deletes a registry install. Changing installed providers reloads them and drops stale probes, so New Task sees the installed version right away. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 17 ++++- crates/ide-agent/src/model.rs | 2 + crates/ide-agent/src/provider.rs | 18 +++-- crates/ide-agent/src/registry.rs | 80 +++++++++++++++++++++- crates/ide-agent/src/tests.rs | 75 ++++++++++++++++++++ crates/ide-host/src/ops/agent.rs | 12 +++- src/bindings.ts | 4 +- src/features/agents/AgentRegistryPage.tsx | 4 +- src/features/agents/HomeEditor.tsx | 7 +- src/features/agents/agentRegistry.test.tsx | 15 +++- src/features/agents/registry.ts | 4 ++ src/features/agents/store.ts | 4 ++ 12 files changed, 226 insertions(+), 16 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index f2fb12a5..4f61c8e0 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -314,10 +314,25 @@ impl AgentManager { source: p.source.clone(), }) .collect(); - *self.shared.providers.write() = specs; + let mut prev = self.shared.providers.write(); + let mut probes = self.shared.probes.lock(); + probes.retain(|id, _| { + let old = prev.iter().find(|p| &p.id == id); + let new = specs.iter().find(|p| &p.id == id); + matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env) + }); + *prev = specs; list } + /// Re-reads configured/installed agents, drops cached probes, and notifies subscribers. + pub fn reload_providers(&self) { + let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); + *self.shared.providers.write() = specs; + self.shared.probes.lock().clear(); + self.shared.emit(AgentEvent::Providers); + } + /// The slash commands to complete for `provider`, in `task`'s directory or (without one) /// in the project: what the agent offered last time there, unadvertised builtins, /// and, for Claude Code, skills and custom commands on disk it has not listed. diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 67b2b619..1ba50ae8 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -413,4 +413,6 @@ pub enum AgentEvent { index: u32, entry: AgentEntry, }, + /// The installed or configured agent providers changed. + Providers, } diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 47902e0f..17608704 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -80,9 +80,16 @@ fn builtins() -> Vec { .collect() } -/// Whether `id` names a built-in agent. +/// Whether `id` names a built-in agent that needs no registry install (native drivers +/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and +/// updated from the ACP Registry into `/agents/.toml`. pub(crate) fn is_builtin(id: &str) -> bool { - BUILTIN.iter().any(|b| b.0 == id) + BUILTIN.iter().any(|&(bid, _, driver, cmd, _)| bid == id && (driver != Driver::Acp || cmd.first() == Some(&"npx"))) +} + +/// Display name of a built-in agent, when `id` is one. +pub(crate) fn builtin_name(id: &str) -> Option<&'static str> { + BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name) } /// An agent file, `/agents/.toml`. @@ -162,7 +169,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { Some(r) => AgentSource::Registry { version: r.version, update: None }, None => AgentSource::Custom, }; - let spec = ProviderSpec { + let mut spec = ProviderSpec { name: agent.name.unwrap_or_else(|| id.clone()), driver: agent.driver, command: agent.command, @@ -173,7 +180,10 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { id, }; match specs.iter_mut().find(|s| s.id == spec.id) { - Some(existing) => *existing = spec, + Some(existing) => { + spec.login_hint = existing.login_hint.clone(); + *existing = spec; + } None => specs.push(spec), } } diff --git a/crates/ide-agent/src/registry.rs b/crates/ide-agent/src/registry.rs index 26bf31f3..1c22007d 100644 --- a/crates/ide-agent/src/registry.rs +++ b/crates/ide-agent/src/registry.rs @@ -165,6 +165,13 @@ fn parse(text: &str) -> IdeResult> { .into_iter() .filter_map(|v| serde_json::from_value::(v).ok()) .filter(|e| safe_segment(&e.id) && safe_segment(&e.version)) + .map(|mut e| { + e.id = canonical_id(&e.id).to_owned(); + if let Some(name) = provider::builtin_name(&e.id) { + e.name = name.to_owned(); + } + e + }) .collect()) } @@ -232,11 +239,16 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo None } -/// Registry agents that are built-in ones under another id. -const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"]; +/// Registry ids that map to a shorter built-in provider id (`/agents/.toml`). +const BUILTIN_ALIASES: &[(&str, &str)] = + &[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")]; + +fn canonical_id(id: &str) -> &str { + BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id) +} fn is_builtin(id: &str) -> bool { - provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id) + provider::is_builtin(canonical_id(id)) } fn on_path(command: &str) -> bool { @@ -387,6 +399,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { /// Installs (or updates to) the registry's current version of `id`, streaming progress /// through `emit`, and points the agent file at it. pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { + let id = canonical_id(id); // The saved index is what the user picked from; fetch only when there is none. let text = match fs::read_to_string(&paths.cache_file) { Ok(text) => text, @@ -618,6 +631,7 @@ fn install_uv( /// Deletes a registry agent's file and installed files. pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> { + let id = canonical_id(id); if !safe_segment(id) { return Err(IdeError::invalid(format!("invalid agent id {id}"))); } @@ -946,4 +960,64 @@ mod tests { assert!(remove(&p, "amp-acp").is_err()); assert!(p.agent_file("amp-acp").exists()); } + + #[test] + fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() { + let dir = tempfile::tempdir().unwrap(); + let p = paths(dir.path()); + let index = r#"{"agents":[ + {"id":"github-copilot-cli","name":"Copilot","version":"1.0.105", + "description":"GitHub's AI pair programmer", + "distribution":{"npx":{"package":"@github/copilot@1.0.105","args":["--acp"]}}} + ]}"#; + let entries = parse(index).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot")); + + let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node"); + assert_eq!(shown.len(), 1); + assert_eq!( + (shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()), + ("copilot", "GitHub Copilot", None) + ); + + CustomAgent { + name: Some("GitHub Copilot".into()), + driver: Driver::Acp, + command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(), + args: vec!["--acp".into()], + env: BTreeMap::new(), + registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }), + } + .write(&p.agent_file("copilot")) + .unwrap(); + + let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node"); + assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true)); + + write_atomic(&p.cache_file, index.as_bytes()).unwrap(); + let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap(); + assert_eq!(spec.name, "GitHub Copilot"); + assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in."); + assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None }); + + let mut providers = vec![AgentProvider { + id: spec.id, + name: spec.name, + command: spec.command, + installed: true, + config: vec![], + commands: vec![], + source: spec.source, + }]; + mark_updates(&p, &mut providers); + assert_eq!( + providers[0].source, + AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) } + ); + + remove(&p, "github-copilot-cli").unwrap(); + let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap(); + assert_eq!(fallback.source, AgentSource::Builtin); + } } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index b4625197..6bd3ea2b 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -942,3 +942,78 @@ async fn task_project_root_controls_local_and_worktree_repositories() { } assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1); } + +#[tokio::test(flavor = "multi_thread")] +async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() { + let tmp = tempfile::tempdir().unwrap(); + let agents_dir = tmp.path().join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" }); + std::fs::write(&fake_bin, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let agent = Arc::new(FakeAgent::default()); + let connector: Connector = { + let agent = agent.clone(); + Arc::new(move |_spec, _cwd, client, _stderr| { + let (a, b) = duplex(1 << 20); + let (ar, aw) = tokio::io::split(a); + let (br, bw) = tokio::io::split(b); + let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines); + agent.servers.lock().push(server); + Ok(Peer::connect_framed(ar, aw, client, Framing::Lines)) + }) + }; + let mgr = AgentManager::with_connector( + AgentPaths { + project_root: tmp.path().to_path_buf(), + store_dir: tmp.path().join("store"), + worktrees_dir: tmp.path().join("worktrees"), + models_file: tmp.path().join("models.json"), + custom_agents_dir: Some(agents_dir.clone()), + home_dir: None, + }, + tokio::runtime::Handle::current(), + connector, + ); + + // Seed a stale failed probe for copilot before installing its custom TOML. + mgr.shared.probes.lock().insert( + "copilot".into(), + AgentProbe { + provider: "copilot".into(), + name: None, + version: None, + protocol: "ACP".into(), + capabilities: AgentCapabilities::default(), + auth_methods: Vec::new(), + error: Some("copilot --acp was not found on PATH.".into()), + }, + ); + + let events = Arc::new(Mutex::new(Vec::new())); + let events_sink = events.clone(); + mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev))); + + let toml = format!( + "name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n", + fake_bin.to_string_lossy() + ); + std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap(); + + mgr.reload_providers(); + + assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers))); + assert!(!mgr.shared.probes.lock().contains_key("copilot")); + + let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap(); + assert!(copilot.installed); + assert!(copilot.command.contains(&*fake_bin.to_string_lossy())); + + let probe = mgr.probe("copilot", false).await.unwrap(); + assert!(probe.error.is_none()); +} diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index f72d603c..5c16f270 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -63,13 +63,21 @@ pub async fn registry_install( .await .map_err(join_err); host.end_request(request); - result? + result??; + for project in host.projects() { + project.agents.reload_providers(); + } + Ok(()) } /// Removes a registry agent: its agent file and installed files. pub async fn registry_remove(host: &Host, _window: &str, id: String) -> IdeResult<()> { let paths = registry_paths(host); - tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)? + tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)??; + for project in host.projects() { + project.agents.reload_providers(); + } + Ok(()) } /// Slash commands to complete for `provider` in `task`'s directory, or in the project diff --git a/src/bindings.ts b/src/bindings.ts index 22f176d2..13367ac8 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1216,7 +1216,9 @@ export type AgentEvent = /** The task's conversation was cut to its first `length` entries (turns rolled back). */ { type: "truncated"; task: string; length: number } | /** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */ -{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry }; +{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry } | +/** The installed or configured agent providers changed. */ +{ type: "providers" }; export type AgentImage = { mimeType: string, diff --git a/src/features/agents/AgentRegistryPage.tsx b/src/features/agents/AgentRegistryPage.tsx index a77a67ea..e0341790 100644 --- a/src/features/agents/AgentRegistryPage.tsx +++ b/src/features/agents/AgentRegistryPage.tsx @@ -8,8 +8,9 @@ import type { AgentRegistry, RegistryAgent, RequestId } from "@/bindings"; import { isCancelled, nextRequestId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { openExternalUrl } from "@/platform/openUrl"; -import { Badge, Button, ProgressBar, SearchField, Spinner, T } from "@/ui"; +import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T } from "@/ui"; import { confirm } from "@/workbench/messages"; +import { providerIcon } from "./model"; import { DISTRIBUTION_LABEL, afterInstall, @@ -213,6 +214,7 @@ function AgentRow({
  • + {agent.name} diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index 3ff76b00..e12ef1e2 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -45,6 +45,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito // Agents installed in Settings (its own window) show up when this one is back in front. useEffect(() => { const refresh = () => void refreshProviders(); + refresh(); window.addEventListener("focus", refresh); return () => window.removeEventListener("focus", refresh); }, []); @@ -66,7 +67,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito }, [root, project?.workspace]); const provider = providers.find((p) => p.id === draft.provider) ?? providers[0]; - const probe = useProbe(provider?.installed ? provider.id : null); + const probe = useProbe(provider?.installed ? provider.id : null, provider?.command); const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS); const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon); // Without git under the project there are no worktrees: force the local checkout. @@ -224,7 +225,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito } /** What the agent reports about itself when it starts (no session is opened), once per agent. */ -function useProbe(provider: string | null): AgentProbe | null { +function useProbe(provider: string | null, command?: string): AgentProbe | null { const [probe, setProbe] = useState(null); useEffect(() => { if (!provider) return; @@ -235,6 +236,6 @@ function useProbe(provider: string | null): AgentProbe | null { return () => { live = false; }; - }, [provider]); + }, [provider, command]); return probe?.provider === provider ? probe : null; } diff --git a/src/features/agents/agentRegistry.test.tsx b/src/features/agents/agentRegistry.test.tsx index f1b88af8..f9c2bc8e 100644 --- a/src/features/agents/agentRegistry.test.tsx +++ b/src/features/agents/agentRegistry.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import type { AgentProvider, AgentRegistry, InstallEvent, RegistryAgent, RequestId } from "@/bindings"; import { IpcError } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; +import { iconMarkup } from "@/ui/icons"; import { MessagesHost } from "@/workbench/messages"; import { AgentRegistryPage } from "./AgentRegistryPage"; import { ADD_AGENT, providerOptions } from "./providerOptions"; @@ -96,9 +97,10 @@ describe("registry helpers", () => { named("zed", "Zed"), named("cline", "Cline"), named("amp-acp", "Amp"), + named("copilot", "GitHub Copilot"), named("bob", "Bob", "2.0.9"), ]); - expect(sorted.map((a) => a.name)).toEqual(["Bob", "Amp", "Cline", "Agoragentic", "Zed"]); + expect(sorted.map((a) => a.name)).toEqual(["Bob", "GitHub Copilot", "Amp", "Cline", "Agoragentic", "Zed"]); }); it("drops a removed agent this system cannot install again", () => { @@ -141,6 +143,17 @@ describe("agent picker", () => { }); describe("Agents settings page", () => { + it("shows the built-in Copilot name and icon in its registry row", async () => { + const backend = new FakeBackend(); + backend.registry = { agents: [agent({ id: "copilot", name: "GitHub Copilot" })], warning: null }; + render(); + const copilot = await screen.findByRole("listitem", { name: "GitHub Copilot" }); + const expected = document.createElement("div"); + expected.innerHTML = iconMarkup("agent-copilot")!; + expect(copilot.querySelector("svg")?.innerHTML).toBe(expected.querySelector("svg")?.innerHTML); + expect(copilot.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + }); + it("lists registry agents, says they are third-party, and searches them", async () => { const backend = new FakeBackend(); render(); diff --git a/src/features/agents/registry.ts b/src/features/agents/registry.ts index eb41e768..f71772ea 100644 --- a/src/features/agents/registry.ts +++ b/src/features/agents/registry.ts @@ -63,6 +63,10 @@ export function afterRemove(agents: RegistryAgent[], id: string): RegistryAgent[ * no popularity data and lists alphabetically, which puts small or unrelated projects on top. */ const FEATURED = [ + "copilot", + "gemini", + "cursor", + "opencode", "amp-acp", "devin", "factory-droid", diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index c2810686..b1b54f3e 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -257,6 +257,10 @@ function onEvent(e: AgentEvent) { scheduleChanges(e.task); break; } + case "providers": { + void refreshProviders(); + break; + } case "entry": { if ( !e.subagent && From 00e717d8fdba4bf58f8f53ea9934be893a677cd9 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 10:18:39 +0800 Subject: [PATCH 071/108] feat(vcs): diff preview in the Git window's Local Changes tab (#714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Local Changes tab of the Git tool window showed only the changes tree; a file's diff was visible only after opening a diff tab. It now previews the selected file's working-tree diff beside the tree when the window is docked at the bottom and below it when docked on a side. The preview follows the selection (arrow keys included), shows a placeholder for no or multiple selection, and can be turned off with View Options › Show Diff Preview (persisted; the selection is kept when toggling). The preview reuses the diff editor, whose toolbar did not fit a side-docked window: the editor is now a size container, the whitespace and highlighting pickers move to the settings menu below 640px and the separators go below 380px, buttons no longer shrink and the differences counter truncates. Fixes #710. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/vcs/CommitView.tsx | 44 +++++- src/features/vcs/DiffEditor.module.css | 21 ++- src/features/vcs/GitLog.tsx | 4 +- src/features/vcs/__tests__/diffFind.test.tsx | 19 +++ .../vcs/__tests__/localChanges.test.tsx | 130 ++++++++++++++++++ src/features/vcs/commitStore.ts | 12 ++ src/platform/locales/text/zh.json | 1 + 7 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/features/vcs/__tests__/localChanges.test.tsx diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index f9019a0a..b809529d 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -32,6 +32,7 @@ import { T, } from "@/ui"; import { editorService } from "@/workbench/contributions"; +import { useToolWindowLayout } from "@/workbench/toolwindows/store"; import { aggregateCheck, buildChangeTree, @@ -47,10 +48,11 @@ import { applyStash, commitChanges, dropStash, showUnstashDialog, stashChanges } import { CommitOptionsPopup } from "./CommitOptions"; import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists"; import { moveToChangelist } from "./changelistsUi"; -import { messageHistory, setAmend, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; +import { messageHistory, setAmend, setDiffPreview, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; import { pushAfterCommit } from "./commitUi"; import { stashTabOf, stashTabs, type ContentTab } from "./gitTabs"; -import { openDiff, withChain, workingDiffData } from "./diffInput"; +import { diffInput, openDiff, withChain, workingDiffData } from "./diffInput"; +import { DiffEditor } from "./DiffEditor"; import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash } from "./model"; import { call, commands } from "@/platform/ipc"; import type { FileStatus } from "@/bindings"; @@ -344,6 +346,37 @@ function ChangesPanel() { ); } +/** + * The Git window's Local Changes tab: a diff preview beside the tree when + * bottom-docked, below it when side-docked (off via View Options › Show Diff Preview). + */ +export function LocalChangesView() { + const preview = useCommit((s) => s.diffPreview); + const path = useCommit((s) => (s.selection.length === 1 ? s.selection[0] : null)); + const anchor = useToolWindowLayout((s) => s.placements.git?.anchor ?? "bottom"); + return ( + + + + + {preview && ( + <> + + + {path ? ( + + ) : ( + + Select a file to preview its changes + + )} + + + )} + + ); +} + /** * The change tree with its toolbar. `commit`: the Commit tool window's (inclusion * checkboxes); `local`: the Git tool window's Local Changes tab of the modal commit @@ -353,6 +386,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { const status = useVcs((s) => s.status); const root = useVcs((s) => s.root) ?? ""; const groupByDir = useCommit((s) => s.groupByDir); + const diffPreview = useCommit((s) => s.diffPreview); const lists = useChangelists((s) => s.state); const checks = useCommitChecks(); const checkboxes = mode !== "local"; @@ -438,6 +472,12 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { showContextMenu(bottomLeft(viewRef.current), [ { kind: "header", label: "Group By" }, { label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) }, + ...(mode === "local" + ? [ + { kind: "separator" } as const, + { label: "Show Diff Preview", checked: diffPreview, run: () => setDiffPreview(!diffPreview) }, + ] + : []), ]) } /> diff --git a/src/features/vcs/DiffEditor.module.css b/src/features/vcs/DiffEditor.module.css index acf34543..5fc7db47 100644 --- a/src/features/vcs/DiffEditor.module.css +++ b/src/features/vcs/DiffEditor.module.css @@ -1,5 +1,6 @@ .root { position: relative; + container: diff-editor / inline-size; flex: 1; height: 100%; min-height: 0; @@ -16,19 +17,37 @@ padding: 0 6px; border-bottom: 1px solid var(--border); } +.toolbar > * { + flex-shrink: 0; +} .sep { width: 1px; height: 14px; margin: 0 4px; background: var(--border-strong); } -.count { +.toolbar > .count { + flex-shrink: 1; + min-width: 0; margin-left: auto; padding: 0 6px; + overflow: hidden; + text-overflow: ellipsis; color: var(--fg-subtle); font-size: var(--fs-small); white-space: nowrap; } +/* Narrow (a side-docked preview): the whitespace / highlighting pickers stay in the gear menu. */ +@container diff-editor (max-width: 640px) { + .select { + display: none; + } +} +@container diff-editor (max-width: 380px) { + .toolbar > .sep { + display: none; + } +} .labels { flex: none; display: flex; diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index ba4c431f..440a9b03 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -93,7 +93,7 @@ import styles from "./GitLog.module.css"; import { tx } from "@/platform/i18n"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { gitTabs, shownTab } from "./gitTabs"; -import { ChangesTree, StashContent, tabItems, useStashTabCounts } from "./CommitView"; +import { LocalChangesView, StashContent, tabItems, useStashTabCounts } from "./CommitView"; import { WorktreesPanel } from "./gitMenuDialogs"; const ROW = 26; @@ -148,7 +148,7 @@ export function GitToolWindow() { aria-labelledby={`openidea-git-tab-${shown}`} > {shown === "changes" ? ( - + ) : shown === "shelf" || shown === "stashes" ? ( ) : shown === "console" ? ( diff --git a/src/features/vcs/__tests__/diffFind.test.tsx b/src/features/vcs/__tests__/diffFind.test.tsx index e7cdde2d..a42649bc 100644 --- a/src/features/vcs/__tests__/diffFind.test.tsx +++ b/src/features/vcs/__tests__/diffFind.test.tsx @@ -40,6 +40,7 @@ vi.mock("@/editor/api", async (importOriginal) => ({ })); import { DiffEditor } from "../DiffEditor"; +import type { DiffData } from "../diffInput"; const diff = (right: object) => ({ @@ -74,6 +75,24 @@ const press = async (index: number, code: string, shift = false) => { afterEach(() => useKeymap.setState({ overrides: {} })); describe("Find in the diff viewer", () => { + it("preserves external focus when loading and changing a working-tree preview", async () => { + const preview = (input: EditorInput) => ( + <> + + + + ); + const { rerender } = render(preview(working)); + const tree = screen.getByRole("button", { name: "Changes tree" }); + tree.focus(); + const oldPane = await pane(1); + expect(tree).toHaveFocus(); + rerender(preview({ ...working, key: "diff:b.ts", data: { ...(working.data as DiffData), path: "/p/b.ts" } })); + await waitFor(() => expect(oldPane.dom.isConnected).toBe(false)); + await pane(1); + expect(tree).toHaveFocus(); + }); + it("opens the editor's find bar from the keymap and closes it with Esc", async () => { useKeymap.setState({ overrides: KEYMAP }); render(); diff --git a/src/features/vcs/__tests__/localChanges.test.tsx b/src/features/vcs/__tests__/localChanges.test.tsx new file mode 100644 index 00000000..23b2f8a5 --- /dev/null +++ b/src/features/vcs/__tests__/localChanges.test.tsx @@ -0,0 +1,130 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, call: vi.fn() }; +}); + +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ input }: { input: { data: { path: string } } }) => ( +
    + ), +})); + +const openDiff = vi.hoisted(() => vi.fn()); +vi.mock("../diffInput", async (original) => ({ + ...(await original()), + openDiff, +})); + +import { LocalChangesView } from "../CommitView"; +import { setDiffPreview, useCommit } from "../commitStore"; +import { useVcs } from "../store"; +import { useToolWindowLayout } from "@/workbench/toolwindows/store"; + +describe("Local Changes diff preview (#710)", () => { + beforeEach(() => { + localStorage.clear(); + openDiff.mockClear(); + useCommit.setState({ diffPreview: true, selection: [] }); + useVcs.setState({ root: "/p", repo: "/p", roots: ["/p"], status: [] }); + useToolWindowLayout.setState({ placements: {} }); + }); + + it("places the preview beside the tree for default bottom docking", () => { + render(); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical"); + }); + + it.each(["left", "right"] as const)("stacks the preview for %s docking and updates when moved", (anchor) => { + render(); + act(() => useToolWindowLayout.setState({ placements: { git: { anchor } } })); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "horizontal"); + act(() => useToolWindowLayout.setState({ placements: { git: { anchor: "bottom" } } })); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical"); + }); + + it("previews the single selected file's working-tree diff", () => { + useCommit.setState({ selection: ["/p/src/a.ts"] }); + render(); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/src/a.ts"); + }); + + it("shows a placeholder until exactly one file is selected", () => { + render(); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + useCommit.setState({ selection: ["/p/a.ts", "/p/b.ts"] }); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + }); + + it("follows the selection", () => { + useCommit.setState({ selection: ["/p/a.ts"] }); + render(); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/a.ts"); + act(() => useCommit.setState({ selection: ["/p/b.ts"] })); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/b.ts"); + act(() => useCommit.setState({ selection: [] })); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + }); + + it("previews keyboard selection and opens the full diff on Enter and double click", () => { + useVcs.setState({ + status: ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + staged: null, + unstaged: "modified" as const, + renamedFrom: null, + })), + }); + render(); + const tree = screen.getByRole("tree", { name: "Changes" }); + tree.focus(); + fireEvent.keyDown(tree, { key: "Home" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/a.ts"); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts"); + expect(tree).toHaveFocus(); + expect(openDiff).not.toHaveBeenCalled(); + fireEvent.keyDown(tree, { key: "Enter" }); + expect(openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/p/b.ts" }), { preview: true }); + fireEvent.doubleClick(screen.getByText("a.ts")); + expect(openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/p/a.ts" }), { preview: true }); + }); + + it("turns off via the View Options toggle, persists, and keeps the tree alone", () => { + useCommit.setState({ selection: ["/p/a.ts"] }); + render(); + act(() => setDiffPreview(false)); + expect(useCommit.getState().diffPreview).toBe(false); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + expect(screen.getByText("No local changes")).toBeTruthy(); + expect(localStorage.getItem("openidea.vcs.diffPreview")).toBe("false"); + }); + + it("keeps the tree selection when the preview is toggled off and on", () => { + useVcs.setState({ + status: ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + staged: null, + unstaged: "modified" as const, + renamedFrom: null, + })), + }); + render(); + const tree = screen.getByRole("tree", { name: "Changes" }); + tree.focus(); + fireEvent.keyDown(tree, { key: "Home" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + act(() => setDiffPreview(false)); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + act(() => setDiffPreview(true)); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts"); + }); +}); diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index e609cec9..b1f78b72 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -14,6 +14,8 @@ interface CommitState { /** Commit Options › Run Git hooks. */ runHooks: boolean; groupByDir: boolean; + /** Whether the Git window's Local Changes tab previews the selection's diff. */ + diffPreview: boolean; /** User overrides of the inclusion checkboxes (absolute path → state). */ checks: Map; /** Changed blocks left out of the commit (absolute path → `hunkKeys`) of `hunks` files. */ @@ -42,6 +44,7 @@ export const useCommit = create(() => ({ author: "", runHooks: true, groupByDir: readBool("openidea.vcs.groupByDir", false), + diffPreview: readBool("openidea.vcs.diffPreview", true), checks: new Map(), excludedHunks: new Map(), selection: [], @@ -71,6 +74,15 @@ export function setGroupByDir(v: boolean) { } } +export function setDiffPreview(v: boolean) { + useCommit.setState({ diffPreview: v }); + try { + localStorage.setItem("openidea.vcs.diffPreview", String(v)); + } catch { + // ignore + } +} + const historyKey = () => `openidea.vcs.commitMessages:${useVcs.getState().root ?? ""}`; const MAX_HISTORY = 20; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5d0f755f..3a2d099a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1755,6 +1755,7 @@ "Restore Size": "恢复大小", "Restore detected roots": "恢复检测到的根", "Result": "结果", + "Select a file to preview its changes": "选择文件以预览其更改", "Select a problem to see its details": "选择一个问题以查看详情", "Select an item to preview": "选择一项以预览", "Severity": "严重性", From ec5302d8385da9c68f13975c8733165818374a4d Mon Sep 17 00:00:00 2001 From: Piettro Castro Date: Tue, 3 Nov 2026 00:14:14 -0500 Subject: [PATCH 072/108] fix(linux): draw GTK's CSD shadow on frameless windows under Wayland tao makes frameless Wayland windows client-side decorated (an empty GTK title bar, so the compositor adds no frame) but leaves them undecorated, so GTK draws no shadow or resize margin and dark windows blend into whatever is behind them. Decorating the window after build keeps that empty title bar: GTK adds the theme's shadow and resize edges, nothing else. Applies to project windows and the Settings window; X11 is unchanged (decorating there would bring back the WM frame). - Detect Wayland from GDK's backend (tao's own test), not env vars. - Measure windows by GTK's content size, which excludes the shadow margin, so remembered geometry and Settings placement don't grow. - Skip the in-page resize handles on Wayland; GTK provides the edges. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + src-tauri/Cargo.toml | 4 ++ src-tauri/src/commands/app.rs | 6 +++ src-tauri/src/linux_window_state.rs | 42 ++++++++++++++++++- src-tauri/src/window_groups.rs | 4 ++ src-tauri/src/windows.rs | 22 +++++++++- src/bindings.ts | 2 + .../settings/dialog/SettingsDialog.module.css | 4 +- src/platform/platformInfo.ts | 4 ++ src/workbench/WindowResizeHandles.tsx | 12 +++--- 10 files changed, 91 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5275a63f..91fb0a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6152,6 +6152,7 @@ dependencies = [ "block2 0.6.2", "dirs 6.0.0", "dunce", + "gtk", "ide-agent", "ide-analysis", "ide-build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ab942c55..633a43b9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -79,6 +79,10 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std # 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(target_os = "linux")'.dependencies] +# Wayland detection and the client size without GTK's CSD shadow (linux_window_state.rs). +gtk = "0.18" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ "Win32_Foundation", diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index e68485c6..e383c9fa 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -15,6 +15,8 @@ pub struct AppInfo { pub version: String, pub platform: String, pub config_dir: ide_core::IpcPath, + /// Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. + pub wayland: bool, } pub use ide_host::ops::project::ProjectInfo; @@ -27,6 +29,10 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo { version: env!("CARGO_PKG_VERSION").into(), platform: std::env::consts::OS.into(), config_dir: state.config_dir.clone().into(), + #[cfg(target_os = "linux")] + wayland: crate::linux_window_state::is_wayland(), + #[cfg(not(target_os = "linux"))] + wayland: false, } } diff --git a/src-tauri/src/linux_window_state.rs b/src-tauri/src/linux_window_state.rs index 3d9816dc..a05a6f7e 100644 --- a/src-tauri/src/linux_window_state.rs +++ b/src-tauri/src/linux_window_state.rs @@ -14,6 +14,7 @@ mod runtime { use super::*; use parking_lot::Mutex; use std::collections::HashMap; + use std::sync::OnceLock; use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin}; const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME; @@ -63,8 +64,42 @@ mod runtime { visible: bool, } + static WAYLAND: OnceLock = OnceLock::new(); + + /// GDK's backend, as tao tests it (`GDK_BACKEND=x11` under a Wayland session is X11). + /// Known once the plugin is set up, before any window exists. + pub fn is_wayland() -> bool { + WAYLAND.get().copied().unwrap_or(false) + } + + /// The client size that `set_size` (`gtk_window_resize`) takes. On Wayland, decorated windows + /// get a GTK CSD shadow margin that tao's inner and outer sizes (the configure size) include. + pub fn content_size(window: &Window) -> tauri::Result> { + let inner = window.inner_size()?; + if !is_wayland() { + return Ok(inner); + } + // GTK objects belong to the main thread. `gtk_window_get_size` follows the configure + // synchronously; the webview's allocation only catches up on the next layout. + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let cloned = window.clone(); + window.run_on_main_thread(move || { + let sizes = cloned.gtk_window().ok().zip(cloned.inner_size().ok()).zip(cloned.scale_factor().ok()); + let _ = tx.send(sizes.map(|((gtk, inner), scale)| { + let (width, height) = gtk::prelude::GtkWindowExt::size(>k); + let content = tauri::LogicalSize::new(width.max(0) as u32, height.max(0) as u32); + (content.to_physical::(scale), inner) + })); + })?; + let Some((content, inner)) = rx.recv().ok().flatten() else { return Ok(inner) }; + // An unmapped window reports its size request; never exceed the configure size. + let usable = + content.width > 1 && content.height > 1 && content.width <= inner.width && content.height <= inner.height; + Ok(if usable { content } else { inner }) + } + fn frame(window: &Window) -> Option { - let size = window.inner_size().ok()?; + let size = content_size(window).ok()?; Some(Frame { visible: window.is_visible().ok()?, size: Size(size.width, size.height), @@ -381,6 +416,9 @@ mod runtime { pub fn plugin() -> TauriPlugin { tauri::plugin::Builder::new("linux-window-state") .setup(|app, _| { + // Plugins are set up on the main thread once GTK is initialised, before any window exists. + use gtk::gdk::prelude::DisplayExtManual; + let _ = WAYLAND.set(gtk::gdk::Display::default().is_some_and(|d| d.backend().is_wayland())); let saved = app .path() .app_config_dir() @@ -466,7 +504,7 @@ mod runtime { } #[cfg(target_os = "linux")] -pub use runtime::{normal_size_requested, plugin, prepare, save_before_close}; +pub use runtime::{content_size, is_wayland, normal_size_requested, plugin, prepare, save_before_close}; #[cfg(test)] mod tests { diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index fef00fa1..64344e0d 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -65,6 +65,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { if !maximized { to.unmaximize().map_err(err)?; to.set_position(from.outer_position().map_err(err)?).map_err(err)?; + // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. + #[cfg(target_os = "linux")] + let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; + #[cfg(not(target_os = "linux"))] let size = from.inner_size().map_err(err)?; #[cfg(target_os = "linux")] crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index a47340ad..14e84c20 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -303,6 +303,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< let window = WebviewWindowBuilder::from_config(app, &config) .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; + #[cfg(target_os = "linux")] + decorate_csd(&window); apply_zoom(app, &window); #[cfg(target_os = "macos")] align_titlebar(app, &window); @@ -325,6 +327,17 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< Ok(window) } +/// Wayland: tao already makes frameless windows client-side decorated (an empty GTK title bar, so +/// the compositor adds no frame), but undecorated GTK windows get no shadow or resize margin. +/// Decorating keeps that empty title bar, so GTK adds the theme's shadow and resize edges and +/// nothing else. X11 has no such title bar: decorating there would bring back the WM frame. +#[cfg(target_os = "linux")] +fn decorate_csd(window: &WebviewWindow) { + if crate::linux_window_state::is_wayland() { + let _ = window.set_decorations(true); + } +} + /// Sets the saved IDE zoom before the page loads; applied later by the frontend, /// it would visibly scale up the splash. fn apply_zoom(app: &AppHandle, window: &WebviewWindow) { @@ -807,6 +820,8 @@ pub fn open_settings_window( let window = builder .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; + #[cfg(target_os = "linux")] + decorate_csd(&window); apply_zoom(app, &window); #[cfg(target_os = "macos")] align_titlebar(app, &window); @@ -887,7 +902,12 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind let size = parent.outer_size()?.to_logical::(scale); let own_scale = window.scale_factor()?; let own = window.outer_size()?.to_logical::(own_scale); - let inner = window.inner_size()?.to_logical::(own_scale); + // On Wayland, the frame is GTK's CSD shadow margin, which tao's inner size includes. + #[cfg(target_os = "linux")] + let inner = crate::linux_window_state::content_size(&window.as_ref().window())?; + #[cfg(not(target_os = "linux"))] + let inner = window.inner_size()?; + let inner = inner.to_logical::(own_scale); let frame = (own.width - inner.width, own.height - inner.height); let bounds = fit_bounds( (pos.x, pos.y, size.width, size.height), diff --git a/src/bindings.ts b/src/bindings.ts index 7c4a2d95..eed10ae6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1379,6 +1379,8 @@ export type AppInfo = { version: string, platform: string, configDir: IpcPath, + /** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */ + wayland: boolean, }; export type AppMetrics = { diff --git a/src/features/settings/dialog/SettingsDialog.module.css b/src/features/settings/dialog/SettingsDialog.module.css index 7661a3a6..d7bad042 100644 --- a/src/features/settings/dialog/SettingsDialog.module.css +++ b/src/features/settings/dialog/SettingsDialog.module.css @@ -203,8 +203,8 @@ background: var(--bg-dialog, var(--bg-elev)); color: var(--fg); } -/* Linux draws no shadow for a frameless window (Wayland/KDE give undecorated - windows no CSD), so mark its edge in CSS instead. */ +/* Linux: on Wayland GTK draws the frameless window's shadow (`decorate_csd` in + windows.rs), but frameless X11 windows have none, so mark the edge in CSS. */ .windowEdge { position: relative; } diff --git a/src/platform/platformInfo.ts b/src/platform/platformInfo.ts index 230a34bd..95846480 100644 --- a/src/platform/platformInfo.ts +++ b/src/platform/platformInfo.ts @@ -9,6 +9,8 @@ interface PlatformInfo { overlayTitleBar: boolean; /** Frameless window: the title bar draws the main menu and caption buttons (Tauri, Windows and Linux). */ framelessTitleBar: boolean; + /** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */ + wayland: boolean; } /** OS from the user agent (before the backend answers, or in a browser). "darwin" must not read as Windows. */ @@ -28,6 +30,7 @@ export const usePlatform = create(() => { version: null, overlayTitleBar: isTauri() && os === "macos", framelessTitleBar: isTauri() && (os === "windows" || os === "linux"), + wayland: false, }; }); @@ -40,6 +43,7 @@ export async function loadPlatformInfo() { version: info.version, overlayTitleBar: info.platform === "macos", framelessTitleBar: info.platform === "windows" || info.platform === "linux", + wayland: info.wayland, }); } catch { /* keep the user-agent guess */ diff --git a/src/workbench/WindowResizeHandles.tsx b/src/workbench/WindowResizeHandles.tsx index 0b5fa9da..dbaf4e8f 100644 --- a/src/workbench/WindowResizeHandles.tsx +++ b/src/workbench/WindowResizeHandles.tsx @@ -1,11 +1,12 @@ /** - * Frameless-window resize handles: Windows gives undecorated windows no - * resize borders, so the title bar's frameless look leaves the window - * stuck at its launch size. Eight invisible hit areas along the edges + * Frameless-window resize handles: Windows and Linux on X11 give undecorated + * windows no resize borders, so the title bar's frameless look leaves the + * window stuck at its launch size. Eight invisible hit areas along the edges * start a native resize drag, like any other desktop app. * * macOS renders its own overlay title bar but still provides resize - * handles, so nothing is added there. + * handles, and on Wayland GTK draws resize edges in the client-side shadow + * (`decorate_csd` in windows.rs), so nothing is added there. */ import { useEffect, useState } from "react"; import { usePlatform } from "@/platform/platformInfo"; @@ -33,7 +34,8 @@ async function start(dir: Dir) { export function WindowResizeHandles() { // A frameless window needs them; a decorated one already has native borders. const frameless = usePlatform((s) => s.framelessTitleBar); - const enabled = isTauri() && frameless; + const gtkEdges = usePlatform((s) => s.os === "linux" && s.wayland); + const enabled = isTauri() && frameless && !gtkEdges; // A maximized or full-screen window has nothing to resize; the edges belong to the content. const [sized, setSized] = useState(false); useEffect(() => { From 53e76e29c641c365c31f64bcbf1ce7cda8675c56 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:35:52 +0800 Subject: [PATCH 073/108] fix(vcs): discover worktrees created while the project is open (#930) A linked worktree created below the project while it was open never joined the repository set: its files had no status entries or gutter marks until the project was reopened. A linked worktree's `.git` is a gitfile and its HEAD lives under `
    /.git/worktrees//`. The watcher classified both as non-metadata, `.git/worktrees` was never watched, and the repository rescan only reacted to `/.git/HEAD`. The watcher now reports the `.git` path and `.git/worktrees/**` (without `.lock` files) as VCS changes and watches `.git/worktrees`; the rescan accepts a worktree's `.git`; and the Add/Remove Worktree commands refresh the repository set directly, so both the in-IDE and the external `git worktree` paths are covered. Fixes #927. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-host/src/ops/vcs/commands.rs | 15 +++- crates/ide-host/src/project.rs | 34 +++++++-- crates/ide-host/tests/vcs_worktrees.rs | 93 ++++++++++++++++++++++++ crates/ide-vfs/src/watch.rs | 95 +++++++++++++++++++++++-- 4 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 crates/ide-host/tests/vcs_worktrees.rs diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index d3913c0a..d8655e3b 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -729,7 +729,13 @@ pub async fn vcs_add_worktree( host.require_trust(window)?; let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) + let result = project + .with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref())); + // A worktree below the project is a new repository: rescan so its files + // join the status and gutter queries (the watcher picks the gitfile up + // too, but only as a debounced event later). + project.refresh_repositories(); + result }) .await } @@ -743,7 +749,12 @@ pub async fn vcs_remove_worktree( ) -> IdeResult<()> { host.require_trust(window)?; let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await + blocking(move || { + let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force)); + project.refresh_repositories(); + result + }) + .await } pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec) -> IdeResult { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 9b4dda80..1391f618 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -325,15 +325,35 @@ impl Host { sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); } } - // A repository was cloned / initialised / removed below the project: its - // `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went. + // A repository was cloned / initialised / removed below the project: + // its `.git/HEAD` appeared or went. A linked worktree's `.git` is a + // plain gitfile, and that file is the only marker written inside the + // worktree itself, so the `.git` path counts too. let known = repos.read().roots(); - let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| { - let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten() - else { - return false; + let mut git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| { + let dir = if p.ends_with(".git/HEAD") { + p.parent().and_then(Path::parent) + } else if p.ends_with(".git") { + p.parent() + } else { + None }; - known.iter().any(|r| r == dir) != p.is_file() + let Some(dir) = dir else { return false }; + known.iter().any(|r| r == dir) != p.exists() + }); + // A linked worktree was registered or unregistered in the main + // gitdir: `git worktree add` / `remove` creates or deletes + // `.git/worktrees/`. Its own gitfile is written before the + // watcher can watch the new directory, so this entry is what + // Linux per-directory watchers see. + git_dirs |= batch.vcs.iter().any(|p| { + let rest: Vec<_> = p + .components() + .skip_while(|c| c.as_os_str() != ".git") + .skip(1) // the `.git` component itself + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2) }); if git_dirs { scan_repositories(&root, &repos, &settings, &sink); diff --git a/crates/ide-host/tests/vcs_worktrees.rs b/crates/ide-host/tests/vcs_worktrees.rs new file mode 100644 index 00000000..57304d18 --- /dev/null +++ b/crates/ide-host/tests/vcs_worktrees.rs @@ -0,0 +1,93 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, Project, ops::vcs}; +use std::{path::PathBuf, process::Command, sync::Arc, time::Duration}; + +fn git(root: &std::path::Path, args: &[&str]) { + let out = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); +} + +async fn wait_for_repos(project: &Arc, count: usize) { + for _ in 0..200 { + if project.repos().all().len() >= count { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("expected {} repositories, found {}", count, project.repos().all().len()); +} + +struct Fixture { + host: Host, + project: Arc, + root: PathBuf, + _temp: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir_all(&root).unwrap(); + git(&root, &["init", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + git(&root, &["config", "commit.gpgsign", "false"]); + git(&root, &["config", "core.autocrlf", "false"]); + std::fs::write(root.join("file.txt"), "original\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "original"]); + 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)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&root, true, false).unwrap(); + let project = host.open_project("main", &root).unwrap(); + wait_for_repos(&project, 1).await; + Self { host, project, root, _temp: temp } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.host.close_project("main"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn command_added_worktree_joins_the_repository_set() { + let f = Fixture::new().await; + let linked = f.root.join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap(); + let roots = f.project.repos().roots(); + assert!(roots.contains(&linked), "worktree below the project did not join the repository set: {roots:?}"); + assert_eq!(roots.len(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn removed_worktree_leaves_the_repository_set() { + let f = Fixture::new().await; + let linked = f.root.join("linked"); + vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap(); + assert_eq!(f.project.repos().all().len(), 2); + vcs::vcs_remove_worktree(&f.host, "main", None, linked, false).await.unwrap(); + assert_eq!(f.project.repos().all().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn watcher_picks_up_a_worktree_added_below_the_project() { + let f = Fixture::new().await; + let linked = f.root.join("watched"); + git(&f.root, &["worktree", "add", "watched", "-b", "watched"]); + wait_for_repos(&f.project, 2).await; + let roots = f.project.repos().roots(); + assert!(roots.contains(&linked), "watcher did not discover the worktree added below the project: {roots:?}"); +} diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index 44dbce5b..011cab69 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -277,6 +277,15 @@ impl DirState { self.watch_tree(path, true, &mut discovered); } } + // VCS metadata directories (`.git`, `.git/worktrees/`) need + // watches too: a worktree registered in the main gitdir writes its + // entry below `.git/worktrees`, and the events in a gitdir created + // during this batch would otherwise never be seen. + for path in &batch.vcs { + if !self.watched.lock().contains(path) && path.symlink_metadata().is_ok_and(|m| m.is_dir()) { + self.watch_tree(path, false, &mut discovered); + } + } for path in &batch.removed { self.unwatch_tree(path); } @@ -415,15 +424,29 @@ impl EventFilter { let idx = comps.iter().position(|c| *c == ".git")?; let rest = &comps[idx + 1..]; Some(match rest { - [one] => *one == "HEAD" || *one == "index" || *one == "packed-refs", - [first, ..] => *first == "refs" && !rest.last().is_some_and(|l| l.to_string_lossy().ends_with(".lock")), - [] => false, + // `refs` and `worktrees` are directories whose creation is itself a + // registration event (`git init`, the first `git worktree add`); + // they must surface even though the depth below them is handled + // separately. + [one] => matches!(one.to_str(), Some("HEAD" | "index" | "packed-refs" | "refs" | "worktrees" | "info")), + [first, ..] => { + let metadata = *first == "refs" + // Linked-worktree gitdirs: a worktree added or removed below + // the project writes its HEAD here. + || *first == "worktrees"; + metadata && !rest.last().is_some_and(|l| l.to_string_lossy().ends_with(".lock")) + } + // The `.git` path itself: a linked worktree's `.git` is a plain + // file (gitfile), and it is the only marker a new worktree writes + // inside its own directory. + [] => true, }) } /// Whether a directory deserves its own watch in per-directory mode: not - /// excluded or git-ignored; inside `.git` only the directory itself and - /// `refs/` (HEAD, index and refs are what VCS refresh needs). + /// excluded or git-ignored; inside `.git` the directory itself, `refs/` + /// and `worktrees/` (HEAD, index, refs and worktree metadata are what VCS + /// refresh needs). pub fn should_watch_dir(&self, dir: &Path) -> bool { let Ok(rel) = dir.strip_prefix(&self.root) else { return false }; if rel.as_os_str().is_empty() { @@ -435,6 +458,7 @@ impl EventFilter { return match rest.as_slice() { [] => true, [first, ..] if *first == "refs" => true, + [first, ..] if *first == "worktrees" => true, [only] => *only == "info", _ => false, }; @@ -690,7 +714,18 @@ mod tests { assert_eq!(g(".git/refs/heads/main"), Some(true)); assert_eq!(g(".git/refs/heads/main.lock"), Some(false)); assert_eq!(g(".git/objects/aa/bb"), Some(false)); - assert_eq!(g(".git"), Some(false)); + // The `.git` path itself: a repo dir, or a linked worktree's gitfile. + assert_eq!(g(".git"), Some(true)); + assert_eq!(g("wt/.git"), Some(true)); + // Linked-worktree gitdir metadata below the main repository. + assert_eq!(g(".git/worktrees/wt/HEAD"), Some(true)); + assert_eq!(g(".git/worktrees/wt/index.lock"), Some(false)); + // The depth-1 directories themselves: `git init` or the first + // `git worktree add` creates them in an already-watched gitdir. + assert_eq!(g(".git/refs"), Some(true)); + assert_eq!(g(".git/worktrees"), Some(true)); + assert_eq!(g(".git/info"), Some(true)); + assert_eq!(g(".git/objects"), Some(false)); } #[test] @@ -744,6 +779,9 @@ mod tests { assert!(!w("build") && w("src/build")); assert!(w(".git") && w(".git/refs") && w(".git/refs/heads") && w(".git/info")); assert!(!w(".git/objects") && !w(".git/objects/ab") && !w(".git/logs")); + // Linked-worktree gitdirs are watched so worktrees added below the + // project while it is open are discovered. + assert!(w(".git/worktrees") && w(".git/worktrees/wt") && w(".git/worktrees/wt/rebase-merge")); assert!(!filter.should_watch_dir(Path::new("/elsewhere"))); // Excludes are replaced in place (Watcher::set_excludes). *filter.excludes.write() = ExcludeMatcher::new(&["gen".into()]); @@ -861,6 +899,51 @@ mod tests { assert_eq!(batches.lock().len(), n, "no events after drop"); } + #[test] + fn per_directory_mode_follows_worktree_registrations() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + for d in ["wt", ".git/refs/heads"] { + std::fs::create_dir_all(root.join(d)).unwrap(); + } + // The worktree's gitfile is written before any watch on the worktree + // directory could register it — the same window a real + // `git worktree add` runs into. + std::fs::write(root.join("wt/.git"), "gitdir: ../.git/worktrees/wt\n").unwrap(); + let batches: Arc>> = Arc::default(); + let sink: SharedSink = Arc::new(|_e: IdeEvent| {}); + let watcher = { + let batches = batches.clone(); + Watcher::start_with( + &root, + Vec::new(), + sink, + Box::new(move |b| batches.lock().push(b.clone())), + WatchMode::PerDirectory, + ) + .unwrap() + }; + // Registering the worktree in the main gitdir surfaces as VCS metadata + // (the deepest directory itself may be created before its parent gains + // a watch, so `.git/worktrees` is the event that reliably fires)... + std::fs::create_dir_all(root.join(".git/worktrees/wt")).unwrap(); + assert!( + wait_for(&batches, |b| b.iter().any(|b| b.vcs.contains(&root.join(".git/worktrees")))), + "{:?}", + batches.lock() + ); + // ...and the registered gitdir gains a watch, so writes inside it are + // seen even though nothing reported the directory itself. + std::thread::sleep(Duration::from_millis(200)); + std::fs::write(root.join(".git/worktrees/wt/HEAD"), "ref: refs/heads/topic\n").unwrap(); + assert!( + wait_for(&batches, |b| b.iter().any(|b| b.vcs.contains(&root.join(".git/worktrees/wt/HEAD")))), + "{:?}", + batches.lock() + ); + drop(watcher); + } + #[test] fn flat_mode_reports_the_roots_own_entries_only() { let tmp = tempfile::tempdir().unwrap(); From 843ff74d5d9f31d70b5716516650e88ac5d08b52 Mon Sep 17 00:00:00 2001 From: Piettro <2058578+pcastro93@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:37:26 -0500 Subject: [PATCH 074/108] fix(tools): name uv as the installer for Python servers when it makes the virtualenv (#936) Tools and the install log named "pip (virtualenv)" for Python language servers (basedpyright, Pyright, ty, Ruff) even when the virtualenv is made with uv, which `install_venv` prefers whenever uv is on `PATH`. The label now comes from the installer that will actually run: "uv (virtualenv)" when uv is on `PATH`, unchanged otherwise. debugpy's private copy still says pip, since it is always installed with `python -m pip`. Co-authored-by: pcastro93 <2058578+pcastro93@users.noreply.github.com> --- crates/ide-tools/src/lib.rs | 15 +++++++++++++-- crates/ide-tools/src/tests.rs | 15 +++++++++++++++ src/features/tools/backend.ts | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 1822367f..af2daf93 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -540,6 +540,15 @@ impl Tools { if managed || self.method_missing(spec.method).is_some() { fallback } else { spec.method } } + /// [`Method::label`], naming the installer that will actually run: a virtualenv is + /// made with uv when it is on `PATH` (see `install_venv`). + fn method_label(&self, method: Method) -> &'static str { + match method { + Method::PipVenv { .. } if self.which("uv").is_some() => "uv (virtualenv)", + m => m.label(), + } + } + /// Requirement check for installing `spec` right now. fn unavailable_reason(&self, spec: &ToolSpec) -> Option { self.method_missing(self.install_method(spec)) @@ -624,7 +633,7 @@ impl Tools { status, path: path.map(Into::into), version, - install_method: Some(self.install_method(spec).label().to_string()), + install_method: Some(self.method_label(self.install_method(spec)).to_string()), can_install: unavailable_reason.is_none() && !bundled, unavailable_reason, can_uninstall, @@ -796,7 +805,9 @@ impl Tools { let result = (|| { std::fs::create_dir_all(&self.root)?; let method = self.install_method(spec); - emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) }); + emit(InstallEvent::Line { + text: format!("Installing {} via {}…", spec.display, self.method_label(method)), + }); match method { Method::Bundled { .. } => { bundled::install(&self.root, spec)?; diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index ccb35e2b..40112196 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -161,6 +161,21 @@ fn interchangeable_servers_list_their_alternatives() { assert!(find(&list, "rust-analyzer").alternatives.is_empty()); } +#[cfg(unix)] +#[test] +fn python_servers_name_uv_when_it_makes_the_virtualenv() { + let env = Env::new(); + env.script(&env.sys, "uv", "exit 0"); + let list = env.tools().list(&langs()); + for name in ["basedpyright", "ty", "ruff"] { + let tool = find(&list, name); + assert_eq!(tool.install_method.as_deref(), Some("uv (virtualenv)"), "{name}"); + assert!(tool.can_install, "{name}: uv alone is enough"); + } + // debugpy's private copy is always made by `python -m pip`. + assert_eq!(find(&list, "debugpy").install_method.as_deref(), Some("pip (private copy)")); +} + #[cfg(unix)] #[test] fn detects_tools_on_path_with_versions() { diff --git a/src/features/tools/backend.ts b/src/features/tools/backend.ts index f0ae5756..f472418a 100644 --- a/src/features/tools/backend.ts +++ b/src/features/tools/backend.ts @@ -99,7 +99,7 @@ export function sampleTools(): ToolInfo[] { name, displayName: name === "pyright" ? "Pyright" : name === "ty" ? "ty (Python)" : name, languages: ["Python"], - installMethod: "pip (virtualenv)", + installMethod: "uv (virtualenv)", alternatives: ["basedpyright", "pyright", "ty"], }), ), From c50d212a1d0f90f60a85bdc8561237c482e77e19 Mon Sep 17 00:00:00 2001 From: youngtseup <53266026+youngtseup@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:38:19 +0800 Subject: [PATCH 075/108] fix(app): keep update progress visible on the welcome screen (#951) Check for Updates on the welcome screen showed nothing while the request was out: the check ran only as a status-bar task, and the welcome screen has no status bar. Leaving a project also dropped every notification, including a running update download. A check started with no project or LightEdit window open now shows its progress in a balloon (keyed like the result, so the result replaces it); project windows keep it in the status bar. A project switch clears only project notifications and keeps the app-wide update ones. Co-authored-by: youngtseup <53266026+youngtseup@users.noreply.github.com> --- src/features/app/__tests__/updates.test.ts | 19 ++++++++++++++++ src/features/app/updates.tsx | 14 ++++++++++-- src/workbench/activate.ts | 4 ++-- src/workbench/notificationStore.ts | 5 +++++ src/workbench/projectNotifications.test.ts | 26 ++++++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/workbench/projectNotifications.test.ts diff --git a/src/features/app/__tests__/updates.test.ts b/src/features/app/__tests__/updates.test.ts index f1710e97..1a4a71f8 100644 --- a/src/features/app/__tests__/updates.test.ts +++ b/src/features/app/__tests__/updates.test.ts @@ -41,6 +41,7 @@ vi.mock("@/platform/ipc", async (orig) => { const { checkForUpdates, installUpdate, notifyUpdate, progressText, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY } = await import("../updates"); const { provideNotificationService } = await import("@/workbench/contributions"); +const { useProject } = await import("@/platform/project"); const { useTasks } = await import("@/workbench/tasks"); type Note = Parameters[0]; @@ -59,6 +60,7 @@ beforeEach(() => { check = deferred(); provideNotificationService({ notify: (n) => void notes.push(n), expire: (k) => void expired.push(k) }); useTasks.setState({ tasks: [] }); + useProject.setState({ project: null, lightEdit: false }); }); afterEach(() => vi.clearAllMocks()); @@ -163,6 +165,23 @@ describe("Check for Updates", () => { expect(notes.at(-1)?.title).toBe(title); expect(task(CHECK_TASK_ID)?.done).toBe(true); }); + + it("shows the check in a balloon where there is no status bar (the welcome screen)", async () => { + const done = checkForUpdates(); + expect(notes[0]).toMatchObject({ level: "info", title: "Checking for updates…", key: UPDATE_KEY }); + check.resolve(ok({ enabled: true, update: null })); + await done; + expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.21 is up to date", key: UPDATE_KEY }); + }); + + it("leaves the check to the status bar in a project window", async () => { + useProject.setState({ project: { root: "/p", name: "p", trusted: true, isGit: false, settings: {} } as never }); + const done = checkForUpdates(); + expect(notes).toHaveLength(0); + check.resolve(ok({ enabled: true, update: null })); + await done; + expect(notes.at(-1)?.title).toBe("OpenIDEA 0.1.21 is up to date"); + }); }); describe("progressText", () => { diff --git a/src/features/app/updates.tsx b/src/features/app/updates.tsx index 7cdae992..e0451b85 100644 --- a/src/features/app/updates.tsx +++ b/src/features/app/updates.tsx @@ -1,7 +1,7 @@ /** * Update check and install. Both run as background tasks in the status bar: the - * check while the request is out, the install with download progress and Cancel, - * which its balloon shows too (the welcome screen has no status bar). + * check while the request is out, the install with download progress and Cancel. + * Their balloons show the progress too (the welcome screen has no status bar). * Every entry point (Help menu, toolbar gear, welcome screen, Search Everywhere, * the "is available" notification and its log entry) goes through these, so a * repeated click joins the running check or install instead of starting another. @@ -17,6 +17,7 @@ import { type UpdateInfo, type UpdateProgress, } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; import { upsertTask } from "@/workbench/tasks"; import { UpdateBalloonBody } from "./UpdateBalloonBody"; @@ -45,6 +46,15 @@ async function runCheck() { let abandon = () => {}; const abandoned = new Promise<"cancelled">((resolve) => (abandon = () => resolve("cancelled"))); upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: null, done: false, cancel: () => abandon() }); + // No status bar (the welcome screen): the balloon is the only place the check can show. + const { project, lightEdit } = useProject.getState(); + if (!installing && !project && !lightEdit) + notifications().notify({ + level: "info", + title, + body: , + key: UPDATE_KEY, + }); try { const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]); if (r === "cancelled") return; diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 71ea2b99..2cc8abd9 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -15,7 +15,7 @@ import { installBrowserGuards } from "./browserGuards"; import { installDistractionFree } from "./distractionFree"; import { installFileDrop } from "./fileDrop"; import { installFocusTracking } from "./focus"; -import { clearNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore"; +import { clearProjectNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore"; import { NOTIFICATIONS_TOOL_WINDOW, NotificationsHeaderActions, @@ -51,7 +51,7 @@ export function activateWorkbench(): () => void { const unsubProject = useProject.subscribe((s, prev) => { if (s.project?.root === prev.project?.root && s.lightEdit === prev.lightEdit) return; // The previous project's notifications (and their actions) no longer apply. - if (prev.project) clearNotifications(); + if (prev.project) clearProjectNotifications(); onProject(s.project?.root ?? null); }); diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index 5069884a..2a0b4e26 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -175,6 +175,11 @@ export function clearNotifications() { useNotifications.setState({ items: [] }); } +/** A project switch drops the previous project's notifications; app-wide ones (updates) stay. */ +export function clearProjectNotifications() { + useNotifications.setState((s) => ({ items: s.items.filter((x) => x.group === "updates") })); +} + /** The list was seen: everything is read and no longer shown as a balloon. */ export function markAllNotificationsRead() { useNotifications.setState((s) => ({ diff --git a/src/workbench/projectNotifications.test.ts b/src/workbench/projectNotifications.test.ts new file mode 100644 index 00000000..9294cab5 --- /dev/null +++ b/src/workbench/projectNotifications.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { useProject } from "@/platform/project"; +import { activateWorkbench } from "./activate"; +import { notify, useNotifications } from "./notificationStore"; + +afterEach(() => { + useNotifications.setState({ items: [] }); + useProject.setState({ project: null, lightEdit: false }); +}); + +describe("notifications across project changes", () => { + it("keeps app-level update notifications and drops project ones", () => { + useProject.setState({ + project: { root: "/p", name: "p", trusted: true, isGit: false, settings: {} } as never, + }); + const dispose = activateWorkbench(); + try { + notify({ level: "info", title: "Downloading OpenIDEA 0.1.35", key: "app.update", sticky: true }); + notify({ level: "info", title: "Git refresh failed", group: "vcs" }); + useProject.setState({ project: null }); + expect(useNotifications.getState().items.map((n) => n.title)).toEqual(["Downloading OpenIDEA 0.1.35"]); + } finally { + dispose(); + } + }); +}); From 2161a2ca03876418a3cc18d56609702fade0c8fa Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:39:29 +0800 Subject: [PATCH 076/108] fix(lsp): serialize the jdt.ls classpath resolve with the build lock (#946) Running a main class in a multi-module Maven project could fail with "Cannot resolve the classpath: jdtls: Attempted to beginRule: P/, does not match outer scope rule: P/". `java.project.getClasspaths` and `getSettings` ran outside the jdt.ls build lock, so they could overlap a build or an m2e refresh of a sibling module that takes a nested Eclipse scheduling rule. The launch now resolves the classpath and JDK under the same build lock the build commands use. A delegated external build already holds that lock across the launch, so its callers pass `build_lock_held` instead of waiting on it again, which would deadlock. Fixes #933. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-host/src/ops/java.rs | 2 +- crates/ide-lsp/src/jdtls.rs | 27 +++++++++++++++++------ crates/ide-lsp/tests/mock_server.rs | 34 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index d1cc0a61..00da1803 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -59,7 +59,7 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? }; 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 mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); let data_dir = host.local_data_dir.clone(); let cancel = cancel.clone(); diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 8198a443..3b84f156 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -521,11 +521,15 @@ impl LspManager { } /// Builds (when asked), then resolves the classpath and JDK of the module at `module`. + /// The classpath queries take the module's workspace scheduling rule like the build + /// commands do, so they run under the build lock; pass `build_lock_held` when the caller + /// already holds it (the delegated external build), or the wait would deadlock. pub async fn java_launch( &self, module: &Path, test: bool, build: bool, + build_lock_held: bool, cancel: &CancellationToken, ) -> IdeResult { let build = if build { Some(self.java_build(module, cancel).await?) } else { None }; @@ -538,14 +542,23 @@ impl LspManager { } _ => (Vec::new(), 0), }; - let cp = self.java_classpath(module, test, cancel).await?; - let java_home = match self.java_vm_location(module, cancel).await { - Ok(home) => home, - Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), - Err(e) => { - tracing::debug!("java.project.getSettings: {e}"); + let (cp, java_home) = { + let (server, _) = self.jdtls(module, cancel).await?; + let _lock = if build_lock_held { None - } + } else { + Some(cancellable_wait(cancel, server.java_build_lock.lock()).await?) + }; + let cp = self.java_classpath(module, test, cancel).await?; + let java_home = match self.java_vm_location(module, cancel).await { + Ok(home) => home, + Err(e) if e.kind == ErrorKind::Cancelled => return Err(e), + Err(e) => { + tracing::debug!("java.project.getSettings: {e}"); + None + } + }; + (cp, java_home) }; Ok(JavaLaunchInfo { build, diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index b33110a2..dc66dd96 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1166,6 +1166,40 @@ async fn unmanaged_java_run_imports_sources_without_an_open_editor() { assert!(state.notified("textDocument/didOpen").is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn java_launch_serializes_the_classpath_with_the_build_lock() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let (_, _file) = h.open("Main.java", "public class Main {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let state = h.launcher.state("jdtls"); + state.respond("java/buildWorkspace", json!(1)); + + // A launch with a build re-takes the lock after java_build has released it. + let info = tokio::time::timeout( + Duration::from_secs(30), + h.mgr.java_launch(&h.root, false, true, false, &Default::default()), + ) + .await + .expect("java_launch deadlocked on the build lock") + .unwrap(); + assert!(info.class_paths.is_empty()); + + // The delegated external build holds the lock while java_launch resolves the + // classpath; java_launch must not re-take it, or the launch would deadlock. + let guard = h.mgr.java_external_build(&h.root, &Default::default()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(30), h.mgr.java_launch(&h.root, false, false, true, &Default::default())) + .await + .expect("java_launch deadlocked on the delegated external build's lock") + .unwrap(); + drop(guard); + let commands: Vec<_> = state + .requested("workspace/executeCommand") + .iter() + .filter_map(|p| p["command"].as_str().map(str::to_owned)) + .collect(); + assert!(commands.contains(&"java.project.getClasspaths".to_owned())); +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started. From 038c23a2c8be7414d6e0e5b7afb6a7c11d8234b2 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:40:18 +0800 Subject: [PATCH 077/108] feat(vcs): tint test files green in the changes tree (#940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changes tree in the Commit and Local Changes views now gives test files a light green background, so test changes stand out from production ones. A file counts as a test when it lies under a test source root (detected or marked by hand) or is named like a test (`*.test.ts`, `*Test.java`, …), the same check the project tree uses; folders under a test root get the tint too. Hover and selection still win. The colour is a new `--bg-file-tests` token derived from `--success` and `--bg-island`, so every theme gets a matching tint, and it follows test roots as they are marked or unmarked. Fixes #931. Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- src/features/vcs/CommitView.module.css | 4 ++ src/features/vcs/CommitView.tsx | 5 ++ .../vcs/__tests__/testFileColor.test.tsx | 65 +++++++++++++++++++ src/styles/tokens.css | 2 + 4 files changed, 76 insertions(+) create mode 100644 src/features/vcs/__tests__/testFileColor.test.tsx diff --git a/src/features/vcs/CommitView.module.css b/src/features/vcs/CommitView.module.css index bfe5eb2e..6f69a9c5 100644 --- a/src/features/vcs/CommitView.module.css +++ b/src/features/vcs/CommitView.module.css @@ -218,6 +218,10 @@ gap: 4px; border-radius: var(--radius-small); } +/* Test files and the folders under a test root, as IDEA's green "Tests" file colour; hover and selection win. */ +.testFile:not(:hover):not([aria-selected="true"]) { + background: var(--bg-file-tests); +} .dropTarget { box-shadow: inset 0 0 0 1px var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index b809529d..cfb95a41 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -8,6 +8,7 @@ import { tx } from "@/platform/i18n"; import clsx from "clsx"; import { executeAction } from "@/platform/actions"; import { dirname } from "@/platform/project"; +import { isTestFile, useEffectiveRoots } from "@/platform/projectRoots"; import { Button, Checkbox, @@ -398,6 +399,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { const viewRef = useRef(null); const repos = useVcs((s) => s.roots); + const projectRoots = useEffectiveRoots(); const roots = useMemo( () => buildChangeTree(status, root, groupByDir, lists, repos), [status, root, groupByDir, lists, repos], @@ -524,6 +526,9 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { getLabel={nodeText} getDirectory={(n) => (n.path && n.type !== "group" ? (n.type === "dir" ? n.path : dirname(n.path)) : null)} getChildren={(n) => n.children} + rowClassName={(n) => + n.type !== "group" && n.path && isTestFile(n.path, projectRoots) ? styles.testFile : undefined + } isExpandable={(n) => n.type !== "file"} expanded={expanded} onExpandedChange={(_next, node, open) => diff --git a/src/features/vcs/__tests__/testFileColor.test.tsx b/src/features/vcs/__tests__/testFileColor.test.tsx new file mode 100644 index 00000000..83d71f60 --- /dev/null +++ b/src/features/vcs/__tests__/testFileColor.test.tsx @@ -0,0 +1,65 @@ +import { act, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, call: vi.fn() }; +}); + +vi.mock("../DiffEditor", () => ({ DiffEditor: () =>
    })); + +import { ChangesTree } from "../CommitView"; +import { useVcs } from "../store"; +import { useProject } from "@/platform/project"; +import { useProjectRoots } from "@/platform/projectRoots"; + +const change = (relPath: string) => ({ + path: `/p/${relPath}`, + relPath, + staged: null, + unstaged: "modified" as const, + renamedFrom: null, +}); + +/** The tree row (`role=treeitem`) showing `name`. */ +const row = (name: string) => screen.getByText(name).closest('[role="treeitem"]') as HTMLElement; + +describe("test files in the changes tree (#931)", () => { + beforeEach(() => { + localStorage.clear(); + useProject.setState({ + project: { name: "p", root: "/p", trusted: true, isGit: true, settings: {} } as never, + settings: {} as never, + }); + useProjectRoots.setState({ + root: "/p", + detected: [{ path: "src/test/java", kind: "test", detectedFrom: "pom.xml" }], + }); + useVcs.setState({ + root: "/p", + repo: "/p", + roots: ["/p"], + status: [ + change("src/main/java/App.java"), + change("src/test/java/AppTest.java"), + change("web/button.test.ts"), + change("web/button.ts"), + ], + }); + }); + + it("tints files under a test root or named like a test, as IDEA's Tests scope does", () => { + render(); + expect(row("AppTest.java").className).toMatch(/testFile/); + expect(row("button.test.ts").className).toMatch(/testFile/); + expect(row("App.java").className).not.toMatch(/testFile/); + expect(row("button.ts").className).not.toMatch(/testFile/); + }); + + it("follows a folder being marked as test sources", () => { + render(); + expect(row("App.java").className).not.toMatch(/testFile/); + act(() => useProjectRoots.setState({ detected: [{ path: "src/main/java", kind: "test", detectedFrom: null }] })); + expect(row("App.java").className).toMatch(/testFile/); + }); +}); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index ac12c4d8..6d27f313 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -314,6 +314,8 @@ html { --bg-tree-selected: #2a4371; --bg-tree-selected-inactive: #33353b; --bg-tree-hover: #ffffff10; + /* IDEA's "Tests" file colour (FileColor.Green), derived so every theme gets a matching tint. */ + --bg-file-tests: color-mix(in srgb, var(--success) 14%, var(--bg-island)); --bg-list-selected: var(--bg-tree-selected); --bg-toolbar-hover: #ffffff17; --bg-toolbar-pressed: #ffffff29; From b55c398fb5f784751fcffc778a4d00a4a49f1343 Mon Sep 17 00:00:00 2001 From: Piettro <2058578+pcastro93@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:42:45 -0500 Subject: [PATCH 078/108] feat(python): add Pyrefly language server (#911) Pyrefly (`pyrefly lsp`) joins basedpyright, Pyright and ty as a fallback Python language server. It is installable from Tools as a managed virtualenv tool and is listed among the interchangeable type checkers, before Ruff, so Ruff still runs after them. Pyrefly only reports type errors in projects with a Pyrefly configuration (`pyrefly.toml` or `[tool.pyrefly]`); hover, completion and navigation work without one. A real-server test (ignored by default, needs `pyrefly` on PATH) covers startup, go-to-definition and diagnostics. Co-authored-by: pcastro93 <2058578+pcastro93@users.noreply.github.com> --- crates/ide-language/src/registry.rs | 4 +- crates/ide-lsp/tests/real_servers.rs | 40 +++++++++++++++++++ crates/ide-sdk/src/apply.rs | 2 +- crates/ide-tools/src/registry.rs | 11 +++++ crates/ide-tools/src/tests.rs | 7 +++- languages/python.toml | 8 ++++ .../tools/__tests__/serverSettings.test.tsx | 7 ++-- src/features/tools/backend.ts | 13 ++++-- src/platform/locales/textCatalog.test.ts | 1 + 9 files changed, 83 insertions(+), 10 deletions(-) diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index d777ba37..ee48ab61 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -553,7 +553,7 @@ mod tests { fn alternative_servers_are_fallbacks() { let reg = reg(); for (lang, servers) in [ - ("python", vec!["basedpyright", "pyright", "ty"]), + ("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]), ("typescript", vec!["vtsls", "typescript-language-server"]), ] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -610,7 +610,7 @@ mod tests { let cfg = reg.get(&LanguageId::new("python")).unwrap(); let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect(); // After the fallbacks: hover, rename and symbols stay with the type checker. - assert_eq!(names, ["basedpyright", "pyright", "ty", "ruff"]); + assert_eq!(names, ["basedpyright", "pyright", "ty", "pyrefly", "ruff"]); let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap(); assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice())); assert!(ruff.require_root_marker && !ruff.fallback); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index ace06401..4ed35010 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -250,6 +250,46 @@ async fn ruff_next_to_basedpyright_end_to_end() { tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires pyrefly on PATH"] +async fn pyrefly_python_end_to_end() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let path = root.join("example.py"); + let source = "def greet(name: str) -> str:\n return name\n\nresult = greet('Ada')\nbad: int = 'wrong'\n"; + std::fs::write(&path, source).unwrap(); + // Pyrefly only reports type errors in projects with a Pyrefly configuration. + std::fs::write(root.join("pyrefly.toml"), "").unwrap(); + let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap(); + python.language_servers.retain(|server| server.name == "pyrefly"); + assert_eq!(python.language_servers.len(), 1); + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python])); + let doc = DocId::next(); + mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source); + retry("pyrefly ready", Duration::from_secs(30), || { + let status = last_status(&events, "pyrefly"); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status { + panic!("pyrefly failed: {msg:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + let defs = retry("pyrefly definition", Duration::from_secs(30), || { + let mgr = mgr.clone(); + async move { mgr.goto(doc, Pos::new(3, 10), GotoKind::Definition).await.ok().filter(|d| !d.is_empty()) } + }) + .await; + assert_eq!(defs[0].path, path); + assert_eq!(defs[0].range.start.line, 0); + retry("pyrefly diagnostics", Duration::from_secs(30), || { + let found = events.lock().iter().any( + |event| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "pyrefly" && !items.is_empty()), + ); + async move { found.then_some(()) } + }) + .await; +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires rust-analyzer"] async fn rust_analyzer_end_to_end() { diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index ec173c95..3f368d46 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -204,7 +204,7 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option { } } -const PYTHON_SERVERS: [&str; 6] = ["basedpyright", "pyright", "ty", "pylsp", "jedi-language-server", "ruff"]; +const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "pylsp", "jedi-language-server", "ruff"]; fn merge(base: &mut Value, layer: Value) { match (base, layer) { diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 755da795..6602f809 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -233,6 +233,17 @@ pub static TOOLS: &[ToolSpec] = &[ homepage: "https://docs.astral.sh/ty/", needs: &[], }, + ToolSpec { + name: "pyrefly", + display: "Pyrefly (Python)", + kind: LanguageServer, + commands: &["pyrefly"], + method: Method::PipVenv { package: "pyrefly" }, + fallback: None, + version: ver("pyrefly"), + homepage: "https://pyrefly.org", + needs: &[], + }, ToolSpec { name: "ruff", display: "Ruff", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 40112196..7b15ba32 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -130,6 +130,11 @@ fn everything_missing_on_an_empty_system() { assert_eq!(ty.command, "ty"); assert_eq!(ty.install_method.as_deref(), Some("pip (virtualenv)")); assert!(ty.languages.iter().any(|language| language == "Python")); + let pyrefly = find(&list, "pyrefly"); + assert_eq!(pyrefly.status, ToolStatus::Missing); + assert_eq!(pyrefly.command, "pyrefly"); + assert_eq!(pyrefly.install_method.as_deref(), Some("pip (virtualenv)")); + assert!(pyrefly.languages.iter().any(|language| language == "Python")); let angular = find(&list, "angular-language-server"); assert_eq!(angular.command, "ngserver"); assert_eq!(angular.install_method.as_deref(), Some("npm")); @@ -150,7 +155,7 @@ fn everything_missing_on_an_empty_system() { fn interchangeable_servers_list_their_alternatives() { let env = Env::new(); let list = env.tools().list(&langs()); - let python = ["basedpyright", "pyright", "ty"]; + let python = ["basedpyright", "pyright", "ty", "pyrefly"]; for name in python { assert_eq!(find(&list, name).alternatives, python); } diff --git a/languages/python.toml b/languages/python.toml index 1d3b9504..a9b4ea92 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -40,6 +40,14 @@ args = ["server"] root-markers = ["ty.toml", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"] install-hint = "uv tool install ty (or: pip install ty)" +[[language-servers]] +name = "pyrefly" +fallback = true +command = "pyrefly" +args = ["lsp"] +root-markers = ["pyrefly.toml", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"] +install-hint = "uv tool install pyrefly (or: pip install pyrefly)" + # Lint, format, organize imports and fix all next to the type checker, in # projects configured for Ruff (formatting is Ruff's alone: basedpyright has none). [[language-servers]] diff --git a/src/features/tools/__tests__/serverSettings.test.tsx b/src/features/tools/__tests__/serverSettings.test.tsx index 2f96403d..a176a994 100644 --- a/src/features/tools/__tests__/serverSettings.test.tsx +++ b/src/features/tools/__tests__/serverSettings.test.tsx @@ -89,7 +89,7 @@ describe("Language Servers page settings", () => { const ty = details("ty (Python)"); expect(within(ty).getByRole("button", { name: "Language server for Python" })).toHaveTextContent("Automatic"); // The language is in the label already, so names drop a "(Python)" suffix. - expect(within(ty).getByText(/basedpyright → Pyright → ty\.$/)).toBeInTheDocument(); + expect(within(ty).getByText(/basedpyright → Pyright → ty → Pyrefly\.$/)).toBeInTheDocument(); await user.click(listItem("gopls")!); expect(within(details("gopls")).getByRole("checkbox", { name: /Use gopls/ })).toBeInTheDocument(); expect(within(details("gopls")).queryByRole("button", { name: /^Language server for/ })).toBeNull(); @@ -108,7 +108,7 @@ describe("Language Servers page settings", () => { expect(picker).toHaveTextContent("ty"); await user.click(picker); await user.click(screen.getByRole("option", { name: /Automatic/ })); - for (const name of ["basedpyright", "pyright", "ty"]) { + for (const name of ["basedpyright", "pyright", "ty", "pyrefly"]) { expect(stagedFor(`languageServers.${name}.enabled`)).toBeNull(); expect(useTestForm.getState().values).not.toHaveProperty(`languageServers.${name}.enabled`); } @@ -127,10 +127,11 @@ describe("Language Servers page settings", () => { expect(stagedFor("languageServers.basedpyright.enabled")).toBe(false); expect(stagedFor("languageServers.pyright.enabled")).toBe(true); expect(stagedFor("languageServers.ty.enabled")).toBe(false); + expect(stagedFor("languageServers.pyrefly.enabled")).toBe(false); expect(picker).toHaveTextContent("Pyright"); await user.click(picker); await user.click(screen.getByRole("option", { name: /Automatic/ })); - for (const name of ["basedpyright", "pyright", "ty"]) { + for (const name of ["basedpyright", "pyright", "ty", "pyrefly"]) { expect(stagedFor(`languageServers.${name}.enabled`)).toBe(true); } }); diff --git a/src/features/tools/backend.ts b/src/features/tools/backend.ts index f472418a..f9dded63 100644 --- a/src/features/tools/backend.ts +++ b/src/features/tools/backend.ts @@ -94,13 +94,20 @@ export function sampleTools(): ToolInfo[] { version: "4.3.3", path: "/opt/homebrew/bin/typescript-language-server", }), - ...["basedpyright", "pyright", "ty"].map((name) => + ...["basedpyright", "pyright", "ty", "pyrefly"].map((name) => tool({ name, - displayName: name === "pyright" ? "Pyright" : name === "ty" ? "ty (Python)" : name, + displayName: + name === "pyright" + ? "Pyright" + : name === "ty" + ? "ty (Python)" + : name === "pyrefly" + ? "Pyrefly (Python)" + : name, languages: ["Python"], installMethod: "uv (virtualenv)", - alternatives: ["basedpyright", "pyright", "ty"], + alternatives: ["basedpyright", "pyright", "ty", "pyrefly"], }), ), tool({ name: "gopls", languages: ["Go"], installMethod: "go install" }), diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 1bba83ce..380a72fa 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -149,6 +149,7 @@ const UNTRANSLATED = new Set([ "OpenIDEA", "Spring Boot", "PostgreSQL", + "Pyrefly", "Pyright", "pytest", "Rust", From 18e16e76a409a03c59b96ab03f53ba747929d1bd Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:50:49 +0800 Subject: [PATCH 079/108] fix(desktop): pass the real window label to project-requiring commands (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Interpreter (creating a virtualenv), the Python packages list and pip install/uninstall, Settings › Git › Test, JVM listen-mode debugging and Create Patch › Save to file failed with "no project is open in this window", or ran without the project. Their commands passed an empty window label to ops that resolve the calling window's project. They now pass the real window label, as the other project-scoped commands do. A sweep of every command that passes an empty label found exactly these seven ops using it: `sdk_locate_tools`, `sdk_python_create_venv`, `sdk_python_packages`, `sdk_python_pip`, `vcs_git_version`, `debug_listen_for_jvm` and `vcs_write_patch`. Without the window, the last one skipped the project's disk sync, so a patch saved inside the project showed up as an external change. Fixes #955. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src-tauri/src/commands/debug.rs | 3 ++- src-tauri/src/commands/sdk.rs | 13 +++++++++---- src-tauri/src/commands/vcs.rs | 13 +++++++++---- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index d9cffa65..eb213460 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -261,12 +261,13 @@ pub async fn debug_attachable_processes(state: State<'_, AppState>) -> IdeResult #[tauri::command] #[specta::specta] pub async fn debug_listen_for_jvm( + window: Window, state: State<'_, AppState>, request: ide_core::RequestId, host: String, port: u16, ) -> IdeResult { - ide_host::ops::debug::debug_listen_for_jvm(&state.host, "", request, host, port).await + ide_host::ops::debug::debug_listen_for_jvm(&state.host, window.label(), request, host, port).await } /// HotSwap (Reload Changed Classes) in a java-debug session. diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index 92a8ea47..050c2c10 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -39,17 +39,19 @@ pub async fn sdk_project_libraries( #[tauri::command] #[specta::specta] pub async fn sdk_locate_tools( + window: Window, state: State<'_, AppState>, names: Vec, near: Option, ) -> IdeResult { - ide_host::ops::sdk::sdk_locate_tools(&state.host, "", names, near).await + ide_host::ops::sdk::sdk_locate_tools(&state.host, window.label(), names, near).await } /// `base -m venv location`; output lines stream through `on_output`. #[tauri::command] #[specta::specta] pub async fn sdk_python_create_venv( + window: Window, state: State<'_, AppState>, base: PathBuf, location: PathBuf, @@ -58,7 +60,7 @@ pub async fn sdk_python_create_venv( ) -> IdeResult { ide_host::ops::sdk::sdk_python_create_venv( &state.host, - "", + window.label(), base, location, request, @@ -70,17 +72,20 @@ pub async fn sdk_python_create_venv( #[tauri::command] #[specta::specta] pub async fn sdk_python_packages( + window: Window, state: State<'_, AppState>, python: PathBuf, request: RequestId, ) -> IdeResult> { - ide_host::ops::sdk::sdk_python_packages(&state.host, "", python, request).await + ide_host::ops::sdk::sdk_python_packages(&state.host, window.label(), python, request).await } /// Installs (`uninstall = false`) or removes a package; output streams through `on_output`. #[tauri::command] #[specta::specta] +#[allow(clippy::too_many_arguments)] pub async fn sdk_python_pip( + window: Window, state: State<'_, AppState>, python: PathBuf, requirement: String, @@ -91,7 +96,7 @@ pub async fn sdk_python_pip( ) -> IdeResult<()> { ide_host::ops::sdk::sdk_python_pip( &state.host, - "", + window.label(), python, requirement, uninstall, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 305fd0e8..0e06e2f5 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -371,8 +371,8 @@ pub async fn vcs_init(state: State<'_, AppState>, dir: PathBuf) -> IdeResult<()> /// executable when `path` is empty. #[tauri::command] #[specta::specta] -pub async fn vcs_git_version(state: State<'_, AppState>, path: String) -> IdeResult { - ide_host::ops::vcs::vcs_git_version(&state.host, "", path).await +pub async fn vcs_git_version(window: Window, state: State<'_, AppState>, path: String) -> IdeResult { + ide_host::ops::vcs::vcs_git_version(&state.host, window.label(), path).await } /// The auto-detected git executable (shown as the field's placeholder). @@ -656,8 +656,13 @@ pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changeli /// Create Patch › Save to file. #[tauri::command] #[specta::specta] -pub async fn vcs_write_patch(state: State<'_, AppState>, path: PathBuf, patch: String) -> IdeResult<()> { - ide_host::ops::vcs::vcs_write_patch(&state.host, "", path, patch).await +pub async fn vcs_write_patch( + window: Window, + state: State<'_, AppState>, + path: PathBuf, + patch: String, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_write_patch(&state.host, window.label(), path, patch).await } #[tauri::command] From fcfe26474de69ce47e921c74370d1c722d6c7aa0 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 14:56:56 +0800 Subject: [PATCH 080/108] fix(format): apply the Code Style indent override to reformat requests (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Editor › Code Style › Java › Tabs and Indents had no effect on Reformat Code: a file indented with 4 spaces stayed that way after choosing "Use tab character" with tab size and indent 1. There were two causes. The reformat request sent the editor's indent, which comes from the indents detected in the file, instead of the Code Style; and jdt.ls takes the per-level indent width (`indentation.size`) only from its formatter profile, never from the request. With an explicit Tabs and Indents override for the language, Reformat Code now asks for the Code Style indent (EditorConfig still applies, the detected indents don't; without an override the editor's indent is kept). The jdt.ls profile carries the same override: it is patched into the bundled IntelliJ profile, or put into a minimal generated profile for the default formatter, and custom profiles are left alone. An unset Indent follows the tab size, as in Code Style. With tabs the request sends the tab size; every editor of a document gets the override when it is created, splits and reopened tabs included, not only after a settings change. Checked with jdt.ls 1.63 on JDK 25: tab character, tab size 1 and indent 1 reformat a 4-space file to one tab per level with both the default and the IntelliJ formatter. Fixes #935. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-lsp/src/jdtls/formatter.rs | 146 ++++++++++++++++++++++++- src/editor/core/cm/setup.ts | 21 +++- src/editor/core/model/document.test.ts | 19 ++++ src/editor/core/model/document.ts | 15 ++- src/editor/core/model/slot.ts | 1 + src/editor/lsp/format.test.ts | 37 ++++++- src/editor/lsp/format.ts | 12 +- 7 files changed, 242 insertions(+), 9 deletions(-) diff --git a/crates/ide-lsp/src/jdtls/formatter.rs b/crates/ide-lsp/src/jdtls/formatter.rs index f7c19dea..18c2b3cd 100644 --- a/crates/ide-lsp/src/jdtls/formatter.rs +++ b/crates/ide-lsp/src/jdtls/formatter.rs @@ -7,6 +7,61 @@ use std::path::Path; const PROFILE: &str = "OpenIDEA IntelliJ IDEA"; const XML: &str = include_str!("intellij.xml"); +const GENERATED_PROFILE: &str = "OpenIDEA Code Style"; +const TAB_CHAR: &str = "org.eclipse.jdt.core.formatter.tabulation.char"; +const TAB_SIZE: &str = "org.eclipse.jdt.core.formatter.tabulation.size"; +const INDENT_SIZE: &str = "org.eclipse.jdt.core.formatter.indentation.size"; + +/// The Tabs and Indents override as Eclipse formatter settings. The profile must +/// carry them too: `indentation.size`, the per-level width, cannot come from the +/// format request, and an unset Indent follows the tab size, as in IDEA. +fn indent_settings(style: &LanguageCodeStyle) -> Option> { + let tab_char = style.use_tab.map(|tab| if tab { "tab" } else { "space" }); + let indent = style.indent.or(style.tab_size); + if tab_char.is_none() && style.tab_size.is_none() && indent.is_none() { + return None; + } + let mut settings: Vec<(&'static str, String)> = Vec::new(); + if let Some(char) = tab_char { + settings.push((TAB_CHAR, char.to_owned())); + } + if let Some(size) = style.tab_size { + settings.push((TAB_SIZE, size.to_string())); + } + if let Some(size) = indent { + settings.push((INDENT_SIZE, size.to_string())); + } + Some(settings) +} + +fn patch_profile_xml(mut xml: String, settings: &[(&'static str, String)]) -> String { + for (id, value) in settings { + let marker = format!(r#" String { + let mut xml = format!( + r#" + + +"#, + GENERATED_PROFILE + ); + for (id, value) in settings { + xml.push_str(&format!(r#" "#)); + xml.push('\n'); + } + xml.push_str(" \n\n"); + xml +} /// Protocol settings only: switching profiles reaches a running jdtls through /// didChangeConfiguration. Explicit language-server settings are applied later. @@ -31,12 +86,17 @@ pub fn configure_formatter( settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides); cfg.settings = Some(settings); let Some(style) = style else { return Ok(()) }; + let indent = indent_settings(style); let (path, profile) = match style.formatter.as_deref() { Some("intellij") => { let path = data_dir.join("lsp/jdtls/intellij.xml"); std::fs::create_dir_all(path.parent().unwrap())?; - if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) { - ide_vfs::write_atomic(&path, XML.as_bytes())?; + let xml = match indent { + Some(indent) => patch_profile_xml(XML.to_owned(), &indent), + None => XML.to_owned(), + }; + if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) { + ide_vfs::write_atomic(&path, xml.as_bytes())?; } (path, PROFILE) } @@ -54,9 +114,22 @@ pub fn configure_formatter( if !path.is_file() { return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display()))); } + // The user's profile defines the style; the override stays editor-only. (path, "") } - _ => return Ok(()), + _ => { + // Default formatter: with a Tabs and Indents override, hand jdtls a + // minimal generated profile carrying it — the format request cannot + // set indentation.size. Without an override the built-in style stays. + let Some(indent) = indent else { return Ok(()) }; + let path = data_dir.join("lsp/jdtls/indent.xml"); + std::fs::create_dir_all(path.parent().unwrap())?; + let xml = indent_profile_xml(&indent); + if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) { + ide_vfs::write_atomic(&path, xml.as_bytes())?; + } + (path, GENERATED_PROFILE) + } }; let mut settings = cfg.settings.take().unwrap_or_else(|| json!({})); settings["java"]["format"]["settings"] = formatter_overrides( @@ -144,6 +217,73 @@ mod tests { } } + #[test] + fn tabs_and_indents_override_reaches_the_formatter_profile() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let data = dir.path().join("data"); + std::fs::create_dir_all(&root).unwrap(); + + // The IntelliJ profile is the bundled XML with the override patched in. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { + formatter: Some("intellij".into()), + use_tab: Some(true), + tab_size: Some(1), + indent: Some(1), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone(); + assert_eq!(settings["profile"], PROFILE); + let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap(); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + + // The default formatter gets a minimal generated profile. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { use_tab: Some(false), tab_size: Some(2), ..Default::default() }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone(); + let path = data.join("lsp/jdtls/indent.xml"); + assert_eq!(settings["url"], crate::uri::path_to_uri_string(&path)); + assert_eq!(settings["profile"], GENERATED_PROFILE); + let xml = std::fs::read_to_string(&path).unwrap(); + assert!(xml.contains(&format!(r#""#))); + // An unset Indent follows the tab size, as in IDEA. + assert!(xml.contains(&format!(r#""#))); + + // Only Indent: the bundled tab settings stay. + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { formatter: Some("intellij".into()), indent: Some(2), ..Default::default() }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap(); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + assert!(patched.contains(&format!(r#""#))); + } + + #[test] + fn tabs_and_indents_override_leaves_custom_profiles_alone() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let custom = root.join("style.xml"); + std::fs::write(&custom, XML).unwrap(); + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { + formatter: Some("custom".into()), + formatter_path: Some("style.xml".into()), + use_tab: Some(true), + tab_size: Some(1), + indent: Some(1), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), &root, dir.path(), None).unwrap(); + assert_eq!(std::fs::read_to_string(&custom).unwrap(), XML); + } + #[test] fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 059b6c13..3197fb1f 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -2,7 +2,7 @@ * The base extension set of every text editor, with compartments for the * parts that change at runtime (language, settings, contributions). */ -import { Compartment, EditorState, Prec, type Extension, type Range } from "@codemirror/state"; +import { Compartment, EditorState, Facet, Prec, type Extension, type Range } from "@codemirror/state"; import { Decoration, EditorView, @@ -154,13 +154,26 @@ export function hardWrapAt(settings: EditorSettings | undefined, info: LanguageI return (info && style.languages[info.id]?.hardWrapAt) || style.hardWrapAt; } -export function indentExtension(indent: EffectiveIndent): Extension { +/** `format`: the indent Reformat Code asks for (see `formatIndentFacet`); `null` keeps the editor's. */ +export function indentExtension(indent: EffectiveIndent, format: EffectiveIndent | null = null): Extension { return [ + formatIndentFacet.of(format), EditorState.tabSize.of(indent.tabSize), indentUnit.of(indent.insertSpaces ? " ".repeat(Math.max(1, indent.indentSize ?? indent.tabSize)) : "\t"), ]; } +/** + * Indentation explicit reformat requests use: the Code Style without the file's + * detected indents (detection affects editing, reformatting uses the configured + * style, as in IDEA). Set only when the language has an explicit Tabs and Indents + * override — unset, formatting keeps the editor's indent, the status bar choice + * included. + */ +export const formatIndentFacet = Facet.define({ + combine: (values) => [values.find((v) => v !== null) ?? null], +}); + export function fontExtension(settings: EditorSettings | undefined): Extension { const vars: string[] = []; const prefs = prefsOf(settings); @@ -297,6 +310,8 @@ export interface SetupOptions { docLength: number; /** Effective indentation of the document (detected / overridden); computed when absent. */ indent?: EffectiveIndent; + /** Indentation for Reformat Code (`formatIndentFacet`); the editor's when absent. */ + formatIndent?: EffectiveIndent | null; /** Soft wraps toggled for this file (View › Active Editor › Soft-Wrap). */ wrapOverride?: boolean | null; /** Extra extensions owned by the slot (update listeners, focus handlers). */ @@ -358,7 +373,7 @@ export function baseExtensions(o: SetupOptions): Extension { keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]), compartments.language.of(o.language ?? []), compartments.comments.of(commentTokens(o.info, o.ctx.language)), - compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info))), + compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info), o.formatIndent)), compartments.readOnly.of(readOnlyExtension(o.ctx.readOnly)), compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)), compartments.sticky.of(stickyExtension(o.settings)), diff --git a/src/editor/core/model/document.test.ts b/src/editor/core/model/document.test.ts index 1b066027..79c35655 100644 --- a/src/editor/core/model/document.test.ts +++ b/src/editor/core/model/document.test.ts @@ -6,6 +6,7 @@ import { createMemoryBackend, type MemoryBackend } from "../backend"; import { _resetLanguages } from "../cm/languages"; import { DocumentManager, ModifiedRanges, saveCleanupChanges, useDocMeta } from "./document"; import { EditorSlot, type SlotHost } from "./slot"; +import { formatOptionsOf } from "@/editor/lsp/format"; const host: SlotHost = { onFocus: () => {}, @@ -37,6 +38,24 @@ function setup(files: Record, settings = {}) { afterEach(() => useDocMeta.setState({}, true)); describe("DocumentManager + slots", () => { + it("gives a new slot the Code Style indent for Reformat Code, not the detected one", async () => { + const settings = { + editor: { + codeStyle: { + languages: { java: { useTab: true, tabSize: 1, indent: 1 } }, + detectIndents: true, + editorconfig: false, + }, + }, + }; + const { manager } = setup({ "/p/A.java": "class A {\n int x;\n}\n" }, settings); + const model = await manager.acquire("/p/A.java"); + const first = new EditorSlot(model, "t1", host); + const split = new EditorSlot(model, "t2", host, { from: first }); + for (const slot of [first, split]) + expect(formatOptionsOf(slot.currentState())).toEqual({ tabSize: 1, insertSpaces: false }); + }); + it("shares one document between split slots and syncs edits to the backend", async () => { const { backend, manager, frame } = setup({ "/p/a.txt": "hello\n" }); const model = await manager.acquire("/p/a.txt"); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index a6bd1f18..e0e8355f 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -405,6 +405,19 @@ export class DocModel { return this.indentOverride ?? effectiveIndent(this.settings(), this.info, this.editorconfig, this.detectedIndent); } + /** + * Indentation explicit reformat requests use (`formatIndentFacet`): with an + * explicit per-language Tabs and Indents override, the Code Style without the + * file's detected indents — the override must reach the formatter, or the + * language server keeps its own style. Without an override formatting keeps + * the editor's indent. + */ + formatIndent() { + const lang = this.info ? prefsOf(this.settings()).codeStyle.languages[this.info.id] : undefined; + if (!lang || (lang.useTab === undefined && lang.tabSize === undefined && lang.indent === undefined)) return null; + return effectiveIndent(this.settings(), this.info, this.editorconfig, null); + } + /** Soft wraps toggled for this file (wins over settings until the file is closed). */ softWrapOverride: boolean | null = null; @@ -429,7 +442,7 @@ export class DocModel { const indent = this.effectiveIndent(); this.setMeta({ indent: { tabSize: indent.tabSize, insertSpaces: indent.insertSpaces } }); const effects = [ - compartments.indent.reconfigure(indentExtension(indent)), + compartments.indent.reconfigure(indentExtension(indent, this.formatIndent())), compartments.wrap.reconfigure(wrapExtension(editor, this.path, this.softWrapOverride)), compartments.sticky.reconfigure(stickyExtension(editor)), compartments.font.reconfigure(fontExtension(editor)), diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index bb818b1c..54221ce6 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -93,6 +93,7 @@ export class EditorSlot { language: model.language, docLength: model.sync.localText.length, indent: model.effectiveIndent(), + formatIndent: model.formatIndent(), wrapOverride: model.softWrapOverride, extra: this.slotExtensions(), }), diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index 8f21dd7c..71f46be7 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -11,7 +11,42 @@ import { protectedRegions, type FormatGuardOptions, } from "@/platform/formatGuard"; -import { dedentsToColumnZero, formatDocument, formattedLines } from "./format"; +import { indentExtension } from "@/editor/core/cm/setup"; +import { dedentsToColumnZero, formatDocument, formatOptionsOf, formattedLines } from "./format"; + +describe("formatOptionsOf", () => { + const stateWith = (...args: Parameters) => + EditorState.create({ extensions: indentExtension(...args) }); + + it("asks for the Code Style override, not the indent detected in the file", () => { + // Code Style › Java: tab character, tab size 1; the file was indented with 4 spaces. + const state = stateWith({ tabSize: 4, insertSpaces: true }, { tabSize: 1, insertSpaces: false, indentSize: 1 }); + expect(formatOptionsOf(state)).toEqual({ tabSize: 1, insertSpaces: false }); + }); + + it("sends the tab size with tabs and the indent size with spaces", () => { + const editor = { tabSize: 8, insertSpaces: true }; + expect(formatOptionsOf(stateWith(editor, { tabSize: 4, insertSpaces: false, indentSize: 2 }))).toEqual({ + tabSize: 4, + insertSpaces: false, + }); + expect(formatOptionsOf(stateWith(editor, { tabSize: 4, insertSpaces: true, indentSize: 2 }))).toEqual({ + tabSize: 2, + insertSpaces: true, + }); + }); + + it("keeps the editor's indent without an override", () => { + expect(formatOptionsOf(stateWith({ tabSize: 4, insertSpaces: true, indentSize: 2 }))).toEqual({ + tabSize: 2, + insertSpaces: true, + }); + expect(formatOptionsOf(stateWith({ tabSize: 3, insertSpaces: false }))).toEqual({ + tabSize: 3, + insertSpaces: false, + }); + }); +}); describe("formattedLines", () => { const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 5d3031c3..e3184a94 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -24,6 +24,7 @@ import { import { useProject } from "@/platform/project"; import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api"; import { docOf, lspRequest, reportLspError } from "./lsp"; +import { formatIndentFacet } from "../core/cm/setup"; import { offsetToPos, textEditsToChanges } from "./positions"; import { tx, txTemplate } from "@/platform/i18n"; import { showHint } from "./hint"; @@ -71,8 +72,17 @@ function formattedHint(view: EditorView, lines: number) { }); } -/** LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. */ +/** + * LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. + * An explicit Code Style override replaces the editor's indent; the server's formatter + * profile carries the same override, so both agree. + */ export function formatOptionsOf(state: EditorState): { tabSize: number; insertSpaces: boolean } { + const style = state.facet(formatIndentFacet)[0]; + if (style) + return style.insertSpaces + ? { tabSize: style.indentSize ?? style.tabSize, insertSpaces: true } + : { tabSize: style.tabSize, insertSpaces: false }; const unit = state.facet(indentUnit); return unit.includes("\t") ? { tabSize: state.tabSize, insertSpaces: false } From 002401e8a771714c5b2a6329f0378aedd5368297 Mon Sep 17 00:00:00 2001 From: Roberto Scinocca Date: Tue, 3 Nov 2026 08:05:09 +0100 Subject: [PATCH 081/108] feat(languages): add ERB with an HTML + Ruby editor mode (#720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby gets an ERB editor mode (HTML with embedded Ruby), Ruby / Rake / RSpec run configuration types, and a Settings › Languages & Frameworks › Ruby page for choosing the interpreter (Auto picks the `.ruby-version` / mise / asdf install, else `ruby` on PATH). - Run: `.rb` scripts run from the editor context menu, `_spec.rb` / `_test.rb` files narrow an RSpec or Rake (`SPEC=` / `TEST=`) configuration, and Rakefile tasks are detected; Rake and RSpec go through `bundle exec` when the project has a Gemfile. The chosen interpreter's `ruby`, `bundle`, `rake` and `rspec` are used and its bin directory is put on PATH. - Configuration forms rebuild from a raw command line keeping option values with their options (`--format documentation`, `-I lib`), and a new Ruby configuration opens in the Ruby form with the script still to fill in. - External Libraries lists the Ruby standard library and the bundle's gems, including those Bundler installs inside the project (`vendor/bundle`). - Ruby is part of the frontend settings defaults and normalization, so the selected interpreter survives a settings round-trip. Closes #719 Co-authored-by: retsef <2175889+retsef@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 8 + crates/ide-core/src/version_managers.rs | 2 + crates/ide-language/src/registry.rs | 3 + crates/ide-run/src/detect.rs | 128 ++++++++++ crates/ide-run/tests/detect.rs | 66 +++++ crates/ide-sdk/src/apply.rs | 27 ++ crates/ide-sdk/src/discover.rs | 6 +- crates/ide-sdk/src/lib.rs | 2 + crates/ide-sdk/src/libraries.rs | 21 +- crates/ide-sdk/src/ruby.rs | 237 ++++++++++++++++++ languages/erb.toml | 6 + src/bindings.ts | 8 +- src/editor/core/cm/erb.test.ts | 33 +++ src/editor/core/cm/erb.ts | 78 ++++++ src/editor/core/cm/languages.ts | 6 + src/features/libraries/index.ts | 2 + src/features/run/configs.test.ts | 19 ++ src/features/run/context.ts | 16 +- src/features/run/rubyTypes.ts | 199 +++++++++++++++ src/features/run/runConfigTypes.test.tsx | 75 ++++++ src/features/run/types.ts | 6 +- .../settings/__tests__/languages.test.ts | 9 + .../settings/languages/LanguagePages.tsx | 22 ++ src/features/settings/pages/languages.tsx | 12 +- src/platform/languageSettings.ts | 4 + src/platform/locales/text/zh.json | 24 ++ src/ui/icons.tsx | 2 +- 27 files changed, 1012 insertions(+), 9 deletions(-) create mode 100644 crates/ide-sdk/src/ruby.rs create mode 100644 languages/erb.toml create mode 100644 src/editor/core/cm/erb.test.ts create mode 100644 src/editor/core/cm/erb.ts create mode 100644 src/features/run/rubyTypes.ts diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 453b1433..38b676ca 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -548,6 +548,7 @@ pub struct LanguagesSettings { pub typescript: TypeScriptSettings, pub prettier: PrettierSettings, pub java: JavaSettings, + pub ruby: RubySettings, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -605,6 +606,13 @@ impl Default for PythonSettings { } } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct RubySettings { + /// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). + pub interpreter: String, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct NodeSettings { diff --git a/crates/ide-core/src/version_managers.rs b/crates/ide-core/src/version_managers.rs index 19e7cd4f..30eeae09 100644 --- a/crates/ide-core/src/version_managers.rs +++ b/crates/ide-core/src/version_managers.rs @@ -18,6 +18,7 @@ pub enum Tool { Go, Maven, Gradle, + Ruby, } impl Tool { @@ -30,6 +31,7 @@ impl Tool { Tool::Go => &["go", "golang"], Tool::Maven => &["maven"], Tool::Gradle => &["gradle"], + Tool::Ruby => &["ruby"], } } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index ee48ab61..984c082a 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -22,6 +22,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("dockerfile", include_str!("../../../languages/dockerfile.toml")), ("dotenv", include_str!("../../../languages/dotenv.toml")), ("elixir", include_str!("../../../languages/elixir.toml")), + ("erb", include_str!("../../../languages/erb.toml")), ("erlang", include_str!("../../../languages/erlang.toml")), ("fsharp", include_str!("../../../languages/fsharp.toml")), ("gitattributes", include_str!("../../../languages/gitattributes.toml")), @@ -520,6 +521,7 @@ mod tests { "MsGenny", "Vue", "Angular Template", + "ERB", ]; fn reg() -> LanguageRegistry { @@ -776,6 +778,7 @@ mod tests { ("AppDelegate.m", "objective-c"), ("a.rb", "ruby"), ("Gemfile", "ruby"), + ("show.html.erb", "erb"), ("index.php", "php"), ("init.lua", "lua"), ("main.dart", "dart"), diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 0f56ad50..7e60b2fd 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -33,6 +33,7 @@ pub(crate) fn detect_with_profiles( detect_maven(root, excluded_poms, &mut out); detect_go(root, &mut out); detect_python(root, &mut out); + detect_ruby(root, &mut out); cmake::detect(root, profiles, &mut out); dotnet::detect(root, &mut out); detect_make(root, &mut out); @@ -905,6 +906,133 @@ fn detect_python(root: &Path, out: &mut Vec) { } } +// ---------------------------------------------------------------------- ruby + +/// Tasks a Rakefile defines statically: `task :name`, `task "name" => deps`, +/// `task name: [...]`, qualified by enclosing `namespace` blocks. `desc` lines are +/// skipped; a `Rake::TestTask` contributes `test`. `load`/`import`ed files are not followed. +pub(crate) fn rake_tasks(src: &str) -> Vec { + let mut tasks = Vec::new(); + let mut namespaces: Vec = Vec::new(); + let mut depth: Vec = Vec::new(); // per open block: whether it is a namespace + let name_of = |rest: &str| -> Option { + let rest = rest.trim_start(); + let (name, _) = if let Some(r) = rest.strip_prefix(':') { + r.split(|c: char| !(c.is_alphanumeric() || c == '_')).next().map(|n| (n.to_owned(), ()))? + } else if let Some(r) = rest.strip_prefix(['"', '\'']) { + r.split(['"', '\'']).next().map(|n| (n.to_owned(), ()))? + } else { + let n: String = rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); + (!n.is_empty() && rest[n.len()..].starts_with(':')).then_some((n, ()))? + }; + (!name.is_empty()).then_some(name) + }; + for raw in src.lines() { + let line = raw.trim(); + if line.starts_with('#') { + continue; + } + let opens = line.ends_with(" do") || line.contains(" do |") || line.contains(" do ") && !line.contains(" end"); + if let Some(rest) = line.strip_prefix("namespace ") + && let Some(ns) = name_of(rest) + { + namespaces.push(ns); + depth.push(true); + continue; + } + if let Some(rest) = line.strip_prefix("task ").or_else(|| line.strip_prefix("task(")) + && let Some(name) = name_of(rest) + { + let full = namespaces.iter().chain([&name]).cloned().collect::>().join(":"); + if !tasks.contains(&full) { + tasks.push(full); + } + } + if line.contains("Rake::TestTask.new") || line.contains("RSpec::Core::RakeTask.new") { + let name = name_of(line.split_once(".new").map(|(_, r)| r.trim_start_matches(['(', ' '])).unwrap_or("")) + .unwrap_or_else(|| if line.contains("RSpec") { "spec".into() } else { "test".into() }); + let full = namespaces.iter().chain([&name]).cloned().collect::>().join(":"); + if !tasks.contains(&full) { + tasks.push(full); + } + } + if opens && !line.starts_with("namespace ") { + depth.push(false); + } + if (line == "end" || line.starts_with("end ") || line.starts_with("end#")) && depth.pop() == Some(true) { + namespaces.pop(); + } + } + tasks +} + +/// `bundle exec ` when the project has a Gemfile, else the bare tool. +fn ruby_tool(root: &Path, tool: &str) -> (String, Vec) { + if root.join("Gemfile").is_file() { + ("bundle".into(), vec!["exec".into(), tool.into()]) + } else { + (tool.into(), Vec::new()) + } +} + +fn ruby_config( + id: String, + name: String, + kind: &str, + (command, mut args): (String, Vec), + extra: &[&str], +) -> RunConfig { + args.extend(extra.iter().map(|a| (*a).to_owned())); + let mut c = config(id, name, kind, &command, &[]); + c.args = args; + c +} + +fn uses_rspec(root: &Path) -> bool { + root.join("spec").is_dir() + || root.join(".rspec").is_file() + || read(&root.join("Gemfile")).is_some_and(|g| g.contains("rspec")) +} + +fn detect_ruby(root: &Path, out: &mut Vec) { + let markers = ["Gemfile", "Rakefile", "rakefile", "Rakefile.rb", ".ruby-version", "config.ru"]; + let gemspec = fs::read_dir(root) + .into_iter() + .flatten() + .flatten() + .any(|e| e.path().extension().is_some_and(|x| x == "gemspec")); + if !gemspec && !markers.iter().any(|m| root.join(m).is_file()) { + return; + } + if root.join("bin/rails").is_file() { + let mut c = ruby_config( + "rails:server".into(), + "rails server".into(), + "ruby", + ("ruby".into(), Vec::new()), + &["bin/rails", "server"], + ); + c.extras.folder = Some("Rails".into()); + out.push(c); + } + if let Some(rakefile) = first_existing(root, &["Rakefile", "rakefile", "Rakefile.rb"]) + && let Some(src) = read(&rakefile) + { + for task in rake_tasks(&src) { + let mut c = + ruby_config(format!("rake:{task}"), format!("rake {task}"), "rake", ruby_tool(root, "rake"), &[&task]); + if task == "test" || task == "spec" || task.ends_with(":test") || task.ends_with(":spec") { + c = c.test(); + } + c.extras.folder = Some("Rake".into()); + out.push(c); + } + } + if uses_rspec(root) { + out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test()); + } +} + // ---------------------------------------------------------------- make / just pub(crate) fn make_targets(src: &str) -> Vec { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 48db7c56..e207f2e3 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -728,3 +728,69 @@ fn cmake_profile_build_configures_only_without_a_cache() { } } } + +#[test] +fn ruby_projects_list_rake_tasks_rspec_and_rails() { + let rakefile = "\ +require 'rake/testtask' + +desc \"Build it\" +task :build do + sh 'make' +end +task \"lint\" => [:build] +task deploy: :build do |t| + puts t +end +namespace :db do + desc 'Migrate' + task :migrate, [:version] do |t, args| + puts args + end + namespace :schema do + task :load + end +end +Rake::TestTask.new do |t| + t.libs << 'test' +end +task default: :build +"; + let dir = project(&[ + ("Gemfile", "source 'https://rubygems.org'\ngem 'rspec'\n"), + ("Rakefile", rakefile), + ("bin/rails", "#!/usr/bin/env ruby\n"), + ("spec/a_spec.rb", ""), + ]); + let c = detect(dir.path()); + assert_eq!( + ids(&c), + [ + "rails:server", + "rake:build", + "rake:lint", + "rake:deploy", + "rake:db:migrate", + "rake:db:schema:load", + "rake:test", + "rake:default", + "rspec" + ] + ); + assert_eq!(cmdline(find(&c, "rails:server")), "ruby bin/rails server"); + assert_eq!(cmdline(find(&c, "rake:db:migrate")), "bundle exec rake db:migrate"); + assert_eq!(find(&c, "rake:db:migrate").kind, "rake"); + assert!(find(&c, "rake:test").is_test); + assert_eq!(cmdline(find(&c, "rspec")), "bundle exec rspec"); + assert!(find(&c, "rspec").is_test); + assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake")); +} + +#[test] +fn ruby_without_bundler_runs_bare_tools() { + let dir = project(&[("Rakefile", "task :test\n")]); + let c = detect(dir.path()); + assert_eq!(ids(&c), ["rake:test"]); + assert_eq!(cmdline(&c[0]), "rake test"); + assert!(detect(project(&[("a.rb", "puts 1\n")]).path()).is_empty()); +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 3f368d46..0f9a937b 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -36,6 +36,12 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option { .or_else(|| pinned::pinned_python(root)) } +/// The project Ruby: the configured one, else the `.ruby-version` / mise / asdf +/// install, else `ruby` on PATH. +pub fn ruby_interpreter(root: &Path, s: &Settings) -> Option { + setting_path(root, &s.languages.ruby.interpreter).or_else(|| crate::ruby::project_ruby(root)) +} + /// The configured `node` executable's directory, else the `bin` of the Node.js the /// project pins for mise / asdf. pub(crate) fn node_dir(root: &Path, s: &Settings) -> Option { @@ -624,6 +630,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings let jdk = project_jdk(root, s); let mvn = maven_command(root, s); let mvn_options = maven_options(root, s); + let ruby = ruby_interpreter(root, s); for c in configs.iter_mut() { resolve_run_command(c, root, s); @@ -709,6 +716,26 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings c.env.entry(key).or_insert(value); } } + "ruby" | "rake" | "rspec" => { + if let Some(ruby) = &ruby + && let Some(bin) = crate::ruby::bin_dir_of(ruby) + { + // `ruby`, `bundle`, `rake`, `rspec` of the chosen interpreter when installed + // alongside it; the rest (a project `bin/rspec` binstub) stays as is. + let name = Path::new(&c.command).file_name().map(|n| n.to_string_lossy().into_owned()); + if let Some(name) = name + && matches!(name.as_str(), "ruby" | "bundle" | "rake" | "rspec" | "gem" | "irb") + && !c.command.contains('/') + && !c.command.contains('\\') + { + let exe = bin.join(exe(&name)); + if exe.is_file() { + c.command = exe.to_string_lossy().into_owned(); + } + } + path_dirs.push(bin); + } + } "maven" | "tomcat" => { if let Some(jdk) = &jdk { c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned()); diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index a9dec42e..425795bd 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -21,6 +21,7 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec { SdkKind::TypeScript => typescript(root), SdkKind::Jdk => jdk(), SdkKind::Maven => maven(), + SdkKind::Ruby => crate::ruby::detect(root), }; if let Some(root) = root { pinned_first(&mut out, kind, root); @@ -35,6 +36,7 @@ fn tool_of(kind: SdkKind) -> Option { SdkKind::Go => Some(Tool::Go), SdkKind::Jdk => Some(Tool::Java), SdkKind::Maven => Some(Tool::Maven), + SdkKind::Ruby => Some(Tool::Ruby), SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None, } } @@ -50,7 +52,7 @@ fn pinned_first(sdks: &mut Vec, kind: SdkKind, root: &Path) { } /// Runs `f` over `items` on scoped threads (version queries spawn processes). -fn par_map(items: &[T], f: impl Fn(&T) -> Option + Sync) -> Vec { +pub(crate) fn par_map(items: &[T], f: impl Fn(&T) -> Option + Sync) -> Vec { std::thread::scope(|s| { let handles: Vec<_> = items.iter().map(|item| s.spawn(|| f(item))).collect(); handles.into_iter().filter_map(|h| h.join().ok().flatten()).collect() @@ -227,7 +229,7 @@ fn pyvenv_version(python: &Path) -> Option { }) } -fn subdirs(dir: &Path) -> Vec { +pub(crate) fn subdirs(dir: &Path) -> Vec { let mut out: Vec = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); out.sort(); diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index ac98b571..79ccfda6 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -19,6 +19,7 @@ pub mod lombok; mod npm; pub mod pinned; pub mod python; +pub mod ruby; mod vue; use serde::{Deserialize, Serialize}; @@ -42,6 +43,7 @@ pub enum SdkKind { Jdk, /// Maven homes. Maven, + Ruby, } /// One installed interpreter / toolchain. diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index bdc450b7..827e42ec 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -54,6 +54,7 @@ const JAVA_MANIFESTS: &[&str] = const NODE_MANIFESTS: &[&str] = &["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"]; const CARGO_MANIFESTS: &[&str] = &["Cargo.toml", "Cargo.lock", "rust-toolchain", "rust-toolchain.toml"]; const GO_MANIFESTS: &[&str] = &["go.mod", "go.sum", "go.work"]; +const RUBY_MANIFESTS: &[&str] = &["Gemfile", "Gemfile.lock", ".ruby-version", ".tool-versions", "mise.toml"]; /// A Maven / Gradle dependency of the project, from the build model. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -70,7 +71,7 @@ pub struct BuildDependency { /// `dependencies` (from the build model) are listed when their jars are in the local repositories. pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[BuildDependency]) -> Vec { type Detect = fn(&Path, &Settings) -> Vec; - let detectors: [Detect; 5] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups]; + let detectors: [Detect; 6] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups, ruby_groups]; std::thread::scope(|s| { let handles: Vec<_> = detectors.iter().map(|d| s.spawn(move || d(root, settings))).collect(); let deps = s.spawn(|| dependency_groups(root, settings, dependencies)); @@ -193,6 +194,24 @@ pub(crate) fn named_roots(roots: Vec<(String, PathBuf)>) -> Vec { .collect() } +// ------------------------------------------------------------------ ruby + +fn ruby_groups(root: &Path, s: &Settings) -> Vec { + let Some(files) = manifests(root, RUBY_MANIFESTS) else { return Vec::new() }; + if !root.join("Gemfile").is_file() && !root.join(".ruby-version").is_file() { + return Vec::new(); + } + let Some(ruby) = crate::apply::ruby_interpreter(root, s) else { return Vec::new() }; + cached(root, "ruby", ruby.display().to_string(), &files, || { + let name = crate::ruby::ruby_sdk(&ruby, "system").map(|s| s.name).unwrap_or_else(|| "Ruby".into()); + let (stdlib, gems) = crate::ruby::library_roots(&ruby, Some(root)); + let mut out = Vec::new(); + out.extend(group(name, "ruby", named_roots(stdlib))); + out.extend(group("Gems", "gems", named_roots(gems))); + out + }) +} + // ------------------------------------------------------------------ jdk fn jdk_groups(root: &Path, s: &Settings) -> Vec { diff --git a/crates/ide-sdk/src/ruby.rs b/crates/ide-sdk/src/ruby.rs new file mode 100644 index 00000000..b7ce8df4 --- /dev/null +++ b/crates/ide-sdk/src/ruby.rs @@ -0,0 +1,237 @@ +//! Ruby interpreters: the ones a version manager installed (rbenv, rvm, chruby, +//! mise, asdf), Homebrew's, and the ones on `PATH`; the project's `.ruby-version`; +//! and the interpreter's standard library and Bundler gems for External Libraries. + +use crate::discover::{par_map, subdirs}; +use crate::exec::{executables_in, query, query_in, search_path}; +use crate::{Sdk, exe, home_dir, pinned}; +use ide_core::version_managers::{self, Tool}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const VERSION_TIMEOUT: Duration = Duration::from_secs(4); +const QUERY_TIMEOUT: Duration = Duration::from_secs(20); + +fn var_path(key: &str) -> Option { + ide_core::process::host_var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from) +} + +/// Where version managers keep one directory per Ruby, with the manager's name. +fn manager_roots() -> Vec<(PathBuf, &'static str)> { + let mut out = Vec::new(); + let Some(home) = home_dir() else { return out }; + out.push((var_path("RBENV_ROOT").unwrap_or_else(|| home.join(".rbenv")).join("versions"), "rbenv")); + out.push((var_path("rvm_path").unwrap_or_else(|| home.join(".rvm")).join("rubies"), "rvm")); + out.push((PathBuf::from("/usr/local/rvm/rubies"), "rvm")); + out.push((home.join(".rubies"), "chruby")); + out.push((PathBuf::from("/opt/rubies"), "chruby")); + out +} + +/// `bin/ruby` of a Ruby install directory. +pub(crate) fn ruby_in_install(dir: &Path) -> Option { + let p = dir.join("bin").join(exe("ruby")); + p.is_file().then_some(p) +} + +/// Installed Rubies: version-manager installs, Homebrew, then `PATH` (shims excluded). +pub(crate) fn detect(root: Option<&Path>) -> Vec { + let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); + for (dir, manager) in manager_roots() { + found.extend(subdirs(&dir).into_iter().filter_map(|v| ruby_in_install(&v)).map(|p| (p, manager))); + } + for (dir, manager) in version_managers::installs(Tool::Ruby) { + found.extend(ruby_in_install(&dir).map(|p| (p, manager))); + } + for brew in ["/opt/homebrew/opt/ruby", "/usr/local/opt/ruby", "/home/linuxbrew/.linuxbrew/opt/ruby"] { + found.extend(ruby_in_install(Path::new(brew)).map(|p| (p, "homebrew"))); + } + let dirs = std::env::split_paths(&search_path()) + .filter(|d| !is_shims_dir(d) && !version_managers::is_shims_dir(d)) + .collect::>(); + found.extend(executables_in("ruby", dirs.into_iter()).into_iter().map(|p| (p, "system"))); + + let mut seen = HashSet::new(); + let candidates: Vec<(PathBuf, &'static str)> = found + .into_iter() + .filter(|(p, _)| p.is_file() && seen.insert(p.canonicalize().unwrap_or_else(|_| p.clone()))) + .collect(); + let mut out = par_map(&candidates, |(path, source)| ruby_sdk(path, source)); + if let Some(root) = root + && let Some(pinned) = pinned_ruby_version(root) + { + // The `.ruby-version` install first, like the project virtualenv for Python. + if let Some(i) = out.iter().position(|s| version_matches(s.version.as_deref().unwrap_or(""), &pinned)) { + let sdk = out.remove(i); + out.insert(0, sdk); + } + } + out +} + +/// rbenv's and rvm's shim directories, which only forward to an install. +fn is_shims_dir(dir: &Path) -> bool { + dir.ends_with(".rbenv/shims") || dir.ends_with(".rvm/bin") +} + +/// `ruby 3.3.1 (2024-04-23 revision …) [arm64-darwin23]` → `3.3.1`. +pub(crate) fn parse_version(out: &str) -> Option { + out.strip_prefix("ruby ")? + .split_whitespace() + .next() + .map(|v| v.trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '-').to_owned()) +} + +pub(crate) fn ruby_sdk(path: &Path, source: &str) -> Option { + let version = query(path, &["--version"], VERSION_TIMEOUT).and_then(|v| parse_version(&v))?; + let short = version.split('.').take(2).collect::>().join("."); + let name = match source { + "system" => format!("Ruby {short}"), + tool => format!("Ruby {short} ({tool})"), + }; + Some(Sdk { + path: path.into(), + name, + version: Some(version), + source: source.to_owned(), + home: path.parent().and_then(Path::parent).map(Path::to_path_buf).map(Into::into), + extra: None, + }) +} + +/// The version `.ruby-version` pins (rbenv / rvm / chruby read it; `ruby-3.3.1` → `3.3.1`), +/// from `root` up, else the mise / asdf pin. +pub fn pinned_ruby_version(root: &Path) -> Option { + let from_file = root.ancestors().find_map(|dir| { + let text = std::fs::read_to_string(dir.join(".ruby-version")).ok()?; + let v = text.lines().map(str::trim).find(|l| !l.is_empty() && !l.starts_with('#'))?; + Some(v.strip_prefix("ruby-").unwrap_or(v).to_owned()) + }); + from_file.or_else(|| pinned::pinned_version(root, Tool::Ruby)) +} + +/// Whether `version` (`3.3.1`) satisfies a pin (`3.3.1`, `3.3`, `3`). +pub(crate) fn version_matches(version: &str, pin: &str) -> bool { + version == pin || version.strip_prefix(pin).is_some_and(|rest| rest.starts_with('.')) +} + +/// The Ruby for `root` without an explicit setting: the install matching +/// `.ruby-version` (version managers, then mise / asdf), else `ruby` on PATH. +pub fn project_ruby(root: &Path) -> Option { + if let Some(pin) = pinned_ruby_version(root) { + let install = manager_roots() + .into_iter() + .flat_map(|(dir, _)| subdirs(&dir)) + .chain(version_managers::installs(Tool::Ruby).into_iter().map(|(d, _)| d)) + .find(|dir| { + let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + version_matches(name.strip_prefix("ruby-").unwrap_or(&name), &pin) + }); + if let Some(ruby) = install.and_then(|d| ruby_in_install(&d)) { + return Some(ruby); + } + } + crate::exec::which("ruby") +} + +/// `bin` directory of `ruby` and the gems it was installed with (`gem` and `bundle` next to it). +pub fn bin_dir_of(ruby: &Path) -> Option { + ruby.parent().map(Path::to_path_buf) +} + +type NamedRoots = Vec<(String, PathBuf)>; + +/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves +/// for `root` (`rails-7.1.3`), each as `(name, path)`. +pub fn library_roots(ruby: &Path, root: Option<&Path>) -> (NamedRoots, NamedRoots) { + const PATHS: &str = "require 'rbconfig'; puts RbConfig::CONFIG['rubylibdir'], RbConfig::CONFIG['sitelibdir'], RbConfig::CONFIG['vendorlibdir']"; + let mut stdlib = Vec::new(); + let mut gems = Vec::new(); + let file_name = |p: &Path| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if let Some(text) = query(ruby, &["-e", PATHS], QUERY_TIMEOUT) { + for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { + let path = PathBuf::from(line); + if path.is_dir() && !stdlib.iter().any(|(_, p)| *p == path) { + let parent = path.parent().map(file_name).unwrap_or_default(); + stdlib.push((format!("{parent}/{}", file_name(&path)), path)); + } + } + } + if let Some(root) = root.filter(|r| r.join("Gemfile").is_file()) { + let bin = bin_dir_of(ruby); + let bundle = bin.as_ref().map(|b| b.join(exe("bundle"))).filter(|b| b.is_file()); + let path = bin.as_ref().map(|b| crate::exec::path_with(&[b.as_path()])).unwrap_or_default(); + let env: &[(&str, &str)] = &[("PATH", &path)]; + let text = match &bundle { + Some(b) => query_in(b, &["list", "--paths"], Some(root), env, QUERY_TIMEOUT), + None => query_in(ruby, &["-S", "bundle", "list", "--paths"], Some(root), env, QUERY_TIMEOUT), + }; + for line in text.unwrap_or_default().lines().map(str::trim).filter(|l| !l.is_empty()) { + let path = PathBuf::from(line); + if path.is_dir() && path != root { + gems.push((file_name(&path), path)); + } + } + } + (stdlib, gems) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn includes_bundled_gems_installed_inside_the_project() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let gem = root.join("vendor/bundle/ruby/4.0.0/gems/rake-13.4.0"); + let external = dir.path().join("gems/rspec-3.13.2"); + let bin = dir.path().join("bin"); + for path in [&gem, &external, &bin] { + std::fs::create_dir_all(path).unwrap(); + } + std::fs::write(root.join("Gemfile"), "gem 'rake'\ngem 'rspec'\n").unwrap(); + for (name, output) in [ + ("ruby", String::new()), + ("bundle", format!("{}\n{}\n{}", gem.display(), external.display(), root.display())), + ] { + let path = bin.join(name); + std::fs::write(&path, format!("#!/bin/sh\nprintf '%s\\n' '{output}'\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let (_, gems) = library_roots(&bin.join("ruby"), Some(&root)); + assert_eq!(gems, vec![("rake-13.4.0".into(), gem), ("rspec-3.13.2".into(), external)]); + } + + #[test] + fn parses_ruby_version_output() { + assert_eq!( + parse_version("ruby 3.3.1 (2024-04-23 revision c56cd86388) [arm64-darwin23]").as_deref(), + Some("3.3.1") + ); + assert_eq!(parse_version("ruby 3.4.0dev (2024-10-01) +PRISM [x86_64-linux]").as_deref(), Some("3.4.0")); + assert_eq!(parse_version("jruby 9.4.5.0"), None); + } + + #[test] + fn version_pins_match_prefixes() { + assert!(version_matches("3.3.1", "3.3.1")); + assert!(version_matches("3.3.1", "3.3")); + assert!(!version_matches("3.31.1", "3.3")); + assert!(!version_matches("3.3.1", "3.3.10")); + } + + #[test] + fn ruby_version_file_wins_over_managers() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("app/sub"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(dir.path().join("app/.ruby-version"), "ruby-3.2.2\n").unwrap(); + assert_eq!(pinned_ruby_version(&project).as_deref(), Some("3.2.2")); + } +} diff --git a/languages/erb.toml b/languages/erb.toml new file mode 100644 index 00000000..50cfd6d0 --- /dev/null +++ b/languages/erb.toml @@ -0,0 +1,6 @@ +id = "erb" +name = "ERB" +extensions = ["erb", "html.erb", "rhtml", "js.erb", "text.erb", "xml.erb", "json.erb"] +block-comment = ["<%#", "%>"] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "ERB" diff --git a/src/bindings.ts b/src/bindings.ts index 13367ac8..7f3e69b7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4098,6 +4098,7 @@ export type LanguagesSettings = { typescript?: TypeScriptSettings, prettier?: PrettierSettings, java?: JavaSettings, + ruby?: RubySettings, }; /** What a freshly opened window should do once its frontend is up. */ @@ -5325,6 +5326,11 @@ export type RevisionChange = { export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded"; +export type RubySettings = { + /** Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). */ + interpreter?: string, +}; + export type RouteSource = { path: string, text: string, @@ -5538,7 +5544,7 @@ export type SdkKind = "python" | "node" | /** JDK homes. */ "jdk" | /** Maven homes. */ -"maven"; +"maven" | "ruby"; /** * IDEA's search context: matches anywhere, only in, or outside comments and diff --git a/src/editor/core/cm/erb.test.ts b/src/editor/core/cm/erb.test.ts new file mode 100644 index 00000000..2d70528e --- /dev/null +++ b/src/editor/core/cm/erb.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +import { classHighlighter, highlightTree } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "./languages"; + +async function tokens(source: string) { + const desc = findLanguageDescription("ERB", "show.html.erb")!; + const support = await loadSupport(desc); + const state = EditorState.create({ doc: source, extensions: [support!] }); + ensureSyntaxTree(state, source.length, 1000); + const result: { text: string; style: string }[] = []; + highlightTree(syntaxTree(state), classHighlighter, (from, to, style) => + result.push({ text: source.slice(from, to), style }), + ); + return result; +} + +describe("ERB mode", () => { + it("highlights HTML outside and Ruby inside the tags", async () => { + const out = await tokens('
    <%= user.name if user %>
    \n<%# note %>\n<% 42 %>'); + expect(out).toContainEqual(expect.objectContaining({ text: "div", style: "tok-typeName" })); + expect(out).toContainEqual(expect.objectContaining({ text: "if", style: "tok-keyword" })); + expect(out).toContainEqual(expect.objectContaining({ text: "42", style: "tok-number" })); + expect(out).toContainEqual(expect.objectContaining({ text: "<%# note %>", style: "tok-comment" })); + expect(out.filter((t) => t.text === "<%=" || t.text === "%>")).toHaveLength(3); + }); + + it("keeps Ruby state across lines", async () => { + const out = await tokens('<% s = "a\n

    " %>

    '); + expect(out.filter((t) => t.text === "p")).toHaveLength(1); + }); +}); diff --git a/src/editor/core/cm/erb.ts b/src/editor/core/cm/erb.ts new file mode 100644 index 00000000..8e8c3f3c --- /dev/null +++ b/src/editor/core/cm/erb.ts @@ -0,0 +1,78 @@ +/** + * ERB (Embedded Ruby) editor mode: HTML outside `<% … %>`, Ruby inside. Each + * region is tokenized by its own legacy stream mode on a sub-stream bounded at + * the next delimiter, so neither mode sees the other's text. + */ +import { LanguageSupport, StreamLanguage, StringStream, type StreamParser } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +type State = { html: H; ruby: R; inRuby: boolean; indentUnit: number }; + +const OPEN = /<%(?:==|=|-|#)?/; +const CLOSE = /-?%>/; + +export function erbParser(html: StreamParser, ruby: StreamParser): StreamParser> { + const copy = (base: StreamParser, state: S): S => + base.copyState + ? base.copyState(state) + : (Object.fromEntries( + Object.entries(state as object).map(([k, v]) => [k, Array.isArray(v) ? v.slice() : v]), + ) as S); + + /** Runs `base` on `stream` cut at `end`, then advances `stream` to where it stopped. */ + const within = (stream: StringStream, end: number, base: StreamParser, state: S): string | null => { + const sub = new StringStream(stream.string.slice(0, end), 4, stream.indentUnit); + sub.pos = stream.pos; + sub.start = stream.start; + const style = base.token(sub, state); + stream.pos = Math.max(sub.pos, stream.pos + (sub.pos === stream.pos ? 1 : 0)); + return style; + }; + + return { + name: "erb", + languageData: { ...html.languageData, commentTokens: { block: { open: "<%#", close: "%>" } } }, + tokenTable: { ...html.tokenTable, ...ruby.tokenTable, erbDelimiter: tags.special(tags.brace) }, + startState: (indentUnit) => ({ + html: html.startState!(indentUnit), + ruby: ruby.startState!(indentUnit), + inRuby: false, + indentUnit, + }), + copyState: (s) => ({ ...s, html: copy(html, s.html), ruby: copy(ruby, s.ruby) }), + blankLine: (s, unit) => (s.inRuby ? ruby.blankLine?.(s.ruby, unit) : html.blankLine?.(s.html, unit)), + indent: (s, text, cx) => (s.inRuby ? ruby.indent?.(s.ruby, text, cx) : html.indent?.(s.html, text, cx)) ?? null, + token(stream, state) { + const rest = stream.string.slice(stream.pos); + if (!state.inRuby) { + const open = OPEN.exec(rest); + if (open?.index === 0) { + stream.pos += open[0].length; + if (open[0] === "<%#") { + const close = CLOSE.exec(stream.string.slice(stream.pos)); + stream.pos = close ? stream.pos + close.index + close[0].length : stream.string.length; + return "comment"; + } + state.inRuby = true; + return "erbDelimiter"; + } + return within(stream, open ? stream.pos + open.index : stream.string.length, html, state.html); + } + const close = CLOSE.exec(rest); + if (close?.index === 0) { + stream.pos += close[0].length; + state.inRuby = false; + return "erbDelimiter"; + } + return within(stream, close ? stream.pos + close.index : stream.string.length, ruby, state.ruby); + }, + }; +} + +export async function erb(): Promise { + const [{ html }, { ruby }] = await Promise.all([ + import("@codemirror/legacy-modes/mode/xml"), + import("@codemirror/legacy-modes/mode/ruby"), + ]); + return new LanguageSupport(StreamLanguage.define(erbParser(html, ruby))); +} diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 9be9fbe3..f6c01d56 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -96,6 +96,12 @@ const OWN_MODES = [ }), ), LanguageDescription.of({ name: "Angular Template", load: () => import("./angular").then((m) => m.angular()) }), + LanguageDescription.of({ + name: "ERB", + alias: ["erb", "rhtml"], + extensions: ["erb", "rhtml"], + load: () => import("./erb").then((m) => m.erb()), + }), ]; const BATCH_KEYWORDS = new Set([ diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index 622f88cc..350507ba 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -26,6 +26,8 @@ const GROUP_ICONS: Record = { goModules: "file-gomod", maven: "maven-project", gradle: "file-gradle", + ruby: "file-ruby", + gems: "file-ruby", }; /** An archive's root (`/jdk/lib/src.zip!`): its entries live below `!/`. */ diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 162e0adf..d27eb265 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -152,6 +152,8 @@ describe("context configuration", () => { cfg({ id: "pytest", kind: "pytest", command: "uv", args: ["run", "pytest"], isTest: true }), cfg({ id: "python:module:app", kind: "python", command: ".venv/bin/python", args: ["-m", "app"] }), cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true }), + cfg({ id: "rspec", kind: "rspec", command: "bundle", args: ["exec", "rspec"], isTest: true }), + cfg({ id: "rake:test", kind: "rake", command: "bundle", args: ["exec", "rake", "test"], isTest: true }), ]; it("classifies files", () => { @@ -163,6 +165,9 @@ describe("context configuration", () => { expect(fileRole("polls/tests.py")).toBe("script"); expect(fileRole("src/test/kotlin/OrderControllerTest.kt")).toBe("test"); expect(fileRole("tests/integration.rs")).toBe("test"); + expect(fileRole("spec/models/user_spec.rb")).toBe("test"); + expect(fileRole("test/user_test.rb")).toBe("test"); + expect(fileRole("lib/tasks/gen.rb")).toBe("script"); expect(fileRole("src/main.rs")).toBe("main"); expect(fileRole("cmd/main.go", "package main\nfunc main() {}")).toBe("main"); expect(fileRole("tools/gen.py")).toBe("script"); @@ -184,6 +189,20 @@ describe("context configuration", () => { expect(contextConfig("src/a.spec.js", configs)!.args).toEqual(["run", "test", "--", "src/a.spec.js"]); }); + it("narrows rspec and rake test runs to the file", () => { + expect(contextConfig("spec/models/user_spec.rb", configs)!.args).toEqual([ + "exec", + "rspec", + "spec/models/user_spec.rb", + ]); + expect(contextConfig("test/user_test.rb", configs)!.args).toEqual([ + "exec", + "rake", + "test", + "TEST=test/user_test.rb", + ]); + }); + it("narrows go, cargo, pytest and gradle runs", () => { expect(contextConfig("internal/calc/calc_test.go", configs)!.args).toEqual(["test", "./internal/calc"]); expect(contextConfig("tests/api.rs", configs)!.args).toEqual(["test", "--test", "api"]); diff --git a/src/features/run/context.ts b/src/features/run/context.ts index c71bf6e6..2e7d9e55 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -19,6 +19,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows( /^test_.*\.py$|_test\.py$/.test(name) || (django && name === "tests.py") || /_test\.go$/.test(name) || + /_(spec|test)\.rb$/.test(name) || /(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) || (/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs") ) @@ -27,7 +28,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows( if (/\.go$/.test(name) && content !== undefined && /^\s*package\s+main\b/m.test(content)) return "main"; if (/\.(kt|java)$/.test(name) && content !== undefined && /\bfun\s+main\s*\(|static\s+void\s+main\s*\(/.test(content)) return "main"; - if (/\.(py|sh|bash|zsh|[cm]?js|ts)$/.test(name)) return "script"; + if (/\.(py|rb|sh|bash|zsh|[cm]?js|ts)$/.test(name)) return "script"; if (windows && /\.(bat|cmd)$/i.test(name)) return "script"; return null; } @@ -39,6 +40,8 @@ const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, ""); function testKinds(relPath: string): string[] { if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"]; if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"]; + if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake"]; + if (/_test\.rb$/.test(relPath)) return ["rake", "rspec"]; if (/\.go$/.test(relPath)) return ["go"]; if (/\.rs$/.test(relPath)) return ["cargo"]; if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"]; @@ -136,7 +139,9 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] { const kinds = testKinds(relPath); // Configurations produced from a test or file are results of narrowing, never bases for it. - const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:")); + const candidates = configs + .filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:")) + .sort((a, b) => kinds.indexOf(a.kind) - kinds.indexOf(b.kind)); const best = allClosest(candidates, relPath); if (best.length) return best; const fallback = candidates[0] ?? implicitPytest(relPath, configs); @@ -162,7 +167,14 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC return derived(base, relPath, { args: [...base.args, file] }); case "pytest": case "python": + case "rspec": return derived(base, relPath, { args: [...base.args, file] }); + case "rake": { + // `rake test` (minitest) takes the file as TEST=; `rake spec` as SPEC=. + const task = base.args.at(-1) ?? ""; + const key = /(^|:)spec$/.test(task) ? "SPEC" : /(^|:)test$/.test(task) ? "TEST" : null; + return key ? derived(base, relPath, { args: [...base.args, `${key}=${file}`] }) : base; + } case "django-test": { const tests = djangoTestsOf(base, [djangoLabel(file)]); return tests ? derived(base, relPath, tests) : null; diff --git a/src/features/run/rubyTypes.ts b/src/features/run/rubyTypes.ts new file mode 100644 index 00000000..f7f3580d --- /dev/null +++ b/src/features/run/rubyTypes.ts @@ -0,0 +1,199 @@ +/** + * Ruby run configuration types: a script (`ruby [options] script [args]`), a Rake + * task and RSpec, the latter two through `bundle exec` when the project uses Bundler. + * The interpreter itself comes from Settings › Ruby (`apply_to_run_configs`). + */ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { formatArgs, parseArgs } from "./configs"; +import { commandName } from "./commandName"; + +const on = (v: string | undefined) => v === "true"; + +/** `bundle exec ` or ``, launched through `params.command` when one is set. */ +function tool(p: Readonly>, name: string): { command: string; args: string[] } { + const command = p.command?.trim(); + return on(p.bundler) + ? { command: command || "bundle", args: ["exec", name] } + : { command: command || name, args: [] }; +} + +/** Splits `bundle exec rest…` / ` rest…` into the launcher, the Bundler flag and `rest`. */ +function unwrap(c: RunConfig, name: string): { command: string; bundler: string; rest: string[] } | null { + const cmd = commandName(c.command); + const bare = c.command === cmd; + if (cmd === "bundle" && c.args[0] === "exec" && c.args[1] === name) + return { command: bare ? "" : c.command, bundler: "true", rest: c.args.slice(2) }; + if (cmd === name) return { command: bare ? "" : c.command, bundler: "", rest: c.args }; + return null; +} + +/** + * Index of the first operand in `args` (the script, spec target or Rake task): the + * first argument that is not an option, nor the value of the option before it. A + * token after an option without `=` is that option's value when the option is known + * to take one (`-I lib`, `--format documentation`) or, with `operand`, when the token + * does not look like an operand (`--foo bar spec/a_spec.rb`); `--` ends the options. + */ +function operandIndex( + args: readonly string[], + valueOptions: ReadonlySet, + operand: (a: string) => boolean = () => true, +): number { + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "--") return i + 1 < args.length ? i + 1 : -1; + if (a.startsWith("-")) continue; + const prev = i > 0 ? args[i - 1]! : ""; + const value = prev.startsWith("-") && !prev.includes("=") && (valueOptions.has(prev) || !operand(a)); + if (!value) return i; + } + return -1; +} + +const RUBY_VALUE_OPTIONS = new Set( + "-I -r -C -E -e -F --encoding --external-encoding --internal-encoding --enable --disable --dump".split(" "), +); +const RSPEC_VALUE_OPTIONS = new Set( + ( + "-I -r --require -O --options -P --pattern --exclude-pattern --default-path -o --out --deprecation-out " + + "-f --format -e --example -E --example-matches -t --tag --seed --order" + ).split(" "), +); +const RAKE_VALUE_OPTIONS = new Set( + ( + "-f --rakefile -I --libdir -r --require -R --rakelibdir -j --jobs -C --directory " + + "-e --execute -E --execute-continue -p --execute-print" + ).split(" "), +); + +const looksLikeScript = (a: string) => /[\\/]|\.rb$/.test(a); +const looksLikeSpecTarget = (a: string) => /[\\/]|\.rb(:\d+|\[[\d:]+\])?$|^(spec|\.)$/.test(a); + +const bundlerField = { + key: "bundler", + label: "Run with Bundler (bundle exec)", + control: "checkbox", +} as const; + +const commandField = (name: string) => + ({ + key: "command", + label: "Launcher", + mono: true, + placeholder: name, + hint: "Path of the tool (bundle when run with Bundler); empty uses the interpreter from Settings › Ruby", + }) as const; + +export const rubyScriptType: RunConfigTypeContribution = { + id: "ruby", + label: "Ruby", + icon: "file-ruby", + description: "Run a Ruby script", + defaults: {}, + fields: [ + { key: "script", label: "Ruby script", mono: true, required: true, placeholder: "e.g. bin/rails or lib/app.rb" }, + { key: "scriptArgs", label: "Script arguments", mono: true, placeholder: "e.g. server -p 3000" }, + { key: "rubyArgs", label: "Ruby arguments", mono: true, placeholder: "e.g. -Ilib -w" }, + bundlerField, + commandField("ruby"), + ], + commandLine(p) { + const t = tool(p, "ruby"); + return { + command: t.command, + args: [ + ...t.args, + ...parseArgs(p.rubyArgs ?? ""), + ...parseArgs(p.script?.trim() ?? ""), + ...parseArgs(p.scriptArgs ?? ""), + ], + }; + }, + paramsFrom(c) { + if (c.kind !== "ruby") return null; + const u = unwrap(c, "ruby"); + if (!u) return null; + const at = operandIndex(u.rest, RUBY_VALUE_OPTIONS, looksLikeScript); + // A new configuration (`ruby`, nothing else) opens in the form with the script still to fill in; + // options without a script (`ruby -e …`, `ruby -v`) stay a plain command. + if (at < 0) + return u.rest.length + ? null + : { command: u.command, bundler: u.bundler, rubyArgs: "", script: "", scriptArgs: "" }; + return { + command: u.command, + bundler: u.bundler, + rubyArgs: formatArgs(u.rest.slice(0, at)), + script: formatArgs([u.rest[at]!]), + scriptArgs: formatArgs(u.rest.slice(at + 1)), + }; + }, +}; + +export const rakeType: RunConfigTypeContribution = { + id: "rake", + label: "Rake", + icon: "file-ruby", + description: "Run a Rake task", + defaults: { bundler: "true" }, + fields: [ + { + key: "task", + label: "Task", + mono: true, + placeholder: "default", + hint: "Task name with arguments, e.g. db:migrate or test TEST=test/a_test.rb; empty runs the default task", + suggestions: (configs) => [ + ...new Set(configs.filter((c) => c.kind === "rake" && c.detected).map((c) => c.args.at(-1) ?? "")), + ], + }, + { key: "options", label: "Rake options", mono: true, placeholder: "e.g. --trace" }, + bundlerField, + commandField("rake"), + ], + commandLine(p) { + const t = tool(p, "rake"); + return { command: t.command, args: [...t.args, ...parseArgs(p.options ?? ""), ...parseArgs(p.task ?? "")] }; + }, + paramsFrom(c) { + if (c.kind !== "rake") return null; + const u = unwrap(c, "rake"); + if (!u) return null; + const at = operandIndex(u.rest, RAKE_VALUE_OPTIONS); + const [options, task] = at < 0 ? [u.rest, []] : [u.rest.slice(0, at), u.rest.slice(at)]; + return { command: u.command, bundler: u.bundler, options: formatArgs(options), task: formatArgs(task) }; + }, +}; + +export const rspecType: RunConfigTypeContribution = { + id: "rspec", + label: "RSpec", + icon: "file-ruby", + description: "Run RSpec examples", + defaults: { bundler: "true" }, + fields: [ + { + key: "target", + label: "Target", + mono: true, + placeholder: "e.g. spec", + hint: "Directory, file or file:line; empty runs the whole suite", + }, + { key: "options", label: "RSpec options", mono: true, placeholder: "e.g. --format documentation --fail-fast" }, + bundlerField, + commandField("rspec"), + ], + commandLine(p) { + const t = tool(p, "rspec"); + return { command: t.command, args: [...t.args, ...parseArgs(p.options ?? ""), ...parseArgs(p.target ?? "")] }; + }, + paramsFrom(c) { + if (c.kind !== "rspec") return null; + const u = unwrap(c, "rspec"); + if (!u) return null; + const at = operandIndex(u.rest, RSPEC_VALUE_OPTIONS, looksLikeSpecTarget); + const [options, target] = at < 0 ? [u.rest, []] : [u.rest.slice(0, at), u.rest.slice(at)]; + return { command: u.command, bundler: u.bundler, options: formatArgs(options), target: formatArgs(target) }; + }, +}; diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index fd207b13..1fb73c57 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -103,6 +103,12 @@ describe("configuration types", () => { ["pytest", "pytest.exe", ["tests/test_app.py", "-q"]], ["pytest", String.raw`C:\project\.venv\Scripts\PYTEST.EXE`, ["-q"]], ["pytest", "PYTHON.EXE", ["-m", "pytest", "tests"]], + ["ruby", String.raw`C:\Ruby33\bin\ruby.exe`, ["-Ilib", "bin/app", "--port", "3000"]], + ["rake", "bundle", ["exec", "rake", "--trace", "db:migrate"]], + ["rspec", "/home/me/.rbenv/versions/3.3.1/bin/rspec", ["--fail-fast", "spec/a_spec.rb:12"]], + ["rspec", "bundle", ["exec", "rspec", "--format", "documentation", "--fail-fast", "spec/foo_spec.rb"]], + ["ruby", "ruby", ["-I", "lib", "-w", "bin/app", "--port", "3000"]], + ["rake", "bundle", ["exec", "rake", "-f", "lib/tasks.rake", "--trace", "db:migrate"]], ["npm", String.raw`C:\Program Files\nodejs\NPM.CMD`, ["run", "test", "--", "two words"]], ["npm", "pnpm.COM", ["test"]], ["application", String.raw`C:\jdk\bin\JAVA.EXE`, ["-cp", "lib/*", "a.Main"]], @@ -172,6 +178,75 @@ describe("configuration types", () => { expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull(); }); + it("ruby types default to Bundler for rake and rspec and mark rspec as tests", () => { + expect(newConfigOfType(typeFor("rake")!, [])).toMatchObject({ command: "bundle", args: ["exec", "rake"] }); + const ruby = newConfigOfType(typeFor("ruby")!, []); + expect(ruby).toMatchObject({ command: "ruby", args: [] }); + expect(formParams(ruby)).toEqual({ command: "", bundler: "", rubyArgs: "", script: "", scriptArgs: "" }); + const rspec = newConfigOfType(typeFor("rspec")!, []); + expect(rspec).toMatchObject({ command: "bundle", args: ["exec", "rspec"], isTest: true }); + expect(withParams(rspec, { bundler: "", target: "spec/a_spec.rb", options: "-fd" })).toMatchObject({ + command: "rspec", + args: ["-fd", "spec/a_spec.rb"], + isTest: true, + }); + expect(formParams(cfg("r", { kind: "ruby", command: "ruby", args: ["app.rb", "-v"] }))).toEqual({ + command: "", + bundler: "", + rubyArgs: "", + script: "app.rb", + scriptArgs: "-v", + }); + }); + + it("ruby types keep option values with their options and the argument order as written", () => { + const ruby = (args: string[]) => formParams(cfg("r", { kind: "ruby", command: "ruby", args })); + expect(ruby(["-I", "lib", "-w", "bin/app", "--port", "3000"])).toEqual({ + command: "", + bundler: "", + rubyArgs: "-I lib -w", + script: "bin/app", + scriptArgs: "--port 3000", + }); + expect(ruby(["-W0", "--", "-weird.rb", "x"])).toMatchObject({ + rubyArgs: "-W0 --", + script: "-weird.rb", + scriptArgs: "x", + }); + expect(ruby(["-e", "puts 1"])).toBeNull(); + expect(ruby(["-v"])).toBeNull(); + + const rspec = (args: string[]) => + formParams(cfg("s", { kind: "rspec", command: "bundle", args: ["exec", "rspec", ...args] })); + expect(rspec(["--format", "documentation", "--fail-fast", "spec/foo_spec.rb"])).toEqual({ + command: "", + bundler: "true", + options: "--format documentation --fail-fast", + target: "spec/foo_spec.rb", + }); + expect(rspec(["--tag", "~slow", "-fd", "spec/a_spec.rb:12", "spec/b_spec.rb"])).toMatchObject({ + options: "--tag ~slow -fd", + target: "spec/a_spec.rb:12 spec/b_spec.rb", + }); + expect(rspec(["--format", "documentation"])).toMatchObject({ options: "--format documentation", target: "" }); + expect(rspec(["--new-option", "value", "spec"])).toMatchObject({ options: "--new-option value", target: "spec" }); + expect(rspec([])).toMatchObject({ options: "", target: "" }); + + const rake = (args: string[]) => + formParams(cfg("k", { kind: "rake", command: "bundle", args: ["exec", "rake", ...args] })); + expect(rake(["-f", "lib/tasks.rake", "--trace", "db:migrate"])).toEqual({ + command: "", + bundler: "true", + options: "-f lib/tasks.rake --trace", + task: "db:migrate", + }); + expect(rake(["--trace", "test", "TEST=test/a_test.rb"])).toMatchObject({ + options: "--trace", + task: "test TEST=test/a_test.rb", + }); + expect(rake(["-T"])).toMatchObject({ options: "-T", task: "" }); + }); + it("Django tests round-trip commandLine / paramsFrom and always report to the test tree", () => { const django = typeFor("django-test")!; expect(django.debugAdapter).toBe("debugpy"); diff --git a/src/features/run/types.ts b/src/features/run/types.ts index a5b51f4f..172b0744 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -23,6 +23,7 @@ import { tomcatType } from "./tomcat"; import { goBuildType, goTestType } from "./goTypes"; import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; +import { rakeType, rspecType, rubyScriptType } from "./rubyTypes"; import { djangoTestType } from "./djangoTypes"; export const COMPOUND = "compound"; @@ -399,6 +400,9 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + rubyScriptType, + rakeType, + rspecType, djangoTestType, dotnetProjectType, dotnetTestType, @@ -453,7 +457,7 @@ export function withParams(config: RunConfig, params: Record, ty ...(config.kind === "dotnet" && config.beforeLaunch === undefined ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } : {}), - ...(config.kind === "dotnet-test" || config.kind === "django-test" ? { isTest: true } : {}), + ...(config.kind === "dotnet-test" || config.kind === "django-test" || config.kind === "rspec" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), ...(params.goKind ? { isTest: params.goKind === "test" } : {}), diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 5864b058..0f78d295 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -35,6 +35,14 @@ describe("languages settings", () => { expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); }); + it("keeps the Ruby interpreter through settings normalization and defaults to Auto", () => { + expect(validateSettings({}).languages.ruby.interpreter).toBe(""); + expect(validateLanguages({ ruby: { interpreter: 42 } }).ruby.interpreter).toBe(""); + const settings = validateSettings({ languages: { ruby: { interpreter: " /opt/ruby/bin/ruby " } } }); + expect(settings.languages.ruby.interpreter).toBe("/opt/ruby/bin/ruby"); + expect(validateSettings(settings).languages.ruby.interpreter).toBe("/opt/ruby/bin/ruby"); + }); + it("keeps Prettier opt-in on save and normalizes its enabled languages", () => { expect(validateLanguages({}).prettier).toMatchObject({ prefer: false, runOnSave: false, path: "" }); expect( @@ -69,6 +77,7 @@ describe("language pages", () => { "Node.js", "Python Interpreter", "Prettier", + "Ruby", "Rust", "TypeScript", ]); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index e507588a..ca3a14f7 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -109,6 +109,28 @@ export function NodePage() { ); } +// --------------------------------------------------------------------------- +// Ruby + +export function RubyPage() { + return ( +

    +
    + +
    +
    + ); +} + // --------------------------------------------------------------------------- // Rust diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 181afc05..8f217c37 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -3,7 +3,7 @@ * Languages & Frameworks › Go / Java / Node.js / Rust / TypeScript. Pages live in * `../languages/`; settings are `languages.*` / `projectStructure.*`. */ -import { GoPage, JavaPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; +import { GoPage, JavaPage, NodePage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; import { PrettierPage } from "../languages/PrettierPage"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; import { PythonInterpreterPage } from "../languages/PythonInterpreterPage"; @@ -118,6 +118,16 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["formatter", "prettier", "markdown", "yaml", "css", "format on save", "node_modules"], component: PrettierPage, }, + { + id: "languages.ruby", + projectScope: true, + defaultScope: "project", + title: "Ruby", + group: LANGUAGES_GROUP, + order: 403.8, + keywords: ["ruby interpreter", "rbenv", "rvm", "chruby", "bundler", "gems", "rake", "rspec"], + component: RubyPage, + }, { id: "languages.rust", projectScope: true, diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 055ff9d4..01032a2a 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -27,6 +27,7 @@ const DEFAULT_GO_BUILD_TAGS: GoBuildTags = { export interface LanguagesValid { python: { interpreter: string; activateVirtualenv: boolean }; + ruby: { interpreter: string }; node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" }; rust: { toolchain: string; @@ -77,6 +78,7 @@ export type FolderListKey = export const DEFAULT_LANGUAGES: LanguagesValid = { python: { interpreter: "", activateVirtualenv: true }, + ruby: { interpreter: "" }, node: { interpreter: "", packageManager: "" }, rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } }, @@ -158,6 +160,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { const d = DEFAULT_LANGUAGES; const l = obj(raw); const py = obj(l.python); + const ruby = obj(l.ruby); const node = obj(l.node); const rust = obj(l.rust); const go = obj(l.go); @@ -173,6 +176,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { interpreter: text(py.interpreter, d.python.interpreter), activateVirtualenv: bool(py.activateVirtualenv, d.python.activateVirtualenv), }, + ruby: { interpreter: text(ruby.interpreter, d.ruby.interpreter) }, node: { interpreter: text(node.interpreter, d.node.interpreter), packageManager: oneOf(node.packageManager, ["", "npm", "pnpm", "yarn", "bun"] as const, d.node.packageManager), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 3a2d099a..5891763c 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4876,6 +4876,30 @@ "Directory, file or test id; empty runs the whole suite": "目录、文件或测试 ID;留空则运行整个测试套件", "pytest options": "pytest 选项", "e.g. -x -q --tb=short": "例如 -x -q --tb=short", + "Ruby": "Ruby 语言", + "Run a Ruby script": "运行 Ruby 脚本", + "Ruby script": "Ruby 脚本", + "e.g. bin/rails or lib/app.rb": "例如 bin/rails 或 lib/app.rb", + "Script arguments": "脚本实参", + "e.g. server -p 3000": "例如 server -p 3000", + "Ruby arguments": "Ruby 实参", + "e.g. -Ilib -w": "例如 -Ilib -w", + "Run with Bundler (bundle exec)": "通过 Bundler 运行(bundle exec)", + "Launcher": "启动器", + "Path of the tool (bundle when run with Bundler); empty uses the interpreter from Settings › Ruby": "工具的路径(通过 Bundler 运行时为 bundle);留空则使用“设置 › Ruby”中的解释器", + "Rake": "Rake 任务", + "Run a Rake task": "运行 Rake 任务", + "Task name with arguments, e.g. db:migrate or test TEST=test/a_test.rb; empty runs the default task": "任务名及其实参,例如 db:migrate 或 test TEST=test/a_test.rb;留空则运行默认任务", + "Rake options": "Rake 选项", + "e.g. --trace": "例如 --trace", + "RSpec": "RSpec 测试", + "Run RSpec examples": "运行 RSpec 示例", + "e.g. spec": "例如 spec", + "Directory, file or file:line; empty runs the whole suite": "目录、文件或 文件:行号;留空则运行整个测试套件", + "RSpec options": "RSpec 选项", + "e.g. --format documentation --fail-fast": "例如 --format documentation --fail-fast", + "Ruby interpreter": "Ruby 解释器", + "Runs the ruby, rake and rspec configurations and lists the standard library and gems under External Libraries. Installs from rbenv, rvm, chruby, mise and asdf are detected.": "用于运行 ruby、rake 和 rspec 配置,并在“外部库”下列出标准库和 gem。会检测 rbenv、rvm、chruby、mise 和 asdf 安装的版本。", "Monospaced": "等宽", "Proportional": "非等宽", "Loading system fonts…": "正在加载系统字体…", diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index bcaf888b..047091b3 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -874,7 +874,7 @@ const EXT_ICONS = table({ "file-swift": "swift swiftinterface", "file-go": "go", "file-rust": "rs", - "file-ruby": "rb rake gemspec ru rbi jbuilder podspec", + "file-ruby": "rb rake gemspec ru rbi jbuilder podspec erb rhtml", "file-lua": "lua luau rockspec", "file-zig": "zig zon", "file-gdscript": "gd", From f67c478b8b92c09bfc75d492e38bc49e01121a99 Mon Sep 17 00:00:00 2001 From: Kevin <68811197+yangyezhuang@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:22:26 +0800 Subject: [PATCH 082/108] fix(ui): wrap long tooltip text inside the bubble (#983) Hovering an editor tab with a long path showed a tooltip whose text ran past the bubble: tooltips never wrapped (`white-space: nowrap`) and were capped at 360px. Tooltip text now wraps inside the bubble, breaking long paths where needed, and the shortcut stays on one line next to it. The bubble grows up to 720px or the window width less 32px, so most paths still fit on one line. Short tooltips look as before (same size and line height), and newlines in tooltip text are still collapsed. Fixes #968. Co-authored-by: yangyezhuang <68811197+yangyezhuang@users.noreply.github.com> --- src/ui/Tooltip.module.css | 10 ++++-- src/ui/Tooltip.test.tsx | 67 +++++++++++++++++++++++++++++++++++++++ src/ui/Tooltip.tsx | 2 +- 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 src/ui/Tooltip.test.tsx diff --git a/src/ui/Tooltip.module.css b/src/ui/Tooltip.module.css index 48cd3c4a..d5717f3d 100644 --- a/src/ui/Tooltip.module.css +++ b/src/ui/Tooltip.module.css @@ -5,7 +5,7 @@ display: flex; align-items: center; gap: 8px; - max-width: 360px; + max-width: min(720px, calc(100vw - 32px)); padding: 6px 10px; border-radius: var(--radius-tooltip, 6px); background: var(--bg-tooltip); @@ -15,17 +15,23 @@ font-size: var(--fs-ui); line-height: 16px; pointer-events: none; - white-space: nowrap; opacity: 0; } .tooltip[data-placed] { opacity: 1; transition: opacity var(--dur-fast) var(--ease); } +/* Long paths wrap inside the bubble; the shortcut stays on one line. */ +.text { + min-width: 0; + overflow-wrap: anywhere; +} .shortcut { font-family: var(--font-keys); font-size: var(--fs-ui); color: color-mix(in srgb, var(--fg-tooltip) 62%, transparent); + white-space: nowrap; + flex-shrink: 0; } @media (prefers-reduced-motion: reduce) { diff --git a/src/ui/Tooltip.test.tsx b/src/ui/Tooltip.test.tsx new file mode 100644 index 00000000..813bd8a8 --- /dev/null +++ b/src/ui/Tooltip.test.tsx @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { TOOLTIP_DELAY, TooltipHost, tooltipAttrs } from "./Tooltip"; +import styles from "./Tooltip.module.css"; + +describe("tooltipAttrs", () => { + it("returns empty object when text is missing", () => { + expect(tooltipAttrs(undefined)).toEqual({}); + expect(tooltipAttrs("")).toEqual({}); + }); + + it("sets data-tooltip and optional shortcut", () => { + expect(tooltipAttrs("Close Tab")).toEqual({ "data-tooltip": "Close Tab" }); + expect(tooltipAttrs("Save All", "Ctrl+S")).toEqual({ + "data-tooltip": "Save All", + "data-tooltip-shortcut": "Ctrl+S", + }); + }); +}); + +describe("TooltipHost", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("shows tooltip text with styles.text class and shortcut after delay", async () => { + render( +
    + + +
    , + ); + + const button = screen.getByRole("button", { name: "Tab" }); + expect(screen.queryByRole("tooltip")).toBeNull(); + + fireEvent.pointerOver(button); + expect(screen.queryByRole("tooltip")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(TOOLTIP_DELAY); + }); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveClass(styles.tooltip!); + + const textSpan = tooltip.querySelector(`.${styles.text!}`); + expect(textSpan).toBeInTheDocument(); + expect(textSpan?.textContent).toBe("d:/project/src/main.rs"); + + const shortcutSpan = tooltip.querySelector(`.${styles.shortcut!}`); + expect(shortcutSpan).toBeInTheDocument(); + expect(shortcutSpan?.textContent).toBe("Ctrl+Shift+S"); + + // Pointer out hides the tooltip + fireEvent.pointerOut(button); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); +}); diff --git a/src/ui/Tooltip.tsx b/src/ui/Tooltip.tsx index 893e2b54..0b970220 100644 --- a/src/ui/Tooltip.tsx +++ b/src/ui/Tooltip.tsx @@ -121,7 +121,7 @@ export function TooltipHost() { data-placed={shown.placed || undefined} style={{ left: shown.x, top: shown.y }} > - {shown.text} + {shown.text} {shown.shortcut && {shown.shortcut}}
    , document.body, From 5e7ee5e08139b7ad8b06326f7672e75d908547e2 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 15:25:45 +0800 Subject: [PATCH 083/108] fix(agents): delete agent tasks whose worktree git no longer recognizes (#979) Deleting a worktree-isolated agent task together with its worktree failed for good with "fatal: '' is not a working tree" once git no longer recognized the worktree: its registration was pruned, the repository moved, or the worktree's `.git` file was deleted. When `git worktree remove --force` fails but the folder is still there and lies inside the agent worktrees directory, the folder is now removed directly, so the task can always be deleted. A stale registration is pruned by git later. Paths outside that directory (the task's cwd comes from its stored record) keep git's error and are never deleted by hand. Fixes #969. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 3 +- crates/ide-agent/src/worktree.rs | 55 +++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 4f61c8e0..cabe079e 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -823,7 +823,8 @@ impl AgentManager { // A copy of the task (a fork) may work in the same worktree. let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd); if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() { - blocking(move || worktree::remove(&root, &cwd)).await?; + let worktrees = self.shared.paths.worktrees_dir.clone(); + blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?; } self.tasks.write().remove(id); *task.deleted.lock() = true; diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs index 6b261b3a..b0e2f62f 100644 --- a/crates/ide-agent/src/worktree.rs +++ b/crates/ide-agent/src/worktree.rs @@ -48,8 +48,24 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas Ok(Created { path, branch, base, base_commit }) } -pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> { - repo_at(project_root)?.remove_worktree(path, true) +/// `git worktree remove --force -- `. When git no longer recognizes the +/// worktree (a pruned registration, a moved repository, a deleted `.git` file) +/// it fails with "is not a working tree". A folder under `worktrees_dir` is still +/// ours, so it is removed directly to keep the task deletable (a stale +/// registration prunes later); anything else keeps git's error. +pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) -> IdeResult<()> { + let repo = repo_at(project_root)?; + let err = match repo.remove_worktree(path, true) { + Ok(()) => return Ok(()), + Err(e) => e, + }; + if !path.exists() { + return Ok(()); + } + if !path.starts_with(worktrees_dir) || path == worktrees_dir { + return Err(err); + } + std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string())) } /// Files under `cwd` that differ from `base` (untracked files included), with line counts. @@ -153,7 +169,7 @@ mod tests { assert!(snapshot(&dir).unwrap().is_empty()); assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None); assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err()); - assert!(remove(&dir, &dir).is_err()); + assert!(remove(&dir, tmp.path(), &dir).is_err()); } fn git(dir: &Path, args: &[&str]) { @@ -202,7 +218,38 @@ mod tests { // A second task with the same title gets its own worktree. assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2"); - remove(&repo, &created.path).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); assert!(!created.path.exists()); } + + #[test] + fn a_broken_worktree_registration_still_leaves_a_deletable_folder() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + + // A pruned registration (the repository moved, `git worktree prune` ran). + let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap(); + let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap()); + std::fs::remove_dir_all(registration).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); + assert!(!created.path.exists()); + + // A deleted `.git` file in the worktree itself. + let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap(); + std::fs::remove_file(created.path.join(".git")).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); + assert!(!created.path.exists()); + + // A folder outside the worktrees directory is never deleted by hand. + let elsewhere = tmp.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap(); + assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err()); + assert!(elsewhere.join("keep.txt").exists()); + } } From 22a5f21a900d179545f5aac542cd69e325e7de4f Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:32:19 +0700 Subject: [PATCH 084/108] perf(vfs): list directories off the main thread (#973) `fs_read_dir` was a synchronous Tauri command, so every directory listing ran on the main thread. On a slow file system such as a network share (`\\wsl.localhost\...`), each stat while expanding the project tree stalled the whole UI. The listing now runs on the blocking pool, as the other disk-heavy commands do, both for the desktop command and over RPC. Refs #916. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- crates/ide-host/src/ops/fs.rs | 38 +++++++++++++++++++---------------- crates/ide-host/src/rpc.rs | 2 +- src-tauri/src/commands/fs.rs | 4 ++-- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index 7c56cd3a..0277dfc4 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -73,24 +73,28 @@ pub async fn copy_merge( /// Files of a deleted folder snapshotted in Local History. const DELETE_SNAPSHOT_LIMIT: usize = 2000; -pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult> { - let excludes = host - .project(window) - .map(|p| { - let globs = p.exclude_globs(); - // `read_dir` matches relative to the enclosing repository: re-anchor - // project-root patterns (`/build`) when the project is a repo subfolder. - let repo = p.root.ancestors().find(|d| d.join(".git").exists()); - match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) { - Some(rel) => { - let rel = rel.to_string_lossy().replace('\\', "/"); - globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect() +/// Runs on the blocking pool: on a network share (`\\wsl.localhost`) every stat is slow. +pub async fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult> { + let project = host.project(window); + host.blocking(move || { + let excludes = project + .map(|p| { + let globs = p.exclude_globs(); + // `read_dir` matches relative to the enclosing repository: re-anchor + // project-root patterns (`/build`) when the project is a repo subfolder. + let repo = p.root.ancestors().find(|d| d.join(".git").exists()); + match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) { + Some(rel) => { + let rel = rel.to_string_lossy().replace('\\', "/"); + globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect() + } + None => globs, } - None => globs, - } - }) - .unwrap_or_default(); - ide_vfs::read_dir(&path, &excludes) + }) + .unwrap_or_default(); + ide_vfs::read_dir(&path, &excludes) + }) + .await } /// Creates an empty file; inside a project, honours the EditorConfig diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index fb0cc28e..2ab5bd7f 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -178,7 +178,7 @@ impl Dispatcher { Ok(()) }) .add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::("id")?)) }) - .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) }) + .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?).await }) .add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await }) .add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) }) .add("fs_create_file_bytes", |c| async move { diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index efa29a9b..2e41aebf 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -39,8 +39,8 @@ pub async fn fs_copy_merge( #[tauri::command] #[specta::specta] -pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - fs::read_dir(&state.host, window.label(), path) +pub async fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { + fs::read_dir(&state.host, window.label(), path).await } /// Creates an empty file; inside a project, honours the EditorConfig From cc4ab5d4a8b2f3a21a23519ce0fc83725a50a390 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 15:35:01 +0800 Subject: [PATCH 085/108] feat(vcs): push notifications say how many commits went where (#961) After a push the notification only said "Push successful". It now says what went out, for example "Pushed 3 commits to origin/main", counting the commits the branch was ahead of its upstream before the push. This covers Push and pushing a branch to its own upstream. A push across several repositories stays generic unless exactly one of them had commits to send. The title is translated as a whole for each count. Fixes #954. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/vcs/__tests__/pushResult.test.ts | 69 +++++++++++++++++++ src/features/vcs/ops.ts | 18 ++++- src/platform/locales/text/zh.json | 2 + 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/features/vcs/__tests__/pushResult.test.ts diff --git a/src/features/vcs/__tests__/pushResult.test.ts b/src/features/vcs/__tests__/pushResult.test.ts new file mode 100644 index 00000000..4e1a73a4 --- /dev/null +++ b/src/features/vcs/__tests__/pushResult.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BranchInfo } from "@/bindings"; + +let branch: Partial = {}; + +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + Channel: class { + onmessage: (line: string) => void = () => {}; + }, +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + commands: { + ...real.commands, + vcsBranchInfo: async () => ({ + status: "ok", + data: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null, ...branch }, + }), + vcsRemote: async () => ({ status: "ok", data: null }), + cancelRequest: async () => ({ status: "ok", data: null }), + }, + }; +}); + +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { provideNotificationService } from "@/workbench/contributions"; +import { runRemote } from "../ops"; + +type Note = { title: string; level: string }; +let notes: Note[] = []; + +beforeEach(() => { + notes = []; + provideNotificationService({ notify: (n) => void notes.push(n as Note), expire: () => {} }); +}); + +afterEach(() => { + branch = {}; + _resetI18nForTests(); +}); + +const pushTitle = async () => { + expect(await runRemote({ op: "push" } as never, undefined, ["/p"])).toBe(true); + return notes.at(-1)!.title; +}; + +describe("push result notification", () => { + it("says how many commits went to which upstream", async () => { + branch = { upstream: "origin/main", ahead: 3 }; + expect(await pushTitle()).toBe("Pushed 3 commits to origin/main"); + branch = { upstream: "origin/main", ahead: 1 }; + expect(await pushTitle()).toBe("Pushed 1 commit to origin/main"); + }); + + it("stays generic when nothing was ahead", async () => { + branch = { upstream: "origin/main", ahead: 0 }; + expect(await pushTitle()).toMatch(/successful$/); + }); + + it("is translated as a whole, count included", async () => { + await setLocale("zh"); + branch = { upstream: "origin/main", ahead: 3 }; + expect(await pushTitle()).toBe("已推送 3 个提交到 origin/main"); + }); +}); diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index c76fb17b..8e81fd0c 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -188,6 +188,7 @@ export async function runRemote( // repositories that have nothing to pull from (detached HEAD, e.g. submodules; no upstream). const failures: string[] = []; const updated: UpdatedRange[] = []; + const pushed: Array<{ to: string; commits: number }> = []; for (const repo of repos) { const name = repos.length > 1 && repo ? repoName(repo) : null; if (name) logConsole("info", name); @@ -200,6 +201,12 @@ export async function runRemote( } try { const before = op.op === "pull" || op.op === "pullFrom" ? await headCommit(repo) : null; + // The push summary counts what the push carries: the commits the branch is + // ahead of its upstream before the push (pushing does not move the local head). + if (op.op === "push" || (op.op === "pushBranch" && !op.target)) { + const info = await call(commands.vcsBranchInfo(repo)).catch(() => null); + if (info?.upstream && info.ahead > 0) pushed.push({ to: info.upstream, commits: info.ahead }); + } await call(commands.vcsRemote(repo, request, op, channel)); const after = before ? await headCommit(repo) : null; if (before && after && before !== after) updated.push({ repo, from: before, to: after }); @@ -212,14 +219,17 @@ export async function runRemote( if (failures.length) throw new Error(failures.join("\n")); lastRemoteError = null; if (op.op === "pull" || op.op === "pullFrom") await notifyUpdated(updated); - else + else { + // One repository's push names what went out; a partial multi-repo push stays generic. + const one = pushed.length === 1 ? pushed[0] : null; notifications().notify({ group: "vcs", level: "info", key: "vcs.remote", - title: `${doneTitle} successful`, + title: one ? pushedTitle(one.commits, one.to) : `${doneTitle} successful`, body: lastLine && !/%/.test(lastLine) ? lastLine : undefined, }); + } return true; } catch (e) { const cancelled = isCancelled(e); @@ -258,6 +268,10 @@ const headCommit = (repo: string | null) => const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; +/** "Pushed 3 commits to origin/main", translated per count. */ +const pushedTitle = (n: number, to: string) => + n === 1 ? tx`Pushed ${n} commit to ${to}` : tx`Pushed ${n} commits to ${to}`; + /** IDEA's Update Project result: "N files updated in M commits" with View Commits. */ async function notifyUpdated(updated: readonly UpdatedRange[]) { if (!updated.length) { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5891763c..2e00073a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4114,6 +4114,8 @@ "Rebasing {n} commit from the root": "正在从根变基 {n} 个提交", "Rebasing {n} commits from the root": "正在从根变基 {n} 个提交", "Push Commits to {remote}": "推送提交到 {remote}", + "Pushed {n} commit to {remote}": "已推送 {n} 个提交到 {remote}", + "Pushed {n} commits to {remote}": "已推送 {n} 个提交到 {remote}", "You're going to force push to '{target}'. It may overwrite commits at the remote. Are you sure you want to proceed?": "您将强制推送到 '{target}'。这可能会覆盖远程上的提交。确定要继续吗?", "Delete “{name}” from the shelf? This cannot be undone.": "是否从搁置中删除“{name}”?此操作无法撤消。", "Handler for {keystroke}": "{keystroke} 的处理程序", From 59766278c521e7b9a402e2108be130690b8ae9a9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:56:05 +0800 Subject: [PATCH 086/108] fix(i18n): translate counts inside the Update Project and Hot Swap results (#994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Chinese the Update Project notification read "已在 2 commits 中更新 3 files" and the Hot Swap result "已重新加载 3 classes". Both sentences were translated, but the counts inside them were English phrases passed in as placeholder values, which are only looked up as exact catalog entries. Each count is now its own source text per variant (`{count} file` / `{count} files`, `{count} commit` / `{count} commits`, `{count} class` / `{count} classes`), built in the current language before it goes into the sentence. These were the only two places where a translated sentence receives an English count phrase as a value. --- .../debugger/__tests__/hotswap.test.ts | 11 ++++++ src/features/debugger/hotswap.ts | 7 ++-- ...shResult.test.ts => remoteResults.test.ts} | 34 +++++++++++++++++-- src/features/vcs/ops.ts | 6 ++-- src/platform/locales/text/zh.json | 5 ++- 5 files changed, 54 insertions(+), 9 deletions(-) rename src/features/vcs/__tests__/{pushResult.test.ts => remoteResults.test.ts} (59%) diff --git a/src/features/debugger/__tests__/hotswap.test.ts b/src/features/debugger/__tests__/hotswap.test.ts index ace2fc1b..3473a32b 100644 --- a/src/features/debugger/__tests__/hotswap.test.ts +++ b/src/features/debugger/__tests__/hotswap.test.ts @@ -29,6 +29,7 @@ vi.mock("@/workbench/contributions", async (importOriginal) => { import type { DebugCapabilities, DebugSessionId } from "@/platform/ipc"; import { getContext } from "@/platform/context"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; import { hotSwapMessage, onHotCodeReplace, reloadChangedClasses } from "../hotswap"; import { publishContext, useDebug, type DebugSession } from "../state"; @@ -107,9 +108,19 @@ describe("HotSwap", () => { it("words results like IDEA", () => { expect(hotSwapMessage(0, []).title).toBe("Loaded classes are up to date. Nothing to reload."); expect(hotSwapMessage(1, []).title).toBe("1 class reloaded"); + expect(hotSwapMessage(3, []).title).toBe("3 classes reloaded"); expect(hotSwapMessage(3, ["boom"])).toMatchObject({ level: "error", body: "boom" }); }); + it("translates the reload count with the sentence", async () => { + await setLocale("zh"); + try { + expect(hotSwapMessage(3, []).title).toBe("已重新加载 3 个类"); + } finally { + _resetI18nForTests(); + } + }); + it("offers a reload when java-debug reports a finished build", () => { // Past the quiet period after the reloads above (their own builds raise no prompt). vi.spyOn(Date, "now").mockReturnValue(Date.now() + 120_000); diff --git a/src/features/debugger/hotswap.ts b/src/features/debugger/hotswap.ts index a4825af1..b24eb225 100644 --- a/src/features/debugger/hotswap.ts +++ b/src/features/debugger/hotswap.ts @@ -18,9 +18,8 @@ export function hotSwapSessions(): DebugSession[] { return Object.values(useDebug.getState().sessions).filter((s) => isLive(s) && !!s.caps?.hotCodeReplace); } -function plural(n: number, word: string) { - return `${n} ${word}${n === 1 ? "" : "es"}`; -} +/** "3 classes", translated per count (the sentence around it is translated on its own). */ +const classesText = (n: number) => (n === 1 ? tx`${n} class` : tx`${n} classes`); /** Message for the reload result (IDEA wording). */ export function hotSwapMessage( @@ -29,7 +28,7 @@ export function hotSwapMessage( ): { level: "info" | "error"; title: string; body?: string } { if (errors.length) return { level: "error", title: "Hot Swap failed", body: errors.join("\n") }; if (changed === 0) return { level: "info", title: "Loaded classes are up to date. Nothing to reload." }; - return { level: "info", title: tx`${plural(changed, "class")} reloaded` }; + return { level: "info", title: tx`${classesText(changed)} reloaded` }; } /** Compiles (unless `build` is false) and reloads changed classes into every Java debug session. */ diff --git a/src/features/vcs/__tests__/pushResult.test.ts b/src/features/vcs/__tests__/remoteResults.test.ts similarity index 59% rename from src/features/vcs/__tests__/pushResult.test.ts rename to src/features/vcs/__tests__/remoteResults.test.ts index 4e1a73a4..fc32d8a0 100644 --- a/src/features/vcs/__tests__/pushResult.test.ts +++ b/src/features/vcs/__tests__/remoteResults.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { BranchInfo } from "@/bindings"; let branch: Partial = {}; +/** What a pull brings in: the head moves to `c2`, with these changed files and commits. */ +let pulled = { files: 0, commits: 0 }; vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ ...(await importOriginal()), @@ -20,13 +22,18 @@ vi.mock("@/platform/ipc", async (importOriginal) => { status: "ok", data: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null, ...branch }, }), - vcsRemote: async () => ({ status: "ok", data: null }), + vcsRemote: async () => { + if (pulled.commits) branch = { ...branch, headCommit: "c2" }; + return { status: "ok", data: null }; + }, + vcsDiffRevisions: async () => ({ status: "ok", data: Array.from({ length: pulled.files }, (_, i) => ({ i })) }), + vcsLog: async () => ({ status: "ok", data: Array.from({ length: pulled.commits }, (_, i) => ({ i })) }), cancelRequest: async () => ({ status: "ok", data: null }), }, }; }); -import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { _resetI18nForTests, setLocale, txTemplate } from "@/platform/i18n"; import { provideNotificationService } from "@/workbench/contributions"; import { runRemote } from "../ops"; @@ -40,6 +47,7 @@ beforeEach(() => { afterEach(() => { branch = {}; + pulled = { files: 0, commits: 0 }; _resetI18nForTests(); }); @@ -67,3 +75,25 @@ describe("push result notification", () => { expect(await pushTitle()).toBe("已推送 3 个提交到 origin/main"); }); }); + +const updateTitle = async () => { + expect(await runRemote({ op: "pull" } as never, undefined, ["/p"])).toBe(true); + return notes.at(-1)!.title; +}; + +describe("Update Project result notification", () => { + it("counts the files and commits that came in", async () => { + pulled = { files: 3, commits: 2 }; + expect(await updateTitle()).toBe("3 files updated in 2 commits"); + branch = {}; + pulled = { files: 1, commits: 1 }; + expect(await updateTitle()).toBe("1 file updated in 1 commit"); + }); + + it("translates the counts inside the sentence", async () => { + await setLocale("zh"); + pulled = { files: 3, commits: 2 }; + // Notification titles are translated as they are shown. + expect(txTemplate(await updateTitle())).toBe("已在 2 个提交中更新 3 个文件"); + }); +}); diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index 8e81fd0c..17d442e9 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -266,7 +266,9 @@ const headCommit = (repo: string | null) => () => null, ); -const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; +/** Counts for a translated sentence: each variant is its own source text, as "{count} files". */ +const filesText = (n: number) => (n === 1 ? tx`${n} file` : tx`${n} files`); +const commitsText = (n: number) => (n === 1 ? tx`${n} commit` : tx`${n} commits`); /** "Pushed 3 commits to origin/main", translated per count. */ const pushedTitle = (n: number, to: string) => @@ -304,7 +306,7 @@ async function notifyUpdated(updated: readonly UpdatedRange[]) { group: "vcs", level: "info", key: "vcs.remote", - title: `${plural(files, "file")} updated in ${plural(commits, "commit")}`, + title: `${filesText(files)} updated in ${commitsText(commits)}`, actions: [ { label: "View Commits", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2e00073a..0522011d 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3781,7 +3781,7 @@ "Select two commits to compare": "选择两个提交进行比较", "Label '{value}' added to Local History": "已将标签 '{value}' 添加到本地历史记录", "{doneTitle} successful": "{doneTitle}成功", - "{file} updated in {commit}": "已在 {commit} 中更新 {file}", + "{file} updated in {commit}": "已在 {commit}中更新 {file}", "Shelved {length} file": "已搁置 {length} 个文件", "Shelved {length} files": "已搁置 {length} 个文件", "{what}: {length} file with conflicts": "{what}:{length} 个文件存在冲突", @@ -3943,6 +3943,8 @@ "Cannot copy the DDL": "无法复制 DDL", "Start the remote JVM with -agentlib:jdwp=transport=dt_socket,server=n,address={host}:{port},suspend=y": "使用 -agentlib:jdwp=transport=dt_socket,server=n,address={host}:{port},suspend=y 启动远程 JVM", "{class} reloaded": "已重新加载 {class}", + "{count} class": "{count} 个类", + "{count} classes": "{count} 个类", "The JavaScript debugger (vscode-js-debug) is not installed": "JavaScript 调试器 (vscode-js-debug) 未安装", "Delve is not installed": "Delve 未安装", "CodeLLDB is not installed": "CodeLLDB 未安装", @@ -5147,6 +5149,7 @@ "Mark as Not Viewed": "标记为未查看", "Mark All as Viewed": "全部标记为已查看", "Mark All as Not Viewed": "全部标记为未查看", + "{count} commit": "{count} 个提交", "{count} commits": "{count} 个提交", "1 commit": "1 个提交", "All commits ({count})": "所有提交 ({count})", From ec465964d278749ccc82c99eccdb3bab9963ad03 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:29:59 +0800 Subject: [PATCH 087/108] feat(editor): highlight JSP pages (#937) JSP files had no highlighting and showed a `?` icon; the HTML mode left directives, scriptlets, JSP comments and EL as plain text and flagged `` / `` as unclosed elements. - A JSP grammar layer (comments, directives, scriptlets, expressions, declarations, EL) with HTML and Java overlays, so markup split by JSP elements still nests and braces match across scriptlets. - EL as its own grammar top: member/index/method calls, namespaced functions, keyword operators, ternaries, lambdas and collection literals. - Block comments use `<%-- --%>` in markup and `//` in scriptlets; JSP comments and scriptlets fold. - `.jsp .jspf .jspx .jsph .tag .tagf .tagx` map to the JSP language with a file icon; `gen:jsp-grammar` regenerates the parser and a test checks it matches the grammar. Closes #870. Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- .oxfmtrc.json | 1 + crates/ide-language/src/registry.rs | 4 + crates/ide-language/src/syntax/mod.rs | 4 +- knip.ts | 1 + languages/jsp.toml | 6 + package.json | 2 + pnpm-lock.yaml | 3 + src-tauri/tauri.macos.conf.json | 1 + src/editor/core/cm/jsp/index.ts | 176 +++++++++++++++++ src/editor/core/cm/jsp/jsp.grammar | 132 +++++++++++++ src/editor/core/cm/jsp/jsp.test.ts | 252 +++++++++++++++++++++++++ src/editor/core/cm/jsp/parser.terms.ts | 39 ++++ src/editor/core/cm/jsp/parser.ts | 24 +++ src/editor/core/cm/jsp/tokens.ts | 101 ++++++++++ src/editor/core/cm/languages.ts | 9 +- src/ui/icons.test.tsx | 3 + src/ui/icons.tsx | 2 + src/ui/openidea/fileTypes/jsp.svg | 4 + src/ui/openidea/fileTypes/jsp_dark.svg | 4 + 19 files changed, 764 insertions(+), 4 deletions(-) create mode 100644 languages/jsp.toml create mode 100644 src/editor/core/cm/jsp/index.ts create mode 100644 src/editor/core/cm/jsp/jsp.grammar create mode 100644 src/editor/core/cm/jsp/jsp.test.ts create mode 100644 src/editor/core/cm/jsp/parser.terms.ts create mode 100644 src/editor/core/cm/jsp/parser.ts create mode 100644 src/editor/core/cm/jsp/tokens.ts create mode 100644 src/ui/openidea/fileTypes/jsp.svg create mode 100644 src/ui/openidea/fileTypes/jsp_dark.svg diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d2730f45..70f64132 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,6 +10,7 @@ "target", "src/bindings.ts", "src/editor/core/cm/angular/parser*.ts", + "src/editor/core/cm/jsp/parser*.ts", "docs/plugins/themes.md", "pnpm-lock.yaml", "**/*.toml", diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 984c082a..34caf951 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -45,6 +45,7 @@ const BUILTIN: &[(&str, &str)] = &[ ("javascriptreact", include_str!("../../../languages/javascriptreact.toml")), ("json", include_str!("../../../languages/json.toml")), ("jsonc", include_str!("../../../languages/jsonc.toml")), + ("jsp", include_str!("../../../languages/jsp.toml")), ("julia", include_str!("../../../languages/julia.toml")), ("kotlin", include_str!("../../../languages/kotlin.toml")), ("latex", include_str!("../../../languages/latex.toml")), @@ -522,6 +523,7 @@ mod tests { "Vue", "Angular Template", "ERB", + "JSP", ]; fn reg() -> LanguageRegistry { @@ -803,6 +805,8 @@ mod tests { ("a.less", "less"), ("App.vue", "vue"), ("App.svelte", "svelte"), + ("index.jsp", "jsp"), + ("WEB-INF/tags/card.tag", "jsp"), ("package.json", "json"), ("tsconfig.json", "jsonc"), ("tsconfig.app.json", "jsonc"), diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index c578dbba..e961eb35 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -201,11 +201,11 @@ impl Syntax { /// The grammar web features parse `lang` with: its own for HTML and CSS, the /// CSS grammar for SCSS / Less (selectors and custom properties parse alike), - /// HTML for Vue / Svelte single-file components. + /// HTML for Vue / Svelte single-file components and JSP pages. fn web_grammar(&self, lang: &LanguageId) -> Option<&Grammar> { let name = match lang.as_str() { "scss" | "less" => "css", - "vue" | "svelte" => "html", + "vue" | "svelte" | "jsp" => "html", _ => self.grammar(lang).map(|g| g.spec.name)?, }; web::is_web_grammar(name).then(|| self.grammar(&LanguageId::new(name))).flatten() diff --git a/knip.ts b/knip.ts index 50822935..cce08398 100644 --- a/knip.ts +++ b/knip.ts @@ -5,6 +5,7 @@ const config: KnipConfig = { project: [ "src/**/*.{ts,tsx,css}", "!src/editor/core/cm/angular/parser.terms.ts", + "!src/editor/core/cm/jsp/parser.terms.ts", "scripts/**/*.ts", "perf/**/*.ts", "e2e/**/*.ts", diff --git a/languages/jsp.toml b/languages/jsp.toml new file mode 100644 index 00000000..1489eca5 --- /dev/null +++ b/languages/jsp.toml @@ -0,0 +1,6 @@ +id = "jsp" +name = "JSP" +extensions = ["jsp", "jspx", "jspf", "jsph", "tag", "tagf", "tagx"] +block-comment = ["<%--", "--%>"] +indent = { tab-size = 2, insert-spaces = true } +ui-mode = "JSP" diff --git a/package.json b/package.json index 6bad3b8c..5426f9f6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit", "gen:bindings": "cargo test -p openidea --test export_bindings", "gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts", + "gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts", "gen:theme-docs": "node scripts/theme/gen-docs.ts", "theme:import-intellij": "node scripts/theme/import-intellij-theme.ts", "dev:app": "node scripts/tauri.ts dev --config src-tauri/tauri.dev.conf.json", @@ -39,6 +40,7 @@ "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.1", "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.13.1", "@codemirror/language-data": "^6.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e2995a3..6c8cb996 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@codemirror/lang-html': specifier: ^6.4.12 version: 6.4.12 + '@codemirror/lang-java': + specifier: ^6.0.2 + version: 6.0.2 '@codemirror/lang-javascript': specifier: ^6.2.5 version: 6.2.5 diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 3993b304..2bf5a414 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -46,6 +46,7 @@ "jsx", "vue", "svelte", + "jsp", "dart", "cs", "fs", diff --git a/src/editor/core/cm/jsp/index.ts b/src/editor/core/cm/jsp/index.ts new file mode 100644 index 00000000..5662174a --- /dev/null +++ b/src/editor/core/cm/jsp/index.ts @@ -0,0 +1,176 @@ +/** + * JSP pages, layered like IntelliJ's JSP view provider: `jsp.grammar` parses + * the JSP layer, and the template text around its elements is one HTML + * document. That is an overlay, so markup split by a scriptlet still nests + * and `value="<%= id %>"` is an attribute with a hole. The scriptlets are + * one Java method body, an overlay too (`<% for (…) { %> … <% } %>` balances + * its braces), each expression and declaration Java of its own. + */ +import { LRLanguage, LanguageSupport, foldInside, foldNodeProp } from "@codemirror/language"; +import { html } from "@codemirror/lang-html"; +import { javaLanguage } from "@codemirror/lang-java"; +import { styleTags, tags as t } from "@lezer/highlight"; +import { + Parser, + parseMixed, + type Input, + type NestedParse, + type PartialParse, + type SyntaxNode, + type SyntaxNodeRef, + type TreeFragment, +} from "@lezer/common"; +import { JAVA_TAGS } from "../languages"; +import { parser } from "./parser"; + +const baseHTML = html({ selfClosingTags: true }); + +const JSP_COMMENT = { block: { open: "<%--", close: "--%>" } }; + +const baseParser = parser.configure({ + props: [ + styleTags({ + JspComment: t.blockComment, + '"<%" "<%=" "<%!" "<%@" "%>"': t.processingInstruction, + // Not `ELExpression/"}"`: the cursor highlighting a page does not match + // that context once the expression's content is mounted. + '"${" "#{" ELEnd/...': t.special(t.brace), + DirectiveName: t.keyword, + AttributeName: t.attributeName, + AttributeValue: t.attributeValue, + Is: t.definitionOperator, + VariableName: t.variableName, + PropertyName: t.propertyName, + "CallExpression/MemberExpression/PropertyName": t.function(t.propertyName), + "CallExpression/VariableName FunctionName": t.function(t.variableName), + Namespace: t.namespace, + Number: t.number, + String: t.string, + BooleanLiteral: t.bool, + null: t.null, + "not empty div mod lt gt le ge instanceof eq ne and or": t.operatorKeyword, + ArithOp: t.arithmeticOperator, + CompareOp: t.compareOperator, + LogicOp: t.logicOperator, + '"->"': t.operator, + '"."': t.derefOperator, + "( )": t.paren, + "[ ]": t.squareBracket, + 'SetOrMapLiteral/"{" SetOrMapLiteral/"}"': t.brace, + ", ;": t.separator, + '":"': t.punctuation, + }), + foldNodeProp.add({ + "Scriptlet Declaration JspExpression": foldInside, + JspComment: (node, state) => ({ + from: node.from + 4, + to: state.sliceDoc(node.to - 4, node.to) == "--%>" ? node.to - 4 : node.to, + }), + }), + ], +}); + +const javaParser = javaLanguage.parser.configure({ props: [styleTags(JAVA_TAGS)] }); + +/** `input` with a `;` at `at`. */ +class TerminatedInput implements Input { + constructor( + private readonly input: Input, + private readonly at: number, + ) {} + + get length() { + return this.input.length; + } + + get lineChunks() { + return this.input.lineChunks; + } + + chunk(from: number): string { + if (from == this.at) return ";"; + const text = this.input.chunk(from); + return from < this.at && this.at < from + text.length ? text.slice(0, this.at - from) : text; + } + + read(from: number, to: number): string { + const text = this.input.read(from, to); + return from <= this.at && this.at < to + ? text.slice(0, this.at - from) + ";" + text.slice(this.at - from + 1) + : text; + } +} + +/** + * Java has no expression top: an expression is parsed as a statement, its + * trailing space read as the `;` (`<%= name %>`), so that a lone name is not + * a call missing its arguments. Without one, it ends in an error node. + */ +class ExpressionParser extends Parser { + private readonly java = javaParser.configure({}); + + createParse( + input: Input, + fragments: readonly TreeFragment[], + ranges: readonly { from: number; to: number }[], + ): PartialParse { + const end = ranges[ranges.length - 1]!.to; + const terminated = /\s/.test(input.read(end - 1, end)) ? new TerminatedInput(input, end - 1) : input; + return this.java.createParse(terminated, fragments, ranges); + } +} + +// To reuse old inner trees, parseMixed takes those that the node at a mount's +// start and its ancestors had with the same parser, so each kind of mount has +// its own instance: an expression at the start of a page would otherwise take +// the scriptlets' tree. +const htmlMixed: NestedParse = { parser: baseHTML.language.parser, overlay: (node) => node.name == "Text" }, + scriptletParser = javaParser.configure({}), + expressionMixed = { parser: new ExpressionParser() }, + declarationMixed = { parser: javaParser.configure({ top: "ClassContent" }) }, + elMixed = { parser: baseParser.configure({ top: "EL" }) }; + +/** The code of the scriptlets in `template`. */ +function scriptletCode(template: SyntaxNode): { from: number; to: number }[] { + const code: { from: number; to: number }[] = []; + for (const scriptlet of template.getChildren("Scriptlet")) { + const java = scriptlet.getChild("JavaCode"); + if (java) code.push({ from: java.from, to: java.to }); + } + return code; +} + +// The HTML overlay is on the document and the scriptlets' on the template it +// holds: a node mounts one tree, and an overlay's predicate does not see the +// nodes of an overlay inside it. +function mixJsp(node: SyntaxNodeRef): NestedParse | null { + switch (node.name) { + case "Document": + return htmlMixed; + case "Template": { + const overlay = scriptletCode(node.node); + return overlay.length ? { parser: scriptletParser, overlay } : null; + } + case "JavaCode": { + const element = node.node.parent?.name; + return element == "JspExpression" ? expressionMixed : element == "Declaration" ? declarationMixed : null; + } + case "ELContent": + return elMixed; + } + return null; +} + +export const jspLanguage = LRLanguage.define({ + name: "jsp", + parser: baseParser.configure({ wrap: parseMixed(mixJsp) }), + languageData: { commentTokens: JSP_COMMENT }, +}); + +export function jsp(): LanguageSupport { + return new LanguageSupport(jspLanguage, [ + baseHTML.support, + // Markup comments out of the page, with a JSP comment, as in IntelliJ. + baseHTML.language.data.of({ commentTokens: JSP_COMMENT }), + ]); +} diff --git a/src/editor/core/cm/jsp/jsp.grammar b/src/editor/core/cm/jsp/jsp.grammar new file mode 100644 index 00000000..3181b948 --- /dev/null +++ b/src/editor/core/cm/jsp/jsp.grammar @@ -0,0 +1,132 @@ +// JSP pages: the JSP layer of a page, as IntelliJ's JSP view provider sees it. +// Comments, directives, scripting elements and EL expressions are parsed here; +// the template text between them is HTML and the scripting elements' code is +// Java, both mounted by `./index.ts`. EL expressions (`${…}`, `#{…}`) are their +// own top. Regenerate with `pnpm gen:jsp-grammar`. + +@top Document { Template } + +// The page's content, a node of its own: the HTML overlay is mounted on the +// document and the scriptlets' Java overlay on this. +Template { + (Text | JspComment | Directive | Declaration | JspExpression | Scriptlet | ELExpression)* +} + +Scriptlet { "<%" JavaCode? "%>" } + +JspExpression { "<%=" JavaCode? "%>" } + +Declaration { "<%!" JavaCode? "%>" } + +ELExpression { ("${" | "#{") ELContent? ELEnd { "}" } } + +@skip { space } { + Directive { "<%@" DirectiveName { name } Attribute* "%>" } + + Attribute { AttributeName { name } (Is AttributeValue)? } + + @top EL { expression? (";" expression?)* } + + expression[@isGroup=Expression] { + literal | + VariableName | + ParenthesizedExpression { "(" (expression ("," expression)*)? ")" } | + ListLiteral { "[" (expression ("," expression)*)? "]" } | + SetOrMapLiteral { "{" (entry ("," entry)*)? "}" } | + MemberExpression { expression !member ("." PropertyName | "[" expression "]") } | + CallExpression { expression !call ArgList } | + FunctionCall { Namespace ":" FunctionName ArgList } | + UnaryExpression { (kw<"not"> | kw<"empty"> | LogicOp<"!"> | ArithOp<"-">) !prefix expression } | + BinaryExpression { + expression !mult (ArithOp<"*" | "/" | "%"> | kw<"div"> | kw<"mod">) expression | + expression !add ArithOp<"+" | "-"> expression | + expression !concat ArithOp<"+="> expression | + expression !rel (CompareOp<"<" | ">" | "<=" | ">="> | kw<"lt"> | kw<"gt"> | kw<"le"> | kw<"ge"> | kw<"instanceof">) expression | + expression !equal (CompareOp<"==" | "!="> | kw<"eq"> | kw<"ne">) expression | + expression !and (LogicOp<"&&"> | kw<"and">) expression | + expression !or (LogicOp<"||"> | kw<"or">) expression + } | + ConditionalExpression { expression !ternary LogicOp<"?"> expression LogicOp<":"> expression } | + LambdaExpression { expression !lambda "->" expression } | + AssignmentExpression { expression !assign Is expression } + } + + entry { expression (":" expression)? } + + ArgList { "(" (expression ("," expression)*)? ")" } +} + +@precedence { + member, + call, + prefix @right, + mult @left, + add @left, + concat @left, + rel @left, + equal @left, + and @left, + or @left, + ternary @right, + lambda @right, + assign @right +} + +literal { + Number | + String | + @specialize[@name=BooleanLiteral] | + kw<"null"> +} + +VariableName { identifier } + +PropertyName { identifier } + +FunctionName { identifier } + +Is { "=" } + +kw { @specialize[@name={term}] } + +ArithOp { expr } +CompareOp { expr } +LogicOp { expr } + +// Tried in this order, before the tokens below: a comment before `<%`. +@external tokens jspComment from "./tokens" { JspComment } +@external tokens text from "./tokens" { Text } +@external tokens javaCode from "./tokens" { JavaCode } +@external tokens elContent from "./tokens" { ELContent } +// `fn` of `fn:join(…)`, so that an identifier before a `:` stays a ternary's. +@external tokens namespace from "./tokens" { Namespace } + +@tokens { + space { $[ \t\n\r]+ } + + identifier { $[a-zA-Z_$\u{a1}-\u{10ffff}] $[a-zA-Z0-9_$\u{a1}-\u{10ffff}]* } + + // Directive and attribute names: `include`, `pageEncoding`, `name-given`. + name { $[a-zA-Z_] $[a-zA-Z0-9_\-]* } + + Number { + (@digit+ ("." @digit*)? | "." @digit+) ($[eE] $[+\-]? @digit+)? + } + + String { + '"' (!["\\] | "\\" _)* '"'? | + "'" (!['\\] | "\\" _)* "'"? + } + + AttributeValue { + '"' !["]* '"'? | + "'" ![']* "'"? + } + + @precedence { Number, "." } + + "<%" "<%=" "<%!" "<%@" "%>" "${" "#{" + "(" ")" "[" "]" "{" "}" "." "," ";" ":" "->" +} + +@detectDelim diff --git a/src/editor/core/cm/jsp/jsp.test.ts b/src/editor/core/cm/jsp/jsp.test.ts new file mode 100644 index 00000000..d18ff2b5 --- /dev/null +++ b/src/editor/core/cm/jsp/jsp.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { EditorState } from "@codemirror/state"; +import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language"; +import type { Tree } from "@lezer/common"; +import { buildParserFile } from "@lezer/generator"; +import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "../languages"; +import { jsp, jspLanguage } from "./index"; + +const highlighter = tagHighlighter([ + { tag: t.processingInstruction, class: "pi" }, + { tag: t.special(t.brace), class: "el" }, + { tag: t.blockComment, class: "comment" }, + { tag: t.operatorKeyword, class: "opkw" }, + { tag: t.keyword, class: "keyword" }, + { tag: t.bool, class: "bool" }, + { tag: t.namespace, class: "ns" }, + { tag: t.typeName, class: "type" }, + { tag: t.definition(t.variableName), class: "def" }, + { tag: t.function(t.variableName), class: "call" }, + { tag: t.function(t.propertyName), class: "method" }, + { tag: t.variableName, class: "var" }, + { tag: t.propertyName, class: "prop" }, + { tag: t.attributeName, class: "attr" }, + { tag: t.attributeValue, class: "value" }, + { tag: t.string, class: "str" }, + { tag: t.number, class: "num" }, + { tag: t.tagName, class: "tag" }, +]); + +/** `text:class` of the styled tokens of `doc`, from `tree` or a fresh parse. */ +function tokens(doc: string, tree: Tree = jspLanguage.parser.parse(doc)): string[] { + const out: string[] = []; + highlightTree(tree, highlighter, (from, to, cls) => out.push(`${doc.slice(from, to)}:${cls}`)); + return out; +} + +/** The error nodes of `doc`'s tree, with the text they start. */ +function errors(doc: string): string[] { + const out: string[] = []; + jspLanguage.parser.parse(doc).iterate({ + enter: (node) => void (node.type.isError && out.push(doc.slice(node.from, node.from + 8))), + }); + return out; +} + +/** The tree of `state`, parsed to the end. */ +function stateTree(state: EditorState): Tree { + const tree = ensureSyntaxTree(state, state.doc.length, 5000); + if (!tree) throw new Error("parse did not finish"); + return tree; +} + +/** A ~8 KB page with directives, scriptlets around markup, expressions and EL. */ +function page(): string { + let doc = `<%@ page contentType="text/html;charset=UTF-8" %>\n<%@ taglib prefix="c" uri="jakarta.tags.core" %>\n`; + for (let i = 0; i < 40; i++) { + doc += + `<%-- row ${i} --%>\n<% if (rows${i} != null) { %>\n` + + ` <%= rows${i}.size() %>\${fn:length(names${i})}\n` + + `<% } %>\n\n`; + } + return doc; +} + +describe("JSP pages", () => { + it("highlights directives and JSP comments", () => { + expect(tokens(`<%@ page import="java.util.*" %><%-- off --%>`)).toEqual([ + "<%@:pi", + "page:keyword", + "import:attr", + '"java.util.*":value', + "%>:pi", + "<%-- off --%>:comment", + ]); + }); + + it("parses the scriptlets as one Java body, around the markup", () => { + const doc = `<% for (String s : list) { %>
  • <%= s %>
  • <% } %>`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "<%:pi", + "for:keyword", + "String:type", + "s:def", + "list:var", + "%>:pi", + "li:tag", + "<%=:pi", + "s:var", + "%>:pi", + "li:tag", + "<%:pi", + "%>:pi", + ]); + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + const open = doc.indexOf("{"); + expect(matchBrackets(state, open, 1)?.end?.from).toBe(doc.lastIndexOf("}")); + }); + + it("keeps markup in a scriptlet's strings out of the page", () => { + const doc = `<% String cell = ""; %>

    x

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toContain('"":str'); + expect(tokens(doc).filter((s) => s.endsWith(":tag"))).toEqual(["p:tag", "p:tag"]); + }); + + it("highlights expressions and declarations as Java", () => { + const doc = `<%= name %><%= req.getParameter("q") %><%! private int count = 0; %>`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "<%=:pi", + "name:var", + "%>:pi", + "<%=:pi", + "req:var", + "getParameter:call", + '"q":str', + "%>:pi", + "<%!:pi", + "private:keyword", + "int:type", + "count:def", + "0:num", + "%>:pi", + ]); + }); + + it("highlights EL expressions", () => { + const doc = `

    \${fn:escapeXml(item.title)} #{a ? b : not empty c.d[0] and x.size() gt 2}

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual([ + "p:tag", + "${:el", + "fn:ns", + "escapeXml:call", + "item:var", + "title:prop", + "}:el", + "#{:el", + "a:var", + "b:var", + "not:opkw", + "empty:opkw", + "c:var", + "d:prop", + "0:num", + "and:opkw", + "x:var", + "size:method", + "gt:opkw", + "2:num", + "}:el", + "p:tag", + ]); + }); + + it("ends an EL expression at its own brace, past nested ones and strings", () => { + const doc = `\${ {'k': '}'}.k == true }`; + expect(errors(doc)).toEqual([]); + expect(tokens(doc)).toEqual(["${:el", "'k':str", "'}':str", "k:prop", "true:bool", "}:el", "b:tag"]); + }); + + it("parses markup with elements in attribute values and custom tags", () => { + const doc = + `` + + `

    `; + expect(errors(doc)).toEqual([]); + expect(tokens(doc).slice(0, 8)).toEqual([ + "c:forEach:tag", + "var:attr", + '"i":value', + "items:attr", + '":value', + "${:el", + "items:var", + "}:el", + ]); + }); + + it("parses EL in a script as EL, like the container", () => { + expect(tokens(``)).toContain("user:var"); + }); + + it("keeps escaped elements as text", () => { + expect(tokens(`

    \\\${a} <\\% b %>

    `)).toEqual(["p:tag", "p:tag"]); + }); + + it("comments with JSP comments in the markup and Java ones in scriptlets", () => { + const doc = `

    x

    <% int a; %>`; + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + const at = (pos: number) => + state.languageDataAt<{ line?: string; block?: { open: string } }>("commentTokens", pos)[0]; + expect(at(4)?.block?.open).toBe("<%--"); + expect(at(doc.indexOf("int") + 1)?.line).toBe("//"); + }); + + it("folds JSP comments and scriptlets", () => { + const doc = `<%--\n a\n--%>\n<%\n int a;\n%>`; + const state = EditorState.create({ doc, extensions: jsp() }); + stateTree(state); + expect(foldable(state, 0, 4)).toEqual({ from: 4, to: doc.indexOf("--%>") }); + const line = state.doc.line(4); + expect(foldable(state, line.from, line.to)).toEqual({ from: line.to, to: doc.lastIndexOf("%>") }); + }); + + it("highlights like a fresh parse after resuming and editing", () => { + const doc = page(); + // Opening parses the first 3000 characters; the rest resumes from that tree. + let state = EditorState.create({ doc, extensions: jsp() }); + expect(tokens(doc, stateTree(state))).toEqual(tokens(doc)); + const at = (text: string, n = 1) => { + let pos = -1; + for (let i = 0; i < n; i++) pos = state.doc.toString().indexOf(text, pos + 1); + return pos; + }; + const edits = [ + () => ({ from: at("rows12.size()") + 4, insert: "x" }), + () => ({ from: at(".size() %>", 15) + 7, insert: "+1" }), + () => ({ from: at("<% } %>", 20), insert: "<% int k = 0; %>\n" }), + () => ({ from: at("<%-- row 7"), to: at("<%-- row 7") + 4 }), + () => ({ from: at("names3"), to: at("names3") + 6, insert: "a.b[0]" }), + () => ({ from: 0, insert: "${top}\n" }), + () => ({ from: at(" { + const dir = import.meta.dirname; + const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "jsp.grammar"), "utf8"), { + fileName: "src/editor/core/cm/jsp/jsp.grammar", + moduleStyle: "es", + typeScript: true, + }); + expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8")); + expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")); + }); + + it("is the JSP mode", async () => { + const desc = findLanguageDescription("JSP", "/p/index.jsp"); + expect((await loadSupport(desc!))?.language).toBe(jspLanguage); + }); +}); diff --git a/src/editor/core/cm/jsp/parser.terms.ts b/src/editor/core/cm/jsp/parser.terms.ts new file mode 100644 index 00000000..b2f03afd --- /dev/null +++ b/src/editor/core/cm/jsp/parser.terms.ts @@ -0,0 +1,39 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +export const + JspComment = 1, + Text = 2, + JavaCode = 3, + ELContent = 4, + Namespace = 5, + Document = 6, + Template = 7, + Directive = 10, + Attribute = 12, + Is = 14, + AttributeValue = 15, + Declaration = 17, + JspExpression = 19, + Scriptlet = 21, + ELExpression = 22, + EL = 27, + Number = 28, + String = 29, + BooleanLiteral = 30, + _null = 31, + VariableName = 32, + PropertyName = 45, + ArgList = 47, + FunctionName = 49, + not = 51, + empty = 52, + div = 57, + mod = 58, + lt = 62, + gt = 63, + le = 64, + ge = 65, + _instanceof = 66, + eq = 68, + ne = 69, + and = 71, + or = 73 diff --git a/src/editor/core/cm/jsp/parser.ts b/src/editor/core/cm/jsp/parser.ts new file mode 100644 index 00000000..ad5275ef --- /dev/null +++ b/src/editor/core/cm/jsp/parser.ts @@ -0,0 +1,24 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import {LRParser} from "@lezer/lr" +import {jspComment, text, javaCode, elContent, namespace} from "./tokens" +const spec_identifier = {__proto__:null,true:60, false:60, null:62, not:102, empty:104, div:114, mod:116, lt:124, gt:126, le:128, ge:130, instanceof:132, eq:136, ne:138, and:142, or:146} +export const parser = LRParser.deserialize({ + version: 14, + states: "1WOqOsOOQ!sQ!rOOO!zQpO'#CfO#POtO'#CmO#XOtO'#CoO#aOtO'#CqO#iOxO'#CrOOOR'#EP'#EPO#qOsO'#CcQOOOOOOOQO'#E['#E[OOQO'#C|'#C|O#xQ!rO'#DPO$PQ!rO'#DTO$WQ!rO'#DVQ&bQ#tOOO&iQpO'#D^OOQ`'#Dc'#DcOOQ`'#Dd'#DdOxQ!rO'#D`OOQO'#EZ'#EZO&nQ!rO'#ETQ&xQpOOOOQO'#Cg'#CgO&}QpO,59QOOOR,59X,59XO'VOpO,59XOOOR,59Z,59ZO'[OpO,59ZOOOR,59],59]O'aOpO,59]OOOR'#Cu'#CuOOOR,59^,59^O#lOpO,59^OOOR-E7}-E7}OOQO,59k,59kO'fQ#tO,59kOOQO,59o,59oO'pQ#tO,59oO'zQ#tO'#E^OOQO,59q,59qO(XQpO,59qOOQ`'#Cj'#CjO(aQ!bO,59sOxQ!rO,59sO(fQ!rO'#D]OOQO,59v,59vOOQ`'#Df'#DfOOQ`'#Di'#DiOOQ`'#Dj'#DjOOQ`'#Dk'#DkOOQ`'#Dq'#DqOOQ`'#Dt'#DtOOQ`'#Dv'#DvOxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POOQ`'#Dy'#DyOxQ!rO,5:dOxQ!rO,5:gOxQ!rO,5:iO(mQ!bO,59xO(rQ#tO,59zO+[Q#tO,5:oOOQO-E8R-E8ROOQO'#Ci'#CiO+fQpO'#ChOOQO'#EQ'#EQO+qQpO1G.lOOOR1G.l1G.lOOOR1G.s1G.sOOOR1G.u1G.uOOOR1G.w1G.wOOOR1G.x1G.xOxQ!rO'#ERO+yQpO1G/VOOQO1G/V1G/VO,RQpO1G/ZOOQO1G/Z1G/ZOxQ!rO,5:xOxQ!rO'#ESO,ZQpO1G/]OOQO1G/]1G/]OOQO'#DZ'#DZOOQO1G/_1G/_O,cQ#tO1G/_O,jQ#tO,59wOOQO,59w,59wO.}Q#tO1G/kO/bQ#tO1G/kO2[Q#tO1G/kO2cQ#tO1G/kO4vQ#tO1G/kO5WQ#tO1G/kO7[Q#tO1G/kO8UQ#tO1G0OO8]Q#tO1G0RO9VQ#tO1G0TOOQO'#D_'#D_O9pQpO1G/dO9uQpO,59SOOQO-E8O-E8OOOOR7+$W7+$WO9zQ#tO,5:mOOQO-E8P-E8POOQO7+$q7+$qOOQO7+$u7+$uO:XQ#tO1G0dOOQO,5:n,5:nOOQO-E8Q-E8QOOQO7+$w7+$wOOQO7+$y7+$yO:cQpO1G/cOOQO1G/c1G/cOOQ`'#Dz'#DzOxQ!rO7+%jOOQO7+%O7+%OOOQO1G.n1G.nOOQO7+$}7+$}O:kQ#tO< <%@ Directive DirectiveName Attribute AttributeName Is AttributeValue <%! Declaration <%= JspExpression <% Scriptlet ELExpression ${ #{ ELEnd } EL Number String BooleanLiteral null VariableName ) ( ParenthesizedExpression , ] [ ListLiteral { SetOrMapLiteral : MemberExpression . PropertyName CallExpression ArgList FunctionCall FunctionName UnaryExpression not empty LogicOp ArithOp BinaryExpression ArithOp div mod ArithOp ArithOp CompareOp lt gt le ge instanceof CompareOp eq ne LogicOp and LogicOp or ConditionalExpression LogicOp LogicOp LambdaExpression -> AssignmentExpression ;", + maxTerm: 110, + nodeProps: [ + ["openedBy", 8,"<%@ <%! <%= <%",26,"{",33,"(",37,"["], + ["closedBy", -4,9,16,18,20,"%>",34,")",38,"]",40,"}"], + ["group", -16,28,29,30,31,32,35,39,41,43,46,48,50,55,74,77,79,"Expression"] + ], + skippedNodes: [0], + repeatNodeCount: 5, + tokenData: "5S~RuXY#fYZ#f]^#fpq#fqr#wrs$Ust(etu(puv*[vw*iwx*txy.wyz.|z{/R{|/W|}/e}!O/j!O!P/w!P!Q0y!Q![1O![!]1a!]!^1f!^!_1k!_!`2j!`!a2w!a!b3U!c!}3Z!}#O4c#P#Q4h#R#S3Z#T#o3Z#o#p4m#p#q4r#q#r4}$g;'S)b;'S;=`*P<%lO)b~#kS!z~XY#fYZ#f]^#fpq#fU#|P#RQ!_!`$PS$UO#_SR$]V_PmQOr$Urs$rs#O$U#O#P$y#P;'S$U;'S;=`(_<%lO$UR$yO_PmQR%OU_POr$Urs%bs;'S$U;'S;=`'p;=`<%l&O<%lO$UR%iV_PmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&TVmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&oOmQQ&rRO;'S&O;'S;=`&{;=`O&OQ'QWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l&O<%lO&OQ'mP;=`<%l&OR'uWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l$U<%lO&OR(bP;=`<%l$U~(hP#o#p(k~(pOh~V(uX#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b#o#p*V$g;'S)b;'S;=`*P<%lO)bU)gW#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b$g;'S)b;'S;=`*P<%lO)bU*SP;=`<%l)bP*[OgPT*aP#VS!`!a*dP*iOWP~*lPvw*o~*tO#`~R*{V_PmQOw*twx$rx#O*t#O#P+b#P;'S*t;'S;=`.q<%lO*tR+gU_POw*twx+yx;'S*t;'S;=`.S;=`<%l,g<%lO*tR,QV_PmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ,lVmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ-URO;'S,g;'S;=`-_;=`O,gQ-dWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l,g<%lO,gQ.PP;=`<%l,gR.XWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l*t<%lO,gR.tP;=`<%l*t~.|Or~~/ROq~~/WO#T~~/]P#W~!_!`/`~/eO#X~~/jOt~~/oP#S~!`!a/r~/wO!p~~/|P|~!Q![0P~0URl~!Q![0P!g!h0_#X#Y0_~0bR{|0k}!O0k!Q![0q~0nP!Q![0q~0vPl~!Q![0q~1OO#U~~1TSl~!O!P0P!Q![1O!g!h0_#X#Y0_~1fOz~~1kO!r~~1pQ#YSuv1v!_!`2eP1{RdPqr2U!_!`2Z!b!c2`P2ZO`PP2`ObPP2eOXP~2jO#[~~2oP!|~!_!`2r~2wO#^~~2|P#Z~!_!`3P~3UO#]~~3ZO#b~V3bX#PU!{Ptu)b}!O3}!Q![3Z!c!}3Z#R#S3Z#T#o3Z$g;'S)b;'S;=`*P<%lO)bP4ST!{P}!O3}!Q![3}!c!}3}#R#S3}#T#o3}~4hOv~~4mOu~~4rOx~~4uP#p#q4x~4}O#a~~5SOj~", + tokenizers: [jspComment, text, javaCode, elContent, namespace, 0, 1, 2], + topRules: {"Document":[0,6],"EL":[1,27]}, + specialized: [{term: 93, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}], + tokenPrec: 1217 +}) diff --git a/src/editor/core/cm/jsp/tokens.ts b/src/editor/core/cm/jsp/tokens.ts new file mode 100644 index 00000000..20288eba --- /dev/null +++ b/src/editor/core/cm/jsp/tokens.ts @@ -0,0 +1,101 @@ +/** + * External tokens of `jsp.grammar`: the runs the JSP layer leaves to HTML and + * Java, the comments, and the extent of EL expressions. + */ +import { ExternalTokenizer, type InputStream } from "@lezer/lr"; +import { ELContent, JavaCode, JspComment, Namespace, Text } from "./parser.terms"; + +const DOUBLE_QUOTE = 34, + HASH = 35, + DOLLAR = 36, + PERCENT = 37, + SINGLE_QUOTE = 39, + PAREN_L = 40, + DASH = 45, + COLON = 58, + LT = 60, + GT = 62, + BACKSLASH = 92, + UNDERSCORE = 95, + BRACE_L = 123, + BRACE_R = 125; + +/** Whether `<%` starts `offset` code units ahead. */ +function scriptStart(input: InputStream, offset = 0): boolean { + return input.peek(offset) == LT && input.peek(offset + 1) == PERCENT; +} + +/** Whether `${` or `#{` starts `offset` code units ahead. */ +function elStart(input: InputStream, offset = 0): boolean { + const ch = input.peek(offset); + return (ch == DOLLAR || ch == HASH) && input.peek(offset + 1) == BRACE_L; +} + +/** `<%-- … --%>`, to the end of the page when unterminated. */ +export const jspComment = new ExternalTokenizer((input) => { + if (!scriptStart(input) || input.peek(2) != DASH || input.peek(3) != DASH) return; + input.advance(4); + while (input.next >= 0) { + if (input.next == DASH && input.peek(1) == DASH && input.peek(2) == PERCENT && input.peek(3) == GT) { + input.advance(4); + break; + } + input.advance(); + } + input.acceptToken(JspComment); +}); + +/** + * Template text: up to a scripting element or an EL expression. Escaped ones + * (`<\%`, `\${`, `\#{`) are text. + */ +export const text = new ExternalTokenizer((input) => { + const start = input.pos; + while (input.next >= 0 && !scriptStart(input) && !elStart(input)) { + input.advance(input.next == BACKSLASH && elStart(input, 1) ? 2 : 1); + } + if (input.pos > start) input.acceptToken(Text); +}); + +/** The code of a scripting element, up to its `%>` (`%\>` quotes one). */ +export const javaCode = new ExternalTokenizer((input) => { + const start = input.pos; + while (input.next >= 0 && !(input.next == PERCENT && input.peek(1) == GT)) { + input.advance(input.next == PERCENT && input.peek(1) == BACKSLASH && input.peek(2) == GT ? 3 : 1); + } + if (input.pos > start) input.acceptToken(JavaCode); +}); + +/** An EL expression's content: up to the `}` closing it, past nested braces and strings. */ +export const elContent = new ExternalTokenizer((input) => { + const start = input.pos; + let depth = 0; + for (let ch; (ch = input.next) >= 0; input.advance()) { + if (ch == BRACE_R) { + if (!depth) break; + depth--; + } else if (ch == BRACE_L) { + depth++; + } else if (ch == DOUBLE_QUOTE || ch == SINGLE_QUOTE) { + input.advance(); + while (input.next >= 0 && input.next != ch) input.advance(input.next == BACKSLASH ? 2 : 1); + if (input.next < 0) break; + } + } + if (input.pos > start) input.acceptToken(ELContent); +}); + +const isIdentifierStart = (ch: number) => + (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch == UNDERSCORE || ch == DOLLAR || ch >= 0xa1; +const isIdentifier = (ch: number) => isIdentifierStart(ch) || (ch >= 48 && ch <= 57); + +/** An identifier followed by `:name(`, a function's namespace (`fn:join(…)`). */ +export const namespace = new ExternalTokenizer((input, stack) => { + if (!isIdentifierStart(input.next) || !stack.canShift(Namespace)) return; + let i = 1; + while (isIdentifier(input.peek(i))) i++; + const end = i; + if (input.peek(i++) != COLON || !isIdentifierStart(input.peek(i++))) return; + while (isIdentifier(input.peek(i))) i++; + if (input.peek(i) == PAREN_L) input.acceptToken(Namespace, end); +}); diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index f6c01d56..2b8231d7 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -102,6 +102,7 @@ const OWN_MODES = [ extensions: ["erb", "rhtml"], load: () => import("./erb").then((m) => m.erb()), }), + LanguageDescription.of({ name: "JSP", load: () => import("./jsp").then((m) => m.jsp()) }), ]; const BATCH_KEYWORDS = new Set([ @@ -222,14 +223,18 @@ const CSS_TAGS: Parameters[0] = { // @lezer/javascript styles `.` as a deref operator, but not `?.`. const JS_TAGS: Parameters[0] = { "?.": t.derefOperator }; +// @lezer/java tags annotations as plain identifiers. +export const JAVA_TAGS: Parameters[0] = { + "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation, +}; + /** Highlighting the stock grammars lack or colour unlike IntelliJ. */ const EXTRA_TAGS: Record[0]> = { JavaScript: JS_TAGS, TypeScript: JS_TAGS, JSX: JS_TAGS, TSX: JS_TAGS, - // @lezer/java tags annotations as plain identifiers. - Java: { "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation }, + Java: JAVA_TAGS, CSS: CSS_TAGS, SCSS: CSS_TAGS, Sass: CSS_TAGS, diff --git a/src/ui/icons.test.tsx b/src/ui/icons.test.tsx index 0e1a7063..8ad1b2e0 100644 --- a/src/ui/icons.test.tsx +++ b/src/ui/icons.test.tsx @@ -172,6 +172,9 @@ describe("fileIconName", () => { ["main.dart", "file-dart"], ["App.vue", "file-vue"], ["App.svelte", "file-svelte"], + ["index.jsp", "file-jsp"], + ["footer.jspf", "file-jsp"], + ["card.tag", "file-jsp"], ["styles.scss", "file-scss"], ["styles.sass", "file-sass"], ["styles.less", "file-less"], diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 047091b3..51f45b46 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -76,6 +76,7 @@ const ICONS = { "file-npm": "openidea/fileTypes/npm", "file-vue": "fileTypes/vue", "file-svelte": "openidea/fileTypes/svelte", + "file-jsp": "openidea/fileTypes/jsp", "file-yaml": "fileTypes/yaml", "file-toml": "fileTypes/toml", "file-html": "fileTypes/html", @@ -822,6 +823,7 @@ const EXT_ICONS = table({ "file-tsx": "tsx", "file-vue": "vue", "file-svelte": "svelte", + "file-jsp": "jsp jspx jspf jsph tag tagf tagx", "file-json": "json jsonc json5 jsonl ndjson geojson topojson webmanifest har avsc code-workspace code-snippets map", "file-md": "md mdx markdown mdown mkd mkdn mdwn ronn workbook", "file-rst": "rst", diff --git a/src/ui/openidea/fileTypes/jsp.svg b/src/ui/openidea/fileTypes/jsp.svg new file mode 100644 index 00000000..3e1a529a --- /dev/null +++ b/src/ui/openidea/fileTypes/jsp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/openidea/fileTypes/jsp_dark.svg b/src/ui/openidea/fileTypes/jsp_dark.svg new file mode 100644 index 00000000..d8443015 --- /dev/null +++ b/src/ui/openidea/fileTypes/jsp_dark.svg @@ -0,0 +1,4 @@ + + + + From ecdd43a55ae6ea0477d0b851cedf4386de83c489 Mon Sep 17 00:00:00 2001 From: Azeem Hassni Date: Tue, 3 Nov 2026 14:37:40 +0500 Subject: [PATCH 088/108] fix(vfs): poll for changes when the OS file watcher cannot start (#906) Opening a project failed outright when the OS file watcher could not start (inotify limits used up, a file system without change notifications). The watcher now falls back to polling the same non-ignored directories every 2s, per directory unless flat so excluded trees like `node_modules` and `target` are not rescanned, and the project opens with an "External file changes sync may be slow" warning that gives the reason. Directory mtime changes seen only by polling are dropped so folders are not reported as modified. Fixes #891. Co-authored-by: azeemhassni <6503258+azeemhassni@users.noreply.github.com> --- crates/ide-host/src/project.rs | 7 ++ crates/ide-vfs/src/watch.rs | 159 ++++++++++++++++++++++++------ src/platform/locales/text/zh.json | 3 +- 3 files changed, 139 insertions(+), 30 deletions(-) diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 1391f618..e7d26808 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -365,6 +365,13 @@ impl Host { }), )? }; + if let Some(reason) = watcher.polling() { + sink.emit(IdeEvent::Notify { + level: ide_core::event::NotifyLevel::Warning, + title: "External file changes sync may be slow".into(), + body: Some(reason.into()), + }); + } // Scratch files live outside the project; watch them too so the // Project view and open editors follow changes made there. diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index 011cab69..d9309d90 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -5,12 +5,15 @@ //! that skips excluded / git-ignored directories (Linux, where a recursive //! inotify watch would register every directory of `node_modules` and //! `target` and exhaust `max_user_watches`). +//! +//! When the OS watcher cannot start (inotify limits used up, a file system +//! without change notifications), the same directories are polled instead. use crate::FsBatch; use crate::rules::{ExcludeMatcher, GitignoreStack}; use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink}; use notify_debouncer_full::notify::event::{ModifyKind, RenameMode}; -use notify_debouncer_full::notify::{self, EventKind, RecommendedWatcher, RecursiveMode, Watcher as _}; +use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind}; use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt}; use parking_lot::{Condvar, Mutex, RwLock}; use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -23,6 +26,8 @@ use std::time::{Duration, Instant}; const DEBOUNCE: Duration = Duration::from_millis(100); /// Upper bound on how long a continuous stream of events is held back. const MAX_DELAY: Duration = Duration::from_millis(1000); +/// How often the polling fallback rescans the watched directories. +const POLL_INTERVAL: Duration = Duration::from_secs(2); /// How directories are registered with the OS. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -43,6 +48,7 @@ impl Default for WatchMode { } type OnChange = Box; +type Emit = Arc; /// Debounced watcher. Emits `IdeEvent::FsChanged` to `sink` and invokes /// `on_change` (used by the index) with the same batch. Stops when dropped; @@ -51,12 +57,14 @@ pub struct Watcher { inner: Inner, excludes: SharedExcludes, rules: Arc, + polling: Option, } type SharedExcludes = Arc>; enum Inner { Recursive(#[allow(dead_code)] Debouncer), + Polling(#[allow(dead_code)] Debouncer), PerDirectory { state: Arc, thread: Option> }, } @@ -76,7 +84,7 @@ impl Watcher { let filter = EventFilter::new(root.clone(), &excludes); let shared = filter.excludes.clone(); let rules = filter.gitignore.clone(); - let emit = move |batch: &FsBatch| { + let emit: Emit = Arc::new(move |batch: &FsBatch| { // VCS-metadata-only batches (`batch.vcs`) carry no worktree paths: // consumers that refresh on any `fsChanged` would reschedule purely // from the backend's own `.git/index` write. @@ -91,13 +99,21 @@ impl Watcher { sink.emit(IdeEvent::ExcludedDirsChanged { dirs: batch.excluded_dirs.iter().map(Into::into).collect() }); } on_change(batch); + }); + let (inner, polling) = match Self::start_native(&root, filter.clone(), emit.clone(), mode) { + Ok(inner) => (inner, None), + // Rather than failing to open the project, keep it in sync a little later. + Err(e) => { + tracing::warn!("{e}; polling {} for changes instead", root.display()); + (Self::start_polling(&root, filter, emit, mode)?, Some(e.message)) + } }; - let inner = match mode { - WatchMode::Recursive => Self::start_recursive(root, filter, emit, RecursiveMode::Recursive), - WatchMode::Flat => Self::start_recursive(root, filter, emit, RecursiveMode::NonRecursive), - WatchMode::PerDirectory => Self::start_per_directory(root, filter, emit), - }?; - Ok(Self { inner, excludes: shared, rules }) + Ok(Self { inner, excludes: shared, rules, polling }) + } + + /// Why changes are polled for instead of reported by the OS; `None` when they are. + pub fn polling(&self) -> Option<&str> { + self.polling.as_deref() } /// Replaces the exclude globs (Settings › Project Structure / File Types @@ -116,21 +132,53 @@ impl Watcher { /// Number of OS-level watches (1 in recursive and flat mode). pub fn watch_count(&self) -> usize { match &self.inner { - Inner::Recursive(_) => 1, + Inner::Recursive(_) | Inner::Polling(_) => 1, Inner::PerDirectory { state, .. } => state.watched.lock().len(), } } - fn start_recursive( - root: PathBuf, + fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult { + let config = notify::Config::default(); + match mode { + WatchMode::Recursive => { + Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive) + } + WatchMode::Flat => { + Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive) + } + WatchMode::PerDirectory => Self::start_per_directory::(root, filter, emit, config), + } + } + + /// Polls per directory unless flat: a recursive poll would rescan + /// `node_modules` and `target` every round. + fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult { + let config = notify::Config::default().with_poll_interval(POLL_INTERVAL); + match mode { + WatchMode::Flat => { + Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling) + } + WatchMode::Recursive | WatchMode::PerDirectory => { + Self::start_per_directory::(root, filter, emit, config) + } + } + } + + fn start_debounced( + root: &Path, filter: EventFilter, - emit: impl Fn(&FsBatch) + Send + 'static, + emit: Emit, recursive: RecursiveMode, - ) -> IdeResult { + config: notify::Config, + ) -> IdeResult> { + let polled = W::kind() == WatcherKind::PollWatcher; // No file-id cache: building it walks and stats the whole tree before `watch` returns, // which made opening large projects block for seconds. Renames arrive as remove + create. let handler = move |result: DebounceEventResult| match result { - Ok(events) => { + Ok(mut events) => { + if polled { + events.retain(|e| !is_dir_touch(&e.event)); + } let (batch, _) = filter.batch_detailed(&events); if !batch.is_empty() { emit(&batch); @@ -142,18 +190,19 @@ impl Watcher { } } }; - let mut debouncer = - new_debouncer_opt::<_, RecommendedWatcher, _>(DEBOUNCE, None, handler, NoCache, notify::Config::default()) - .map_err(|e| io_error(format!("cannot start file watcher: {e}")))?; - debouncer.watch(&root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?; - Ok(Inner::Recursive(debouncer)) + let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config) + .map_err(|e| io_error(format!("cannot start file watcher: {e}")))?; + debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?; + Ok(debouncer) } - fn start_per_directory( - root: PathBuf, + fn start_per_directory( + root: &Path, filter: EventFilter, - emit: impl Fn(&FsBatch) + Send + 'static, + emit: Emit, + config: notify::Config, ) -> IdeResult { + let polled = W::kind() == WatcherKind::PollWatcher; let state = Arc::new(DirState { filter, watcher: Mutex::new(None), @@ -163,10 +212,11 @@ impl Watcher { stop: AtomicBool::new(false), }); let weak: Weak = Arc::downgrade(&state); - let watcher = RecommendedWatcher::new( + let watcher = W::new( move |event: notify::Result| { let Some(state) = weak.upgrade() else { return }; match event { + Ok(event) if polled && is_dir_touch(&event) => {} Ok(event) => { state.queue.lock().push((event, Instant::now())); state.wake.notify_one(); @@ -174,12 +224,12 @@ impl Watcher { Err(e) => tracing::warn!("file watcher: {e}"), } }, - notify::Config::default(), + config, ) .map_err(|e| io_error(format!("cannot start file watcher: {e}")))?; - *state.watcher.lock() = Some(watcher); + *state.watcher.lock() = Some(Box::new(watcher)); let mut created = Vec::new(); - state.watch_tree(&root, false, &mut created); + state.watch_tree(root, false, &mut created); if state.watched.lock().is_empty() { return Err(io_error(format!("cannot watch {}", root.display()))); } @@ -187,7 +237,7 @@ impl Watcher { let state = state.clone(); std::thread::Builder::new() .name("openidea-fs-watch".into()) - .spawn(move || state.run(emit)) + .spawn(move || state.run(&*emit)) .map_err(|e| io_error(format!("cannot start file watcher: {e}")))? }; Ok(Inner::PerDirectory { state, thread: Some(thread) }) @@ -212,11 +262,17 @@ fn io_error(msg: String) -> IdeError { IdeError::new(ide_core::error::ErrorKind::Io, msg) } +/// A directory's own mtime moving as entries come and go: polling sees it, the +/// OS watchers report only the entries (also seen, through that directory's watch). +fn is_dir_touch(event: ¬ify::Event) -> bool { + matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir()) +} + /// State of the per-directory strategy, shared with the notify callback /// (weakly) and the debounce thread. struct DirState { filter: EventFilter, - watcher: Mutex>, + watcher: Mutex>>, watched: Mutex>, queue: Mutex>, wake: Condvar, @@ -396,6 +452,7 @@ fn merge(prev: Option, next: Change) -> Change { } } +#[derive(Clone)] pub(crate) struct EventFilter { root: PathBuf, excludes: SharedExcludes, @@ -791,7 +848,7 @@ mod tests { fn watcher_state(w: &Watcher) -> &DirState { match &w.inner { Inner::PerDirectory { state, .. } => state, - Inner::Recursive(_) => panic!("not per-directory"), + Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"), } } @@ -969,4 +1026,48 @@ mod tests { std::thread::sleep(Duration::from_millis(300)); assert!(!all_paths(&batches.lock()).contains(&root.join("sub/deep.txt"))); } + + /// The fallback used when the OS watcher cannot start. + fn polling_watcher(root: &Path, excludes: Vec, batches: Arc>>) -> Watcher { + let filter = EventFilter::new(root.to_path_buf(), &excludes); + let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone()); + let emit: Emit = Arc::new(move |b: &FsBatch| batches.lock().push(b.clone())); + let inner = Watcher::start_polling(root, filter, emit, WatchMode::default()).unwrap(); + Watcher { inner, excludes, rules, polling: Some("cannot start file watcher".into()) } + } + + #[test] + fn polling_skips_excluded_dirs_and_follows_new_ones() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + for d in ["src/a", "node_modules/x"] { + std::fs::create_dir_all(root.join(d)).unwrap(); + } + let batches: Arc>> = Arc::default(); + let watcher = polling_watcher(&root, vec!["**/node_modules".into()], batches.clone()); + // root, src, src/a + assert_eq!(watcher.watch_count(), 3); + + std::fs::write(root.join("node_modules/x/z.js"), "x").unwrap(); + std::fs::write(root.join("src/a/f.rs"), "x").unwrap(); + std::fs::create_dir_all(root.join("src/new/inner")).unwrap(); + std::fs::write(root.join("src/new/inner/g.rs"), "x").unwrap(); + assert!( + wait_for(&batches, |b| { + let paths = all_paths(b); + paths.contains(&root.join("src/a/f.rs")) && paths.contains(&root.join("src/new/inner/g.rs")) + }), + "{:?}", + batches.lock() + ); + let paths = all_paths(&batches.lock()); + assert!(!paths.iter().any(|p| p.starts_with(root.join("node_modules"))), "{paths:?}"); + // Folders whose entries changed aren't reported as modified, as with inotify. + assert!(!paths.contains(&root.join("src")) && !paths.contains(&root.join("src/a")), "{paths:?}"); + assert_eq!(watcher.watch_count(), 5); + + // Later writes inside a new folder are seen through its own poll. + std::fs::write(root.join("src/new/inner/h.rs"), "x").unwrap(); + assert!(wait_for(&batches, |b| all_paths(b).contains(&root.join("src/new/inner/h.rs")))); + } } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0522011d..f965fcad 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5547,5 +5547,6 @@ "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", "e.g. --keepdb --parallel": "例如 --keepdb --parallel", "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", - "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改" + "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改", + "External file changes sync may be slow": "外部文件更改同步可能较慢" } From 291fa9affebe389ec80726d3f59daf1ec2430a00 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:57:42 +0800 Subject: [PATCH 089/108] feat(window): close other / left / right project tabs from the window-group tab menu (#965) Right-clicking a project tab in a merged window group offers Close Other Tabs, Close Tabs to the Left and Close Tabs to the Right (left/right disabled at the ends); the same commands are in the Command Palette and can take shortcuts. The backend closes the projects one at a time, focusing each so its own unsaved-changes prompt is visible, as Quit does. Cancelling a prompt stops the batch and leaves that project and the rest open; the clicked tab stays and is selected when the batch finishes. Starting Quit clears a pending batch. Closes #942. Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- docs/workspaces-and-window-groups.md | 2 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/window_group.rs | 14 +++++ src-tauri/src/window_groups.rs | 77 ++++++++++++++++++++++++++ src-tauri/src/windows.rs | 6 +- src/bindings.ts | 4 ++ src/features/app/closeGuard.ts | 3 +- src/features/app/windows.ts | 37 ++++++++++++- src/platform/locales/text/zh.json | 3 + src/workbench/WindowGroupTabs.test.tsx | 27 +++++++++ src/workbench/WindowGroupTabs.tsx | 13 +++++ 11 files changed, 184 insertions(+), 4 deletions(-) diff --git a/docs/workspaces-and-window-groups.md b/docs/workspaces-and-window-groups.md index be9cf6a2..db86800e 100644 --- a/docs/workspaces-and-window-groups.md +++ b/docs/workspaces-and-window-groups.md @@ -28,7 +28,7 @@ The specialized Maven/Gradle tool-window model still describes the workspace dir Open two project windows, then use **Window → Merge All Project Windows**. The project tabs occupy their own row below the main toolbar. Click a tab or use **Next/Previous Project Tab** to switch; drag tabs to reorder them. The default shortcuts are ⌥⌘PageDown/PageUp on macOS and Ctrl+Alt+PageDown/PageUp on Windows/Linux. -Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled. +Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled. The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4a8f3c06..0a410b34 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -77,6 +77,8 @@ pub fn builder() -> tauri_specta::Builder { window_group::window_group_select, window_group::window_group_detach, window_group::window_group_close, + window_group::window_group_close_tabs, + window_group::window_group_cancel_close, window_group::window_group_reorder, window_group::window_group_restore, app::light_edit_open, diff --git a/src-tauri/src/commands/window_group.rs b/src-tauri/src/commands/window_group.rs index 210797dc..f6d4d29a 100644 --- a/src-tauri/src/commands/window_group.rs +++ b/src-tauri/src/commands/window_group.rs @@ -41,6 +41,20 @@ pub fn window_group_close(window: WebviewWindow, label: String) -> IdeResult<()> .map_err(|e| IdeError::internal(e.to_string())) } +/// Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`. +#[tauri::command] +#[specta::specta] +pub fn window_group_close_tabs(window: WebviewWindow, keep: String, labels: Vec) -> IdeResult<()> { + groups::close_tabs(window.app_handle(), window.label(), &keep, labels) +} + +/// This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. +#[tauri::command] +#[specta::specta] +pub fn window_group_cancel_close(window: WebviewWindow) { + groups::cancel_close(window.app_handle(), window.label()); +} + #[tauri::command] #[specta::specta] pub async fn window_group_reorder(window: WebviewWindow, labels: Vec) -> IdeResult<()> { diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index fef00fa1..d8c47c2b 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -6,6 +6,7 @@ use crate::{ use ide_core::{IdeError, IdeEvent, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; +use std::collections::VecDeque; use std::path::PathBuf; use std::sync::atomic::Ordering; use tauri::{AppHandle, Manager, WebviewWindow}; @@ -170,6 +171,82 @@ pub fn destroyed(app: &AppHandle, caller: &str) { }); } changed(app); + let advance = state.tab_closes.lock().as_mut().is_some_and(|batch| { + let closing = batch.pending.front().is_some_and(|l| l == caller); + if closing { + batch.pending.pop_front(); + } + closing + }); + if advance { + close_next(app); + } +} + +/// Tabs left to close; the first one is closing now. +pub struct TabCloses { + keep: String, + pending: VecDeque, +} + +/// Closes `labels` one at a time so each project's unsaved-changes prompt is shown in turn, then selects `keep`. +pub fn close_tabs(app: &AppHandle, caller: &str, keep: &str, labels: Vec) -> IdeResult<()> { + let state = app.state::(); + let members = state + .groups + .lock() + .group(caller) + .map(|g| g.members.clone()) + .ok_or_else(|| IdeError::not_found("This window is not grouped"))?; + if !members.iter().any(|m| m == keep) { + return Err(IdeError::invalid("The window is not in this group")); + } + let pending = members.into_iter().filter(|m| m != keep && labels.contains(m)).collect(); + *state.tab_closes.lock() = Some(TabCloses { keep: keep.to_owned(), pending }); + close_next(app); + Ok(()) +} + +/// The closing tab's project vetoed (its unsaved-changes prompt was cancelled): leave the rest open. +pub fn cancel_close(app: &AppHandle, caller: &str) { + let state = app.state::(); + let mut batch = state.tab_closes.lock(); + if batch.as_ref().is_some_and(|b| b.pending.front().is_some_and(|l| l == caller)) { + *batch = None; + } +} + +fn close_next(app: &AppHandle) { + let state = app.state::(); + loop { + let next = { + let mut batch = state.tab_closes.lock(); + let Some(b) = batch.as_mut() else { return }; + match b.pending.front() { + Some(label) => Ok(label.clone()), + None => Err(batch.take().unwrap().keep), + } + }; + match next { + Ok(label) => { + if let Some(window) = app.get_webview_window(&label) { + windows::focus(&window); + if window.close().is_ok() { + return; + } + } + if let Some(b) = state.tab_closes.lock().as_mut() { + b.pending.pop_front(); + } + } + Err(keep) => { + if let Some(window) = app.get_webview_window(&keep) { + windows::focus(&window); + } + return; + } + } + } } #[derive(Serialize, Deserialize)] diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index b9d14c12..d6bc2051 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -67,6 +67,8 @@ pub struct WindowsState { last_focused: Mutex>, /// Set by Quit while windows close one by one. quitting: AtomicBool, + /// Project tabs closing one by one (Close Other Tabs and friends). + pub tab_closes: Mutex>, /// Relaunch once the quit completes (after installing an update). restart: AtomicBool, } @@ -109,7 +111,9 @@ impl WindowsState { pub fn set_quitting(&self, on: bool) { self.quitting.store(on, Ordering::SeqCst); - if !on { + if on { + *self.tab_closes.lock() = None; + } else { self.restart.store(false, Ordering::SeqCst); } } diff --git a/src/bindings.ts b/src/bindings.ts index 7f3e69b7..bb162c12 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -23,6 +23,10 @@ export const commands = { windowGroupSelect: (label: string) => typedError(__TAURI_INVOKE("window_group_select", { label })), windowGroupDetach: () => typedError(__TAURI_INVOKE("window_group_detach")), windowGroupClose: (label: string) => typedError(__TAURI_INVOKE("window_group_close", { label })), + /** Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`. */ + windowGroupCloseTabs: (keep: string, labels: string[]) => typedError(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })), + /** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */ + windowGroupCancelClose: () => __TAURI_INVOKE("window_group_cancel_close"), windowGroupReorder: (labels: string[]) => typedError(__TAURI_INVOKE("window_group_reorder", { labels })), windowGroupRestore: () => typedError(__TAURI_INVOKE("window_group_restore")), /** diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 4194a168..2eedcb92 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -125,10 +125,11 @@ async function onCloseRequested(deps: CloseDeps): Promise { }, deps, ); - if (!ok && ctx.quitting) await commands.appCancelQuit(); + if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose()); return ok; } catch (e) { maybeEditorService()?.cancelClose?.(); + void commands.windowGroupCancelClose().catch(() => {}); deps.onSaveFailed(e); return false; } finally { diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 3b39eaf4..0125e469 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -23,7 +23,7 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote"; import { holdSplash } from "@/platform/splash"; import { editorService, maybeEditorService, notifications } from "@/workbench/contributions"; import { chooseProjectWindow } from "@/workbench/projectWindow"; -import { cycleWindowTab } from "@/platform/windowGroups"; +import { cycleWindowTab, useWindowGroup } from "@/platform/windowGroups"; import { showWorkspaceDialog } from "./WorkspaceDialog"; export const LAUNCH_EVENT = "openidea://launch"; @@ -222,6 +222,20 @@ export async function consumeLaunch(): Promise { } } +/** Closes the project tabs on `side` of the `arg` tab (default: the current one), keeping that tab. */ +function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] { + return async ({ arg }) => { + const tabs = useWindowGroup.getState().tabs; + const keep = typeof arg === "string" ? arg : tabs.find((tab) => tab.current)?.label; + const index = tabs.findIndex((tab) => tab.label === keep); + if (!keep || index < 0) return; + const labels = tabs + .filter((_, i) => (side === "left" ? i < index : side === "right" ? i > index : i !== index)) + .map((tab) => tab.label); + if (labels.length) await call(commands.windowGroupCloseTabs(keep, labels)); + }; +} + export function windowActions(): Action[] { return [ { @@ -302,6 +316,27 @@ export function windowActions(): Action[] { if (typeof arg === "string") await call(commands.windowGroupClose(arg)); }, }, + { + id: "window.closeOtherTabs", + title: "Close Other Project Tabs", + category: t("app.category.window"), + when: "windowGrouped", + run: closeProjectTabs("others"), + }, + { + id: "window.closeTabsToLeft", + title: "Close Project Tabs to the Left", + category: t("app.category.window"), + when: "windowGrouped", + run: closeProjectTabs("left"), + }, + { + id: "window.closeTabsToRight", + title: "Close Project Tabs to the Right", + category: t("app.category.window"), + when: "windowGrouped", + run: closeProjectTabs("right"), + }, { id: "window.reorderTabs", title: "Reorder Project Tabs", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f965fcad..d7b36d69 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5008,6 +5008,9 @@ "Window group": "窗口组", "Select Project Tab": "选择项目标签页", "Close Project Tab": "关闭项目标签页", + "Close Other Project Tabs": "关闭其他项目标签页", + "Close Project Tabs to the Left": "关闭左侧项目标签页", + "Close Project Tabs to the Right": "关闭右侧项目标签页", "Reorder Project Tabs": "重新排列项目标签页", "Close {name}": "关闭 {name}", "Tomcat Server": "Tomcat 服务器", diff --git a/src/workbench/WindowGroupTabs.test.tsx b/src/workbench/WindowGroupTabs.test.tsx index 6db78e67..4f83e5cf 100644 --- a/src/workbench/WindowGroupTabs.test.tsx +++ b/src/workbench/WindowGroupTabs.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { executeAction } from "@/platform/actions"; import { useWindowGroup } from "@/platform/windowGroups"; +import { useContextMenuStore } from "@/ui/Menu"; import { WindowGroupTabs } from "./WindowGroupTabs"; vi.mock("@/platform/actions", async (importOriginal) => ({ @@ -37,3 +38,29 @@ it("removes the tab row when the group dissolves", () => { render(); expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); }); + +it("offers closing the other tabs and those on either side of the clicked tab", () => { + useWindowGroup.setState({ + tabs: [ + { label: "main", name: "Frontend", root: "/front", current: true }, + { label: "project-1", name: "Backend", root: "/back", current: false }, + { label: "project-2", name: "Docs", root: "/docs", current: false }, + ], + }); + render(); + const menuFor = (name: string) => { + fireEvent.contextMenu(screen.getByRole("tab", { name })); + const items = useContextMenuStore.getState().menu!.items; + return (label: string) => + items.find((i) => "label" in i && i.label === label) as { disabled?: boolean; run?: () => unknown }; + }; + const first = menuFor("Frontend"); + expect(first("Close Tabs to the Left").disabled).toBe(true); + expect(first("Close Tabs to the Right").disabled).toBe(false); + const middle = menuFor("Backend"); + void middle("Close Other Tabs").run?.(); + expect(executeAction).toHaveBeenLastCalledWith("window.closeOtherTabs", "project-1"); + void middle("Close Tabs to the Left").run?.(); + expect(executeAction).toHaveBeenLastCalledWith("window.closeTabsToLeft", "project-1"); + expect(menuFor("Docs")("Close Tabs to the Right").disabled).toBe(true); +}); diff --git a/src/workbench/WindowGroupTabs.tsx b/src/workbench/WindowGroupTabs.tsx index f10f28e1..49e873e3 100644 --- a/src/workbench/WindowGroupTabs.tsx +++ b/src/workbench/WindowGroupTabs.tsx @@ -61,13 +61,26 @@ export function WindowGroupTabs() { }} onContextMenu={(e) => { e.preventDefault(); + const index = tabs.indexOf(tab); showContextMenu(e, [ { label: "Move Tab to New Window", disabled: !tab.current, run: () => executeAction("window.detachTab"), }, + { kind: "separator" }, { label: "Close Tab", run: () => executeAction("window.closeTab", tab.label) }, + { label: "Close Other Tabs", run: () => executeAction("window.closeOtherTabs", tab.label) }, + { + label: "Close Tabs to the Left", + disabled: index <= 0, + run: () => executeAction("window.closeTabsToLeft", tab.label), + }, + { + label: "Close Tabs to the Right", + disabled: index >= tabs.length - 1, + run: () => executeAction("window.closeTabsToRight", tab.label), + }, ]); }} > From 5ad179a9e6d6a2ac65d3a7d9d82bcb11634e7a43 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:12:43 +0800 Subject: [PATCH 090/108] perf(java): stop configuration indexing from blocking navigation and keep it warm (#1012) Java Go to Definition consults the Spring configuration command only inside @Value / getProperty / getRequiredProperty arguments (tree-sitter check), so ordinary Ctrl+click and Ctrl+hover go straight to JDT instead of waiting on a project-wide index rebuild. The java-usages bundle (1.2.1) keeps its configuration snapshot across editor open/close, builds, saves of non-Java resources and reconciles; it invalidates on Java model changes and unsaved Java edits only. Fixes #998 Fixes #1002 --- .../ide-language/src/syntax/configuration.rs | 82 +++++++++++++++++- crates/ide-language/src/syntax/lines.rs | 10 +++ crates/ide-language/src/syntax/web.rs | 11 +-- crates/ide-lsp/src/jdtls/configuration.rs | 16 +++- crates/ide-lsp/tests/mock_server.rs | 35 +++++++- crates/ide-sdk/src/java.rs | 2 +- plugins/java-usages/CONFIGURATION.md | 13 +-- plugins/java-usages/MANIFEST.MF | 2 +- plugins/java-usages/README.md | 5 +- plugins/java-usages/openidea-java-usages.jar | Bin 82033 -> 83078 bytes .../configuration/ConfigurationCommand.java | 27 ++++-- plugins/java-usages/tests/configuration.py | 29 ++++++- 12 files changed, 203 insertions(+), 29 deletions(-) diff --git a/crates/ide-language/src/syntax/configuration.rs b/crates/ide-language/src/syntax/configuration.rs index 43c1cb5c..4a1a081f 100644 --- a/crates/ide-language/src/syntax/configuration.rs +++ b/crates/ide-language/src/syntax/configuration.rs @@ -1,9 +1,9 @@ //! Source ranges for Spring-style configuration keys. This is a syntax index, //! not a YAML evaluator: aliases, merge keys and runtime profile precedence are //! intentionally left to the YAML server / application. -use super::lines::LineIndex; +use super::lines::{LineIndex, byte_at}; use super::{ParsedTree, Syntax, TreeError}; -use ide_core::{LanguageId, Range}; +use ide_core::{LanguageId, Pos, Range}; use tree_sitter::Node; #[derive(Clone, Debug, PartialEq, Eq)] @@ -26,6 +26,40 @@ impl Syntax { } } +/// Spring's property lookups whose first argument names a configuration key. +const PROPERTY_LOOKUPS: [&str; 2] = ["getProperty", "getRequiredProperty"]; + +impl Syntax { + /// Whether `pos` in Java `text` lies where a configuration key can be written: + /// inside the arguments of a `@Value` annotation or of a `getProperty` / + /// `getRequiredProperty` call. Everywhere else Go to Definition is ordinary + /// Java navigation and need not consult the configuration index. A file the + /// grammar cannot parse answers false. + pub fn java_configuration_site(&self, text: &str, pos: Pos, cancelled: &dyn Fn() -> bool) -> bool { + let Some(at) = byte_at(text, pos) else { return false }; + self.with_parsed(&LanguageId::new("java"), text, cancelled, |tree| { + let mut node = tree.tree.root_node().descendant_for_byte_range(at, at); + while let Some(n) = node { + let Some(parent) = n.parent() else { break }; + let callee = parent.child_by_field_name("name").map(|name| &text[name.byte_range()]); + match (n.kind(), parent.kind(), callee) { + ("annotation_argument_list", "annotation", Some(name)) => { + return name.rsplit('.').next() == Some("Value"); + } + ("argument_list", "method_invocation", Some(name)) if PROPERTY_LOOKUPS.contains(&name) => { + return true; + } + // Keys are expressions; nothing above a body or declaration can hold one. + (_, "block" | "class_body" | "interface_body" | "program", _) => return false, + _ => node = Some(parent), + } + } + false + }) + .unwrap_or(false) + } +} + impl ParsedTree<'_> { pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec { let mut out = Vec::new(); @@ -188,6 +222,50 @@ fn unescape_property(raw: &str) -> Option { #[cfg(test)] mod tests { use super::*; + + #[test] + fn java_configuration_sites() { + let text = r#"package a; +import org.springframework.beans.factory.annotation.Value; +class A { + @Value("${app.port:80}") int port; + @org.springframework.beans.factory.annotation.Value(value = Keys.NAME) String name; + @Values("x") int other; + @Override public String toString() { + String v = env.getProperty("app.host", "h") + env.getRequiredProperty(Keys.USER); + return helper.getValue("😀 app.port") + String.valueOf(port) + env.getProperty("k").trim(); + } +} +"#; + let syntax = Syntax::new(); + let at = |needle: &str, offset: u32| { + let line = text.lines().position(|l| l.contains(needle)).unwrap(); + let byte = text.lines().nth(line).unwrap().find(needle).unwrap(); + let col = text.lines().nth(line).unwrap()[..byte].encode_utf16().count() as u32 + offset; + syntax.java_configuration_site(text, Pos::new(line as u32, col), &|| false) + }; + // @Value arguments (literal, qualified annotation, constant key). + assert!(at("${app.port", 2)); + assert!(at("Keys.NAME", 6)); + // Lookup arguments, including a constant and the default value. + assert!(at("\"app.host\"", 3)); + assert!(at("\"h\"", 1)); + assert!(at("Keys.USER", 1)); + assert!(at("\"k\"", 1)); + // Ordinary Java: names, other annotations, other calls, the lookup's own name and receiver. + assert!(!at("@Value(\"$", 2)); + assert!(!at("int port", 4)); + assert!(!at("@Values", 9)); + assert!(!at("@Override", 2)); + assert!(!at("getProperty(\"app", 3)); + assert!(!at("env.getProperty(\"app", 1)); + assert!(!at("app.port\")", 1)); + assert!(!at("valueOf", 2)); + assert!(!at(".trim", 2)); + // Out of range and non-Java input. + assert!(!syntax.java_configuration_site(text, Pos::new(99, 0), &|| false)); + assert!(!syntax.java_configuration_site("", Pos::new(0, 0), &|| false)); + } #[test] fn yaml_structures_and_utf16() { let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n"; diff --git a/crates/ide-language/src/syntax/lines.rs b/crates/ide-language/src/syntax/lines.rs index 5b8de206..6fd0b190 100644 --- a/crates/ide-language/src/syntax/lines.rs +++ b/crates/ide-language/src/syntax/lines.rs @@ -86,6 +86,16 @@ pub(crate) fn utf16_len(s: &str) -> u32 { if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() } } +/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line. +pub(crate) fn byte_at(text: &str, pos: Pos) -> Option { + let mut start = 0; + for _ in 0..pos.line { + start += text[start..].find('\n')? + 1; + } + let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i); + Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize)) +} + pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize { let mut units = 0; for (i, c) in s.char_indices() { diff --git a/crates/ide-language/src/syntax/web.rs b/crates/ide-language/src/syntax/web.rs index 73c9896d..88a28757 100644 --- a/crates/ide-language/src/syntax/web.rs +++ b/crates/ide-language/src/syntax/web.rs @@ -6,7 +6,7 @@ //! `