From 4f1feebbd0b012f7f807ecc386d7de43d5be88de Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:14:55 +0800 Subject: [PATCH 01/25] fix(tools): install rust-analyzer without rustup (#490) * fix(tools): install rust-analyzer without rustup * fix(tools): reinstall a managed rust-analyzer with the installer that made it Once rustup appeared, Reinstall ran rustup while the Open VSX copy stayed in use. --- crates/ide-tools/src/lib.rs | 65 ++++++++++++++++----------- crates/ide-tools/src/registry.rs | 63 +++++++++++++++++++++++++++ crates/ide-tools/src/sources.rs | 69 ++++++++++++++++++++++++++++- crates/ide-tools/src/tests.rs | 75 +++++++++++++++++++++++++++++++- 4 files changed, 243 insertions(+), 29 deletions(-) diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 66b67a8cb..317eea3ee 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -138,7 +138,7 @@ fn bin_dirs_in(root: &Path, platform: Platform) -> Vec { } fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option { - match spec.method { + match spec.fallback.unwrap_or(spec.method) { Method::Npm { .. } => Some(root.join("node_modules").join(".bin")), Method::Go { .. } | Method::Gem { .. } | Method::DotnetTool { .. } | Method::Release { .. } => { Some(root.join(spec.name).join("bin")) @@ -520,16 +520,26 @@ impl Tools { parse_version(&out) } + /// The installer that owns this tool: a managed copy is refreshed and removed by the + /// fallback that made it, even if the primary installer has appeared since. + fn install_method(&self, spec: &ToolSpec) -> Method { + let Some(fallback) = spec.fallback else { return spec.method }; + let managed = managed_bin_dir(&self.root, spec, self.platform) + .and_then(|dir| self.exe_in(&dir, spec.commands[0])) + .is_some(); + if managed || self.method_missing(spec.method).is_some() { fallback } else { spec.method } + } + /// Requirement check for installing `spec` right now. fn unavailable_reason(&self, spec: &ToolSpec) -> Option { - self.installer_missing(spec) + self.method_missing(self.install_method(spec)) .or_else(|| spec.needs.iter().find(|(cmd, _)| self.which(cmd).is_none()).map(|(_, why)| why.to_string())) } /// What the install method itself lacks (npm, a build for this platform, …). - fn installer_missing(&self, spec: &ToolSpec) -> Option { + fn method_missing(&self, method: Method) -> Option { let need = |ok: bool, msg: &str| (!ok).then(|| msg.to_string()); - match spec.method { + match method { Method::Rustup { .. } => need(self.which("rustup").is_some(), "rustup is required (https://rustup.rs)"), Method::Npm { .. } => need( self.which_any(&["npm", "pnpm"]).is_some(), @@ -602,7 +612,7 @@ impl Tools { status, path: path.map(Into::into), version, - install_method: Some(spec.method.label().to_string()), + install_method: Some(self.install_method(spec).label().to_string()), can_install: unavailable_reason.is_none(), unavailable_reason, can_uninstall, @@ -643,7 +653,16 @@ impl Tools { fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option, Option) { let main = spec.commands[0]; - match spec.method { + let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main)); + if let Some(path) = managed { + let version = match spec.method { + Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])), + _ => self.manifest(spec.name).and_then(|m| m.version), + } + .or_else(|| self.query_version(spec, &path)); + return (ToolStatus::Installed, Some(path), version); + } + match self.install_method(spec) { Method::Rustup { .. } => { let Some(path) = self.which(main) else { return (ToolStatus::Missing, None, None) }; // The rustup proxy exists even without the component; it only answers when installed. @@ -678,24 +697,13 @@ impl Tools { _ => (ToolStatus::Missing, None, None), } } - _ => { - let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main)); - if let Some(path) = managed { - let version = match spec.method { - Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])), - _ => self.manifest(spec.name).and_then(|m| m.version), - } - .or_else(|| self.query_version(spec, &path)); - return (ToolStatus::Installed, Some(path), version); + _ => match self.which(main) { + Some(path) => { + let version = self.query_version(spec, &path); + (ToolStatus::OnPath, Some(path), version) } - match self.which(main) { - Some(path) => { - let version = self.query_version(spec, &path); - (ToolStatus::OnPath, Some(path), version) - } - None => (ToolStatus::Missing, None, None), - } - } + None => (ToolStatus::Missing, None, None), + }, } } @@ -769,8 +777,9 @@ impl Tools { let emit = &fan as Emit; let result = (|| { std::fs::create_dir_all(&self.root)?; - emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, spec.method.label()) }); - match spec.method { + let method = self.install_method(spec); + emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) }); + match method { Method::Rustup { component } => { let mut c = self.command(self.which("rustup").expect("checked")); c.args(["component", "add", component]); @@ -1072,6 +1081,10 @@ impl Tools { } ReleaseSource::OpenVsx { namespace, extension } => { let json = http.get_text(&sources::openvsx_latest_url(namespace, extension), cancel)?; + let json = match sources::openvsx_platform_url(&json, assets, self.platform)? { + Some(url) => http.get_text(&url, cancel)?, + None => json, + }; sources::openvsx_pick(&json, assets, self.platform) } ReleaseSource::MavenCentral { group, artifact } => { @@ -1172,7 +1185,7 @@ impl Tools { let _guard = InstallGuard::acquire(&format!("uninstall-{name}"))?; let cancel = CancellationToken::new(); let quiet = |_: InstallEvent| {}; - match spec.method { + match self.install_method(spec) { Method::Rustup { component } => { let rustup = self.which("rustup").ok_or_else(|| IdeError::unavailable("rustup not found"))?; let mut c = self.command(rustup); diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index b571b054d..161d9dc86 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -82,6 +82,8 @@ pub struct ToolSpec { /// is a plugin bundle loaded by another tool (copied to `bin/` under this name). pub commands: &'static [&'static str], pub method: Method, + /// Used when the primary installer is unavailable. + pub fallback: Option, /// Version query: a command (looked up next to the main binary first) and its args. pub version: Option<(&'static str, &'static [&'static str])>, pub homepage: &'static str, @@ -123,6 +125,18 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["rust-analyzer"], method: Method::Rustup { component: "rust-analyzer" }, + fallback: Some(Method::Release { + source: ReleaseSource::OpenVsx { namespace: "rust-lang", extension: "rust-analyzer" }, + assets: &[ + rule(Mac, Arm64, "rust-lang.rust-analyzer-*@darwin-arm64.vsix"), + rule(Mac, X64, "rust-lang.rust-analyzer-*@darwin-x64.vsix"), + rule(Linux, Arm64, "rust-lang.rust-analyzer-*@linux-arm64.vsix"), + rule(Linux, X64, "rust-lang.rust-analyzer-*@linux-x64.vsix"), + rule(Windows, Arm64, "rust-lang.rust-analyzer-*@win32-arm64.vsix"), + rule(Windows, X64, "rust-lang.rust-analyzer-*@win32-x64.vsix"), + ], + locate: &["rust-analyzer"], + }), version: ver("rust-analyzer"), homepage: "https://rust-analyzer.github.io", needs: &[], @@ -133,6 +147,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vtsls"], method: Method::Npm { packages: &["@vtsls/language-server"] }, + fallback: None, version: V, homepage: "https://github.com/yioneko/vtsls", needs: &[], @@ -143,6 +158,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["typescript-language-server"], method: Method::Npm { packages: &["typescript-language-server", TYPESCRIPT] }, + fallback: None, version: ver("typescript-language-server"), homepage: "https://github.com/typescript-language-server/typescript-language-server", needs: &[], @@ -153,6 +169,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["pyright-langserver", "pyright"], method: Method::Npm { packages: &["pyright"] }, + fallback: None, version: ver("pyright"), homepage: "https://github.com/microsoft/pyright", needs: &[], @@ -163,6 +180,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["basedpyright-langserver", "basedpyright"], method: Method::PipVenv { package: "basedpyright" }, + fallback: None, version: ver("basedpyright"), homepage: "https://docs.basedpyright.com", needs: &[], @@ -173,6 +191,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["ty"], method: Method::PipVenv { package: "ty" }, + fallback: None, version: ver("ty"), homepage: "https://docs.astral.sh/ty/", needs: &[], @@ -183,6 +202,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["bash-language-server"], method: Method::Npm { packages: &["bash-language-server"] }, + fallback: None, version: ver("bash-language-server"), homepage: "https://github.com/bash-lsp/bash-language-server", needs: &[], @@ -193,6 +213,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["yaml-language-server"], method: Method::Npm { packages: &["yaml-language-server"] }, + fallback: None, version: V, homepage: "https://github.com/redhat-developer/yaml-language-server", needs: &[], @@ -203,6 +224,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vscode-json-language-server"], method: Method::Npm { packages: &["vscode-langservers-extracted"] }, + fallback: None, version: V, homepage: "https://github.com/hrsh7th/vscode-langservers-extracted", needs: &[], @@ -213,6 +235,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vscode-css-language-server"], method: Method::Npm { packages: &["vscode-langservers-extracted"] }, + fallback: None, version: V, homepage: "https://github.com/hrsh7th/vscode-langservers-extracted", needs: &[], @@ -223,6 +246,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vscode-html-language-server"], method: Method::Npm { packages: &["vscode-langservers-extracted"] }, + fallback: None, version: V, homepage: "https://github.com/hrsh7th/vscode-langservers-extracted", needs: &[], @@ -233,6 +257,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["docker-langserver"], method: Method::Npm { packages: &["dockerfile-language-server-nodejs"] }, + fallback: None, version: V, homepage: "https://github.com/rcjsuen/dockerfile-language-server", needs: &[], @@ -243,6 +268,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["svelteserver"], method: Method::Npm { packages: &["svelte-language-server", TYPESCRIPT] }, + fallback: None, version: V, homepage: "https://github.com/sveltejs/language-tools", needs: &[], @@ -253,6 +279,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vue-language-server"], method: Method::Npm { packages: &["@vue/language-server", TYPESCRIPT] }, + fallback: None, version: V, homepage: "https://github.com/vuejs/language-tools", needs: &[], @@ -264,6 +291,7 @@ pub static TOOLS: &[ToolSpec] = &[ commands: &["ngserver"], // Brings the TypeScript and `@angular/language-service` it loads. method: Method::Npm { packages: &["@angular/language-server"] }, + fallback: None, version: V, homepage: "https://angular.dev/tools/language-service", needs: &[], @@ -274,6 +302,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["graphql-lsp"], method: Method::Npm { packages: &["graphql-language-service-cli"] }, + fallback: None, version: V, homepage: "https://github.com/graphql/graphiql", needs: &[], @@ -284,6 +313,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["intelephense"], method: Method::Npm { packages: &["intelephense"] }, + fallback: None, version: V, homepage: "https://intelephense.com", needs: &[], @@ -294,6 +324,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["gopls"], method: Method::Go { module: "golang.org/x/tools/gopls@latest" }, + fallback: None, version: Some(("gopls", &["version"])), homepage: "https://pkg.go.dev/golang.org/x/tools/gopls", needs: &[], @@ -304,6 +335,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["sqls"], method: Method::Go { module: "github.com/sqls-server/sqls@latest" }, + fallback: None, version: Some(("sqls", &["--version"])), homepage: "https://github.com/sqls-server/sqls", needs: &[], @@ -314,6 +346,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: Debugger, commands: &["dlv"], method: Method::Go { module: "github.com/go-delve/delve/cmd/dlv@latest" }, + fallback: None, version: Some(("dlv", &["version"])), homepage: "https://github.com/go-delve/delve", needs: &[], @@ -324,6 +357,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: Debugger, commands: &["python3"], method: Method::PipTarget { package: "debugpy", module: "debugpy" }, + fallback: None, version: V, homepage: "https://github.com/microsoft/debugpy", needs: &[], @@ -342,6 +376,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["clangd"], }, + fallback: None, version: ver("clangd"), homepage: "https://clangd.llvm.org", needs: &[], @@ -362,6 +397,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["lua-language-server"], }, + fallback: None, version: ver("lua-language-server"), homepage: "https://luals.github.io", needs: &[], @@ -381,6 +417,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["marksman"], }, + fallback: None, version: ver("marksman"), homepage: "https://github.com/artempyanykh/marksman", needs: &[], @@ -401,6 +438,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["taplo"], }, + fallback: None, version: ver("taplo"), homepage: "https://taplo.tamasfe.dev", needs: &[], @@ -421,6 +459,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["zls"], }, + fallback: None, version: ver("zls"), homepage: "https://github.com/zigtools/zls", needs: &[], @@ -441,6 +480,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["terraform-ls"], }, + fallback: None, version: Some(("terraform-ls", &["version"])), homepage: "https://github.com/hashicorp/terraform-ls", needs: &[], @@ -466,6 +506,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["codelldb"], }, + fallback: None, version: V, homepage: "https://github.com/vadimcn/codelldb", needs: &[], @@ -485,6 +526,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["dapDebugServer.js"], }, + fallback: None, version: V, homepage: "https://github.com/microsoft/vscode-js-debug", needs: &[], @@ -503,6 +545,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["jdtls", "jdtls.bat"], }, + fallback: None, version: V, homepage: "https://github.com/eclipse-jdtls/eclipse.jdt.ls", needs: &[], @@ -522,6 +565,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["com.microsoft.java.debug.plugin-*.jar"], }, + fallback: None, version: V, homepage: "https://github.com/microsoft/java-debug", needs: &[], @@ -541,6 +585,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["lombok.jar"], }, + fallback: None, version: V, homepage: "https://projectlombok.org", needs: &[], @@ -565,6 +610,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["kotlin-lsp", "kotlin-lsp.sh", "kotlin-lsp.cmd"], }, + fallback: None, version: V, homepage: "https://github.com/Kotlin/kotlin-lsp", needs: &[], @@ -581,6 +627,7 @@ pub static TOOLS: &[ToolSpec] = &[ assets: ELIXIR_LS_ASSETS, locate: &["language_server.sh", "language_server.bat"], }, + fallback: None, version: V, homepage: "https://github.com/elixir-lsp/elixir-ls", needs: ELIXIR, @@ -595,6 +642,7 @@ pub static TOOLS: &[ToolSpec] = &[ assets: ELIXIR_LS_ASSETS, locate: &["debug_adapter.sh", "debug_adapter.bat"], }, + fallback: None, version: V, homepage: "https://github.com/elixir-lsp/elixir-ls", needs: ELIXIR, @@ -616,6 +664,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["buf"], }, + fallback: None, version: ver("buf"), homepage: "https://buf.build", needs: &[], @@ -636,6 +685,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["clojure-lsp"], }, + fallback: None, version: V, homepage: "https://clojure-lsp.io", needs: &[], @@ -657,6 +707,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["texlab"], }, + fallback: None, version: ver("texlab"), homepage: "https://github.com/latex-lsp/texlab", needs: &[], @@ -677,6 +728,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["neocmakelsp"], }, + fallback: None, version: ver("neocmakelsp"), homepage: "https://github.com/Decodetalkers/neocmakelsp", needs: &[], @@ -697,6 +749,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["lemminx-*"], }, + fallback: None, version: V, homepage: "https://github.com/eclipse/lemminx", needs: &[], @@ -715,6 +768,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["perlnavigator"], }, + fallback: None, version: V, homepage: "https://github.com/bscan/PerlNavigator", needs: &[("perl", "Perl is required (https://www.perl.org/get.html)")], @@ -729,6 +783,7 @@ pub static TOOLS: &[ToolSpec] = &[ assets: &[any_arch(Mac, "server.zip"), any_arch(Linux, "server.zip"), any_arch(Windows, "server.zip")], locate: &["kotlin-language-server"], }, + fallback: None, version: V, homepage: "https://github.com/fwcd/kotlin-language-server", needs: JAVA, @@ -743,6 +798,7 @@ pub static TOOLS: &[ToolSpec] = &[ assets: &[any_arch(Mac, "adapter.zip"), any_arch(Linux, "adapter.zip"), any_arch(Windows, "adapter.zip")], locate: &["kotlin-debug-adapter"], }, + fallback: None, version: V, homepage: "https://github.com/fwcd/kotlin-debug-adapter", needs: JAVA, @@ -762,6 +818,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["netcoredbg"], }, + fallback: None, version: V, homepage: "https://github.com/Samsung/netcoredbg", needs: &[], @@ -780,6 +837,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["bashDebug.js"], }, + fallback: None, version: V, homepage: "https://github.com/rogalmic/vscode-bash-debug", needs: NODE, @@ -798,6 +856,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["phpDebug.js"], }, + fallback: None, version: V, homepage: "https://github.com/xdebug/vscode-php-debug", needs: NODE, @@ -808,6 +867,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["ruby-lsp"], method: Method::Gem { gem: "ruby-lsp" }, + fallback: None, version: V, homepage: "https://shopify.github.io/ruby-lsp", needs: &[], @@ -818,6 +878,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["solargraph"], method: Method::Gem { gem: "solargraph" }, + fallback: None, version: ver("solargraph"), homepage: "https://solargraph.org", needs: &[], @@ -828,6 +889,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["csharp-ls"], method: Method::DotnetTool { package: "csharp-ls" }, + fallback: None, version: V, homepage: "https://github.com/razzmatazz/csharp-language-server", needs: &[], @@ -838,6 +900,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["fsautocomplete"], method: Method::DotnetTool { package: "fsautocomplete" }, + fallback: None, version: V, homepage: "https://github.com/ionide/FsAutoComplete", needs: &[], diff --git a/crates/ide-tools/src/sources.rs b/crates/ide-tools/src/sources.rs index aa10a9852..09ec12a4b 100644 --- a/crates/ide-tools/src/sources.rs +++ b/crates/ide-tools/src/sources.rs @@ -177,6 +177,8 @@ pub fn jdtls_download(version: &str, latest_txt: &str) -> IdeResult { #[derive(Deserialize)] struct VsxExtension { version: String, + #[serde(default)] + downloads: std::collections::BTreeMap, files: VsxFiles, } @@ -191,7 +193,22 @@ pub fn openvsx_latest_url(namespace: &str, extension: &str) -> String { format!("https://open-vsx.org/api/{namespace}/{extension}/latest") } -/// The `.vsix` of an Open VSX `…/latest` response (platform-independent extensions only). +/// Select platform metadata when the universal package does not match the asset rules. +pub fn openvsx_platform_url(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult> { + let ext: VsxExtension = + serde_json::from_str(json).map_err(|e| proto(format!("unexpected Open VSX response: {e}")))?; + if select_asset(&[file_name(&ext.files.download)], rules, platform).is_some() { + return Ok(None); + } + let names: Vec<&str> = ext.downloads.values().map(|url| file_name(url)).collect(); + let name = select_asset(&names, rules, platform) + .ok_or_else(|| IdeError::unavailable("No Open VSX package for this platform"))?; + let url = ext.downloads.values().find(|url| file_name(url) == name).expect("selected download"); + let (metadata, _) = url.rsplit_once("/file/").ok_or_else(|| proto("unexpected Open VSX download URL"))?; + Ok(Some(metadata.to_string())) +} + +/// The `.vsix` of an Open VSX `…/latest` response. pub fn openvsx_pick(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult { let ext: VsxExtension = serde_json::from_str(json).map_err(|e| proto(format!("unexpected Open VSX response: {e}")))?; @@ -355,6 +372,56 @@ mod tests { assert!(openvsx_pick(&other, rules, LINUX).is_err()); } + #[test] + fn openvsx_rust_analyzer_platform_packages() { + use crate::platform::{Arch, Os}; + let crate::registry::Method::Release { assets, .. } = find("rust-analyzer").unwrap().fallback.unwrap() else { + panic!("expected release fallback") + }; + let targets = [ + (Os::Mac, Arch::Arm64, "darwin-arm64"), + (Os::Mac, Arch::X64, "darwin-x64"), + (Os::Linux, Arch::Arm64, "linux-arm64"), + (Os::Linux, Arch::X64, "linux-x64"), + (Os::Windows, Arch::Arm64, "win32-arm64"), + (Os::Windows, Arch::X64, "win32-x64"), + ]; + let base = "https://open-vsx.org/api/rust-lang/rust-analyzer"; + let downloads: serde_json::Map = targets + .iter() + .map(|(_, _, target)| { + ( + target.to_string(), + format!("{base}/{target}/1.0.0/file/rust-lang.rust-analyzer-1.0.0@{target}.vsix").into(), + ) + }) + .collect(); + let universal = serde_json::json!({ + "version": "1.0.0", "downloads": downloads, + "files": {"download": format!("{base}/1.0.0/file/rust-lang.rust-analyzer-1.0.0.vsix")} + }) + .to_string(); + for (os, arch, target) in targets { + let platform = Platform { os, arch }; + assert_eq!( + openvsx_platform_url(&universal, assets, platform).unwrap(), + Some(format!("{base}/{target}/1.0.0")) + ); + let json = serde_json::json!({ + "version": "1.0.0", + "files": { + "download": downloads[target], + "sha256": format!("{base}/{target}/1.0.0/file/rust-lang.rust-analyzer-1.0.0@{target}.sha256") + } + }) + .to_string(); + let download = openvsx_pick(&json, assets, platform).unwrap(); + assert!(download.name.ends_with(&format!("@{target}.vsix"))); + assert!(download.checksum_url.unwrap().ends_with(&format!("@{target}.sha256"))); + } + assert!(openvsx_platform_url(&universal.replace("downloads", "unused"), assets, MAC_ARM).is_err()); + } + #[test] fn maven_central() { let xml = diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index b5cba6656..e6aa29c27 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -65,8 +65,8 @@ fn everything_missing_on_an_empty_system() { let ra = find(&list, "rust-analyzer"); assert_eq!(ra.status, ToolStatus::Missing); assert_eq!(ra.languages, vec!["Rust".to_string()]); - assert!(!ra.can_install, "rustup is not on the fake PATH"); - assert!(ra.unavailable_reason.as_deref().unwrap().contains("rustup")); + assert!(!ra.can_install, "curl is not on the fake PATH"); + assert!(ra.unavailable_reason.as_deref().unwrap().contains("curl")); let vtsls = find(&list, "vtsls"); assert!(vtsls.languages.iter().any(|l| l == "TypeScript")); assert!(vtsls.unavailable_reason.as_deref().unwrap().contains("npm")); @@ -552,3 +552,74 @@ fn the_installing_request_can_still_cancel_its_shared_install() { assert!(slot.cancel.is_cancelled()); release_install_slot("taplo", &slot); } + +#[cfg(unix)] +#[test] +fn rust_analyzer_selects_openvsx_without_rustup() { + let env = Env::new(); + env.script(&env.sys, "curl", "exit 0"); + let tools = env.tools(); + let spec = registry::find("rust-analyzer").unwrap(); + assert!(matches!( + tools.install_method(spec), + Method::Release { source: ReleaseSource::OpenVsx { namespace: "rust-lang", extension: "rust-analyzer" }, .. } + )); + let info = tools.info("rust-analyzer", &langs()).unwrap(); + assert_eq!(info.status, ToolStatus::Missing); + assert_eq!(info.install_method.as_deref(), Some("Open VSX")); + assert!(info.can_install); + assert!(info.unavailable_reason.is_none()); + assert!(bin_dirs_in(&env.root, Platform::current()).contains(&env.root.join("rust-analyzer/bin"))); +} + +#[cfg(unix)] +static RUST_ANALYZER_INSTALL: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + +#[cfg(unix)] +#[test] +fn rust_analyzer_prefers_rustup_and_installs_component() { + let _guard = RUST_ANALYZER_INSTALL.lock(); + let env = Env::new(); + env.script(&env.sys, "rustup", "test \"$*\" = 'component add rust-analyzer'"); + env.script(&env.sys, "rust-analyzer", "echo 'rust-analyzer 1.0.0'"); + let tools = env.tools(); + assert!(matches!( + tools.install_method(registry::find("rust-analyzer").unwrap()), + Method::Rustup { component: "rust-analyzer" } + )); + let info = tools.info("rust-analyzer", &langs()).unwrap(); + assert_eq!(info.install_method.as_deref(), Some("rustup")); + assert!(info.can_install, "rustup does not require curl"); + let mut lines = Vec::new(); + let emit = parking_lot::Mutex::new(&mut lines); + tools + .install("rust-analyzer", &langs(), &CancellationToken::new(), &|event| { + if let InstallEvent::Line { text } = event { + emit.lock().push(text); + } + }) + .unwrap(); + assert!(lines.iter().any(|line| line.contains("via rustup"))); +} + +#[cfg(unix)] +#[test] +fn rust_analyzer_managed_copy_survives_rustup_becoming_available() { + let _guard = RUST_ANALYZER_INSTALL.lock(); + let env = Env::new(); + let bin = env.root.join("rust-analyzer/bin"); + env.script(&bin, "rust-analyzer", "echo 'rust-analyzer 1.0.0'"); + for with_rustup in [false, true] { + if with_rustup { + env.script(&env.sys, "rustup", "exit 1"); + env.script(&env.sys, "rust-analyzer", "exit 1"); + } + let info = env.tools().info("rust-analyzer", &langs()).unwrap(); + assert_eq!(info.status, ToolStatus::Installed); + assert_eq!(info.path.as_deref(), Some(bin.join("rust-analyzer").as_path())); + assert_eq!(info.install_method.as_deref(), Some("Open VSX"), "Reinstall refreshes the copy in use"); + assert!(info.can_uninstall); + } + env.tools().uninstall("rust-analyzer", &langs()).unwrap(); + assert!(!env.root.join("rust-analyzer").exists()); +} From 504d8592d243df533081d94b24c35ed64a9e6a4b Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sat, 31 Oct 2026 10:15:04 +0800 Subject: [PATCH 02/25] fix(tools): include the Windows user PATH agents and tools are resolved on (#499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tools): include the Windows user PATH agents and tools are resolved on On Windows the login-shell environment is never read (it is a Unix-only path), so a process started before the user PATH was edited — or one that simply does not inherit it — resolves agent executables and language-server tools on the machine PATH alone. A tool installed for the user (npm -g, \dotnet tool install -g\, a vendor installer) is then reported as not on PATH. Read the user's own PATH from HKCU\\Environment (expanding REG_EXPAND_SZ entries) and append its directories after the inherited ones in both the tool search path and the native agent lookup, so user installs resolve without shadowing the inherited order. Fixes #330. * fix(tools): use div_ceil for the registry buffer size * fix(tools): add the Windows user PATH to the process PATH at startup Lookups alone left agents reported as not installed, and programs started from user PATH entries could not find their own dependencies. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/ide-agent/src/native/mod.rs | 3 +- crates/ide-core/Cargo.toml | 2 +- crates/ide-core/src/process.rs | 100 +++++++++++++++++++++++++++++ crates/ide-core/src/tool_path.rs | 36 ++++++++++- crates/ide-tools/src/lib.rs | 8 ++- 5 files changed, 141 insertions(+), 8 deletions(-) diff --git a/crates/ide-agent/src/native/mod.rs b/crates/ide-agent/src/native/mod.rs index 665e9a049..d7e34e77c 100644 --- a/crates/ide-agent/src/native/mod.rs +++ b/crates/ide-agent/src/native/mod.rs @@ -27,7 +27,8 @@ pub(crate) fn newest_install(command: &str) -> String { let path = ide_core::process::host_var_os("PATH") .or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned())) .unwrap_or_default(); - newest_install_in(command, &path) + // Windows: read afresh, so a tool the user installed since the IDE started is found too. + newest_install_in(command, &ide_core::process::with_user_path(&path)) } fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String { diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index af0d6b992..a2a26d945 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"] } +windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] } diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index f06ea74ff..85ee20f5d 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -237,10 +237,110 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str())) } +/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the +/// merged `PATH` a process may not inherit — an IDE started before the variable was +/// edited, or as a different user, sees only the machine entries. `%VAR%` references +/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere: +/// there a login shell already supplies the user's whole `PATH`. +#[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, + }; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + unsafe { + let mut key: HKEY = std::ptr::null_mut(); + 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(); + RegCloseKey(key); + value.filter(|v| !v.trim().is_empty()).map(OsString::from) + } +} + +/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl). +#[cfg(not(windows))] +pub fn user_path() -> Option { + None +} + +/// `path` followed by the [`user_path`] entries it lacks. +pub fn with_user_path(path: &OsStr) -> OsString { + user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user)) +} + +fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString { + let mut dirs: Vec = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect(); + for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) { + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned()) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + fn user_path_reads_the_current_users_path() { + // This host has a user `Path` only when the test account set one; either way the call + // must not fail, and a present value is a non-empty `;`-joined list of directories. + if let Some(path) = user_path() { + let dirs: Vec<_> = std::env::split_paths(&path).collect(); + assert!(!dirs.is_empty(), "user PATH should hold at least one directory"); + } + } + + #[test] + fn append_path_entries_keeps_the_order_and_adds_what_is_missing() { + let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap(); + assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"])); + assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"])); + } + #[test] fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() { set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]); diff --git a/crates/ide-core/src/tool_path.rs b/crates/ide-core/src/tool_path.rs index 51dc4ee4d..266f5bae0 100644 --- a/crates/ide-core/src/tool_path.rs +++ b/crates/ide-core/src/tool_path.rs @@ -17,7 +17,16 @@ pub fn search_path() -> OsString { let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from); let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone())); let shims: Vec = version_managers::managers().iter().map(|m| m.shims()).collect(); - let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_path.as_deref(), home.as_deref(), &shims); + // The Windows user `PATH` may not reach a process started before it was set; append its + // entries after the inherited ones so missing user tools still resolve. + let user_path = crate::process::user_path(); + let dirs = search_dirs( + host_var_os("PATH").as_deref(), + shell_path.as_deref(), + user_path.as_deref(), + home.as_deref(), + &shims, + ); std::env::join_paths(dirs).unwrap_or_default() } @@ -56,10 +65,12 @@ fn is_executable(path: &Path) -> bool { fn search_dirs( process_path: Option<&OsStr>, shell_path: Option<&OsStr>, + user_path: Option<&OsStr>, home: Option<&Path>, shims: &[PathBuf], ) -> Vec { - let mut dirs: Vec = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect(); + let mut dirs: Vec = + process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect(); if let Some(home) = home { dirs.extend(USER_DIRS.iter().map(|d| home.join(d))); dirs.extend(nvm_bins(&home.join(".nvm/versions/node"))); @@ -172,6 +183,7 @@ mod tests { Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")), // nushell prints `$PATH` as is. Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")), + None, Some(Path::new("/home/me")), &shims, ); @@ -185,10 +197,28 @@ mod tests { let unique: HashSet<&PathBuf> = dirs.iter().collect(); assert_eq!(unique.len(), dirs.len()); // A `:` can't be in a `PATH` entry: those below such a home are left out. - let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]); + let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]); assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home"))); } + #[test] + fn search_dirs_append_user_path_entries_once_after_the_inherited_path() { + let (system, dup, usertool) = + if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") }; + let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" }); + let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap(); + let inherited = join(&[system, dup]); + let user = join(&[dup, usertool]); + let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]); + let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap(); + // User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks. + assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1); + assert!(at(dup) < at(usertool)); + // And before this platform's home/system fallbacks. + let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap(); + assert!(at(usertool) < fallback); + } + #[test] fn nvm_versions_newest_first() { let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id())); diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 317eea3ee..d5cd36c0d 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -151,11 +151,13 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option

Date: Sat, 31 Oct 2026 10:40:24 +0800 Subject: [PATCH 03/25] feat(project-tree): enrich JVM project view icons (#512) Project rows only showed generic folder and filename icons, so JVM modules, packages and declarations looked alike. Rows now show Maven and Gradle project and module icons, packages, Spring configuration and Boot application classes, runnable classes, test classes, Java and Kotlin declaration types, and generated, compiled and javadoc output folders. Editor tabs, Goto File and Recent Files use the same resolver. Types come from a bounded syntax parse of visible files, served locally and over remote RPC. Spring stereotypes keep their ordinary type icons, and .class files stay binary. Fixes #347 --- THIRD_PARTY_LICENSES.md | 5 +- crates/ide-host/src/ops/jvm_icons.rs | 102 ++++++++++ crates/ide-host/src/ops/mod.rs | 1 + crates/ide-host/src/rpc.rs | 3 + crates/ide-host/tests/rpc.rs | 16 ++ crates/ide-syntax/src/jvm.rs | 189 ++++++++++++++++++ crates/ide-syntax/src/lib.rs | 2 + crates/ide-syntax/src/tests/jvm.rs | 78 ++++++++ crates/ide-syntax/src/tests/mod.rs | 1 + fixtures/ts-app/.openidea/.gitignore | 3 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/syntax.rs | 10 + src/bindings.ts | 14 ++ src/editor/core/view/EditorArea.tsx | 11 +- .../project-tree/ProjectView.module.css | 13 ++ src/features/project-tree/ProjectView.tsx | 46 ++++- src/features/project-tree/folderKind.test.ts | 61 ++++++ src/features/project-tree/folderKind.ts | 38 +++- .../search/everywhere/SearchEverywhere.tsx | 5 +- src/features/search/recent/RecentPopups.tsx | 4 +- src/platform/filePresentation.cache.test.tsx | 101 ++++++++++ src/platform/filePresentation.test.ts | 53 +++++ src/platform/filePresentation.ts | 189 ++++++++++++++++++ src/platform/projectPresentation.test.ts | 63 ++++++ src/platform/projectPresentation.ts | 83 ++++++++ src/ui/expui/gradle/gradle.svg | 5 + src/ui/expui/gradle/gradle@20x20.svg | 5 + src/ui/expui/gradle/gradle@20x20_dark.svg | 5 + src/ui/expui/gradle/gradleSubproject.svg | 6 + src/ui/expui/gradle/gradleSubproject_dark.svg | 6 + src/ui/expui/gradle/gradle_dark.svg | 5 + src/ui/expui/kotlin/objectKotlin.svg | 8 + src/ui/expui/kotlin/objectKotlin_dark.svg | 8 + src/ui/expui/nodes/classAbstract.svg | 6 + src/ui/expui/nodes/classAbstract_dark.svg | 6 + src/ui/expui/nodes/compiledClassesFolder.svg | 6 + .../nodes/compiledClassesFolder_dark.svg | 6 + src/ui/expui/nodes/exception.svg | 5 + src/ui/expui/nodes/exception_dark.svg | 5 + src/ui/expui/nodes/generatedSource.svg | 12 ++ src/ui/expui/nodes/generatedSource_dark.svg | 12 ++ src/ui/expui/nodes/generatedTestRoot.svg | 12 ++ src/ui/expui/nodes/generatedTestRoot_dark.svg | 12 ++ src/ui/expui/nodes/javaDocFolder.svg | 6 + src/ui/expui/nodes/javaDocFolder_dark.svg | 6 + src/ui/expui/nodes/runnableMark.svg | 12 ++ src/ui/expui/nodes/runnableMark_dark.svg | 12 ++ src/ui/expui/nodes/test.svg | 5 + src/ui/expui/nodes/test_dark.svg | 5 + src/ui/icons.tsx | 31 ++- 50 files changed, 1279 insertions(+), 20 deletions(-) create mode 100644 crates/ide-host/src/ops/jvm_icons.rs create mode 100644 crates/ide-syntax/src/jvm.rs create mode 100644 crates/ide-syntax/src/tests/jvm.rs create mode 100644 fixtures/ts-app/.openidea/.gitignore create mode 100644 src/features/project-tree/folderKind.test.ts create mode 100644 src/platform/filePresentation.cache.test.tsx create mode 100644 src/platform/filePresentation.test.ts create mode 100644 src/platform/filePresentation.ts create mode 100644 src/platform/projectPresentation.test.ts create mode 100644 src/platform/projectPresentation.ts create mode 100644 src/ui/expui/gradle/gradle.svg create mode 100644 src/ui/expui/gradle/gradle@20x20.svg create mode 100644 src/ui/expui/gradle/gradle@20x20_dark.svg create mode 100644 src/ui/expui/gradle/gradleSubproject.svg create mode 100644 src/ui/expui/gradle/gradleSubproject_dark.svg create mode 100644 src/ui/expui/gradle/gradle_dark.svg create mode 100644 src/ui/expui/kotlin/objectKotlin.svg create mode 100644 src/ui/expui/kotlin/objectKotlin_dark.svg create mode 100644 src/ui/expui/nodes/classAbstract.svg create mode 100644 src/ui/expui/nodes/classAbstract_dark.svg create mode 100644 src/ui/expui/nodes/compiledClassesFolder.svg create mode 100644 src/ui/expui/nodes/compiledClassesFolder_dark.svg create mode 100644 src/ui/expui/nodes/exception.svg create mode 100644 src/ui/expui/nodes/exception_dark.svg create mode 100644 src/ui/expui/nodes/generatedSource.svg create mode 100644 src/ui/expui/nodes/generatedSource_dark.svg create mode 100644 src/ui/expui/nodes/generatedTestRoot.svg create mode 100644 src/ui/expui/nodes/generatedTestRoot_dark.svg create mode 100644 src/ui/expui/nodes/javaDocFolder.svg create mode 100644 src/ui/expui/nodes/javaDocFolder_dark.svg create mode 100644 src/ui/expui/nodes/runnableMark.svg create mode 100644 src/ui/expui/nodes/runnableMark_dark.svg create mode 100644 src/ui/expui/nodes/test.svg create mode 100644 src/ui/expui/nodes/test_dark.svg diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 0c7118219..6903ad3de 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -12,12 +12,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor `python/python-requirements` (`fileTypes/requirements`); `python/pipenv` (`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` → `fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`, - `kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`); + `kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`); `plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`); `plugins/terminal` (`toolwindows/terminal`); `plugins/maven` (`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*` → `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`, - `icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`); + `icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`, + `icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`); `platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`). Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below). - [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin diff --git a/crates/ide-host/src/ops/jvm_icons.rs b/crates/ide-host/src/ops/jvm_icons.rs new file mode 100644 index 000000000..a9d24773a --- /dev/null +++ b/crates/ide-host/src/ops/jvm_icons.rs @@ -0,0 +1,102 @@ +//! Bounded, read-only JVM presentation for visible files, also served remotely. +use crate::Host; +use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId}; +use ide_syntax::{JvmPresentation, Snapshot}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::{ + collections::HashMap, + fs::File, + io::Read, + path::PathBuf, + sync::{Mutex, OnceLock}, + time::SystemTime, +}; + +pub const PREFIX_BYTES: u64 = 64 * 1024; +const MAX_BATCH: usize = 64; +const MAX_CACHE: usize = 4096; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmFilePresentation { + pub path: PathBuf, + pub presentation: JvmPresentation, +} + +#[derive(Clone)] +struct Cached { + modified: SystemTime, + size: u64, + presentation: JvmPresentation, +} + +fn prefix(path: &PathBuf) -> std::io::Result { + let mut bytes = Vec::new(); + File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?; + // A truncated final UTF-8 character must not invalidate the entire prefix. + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +pub async fn jvm_file_presentations( + host: &Host, + window: &str, + paths: Vec, +) -> IdeResult> { + if paths.len() > MAX_BATCH { + return Err(IdeError::invalid("at most 64 JVM files per batch")); + } + let project = host.project(window)?; + host.blocking(move || { + static CACHE: OnceLock>> = OnceLock::new(); + let cache = CACHE.get_or_init(Default::default); + let mut results = Vec::new(); + for path in paths { + let language = match path.extension().and_then(|e| e.to_str()) { + Some("java") => LanguageId::new("java"), + Some("kt") => LanguageId::new("kotlin"), + _ => continue, + }; + let Ok(meta) = path.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { continue }; + let hit = + cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned(); + let presentation = if let Some(hit) = hit { + hit.presentation + } else { + let Ok(source) = prefix(&path) else { continue }; + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); + let presentation = project.structure.jvm_presentation( + &Snapshot::text(&language, &source), + stem, + &CancellationToken::new(), + )?; + let mut cache = cache.lock().unwrap(); + if cache.len() >= MAX_CACHE { + cache.clear(); + } + cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() }); + presentation + }; + results.push(JvmFilePresentation { path, presentation }); + } + Ok(results) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn huge_files_read_only_a_prefix() { + let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id())); + let file = File::create(&path).unwrap(); + file.set_len(100 * 1024 * 1024).unwrap(); + assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 4baf33cac..854b0cfc5 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -13,6 +13,7 @@ pub mod env; pub mod fs; pub mod history; pub mod java; +pub mod jvm_icons; pub mod lsp; pub mod nav; pub mod notebook; diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 56055d2d8..14f15e807 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -148,6 +148,9 @@ impl Dispatcher { c.host.close_project(&c.window); Ok(()) }) + .add("jvm_file_presentations", |c| async move { + ops::jvm_icons::jvm_file_presentations(&c.host, &c.window, c.arg("paths")?).await + }) .add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) }) .add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) }) .add("settings_set", |c| async move { diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 3e195abfc..8a5947aa1 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -81,6 +81,22 @@ async fn serves_projects_files_documents_search_and_runs() { let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap(); assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}"); + // JVM icons use the same read-only protocol as local windows. + let java = project.join("src/Foo.java"); + let kotlin = project.join("src/Singleton.kt"); + std::fs::write(&java, "abstract class Foo {}").unwrap(); + std::fs::write(&kotlin, "object Singleton {}").unwrap(); + let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java, kotlin] })).await.unwrap(); + assert_eq!(icons[0]["presentation"]["kind"], "abstractClass"); + assert_eq!(icons[1]["presentation"]["kind"], "object"); + // Replacing a file invalidates its cached metadata, including same-size writes. + std::fs::write(&java, "interface Foo {} ").unwrap(); + std::fs::File::open(&java).unwrap().set_modified(std::time::SystemTime::UNIX_EPOCH).unwrap(); + let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java] })).await.unwrap(); + assert_eq!(icons[0]["presentation"]["kind"], "interface"); + let oversized = invoke(&peer, "jvm_file_presentations", json!({ "paths": vec![java.clone(); 65] })).await; + assert!(oversized.is_err()); + // Edit and save a document. let file = project.join("src/main.py"); let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap(); diff --git a/crates/ide-syntax/src/jvm.rs b/crates/ide-syntax/src/jvm.rs new file mode 100644 index 000000000..9da0f7b46 --- /dev/null +++ b/crates/ide-syntax/src/jvm.rs @@ -0,0 +1,189 @@ +//! Syntactic presentation of the primary top-level JVM declaration. +use crate::{Snapshot, StructureResult, StructureService}; +use ide_core::CancellationToken; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tree_sitter::Node; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum JvmTypeKind { + Class, + AbstractClass, + Interface, + Enum, + Record, + Annotation, + Exception, + Object, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmPresentation { + pub kind: Option, + pub spring_boot: bool, + pub runnable: bool, +} + +fn text<'a>(node: Node, source: &'a str) -> &'a str { + source.get(node.byte_range()).unwrap_or_default() +} + +fn children(node: Node) -> Vec { + node.children(&mut node.walk()).collect() +} + +fn descendant(node: Node<'_>, predicate: &impl Fn(Node<'_>) -> bool) -> bool { + predicate(node) || node.children(&mut node.walk()).any(|n| descendant(n, predicate)) +} + +fn keyword(node: Node, source: &str, word: &str) -> bool { + descendant(node, &|n| { + n.child_count() == 0 && (n.kind() == word || n.kind().ends_with("_modifier") && text(n, source) == word) + }) +} + +fn annotation(node: Node, source: &str, name: &str) -> bool { + node.named_children(&mut node.walk()).any(|n| { + if !matches!(n.kind(), "annotation" | "marker_annotation") { + return false; + } + if let Some(id) = n.child_by_field_name("name") { + return text(id, source).rsplit('.').next() == Some(name); + } + // Kotlin: @ -> constructor_invocation -> user_type (or type -> user_type). + let mut current = n; + while let Some(first) = current.named_children(&mut current.walk()).next() { + if matches!(first.kind(), "use_site_target") { + return false; + } + if first.kind() == "user_type" { + let ids: Vec<_> = + first.named_children(&mut first.walk()).filter(|c| c.kind() == "identifier").collect(); + return ids.last().is_some_and(|id| text(*id, source) == name); + } + current = first; + } + false + }) +} + +fn exception_base(node: Node, source: &str) -> bool { + if matches!(node.kind(), "type_arguments" | "value_arguments" | "explicit_delegation") { + return false; + } + if matches!(node.kind(), "type_identifier" | "user_type" | "scoped_type_identifier") { + let name = if node.kind() == "user_type" || node.kind() == "scoped_type_identifier" { + node.named_children(&mut node.walk()) + .filter(|n| matches!(n.kind(), "identifier" | "type_identifier")) + .last() + .map(|n| text(n, source)) + .unwrap_or_default() + } else { + text(node, source) + }; + return name.ends_with("Exception") || name.ends_with("Error") || name == "Throwable"; + } + node.named_children(&mut node.walk()).any(|n| exception_base(n, source)) +} + +fn members(node: Node<'_>) -> Vec> { + let mut out = Vec::new(); + for child in node.named_children(&mut node.walk()) { + if matches!(child.kind(), "method_declaration" | "function_declaration") { + out.push(child); + } + if matches!(child.kind(), "companion_object" | "class_body" | "enum_body_declarations") { + out.extend(members(child)); + } + } + out +} + +fn is_type(node: Node) -> bool { + matches!( + node.kind(), + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "record_declaration" + | "annotation_type_declaration" + | "object_declaration" + ) +} + +impl StructureService { + /// Only top-level declarations participate; the filename-matching declaration + /// wins, otherwise the first type does. Strings/comments never supply evidence. + pub fn jvm_presentation( + &self, + snap: &Snapshot, + stem: &str, + cancel: &CancellationToken, + ) -> StructureResult { + self.run(snap, cancel, |cx| { + if !matches!(cx.grammar, "java" | "kotlin") { + return Ok(JvmPresentation::default()); + } + let top = cx.tree.root_node(); + let types: Vec<_> = top.named_children(&mut top.walk()).filter(|n| is_type(*n)).collect(); + let Some(node) = types + .iter() + .find(|n| n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == stem)) + .or(types.first()) + .copied() + else { + return Ok(JvmPresentation::default()); + }; + let parts = children(node); + let modifiers = parts.iter().find(|n| n.kind() == "modifiers").copied(); + let has = |word| modifiers.is_some_and(|m| keyword(m, snap.text, word)); + let supertype = parts.iter().find(|n| matches!(n.kind(), "superclass" | "delegation_specifiers")); + let exception = supertype.is_some_and(|s| exception_base(*s, snap.text)); + let kind = match node.kind() { + "annotation_type_declaration" => JvmTypeKind::Annotation, + "interface_declaration" => JvmTypeKind::Interface, + "enum_declaration" => JvmTypeKind::Enum, + "record_declaration" => JvmTypeKind::Record, + "object_declaration" => JvmTypeKind::Object, + _ if has("annotation") => JvmTypeKind::Annotation, + _ if has("enum") => JvmTypeKind::Enum, + _ if parts.iter().any(|n| n.kind() == "interface") => JvmTypeKind::Interface, + _ if exception => JvmTypeKind::Exception, + _ if has("abstract") => JvmTypeKind::AbstractClass, + _ => JvmTypeKind::Class, + }; + let spring_boot = modifiers.is_some_and(|m| annotation(m, snap.text, "SpringBootApplication")); + let body = parts.iter().find(|n| n.kind() == "class_body"); + 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") + { + return false; + } + if cx.grammar == "kotlin" { + return children(n) + .iter() + .any(|m| m.kind() == "modifiers" && annotation(*m, snap.text, "JvmStatic")); + } + let modifiers = children(n).into_iter().find(|m| m.kind() == "modifiers"); + let params = n.child_by_field_name("parameters"); + modifiers.is_some_and(|m| keyword(m, snap.text, "static") && keyword(m, snap.text, "public")) + && n.child_by_field_name("type").is_some_and(|t| text(t, snap.text) == "void") + && params.is_some_and(|p| { + let args: Vec<_> = p.named_children(&mut p.walk()).collect(); + args.len() == 1 + && descendant(args[0], &|t| { + t.kind() == "type_identifier" + && matches!(text(t, snap.text), "String" | "java.lang.String") + }) + && (keyword(args[0], snap.text, "[") || keyword(args[0], snap.text, "...")) + }) + }) + }); + Ok(JvmPresentation { kind: Some(kind), spring_boot, runnable }) + }) + } +} diff --git a/crates/ide-syntax/src/lib.rs b/crates/ide-syntax/src/lib.rs index 09253c3ae..b72382883 100644 --- a/crates/ide-syntax/src/lib.rs +++ b/crates/ide-syntax/src/lib.rs @@ -19,6 +19,7 @@ //! the query loops honour the [`CancellationToken`]. mod identifiers; +mod jvm; mod kinds; mod lines; mod locals; @@ -28,6 +29,7 @@ mod types; #[cfg(test)] mod tests; +pub use jvm::{JvmPresentation, JvmTypeKind}; pub use types::*; use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range}; diff --git a/crates/ide-syntax/src/tests/jvm.rs b/crates/ide-syntax/src/tests/jvm.rs new file mode 100644 index 000000000..fb95ef155 --- /dev/null +++ b/crates/ide-syntax/src/tests/jvm.rs @@ -0,0 +1,78 @@ +use super::*; +use crate::{JvmPresentation, JvmTypeKind::*}; + +fn presentation(language: &str, text: &str) -> JvmPresentation { + service().jvm_presentation(&Snapshot::text(&lang(language), text), "Foo", &none()).unwrap() +} + +#[test] +fn jvm_declaration_kinds() { + for (language, source, kind) in [ + ("java", "public class Foo {}", Class), + ("java", "abstract class Foo {}", AbstractClass), + ("java", "interface Foo {}", Interface), + ("java", "enum Foo { A }", Enum), + ("java", "record Foo(int x) {}", Record), + ("java", "@interface Foo {}", Annotation), + ("java", "class Foo extends java.lang.RuntimeException {}", Exception), + ("kotlin", "class Foo", Class), + ("kotlin", "abstract class Foo", AbstractClass), + ("kotlin", "interface Foo", Interface), + ("kotlin", "enum class Foo { A }", Enum), + ("kotlin", "annotation class Foo", Annotation), + ("kotlin", "class Foo : RuntimeException()", Exception), + ("kotlin", "object Foo {}", Object), + ("kotlin", "data class Foo(val x: Int)", Class), + ] { + assert_eq!(presentation(language, source).kind, Some(kind), "{language}: {source}"); + } +} + +#[test] +fn boot_annotations_are_on_the_primary_type_only() { + for language in ["java", "kotlin"] { + for annotation in ["@SpringBootApplication", "@org.springframework.boot.autoconfigure.SpringBootApplication"] { + assert!(presentation(language, &format!("{annotation} class Foo {{}}")).spring_boot); + } + for source in [ + "// @SpringBootApplication\nclass Foo {}", + "/* @SpringBootApplication */ class Foo {}", + "@Service class Foo {}", + "@Controller class Foo {}", + "@Wrapper(\"@SpringBootApplication\") class Foo {}", + "class Outer { @SpringBootApplication class Nested {} } class Foo {}", + ] { + assert!(!presentation(language, source).spring_boot, "{source}"); + } + } + assert!(!presentation("java", "class Foo { String s = \"@SpringBootApplication\"; }").spring_boot); + assert!(!presentation("kotlin", "class Foo { val s = \"@SpringBootApplication\" }").spring_boot); + assert!(!presentation("java", "class Foo { @SpringBootApplication void method() {} }").spring_boot); + assert!(!presentation("java", "@Wrapper(@SpringBootApplication) class Foo {}").spring_boot); + assert_eq!(presentation("java", "class Foo extends Box {}").kind, Some(Class)); + assert_eq!(presentation("kotlin", "class Foo : Box()").kind, Some(Class)); + assert_eq!(presentation("java", "// no type\n"), JvmPresentation::default()); + assert_eq!(presentation("kotlin", "val text = \"class Foo\""), JvmPresentation::default()); + assert_eq!(presentation("java", "interface Other {} class Foo {} ").kind, Some(Class)); +} + +#[test] +fn runnable_methods() { + assert!(presentation("java", "class Foo { public static void main(String[] args) {} }").runnable); + assert!(presentation("java", "class Foo { public static void main(String... args) {} }").runnable); + assert!(!presentation("java", "class Foo { void main() {} }").runnable); + assert!(!presentation("java", "class Foo { class Nested { public static void main(String[] a) {} } }").runnable); + assert!(!presentation("java", "class Foo { String s = \"public static void main(String[] args) {}\"; }").runnable); + assert!( + presentation( + "kotlin", + "class Foo { + companion object { + @JvmStatic + fun main(args: Array) {} + } + }" + ) + .runnable + ); +} diff --git a/crates/ide-syntax/src/tests/mod.rs b/crates/ide-syntax/src/tests/mod.rs index 3c677d41b..fe753dc42 100644 --- a/crates/ide-syntax/src/tests/mod.rs +++ b/crates/ide-syntax/src/tests/mod.rs @@ -1,6 +1,7 @@ //! Test helpers: sources are plain strings; positions are named by the n-th //! whole-word occurrence of an identifier (1-based), so fixtures stay readable. +mod jvm; mod locals_langs; mod structure; diff --git a/fixtures/ts-app/.openidea/.gitignore b/fixtures/ts-app/.openidea/.gitignore new file mode 100644 index 000000000..44ade5287 --- /dev/null +++ b/fixtures/ts-app/.openidea/.gitignore @@ -0,0 +1,3 @@ +# Per-user IDE state +/shelf/ +/changelists.json diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ab1388209..121f2eab1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -151,6 +151,7 @@ pub fn builder() -> tauri_specta::Builder { doc::doc_folding, doc::doc_highlights, doc::doc_selection_ranges, + syntax::jvm_file_presentations, syntax::syntax_support, syntax::syntax_node_at, syntax::syntax_expression_at, diff --git a/src-tauri/src/commands/syntax.rs b/src-tauri/src/commands/syntax.rs index d3292ceee..ebf8585c4 100644 --- a/src-tauri/src/commands/syntax.rs +++ b/src-tauri/src/commands/syntax.rs @@ -112,3 +112,13 @@ pub async fn syntax_structural_ranges( ) -> IdeResult { ide_host::ops::syntax::syntax_structural_ranges(&state.host, window.label(), doc, pos, request).await } + +#[tauri::command] +#[specta::specta] +pub async fn jvm_file_presentations( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult> { + ide_host::ops::jvm_icons::jvm_file_presentations(&state.host, window.label(), paths).await +} diff --git a/src/bindings.ts b/src/bindings.ts index 559ec6695..b34885d05 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -226,6 +226,7 @@ export const commands = { * Large documents fall back to word / line / document ranges. */ docSelectionRanges: (doc: DocId, offset: number) => typedError(__TAURI_INVOKE("doc_selection_ranges", { doc, offset })), + jvmFilePresentations: (paths: string[]) => typedError(__TAURI_INVOKE("jvm_file_presentations", { paths })), /** * What the structure service supports for a language (`tree`: every query, * `locals`: scope analysis). @@ -3737,6 +3738,19 @@ export type JdkVendor = { name: string, }; +export type JvmFilePresentation = { + path: string, + presentation: JvmPresentation, +}; + +export type JvmPresentation = { + kind: JvmTypeKind | null, + springBoot: boolean, + runnable: boolean, +}; + +export type JvmTypeKind = "class" | "abstractClass" | "interface" | "enum" | "record" | "annotation" | "exception" | "object"; + /** Editor › Code Style › › Tabs and Indents (unset fields inherit). */ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_Deserialize; diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index 8b65856d1..e91bebf39 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -1,3 +1,4 @@ +import { useFilePresentationIcons } from "@/platform/filePresentation"; /** * The editor area: split layout of panes, each with IDEA-style tabs (preview * tab in italics, pinned tabs, modified dot, file icons coloured by file @@ -145,6 +146,9 @@ function PaneView({ paneId }: { paneId: string }) { const { showExtension, markModified, showCloseButton, showFileIcon, showDirectoryForNonUnique, showFullPathOnHover } = tabPrefs; + const fileIcons = useFilePresentationIcons( + showFileIcon ? tabs.flatMap((t) => (t.input.kind === "file" && t.input.path ? [t.input.path] : [])) : [], + ); const items: TabItem[] = useMemo(() => { const names = tabs.map((t) => tabTitle(t.input, kinds)); return tabs.map((t, i) => { @@ -167,7 +171,11 @@ function PaneView({ paneId }: { paneId: string }) { ), text: dupe ? `${name} — ${dupe}` : name, - icon: showFileIcon ? tabIcon(t.input, kinds) : undefined, + icon: showFileIcon + ? t.input.kind === "file" && t.input.path + ? fileIcons.get(t.input.path) + : tabIcon(t.input, kinds) + : undefined, italic: t.preview, modified: dirty[i], closable: showCloseButton, @@ -178,6 +186,7 @@ function PaneView({ paneId }: { paneId: string }) { }; }); }, [ + fileIcons, tabs, kinds, dirty, diff --git a/src/features/project-tree/ProjectView.module.css b/src/features/project-tree/ProjectView.module.css index e947b4ec8..c23ebaf46 100644 --- a/src/features/project-tree/ProjectView.module.css +++ b/src/features/project-tree/ProjectView.module.css @@ -125,3 +125,16 @@ padding: 8px 12px; color: var(--fg-subtle); } + +.iconStack { + position: relative; + display: inline-flex; + flex: none; + width: 16px; + height: 16px; +} +.runnable { + position: absolute; + inset: 0; + pointer-events: none; +} diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index d9e2e8a84..f54af5159 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -24,7 +24,7 @@ import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { isTauri } from "@/platform/ipc"; import type { RootFolder } from "@/platform/languageSettings"; -import { useEffectiveRoots } from "@/platform/projectRoots"; +import { useDetectedRoots, useEffectiveRoots } from "@/platform/projectRoots"; import { macStroke, strokeOf } from "@/platform/keymap"; import { OWNS_FILE_DROP, prepareMenuGroups, useFileDecoration } from "@/workbench/contributions"; import { @@ -44,6 +44,9 @@ import { } from "@/ui"; import { contextMenuItems, menuGroupContext, newMenuItems } from "./contextMenu"; import { openNewPopup } from "./newPopupModel"; +import { presentationIcon, useJvmPresentation } from "@/platform/filePresentation"; +import { moduleFolderIcon, useProjectPresentation, type BuildPresentation } from "@/platform/projectPresentation"; +import { relativePath } from "@/platform/project"; import { folderKind } from "./folderKind"; import { useHomeDir } from "./home"; import { keyLike, nodeKeyFor, visibleInfo, type ProjectNode } from "./model"; @@ -109,11 +112,23 @@ const getLabel = (n: ProjectNode) => n.label; const isExpandable = (n: ProjectNode) => n.dir && !n.placeholder; const loadChildren = (n: ProjectNode) => loadDir(n.path); -function iconFor(node: ProjectNode, roots: readonly RootFolder[], root: string | null): string { +function iconFor( + node: ProjectNode, + roots: readonly RootFolder[], + root: string | null, + build: BuildPresentation, + detected: readonly RootFolder[], +): string { if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file"; if (node.icon) return node.icon; - if (node.root) return "module"; - if (node.dir) return folderIconName(folderKind(node, roots, root)); + if (node.dir) { + const kind = folderKind(node, roots, root, build, detected); + if (kind !== "plain") return folderIconName(kind); + const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null; + const module = rel === null ? null : moduleFolderIcon(rel, node.root, build.modules); + if (module) return module; + return node.root ? "module" : "folder"; + } return fileIconName(node.label); } @@ -246,7 +261,13 @@ const Row = memo(function Row({ const deco = useFileDecoration(node.path); const roots = useEffectiveRoots(); const root = useProjectTree((s) => s.root); - const icon = iconFor(node, roots, root); + const detected = useDetectedRoots(); + const build = useProjectPresentation(); + const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs); + const icon = + node.dir || node.placeholder || node.icon + ? iconFor(node, roots, root, build, detected) + : presentationIcon(node.path, file, roots, root, build.spring); const isEditing = !!editing && (node.placeholder ? editing.kind === "new" : editing.kind === "rename" && editing.key === node.key); const faded = node.ignored || deco.faded; @@ -263,11 +284,16 @@ const Row = memo(function Row({ onDoubleClick={(e) => onDoubleClick(node, e)} onPointerDown={(e) => onPointerDown(node, e)} > - + + + {!!file?.runnable && icon !== "spring-boot" && icon !== "jvm-test" && ( + + )} + {isEditing && editing ? ( + folderKind({ path: `/p/${rel}`, excluded }, rs, "/p", build); +describe("JVM folder presentation", () => { + it.each([ + ["src/main/java", "source"], + ["src/test/java", "test"], + ["src/main/resources", "resources"], + ["src/test/resources", "testResources"], + ["src/main/java/com/example", "package"], + ["src/test/java/com", "package"], + ["src/main/resources/templates", "plain"], + ["plain", "plain"], + ])("%s: %s", (path, expected) => expect(kind(path)).toBe(expected)); + it.each([ + ["target/generated-sources", "generatedSource"], + ["target/generated-test-sources", "generatedTest"], + ["app/build/generated", "generatedSource"], + ["app/build/classes/kotlin", "compiled"], + ["target/classes", "compiled"], + ["target/site/apidocs", "javadoc"], + ["app/build/docs/javadoc", "javadoc"], + ])("%s: %s", (path, expected) => { + expect(kind(path)).toBe(expected); + expect(kind(path, true)).toBe("excluded"); + expect(outputFolderKind(path, new Map())).toBeNull(); + }); + it("shows packages below custom marked JVM source roots", () => { + expect(kind("custom/com/example", false, [{ path: "custom", kind: "source", detectedFrom: null }])).toBe("package"); + }); + it("keeps marks and generated source roots ahead of output and exclusion", () => { + expect( + kind("target/generated-sources", true, [ + { path: "target/generated-sources", kind: "source", detectedFrom: "pom.xml" }, + ]), + ).toBe("source"); + expect(kind("target/classes", false, [{ path: "target/classes", kind: "test", detectedFrom: null }])).toBe("test"); + expect( + kind("src/main/java/com", false, [...roots, { path: "src/main/java/com", kind: "excluded", detectedFrom: null }]), + ).toBe("excluded"); + }); + it.each(["generated-sources", "src/main/java/classes", "other/build/generated", "build/generated"])( + "does not guess output roles for %s", + (path) => expect(outputFolderKind(path, build.modules)).toBeNull(), + ); +}); diff --git a/src/features/project-tree/folderKind.ts b/src/features/project-tree/folderKind.ts index 194238ee0..b87994170 100644 --- a/src/features/project-tree/folderKind.ts +++ b/src/features/project-tree/folderKind.ts @@ -1,8 +1,10 @@ /** Folder icon kind in the project view, from the effective roots. */ -import { rootRole, type FolderRole, type RootFolder } from "@/platform/languageSettings"; +import { rootFor, rootRole, type FolderRole, type RootFolder } from "@/platform/languageSettings"; import { basename, relativePath } from "@/platform/project"; import type { FolderKind } from "@/ui"; import type { ProjectNode } from "./model"; +import { packageKindAt } from "./packages"; +import type { BuildPresentation } from "@/platform/projectPresentation"; const ROLE_KIND: Record = { source: "source", @@ -20,9 +22,39 @@ export function folderKind( node: Pick, roots: readonly RootFolder[], root: string | null, + build?: BuildPresentation, + detected: readonly RootFolder[] = [], ): FolderKind { - if (node.excluded) return "excluded"; - const role = root ? rootRole(roots, relativePath(root, node.path)) : null; + const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null; + const role = rel === null ? null : rootRole(roots, rel); + const exact = roots.find((r) => r.path === rel); + const output = rel === null ? null : outputFolderKind(rel, build?.modules ?? new Map()); + if (role && (exact?.detectedFrom === null || output === "generatedSource" || output === "generatedTest")) + return ROLE_KIND[role]; + const containing = rel === null ? null : rootFor(roots, rel); + if (node.excluded || containing?.kind === "excluded") return "excluded"; if (role) return ROLE_KIND[role]; + if (output) return output; + if (rel !== null && (containing?.kind === "source" || containing?.kind === "test")) { + const inModule = [...(build?.modules.keys() ?? [])].some((dir) => !dir || rel.startsWith(`${dir}/`)); + if (inModule || packageKindAt(rel, roots, detected) === "jvm") return "package"; + } return basename(node.path) === ".github" ? "github" : "plain"; } + +/** Output roles are relative to detected JVM modules, never arbitrary package names. */ +export function outputFolderKind(rel: string, modules: BuildPresentation["modules"]): FolderKind | null { + const parts = rel.split("/"); + for (let i = 0; i < parts.length - 1; i++) { + const output = parts[i]; + if (output !== "target" && output !== "build") continue; + const tool = modules.get(parts.slice(0, i).join("/")); + if (tool !== (output === "target" ? "maven" : "gradle")) continue; + const path = parts.slice(i + 1).join("/"); + if (/^generated-test-sources(\/|$)/.test(path) || /^generated\/.*\btest\b/.test(path)) return "generatedTest"; + if (/^(generated-sources|generated)(\/|$)/.test(path)) return "generatedSource"; + if (/^(classes|test-classes)(\/|$)/.test(path)) return "compiled"; + if (/^(site\/apidocs|apidocs|docs\/javadoc|javadoc)(\/|$)/.test(path)) return "javadoc"; + } + return null; +} diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index 0ddb04ed9..86b116fca 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -1,3 +1,4 @@ +import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import clsx from "clsx"; import { actionToggled, executeAction, getAction } from "@/platform/actions"; @@ -16,7 +17,6 @@ import { SymbolBadge, Select, Toggle, - fileIconName, listNavigate, type MenuItem, T, @@ -830,9 +830,10 @@ function FileRow({ line: number | null; }) { const deco = useFileDecoration(path); + const { icon } = useFilePresentation(path); return ( <> - + diff --git a/src/features/search/recent/RecentPopups.tsx b/src/features/search/recent/RecentPopups.tsx index 20220c24d..efab57b66 100644 --- a/src/features/search/recent/RecentPopups.tsx +++ b/src/features/search/recent/RecentPopups.tsx @@ -1,3 +1,4 @@ +import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import clsx from "clsx"; import { editorsFor, getNavigationHistory } from "@/editor/api"; @@ -353,9 +354,10 @@ function ToolRow({ tw, indices }: { tw: ToolWindowContribution; indices: number[ function RecentFileRow({ item, current }: { item: RecentFileItem; current: boolean }) { const deco = useFileDecoration(item.editor ? "" : item.path); + const { icon } = useFilePresentation(item.editor ? "" : item.path); return ( <> - + ({ + resolve: vi.fn(), + event: undefined as undefined | ((e: { created: string[]; modified: string[]; removed: string[] }) => void), +})); +vi.mock("./ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { jvmFilePresentations: backend.resolve }, + onIdeEvent: (name: string, handler: typeof backend.event) => { + if (name === "fsChanged") backend.event = handler; + return () => {}; + }, +})); +vi.mock("./projectPresentation", () => ({ useProjectPresentation: () => ({ spring: false, modules: new Map() }) })); +import { useFilePresentation, useFilePresentationIcons } from "./filePresentation"; +const answer = (path: string, kind = "interface") => ({ + path, + presentation: { kind, springBoot: false, runnable: false }, +}); +const tick = async () => { + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); +}; + +describe("visible file resolver", () => { + beforeEach(() => { + vi.useFakeTimers(); + backend.resolve + .mockReset() + .mockImplementation(async (paths: string[]) => ({ status: "ok", data: paths.map((p) => answer(p)) })); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + it("shows a plain icon immediately and batches mounted paths only", async () => { + const { result } = renderHook(() => useFilePresentationIcons(["/p/Foo.java", "/p/Bar.kt", "/p/config.yml"])); + expect(result.current.get("/p/Foo.java")).toBe("file-java"); + expect(backend.resolve).not.toHaveBeenCalled(); + await tick(); + expect(backend.resolve).toHaveBeenCalledOnce(); + expect(backend.resolve.mock.calls[0]?.[0]).toEqual(["/p/Foo.java", "/p/Bar.kt"]); + expect(result.current.get("/p/Foo.java")).toBe("interface"); + }); + it("drops unmounted requests and splits large sets into bounded batches", async () => { + const dropped = renderHook(() => useFilePresentation("/p/Invisible.java")); + dropped.unmount(); + const { result } = renderHook(() => + useFilePresentationIcons(Array.from({ length: 130 }, (_, i) => `/p/F${i}.java`)), + ); + await tick(); + await tick(); + await tick(); + expect(backend.resolve.mock.calls.map(([paths]) => paths.length)).toEqual([64, 64, 2]); + expect(result.current.get("/p/F129.java")).toBe("interface"); + expect(backend.resolve.mock.calls.flatMap(([paths]) => paths)).not.toContain("/p/Invisible.java"); + }); + it("invalidates on watcher changes and rejects an in-flight stale result", async () => { + let finish!: (value: unknown) => void; + backend.resolve.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => useFilePresentation("/p/Foo.java")); + await tick(); + act(() => backend.event?.({ created: [], modified: ["/p/Foo.java"], removed: [] })); + await act(async () => { + finish({ status: "ok", data: [answer("/p/Foo.java", "class")] }); + }); + expect(result.current.icon).toBe("file-java"); + await tick(); + expect(result.current.icon).toBe("interface"); + expect(backend.resolve).toHaveBeenCalledTimes(2); + }); + it("rechecks changed mtimes while preserving cached results for unchanged rows", async () => { + const { result, rerender } = renderHook(({ mtime }) => useFilePresentation("/p/Foo.java", mtime), { + initialProps: { mtime: 1 }, + }); + await tick(); + rerender({ mtime: 1 }); + await tick(); + expect(backend.resolve).toHaveBeenCalledOnce(); + rerender({ mtime: 2 }); + await tick(); + expect(backend.resolve).toHaveBeenCalledTimes(2); + expect(result.current.icon).toBe("interface"); + }); + it("caches failures instead of continuously retrying visible batches", async () => { + backend.resolve.mockRejectedValue(new Error("offline")); + renderHook(() => useFilePresentationIcons(Array.from({ length: 65 }, (_, i) => `/p/F${i}.java`))); + await tick(); + await tick(); + await tick(); + expect(backend.resolve).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/platform/filePresentation.test.ts b/src/platform/filePresentation.test.ts new file mode 100644 index 000000000..066b001e2 --- /dev/null +++ b/src/platform/filePresentation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { presentationIcon } from "./filePresentation"; +import type { RootFolder } from "./languageSettings"; +import type { JvmPresentation } from "./ipc"; + +const roots: RootFolder[] = [ + { path: "src/main/java", kind: "source", detectedFrom: "pom.xml" }, + { path: "src/test/java", kind: "test", detectedFrom: "pom.xml" }, + { path: "src/main/resources", kind: "resources", detectedFrom: "pom.xml" }, + { path: "src/test/resources", kind: "testResources", detectedFrom: "pom.xml" }, +]; +const type = (kind: JvmPresentation["kind"]): JvmPresentation => ({ kind, springBoot: false, runnable: false }); +const icon = (path: string, info: JvmPresentation | null = null, spring = false) => + presentationIcon(`/p/${path}`, info, roots, "/p", spring); + +describe("JVM file presentation", () => { + it.each([ + ["class", "class"], + ["abstractClass", "class-abstract"], + ["interface", "interface"], + ["enum", "enum"], + ["record", "record"], + ["annotation", "annotation"], + ["exception", "exception"], + ["object", "kotlin-object"], + ] as const)("maps %s to %s", (kind, expected) => expect(icon("Foo.java", type(kind))).toBe(expected)); + it.each(["LIMIT.java", "AUDIT.java", "Contest.java"])("keeps the type icon of %s", (path) => + expect(icon(path, type("class"))).toBe("class"), + ); + it.each(["FooTest.java", "FooTests.java", "FooIT.java", "FooTest.kt", "src/test/java/Anything.java"])( + "marks test classes: %s", + (path) => expect(icon(path, type("class"))).toBe("jvm-test"), + ); + it("keeps files without declarations and binaries as file types", () => { + expect(icon("Foo.java", type(null))).toBe("file-java"); + expect(icon("FooTest.java", type(null))).toBe("file-java"); + expect(icon("Foo.kt")).toBe("file-kotlin"); + expect(icon("Foo.class")).toBe("file-binary"); + expect(icon("Contest.java", type("class"))).toBe("class"); + }); + it.each(["application.yml", "application-dev.yaml", "bootstrap.properties", "bootstrap-local.yml"])( + "requires Spring or a resource root: %s", + (name) => { + expect(icon(`src/main/resources/${name}`)).toBe("spring-config"); + expect(icon(`src/test/resources/${name}`)).toBe("spring-config"); + expect(icon(name, null, true)).toBe("spring-config"); + expect(icon(name)).not.toBe("spring-config"); + }, + ); + it("uses the entire Boot icon on application classes", () => { + expect(icon("App.java", { ...type("class"), springBoot: true, runnable: true })).toBe("spring-boot"); + }); +}); diff --git a/src/platform/filePresentation.ts b/src/platform/filePresentation.ts new file mode 100644 index 000000000..26ffe2952 --- /dev/null +++ b/src/platform/filePresentation.ts @@ -0,0 +1,189 @@ +/** Visible-file JVM presentation: one bounded IPC batch, shared cache and watcher. */ +import { useEffect, useMemo } from "react"; +import { create } from "zustand"; +import { fileIconName, type IconName } from "@/ui/icons"; +import { call, commands, isTauri, onIdeEvent, type JvmPresentation } from "./ipc"; +import { basename, relativePath, useProject } from "./project"; +import { rootFor, type RootFolder } from "./languageSettings"; +import { useEffectiveRoots } from "./projectRoots"; +import { useProjectPresentation } from "./projectPresentation"; + +const JVM = /\.(java|kt)$/; +const MAX_CACHE = 4096; +const pending = new Map(); +const visible = new Map(); +const versions = new Map(); +const mtimes = new Map(); +const cache = create<{ entries: ReadonlyMap }>(() => ({ entries: new Map() })); +let timer: ReturnType | undefined; +let running = false; +let epoch = 0; +let dispose: (() => void) | undefined; +let users = 0; + +function enqueue(path: string) { + if (!JVM.test(path) || cache.getState().entries.has(path) || !isTauri()) return; + pending.set(path, versions.get(path) ?? 0); + if (!running && !timer) + timer = setTimeout(() => { + timer = undefined; + void flush(); + }, 0); +} +async function flush() { + if (running) return; + const batch = [...pending].filter(([p]) => visible.has(p)).slice(0, 64); + pending.clear(); + if (!batch.length) return; + running = true; + const gen = epoch; + try { + const results = await call(commands.jvmFilePresentations(batch.map(([p]) => p))); + if (gen !== epoch) return; + const entries = new Map(cache.getState().entries); + for (const [path, version] of batch) { + if ((versions.get(path) ?? 0) !== version) continue; + entries.delete(path); + entries.set(path, results.find((r) => r.path === path)?.presentation ?? null); + } + while (entries.size > MAX_CACHE) entries.delete(entries.keys().next().value!); + cache.setState({ entries }); + } catch { + if (gen === epoch) { + const entries = new Map(cache.getState().entries); + for (const [path, version] of batch) if ((versions.get(path) ?? 0) === version) entries.set(path, null); + cache.setState({ entries }); + } + } finally { + running = false; + for (const path of versions.keys()) if (!visible.has(path)) versions.delete(path); + // Requests that arrived during IPC stay bounded to the mounted consumers. + for (const p of visible.keys()) if (!batch.some(([b]) => b === p)) enqueue(p); + if (pending.size && !timer) + timer = setTimeout(() => { + timer = undefined; + void flush(); + }, 0); + } +} + +export function invalidateFilePresentations(paths: readonly string[]) { + const entries = new Map(cache.getState().entries); + for (const key of new Set([...entries.keys(), ...visible.keys()])) { + if (!paths.some((p) => key === p || key.startsWith(p + "/"))) continue; + entries.delete(key); + versions.set(key, (versions.get(key) ?? 0) + 1); + } + cache.setState({ entries }); + for (const p of visible.keys()) enqueue(p); +} +function mount(path: string) { + if (!JVM.test(path)) return () => {}; + if (users++ === 0) { + cache.setState({ entries: new Map() }); + mtimes.clear(); + const fs = onIdeEvent("fsChanged", (e) => invalidateFilePresentations([...e.created, ...e.modified, ...e.removed])); + const project = useProject.subscribe((s, prev) => { + if (s.project === prev.project) return; + epoch++; + versions.clear(); + pending.clear(); + cache.setState({ entries: new Map() }); + }); + dispose = () => { + fs(); + project(); + }; + } + visible.set(path, (visible.get(path) ?? 0) + 1); + enqueue(path); + return () => { + const count = (visible.get(path) ?? 1) - 1; + if (count) visible.set(path, count); + else { + visible.delete(path); + pending.delete(path); + } + if (--users === 0) { + epoch++; + pending.clear(); + dispose?.(); + } + }; +} + +export function presentationIcon( + path: string, + info: JvmPresentation | null | undefined, + roots: readonly RootFolder[], + root: string | null, + spring: boolean, +): IconName { + const name = basename(path); + const role = root ? rootFor(roots, relativePath(root, path))?.kind : null; + if ( + /^(application|bootstrap).*\.(yml|yaml|properties)$/.test(name) && + (spring || role === "resources" || role === "testResources") + ) + return "spring-config"; + if (!info?.kind) return fileIconName(name); + if (info.springBoot) return "spring-boot"; + if (/(Tests?|[a-z\d]IT)\.(java|kt)$/.test(name) || role === "test") return "jvm-test"; + const kind: Record, IconName> = { + class: "class", + abstractClass: "class-abstract", + interface: "interface", + enum: "enum", + record: "record", + annotation: "annotation", + exception: "exception", + object: "kotlin-object", + }; + return kind[info.kind]; +} + +export function useJvmPresentation(path: string, mtime?: number | null) { + useEffect(() => (path ? mount(path) : undefined), [path]); + // Directory refresh metadata also invalidates results if a watcher event was missed. + useEffect(() => { + if (mtime != null && JVM.test(path)) { + const previous = mtimes.get(path); + mtimes.set(path, mtime); + if (previous !== undefined && previous !== mtime) invalidateFilePresentations([path]); + if (mtimes.size > MAX_CACHE) mtimes.delete(mtimes.keys().next().value!); + } + }, [path, mtime]); + return cache((s) => s.entries.get(path)); +} + +export function useFilePresentation(path: string, mtime?: number | null) { + const info = useJvmPresentation(path, mtime); + const roots = useEffectiveRoots(); + const root = useProject((s) => s.project?.root ?? null); + const build = useProjectPresentation(); + const icon = presentationIcon(path, info, roots, root, build.spring); + return { icon, runnable: !!info?.runnable && icon !== "spring-boot" && icon !== "jvm-test" }; +} + +/** Tab strips resolve their mounted file tabs through the same batch as tree rows. */ +export function useFilePresentationIcons(paths: readonly string[]) { + const key = JSON.stringify(paths); + useEffect(() => { + const disposers = (JSON.parse(key) as string[]).map(mount); + return () => disposers.forEach((dispose) => dispose()); + }, [key]); + const entries = cache((s) => s.entries); + const roots = useEffectiveRoots(); + const root = useProject((s) => s.project?.root ?? null); + const build = useProjectPresentation(); + return useMemo( + () => + new Map( + (JSON.parse(key) as string[]).map((path) => [ + path, + presentationIcon(path, entries.get(path), roots, root, build.spring), + ]), + ), + [key, entries, roots, root, build.spring], + ); +} diff --git a/src/platform/projectPresentation.test.ts b/src/platform/projectPresentation.test.ts new file mode 100644 index 000000000..54f6524d9 --- /dev/null +++ b/src/platform/projectPresentation.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { buildPresentation, moduleFolderIcon } from "./projectPresentation"; +import type { BuildModel } from "./ipc"; + +const model = (partial: unknown) => ({ maven: null, gradle: null, ...(partial as object) }) as BuildModel; +describe("build presentation context", () => { + it.each(["maven", "gradle"] as const)("uses distinct %s root and nested module glyphs", (tool) => { + const modules = new Map([ + ["", tool], + ["app", tool], + ]); + expect(moduleFolderIcon("", true, modules)).toBe(`${tool}-project`); + expect(moduleFolderIcon("app", false, modules)).toBe(`${tool}-module`); + expect(moduleFolderIcon("plain", false, modules)).toBeNull(); + }); + it("includes Maven aggregators and modules without source roots", () => { + const context = buildPresentation( + model({ + maven: { + projects: [ + { dir: "", dependencies: [], plugins: [] }, + { dir: "app", dependencies: [{ groupId: "org.springframework.boot" }], plugins: [] }, + ], + }, + }), + ); + expect([...context.modules]).toEqual([ + ["", "maven"], + ["app", "maven"], + ]); + expect(context.spring).toBe(true); + }); + it("includes Gradle root and nested project directories", () => { + const context = buildPresentation( + model({ + gradle: { + projects: [ + { dir: "", tasks: [] }, + { dir: "lib/core", tasks: [] }, + ], + }, + }), + ); + expect([...context.modules]).toEqual([ + ["", "gradle"], + ["lib/core", "gradle"], + ]); + expect(context.spring).toBe(false); + }); + it("detects Spring build plugins and Gradle Boot tasks", () => { + expect( + buildPresentation( + model({ + maven: { projects: [{ dir: "", dependencies: [], plugins: [{ groupId: "org.springframework.boot" }] }] }, + }), + ).spring, + ).toBe(true); + expect(buildPresentation(model({ gradle: { projects: [{ dir: "", tasks: [{ name: "bootRun" }] }] } })).spring).toBe( + true, + ); + }); + it("keeps plain folders plain", () => expect(buildPresentation(model({})).modules.size).toBe(0)); +}); diff --git a/src/platform/projectPresentation.ts b/src/platform/projectPresentation.ts new file mode 100644 index 000000000..9e0648159 --- /dev/null +++ b/src/platform/projectPresentation.ts @@ -0,0 +1,83 @@ +/** Read-only build context shared by file and folder presentation consumers. */ +import { useEffect } from "react"; +import { create } from "zustand"; +import { call, commands, isTauri, onIdeEvent, type BuildModel } from "./ipc"; +import { useProject } from "./project"; + +export interface BuildPresentation { + modules: ReadonlyMap; + spring: boolean; +} +const EMPTY: BuildPresentation = { modules: new Map(), spring: false }; +export const useBuildPresentation = create(() => EMPTY); + +export function buildPresentation(model: BuildModel): BuildPresentation { + const modules = new Map(); + for (const p of model.maven?.projects ?? []) modules.set(p.dir, "maven"); + for (const p of model.gradle?.projects ?? []) modules.set(p.dir, "gradle"); + const spring = + (model.maven?.projects ?? []).some( + (p) => + p.dependencies.some((d) => d.groupId.startsWith("org.springframework")) || + p.plugins.some((p) => p.groupId.startsWith("org.springframework")), + ) || (model.gradle?.projects ?? []).some((p) => p.tasks.some((t) => t.name === "bootRun")); + return { modules, spring }; +} + +export function moduleFolderIcon(rel: string, root: boolean, modules: BuildPresentation["modules"]): string | null { + const tool = modules.get(rel); + return tool ? `${tool}-${root ? "project" : "module"}` : null; +} + +let users = 0; +let generation = 0; +let dispose: (() => void) | undefined; +async function load() { + const gen = ++generation; + if (!useProject.getState().project || !isTauri()) { + useBuildPresentation.setState(EMPTY); + return; + } + try { + const model = await call(commands.buildModel()); + if (generation === gen) useBuildPresentation.setState(buildPresentation(model)); + } catch { + /* Presentation keeps its filename fallback. */ + } +} + +export function useProjectPresentation(): BuildPresentation { + useEffect(() => { + if (users++ === 0) { + void load(); + const project = useProject.subscribe((s, prev) => { + if (s.project === prev.project) return; + useBuildPresentation.setState(EMPTY); + void load(); + }); + const roots = onIdeEvent("projectRootsChanged", () => void load()); + let timer: ReturnType | undefined; + const files = onIdeEvent("fsChanged", (e) => { + if ( + ![...e.created, ...e.modified, ...e.removed].some((p) => + /(?:^|\/)(pom\.xml|(?:build|settings)\.gradle(?:\.kts)?)$/.test(p), + ) + ) + return; + clearTimeout(timer); + timer = setTimeout(() => void load(), 100); + }); + dispose = () => { + project(); + roots(); + files(); + clearTimeout(timer); + generation++; + }; + } + return () => { + if (--users === 0) dispose?.(); + }; + }, []); + return useBuildPresentation(); +} diff --git a/src/ui/expui/gradle/gradle.svg b/src/ui/expui/gradle/gradle.svg new file mode 100644 index 000000000..29f3e59ee --- /dev/null +++ b/src/ui/expui/gradle/gradle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/gradle/gradle@20x20.svg b/src/ui/expui/gradle/gradle@20x20.svg new file mode 100644 index 000000000..8a44d0cd4 --- /dev/null +++ b/src/ui/expui/gradle/gradle@20x20.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/gradle/gradle@20x20_dark.svg b/src/ui/expui/gradle/gradle@20x20_dark.svg new file mode 100644 index 000000000..819cf4b42 --- /dev/null +++ b/src/ui/expui/gradle/gradle@20x20_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/gradle/gradleSubproject.svg b/src/ui/expui/gradle/gradleSubproject.svg new file mode 100644 index 000000000..f5cd5ab0c --- /dev/null +++ b/src/ui/expui/gradle/gradleSubproject.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/gradle/gradleSubproject_dark.svg b/src/ui/expui/gradle/gradleSubproject_dark.svg new file mode 100644 index 000000000..b568277a4 --- /dev/null +++ b/src/ui/expui/gradle/gradleSubproject_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/gradle/gradle_dark.svg b/src/ui/expui/gradle/gradle_dark.svg new file mode 100644 index 000000000..37c055abf --- /dev/null +++ b/src/ui/expui/gradle/gradle_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/kotlin/objectKotlin.svg b/src/ui/expui/kotlin/objectKotlin.svg new file mode 100644 index 000000000..8393e60ce --- /dev/null +++ b/src/ui/expui/kotlin/objectKotlin.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/kotlin/objectKotlin_dark.svg b/src/ui/expui/kotlin/objectKotlin_dark.svg new file mode 100644 index 000000000..240bbdf83 --- /dev/null +++ b/src/ui/expui/kotlin/objectKotlin_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/nodes/classAbstract.svg b/src/ui/expui/nodes/classAbstract.svg new file mode 100644 index 000000000..212d15029 --- /dev/null +++ b/src/ui/expui/nodes/classAbstract.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/classAbstract_dark.svg b/src/ui/expui/nodes/classAbstract_dark.svg new file mode 100644 index 000000000..6cf5dbf2e --- /dev/null +++ b/src/ui/expui/nodes/classAbstract_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/compiledClassesFolder.svg b/src/ui/expui/nodes/compiledClassesFolder.svg new file mode 100644 index 000000000..8b054bd75 --- /dev/null +++ b/src/ui/expui/nodes/compiledClassesFolder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/compiledClassesFolder_dark.svg b/src/ui/expui/nodes/compiledClassesFolder_dark.svg new file mode 100644 index 000000000..9402ce5dc --- /dev/null +++ b/src/ui/expui/nodes/compiledClassesFolder_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/exception.svg b/src/ui/expui/nodes/exception.svg new file mode 100644 index 000000000..ab5bae754 --- /dev/null +++ b/src/ui/expui/nodes/exception.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/nodes/exception_dark.svg b/src/ui/expui/nodes/exception_dark.svg new file mode 100644 index 000000000..710a292ec --- /dev/null +++ b/src/ui/expui/nodes/exception_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/nodes/generatedSource.svg b/src/ui/expui/nodes/generatedSource.svg new file mode 100644 index 000000000..e75a1b6d7 --- /dev/null +++ b/src/ui/expui/nodes/generatedSource.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/generatedSource_dark.svg b/src/ui/expui/nodes/generatedSource_dark.svg new file mode 100644 index 000000000..2f9a7be68 --- /dev/null +++ b/src/ui/expui/nodes/generatedSource_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/generatedTestRoot.svg b/src/ui/expui/nodes/generatedTestRoot.svg new file mode 100644 index 000000000..158e678e1 --- /dev/null +++ b/src/ui/expui/nodes/generatedTestRoot.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/generatedTestRoot_dark.svg b/src/ui/expui/nodes/generatedTestRoot_dark.svg new file mode 100644 index 000000000..b67cfa3b8 --- /dev/null +++ b/src/ui/expui/nodes/generatedTestRoot_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/javaDocFolder.svg b/src/ui/expui/nodes/javaDocFolder.svg new file mode 100644 index 000000000..9c9970804 --- /dev/null +++ b/src/ui/expui/nodes/javaDocFolder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/javaDocFolder_dark.svg b/src/ui/expui/nodes/javaDocFolder_dark.svg new file mode 100644 index 000000000..d0c779ee5 --- /dev/null +++ b/src/ui/expui/nodes/javaDocFolder_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/nodes/runnableMark.svg b/src/ui/expui/nodes/runnableMark.svg new file mode 100644 index 000000000..1cd8fbe20 --- /dev/null +++ b/src/ui/expui/nodes/runnableMark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/runnableMark_dark.svg b/src/ui/expui/nodes/runnableMark_dark.svg new file mode 100644 index 000000000..3bc267caf --- /dev/null +++ b/src/ui/expui/nodes/runnableMark_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/ui/expui/nodes/test.svg b/src/ui/expui/nodes/test.svg new file mode 100644 index 000000000..db47ea54d --- /dev/null +++ b/src/ui/expui/nodes/test.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/nodes/test_dark.svg b/src/ui/expui/nodes/test_dark.svg new file mode 100644 index 000000000..bd319b9b1 --- /dev/null +++ b/src/ui/expui/nodes/test_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 603c8f1da..6b6c329b6 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -41,6 +41,20 @@ const ICONS = { "folder-library": "nodes/libraryFolder", "folder-github": "nodes/folderGithub", module: "nodes/module", + "folder-generated-source": "nodes/generatedSource", + "folder-generated-test": "nodes/generatedTestRoot", + "folder-compiled": "nodes/compiledClassesFolder", + "folder-javadoc": "nodes/javaDocFolder", + "class-abstract": "nodes/classAbstract", + record: "nodes/record", + annotation: "nodes/annotation", + exception: "nodes/exception", + "jvm-test": "nodes/test", + "runnable-mark": "nodes/runnableMark", + "kotlin-object": "kotlin/objectKotlin", + "spring-config": "openidea/runConfigurations/springBoot", + "gradle-project": "gradle/gradle", + "gradle-module": "gradle/gradleSubproject", "spring-boot": "openidea/runConfigurations/springBoot", file: "fileTypes/text", "file-unknown": "fileTypes/unknown", @@ -917,7 +931,12 @@ export type FolderKind = | "excluded" | "library" | "github" - | "module"; + | "module" + | "package" + | "generatedSource" + | "generatedTest" + | "compiled" + | "javadoc"; /** Folder icon by role (expui has no separate "open" folder glyph). */ export function folderIconName(kind: FolderKind = "plain"): IconName { @@ -938,6 +957,16 @@ export function folderIconName(kind: FolderKind = "plain"): IconName { return "folder-github"; case "module": return "module"; + case "package": + return "package"; + case "generatedSource": + return "folder-generated-source"; + case "generatedTest": + return "folder-generated-test"; + case "compiled": + return "folder-compiled"; + case "javadoc": + return "folder-javadoc"; default: return "folder"; } From 8fe09eba7ff02554e7ae2be76f05ca37e632dacb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:44:36 +0800 Subject: [PATCH 04/25] test(debugger): pin the module in the Java separator tests (#514) Java launch now resolves the module of a main class before starting, so these tests reached the unmocked file search. They only cover path separators, so set the module. --- src/features/debugger/__tests__/javaSeparator.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/debugger/__tests__/javaSeparator.test.ts b/src/features/debugger/__tests__/javaSeparator.test.ts index a6a93d131..4673acc2a 100644 --- a/src/features/debugger/__tests__/javaSeparator.test.ts +++ b/src/features/debugger/__tests__/javaSeparator.test.ts @@ -18,6 +18,7 @@ const config: RunConfig = { id: "main", name: "Main", kind: "application", + module: "", command: "java", args: ["-cp", "${classpath}", "Main"], cwd: null, From 92cbdd0cb828f7a8f1ca046415ad1bf67215bf3d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:29:26 +0800 Subject: [PATCH 05/25] test(host): open the file for writing before setting its time (#525) Setting a file's modified time needs a writable handle on Windows, so the JVM icon cache check in the RPC test failed there. --- crates/ide-host/tests/rpc.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 8a5947aa1..2455476f3 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -91,7 +91,12 @@ async fn serves_projects_files_documents_search_and_runs() { assert_eq!(icons[1]["presentation"]["kind"], "object"); // Replacing a file invalidates its cached metadata, including same-size writes. std::fs::write(&java, "interface Foo {} ").unwrap(); - std::fs::File::open(&java).unwrap().set_modified(std::time::SystemTime::UNIX_EPOCH).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&java) + .unwrap() + .set_modified(std::time::SystemTime::UNIX_EPOCH) + .unwrap(); let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java] })).await.unwrap(); assert_eq!(icons[0]["presentation"]["kind"], "interface"); let oversized = invoke(&peer, "jvm_file_presentations", json!({ "paths": vec![java.clone(); 65] })).await; From bde19959b939acfd325d32816d19f57efbcec3db Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:41:39 +0800 Subject: [PATCH 06/25] feat(settings): import and export editor color schemes (#518) The Color Scheme page only offered built-in schemes, so a scheme from another IDE could not be used. Import reads .icls files into named custom schemes in user settings, reports applied and skipped values, and rejects malformed files. Export writes the current scheme, replacing an existing file, and custom schemes can be deleted. Colours are validated before reaching a stylesheet, and project settings cannot define schemes. The Appearance selector and Quick Switch list custom schemes too. Fixes #513 --- crates/ide-core/src/settings.rs | 12 + crates/ide-workspace/src/settings.rs | 60 +++- src/bindings.ts | 21 ++ src/editor/core/colorSchemeRuntime.ts | 2 +- .../settings/__tests__/colorScheme.test.tsx | 116 +++++++ src/features/settings/pages/behavior.tsx | 5 +- .../settings/pages/editor/Editor.module.css | 1 + .../settings/pages/editor/colorScheme.tsx | 185 +++++++++-- .../__fixtures__/colorSchemes/daylight.icls | 8 + .../__fixtures__/colorSchemes/forest.icls | 21 ++ src/platform/clientSettings.ts | 1 + src/platform/colorScheme.ts | 70 +++- src/platform/editorSettings.ts | 9 + src/platform/icls.test.ts | 166 ++++++++++ src/platform/icls.ts | 309 ++++++++++++++++++ src/platform/locales/text/zh.json | 13 +- src/workbench/quickSwitch.ts | 8 +- 17 files changed, 950 insertions(+), 57 deletions(-) create mode 100644 src/features/settings/__tests__/colorScheme.test.tsx create mode 100644 src/platform/__fixtures__/colorSchemes/daylight.icls create mode 100644 src/platform/__fixtures__/colorSchemes/forest.icls create mode 100644 src/platform/icls.test.ts create mode 100644 src/platform/icls.ts diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 434f5a30a..bf4bc91ba 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -342,6 +342,15 @@ impl Default for AppearanceSettings { } } +/// Imported editor colour scheme on top of a built-in base. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CustomColorScheme { + pub name: String, + pub parent: String, + pub attributes: BTreeMap, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct EditorSettings { @@ -357,6 +366,8 @@ pub struct EditorSettings { pub format_on_save: bool, /// Editor colour scheme id; `theme-default` follows the IDE theme. pub color_scheme: String, + /// Imported editor schemes, stored in user settings. + pub custom_schemes: BTreeMap, /// Color Scheme edits: scheme id → attribute id → override. pub color_overrides: BTreeMap>, /// Color Scheme › Language Defaults › Semantic highlighting: local variables and @@ -404,6 +415,7 @@ impl Default for EditorSettings { format_on_save: false, color_scheme: "theme-default".into(), color_overrides: BTreeMap::new(), + custom_schemes: BTreeMap::new(), semantic_highlighting: false, rainbow_brackets: false, ligatures: false, diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 62abb99d0..3b278a0bc 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -27,6 +27,12 @@ pub enum SettingsScope { Project, } +fn remove_custom_schemes(layer: &mut Json) { + if let Some(editor) = layer.get_mut("editor").and_then(Json::as_object_mut) { + editor.remove("customSchemes"); + } +} + fn defaults_json() -> Json { serde_json::to_value(Settings::default()).expect("settings serialize") } @@ -178,10 +184,12 @@ impl SettingsStore { fn compute(&self) -> Settings { let mut merged = defaults_json(); - for (_, file) in self.layers() { - if let Some(layer) = read_layer(&file) - && let Err(e) = try_merge(&mut merged, layer) - { + for (scope, file) in self.layers() { + let Some(mut layer) = read_layer(&file) else { continue }; + if scope == SettingsScope::Project { + remove_custom_schemes(&mut layer); + } + if let Err(e) = try_merge(&mut merged, layer) { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); } } @@ -205,6 +213,13 @@ impl SettingsStore { /// `null` removes the key from that layer. Unknown keys and values of the /// wrong type are rejected with `InvalidInput`; the file is left untouched. pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult { + if scope == SettingsScope::Project + && (key == "editor.customSchemes" + || key.starts_with("editor.customSchemes.") + || (key == "editor" && value.get("customSchemes").is_some())) + { + return Err(IdeError::invalid("custom color schemes belong to user settings")); + } let file = self.file(scope).ok_or_else(|| IdeError::invalid("no project is open"))?.to_path_buf(); let segments: Vec<&str> = key.split('.').collect(); if segments.iter().any(|s| s.is_empty()) { @@ -242,7 +257,10 @@ impl SettingsStore { for (layer_scope, layer_file) in self.layers() { let is_target = layer_scope == scope; let layer = if is_target { parse_layer(&new_text).ok() } else { read_layer(&layer_file) }; - let Some(layer) = layer else { continue }; + let Some(mut layer) = layer else { continue }; + if layer_scope == SettingsScope::Project { + remove_custom_schemes(&mut layer); + } if let Err(e) = try_merge(&mut merged, layer) && is_target { @@ -316,6 +334,38 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn custom_color_schemes_are_user_settings_and_survive_reload() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("cfg"); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(proj.join(".openidea")).unwrap(); + let store = SettingsStore::load(&cfg, Some(&proj)); + let schemes = json!({"custom-forest": {"name": "Forest", "parent": "dark", "attributes": { + "keyword": {"foreground": "#99dd77", "bold": true} + }}}); + store.set(SettingsScope::Global, "editor.customSchemes", schemes.clone()).unwrap(); + store.set(SettingsScope::Global, "editor.colorScheme", json!("custom-forest")).unwrap(); + let loaded = SettingsStore::load(&cfg, Some(&proj)).effective(); + assert_eq!(loaded.editor.color_scheme, "custom-forest"); + assert_eq!(serde_json::to_value(&loaded.editor.custom_schemes).unwrap(), schemes); + assert!(store.set(SettingsScope::Project, "editor.customSchemes", schemes.clone()).is_err()); + assert!( + store + .set(SettingsScope::Project, "editor.customSchemes.custom-other", schemes["custom-forest"].clone()) + .is_err() + ); + assert!(store.set(SettingsScope::Project, "editor", json!({"customSchemes": schemes})).is_err()); + std::fs::write( + proj.join(".openidea/settings.toml"), + "[editor.customSchemes.custom-forest]\nname = \"Project\"\nparent = \"light\"\nattributes = {}\n", + ) + .unwrap(); + assert_eq!(store.reload().editor.custom_schemes["custom-forest"].name, "Forest"); + store.set(SettingsScope::Global, "editor.customSchemes", json!({})).unwrap(); + assert!(SettingsStore::load(&cfg, Some(&proj)).effective().editor.custom_schemes.is_empty()); + } + #[test] fn layering_and_fallback() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/bindings.ts b/src/bindings.ts index b34885d05..501a2116e 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1992,6 +1992,23 @@ export type CreatePullRequest = { push: boolean, }; +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme = CustomColorScheme_Serialize | CustomColorScheme_Deserialize; + +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme_Deserialize = { + name: string, + parent: string, + attributes: { [key in string]: TextAttributesOverride_Deserialize }, +}; + +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme_Serialize = { + name: string, + parent: string, + attributes: { [key in string]: TextAttributesOverride_Serialize }, +}; + /** A named profile made with Copy. */ export type CustomInspectionProfile = { name?: string, @@ -2738,6 +2755,8 @@ export type EditorSettings_Deserialize = { formatOnSave?: boolean, /** Editor colour scheme id; `theme-default` follows the IDE theme. */ colorScheme?: string, + /** Imported editor schemes, stored in user settings. */ + customSchemes?: { [key in string]: CustomColorScheme_Deserialize }, /** Color Scheme edits: scheme id → attribute id → override. */ colorOverrides?: { [key in string]: { [key in string]: TextAttributesOverride_Deserialize } }, /** @@ -2785,6 +2804,8 @@ export type EditorSettings_Serialize = { formatOnSave: boolean, /** Editor colour scheme id; `theme-default` follows the IDE theme. */ colorScheme: string, + /** Imported editor schemes, stored in user settings. */ + customSchemes: { [key in string]: CustomColorScheme_Serialize }, /** Color Scheme edits: scheme id → attribute id → override. */ colorOverrides: { [key in string]: { [key in string]: TextAttributesOverride_Serialize } }, /** diff --git a/src/editor/core/colorSchemeRuntime.ts b/src/editor/core/colorSchemeRuntime.ts index b94600d42..d6aa7221f 100644 --- a/src/editor/core/colorSchemeRuntime.ts +++ b/src/editor/core/colorSchemeRuntime.ts @@ -16,7 +16,7 @@ export function installColorScheme(): () => void { const apply = () => { const settings = useProject.getState().settings; const prefs = editorPrefs(settings); - const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides); + const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides, undefined, prefs.customSchemes); if (el.textContent !== css) el.textContent = css; for (const [name, value] of Object.entries(fontWeightVars(prefs.fontWeight, prefs.boldFontWeight))) if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value); diff --git a/src/features/settings/__tests__/colorScheme.test.tsx b/src/features/settings/__tests__/colorScheme.test.tsx new file mode 100644 index 000000000..4d9a40c4d --- /dev/null +++ b/src/features/settings/__tests__/colorScheme.test.tsx @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { executeAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import { DEFAULT_SETTINGS } from "../model/schema"; +import { openSettings, stage, stagedValue, useSettingsDialog } from "../model/staging"; +import { PageContext } from "../components/context"; +import { COLOR_SCHEME_PAGE } from "../pages/editor/colorScheme"; + +const mocks = vi.hoisted(() => ({ + open: vi.fn(), + save: vi.fn(), + read: vi.fn(), + write: vi.fn(), + ask: vi.fn(), + confirm: vi.fn(), + error: vi.fn(), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open, save: mocks.save })); +vi.mock("@/workbench/messages", () => ({ ask: mocks.ask, confirm: mocks.confirm, showError: mocks.error })); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { ...actual, commands: { ...actual.commands, fsReadText: mocks.read, fsCreateFileBytes: mocks.write } }; +}); +const value = (key: string) => stagedValue(useSettingsDialog.getState(), "global", `editor.${key}`); +const run = (name: string) => + act(async () => { + await executeAction(`editor.colorScheme.${name}`); + }); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.open.mockResolvedValue("/schemes/forest.icls"); + mocks.save.mockResolvedValue("/schemes/export.icls"); + mocks.read.mockResolvedValue({ + status: "ok", + data: readFileSync("src/platform/__fixtures__/colorSchemes/forest.icls", "utf8"), + }); + mocks.write.mockResolvedValue({ status: "ok", data: null }); + useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null }); + openSettings("editor.colorScheme"); + const Page = COLOR_SCHEME_PAGE.component!; + render( + + + , + ); +}); + +describe("scheme actions", () => { + it("imports and selects in global scope, exports and confirms deletion", async () => { + await run("import"); + const id = value("colorScheme") as string; + expect(id).toMatch(/^custom-/); + expect(screen.getByRole("status")).toHaveTextContent("Applied 16 values; skipped 2"); + expect(Object.values(useSettingsDialog.getState().staged).every((s) => s.scope === "global")).toBe(true); + await run("export"); + expect(mocks.write).toHaveBeenCalledWith("/schemes/export.icls", expect.any(Array)); + expect(new TextDecoder().decode(new Uint8Array(mocks.write.mock.calls[0]![1]))).toContain( + 'name="Forest & Moss"', + ); + mocks.confirm.mockResolvedValueOnce(false); + await run("delete"); + expect(value("colorScheme")).toBe(id); + mocks.confirm.mockResolvedValueOnce(true); + await run("delete"); + expect(value("colorScheme")).toBe("theme-default"); + expect(value("customSchemes")).toEqual({}); + }); + + it("accepts file paths from action callers and keeps the same import/export flow", async () => { + await act(async () => { + await executeAction("editor.colorScheme.import", "/chosen/custom.icls"); + }); + expect(mocks.open).not.toHaveBeenCalled(); + expect(mocks.read).toHaveBeenCalledWith("/chosen/custom.icls"); + expect(value("colorScheme")).toMatch(/^custom-/); + await act(async () => { + await executeAction("editor.colorScheme.export", "/chosen/custom-export.icls"); + }); + expect(mocks.save).not.toHaveBeenCalled(); + expect(mocks.write).toHaveBeenCalledWith("/chosen/custom-export.icls", expect.any(Array)); + }); + + it("supports cancel, keep both and overwrite collisions", async () => { + await run("import"); + const first = value("colorScheme"); + mocks.ask.mockResolvedValueOnce({ button: "cancel" }); + await run("import"); + expect(value("colorScheme")).toBe(first); + mocks.ask.mockResolvedValueOnce({ button: "both" }); + await run("import"); + expect(Object.values(value("customSchemes") as Record).map((s) => s.name)).toEqual([ + "Forest & Moss", + "Forest & Moss (2)", + ]); + act(() => + stage("editor.colorScheme", "global", "editor.colorOverrides", { + [first as string]: { keyword: { foreground: "#ff0000" } }, + }), + ); + mocks.ask.mockResolvedValueOnce({ button: "overwrite" }); + await run("import"); + expect(value("colorScheme")).toBe(first); + expect(value("colorOverrides")).toEqual({}); + expect(Object.keys(value("customSchemes") as object)).toHaveLength(2); + }); + + it("shows a malformed-file error without staging any changes", async () => { + mocks.read.mockResolvedValueOnce({ status: "ok", data: '' }); + await run("import"); + await waitFor(() => expect(mocks.error).toHaveBeenCalledWith("Color Scheme", "Invalid .icls color scheme")); + expect(useSettingsDialog.getState().staged).toEqual({}); + }); +}); diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 5b0a5fb66..f44d76c7a 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -10,7 +10,7 @@ import { useId, useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; import { shortcutLabel } from "@/platform/keymap"; import { isTauri } from "@/platform/ipc"; -import { SCHEMES as EDITOR_SCHEMES } from "@/platform/colorScheme"; +import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme"; import { formatTimestamp } from "@/platform/dateFormat"; import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui"; import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore"; @@ -117,7 +117,8 @@ const SCHEMES: SelectOption[] = [ function ColorSchemeRow() { const [scheme, setScheme] = useSetting("editor.colorScheme"); - const options = SCHEMES.some((s) => s.value === scheme) ? SCHEMES : [...SCHEMES, { value: scheme, label: scheme }]; + const [customs] = useSetting("editor.customSchemes", "global"); + const options = [SCHEMES[0]!, ...listSchemes(customSchemes(customs)).map((s) => ({ value: s.id, label: s.name }))]; return ( diff --git a/src/features/settings/pages/editor/Editor.module.css b/src/features/settings/pages/editor/Editor.module.css index a2dfc3292..2f76363b0 100644 --- a/src/features/settings/pages/editor/Editor.module.css +++ b/src/features/settings/pages/editor/Editor.module.css @@ -148,6 +148,7 @@ /* Color Scheme page */ .schemeRow { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 6b0557aee..75aa54df1 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -4,32 +4,32 @@ * italic / effects editor, and a live preview. Edits are staged in * `editor.colorOverrides[scheme][attribute]`; `editor/core` injects them. */ -import { useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Button, Checkbox, Input, Tree, type SelectOption, T } from "@/ui"; -import { fontWeightVars, type TextAttributesOverride } from "@/platform/editorSettings"; +import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride } from "@/platform/editorSettings"; import { ATTRIBUTES, SCHEMES, - boldWeight, + overrideDeclarations, effectiveScheme, - schemeTokens, + resolvedSchemeTokens, + listSchemes, + customSchemes, type AttributeDef, - type SchemeId, } from "@/platform/colorScheme"; import { HEX_COLOR } from "../../model/schema"; import { Row, Select } from "../../components/Form"; import { useSetting } from "../../components/context"; import { editorPage } from "./kit"; import styles from "./Editor.module.css"; +import { executeAction, registerActions } from "@/platform/actions"; +import { call, commands } from "@/platform/ipc"; +import { ask, confirm, showError } from "@/workbench/messages"; +import { parseIcls, importScheme, deleteScheme, exportIcls } from "@/platform/icls"; import { tx } from "@/platform/i18n"; type Overrides = Record>; -const SCHEME_OPTIONS: SelectOption[] = [ - { value: "theme-default", label: "Theme default" }, - ...SCHEMES.map((s) => ({ value: s.id, label: s.name })), -]; - interface Node { key: string; label: string; @@ -56,9 +56,14 @@ function buildTree(): Node[] { const TREE = buildTree(); /** CSS custom properties of a scheme plus pending overrides (for the preview). */ -function previewVars(id: SchemeId, o: Record | undefined): CSSProperties { +function previewVars( + id: string, + o: Record | undefined, + customs: CustomColorSchemes, +): CSSProperties { const vars: Record = {}; - for (const [k, v] of schemeTokens(id)) + const tokens = resolvedSchemeTokens(id, customs); + for (const [k, v] of tokens) if ( k.startsWith("--syn-") || k.startsWith("--fg-") || @@ -67,21 +72,8 @@ function previewVars(id: SchemeId, o: Record | u k === "--caret" ) vars[k] = v; - vars["--bg-editor"] = schemeTokens(id).get("--bg-editor") ?? schemeTokens(id).get("--bg-island") ?? ""; - for (const attr of ATTRIBUTES) { - const ov = o?.[attr.id]; - if (!ov) continue; - if (ov.foreground && attr.fg) vars[attr.fg] = ov.foreground; - if (ov.background && attr.bg) vars[attr.bg] = ov.background; - if (attr.token) { - if (ov.bold !== undefined) vars[`--syn-${attr.token}-bold`] = boldWeight(ov.bold); - if (ov.italic !== undefined) vars[`--syn-${attr.token}-italic`] = ov.italic ? "italic" : "normal"; - if (ov.background) vars[`--syn-${attr.token}-bg`] = ov.background; - if (ov.effect && ov.effect !== "none") - vars[`--syn-${attr.token}-effect`] = - `${ov.effect === "strikeout" ? "line-through" : ov.effect === "wave" ? "underline wavy" : "underline"} ${ov.effectColor ?? "currentColor"}`; - } - } + vars["--bg-editor"] = tokens.get("--bg-editor") ?? tokens.get("--bg-island") ?? ""; + for (const [key, value] of overrideDeclarations(o)) vars[key] = value; return vars as CSSProperties; } @@ -270,17 +262,21 @@ function ColorRow({ } function ColorSchemePage() { - const [scheme, setScheme] = useSetting("editor.colorScheme"); - const [overrides, setOverrides] = useSetting("editor.colorOverrides"); + const [scheme, setScheme] = useSetting("editor.colorScheme", "global"); + const [overrides, setOverrides] = useSetting("editor.colorOverrides", "global"); const [weight] = useSetting("editor.fontWeight"); const [boldFontWeight] = useSetting("editor.boldFontWeight"); + const [rawCustoms, setCustoms] = useSetting("editor.customSchemes", "global"); + const customs = useMemo(() => customSchemes(rawCustoms), [rawCustoms]); + const [report, setReport] = useState(""); + const [busy, setBusy] = useState(false); const [selected, setSelected] = useState("keyword"); - const id = effectiveScheme(scheme); + const id = effectiveScheme(scheme, undefined, customs); const mine = overrides?.[id]; const attr = ATTRIBUTES.find((a) => a.id === selected); const vars = useMemo( - () => ({ ...previewVars(id, mine), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, - [id, mine, weight, boldFontWeight], + () => ({ ...previewVars(id, mine, customs), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, + [id, mine, customs, weight, boldFontWeight], ); const update = (attrId: string, v: TextAttributesOverride | undefined) => { const next: Overrides = { ...(overrides ?? {}) }; @@ -291,13 +287,128 @@ function ColorSchemePage() { else delete next[id]; setOverrides(next); }; - const schemeName = SCHEMES.find((s) => s.id === id)?.name ?? id; + const schemes = listSchemes(customs); + const schemeName = schemes.find((s) => s.id === id)?.name ?? id; + const options: SelectOption[] = [ + { value: "theme-default", label: "Theme default" }, + ...schemes.map((s) => ({ value: s.id, label: s.name })), + ]; + const handlers = useRef Promise>>({}); + useEffect(() => { + handlers.current = { + import: async (file) => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const path = + file ?? + (await open({ + multiple: false, + filters: [{ name: tx("IntelliJ Color Scheme"), extensions: ["icls"] }], + })); + if (!path) return; + const parsed = parseIcls(await call(commands.fsReadText(path))); + let overwrite = false; + if (schemes.some((s) => s.name === parsed.scheme.name)) { + const canOverwrite = Object.values(customs).some((s) => s.name === parsed.scheme.name); + const { button } = await ask({ + title: tx("Import Scheme"), + body: tx`A scheme named '${parsed.scheme.name}' already exists.`, + buttons: [ + ...(canOverwrite ? [{ id: "overwrite", label: tx("Overwrite") }] : []), + { id: "both", label: tx("Keep Both"), primary: true }, + ], + }); + if (button !== "overwrite" && button !== "both") return; + overwrite = button === "overwrite"; + } + const next = importScheme(customs, parsed.scheme, overwrite); + setCustoms(next.customs); + if (overwrite) + setOverrides(Object.fromEntries(Object.entries(overrides ?? {}).filter(([key]) => key !== next.id))); + setScheme(next.id); + setReport(tx`Applied ${parsed.applied} values; skipped ${parsed.skipped} unsupported or invalid values.`); + }, + export: async (file) => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = + file ?? + (await save({ + defaultPath: `${schemeName.replace(/[/\\:*?"<>|]/g, "_")}.icls`, + filters: [{ name: tx("IntelliJ Color Scheme"), extensions: ["icls"] }], + })); + if (!path) return; + const bytes = Array.from(new TextEncoder().encode(exportIcls(id, customs, mine))); + // The save dialog already confirmed replacing an existing file. + await call(commands.fsDelete(path)).catch(() => {}); + await call(commands.fsCreateFileBytes(path, bytes)); + setReport(tx("Scheme exported.")); + }, + delete: async () => { + if ( + !customs[id] || + !(await confirm({ + title: tx("Delete Scheme"), + message: tx`Delete color scheme '${schemeName}'?`, + ok: tx("Delete"), + })) + ) + return; + const next = deleteScheme(customs, overrides ?? {}, scheme, id); + setCustoms(next.customs); + setOverrides(next.overrides); + setScheme(next.selected); + setReport(""); + }, + }; + }); + const pending = useRef(false); + useEffect( + () => + registerActions( + (["import", "export", "delete"] as const).map((name) => ({ + id: `editor.colorScheme.${name}`, + title: name === "import" ? "Import Scheme" : name === "export" ? "Export Scheme" : "Delete Scheme", + category: "Editor", + run: async ({ arg }) => { + if (pending.current) return; + pending.current = true; + setBusy(true); + try { + await handlers.current[name]!(typeof arg === "string" ? arg : undefined); + } catch (error) { + await showError( + tx("Color Scheme"), + error instanceof Error && error.message === "Invalid .icls color scheme" + ? tx("Invalid .icls color scheme") + : String(error), + ); + } finally { + pending.current = false; + setBusy(false); + } + }, + })), + ), + [], + ); return (

- + + +
+ {report && ( +
+ {report} +
+ )} {scheme === "theme-default" && (
{tx`Follows the IDE theme: currently ${schemeName}.`}
)} @@ -364,6 +480,7 @@ export const COLOR_SCHEME_PAGE = editorPage({ id: "editor.colorScheme", title: "Color Scheme", order: 23, + projectScope: false, keywords: [ "colors", "syntax highlighting", diff --git a/src/platform/__fixtures__/colorSchemes/daylight.icls b/src/platform/__fixtures__/colorSchemes/daylight.icls new file mode 100644 index 000000000..95b126d46 --- /dev/null +++ b/src/platform/__fixtures__/colorSchemes/daylight.icls @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/platform/__fixtures__/colorSchemes/forest.icls b/src/platform/__fixtures__/colorSchemes/forest.icls new file mode 100644 index 000000000..b38a1aaee --- /dev/null +++ b/src/platform/__fixtures__/colorSchemes/forest.icls @@ -0,0 +1,21 @@ + + idea + + + + + + + + + + + + + diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index 77f504379..b2c2c83b0 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -21,6 +21,7 @@ export const CLIENT_SETTINGS_KEYS = [ "editor.boldFontWeight", "editor.colorScheme", "editor.colorOverrides", + "editor.customSchemes", "editor.semanticHighlighting", "editor.rainbowBrackets", "editor.softWrap", diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index 3da62ea98..8542617e3 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -8,7 +8,7 @@ * Attribute ids are stable (they key `editor.colorOverrides[scheme][id]`). */ import tokensCss from "@/styles/tokens.css?raw"; -import type { TextAttributesOverride } from "./editorSettings"; +import type { CustomColorSchemes, TextAttributesOverride } from "./editorSettings"; import { BUILTIN_THEMES, isBuiltinTheme, themeKind, type BuiltinThemeId } from "./themes"; export type SchemeId = BuiltinThemeId; @@ -253,8 +253,9 @@ export function schemeTokens(id: SchemeId, css: string = source): Vars { export function effectiveScheme( setting: string | undefined, root: HTMLElement | null = typeof document !== "undefined" ? document.documentElement : null, -): SchemeId { - if (isBuiltinTheme(setting)) return setting; + customs: CustomColorSchemes = {}, +): string { + if (isBuiltinTheme(setting) || (setting && Object.hasOwn(customs, setting))) return setting; return themeScheme(root); } @@ -289,10 +290,10 @@ export function overrideDeclarations( for (const attr of ATTRIBUTES) { const o = overrides[attr.id]; if (!o) continue; - if (o.foreground && attr.fg) out.push([attr.fg, o.foreground]); - if (o.background && attr.bg) out.push([attr.bg, o.background]); - if (o.effectColor && attr.effect) out.push([attr.effect, o.effectColor]); - if (attr.effect && o.effectColor) { + if (isHexColor(o.foreground) && attr.fg) out.push([attr.fg, o.foreground]); + if (isHexColor(o.background) && attr.bg) out.push([attr.bg, o.background]); + if (isHexColor(o.effectColor) && attr.effect) out.push([attr.effect, o.effectColor]); + if (attr.effect && isHexColor(o.effectColor)) { if (attr.id === "error") out.push(["--editor-error-decoration", `underline wavy ${o.effectColor}`]); if (attr.id === "warning") out.push(["--editor-warning-decoration", `underline wavy ${o.effectColor}`]); } @@ -303,13 +304,58 @@ export function overrideDeclarations( out.push([`--syn-${t}-italic`, o.italic ? "italic" : "normal"]); if (t === "comment") out.push(["--syn-comment-style", o.italic ? "italic" : "normal"]); } - if (o.background) out.push([`--syn-${t}-bg`, o.background]); - if (o.effect) out.push([`--syn-${t}-effect`, EFFECT_CSS[o.effect]?.(o.effectColor ?? "currentColor") ?? "none"]); + if (isHexColor(o.background)) out.push([`--syn-${t}-bg`, o.background]); + if (o.effect) + out.push([ + `--syn-${t}-effect`, + EFFECT_CSS[o.effect]?.(isHexColor(o.effectColor) ? o.effectColor : "currentColor") ?? "none", + ]); } } return out; } +export const isHexColor = (value: unknown): value is string => + typeof value === "string" && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value); + +/** Validate persisted imports too: settings files are user editable. */ +export function customSchemes(raw: unknown): CustomColorSchemes { + const out: CustomColorSchemes = {}; + if (!raw || typeof raw !== "object") return out; + for (const [id, value] of Object.entries(raw)) { + if (!/^custom-[\w-]+$/.test(id) || !value || typeof value !== "object") continue; + const v = value as Record; + if (typeof v.name !== "string" || !v.name.trim() || (v.parent !== "light" && v.parent !== "dark")) continue; + const attributes: Record = {}; + if (v.attributes && typeof v.attributes === "object") { + for (const attr of ATTRIBUTES) { + const a = (v.attributes as Record)[attr.id]; + if (!a || typeof a !== "object") continue; + const safe: TextAttributesOverride = {}; + for (const key of ["foreground", "background", "effectColor"] as const) + if (isHexColor(a[key])) safe[key] = a[key]; + for (const key of ["bold", "italic"] as const) if (typeof a[key] === "boolean") safe[key] = a[key]; + if (a.effect && Object.hasOwn(EFFECT_CSS, a.effect)) safe.effect = a.effect; + attributes[attr.id] = safe; + } + } + out[id] = { name: v.name, parent: v.parent, attributes }; + } + return out; +} + +export function listSchemes(customs: CustomColorSchemes = {}) { + return [...SCHEMES, ...Object.entries(customSchemes(customs)).map(([id, s]) => ({ id, name: s.name }))]; +} + +export function resolvedSchemeTokens(id: string, customs: CustomColorSchemes = {}): Vars { + const custom = customSchemes(customs)[id]; + const base = isBuiltinTheme(id) ? id : (custom?.parent ?? "light"); + const tokens = new Map(schemeTokens(base)); + for (const [key, value] of overrideDeclarations(custom?.attributes)) tokens.set(key, value); + return tokens; +} + const SAFE = /^[^;{}<>]*$/; /** Stylesheet text for the chosen scheme and its overrides ("" when the theme's own is used unchanged). */ @@ -317,14 +363,16 @@ export function schemeCss( setting: string | undefined, overrides: Record> | undefined, root?: HTMLElement | null, + customs: CustomColorSchemes = {}, ): string { - const id = effectiveScheme(setting, root); + const valid = customSchemes(customs); + const id = effectiveScheme(setting, root, valid); const decls: Array<[string, string]> = []; // A plugin theme brings its own editor colours: an explicit scheme replaces them even on the same base. const el = root === undefined ? (typeof document !== "undefined" ? document.documentElement : null) : root; const pluginTheme = !!el?.dataset.pluginTheme && id === setting; if (id !== themeScheme(root) || pluginTheme) { - const tokens = schemeTokens(id); + const tokens = resolvedSchemeTokens(id, valid); for (const [k, v] of tokens) if (SCHEME_VARS.has(k) || k.startsWith("--syn-")) decls.push([k, v]); // The editor surface follows the scheme, not the island colour of the UI theme. const bg = tokens.get("--bg-editor") ?? tokens.get("--bg-island"); diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index 1f1b3f99b..4443d8897 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -15,6 +15,14 @@ export interface TextAttributesOverride { effectColor?: string; } +export interface CustomColorScheme { + name: string; + parent: "dark" | "light"; + attributes: Record; +} + +export type CustomColorSchemes = Record; + export interface LanguageCodeStyle { useTab?: boolean; tabSize?: number; @@ -55,6 +63,7 @@ export function lineCommentStyleFor( } export const EDITOR_EXTRA_DEFAULTS = { + customSchemes: {} as CustomColorSchemes, colorOverrides: {} as Record>, /** Color Scheme › Language Defaults › Semantic highlighting (rainbow locals / parameters). */ semanticHighlighting: false, diff --git a/src/platform/icls.test.ts b/src/platform/icls.test.ts new file mode 100644 index 000000000..675a23f24 --- /dev/null +++ b/src/platform/icls.test.ts @@ -0,0 +1,166 @@ +import { readFileSync } from "node:fs"; +import { beforeAll, describe, expect, it } from "vitest"; +import { _setSchemeSource, customSchemes, effectiveScheme, listSchemes, SCHEMES, schemeCss } from "./colorScheme"; +import { deleteScheme, exportIcls, importScheme, parseIcls } from "./icls"; +import { validateSettings } from "@/features/settings/model/schema"; +import { colorSchemeItems } from "@/workbench/quickSwitch"; +import { useProject } from "./project"; +import type { Settings } from "./ipc"; +import { isClientSetting } from "./clientSettings"; + +const fixture = (name: string) => readFileSync(`src/platform/__fixtures__/colorSchemes/${name}.icls`, "utf8"); +beforeAll(() => _setSchemeSource(readFileSync("src/styles/tokens.css", "utf8"))); + +describe(".icls schemes", () => { + it("maps defaults, Java/Kotlin, editor surfaces, search and diagnostics with counts", () => { + const { scheme, applied, skipped } = parseIcls(fixture("forest")); + expect(scheme.name).toBe("Forest & Moss"); + expect(scheme.parent).toBe("dark"); + expect(scheme.attributes.keyword).toEqual({ foreground: "#99dd77", bold: true, italic: false }); + expect(scheme.attributes.functionDeclaration).toEqual({ foreground: "#e0c080", bold: false, italic: true }); + expect(scheme.attributes.text).toEqual({ foreground: "#d8ebd8", background: "#18281e" }); + expect(scheme.attributes.caret?.foreground).toBe("#f0e080"); + expect(scheme.attributes.selection?.background).toBe("#406040"); + expect(scheme.attributes.gutter?.background).toBe("#142018"); + expect(scheme.attributes.searchResult?.background).toBe("#6a5530"); + expect(scheme.attributes.error).toEqual({ effect: "wave", effectColor: "#ff6655" }); + expect([applied, skipped]).toEqual([16, 2]); + }); + + it.each([ + "", + "', + "", + '', + ']>', + ])("rejects malformed XML without mutating schemes: %s", (xml) => { + const customs = importScheme({}, parseIcls(fixture("forest")).scheme).customs; + const before = structuredClone(customs); + expect(() => parseIcls(xml)).toThrow("Invalid .icls color scheme"); + expect(customs).toEqual(before); + }); + + it.each([ + ["Darcula", "ffffff", "dark"], + ["Default", "000000", "light"], + ["Other", "101820", "dark"], + ["Other", "fefefe", "light"], + ["", "0", "dark"], + ])("maps parent %s and background %s to %s", (parent, bg, expected) => { + expect( + parseIcls( + ``, + ).scheme.parent, + ).toBe(expected); + }); + + it("ignores CSS injection on import and from edited settings", () => { + const { scheme, applied, skipped } = parseIcls( + '', + ); + expect([applied, skipped]).toEqual([0, 2]); + expect(scheme.attributes).toEqual({}); + const customs = { + "custom-test": { + name: "Test", + parent: "dark", + attributes: { + keyword: { foreground: "red", background: "url(https://evil)", effectColor: "var(--bad)", effect: "wave" }, + }, + }, + }; + const valid = customSchemes(customs); + expect(valid["custom-test"]?.attributes.keyword).toEqual({ effect: "wave" }); + const css = schemeCss("custom-test", undefined, null, valid); + expect(css).not.toMatch(/evil|url\(|--bad/); + expect(schemeCss("dark", { dark: { keyword: { foreground: "url(https://evil)" } } }, null)).not.toContain("evil"); + }); + + it("keeps both, overwrites a custom, and never overwrites a built-in", () => { + const parsed = parseIcls(fixture("forest")).scheme; + const first = importScheme({}, parsed); + const both = importScheme(first.customs, parsed); + expect(both.customs[both.id]?.name).toBe("Forest & Moss (2)"); + const third = importScheme(both.customs, parsed); + expect(third.customs[third.id]?.name).toBe("Forest & Moss (3)"); + const overwrite = importScheme(both.customs, { ...parsed, parent: "light" }, true); + expect(overwrite.id).toBe(first.id); + expect(overwrite.customs[first.id]?.parent).toBe("light"); + const builtin = importScheme({}, { ...parsed, name: SCHEMES[0]!.name }, true); + expect(builtin.customs[builtin.id]?.name).toBe(`${SCHEMES[0]!.name} (2)`); + expect(SCHEMES.some((s) => s.id === builtin.id)).toBe(false); + }); + + it("uses imported values under either theme, overrides them, and falls back after deletion", () => { + const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme); + const overrides = { [id]: { keyword: { foreground: "#abcdef" } } }; + for (const theme of ["light", "dark"]) { + const root = document.createElement("div"); + root.dataset.theme = theme; + const css = schemeCss(id, overrides, root, customs); + expect(css).toContain("--bg-editor: #18281e !important"); + expect(css.lastIndexOf("--syn-keyword: #abcdef")).toBeGreaterThan(css.indexOf("--syn-keyword: #99dd77")); + expect(effectiveScheme("theme-default", root, customs)).toBe(theme); + const deleted = deleteScheme(customs, overrides, id, id); + expect(deleted).toEqual({ customs: {}, overrides: {}, selected: "theme-default" }); + expect(effectiveScheme(id, root, deleted.customs)).toBe(theme); + expect(schemeCss(deleted.selected, deleted.overrides, root, deleted.customs)).toBe(""); + expect(deleteScheme(customs, overrides, "light", id).selected).toBe("light"); + } + }); + + it.each(["forest", "daylight"])("exports visible values and re-imports %s with overrides", (name) => { + const { id, customs } = importScheme({}, parseIcls(fixture(name)).scheme); + const override = { + keyword: { foreground: "#123456", bold: false, italic: true }, + error: { effectColor: "#aa1122" }, + }; + const xml = exportIcls(id, customs, override); + const imported = parseIcls(xml).scheme; + expect(imported.name).toBe(customs[id]!.name); + expect(imported.parent).toBe(customs[id]!.parent); + expect(imported.attributes.keyword).toEqual(override.keyword); + expect(imported.attributes.text).toEqual(customs[id]!.attributes.text); + expect(imported.attributes.error?.effectColor).toBe("#aa1122"); + }); + + it("exports shorthand colors as full RGB", () => { + const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme); + expect( + parseIcls(exportIcls(id, customs, { keyword: { foreground: "#abc" } })).scheme.attributes.keyword?.foreground, + ).toBe("#aabbcc"); + }); + + it("round trips custom settings and lists them without changing built-ins", () => { + const { id, customs } = importScheme({}, parseIcls(fixture("forest")).scheme); + const settings = validateSettings( + JSON.parse( + JSON.stringify({ + editor: { + customSchemes: customs, + colorScheme: id, + colorOverrides: { [id]: { string: { foreground: "#123456" } } }, + }, + }), + ), + ); + expect(settings.editor.customSchemes).toEqual(customs); + expect(settings.editor.colorScheme).toBe(id); + expect(listSchemes(customs).at(-1)?.name).toBe("Forest & Moss"); + expect(listSchemes(customs)).toHaveLength(SCHEMES.length + 1); + expect(isClientSetting("editor.customSchemes")).toBe(true); + const previous = useProject.getState().settings; + try { + useProject.setState({ settings: settings as Settings }); + const items = colorSchemeItems(id); + expect(items.find((s) => "id" in s && s.id === `scheme.${id}`)).toMatchObject({ + label: "Forest & Moss", + checked: true, + }); + expect(items.find((s) => "id" in s && s.id === "scheme.theme-default")).toMatchObject({ checked: false }); + } finally { + useProject.setState({ settings: previous }); + } + }); +}); diff --git a/src/platform/icls.ts b/src/platform/icls.ts new file mode 100644 index 000000000..3e6269f56 --- /dev/null +++ b/src/platform/icls.ts @@ -0,0 +1,309 @@ +/** IntelliJ editor schemes. Unsupported keys are counted, never copied into CSS. */ +import { ATTRIBUTES, customSchemes, isHexColor, listSchemes, resolvedSchemeTokens } from "./colorScheme"; +import { isBuiltinTheme, themeKind } from "./themes"; +import type { CustomColorScheme, CustomColorSchemes, TextAttributesOverride } from "./editorSettings"; + +// Many language keys share the editor's language-default tokens. +const ATTRIBUTE_NAMES: Record = { + TEXT: "text", + DEFAULT_TEXT: "text", + DEFAULT_KEYWORD: "keyword", + DEFAULT_IDENTIFIER: "identifier", + DEFAULT_LOCAL_VARIABLE: "identifier", + DEFAULT_PARAMETER: "parameter", + DEFAULT_CONSTANT: "constant", + DEFAULT_INSTANCE_FIELD: "field", + DEFAULT_STATIC_FIELD: "constant", + DEFAULT_FUNCTION_DECLARATION: "functionDeclaration", + DEFAULT_FUNCTION_CALL: "functionCall", + DEFAULT_PREDEFINED_SYMBOL: "predefined", + DEFAULT_CLASS_NAME: "className", + DEFAULT_INTERFACE_NAME: "className", + DEFAULT_NUMBER: "number", + DEFAULT_STRING: "string", + DEFAULT_VALID_STRING_ESCAPE: "escape", + DEFAULT_INVALID_STRING_ESCAPE: "invalid", + DEFAULT_LINE_COMMENT: "lineComment", + DEFAULT_BLOCK_COMMENT: "lineComment", + DEFAULT_DOC_COMMENT: "docComment", + DEFAULT_METADATA: "metadata", + DEFAULT_OPERATION_SIGN: "operator", + DEFAULT_BRACES: "operator", + DEFAULT_BRACKETS: "operator", + DEFAULT_PARENTHS: "operator", + DEFAULT_DOT: "operator", + DEFAULT_COMMA: "operator", + DEFAULT_SEMICOLON: "operator", + DEFAULT_MARKUP_TAG: "markupTag", + DEFAULT_MARKUP_ATTRIBUTE: "markupAttribute", + BAD_CHARACTER: "invalid", + TODO_DEFAULT_ATTRIBUTES: "todo", + SEARCH_RESULT_ATTRIBUTES: "searchResult", + TEXT_SEARCH_RESULT_ATTRIBUTES: "searchResultCurrent", + WRITE_SEARCH_RESULT_ATTRIBUTES: "identifierUnderCaretWrite", + IDENTIFIER_UNDER_CARET_ATTRIBUTES: "identifierUnderCaret", + MATCHED_BRACE_ATTRIBUTES: "matchedBrace", + ERRORS_ATTRIBUTES: "error", + WARNING_ATTRIBUTES: "warning", + WEAK_WARNING_ATTRIBUTES: "weakWarning", + NOT_USED_ELEMENT_ATTRIBUTES: "unused", + FOLDED_TEXT_ATTRIBUTES: "folded", + INLAY_DEFAULT: "inlay", + PY_SELF_PARAMETER: "pySelf", + PY_KEYWORD_ARGUMENT: "pyKeywordArg", + PY_DOC_STRING: "pyDocstring", + PY_BUILTIN_NAME: "pyBuiltin", + PY_DECORATOR: "pyDecorator", + MARKDOWN_HEADER_LEVEL_1: "mdHeading", + MARKDOWN_CODE_SPAN: "mdCode", + MARKDOWN_LINK_TEXT: "mdLink", + REGEXP: "regexp", +}; +const LANGUAGE_KEYS: Record = { + KEYWORD: "keyword", + NUMBER: "number", + STRING: "string", + STRING_TEXT: "string", + VALID_STRING_ESCAPE: "escape", + LINE_COMMENT: "lineComment", + BLOCK_COMMENT: "lineComment", + DOC_COMMENT: "docComment", + CLASS: "className", + CLASS_NAME: "className", + INTERFACE: "className", + ENUM: "className", + TYPE_PARAMETER: "className", + ANNOTATION_NAME: "metadata", + ANNOTATION: "metadata", + LOCAL_VARIABLE: "identifier", + PARAMETER: "parameter", + INSTANCE_FIELD: "field", + STATIC_FIELD: "constant", + STATIC_FINAL_FIELD: "constant", + PROPERTY: "field", + BACKING_FIELD_VARIABLE: "field", + FUNCTION_DECLARATION: "functionDeclaration", + METHOD_DECLARATION: "functionDeclaration", + FUNCTION_CALL: "functionCall", + METHOD_CALL: "functionCall", + STATIC_METHOD: "functionCall", + CONSTRUCTOR_CALL: "className", + OPERATION_SIGN: "operator", + BRACES: "operator", + BRACKETS: "operator", + PARENTH: "operator", + PARENTHS: "operator", +}; +for (const [name, id] of Object.entries(LANGUAGE_KEYS)) { + ATTRIBUTE_NAMES[`JAVA_${name}`] = id; + ATTRIBUTE_NAMES[`KOTLIN_${name}`] = id; + ATTRIBUTE_NAMES[name] = id; +} +Object.assign(ATTRIBUTE_NAMES, { + KOTLIN_MUTABLE_VARIABLE: "identifier", + KOTLIN_PACKAGE_PROPERTY: "field", + KOTLIN_SYNTHETIC_EXTENSION_PROPERTY: "field", + KOTLIN_EXTENSION_FUNCTION_CALL: "functionCall", + KOTLIN_NAMED_ARGUMENT: "parameter", + KOTLIN_OBJECT: "className", +}); + +const COLOR_NAMES: Record = { + CARET_COLOR: ["caret", "foreground"], + CARET_ROW_COLOR: ["caretRow", "background"], + SELECTION_BACKGROUND: ["selection", "background"], + INACTIVE_SELECTION_BACKGROUND: ["selectionInactive", "background"], + GUTTER_BACKGROUND: ["gutter", "background"], + LINE_NUMBERS_COLOR: ["lineNumber", "foreground"], + LINE_NUMBER_ON_CARET_ROW_COLOR: ["lineNumberCaret", "foreground"], + WHITESPACES: ["whitespaces", "foreground"], + INDENT_GUIDE: ["indentGuide", "foreground"], + RIGHT_MARGIN_COLOR: ["rightMargin", "foreground"], + METHOD_SEPARATORS_COLOR: ["methodSeparator", "foreground"], +}; +// Preserve editor attributes without a standard exchange name on our own round trip. +for (const attr of ATTRIBUTES) + if (!Object.values(ATTRIBUTE_NAMES).includes(attr.id)) ATTRIBUTE_NAMES[`OPENIDEA_${attr.id}`] = attr.id; + +const EFFECTS: Record = { + "0": "underline", + "1": "wave", + "3": "strikeout", + "4": "underline", +}; +const children = (el: Element, tag: string) => Array.from(el.children).filter((e) => e.tagName === tag); +const color = (v: string | null): string | undefined => + v && /^[0-9a-f]{1,6}$/i.test(v) ? `#${v.padStart(6, "0").toLowerCase()}` : undefined; + +export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: number; skipped: number } { + const doc = new DOMParser().parseFromString(xml, "application/xml"); + const root = doc.documentElement; + if ( + doc.querySelector("parsererror") || + doc.doctype || + root.tagName !== "scheme" || + !root.getAttribute("name")?.trim() + ) + throw new Error("Invalid .icls color scheme"); + const attributes: Record = {}; + let applied = 0; + let skipped = 0; + const merge = (id: string, value: TextAttributesOverride) => { + attributes[id] = { ...attributes[id], ...value }; + }; + for (const section of Array.from(root.children)) { + if (section.tagName === "colors") + for (const entry of children(section, "option")) { + const name = entry.getAttribute("name") ?? ""; + const target = Object.hasOwn(COLOR_NAMES, name) ? COLOR_NAMES[name] : undefined; + const c = color(entry.getAttribute("value")); + if (target && c) { + merge(target[0], { [target[1]]: c }); + applied++; + } else skipped++; + } + if (section.tagName === "attributes") + for (const entry of children(section, "option")) { + const name = entry.getAttribute("name") ?? ""; + const id = Object.hasOwn(ATTRIBUTE_NAMES, name) ? ATTRIBUTE_NAMES[name] : undefined; + if (!id) { + skipped++; + continue; + } + const value = children(entry, "value")[0]; + if (!value) { + skipped++; + continue; + } + for (const option of children(value, "option")) { + const name = option.getAttribute("name"); + const raw = option.getAttribute("value"); + const c = color(raw); + const field = + name === "FOREGROUND" + ? "foreground" + : name === "BACKGROUND" + ? "background" + : name === "EFFECT_COLOR" + ? "effectColor" + : undefined; + const attr = ATTRIBUTES.find((a) => a.id === id)!; + const supported = + field === "foreground" + ? !!attr.fg + : field === "background" + ? !!attr.bg || !!attr.token + : !!attr.effect || !!attr.token; + if (field && c && supported) { + merge(id, { [field]: c }); + applied++; + } else if (name === "FONT_TYPE" && attr.token && raw && /^[0-3]$/.test(raw)) { + merge(id, { bold: !!(Number(raw) & 1), italic: !!(Number(raw) & 2) }); + applied++; + } else if (name === "EFFECT_TYPE" && (attr.token || attr.effect) && raw && Object.hasOwn(EFFECTS, raw)) { + merge(id, { effect: EFFECTS[raw] }); + applied++; + } else skipped++; + } + } + } + const parentName = root.getAttribute("parent_scheme") ?? ""; + const bg = attributes.text?.background; + const rgb = bg ? [1, 3, 5].map((i) => parseInt(bg.slice(i, i + 2), 16)) : [255, 255, 255]; + const dark = rgb[0]! * 0.299 + rgb[1]! * 0.587 + rgb[2]! * 0.114 < 128; + const parent = /darcula|dark/i.test(parentName) + ? "dark" + : /default|light/i.test(parentName) + ? "light" + : dark + ? "dark" + : "light"; + return { scheme: { name: root.getAttribute("name")!.trim(), parent, attributes }, applied, skipped }; +} + +/** Name collisions include built-ins; imports never replace a built-in. */ +export function importScheme(customs: CustomColorSchemes, scheme: CustomColorScheme, overwrite = false) { + const existing = Object.entries(customs).find(([, s]) => s.name === scheme.name); + const names = new Set(listSchemes(customs).map((s) => s.name)); + let name = scheme.name; + if (!(overwrite && existing)) for (let i = 2; names.has(name); i++) name = `${scheme.name} (${i})`; + const id = overwrite && existing ? existing[0] : `custom-${crypto.randomUUID()}`; + return { id, customs: { ...customs, [id]: { ...scheme, name } } }; +} + +export function deleteScheme( + customs: CustomColorSchemes, + overrides: Record>, + selected: string, + id: string, +) { + return { + customs: Object.fromEntries(Object.entries(customs).filter(([key]) => key !== id)), + overrides: Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== id)), + selected: selected === id ? "theme-default" : selected, + }; +} + +const exportColor = (c: string) => + c.length === 4 + ? c + .slice(1) + .split("") + .map((ch) => ch + ch) + .join("") + : c.slice(1); +const escapeXml = (v: string) => + v.replace(/&/g, "&").replace(//g, ">"); +export function exportIcls( + id: string, + customs: CustomColorSchemes, + overrides: Record = {}, +): string { + const custom = customSchemes(customs)[id]; + const name = listSchemes(customs).find((s) => s.id === id)?.name ?? id; + const tokens = resolvedSchemeTokens(id, customs); + const values = (attr: (typeof ATTRIBUTES)[number]): TextAttributesOverride => ({ + ...(attr.token + ? { + bold: /bold|[6-9]00/.test(tokens.get(`--syn-${attr.token}-bold`) ?? ""), + italic: + (tokens.get(`--syn-${attr.token}-italic`) ?? + (attr.token === "comment" + ? tokens.get("--syn-comment-style") + : (attr.italicOnly && attr.defaultItalic !== false) || ["doc-comment", "docstring"].includes(attr.token) + ? "italic" + : "normal")) === "italic", + } + : {}), + ...(attr.fg && isHexColor(tokens.get(attr.fg)) ? { foreground: tokens.get(attr.fg) } : {}), + ...(attr.bg && isHexColor(tokens.get(attr.bg)) ? { background: tokens.get(attr.bg) } : {}), + ...(attr.effect && isHexColor(tokens.get(attr.effect)) ? { effectColor: tokens.get(attr.effect) } : {}), + ...custom?.attributes[attr.id], + ...overrides[attr.id], + }); + const colors: string[] = []; + const attributes: string[] = []; + for (const [key, [id, field]] of Object.entries(COLOR_NAMES)) { + const attr = ATTRIBUTES.find((a) => a.id === id); + const c = attr && values(attr)[field]; + if (isHexColor(c)) colors.push(` `); + } + const parent = custom?.parent ?? (isBuiltinTheme(id) ? themeKind(id) : "dark"); + return `\n \n${colors.join("\n")}\n \n \n${attributes.join("\n")}\n \n\n`; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 01c22f78a..3e6b4eadf 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5228,5 +5228,16 @@ "For clipboard images: {timestamp} becomes YYYYMMDD-HHmmss. Source file names are preserved.": "剪贴板图片中的 {timestamp} 替换为 YYYYMMDD-HHmmss。保留源文件名称。", "Image folder must be relative.": "图片文件夹必须使用相对路径。", "Image exceeds 16 MB.": "图片超过 16 MB。", - "Cannot insert image": "无法插入图片" + "Cannot insert image": "无法插入图片", + "Import Scheme": "导入配色方案", + "Export Scheme": "导出配色方案", + "Delete Scheme": "删除配色方案", + "Keep Both": "保留两个", + "Scheme exported.": "配色方案已导出。", + "Invalid .icls color scheme": "无效的 .icls 配色方案", + "IntelliJ Color Scheme": "IntelliJ 配色方案", + "A scheme named '{name}' already exists.": "名为“{name}”的配色方案已存在。", + "Applied {applied} values; skipped {skipped} unsupported or invalid values.": "已应用 {applied} 个值;跳过了 {skipped} 个不支持或无效的值。", + "Delete color scheme '{name}'?": "删除配色方案“{name}”?", + "Overwrite": "覆盖" } diff --git a/src/workbench/quickSwitch.ts b/src/workbench/quickSwitch.ts index bb1485e0a..23ec50899 100644 --- a/src/workbench/quickSwitch.ts +++ b/src/workbench/quickSwitch.ts @@ -3,7 +3,8 @@ * toolbar's gear menu and View › Quick Switch Scheme… (⌃`). */ import { call, commands, isTauri } from "@/platform/ipc"; -import { SCHEMES } from "@/platform/colorScheme"; +import { listSchemes } from "@/platform/colorScheme"; +import { editorPrefs } from "@/platform/editorSettings"; import { PRESET_TITLES, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import type { MenuItem } from "@/ui"; @@ -40,7 +41,8 @@ export function keymapItems(keymapId: string, customKeymaps: string[]): MenuItem } export function colorSchemeItems(current: string | undefined): MenuItem[] { - const value = current && SCHEMES.some((s) => s.id === current) ? current : "theme-default"; + const schemes = listSchemes(editorPrefs().customSchemes); + const value = current && schemes.some((s) => s.id === current) ? current : "theme-default"; return [ { id: "scheme.theme-default", @@ -48,7 +50,7 @@ export function colorSchemeItems(current: string | undefined): MenuItem[] { checked: value === "theme-default", run: () => void setGlobal("editor.colorScheme", "theme-default", "color scheme"), }, - ...SCHEMES.map((s) => ({ + ...schemes.map((s) => ({ id: `scheme.${s.id}`, label: s.name, checked: s.id === value, From 290abd303e0d879d1cf1e1446db4b050ed2d7df0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:01:54 +0800 Subject: [PATCH 07/25] feat(deployment): deploy to a local or mounted folder (#521) Deployment only supported SFTP, so a web root on the same machine or on a mounted share still needed an SSH server. Servers now have a type: SFTP or Local or mounted folder. A local server has a folder and optional web URL and shares upload on save, upload and download, delete, compare and sync, excludes, mappings and the Remote Host view with SFTP through one backend filesystem. Copies never read the whole file into memory, a file is never copied onto itself, and Test Connection checks the folder exists and is writable. Existing servers stay SFTP. Fixes #467 --- crates/ide-core/src/remote_settings.rs | 31 +- crates/ide-host/src/ops/deploy.rs | 200 +++++++++++++ crates/ide-host/src/ops/mod.rs | 2 + crates/ide-host/tests/rpc.rs | 66 +++++ crates/ide-remote/src/deploy.rs | 4 +- crates/ide-remote/src/service.rs | 86 ++++-- crates/ide-remote/tests/local.rs | 211 ++++++++++++++ crates/ide-ssh/src/filesystem.rs | 274 ++++++++++++++++++ crates/ide-ssh/src/lib.rs | 4 +- crates/ide-ssh/src/sftp.rs | 6 +- src-tauri/src/commands/deploy.rs | 160 ++++------ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/remote_dev.rs | 11 + src/bindings.ts | 32 +- src/features/deployment/actions.tsx | 18 +- src/features/deployment/remoteHost.test.ts | 32 ++ src/features/deployment/remoteHost.ts | 39 ++- src/features/deployment/store.ts | 2 + src/features/remote-dev/ConnectDialog.tsx | 2 +- .../settings/pages/Deployment.module.css | 10 + src/features/settings/pages/deployment.tsx | 90 ++++-- .../settings/pages/deploymentModel.test.ts | 9 + src/features/settings/pages/runTargets.tsx | 2 +- src/platform/deploymentSettings.test.ts | 23 ++ src/platform/locales/text/zh.json | 9 + src/platform/remoteSettings.ts | 8 + 26 files changed, 1127 insertions(+), 206 deletions(-) create mode 100644 crates/ide-host/src/ops/deploy.rs create mode 100644 crates/ide-remote/tests/local.rs create mode 100644 crates/ide-ssh/src/filesystem.rs create mode 100644 src/features/deployment/remoteHost.test.ts create mode 100644 src/platform/deploymentSettings.test.ts diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs index 771c30735..3d1703b6f 100644 --- a/crates/ide-core/src/remote_settings.rs +++ b/crates/ide-core/src/remote_settings.rs @@ -103,10 +103,19 @@ impl Default for DeploymentSettings { } } -/// An SFTP server and how project folders map onto it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DeploymentType { + #[default] + Sftp, + Local, +} + +/// A deployment filesystem and how project folders map onto it. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct DeploymentServer { + pub kind: DeploymentType, pub id: String, pub name: String, /// [`SshConfiguration::id`]. @@ -148,3 +157,23 @@ pub struct RunTarget { pub node: String, pub java: String, } + +#[cfg(test)] +mod deployment_tests { + use super::*; + + #[test] + fn deployment_type_defaults_and_round_trips() { + let legacy: DeploymentServer = + serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap(); + assert_eq!(legacy.kind, DeploymentType::Sftp); + assert_eq!(legacy.ssh_config, "ssh"); + for kind in [DeploymentType::Sftp, DeploymentType::Local] { + let server = DeploymentServer { kind, ..legacy.clone() }; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&server).unwrap()).unwrap(), + server + ); + } + } +} diff --git a/crates/ide-host/src/ops/deploy.rs b/crates/ide-host/src/ops/deploy.rs new file mode 100644 index 000000000..1b843995a --- /dev/null +++ b/crates/ide-host/src/ops/deploy.rs @@ -0,0 +1,200 @@ +use crate::Host; +use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; +use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; +use ide_ssh::{FsConnection, RemoteEntry}; +use std::path::PathBuf; + +/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`. +fn progress( + sink: SharedSink, + title: &str, + token: CancellationToken, +) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) { + let id = TaskId::next(); + let cancel = token; + let handle = ide_core::task::on_cancel(id, move || cancel.cancel()); + let title = title.to_owned(); + let (s, t) = (sink.clone(), title.clone()); + let report = move |path: &str, i: usize, n: usize| { + let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) }; + let message = Some(path.to_owned()); + s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true }); + }; + let done = move || { + drop(handle); + sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false }) + }; + (report, done) +} + +fn local(paths: Vec) -> Vec { + paths.into_iter().map(|p| p.to_path_buf()).collect() +} + +/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). +pub async fn deploy_upload( + window: &str, + state: &Host, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone()); + let r = state + .remote + .upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Download from…: the remote counterparts of files and folders. +pub async fn deploy_download( + window: &str, + state: &Host, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone()); + let r = state + .remote + .download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Deletes the remote counterparts of files and folders. +pub async fn deploy_delete_remote( + window: &str, + state: &Host, + paths: Vec, + server: Option, +) -> IdeResult { + let project = state.project(window)?; + state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await +} + +/// Sync with Deployed: local and remote files under `path`, side by side. +pub async fn deploy_compare( + window: &str, + state: &Host, + path: IpcPath, + server: Option, + request: RequestId, +) -> IdeResult> { + let project = state.project(window)?; + let token = state.begin_request(request); + let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await; + state.end_request(request); + r +} + +/// Applies the Sync with Deployed choices. +pub async fn deploy_sync( + window: &str, + state: &Host, + steps: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone()); + let r = state + .remote + .sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Where `path` is deployed (remote path, web URL); `None` when not mapped. +pub async fn deploy_info( + window: &str, + state: &Host, + path: IpcPath, + server: Option, +) -> IdeResult> { + let project = state.project(window)?; + state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await +} + +/// Remote Host tool window: the server root of `server`, resolved on the host. +pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult { + let settings = match state.project(window) { + Ok(p) => p.settings.effective(), + Err(_) => state.global_settings.effective(), + }; + state.remote.server_root(&settings, &server).await +} + +/// Entries of a folder on the selected filesystem (`~`: its home directory). +pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult> { + state.remote.list(&connection, &path).await +} + +/// Opens a remote file for editing: a local copy, uploaded back on save. +pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult { + Ok(state.remote.open_remote(&connection, &path).await?.into()) +} + +/// The filesystem connection and path a cached editing copy stands for. +pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> { + state.remote.remote_of(&path) +} + +pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.remote_delete(&connection, &path).await +} + +pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.remote_mkdir(&connection, &path).await +} + +pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> { + state.remote.remote_rename(&connection, &from, &to).await +} + +pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.test_connection(&connection, &path).await +} + +pub(crate) fn register(d: &mut crate::rpc::Dispatcher) { + d.add("deploy_upload", |c| async move { + deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_download", |c| async move { + deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_delete_remote", |c| async move { + deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await + }); + d.add("deploy_compare", |c| async move { + deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_sync", |c| async move { + deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await }); + d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await }); + d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) }); + d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_rename", |c| async move { + remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await + }); + d.add("deploy_test_connection", |c| async move { + deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await + }); +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 854b0cfc5..228d02fcd 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 deploy; pub mod doc; pub mod env; pub mod fs; @@ -81,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) { search::register(d); run::register(d); env::register(d); + deploy::register(d); } impl Host { diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 2455476f3..ade529267 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -191,3 +191,69 @@ impl EventType for EventMessage { serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned() } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_deployment_runs_on_the_project_host() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let target = tmp.path().join("mounted"); + std::fs::create_dir(&project).unwrap(); + std::fs::create_dir(&target).unwrap(); + let project = dunce::canonicalize(project).unwrap(); + let file = project.join("local.txt"); + std::fs::write(&file, "host file").unwrap(); + let slot = Arc::new(OnceLock::new()); + let sink_slot = slot.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + 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()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let dispatcher = Dispatcher::standard(); + for cmd in [ + "deploy_upload", + "deploy_download", + "deploy_delete_remote", + "deploy_compare", + "deploy_sync", + "deploy_info", + "deploy_server_root", + "deploy_test_connection", + "remote_list", + "remote_open", + "remote_of", + "remote_delete", + "remote_mkdir", + "remote_rename", + ] { + assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}"); + } + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let peer = Peer::connect(cr, cw, Arc::new(Client::default())); + invoke(&peer, "open_project", json!({"path": project})).await.unwrap(); + let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"}); + invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()})) + .await + .unwrap(); + invoke(&peer, "deploy_test_connection", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap(); + let report = invoke(&peer, "deploy_upload", json!({"paths":[file], "request":1001})).await.unwrap(); + assert_eq!(report["files"], 1, "{report}"); + assert_eq!(std::fs::read_to_string(target.join("local.txt")).unwrap(), "host file"); + let entries = invoke(&peer, "remote_list", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap(); + assert_eq!(entries[0]["name"], "local.txt"); + std::fs::write(target.join("local.txt"), "host download").unwrap(); + let report = invoke(&peer, "deploy_download", json!({"paths":[file], "request":1002})).await.unwrap(); + assert_eq!(report["files"], 1); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "host download"); + invoke(&peer, "deploy_delete_remote", json!({"paths":[file]})).await.unwrap(); + assert!(!target.join("local.txt").exists()); + invoke(&peer, "close_project", json!({})).await.unwrap(); +} diff --git a/crates/ide-remote/src/deploy.rs b/crates/ide-remote/src/deploy.rs index 81887a01d..712b9c197 100644 --- a/crates/ide-remote/src/deploy.rs +++ b/crates/ide-remote/src/deploy.rs @@ -1,5 +1,5 @@ //! Upload, download, delete and synchronise project files with a deployment -//! server over SFTP. +//! server through a shared filesystem interface. use crate::mapping::{Excludes, Mappings, posix}; use ide_core::{CancellationToken, IdeError, IdeResult}; @@ -96,7 +96,7 @@ pub fn status(local: Option, remote: Option) -> SyncStatus } } -/// Operations on one server through one SFTP session. +/// Operations on one server through its filesystem. pub struct Deployment<'a> { pub fs: &'a RemoteFs, pub map: &'a Mappings, diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs index 9be7444b5..12b682936 100644 --- a/crates/ide-remote/src/service.rs +++ b/crates/ide-remote/src/service.rs @@ -1,14 +1,14 @@ -//! The deployment and run-target operations of one app, over the shared SSH -//! connections: what the IPC commands and the save hook call. +//! Deployment over SFTP or local folders, and SSH run targets. +//! Shared by IPC commands and project save/delete hooks. use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport}; use crate::mapping::{Excludes, Mappings}; use crate::target::{RemoteRuns, RemoteStart, SyncFn}; -use ide_core::remote_settings::{AutoUpload, DeploymentServer, RunTarget}; +use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget}; use ide_core::settings::Settings; use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink}; use ide_run::{OutputChunk, RunConfig, RunInfo}; -use ide_ssh::{RemoteEntry, RemoteFs, SshManager}; +use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; @@ -22,7 +22,7 @@ pub type Progress<'a> = &'a mut (dyn FnMut(&str, usize, usize) + Send); /// A remote file opened for editing (a local copy uploaded back on save). #[derive(Debug, Clone)] struct RemoteEdit { - ssh_config: String, + connection: FsConnection, remote: String, } @@ -37,7 +37,7 @@ pub struct DeployedPath { pub struct RemoteService { ssh: Arc, - /// Local copies of remote files: `//`. + /// Local copies of remote files, separated by filesystem connection. cache: PathBuf, edits: Mutex>, pub runs: RemoteRuns, @@ -71,12 +71,24 @@ impl RemoteService { Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() } } - async fn fs(&self, ssh_config: &str) -> IdeResult { - if ssh_config.is_empty() { - return Err(IdeError::invalid("the deployment server has no SSH configuration")); + async fn fs(&self, connection: &FsConnection) -> IdeResult { + match connection { + FsConnection::Local => Ok(RemoteFs::Local), + FsConnection::Sftp { ssh } => { + if ssh.is_empty() { + return Err(IdeError::invalid("the deployment server has no SSH configuration")); + } + let session = self.ssh.session(ssh).await?; + Ok(RemoteFs::new(session.sftp().await?)) + } } - let session = self.ssh.session(ssh_config).await?; - Ok(RemoteFs::new(session.sftp().await?)) + } + + pub async fn test_connection(&self, connection: &FsConnection, path: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; + let result = fs.test(path).await; + fs.close().await; + result } /// Runs `f` with a [`Deployment`] on `server`. @@ -88,7 +100,7 @@ impl RemoteService { cancel: CancellationToken, f: impl AsyncFnOnce(&Deployment<'_>) -> T, ) -> IdeResult { - let fs = self.fs(&server.ssh_config).await?; + let fs = self.fs(&connection(server)).await?; let remote_root = match fs.resolve(&server.root_path).await { Ok(r) => r, Err(e) => { @@ -198,54 +210,57 @@ impl RemoteService { /// The server root of `server_id`, resolved on the host. pub async fn server_root(&self, settings: &Settings, server_id: &str) -> IdeResult { let server = server(settings, Some(server_id))?; - let fs = self.fs(&server.ssh_config).await?; + let fs = self.fs(&connection(&server)).await?; let r = fs.resolve(&server.root_path).await; fs.close().await; r } - pub async fn list(&self, ssh_config: &str, path: &str) -> IdeResult> { - let fs = self.fs(ssh_config).await?; + pub async fn list(&self, connection: &FsConnection, path: &str) -> IdeResult> { + let fs = self.fs(connection).await?; let r = async { fs.list(&fs.resolve(path).await?).await }.await; fs.close().await; r } /// Downloads a remote file for editing; saving the copy uploads it back. - pub async fn open_remote(&self, ssh_config: &str, remote: &str) -> IdeResult { - let rel = remote.split('/').filter(|s| !s.is_empty() && *s != "..").fold(PathBuf::new(), |p, s| p.join(s)); - let local = self.cache.join(sanitize(ssh_config)).join(rel); - let fs = self.fs(ssh_config).await?; + pub async fn open_remote(&self, connection: &FsConnection, remote: &str) -> IdeResult { + let rel = remote + .split('/') + .filter(|s| !s.is_empty() && *s != ".." && *s != ".") + .fold(PathBuf::new(), |p, s| p.join(s.replace('%', "%25").replace(':', "%3A").replace('\\', "%5C"))); + let local = self.cache.join(sanitize(&cache_key(connection))).join(rel); + let fs = self.fs(connection).await?; let r = fs.download(remote, &local).await; fs.close().await; r?; self.edits .lock() - .insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() }); + .insert(local.clone(), RemoteEdit { connection: connection.clone(), remote: remote.to_owned() }); Ok(local) } /// The remote file a local copy stands for. - pub fn remote_of(&self, local: &Path) -> Option<(String, String)> { - self.edits.lock().get(local).map(|e| (e.ssh_config.clone(), e.remote.clone())) + pub fn remote_of(&self, local: &Path) -> Option<(FsConnection, String)> { + self.edits.lock().get(local).map(|e| (e.connection.clone(), e.remote.clone())) } - pub async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_delete(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.remove(remote).await; fs.close().await; r } - pub async fn remote_mkdir(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_mkdir(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.mkdir_all(remote).await; fs.close().await; r } - pub async fn remote_rename(&self, ssh_config: &str, from: &str, to: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_rename(&self, connection: &FsConnection, from: &str, to: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.rename(from, to).await; fs.close().await; r @@ -366,3 +381,18 @@ impl RemoteService { fn sanitize(s: &str) -> String { s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect() } + +/// A server's transport, also used by the browser and remote editor save hook. +pub fn connection(server: &DeploymentServer) -> FsConnection { + match server.kind { + DeploymentType::Local => FsConnection::Local, + DeploymentType::Sftp => FsConnection::Sftp { ssh: server.ssh_config.clone() }, + } +} + +fn cache_key(connection: &FsConnection) -> String { + match connection { + FsConnection::Local => "local".to_owned(), + FsConnection::Sftp { ssh } => format!("sftp-{ssh}"), + } +} diff --git a/crates/ide-remote/tests/local.rs b/crates/ide-remote/tests/local.rs new file mode 100644 index 000000000..cb6540579 --- /dev/null +++ b/crates/ide-remote/tests/local.rs @@ -0,0 +1,211 @@ +//! Local deployment uses the service entry points used by SFTP and save hooks. +use ide_core::CancellationToken; +use ide_core::remote_settings::{AutoUpload, DeploymentMapping, DeploymentServer, DeploymentType}; +use ide_core::settings::Settings; +use ide_remote::{RemoteService, SyncAction, SyncStatus, SyncStep}; +use ide_ssh::{FsConnection, SshFiles, SshManager}; +use std::path::Path; +use std::sync::Arc; + +fn service(root: &Path) -> RemoteService { + RemoteService::new( + Arc::new(SshManager::with_files( + root.join("secrets"), + SshFiles { known_hosts: root.join("known_hosts"), config: None }, + )), + root.join("cache"), + ) +} + +fn settings(target: &Path) -> Settings { + let mut s = Settings::default(); + s.deployment.servers.push(DeploymentServer { + id: "local".into(), + kind: DeploymentType::Local, + root_path: target.to_string_lossy().into_owned(), + mappings: vec![DeploymentMapping { deployment: "app".into(), ..Default::default() }], + excluded_paths: vec!["app/private".into()], + ..Default::default() + }); + s.deployment.default_server = "local".into(); + s.deployment.auto_upload = AutoUpload::Always; + s.deployment.delete_remote = true; + s +} + +#[tokio::test] +async fn local_deployment_and_browser_share_all_paths() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let target = tmp.path().join("mounted"); + std::fs::create_dir_all(root.join("src/nested")).unwrap(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::create_dir_all(root.join("private")).unwrap(); + std::fs::create_dir(&target).unwrap(); + std::fs::write(root.join("src/nested/a.txt"), "initial").unwrap(); + std::fs::write(root.join(".git/config"), "excluded").unwrap(); + std::fs::write(root.join("private/key"), "excluded remote path").unwrap(); + let service = service(tmp.path()); + let settings = settings(&target); + let cancel = CancellationToken::new(); + service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await.unwrap(); + let report = service + .upload(&settings, &root, None, std::slice::from_ref(&root), cancel.clone(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!(report.files, 1, "{report:?}"); + assert!(report.errors.is_empty(), "{report:?}"); + assert!(!target.join("app/.git").exists()); + assert!(!target.join("app/private").exists()); + assert_eq!( + ide_ssh::sftp::mtime_of(&root.join("src/nested/a.txt")), + ide_ssh::sftp::mtime_of(&target.join("app/src/nested/a.txt")) + ); + let compared = service.compare(&settings, &root, None, &root, cancel.clone()).await.unwrap(); + assert_eq!(compared.iter().find(|e| e.path == "src/nested/a.txt").unwrap().status, SyncStatus::Same); + std::fs::write(root.join("src/nested/a.txt"), "saved").unwrap(); + let saved = service.on_saved(&settings, &root, &root.join("src/nested/a.txt")).await.unwrap().unwrap(); + assert!(saved.errors.is_empty()); + let remote_file = target.join("app/src/nested/a.txt"); + assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "saved"); + std::fs::write(&remote_file, "downloaded").unwrap(); + let report = + service.download(&settings, &root, None, &[root.join("src")], cancel.clone(), &mut |_, _, _| {}).await.unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(std::fs::read_to_string(root.join("src/nested/a.txt")).unwrap(), "downloaded"); + let connection = FsConnection::Local; + let entries = service.list(&connection, target.join("app/src/nested").to_str().unwrap()).await.unwrap(); + assert_eq!(entries[0].name, "a.txt"); + let cached = service.open_remote(&connection, remote_file.to_str().unwrap()).await.unwrap(); + std::fs::write(&cached, "remote edit").unwrap(); + assert!(service.on_saved(&settings, &root, &cached).await.unwrap().unwrap().errors.is_empty()); + assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "remote edit"); + service.remote_mkdir(&connection, target.join("browser/new/nested").to_str().unwrap()).await.unwrap(); + service + .remote_rename(&connection, target.join("browser").to_str().unwrap(), target.join("renamed").to_str().unwrap()) + .await + .unwrap(); + service.remote_delete(&connection, target.join("renamed").to_str().unwrap()).await.unwrap(); + assert!(!target.join("renamed").exists()); + let report = service + .sync( + &settings, + &root, + None, + &[SyncStep { path: "src/nested/a.txt".into(), action: SyncAction::Download }], + cancel, + &mut |_, _, _| {}, + ) + .await + .unwrap(); + assert_eq!(report.files, 1); + std::fs::remove_file(root.join("src/nested/a.txt")).unwrap(); + assert!( + service + .on_deleted(&settings, &root, &[root.join("src/nested/a.txt")]) + .await + .unwrap() + .unwrap() + .errors + .is_empty() + ); + assert!(!remote_file.exists()); +} + +#[tokio::test] +async fn self_upload_download_and_aliases_never_destroy_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let service = service(root); + let mut settings = settings(root); + settings.deployment.servers[0].mappings[0].deployment.clear(); + let file = root.join("file"); + std::fs::write(&file, "keep me").unwrap(); + for download in [false, true] { + let report = if download { + service + .download( + &settings, + root, + None, + std::slice::from_ref(&file), + CancellationToken::new(), + &mut |_, _, _| {}, + ) + .await + } else { + service + .upload(&settings, root, None, std::slice::from_ref(&file), CancellationToken::new(), &mut |_, _, _| {}) + .await + } + .unwrap(); + assert!(report.errors[0].message.contains("same file")); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me"); + } + #[cfg(unix)] + { + let alias = root.join("alias"); + std::fs::hard_link(&file, &alias).unwrap(); + assert!( + ide_ssh::RemoteFs::Local + .upload(&file, alias.to_str().unwrap(), true) + .await + .unwrap_err() + .message + .contains("same file") + ); + std::fs::remove_file(&alias).unwrap(); + std::os::unix::fs::symlink(&file, &alias).unwrap(); + assert!( + ide_ssh::RemoteFs::Local + .upload(&file, alias.to_str().unwrap(), true) + .await + .unwrap_err() + .message + .contains("same file") + ); + ide_ssh::RemoteFs::Local.remove(alias.to_str().unwrap()).await.unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn missing_non_directory_and_unwritable_targets_are_clear() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let service = service(tmp.path()); + let target = tmp.path().join("target"); + assert!( + service + .test_connection(&FsConnection::Local, target.to_str().unwrap()) + .await + .unwrap_err() + .message + .contains("does not exist") + ); + std::fs::write(&target, "file").unwrap(); + assert!( + service + .test_connection(&FsConnection::Local, target.to_str().unwrap()) + .await + .unwrap_err() + .message + .contains("Not a folder") + ); + std::fs::remove_file(&target).unwrap(); + std::fs::create_dir(&target).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o555)).unwrap(); + let result = service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await; + let file = tmp.path().join("source"); + std::fs::write(&file, "data").unwrap(); + let mut settings = settings(&target); + settings.deployment.servers[0].mappings[0].deployment.clear(); + let report = service + .upload(&settings, tmp.path(), None, &[file], CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(result.unwrap_err().message.contains("not writable")); + assert_eq!(report.errors.len(), 1, "{report:?}"); +} diff --git a/crates/ide-ssh/src/filesystem.rs b/crates/ide-ssh/src/filesystem.rs new file mode 100644 index 000000000..04bdbed7c --- /dev/null +++ b/crates/ide-ssh/src/filesystem.rs @@ -0,0 +1,274 @@ +//! Filesystem operations shared by deployment and remote development bootstrap. +use crate::sftp::{RemoteEntry, SftpFs}; +use ide_core::{IdeError, IdeResult}; +use russh_sftp::client::SftpSession; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FsConnection { + Sftp { ssh: String }, + Local, +} + +pub enum RemoteFs { + Sftp(SftpFs), + Local, +} + +impl RemoteFs { + /// Keeps SSH bootstrap callers explicitly on SFTP. + pub fn new(sftp: SftpSession) -> Self { + Self::Sftp(SftpFs::new(sftp)) + } + + pub async fn home(&self) -> IdeResult { + match self { + Self::Sftp(fs) => fs.home().await, + Self::Local => { + dirs::home_dir().map(|p| path_string(&p)).ok_or_else(|| IdeError::invalid("Home folder is unavailable")) + } + } + } + + pub async fn resolve(&self, path: &str) -> IdeResult { + match self { + Self::Sftp(fs) => fs.resolve(path).await, + Self::Local => { + let expanded = crate::expand(path.trim()); + let path = if path.trim().is_empty() || path.trim() == "~" { + std::path::PathBuf::from(self.home().await?) + } else { + expanded + }; + let path = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; + Ok(path_string(&path)) + } + } + } + + pub async fn stat(&self, path: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.stat(path).await, + Self::Local => match tokio::fs::metadata(path).await { + Ok(meta) => Ok(Some(local_entry(path, meta).await?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + }, + } + } + + pub async fn list(&self, dir: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.list(dir).await, + Self::Local => { + let mut read = tokio::fs::read_dir(dir).await?; + let mut entries = Vec::new(); + while let Some(e) = read.next_entry().await? { + let path = path_string(&e.path()); + entries.push(local_entry(&path, tokio::fs::symlink_metadata(e.path()).await?).await?); + } + entries.sort_by(|a, b| { + b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(entries) + } + } + } + + pub async fn read(&self, path: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.read(path).await, + Self::Local => Ok(tokio::fs::read(path).await?), + } + } + + pub async fn write(&self, path: &str, data: &[u8], mtime: Option) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.write(path, data, mtime).await, + Self::Local => { + if let Some(parent) = Path::new(path).parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, data).await?; + if let Some(t) = mtime { + set_mtime(path, t).await; + } + Ok(()) + } + } + } + + pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.upload(local, remote, preserve_time).await, + Self::Local => { + reject_same_file(local, Path::new(remote)).await?; + copy_file(local, Path::new(remote)).await?; + if let Some(t) = preserve_time.then(|| crate::sftp::mtime_of(local)).flatten() { + set_mtime(remote, t).await; + } + Ok(()) + } + } + } + + pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.download(remote, local).await, + Self::Local => { + reject_same_file(Path::new(remote), local).await?; + copy_file(Path::new(remote), local).await + } + } + } + + pub async fn mkdir_all(&self, path: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.mkdir_all(path).await, + Self::Local => Ok(tokio::fs::create_dir_all(path).await?), + } + } + + pub async fn remove(&self, path: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.remove(path).await, + Self::Local => { + let meta = tokio::fs::symlink_metadata(path).await?; + if meta.is_dir() { + tokio::fs::remove_dir_all(path).await?; + } else { + tokio::fs::remove_file(path).await?; + } + Ok(()) + } + } + } + + pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.rename(from, to).await, + Self::Local => Ok(tokio::fs::rename(from, to).await?), + } + } + + /// Probe actual write access, including ACLs and read-only mounts. + pub async fn test(&self, path: &str) -> IdeResult<()> { + let path = self.resolve(path).await?; + let entry = + self.stat(&path).await?.ok_or_else(|| IdeError::not_found(format!("Folder does not exist: {path}")))?; + if !entry.is_dir { + return Err(IdeError::invalid(format!("Not a folder: {path}"))); + } + if matches!(self, Self::Local) { + let name = format!( + ".openidea-write-test-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() + ); + let probe = Path::new(&path).join(name); + let mut file = + tokio::fs::OpenOptions::new().write(true).create_new(true).open(&probe).await.map_err(|e| { + IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("Folder is not writable: {path}: {e}"), + ) + })?; + use tokio::io::AsyncWriteExt; + let result = file.write_all(b"test").await; + drop(file); + let cleanup = tokio::fs::remove_file(probe).await; + result.and(cleanup).map_err(|e| { + IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("Folder is not writable: {path}: {e}"), + ) + })?; + } else { + self.list(&path).await?; + } + Ok(()) + } + + pub async fn close(&self) { + if let Self::Sftp(fs) = self { + fs.close().await; + } + } +} + +/// Copies without holding the file in memory: deployments can include large archives. +async fn copy_file(from: &Path, to: &Path) -> IdeResult<()> { + if let Some(parent) = to.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::copy(from, to).await?; + Ok(()) +} + +async fn set_mtime(path: &str, seconds: u32) { + let result = async { + let file = tokio::fs::OpenOptions::new().write(true).open(path).await?.into_std().await; + file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(u64::from(seconds))) + }; + if let Err(e) = result.await { + tracing::debug!("Cannot set the time of {path}: {e}"); + } +} + +fn path_string(path: &Path) -> String { + let path = path.to_string_lossy().into_owned(); + #[cfg(windows)] + let path = path.replace('\\', "/"); + path +} + +async fn local_entry(path: &str, meta: std::fs::Metadata) -> IdeResult { + #[cfg(unix)] + let permissions = { + use std::os::unix::fs::PermissionsExt; + crate::sftp::mode_string(meta.permissions().mode()) + }; + #[cfg(not(unix))] + let permissions = if meta.permissions().readonly() { "r--r--r--" } else { "rw-rw-rw-" }.to_owned(); + let is_dir = meta.is_dir() || (meta.is_symlink() && tokio::fs::metadata(path).await.is_ok_and(|m| m.is_dir())); + Ok(RemoteEntry { + name: Path::new(path).file_name().unwrap_or_default().to_string_lossy().into_owned(), + path: path.to_owned(), + is_dir, + is_symlink: meta.is_symlink(), + size: meta.len(), + mtime: meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|t| t.as_secs().try_into().ok()), + permissions, + }) +} + +async fn reject_same_file(from: &Path, to: &Path) -> IdeResult<()> { + let source = tokio::fs::metadata(from).await?; + let target = match tokio::fs::metadata(to).await { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + #[cfg(unix)] + let same = { + use std::os::unix::fs::MetadataExt; + source.dev() == target.dev() && source.ino() == target.ino() + }; + #[cfg(not(unix))] + let same = { + let _ = (source, target); + tokio::fs::canonicalize(from).await? == tokio::fs::canonicalize(to).await? + }; + if same { + Err(IdeError::invalid(format!("Source and destination are the same file: {}", from.display()))) + } else { + Ok(()) + } +} diff --git a/crates/ide-ssh/src/lib.rs b/crates/ide-ssh/src/lib.rs index 1ab25a185..c5790b281 100644 --- a/crates/ide-ssh/src/lib.rs +++ b/crates/ide-ssh/src/lib.rs @@ -3,6 +3,7 @@ //! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`. pub mod askpass; +pub mod filesystem; pub mod git_creds; pub mod manager; pub mod secrets; @@ -14,9 +15,10 @@ pub mod target; pub mod testing; pub mod tunnel; +pub use filesystem::{FsConnection, RemoteFs}; pub use manager::SshManager; pub use russh::Sig; pub use session::{ConnectError, ExecOutput, Output, RemoteProcess, Session, UnknownHostKey}; -pub use sftp::{RemoteEntry, RemoteFs}; +pub use sftp::RemoteEntry; pub use target::{SshFiles, SshTarget, expand, openssh_args}; pub use tunnel::Tunnel; diff --git a/crates/ide-ssh/src/sftp.rs b/crates/ide-ssh/src/sftp.rs index c14c14463..38a8fde74 100644 --- a/crates/ide-ssh/src/sftp.rs +++ b/crates/ide-ssh/src/sftp.rs @@ -41,7 +41,7 @@ pub fn parent(path: &str) -> Option<&str> { Some(if i == 0 { "/" } else { &trimmed[..i] }) } -fn mode_string(mode: u32) -> String { +pub(crate) fn mode_string(mode: u32) -> String { let bits = [ (0o400, 'r'), (0o200, 'w'), @@ -78,11 +78,11 @@ fn err(path: &str, e: SftpError) -> IdeError { } /// Higher-level operations on an [`SftpSession`]. -pub struct RemoteFs { +pub struct SftpFs { sftp: SftpSession, } -impl RemoteFs { +impl SftpFs { pub fn new(sftp: SftpSession) -> Self { Self { sftp } } diff --git a/src-tauri/src/commands/deploy.rs b/src-tauri/src/commands/deploy.rs index 22df06480..be959bde2 100644 --- a/src-tauri/src/commands/deploy.rs +++ b/src-tauri/src/commands/deploy.rs @@ -1,38 +1,9 @@ use crate::state::AppState; -use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; +use ide_core::{IdeResult, IpcPath, RequestId}; use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; -use ide_ssh::RemoteEntry; -use std::path::PathBuf; +use ide_ssh::{FsConnection, RemoteEntry}; use tauri::{State, Window}; -/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`. -fn progress( - sink: SharedSink, - title: &str, - token: CancellationToken, -) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) { - let id = TaskId::next(); - let cancel = token; - let handle = ide_core::task::on_cancel(id, move || cancel.cancel()); - let title = title.to_owned(); - let (s, t) = (sink.clone(), title.clone()); - let report = move |path: &str, i: usize, n: usize| { - let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) }; - let message = Some(path.to_owned()); - s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true }); - }; - let done = move || { - drop(handle); - sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false }) - }; - (report, done) -} - -fn local(paths: Vec) -> Vec { - paths.into_iter().map(|p| p.to_path_buf()).collect() -} - -/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). #[tauri::command] #[specta::specta] pub async fn deploy_upload( @@ -42,19 +13,9 @@ pub async fn deploy_upload( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone()); - let r = state - .remote - .upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_upload(window.label(), &state.host, paths, server, request).await } -/// Download from…: the remote counterparts of files and folders. #[tauri::command] #[specta::specta] pub async fn deploy_download( @@ -64,19 +25,9 @@ pub async fn deploy_download( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone()); - let r = state - .remote - .download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_download(window.label(), &state.host, paths, server, request).await } -/// Deletes the remote counterparts of files and folders. #[tauri::command] #[specta::specta] pub async fn deploy_delete_remote( @@ -85,11 +36,9 @@ pub async fn deploy_delete_remote( paths: Vec, server: Option, ) -> IdeResult { - let project = state.project(window.label())?; - state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await + ide_host::ops::deploy::deploy_delete_remote(window.label(), &state.host, paths, server).await } -/// Sync with Deployed: local and remote files under `path`, side by side. #[tauri::command] #[specta::specta] pub async fn deploy_compare( @@ -99,14 +48,9 @@ pub async fn deploy_compare( server: Option, request: RequestId, ) -> IdeResult> { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await; - state.end_request(request); - r + ide_host::ops::deploy::deploy_compare(window.label(), &state.host, path, server, request).await } -/// Applies the Sync with Deployed choices. #[tauri::command] #[specta::specta] pub async fn deploy_sync( @@ -116,19 +60,9 @@ pub async fn deploy_sync( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone()); - let r = state - .remote - .sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_sync(window.label(), &state.host, steps, server, request).await } -/// Where `path` is deployed (remote path, web URL); `None` when not mapped. #[tauri::command] #[specta::specta] pub async fn deploy_info( @@ -137,56 +71,66 @@ pub async fn deploy_info( path: IpcPath, server: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await + ide_host::ops::deploy::deploy_info(window.label(), &state.host, path, server).await } -/// Remote Host tool window: the server root of `server`, resolved on the host. #[tauri::command] #[specta::specta] pub async fn deploy_server_root(window: Window, state: State<'_, AppState>, server: String) -> IdeResult { - let settings = match state.project(window.label()) { - Ok(p) => p.settings.effective(), - Err(_) => state.global_settings.effective(), - }; - state.remote.server_root(&settings, &server).await -} - -/// Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). -#[tauri::command] -#[specta::specta] -pub async fn remote_list(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult> { - state.remote.list(&ssh, &path).await -} - -/// Opens a remote file for editing: a local copy, uploaded back on save. -#[tauri::command] -#[specta::specta] -pub async fn remote_open(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult { - Ok(state.remote.open_remote(&ssh, &path).await?.into()) -} - -/// The remote file a local copy stands for: (SSH configuration id, remote path). -#[tauri::command] -#[specta::specta] -pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(String, String)> { - state.remote.remote_of(&path) + ide_host::ops::deploy::deploy_server_root(window.label(), &state.host, server).await } #[tauri::command] #[specta::specta] -pub async fn remote_delete(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { - state.remote.remote_delete(&ssh, &path).await +pub async fn remote_list( + state: State<'_, AppState>, + connection: FsConnection, + path: String, +) -> IdeResult> { + ide_host::ops::deploy::remote_list(&state.host, connection, path).await } #[tauri::command] #[specta::specta] -pub async fn remote_mkdir(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { - state.remote.remote_mkdir(&ssh, &path).await +pub async fn remote_open(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult { + ide_host::ops::deploy::remote_open(&state.host, connection, path).await } #[tauri::command] #[specta::specta] -pub async fn remote_rename(state: State<'_, AppState>, ssh: String, from: String, to: String) -> IdeResult<()> { - state.remote.remote_rename(&ssh, &from, &to).await +pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(FsConnection, String)> { + ide_host::ops::deploy::remote_of(&state.host, path) +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_delete(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<()> { + ide_host::ops::deploy::remote_delete(&state.host, connection, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_mkdir(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<()> { + ide_host::ops::deploy::remote_mkdir(&state.host, connection, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_rename( + state: State<'_, AppState>, + connection: FsConnection, + from: String, + to: String, +) -> IdeResult<()> { + ide_host::ops::deploy::remote_rename(&state.host, connection, from, to).await +} + +#[tauri::command] +#[specta::specta] +pub async fn deploy_test_connection( + state: State<'_, AppState>, + connection: FsConnection, + path: String, +) -> IdeResult<()> { + ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path).await } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 121f2eab1..f7ff09bd5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -457,11 +457,13 @@ pub fn builder() -> tauri_specta::Builder { deploy::deploy_info, deploy::deploy_server_root, deploy::remote_list, + deploy::deploy_test_connection, deploy::remote_open, deploy::remote_of, deploy::remote_delete, deploy::remote_mkdir, deploy::remote_rename, + crate::remote_dev::remote_bootstrap_list, crate::remote_dev::remote_connect, crate::remote_dev::remote_current, crate::remote_dev::remote_disconnect, diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 876b15e68..8b5f97be0 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -17,6 +17,17 @@ use std::sync::Arc; use tauri::ipc::Channel; use tauri::{AppHandle, Manager, State, Window}; +/// Browse before connecting, using the desktop's SSH configuration even in a remote window. +#[tauri::command] +#[specta::specta] +pub async fn remote_bootstrap_list( + state: State<'_, AppState>, + ssh: String, + path: String, +) -> IdeResult> { + state.remote.list(&ide_ssh::FsConnection::Sftp { ssh }, &path).await +} + /// Where the server is installed on the remote host (per version). fn install_dir(version: &str) -> String { format!("~/.openidea/server/{version}") diff --git a/src/bindings.ts b/src/bindings.ts index 501a2116e..0ce6fda5f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -966,33 +966,26 @@ export const commands = { * (run in a terminal tab, so prompts and host-key questions are answered there). */ sshTerminalCommand: (id: string) => typedError(__TAURI_INVOKE("ssh_terminal_command", { id })), - /** Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). */ deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_upload", { paths, server, request })), - /** Download from…: the remote counterparts of files and folders. */ deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_download", { paths, server, request })), - /** Deletes the remote counterparts of files and folders. */ deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError(__TAURI_INVOKE("deploy_delete_remote", { paths, server })), - /** Sync with Deployed: local and remote files under `path`, side by side. */ deployCompare: (path: IpcPath, server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_compare", { path, server, request })), - /** Applies the Sync with Deployed choices. */ deploySync: (steps: SyncStep[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_sync", { steps, server, request })), - /** Where `path` is deployed (remote path, web URL); `None` when not mapped. */ deployInfo: (path: IpcPath, server: string | null) => typedError<{ server: string, remotePath: string, webUrl: string | null, } | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })), - /** Remote Host tool window: the server root of `server`, resolved on the host. */ deployServerRoot: (server: string) => typedError(__TAURI_INVOKE("deploy_server_root", { server })), - /** Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). */ - remoteList: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_list", { ssh, path })), - /** Opens a remote file for editing: a local copy, uploaded back on save. */ - remoteOpen: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_open", { ssh, path })), - /** The remote file a local copy stands for: (SSH configuration id, remote path). */ - remoteOf: (path: IpcPath) => __TAURI_INVOKE<[string, string] | null>("remote_of", { path }), - remoteDelete: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_delete", { ssh, path })), - remoteMkdir: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { ssh, path })), - remoteRename: (ssh: string, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { ssh, from, to })), + remoteList: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_list", { connection, path })), + deployTestConnection: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("deploy_test_connection", { connection, path })), + remoteOpen: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_open", { connection, path })), + remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }), + remoteDelete: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_delete", { connection, path })), + remoteMkdir: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { connection, path })), + remoteRename: (connection: FsConnection, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { connection, from, to })), + /** Browse before connecting, using the desktop's SSH configuration even in a remote window. */ + remoteBootstrapList: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })), /** * File › Remote Development: opens `path` on the host of SSH configuration * `ssh_config` in this window (installs / starts the server as needed). @@ -2410,8 +2403,9 @@ export type DeploymentMapping = { web?: string, }; -/** An SFTP server and how project folders map onto it. */ +/** A deployment filesystem and how project folders map onto it. */ export type DeploymentServer = { + kind?: DeploymentType, id?: string, name?: string, /** [`SshConfiguration::id`]. */ @@ -2439,6 +2433,8 @@ export type DeploymentSettings = { exclude?: string[], }; +export type DeploymentType = "sftp" | "local"; + export type Diagnostic = { range: Range, severity: Severity, @@ -3118,6 +3114,8 @@ export type Fragment = { text: string, }; +export type FsConnection = { kind: "sftp"; ssh: string } | { kind: "local" }; + /** * A method breakpoint (DAP function breakpoint). `name` uses the adapter's syntax: * `pkg.Class#method` for java-debug, `pkg.Func` / `pkg.(*T).Method` for Delve, a plain diff --git a/src/features/deployment/actions.tsx b/src/features/deployment/actions.tsx index ceb52d845..b54458b07 100644 --- a/src/features/deployment/actions.tsx +++ b/src/features/deployment/actions.tsx @@ -17,7 +17,7 @@ import styles from "./Deployment.module.css"; const GROUP = "tools"; const CATEGORY = "Deployment"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); const projectRoot = () => useProject.getState().project?.root ?? null; const deployment = () => workflowSettings().deployment; @@ -80,7 +80,7 @@ function ReportBody({ report }: { report: TransferReport }) {
    {shown.map((e, i) => (
  • - {root ? relativePath(root, e.path) : e.path}: {e.message} + {root ? relativePath(root, e.path) : e.path}: {txt(e.message)}
  • ))}
@@ -120,7 +120,7 @@ async function transfer(direction: "upload" | "download", server: DeploymentServ if (!server) return; const request = nextRequestId(); try { - const report = await withSsh(server.sshConfig, () => + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => call( direction === "upload" ? commands.deployUpload(paths, server.id, request) @@ -155,7 +155,9 @@ async function deleteRemote(paths: string[]) { }); if (!ok) return; try { - const report = await withSsh(server.sshConfig, () => call(commands.deployDeleteRemote(paths, server.id))); + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deployDeleteRemote(paths, server.id)), + ); showReport(report, "delete", server); } catch (e) { showFailure("Cannot delete from {server}", server, e); @@ -179,7 +181,9 @@ export async function openSync(server: DeploymentServerValid, path: string) { }, }); try { - const entries = await withSsh(server.sshConfig, () => call(commands.deployCompare(path, server.id, request))); + const entries = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deployCompare(path, server.id, request)), + ); if (useDeployment.getState().sync?.request !== request) return; patchSync({ entries, choices: initialSyncChoices(entries), request: null }); } catch (e) { @@ -203,7 +207,9 @@ export async function runSync(steps: { path: string; action: SyncAction }[]) { const request = nextRequestId(); patchSync({ running: true, request }); try { - const report = await withSsh(server.sshConfig, () => call(commands.deploySync(steps, server.id, request))); + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deploySync(steps, server.id, request)), + ); useDeployment.setState({ sync: null }); showReport(report, "sync", server); } catch (e) { diff --git a/src/features/deployment/remoteHost.test.ts b/src/features/deployment/remoteHost.test.ts new file mode 100644 index 000000000..7ef2cfa46 --- /dev/null +++ b/src/features/deployment/remoteHost.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings"; +import { ensureRoot } from "./remoteHost"; +import { useDeployment } from "./store"; + +const ipc = vi.hoisted(() => ({ root: vi.fn(), list: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + commands: { deployServerRoot: ipc.root, remoteList: ipc.list }, +})); + +beforeEach(() => { + useDeployment.setState({ trees: {} }); + vi.clearAllMocks(); + ipc.root.mockResolvedValue({ status: "ok", data: "/srv" }); + ipc.list.mockResolvedValue({ status: "ok", data: [] }); +}); + +it("reloads a server's tree when changing its filesystem type or root", async () => { + const server = { ...DEFAULT_DEPLOYMENT_SERVER, id: "existing", sshConfig: "ssh", rootPath: "/srv" }; + await ensureRoot(server); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "sftp", ssh: "ssh" }, "/srv"); + await ensureRoot(server); + expect(ipc.root).toHaveBeenCalledTimes(1); + const local = { ...server, kind: "local" as const }; + await ensureRoot(local); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/srv"); + ipc.root.mockResolvedValue({ status: "ok", data: "/mounted" }); + await ensureRoot({ ...local, rootPath: "/mounted" }); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/mounted"); + expect(useDeployment.getState().trees.existing?.root).toBe("/mounted"); +}); diff --git a/src/features/deployment/remoteHost.ts b/src/features/deployment/remoteHost.ts index 8069f87b1..34a90091b 100644 --- a/src/features/deployment/remoteHost.ts +++ b/src/features/deployment/remoteHost.ts @@ -2,7 +2,7 @@ import { txTemplate } from "@/platform/i18n"; import { call, commands, type RemoteEntry } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import type { DeploymentServerValid } from "@/platform/remoteSettings"; +import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings"; import { copyText } from "@/ui"; import { editorService, notifications } from "@/workbench/contributions"; import { promptInput } from "@/workbench/inputDialogs"; @@ -12,7 +12,9 @@ import { joinRemote, localPathForRemote, remoteName, remoteParent } from "./mode import { withSsh } from "./ssh"; import { EMPTY_TREE, patchTree, useDeployment } from "./store"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); + +const sourceKey = (server: DeploymentServerValid) => JSON.stringify([deploymentConnection(server), server.rootPath]); const tree = (server: string) => useDeployment.getState().trees[server] ?? EMPTY_TREE; @@ -25,14 +27,19 @@ function fail(title: string, e: unknown) { } export async function loadFolder(server: DeploymentServerValid, path: string) { + const source = sourceKey(server); try { - const entries = await withSsh(server.sshConfig, () => call(commands.remoteList(server.sshConfig, path))); + const entries = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteList(deploymentConnection(server), path)), + ); + if (tree(server.id).source !== source) return; patchTree(server.id, (t) => { const folderErrors = { ...t.folderErrors }; delete folderErrors[path]; return { listings: { ...t.listings, [path]: sortEntries(entries) }, folderErrors }; }); } catch (e) { + if (tree(server.id).source !== source) return; patchTree(server.id, (t) => path === t.root ? { error: errorText(e) } : { folderErrors: { ...t.folderErrors, [path]: errorText(e) } }, ); @@ -41,6 +48,8 @@ export async function loadFolder(server: DeploymentServerValid, path: string) { /** Resolves the server root once per session and lists it. */ export async function ensureRoot(server: DeploymentServerValid, force = false) { + const source = sourceKey(server); + if (tree(server.id).source !== source) patchTree(server.id, { ...EMPTY_TREE, source }); const current = tree(server.id); if (current.root && !force && current.listings[current.root]) return; patchTree(server.id, { error: null }); @@ -48,10 +57,14 @@ export async function ensureRoot(server: DeploymentServerValid, force = false) { const root = current.root && !force ? current.root - : await withSsh(server.sshConfig, () => call(commands.deployServerRoot(server.id))); + : await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deployServerRoot(server.id)), + ); + if (tree(server.id).source !== source) return; patchTree(server.id, { root }); await loadFolder(server, root); } catch (e) { + if (tree(server.id).source !== source) return; patchTree(server.id, { error: errorText(e) }); } } @@ -59,7 +72,7 @@ export async function ensureRoot(server: DeploymentServerValid, force = false) { /** Reloads the root and the expanded folders. */ export async function refreshAll(server: DeploymentServerValid) { const t = tree(server.id); - if (!t.root) return ensureRoot(server, true); + if (!t.root || t.source !== sourceKey(server)) return ensureRoot(server, true); patchTree(server.id, { listings: {}, folderErrors: {}, error: null }); await loadFolder(server, t.root); await Promise.all([...t.expanded].map((p) => loadFolder(server, p))); @@ -67,7 +80,9 @@ export async function refreshAll(server: DeploymentServerValid) { export async function openRemote(server: DeploymentServerValid, entry: RemoteEntry) { try { - const local = await withSsh(server.sshConfig, () => call(commands.remoteOpen(server.sshConfig, entry.path))); + const local = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteOpen(deploymentConnection(server), entry.path)), + ); await editorService().openFile(local, { focus: true }); } catch (e) { fail(`Cannot open ${entry.name}`, e); @@ -82,7 +97,9 @@ export async function newFolder(server: DeploymentServerValid, parent: string) { }); if (!name) return; try { - await withSsh(server.sshConfig, () => call(commands.remoteMkdir(server.sshConfig, joinRemote(parent, name)))); + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteMkdir(deploymentConnection(server), joinRemote(parent, name))), + ); patchTree(server.id, (t) => ({ expanded: new Set([...t.expanded, parent]) })); await loadFolder(server, parent); } catch (e) { @@ -100,8 +117,8 @@ export async function renameEntry(server: DeploymentServerValid, entry: RemoteEn if (!name || name === entry.name) return; const parent = remoteParent(entry.path); try { - await withSsh(server.sshConfig, () => - call(commands.remoteRename(server.sshConfig, entry.path, joinRemote(parent, name))), + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteRename(deploymentConnection(server), entry.path, joinRemote(parent, name))), ); await loadFolder(server, parent); } catch (e) { @@ -127,7 +144,9 @@ export async function deleteEntries(server: DeploymentServerValid, entries: Remo const parents = new Set(); for (const entry of entries) { try { - await withSsh(server.sshConfig, () => call(commands.remoteDelete(server.sshConfig, entry.path))); + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteDelete(deploymentConnection(server), entry.path)), + ); parents.add(remoteParent(entry.path)); } catch (e) { fail(`Cannot delete ${entry.name}`, e); diff --git a/src/features/deployment/store.ts b/src/features/deployment/store.ts index 279b6f152..d7edd3505 100644 --- a/src/features/deployment/store.ts +++ b/src/features/deployment/store.ts @@ -26,6 +26,7 @@ export interface SecretPrompt { /** Remote Host tool window, per server: resolved root, loaded folders, expanded folders. */ export interface RemoteTreeState { + source: string | null; root: string | null; error: string | null; listings: Record; @@ -52,6 +53,7 @@ export const useDeployment = create(() => ({ })); export const EMPTY_TREE: RemoteTreeState = { + source: null, root: null, error: null, listings: {}, diff --git a/src/features/remote-dev/ConnectDialog.tsx b/src/features/remote-dev/ConnectDialog.tsx index ef8426fd0..7e8eab8e8 100644 --- a/src/features/remote-dev/ConnectDialog.tsx +++ b/src/features/remote-dev/ConnectDialog.tsx @@ -38,7 +38,7 @@ function ConnectDialog() { setError(null); setBusy(true); try { - const entries = await call(commands.remoteList(ssh, dir)); + const entries = await call(commands.remoteBootstrapList(ssh, dir)); const shown = listedDirectory(dir, entries); setBrowse({ dir: shown, folders: browsableFolders(entries) }); setPath(shown); diff --git a/src/features/settings/pages/Deployment.module.css b/src/features/settings/pages/Deployment.module.css index 8f30054e0..43cdb3fea 100644 --- a/src/features/settings/pages/Deployment.module.css +++ b/src/features/settings/pages/Deployment.module.css @@ -54,12 +54,22 @@ } .wide { width: 320px; + max-width: 100%; } .field { display: flex; + flex: 1; + min-width: 0; align-items: center; gap: 6px; } +.field > input { + min-width: 0; + flex: 1; +} +.field > button { + flex-shrink: 0; +} .note { color: var(--fg-subtle); font-size: var(--fs-small); diff --git a/src/features/settings/pages/deployment.tsx b/src/features/settings/pages/deployment.tsx index 024180060..53378a7ee 100644 --- a/src/features/settings/pages/deployment.tsx +++ b/src/features/settings/pages/deployment.tsx @@ -5,10 +5,10 @@ */ import { useEffect, useId, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; -import { call, commands, IpcError, type RemoteEntry } from "@/platform/ipc"; -import { useTx, tx } from "@/platform/i18n"; -import type { DeploymentServerValid } from "@/platform/remoteSettings"; -import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Spinner, T, Tabs } from "@/ui"; +import { call, commands, IpcError, type FsConnection, type RemoteEntry } from "@/platform/ipc"; +import { useTx, tx, txTemplate } from "@/platform/i18n"; +import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings"; +import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui"; import { GlobListEditor, Row } from "../components/Form"; import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect"; import { useSetting } from "../components/context"; @@ -29,7 +29,7 @@ import { PageLink } from "./workflowControls"; import workflow from "./Workflow.module.css"; import styles from "./Deployment.module.css"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); // --------------------------------------------------------------------------- // Connection problems: trust an unknown host key or enter the password. @@ -96,12 +96,12 @@ function ConnectionProblem({ ssh, error, onRetry }: { ssh: string; error: unknow // Remote folder chooser (Root path › Browse…) function RemoteFolderDialog({ - ssh, + connection, initial, onChoose, onClose, }: { - ssh: string; + connection: FsConnection; initial: string; onChoose: (path: string) => void; onClose: () => void; @@ -111,6 +111,7 @@ function RemoteFolderDialog({ const [attempt, setAttempt] = useState(0); const [result, setResult] = useState<{ key: string; folders?: RemoteEntry[]; error?: unknown } | null>(null); const [selected, setSelected] = useState(null); + const connectionKey = JSON.stringify(connection); const key = `${path}\n${attempt}`; const current = result?.key === key ? result : null; const folders = current ? (current.folders ?? []) : null; @@ -118,7 +119,7 @@ function RemoteFolderDialog({ useEffect(() => { let live = true; - call(commands.remoteList(ssh, path)).then( + call(commands.remoteList(JSON.parse(connectionKey) as FsConnection, path)).then( (entries) => { if (!live) return; // `~` lists the login folder: name it by the absolute path the entries report. @@ -136,7 +137,7 @@ function RemoteFolderDialog({ return () => { live = false; }; - }, [ssh, path, key]); + }, [connectionKey, path, key]); const go = (next: string) => { setDraft(next); @@ -166,7 +167,7 @@ function RemoteFolderDialog({ return ( {error ? ( - + ) : (
    (null); - const test = result?.ssh === server.sshConfig ? result : null; + const connection = deploymentConnection(server); + const testKey = `${server.kind}:${server.sshConfig}:${server.rootPath}`; + const test = result?.ssh === testKey ? result : null; const rootId = useId(); const runTest = async () => { - const ssh = server.sshConfig; + const ssh = testKey; setTest({ ssh, kind: "busy" }); try { - const entries = await call(commands.remoteList(ssh, server.rootPath.trim() || "~")); - setTest({ ssh, kind: "ok", count: entries.length }); + await call(commands.deployTestConnection(connection, server.rootPath.trim() || "~")); + setTest({ ssh, kind: "ok", count: 0 }); } catch (e) { setTest({ ssh, kind: "error", error: e }); } @@ -294,12 +297,25 @@ function ConnectionTab({ return (
    update({ name })} /> - - update({ sshConfig })} /> - - Configure… - + + update({ rootPath: e.target.value })} /> - @@ -322,7 +338,11 @@ function ConnectionTab({ placeholder="http://example.com" />
    - {test?.kind === "busy" && } @@ -332,12 +352,15 @@ function ConnectionTab({ )}
    - {test?.kind === "error" && ( - void runTest()} /> - )} + {test?.kind === "error" && + (server.kind === "sftp" ? ( + void runTest()} /> + ) : ( + {errorText(test.error)} + ))} {browsing && ( setBrowsing(false)} onChoose={(rootPath) => { @@ -608,7 +631,18 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [ order: 365, projectScope: true, defaultScope: "project", - keywords: ["sftp", "ssh", "server", "upload", "remote host", "mappings", "web server", "deploy"], + keywords: [ + "local", + "mounted", + "sftp", + "ssh", + "server", + "upload", + "remote host", + "mappings", + "web server", + "deploy", + ], sections: [ { options: [ diff --git a/src/features/settings/pages/deploymentModel.test.ts b/src/features/settings/pages/deploymentModel.test.ts index 56fddec25..de0525904 100644 --- a/src/features/settings/pages/deploymentModel.test.ts +++ b/src/features/settings/pages/deploymentModel.test.ts @@ -54,6 +54,7 @@ describe("server list", () => { expect(nextServerName([a])).toBe("Server 2"); expect(nextServerName([{ ...a, name: "Server 2" }])).toBe("Server 3"); const fresh = newServer([a]); + expect(fresh.kind).toBe("sftp"); expect(fresh.name).toBe("Server 2"); expect(fresh.id).not.toBe(""); expect(fresh.mappings).toEqual([{ local: "", deployment: "", web: "" }]); @@ -63,6 +64,14 @@ describe("server list", () => { expect(copy.id).not.toBe("a"); }); + it("copies a local server without changing its type or mappings", () => { + const local = { ...a, kind: "local" as const, rootPath: "/mounted/www" }; + const copy = newServer([local], local); + expect(copy).toMatchObject({ kind: "local", rootPath: "/mounted/www", sshConfig: "" }); + expect(copy.mappings).toEqual(local.mappings); + expect(copy.mappings).not.toBe(local.mappings); + }); + it("keeps the default server valid", () => { expect(fixDefaultServer([a], "a")).toBe("a"); expect(fixDefaultServer([a], "gone")).toBe("a"); diff --git a/src/features/settings/pages/runTargets.tsx b/src/features/settings/pages/runTargets.tsx index b0e9aa492..88e24d532 100644 --- a/src/features/settings/pages/runTargets.tsx +++ b/src/features/settings/pages/runTargets.tsx @@ -52,7 +52,7 @@ function RemoteFolderDialog({ useEffect(() => { let live = true; - call(commands.remoteList(sshConfig, request.path)).then( + call(commands.remoteList({ kind: "sftp", ssh: sshConfig }, request.path)).then( (list) => { if (!live) return; setEntries(list.filter((e) => e.isDir).sort((a, b) => a.name.localeCompare(b.name))); diff --git a/src/platform/deploymentSettings.test.ts b/src/platform/deploymentSettings.test.ts new file mode 100644 index 000000000..ece25b9e3 --- /dev/null +++ b/src/platform/deploymentSettings.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { deploymentConnection, validateDeployment } from "./remoteSettings"; + +describe("deployment types", () => { + it("reads existing servers as SFTP without changing their connection", () => { + const legacy = { id: "existing", name: "Staging", sshConfig: "ssh", rootPath: "/srv", webUrl: "http://localhost" }; + const server = validateDeployment({ servers: [legacy] }).servers[0]!; + expect(server).toMatchObject({ ...legacy, kind: "sftp" }); + expect(deploymentConnection(server)).toEqual({ kind: "sftp", ssh: "ssh" }); + }); + it.each(["sftp", "local"])("round trips %s settings", (kind) => { + const settings = validateDeployment({ servers: [{ kind, rootPath: "/mounted/www", sshConfig: "retained" }] }); + expect(validateDeployment(JSON.parse(JSON.stringify(settings)))).toEqual(settings); + expect(deploymentConnection(settings.servers[0]!)).toEqual( + kind === "local" ? { kind: "local" } : { kind: "sftp", ssh: "retained" }, + ); + }); + it("normalizes unsupported types to SFTP", () => { + for (const kind of [undefined, "ftp", "ftps", "webdav", "inPlace", null]) { + expect(validateDeployment({ servers: [{ kind }] }).servers[0]!.kind).toBe("sftp"); + } + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 3e6b4eadf..927270a01 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4143,6 +4143,15 @@ "Mappings' deployment paths are relative to this folder.": "映射中的部署路径相对于此文件夹。", "Login folder": "登录目录", "Web server URL": "Web 服务器 URL", + "Choose Folder": "选择文件夹", + "Home folder is unavailable": "无法获取主目录", + "Folder does not exist: {path}": "文件夹不存在:{path}", + "Not a folder: {path}": "不是文件夹:{path}", + "Folder is not writable: {path}: {error}": "文件夹不可写:{path}:{error}", + "Source and destination are the same file: {path}": "源和目标是同一个文件:{path}", + "Local or mounted folder": "本地或挂载的文件夹", + "Folder path": "文件夹路径", + "SFTP": "SFTP 文件传输", "Connected successfully": "连接成功", "Trust Host Key and Retry": "信任主机密钥并重试", "Choose Remote Folder": "选择远程文件夹", diff --git a/src/platform/remoteSettings.ts b/src/platform/remoteSettings.ts index 04292116f..2f285c9ce 100644 --- a/src/platform/remoteSettings.ts +++ b/src/platform/remoteSettings.ts @@ -23,6 +23,7 @@ export interface DeploymentMappingValid { } export interface DeploymentServerValid { + kind: "sftp" | "local"; id: string; name: string; sshConfig: string; @@ -70,6 +71,7 @@ export const DEFAULT_SSH_CONFIG: SshConfigValid = { }; export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = { + kind: "sftp", id: "", name: "", sshConfig: "", @@ -157,6 +159,7 @@ export function validateDeployment(raw: unknown): DeploymentSettingsValid { const o = obj(raw); const d = DEFAULT_DEPLOYMENT; const servers = withIds(o.servers, "server", (s) => ({ + kind: oneOf(s.kind, ["sftp", "local"] as const, "sftp"), id: text(s.id, ""), name: text(s.name, ""), sshConfig: text(s.sshConfig, ""), @@ -208,3 +211,8 @@ export function sshLabel(c: Pick `${prefix}-${Date.now().toString(36)}-${++seq}`; + +/** Backend connection descriptor for deployment and folder browsing. */ +export function deploymentConnection(server: DeploymentServerValid): { kind: "local" } | { kind: "sftp"; ssh: string } { + return server.kind === "local" ? { kind: "local" } : { kind: "sftp", ssh: server.sshConfig }; +} From 53bebe4836dd25b3fe2ca39f86d884cc7c9b5fa3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:08:27 +0800 Subject: [PATCH 08/25] fix(tomcat): resolve the WAR artifact of the selected Maven module (#529) A Tomcat run with a WAR from a nested Maven module failed with "The built WAR artifact does not exist": the artifact path query was evaluated for the reactor parent, not the selected module. The build now resolves the selected POM's effective identity first, using the same options and environment as the build, and asks for that project's artifact. Custom outputs, .mvn settings and root modules are covered, Gradle WAR manifests are written as UTF-8, and Tomcat deployment errors now name the checked path. Fixes #515 --- Cargo.lock | 1 + crates/ide-build/src/process.rs | 17 ++- crates/ide-build/src/web.rs | 112 ++++++++++++++++-- crates/ide-host/src/ops/run/commands.rs | 24 ++-- crates/ide-run/Cargo.toml | 1 + crates/ide-run/src/lib.rs | 5 + crates/ide-run/src/process.rs | 12 ++ crates/ide-run/src/tomcat.rs | 31 ++++- crates/ide-run/tests/process.rs | 5 + crates/ide-run/tests/tomcat_artifact.rs | 150 ++++++++++++++++++++++++ src/platform/locales/text/zh.json | 5 +- 11 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 crates/ide-run/tests/tomcat_artifact.rs diff --git a/Cargo.lock b/Cargo.lock index 9b16ceaac..94b708920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4461,6 +4461,7 @@ name = "ide-run" version = "0.1.29" dependencies = [ "encoding_rs", + "ide-build", "ide-core", "libc", "parking_lot", diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index d0335def8..b598eb791 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -58,6 +58,18 @@ pub(crate) fn run( cwd: &Path, env: &BTreeMap, cancel: &CancellationToken, + on_line: impl FnMut(Stream, &str), +) -> IdeResult { + run_with_env(program, args, cwd, env, false, cancel, on_line) +} + +pub(crate) fn run_with_env( + program: &str, + args: &[String], + cwd: &Path, + env: &BTreeMap, + clear_env: bool, + cancel: &CancellationToken, mut on_line: impl FnMut(Stream, &str), ) -> IdeResult { tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display()); @@ -66,7 +78,7 @@ pub(crate) fn run( ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows)); let program = program_path(program, env); let mut command = Command::new(&program); - command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null()); + 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)] { @@ -74,6 +86,9 @@ pub(crate) fn run( command.process_group(0); } ide_core::process::prepare(&mut command); + if clear_env { + command.env_clear().envs(env).envs(encoding.env); + } let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?; let (tx, rx) = mpsc::channel::<(Stream, String)>(); diff --git a/crates/ide-build/src/web.rs b/crates/ide-build/src/web.rs index c918bb8ee..9f25039ac 100644 --- a/crates/ide-build/src/web.rs +++ b/crates/ide-build/src/web.rs @@ -1,6 +1,6 @@ //! WAR modules and build-tool-owned archive resolution (including custom output names). use crate::BuildModel; -use ide_core::{IdeError, IdeResult}; +use ide_core::{CancellationToken, IdeError, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; use std::{collections::BTreeMap, fs, path::Path}; @@ -47,6 +47,50 @@ pub struct WebBuild { pub args: Vec, pub cwd: String, pub env: BTreeMap, + maven_project: Option<(Vec, std::path::PathBuf)>, +} + +impl WebBuild { + /// Resolve the selected module's effective identity using the build's options and environment. + /// Runs off the UI thread, before launching the reactor build. + pub fn resolve_project( + &mut self, + root: &Path, + env: &BTreeMap, + cancel: &CancellationToken, + ) -> IdeResult<()> { + let Some((args, output)) = &self.maven_project else { + return Ok(()); + }; + if output.exists() { + fs::remove_file(output)?; + } + let cwd = root.join(&self.cwd).canonicalize()?; + let mut env = env.clone(); + if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) { + // -f changes Maven's .mvn search origin; keep the reactor's launcher settings. + let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd); + env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR")); + env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned()); + } + let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?; + if !result.success { + return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim()))); + } + let id = fs::read_to_string(output)?; + let parts: Vec<_> = id.trim().split(':').collect(); + if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) { + return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim()))); + } + let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]); + self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file")); + self.maven_project = None; + Ok(()) + } +} + +fn groovy_literal(s: &str) -> String { + format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) } pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult { @@ -81,21 +125,36 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu .replace('\\', "/"); args.extend(["-pl".into(), module, "-am".into()]); } + // help:evaluate runs once, in the reactor's first project. Query the selected + // POM's effective identity first; profiles and -D options can change its GAV. + let output = cache.join("artifact-project.txt"); + let mut probe = top.options.clone(); + probe.extend([ + "-f".into(), + root.join(&p.pom).to_string_lossy().into_owned(), + "help:evaluate".into(), + "-Dexpression=project.id".into(), + format!("-Doutput={}", output.display()), + ]); args.extend([ "-DskipTests".into(), "package".into(), "help:evaluate".into(), - "-Dexpression=project.artifact.file".into(), format!("-Doutput={}", manifest.display()), ]); - return Ok(WebBuild { command: top.command.clone(), args, cwd: top.dir.clone(), env: p.env.clone() }); + return Ok(WebBuild { + command: top.command.clone(), + args, + cwd: top.dir.clone(), + env: p.env.clone(), + maven_project: Some((probe, output)), + }); } if let Some(path) = id.strip_prefix("gradle:") { let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?; if !artifacts(root, model).iter().any(|a| a.id == id) { return Err(IdeError::invalid("Choose a Gradle WAR module")); } - let literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")); let script = cache.join("war.init.gradle"); fs::write( &script, @@ -105,12 +164,12 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu def warTask = p.tasks.named('war') p.tasks.register('openideaTomcatArtifact') {{ dependsOn warTask - doLast {{ new File({}).text = warTask.get().archiveFile.get().asFile.absolutePath }} + doLast {{ new File({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }} }} }} "#, - literal(path), - literal(&manifest.to_string_lossy()) + groovy_literal(path), + groovy_literal(&manifest.to_string_lossy()) ), )?; let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':')); @@ -119,6 +178,7 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task], cwd: g.dir.clone(), env: g.env.clone(), + maven_project: None, }); } Err(IdeError::invalid("Choose a WAR artifact")) @@ -136,7 +196,9 @@ mod tests { assert_eq!(a.len(), 1); assert_eq!(a[0].name, "web"); let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap(); - assert!(plan.args.contains(&"-Dexpression=project.artifact.file".into())); + let (probe, _) = plan.maven_project.as_ref().unwrap(); + assert!(probe.contains(&"-Dexpression=project.id".into())); + assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned())); assert!( plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt") || a.ends_with("base with spaces\\artifact-path.txt")) @@ -152,6 +214,13 @@ mod tests { let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap(); assert_eq!(plan.cwd, ""); assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]); + assert!( + plan.maven_project + .as_ref() + .unwrap() + .0 + .contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned()) + ); } #[test] fn gradle_war_uses_archive_provider_and_ignores_comments() { @@ -169,4 +238,31 @@ mod tests { assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()")); assert!(!crate::gradle::has_war_plugin("// plugins { war }")); } + + #[test] + fn gradle_manifest_paths_preserve_windows_characters() { + for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] { + let literal = groovy_literal(path); + assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path); + } + } + + #[test] + fn gradle_root_and_nested_modules_write_utf8_archive_paths() { + for module in [":", ":apps:timesheet"] { + let tmp = tempfile::tempdir().unwrap(); + let dir = if module == ":" { "" } else { "apps/timesheet" }; + fs::create_dir_all(tmp.path().join(dir)).unwrap(); + fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'") + .unwrap(); + fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap(); + let model = crate::load(tmp.path(), &tmp.path().join("cache"), None); + let cache = tmp.path().join("工作 cache"); + let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap(); + assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':')))); + let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap(); + assert!(script.contains(&format!("rootProject.project('{}')", module))); + assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'")); + } + } } diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index fdfc21af8..86754386c 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -81,22 +81,21 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I .filter(|s| !s.is_empty()) .or_else(|| artifacts.first().map(|a| &a.id)) .ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?; - let plan = ide_build::web::build( + let mut plan = ide_build::web::build( &project.root, &model, id, &ide_run::tomcat::base_dir(&project.cache_dir, &config.id), )?; - let mut env = plan.env; - env.extend(config.env.clone()); - Ok(Some(RunConfig { + plan.env.extend(config.env.clone()); + let mut build = RunConfig { id: format!("tomcat-build:{}", config.id), name: format!("Build {}", config.name), kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(), - command: plan.command, - args: plan.args, - cwd: Some(std::path::PathBuf::from(plan.cwd).into()), - env, + command: plan.command.clone(), + args: vec![], + cwd: Some(std::path::PathBuf::from(&plan.cwd).into()), + env: plan.env.clone(), detected: false, is_test: false, debug_adapter: None, @@ -106,7 +105,14 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I env_files: config.extras.env_files, ..Default::default() }, - })) + }; + plan.resolve_project( + &project.root, + &ide_run::resolved_env(&project.root, &build)?, + &ide_core::CancellationToken::new(), + )?; + build.args = plan.args; + Ok(Some(build)) }) .await .map_err(|e| IdeError::internal(e.to_string()))? diff --git a/crates/ide-run/Cargo.toml b/crates/ide-run/Cargo.toml index 23df66031..076ad24e7 100644 --- a/crates/ide-run/Cargo.toml +++ b/crates/ide-run/Cargo.toml @@ -24,4 +24,5 @@ quick-xml = "0.42" libc = "0.2" [dev-dependencies] +ide-build = { path = "../ide-build" } tempfile.workspace = true diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index b32ec8b53..0c6e746ae 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -240,6 +240,11 @@ pub fn system_env() -> BTreeMap { ide_core::process::host_vars().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))).collect() } +/// Complete environment for a preparatory tool belonging to this run configuration. +pub fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult> { + process::resolved_env(root, config) +} + /// Path of the user run configuration file for `root`. pub fn user_configs_path(root: &Path) -> PathBuf { config::config_path(root) diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 6fbf8b58c..7a1f6a869 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -661,6 +661,18 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { Ok(LaunchEnv { clear: !extras.include_system_env, vars }) } +pub(crate) fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult> { + let env = launch_env(root, config)?; + let mut vars = if env.clear { BTreeMap::new() } else { crate::system_env() }; + if !env.clear { + vars.extend( + ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))), + ); + } + vars.extend(env.vars); + Ok(vars) +} + fn apply_env(cmd: &mut Command, env: &LaunchEnv) { if env.clear { cmd.env_clear(); diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs index 0f452e0bc..dffb377a4 100644 --- a/crates/ide-run/src/tomcat.rs +++ b/crates/ide-run/src/tomcat.rs @@ -216,7 +216,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: .map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?; let war = PathBuf::from(war.trim()); if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) { - return Err(invalid("The built WAR artifact does not exist")); + return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display()))); } let dest = base.join("deployment"); explode(&war, &dest, resources_only)?; @@ -227,11 +227,14 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: .get("deploymentPath") .filter(|v| !v.trim().is_empty()) .ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?; - let path = root.join(value).canonicalize()?; + let path = root.join(value); + let path = path + .canonicalize() + .map_err(|e| invalid(format!("Invalid Tomcat deployment: {} ({e})", path.display())))?; if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")))) || (kind == "directory" && !path.is_dir()) { - return Err(invalid("Invalid Tomcat deployment")); + return Err(invalid(format!("Invalid Tomcat deployment: {}", path.display()))); } Ok(path) } @@ -436,6 +439,28 @@ mod tests { assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent")); } #[test] + fn deployment_errors_identify_the_checked_path() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("cache"); + fs::create_dir_all(&base).unwrap(); + for path in ["missing.war", r"C:\工作\timesheet\target\timesheet.war", r"\\server\share\timesheet.war"] { + fs::write(base.join("artifact-path.txt"), format!("{path}\r\n")).unwrap(); + let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string(); + assert!(error.contains(path), "{error}"); + } + for kind in ["war", "directory"] { + let mut c = config(); + c.extras.params.insert("deployment".into(), kind.into()); + c.extras.params.insert("deploymentPath".into(), "missing".into()); + let error = deployment(tmp.path(), &base, &c, false).unwrap_err().to_string(); + assert!(error.contains(&tmp.path().join("missing").display().to_string()), "{error}"); + c.extras.params.insert("deploymentPath".into(), "cache".into()); + if kind == "war" { + assert!(deployment(tmp.path(), &base, &c, false).unwrap_err().to_string().contains("cache")); + } + } + } + #[test] fn prepares_isolated_base_and_jdwp_only_for_run() { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().join("Tomcat Home"); diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index de49fcbde..316a411f0 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -291,6 +291,10 @@ fn env_files_system_env_and_redirects() { config.extras.include_system_env = false; config.extras.stdin_file = Some("input.txt".into()); config.extras.output_file = Some("logs/out.log".into()); + let prepared = ide_run::resolved_env(root, &config).unwrap(); + assert_eq!(prepared.get("FROM_FILE").map(String::as_str), Some("file")); + assert_eq!(prepared.get("OVERRIDDEN").map(String::as_str), Some("config")); + assert!(!prepared.contains_key("HOME")); let (id, chunks) = start(&h, &config); // stdin comes from the file: writing to it is refused. assert!(h.pm.write_stdin(id, "x").is_err()); @@ -302,6 +306,7 @@ fn env_files_system_env_and_redirects() { // With the system environment, HOME is inherited. config.extras.include_system_env = true; + assert!(ide_run::resolved_env(root, &config).unwrap().contains_key("HOME")); config.extras.stdin_file = None; config.extras.output_file = None; config.args = vec!["-c".into(), "printf '%s' \"${HOME:+set}\"".into()]; diff --git a/crates/ide-run/tests/tomcat_artifact.rs b/crates/ide-run/tests/tomcat_artifact.rs new file mode 100644 index 000000000..52c5045d7 --- /dev/null +++ b/crates/ide-run/tests/tomcat_artifact.rs @@ -0,0 +1,150 @@ +//! Opt-in build-tool integration checks: cargo test -p ide-run --test tomcat_artifact -- --ignored +use std::{fs, path::Path, process::Command}; + +fn write(root: &Path, path: &str, text: &str) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); +} + +fn build_and_deploy(root: &Path, id: &str, expected: &Path) { + let cache = root.join("工作 cache"); + let model = ide_build::load(root, &cache, None); + let mut plan = ide_build::web::build(root, &model, id, &cache).unwrap(); + plan.resolve_project(root, &std::env::vars().collect(), &ide_core::CancellationToken::new()).unwrap(); + let output = Command::new(&plan.command) + .args(&plan.args) + .envs(&plan.env) + .current_dir(root.join(&plan.cwd)) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let manifest = fs::read_to_string(cache.join("artifact-path.txt")).unwrap(); + assert_eq!(Path::new(manifest.trim()).canonicalize().unwrap(), expected.canonicalize().unwrap()); + let mut config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({ + "id": "tomcat:test", "name": "Tomcat", "kind": "tomcat", "command": "catalina", + "args": [], "env": {}, "detected": false, "isTest": false + })) + .unwrap(); + config.cwd = Some("timesheet".into()); + config.extras.params.insert("artifact".into(), id.into()); + let deployed = ide_run::tomcat::deployment(root, &cache, &config, false).unwrap(); + assert_eq!(fs::read_to_string(deployed.join("index.html")).unwrap(), "issue 515 timesheet"); + let siblings: Vec<_> = fs::read_dir(expected.parent().unwrap()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".war.")) + .collect(); + assert!(siblings.is_empty(), "Unexpected WAR siblings: {siblings:?}"); +} + +#[test] +#[ignore = "requires Maven and downloads Maven plugins"] +fn maven_packaged_artifacts_deploy_from_root_and_nested_reactors() { + for (reactor, module, custom) in + [("", "", false), ("", "timesheet", false), ("parent", "apps/timesheet", true), ("timesheet", "", true)] + { + let tmp = tempfile::Builder::new().prefix("tomcat maven ").tempdir().unwrap(); + let root = tmp.path(); + let reactor_dir = root.join(reactor); + let module_dir = reactor_dir.join(module); + let plugins = "maven-war-plugin3.4.0false"; + let parent = if module.is_empty() && custom { + write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n"); + "test${revision}1" + .to_owned() + } else if module.is_empty() { + "test1".to_owned() + } else { + write( + &reactor_dir, + "pom.xml", + &format!( + "4.0.0testparent1pomcore{module}{plugins}" + ), + ); + write( + &reactor_dir, + "core/pom.xml", + "4.0.0testparent1core", + ); + let relative = "../".repeat(module.split('/').count()); + format!( + "testparent1{relative}pom.xml" + ) + }; + let dependency = if module.is_empty() { + "" + } else { + "testcore1" + }; + let output = if custom { + "${project.basedir}/custom outputcustom-timesheet" + } else { + "timesheet" + }; + write( + &module_dir, + "pom.xml", + &format!( + "4.0.0{parent}timesheetwar{dependency}{plugins}{output}" + ), + ); + write(&module_dir, "src/main/webapp/index.html", "issue 515 timesheet"); + if custom && !module.is_empty() { + for dir in [&reactor_dir, &reactor_dir.join("core"), &module_dir] { + let pom = dir.join("pom.xml"); + let text = + fs::read_to_string(&pom).unwrap().replace("1", "${revision}"); + fs::write(pom, text).unwrap(); + } + let pom = reactor_dir.join("pom.xml"); + fs::write( + &pom, + fs::read_to_string(&pom).unwrap().replace( + "pom", + "pom1", + ), + ) + .unwrap(); + write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n"); + write(&module_dir, ".mvn/maven.config", "-Drevision=3\n"); + } + let pom = module_dir.join("pom.xml").strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"); + let expected = + module_dir.join(if custom { "custom output/custom-timesheet.war" } else { "target/timesheet.war" }); + build_and_deploy(root, &format!("maven:{pom}"), &expected); + } +} + +#[test] +#[ignore = "requires Gradle"] +fn gradle_packaged_artifacts_deploy_with_custom_archive_names() { + for module in ["", "apps/timesheet"] { + let tmp = tempfile::Builder::new().prefix("tomcat gradle ").tempdir().unwrap(); + let root = tmp.path(); + let settings = if module.is_empty() { + "rootProject.name = 'timesheet'\n" + } else { + "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'\n" + }; + write(root, "settings.gradle", settings); + write( + &root.join(module), + "build.gradle", + "plugins { id 'war' }\ntasks.named('war') { archiveFileName = 'custom-timesheet.war'; destinationDirectory = layout.projectDirectory.dir('custom output') }\n", + ); + write(&root.join(module), "src/main/webapp/index.html", "issue 515 timesheet"); + let project = if module.is_empty() { ":" } else { ":apps:timesheet" }; + build_and_deploy( + root, + &format!("gradle:{project}"), + &root.join(module).join("custom output/custom-timesheet.war"), + ); + } +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 927270a01..e34a71919 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4936,9 +4936,10 @@ "Choose a Gradle WAR module": "请选择 Gradle WAR 模块", "Build the selected WAR artifact before launching Tomcat": "请先构建所选 WAR 构件,再启动 Tomcat", "Build the WAR artifact before launching Tomcat": "请先构建 WAR 构件,再启动 Tomcat", - "The built WAR artifact does not exist": "构建的 WAR 构件不存在", + "The built WAR artifact does not exist: {path}": "构建的 WAR 构件不存在:{path}", + "Cannot resolve Maven WAR artifact: {details}": "无法解析 Maven WAR 构件:{details}", "Choose a WAR file or exploded directory": "请选择 WAR 文件或展开目录", - "Invalid Tomcat deployment": "无效的 Tomcat 部署", + "Invalid Tomcat deployment: {path}": "无效的 Tomcat 部署:{path}", "Unknown Tomcat deployment type": "未知的 Tomcat 部署类型", "Choose a Tomcat installation directory": "请选择 Tomcat 安装目录", "HTTP port must be between 1 and 65535": "HTTP 端口必须介于 1 和 65535 之间", From d4b354732c10dc9cb47e6495ba428f4413a15cef Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:09:18 +0800 Subject: [PATCH 09/25] feat(find): open in-file results in the Find window (#527) The in-file Find bar had no way to see all matches at once. Its new More menu adds Open Results in Find Window (Alt+F7 while the bar has focus), which lists the current query's matches as an Occurrences tab in the Find tool window, using the open editor text and the case, word, regex and selection options. The menu also has the multiple-cursor occurrence actions and a Scroll to Results During Typing toggle. Fixes #523 --- src/editor/core/actions.ts | 39 ++++- src/editor/core/cm/find.test.ts | 116 ++++++++++++++ src/editor/core/cm/find.ts | 24 +++ src/editor/core/view/FindBar.test.tsx | 149 +++++++++++++++++ src/editor/core/view/FindBar.tsx | 151 +++++++++++++++++- .../search/__tests__/editorResults.test.tsx | 45 ++++++ .../search/__tests__/findWindow.test.ts | 31 +++- src/features/search/find/FindToolWindow.tsx | 12 +- src/features/search/find/session.ts | 5 +- src/features/search/find/windowStore.ts | 22 ++- src/features/search/index.ts | 5 +- src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/locales/text/zh.json | 7 +- src/workbench/contributions.ts | 22 ++- 16 files changed, 613 insertions(+), 18 deletions(-) create mode 100644 src/editor/core/view/FindBar.test.tsx create mode 100644 src/features/search/__tests__/editorResults.test.tsx diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 8de51c9e9..ee4ee2c27 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -31,7 +31,7 @@ import { type SimpleRange, } from "./model/selectionStack"; import type { EditorSlot } from "./model/slot"; -import { findAgain, openFindBar } from "./view/FindBar"; +import { findAgain, findPanelTarget, openFindBar, openFindBarResults, useFindPreferences } from "./view/FindBar"; import { collapseAll, collapseRecursively, @@ -479,6 +479,19 @@ export function registerEditorActions(): () => void { const pane = s.panes[s.activePane]; return pane?.active ? { paneId: pane.id, tabId: pane.active } : null; }; + const occurrence = (id: string, title: string, run: (view: EditorView) => unknown): Action => ({ + id, + title, + category: "Edit", + when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`, + run: ({ arg, ctx }) => { + const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null; + if (view) { + run(view); + view.focus(); + } else withView(run)(); + }, + }); return registerActions([ schemeSwitch("editor.toggleSemanticHighlighting", "Semantic Highlighting", "semanticHighlighting", [ "rainbow variables", @@ -552,10 +565,8 @@ export function registerEditorActions(): () => void { category: "Folding", keywords: ["unfold"], }), - text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addNextOccurrence(v), { - category: "Edit", - }), - text("code.selectAllOccurrences", "Select All Occurrences", (v) => selectAllOccurrences(v), { category: "Edit" }), + occurrence("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", addNextOccurrence), + occurrence("code.selectAllOccurrences", "Select All Occurrences", selectAllOccurrences), text("code.completeStatement", "Complete Current Statement", (v, slot) => completeCurrentStatement(v, slot)), text("code.startNewLine", "Start New Line", (v) => startNewLine(v)), text("code.startNewLineBefore", "Start New Line Before Current", (v) => startNewLineBefore(v)), @@ -623,7 +634,7 @@ export function registerEditorActions(): () => void { text("code.addCaretsToLineEnds", "Add Carets to Ends of Selected Lines", (v) => addCaretsToLineEnds(v), { category: "Edit", }), - text("code.unselectOccurrence", "Unselect Occurrence", (v) => unselectOccurrence(v), { category: "Edit" }), + occurrence("code.unselectOccurrence", "Unselect Occurrence", unselectOccurrence), text("code.pasteFromHistory", "Paste from History…", (v) => pasteFromHistory(v), { category: "Edit", keywords: ["clipboard", "paste multiple"], @@ -717,6 +728,22 @@ export function registerEditorActions(): () => void { if (slot) toggleReadOnly(slot.model); }, }, + { + id: "find.openInWindow", + title: "Open Results in Find Window", + category: "Find", + when: "findBarHasResults", + keyWhen: "findBarFocus", + run: ({ arg }) => openFindBarResults(arg), + }, + { + id: "find.scrollToResults", + title: "Scroll to Results During Typing", + category: "Find", + when: "findBarOpen", + toggled: () => useFindPreferences.getState().scrollToResults, + run: () => useFindPreferences.setState((s) => ({ scrollToResults: !s.scrollToResults })), + }, { id: "find.inFile", title: "Find", diff --git a/src/editor/core/cm/find.test.ts b/src/editor/core/cm/find.test.ts index 091e5adf9..b56e7fd5d 100644 --- a/src/editor/core/cm/find.test.ts +++ b/src/editor/core/cm/find.test.ts @@ -6,6 +6,7 @@ import { StreamLanguage } from "@codemirror/language"; import { literalKindAt } from "./literals"; import { countMatches, + findResultLines, excludeCurrent, findExcluded, isExcluded, @@ -119,6 +120,17 @@ describe("search context, exclude and preserve case", () => { } const starts = (v: EditorView) => countMatches(v.state).starts; + it.each(["inComments", "inStringLiterals", "exceptComments", "exceptCommentsAndStringLiterals"] as const)( + "exports the same %s syntax filter as the bar", + (context) => { + const v = makeCode({ context }); + const lines = findResultLines(v.state, { ...base, search: "foo", context }); + expect(lines.flatMap((m) => m.ranges.map(([from]) => v.state.doc.line(m.line + 1).from + from))).toEqual( + starts(v), + ); + }, + ); + it("filters matches by comment / string literal", () => { expect(starts(makeCode({}))).toEqual([0, 5, 14, 18]); expect(starts(makeCode({ context: "inComments" }))).toEqual([14]); @@ -160,3 +172,107 @@ describe("search context, exclude and preserve case", () => { expect(sel(w)).toEqual([4, 7]); }); }); + +describe("in-file Find result lines", () => { + it.each([ + [ + { search: "foo" }, + [ + [ + 0, + [ + [3, 6], + [7, 10], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "foo", caseSensitive: true }, + [ + [ + 0, + [ + [3, 6], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "foo", wholeWord: true }, + [ + [ + 0, + [ + [3, 6], + [7, 10], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "f.o", regexp: true, caseSensitive: true }, + [ + [ + 0, + [ + [3, 6], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [{ search: "missing" }, []], + [{ search: "" }, []], + [{ search: "[", regexp: true }, []], + ] as const)("preserves matching options %j and UTF-16 columns", (options, expected) => { + const v = make("😀 foo FOO foobar\nfoo", options); + expect(findResultLines(v.state, { ...useOptions, ...options }).map((m) => [m.line, m.ranges])).toEqual(expected); + }); + + const useOptions: FindOptions = { search: "foo", regexp: false, caseSensitive: false, wholeWord: false, replace: "" }; + + it("searches edited text in the selection scope, grouping ranges on the same line", () => { + const v = make("foo\nfoo foo\nfoo", useOptions, { from: 4, to: 11 }); + v.dispatch({ changes: { from: 4, to: 7, insert: "FOO" } }); + expect(findResultLines(v.state, useOptions)).toEqual([ + { + line: 1, + text: "FOO foo", + textOffset: 0, + ranges: [ + [0, 3], + [4, 7], + ], + }, + ]); + }); + + it("splits multiline matches into highlighted result lines", () => { + const v = make("before foo\nbar after", { search: "foo\nbar" }); + expect(findResultLines(v.state, { ...useOptions, search: "foo\nbar" })).toEqual([ + { line: 0, text: "before foo", textOffset: 0, ranges: [[7, 10]] }, + { line: 1, text: "bar after", textOffset: 0, ranges: [[0, 3]] }, + ]); + }); + + it("retains excluded occurrences", () => { + const v = make("foo foo foo", useOptions); + v.dispatch({ effects: toggleFindExcluded.of({ from: 4, to: 7 }) }); + expect(findResultLines(v.state, useOptions)[0]?.ranges).toEqual([ + [0, 3], + [8, 11], + ]); + }); + + it("exports every match beyond the bar counter limit", () => { + const v = make("foo\n".repeat(10_005), useOptions); + expect(findResultLines(v.state, useOptions)).toHaveLength(10_005); + }); +}); diff --git a/src/editor/core/cm/find.ts b/src/editor/core/cm/find.ts index 27644fe65..9c7358d4d 100644 --- a/src/editor/core/cm/find.ts +++ b/src/editor/core/cm/find.ts @@ -28,6 +28,7 @@ import { setSearchQuery, } from "@codemirror/search"; import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; +import type { LineMatch } from "@/platform/ipc"; import { contextAccepts, preserveCase, type SearchContext } from "@/platform/search"; import { literalKindAt, literalsExtension } from "./literals"; @@ -376,3 +377,26 @@ export function excludeCurrent(view: EditorView): boolean { if (!excluded) step(view, true); return true; } + +/** Result lines from the same query, scope and syntax filters used by the bar. */ +export function findResultLines(state: EditorState, options: FindOptions, scope = scopeOf(state)) { + const query = makeQuery(options); + const lines = new Map(); + if (!query.valid) return []; + const cursor = query.getCursor(state, Math.min(scope.from, state.doc.length), Math.min(scope.to, state.doc.length)); + for (let r = cursor.next(); !r.done; r = cursor.next()) { + const { from, to } = r.value; + const first = state.doc.lineAt(from); + const last = state.doc.lineAt(Math.max(from, to - 1)); + for (let number = first.number; number <= last.number; number++) { + const line = state.doc.line(number); + let result = lines.get(number); + if (!result) { + result = { line: number - 1, text: line.text, textOffset: 0, ranges: [] }; + lines.set(number, result); + } + result.ranges.push([Math.max(from, line.from) - line.from, Math.min(to, line.to) - line.from]); + } + } + return [...lines.values()]; +} diff --git a/src/editor/core/view/FindBar.test.tsx b/src/editor/core/view/FindBar.test.tsx new file mode 100644 index 000000000..fbe166300 --- /dev/null +++ b/src/editor/core/view/FindBar.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { executeAction, registerAction } from "@/platform/actions"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import { getContext } from "@/platform/context"; +import { provideFindResults } from "@/workbench/contributions"; +import { registerEditorActions } from "../actions"; +import { findExtension, findResultLines } from "../cm/find"; +import { FindPanel, useFindOptions, useFindPreferences, type FindTarget } from "./FindBar"; + +const disposers: Array<() => void> = []; +afterEach(() => { + disposers + .splice(0) + .reverse() + .forEach((dispose) => dispose()); + useFindOptions.setState({ search: "", regexp: false, caseSensitive: false, wholeWord: false }); + useFindPreferences.setState({ scrollToResults: true }); + useKeymap.setState({ preset: "intellij-macos", overrides: {} }); +}); + +function setup() { + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: "foo\nFOO\nfoo", + extensions: [findExtension(), EditorState.allowMultipleSelections.of(true)], + }), + }); + const target: FindTarget = { + id: "test-find", + view: () => view, + watch: () => () => {}, + isOpen: () => true, + results: (options) => ({ + title: `Occurrences of '${options.search}'`, + query: { + pattern: options.search, + isRegex: options.regexp, + caseSensitive: options.caseSensitive, + wholeWord: options.wholeWord, + include: [], + exclude: [], + scope: { kind: "files", paths: ["/a.ts"] }, + maxMatches: 0, + }, + read: () => ({ path: "/a.ts", relPath: "a.ts", matches: findResultLines(view.state, options) }), + }), + }; + disposers.push(() => view.destroy(), registerEditorActions(), installKeyboardDispatcher()); + const panel = render( {}} />); + disposers.push(panel.unmount); + return { view, field: screen.getByRole("textbox", { name: "Find" }) }; +} + +const setSearch = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } }); + +describe("Find bar More actions", () => { + it("opens current results from the menu and reserves Alt+F7 while focused", async () => { + const user = userEvent.setup(); + const open = vi.fn(); + const usages = vi.fn(); + disposers.push(provideFindResults(open), registerAction({ id: "find.usages", title: "Find Usages", run: usages })); + const { field } = setup(); + setSearch(field, "foo"); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Open Results in Find Window/ })); + expect(open).toHaveBeenCalledOnce(); + expect(open.mock.calls[0]![0].read().matches).toHaveLength(3); + field.focus(); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledTimes(2); + expect(usages).not.toHaveBeenCalled(); + setSearch(field, "missing"); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledTimes(2); + expect(usages).not.toHaveBeenCalled(); + expect(getContext().findBarHasResults).toBe(false); + }); + + it.each(["intellij-macos", "intellij-windows", "vscode-macos", "vscode-windows", "eclipse-macos", "eclipse-windows"])( + "exports with Alt+F7 in the %s preset", + (preset) => { + useKeymap.setState({ preset }); + const open = vi.fn(); + disposers.push(provideFindResults(open)); + const { field } = setup(); + setSearch(field, "foo"); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledOnce(); + }, + ); + + it("honors a rebound shortcut and releases it outside the bar", () => { + const open = vi.fn(); + const other = vi.fn(); + useKeymap.setState({ overrides: { "find.openInWindow": ["Alt+F8"], "find.usages": ["Alt+F8"] } }); + disposers.push(provideFindResults(open), registerAction({ id: "find.usages", title: "Find Usages", run: other })); + const { field, view } = setup(); + setSearch(field, "foo"); + fireEvent.keyDown(field, { key: "F8", code: "F8", altKey: true }); + expect(open).toHaveBeenCalledOnce(); + view.focus(); + fireEvent.keyDown(view.contentDOM, { key: "F8", code: "F8", altKey: true }); + expect(other).toHaveBeenCalledOnce(); + expect(open).toHaveBeenCalledOnce(); + }); + + it.each(["", "missing", "["])("disables export for %j", async (query) => { + const user = userEvent.setup(); + const { field } = setup(); + if (query === "[") act(() => useFindOptions.setState({ regexp: true })); + setSearch(field, query); + await user.click(screen.getByRole("button", { name: "More" })); + expect(screen.getByRole("menuitem", { name: /Open Results in Find Window/ })).toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("runs existing multiple cursor actions on the searched editor", async () => { + const user = userEvent.setup(); + const { field, view } = setup(); + setSearch(field, "foo"); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Select All Occurrences/ })); + expect(view.state.selection.ranges).toHaveLength(2); + field.focus(); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Unselect Occurrence/ })); + expect(view.state.selection.ranges).toHaveLength(1); + }); + + it("turns off incremental selection while retaining results and explicit navigation", async () => { + const { field, view } = setup(); + await act(async () => { + await executeAction("find.scrollToResults"); + }); + expect(useFindPreferences.getState().scrollToResults).toBe(false); + setSearch(field, "foo"); + expect(view.state.selection.main.empty).toBe(true); + await waitFor(() => expect(screen.getByText("?/3")).toBeInTheDocument()); + fireEvent.keyDown(field, { key: "Enter", code: "Enter" }); + expect(view.state.selection.main.empty).toBe(false); + }); +}); diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index df139ed0e..438bee707 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -21,6 +21,7 @@ import { findNext, findPrevious, findScope, + findResultLines, isExcluded, regexValid, replaceAll, @@ -33,7 +34,12 @@ import { type FindScope, } from "../cm/find"; import styles from "./EditorArea.module.css"; -import { hasPrimaryModifier } from "@/platform/keymap"; +import { executeAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { showFindResults, type FindResultsSource } from "@/workbench/contributions"; +import { relativePath, useProject } from "@/platform/project"; +import { tx } from "@/platform/i18n"; +import { bindingsFor, hasPrimaryModifier, normalizeBinding, registerKeyClaim, shortcutLabel } from "@/platform/keymap"; /** The query is shared by all panes and survives closing the bar, like IDEA. */ export const useFindOptions = create>(() => ({ @@ -67,6 +73,7 @@ export interface FindTarget { watch(fn: (u: { docChanged: boolean; selectionSet: boolean }) => void): () => void; /** Still open: an unmount that isn't a close (React's dev-mode remount) keeps the highlights. */ isOpen(): boolean; + results?(options: FindOptions): FindResultsSource | null; } function activeView(paneId: string): EditorView | null { @@ -91,12 +98,68 @@ function paneTarget(paneId: string): FindTarget { return () => void slot.listeners.delete(fn); }, isOpen: () => !!useFindBars.getState()[paneId], + results(options) { + const tab = useTabs.getState().panes[paneId]?.active; + const slot = tab ? editorController().slotFor(tab) : null; + if (!slot) return null; + const state = slot.currentState(); + const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length }; + const path = slot.ctx.path; + const root = useProject.getState().project?.root; + return { + title: tx`Occurrences of '${options.search}'`, + query: { + pattern: options.search, + isRegex: options.regexp, + caseSensitive: options.caseSensitive, + wholeWord: options.wholeWord, + context: options.context, + include: [], + exclude: [], + scope: { kind: "files", paths: [path] }, + maxMatches: 0, + }, + read: () => { + const current = slot.disposed ? state : slot.currentState(); + return { + path, + relPath: root ? relativePath(root, path) : path, + matches: findResultLines( + current, + options, + state.field(findScope, false) + ? (current.field(findScope, false) ?? scope) + : { from: 0, to: current.doc.length }, + ), + }; + }, + }; + }, }; paneTargets.set(paneId, target); } return target; } +export const useFindPreferences = create(() => ({ scrollToResults: true })); +const panelTargets = new Map(); +let focusedTarget: FindTarget | undefined; + +export function findPanelTarget(arg?: unknown): FindTarget | undefined { + return typeof arg === "string" ? panelTargets.get(arg) : focusedTarget; +} + +export function openFindBarResults(arg?: unknown) { + const target = findPanelTarget(arg); + const opts = useFindOptions.getState(); + if (!opts.search || (opts.regexp && !regexValid(opts.search))) return; + const source = target?.results?.({ ...opts }); + if (source?.read().matches.length) { + rememberQuery(false); + showFindResults(source); + } +} + export function closeFindBar(paneId: string) { useFindBars.setState({ [paneId]: undefined }); } @@ -236,11 +299,54 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo const replaceRef = useRef(null); const findBoxRef = useRef(null); const replaceBoxRef = useRef(null); + const panelRef = useRef(null); + const moreRef = useRef(null); + const scrollToResults = useFindPreferences((s) => s.scrollToResults); + const [moreMenu, setMoreMenu] = useState(false); const filterRef = useRef(null); const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null); const [filterMenu, setFilterMenu] = useState(false); const setOpts = (patch: Partial) => useFindOptions.setState(patch); + useLayoutEffect(() => { + panelTargets.set(id, target); + setContext("findBarOpen", true); + return () => { + panelTargets.delete(id); + setContext("findBarOpen", panelTargets.size > 0); + if (focusedTarget === target) { + focusedTarget = undefined; + setContext("findBarFocus", false); + setContext("findBarHasResults", false); + } + }; + }, [id, target]); + + useEffect(() => { + if (focusedTarget === target) + setContext( + "findBarHasResults", + !!target.results && !!opts.search && (!opts.regexp || regexValid(opts.search)) && !!count?.total, + ); + }, [target, opts, count]); + + // Reserve this bar's shortcut even when there are no results to export. + useEffect( + () => + registerKeyClaim((e, stroke) => { + if ( + !panelRef.current?.contains(e.target as Node) || + !bindingsFor("find.openInWindow").some((binding) => normalizeBinding(binding) === stroke) + ) + return false; + e.preventDefault(); + e.stopPropagation(); + void executeAction("find.openInWindow", id); + return true; + }), + [id], + ); + // Opening (or ⌘F again): focus + select the field. useLayoutEffect(() => { inputRef.current?.focus(); @@ -254,7 +360,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo const valid = !opts.regexp || regexValid(opts.search); setQuery(view, valid ? opts : { ...opts, search: "" }); view.dispatch({ effects: setFindOpen.of(true) }); - if (valid && opts.search) { + if (valid && opts.search && useFindPreferences.getState().scrollToResults) { const from = view.state.selection.main.from; view.dispatch({ selection: { anchor: from } }); findNext(view); @@ -387,10 +493,42 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo run: () => setOpts({ context: c.value }), })), ]; + const invoke = (action: string) => void executeAction(action, id); + const moreItems: MenuItem[] = [ + { + label: "Open Results in Find Window", + shortcut: shortcutLabel("find.openInWindow"), + disabled: !target.results || !opts.search || invalid || !count?.total, + run: () => invoke("find.openInWindow"), + }, + { kind: "separator" }, + { kind: "header", label: "Multiple Cursors" }, + ...( + [ + ["code.addCaretNextOccurrence", "Add Selection for Next Occurrence"], + ["code.unselectOccurrence", "Unselect Occurrence"], + ["code.selectAllOccurrences", "Select All Occurrences"], + ] as const + ).map(([action, label]) => ({ label, shortcut: shortcutLabel(action), run: () => invoke(action) })), + { kind: "separator" }, + { label: "Scroll to Results During Typing", checked: scrollToResults, run: () => invoke("find.scrollToResults") }, + ]; const canReplace = !!opts.search && !invalid; return (
    { + focusedTarget = target; + setContext("findBarFocus", true); + setContext( + "findBarHasResults", + !!target.results && !!opts.search && (!opts.regexp || regexValid(opts.search)) && !!count?.total, + ); + }} + onBlurCapture={(e) => { + if (!panelRef.current?.contains(e.relatedTarget as Node | null)) setContext("findBarFocus", false); + }} className={clsx(styles.find, onReplaceShown && styles.findExpandable)} role="search" aria-label={replace ? "Replace in file" : "Find in file"} @@ -468,6 +606,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo pressed={scoped || opts.context !== "any"} onClick={() => setFilterMenu(true)} /> + setMoreMenu(true)} />
    {replace && ( @@ -519,6 +658,14 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo aria-label="Recent searches" onClose={() => setHistoryMenu(null)} /> + setMoreMenu(false)} + /> { + const openFile = vi.fn(async () => {}); + provideEditorService({ + openFile, + openInput: async () => {}, + activePath: () => null, + activeCursor: () => null, + saveAll: async () => {}, + }); + useFindViewOptions.setState({ preview: false }); + openEditorResults({ + title: "Occurrences of 'foo'", + query: { + pattern: "foo", + isRegex: false, + wholeWord: true, + caseSensitive: true, + include: [], + exclude: [], + scope: { kind: "files", paths: ["/p/a.ts"] }, + maxMatches: 0, + }, + read: () => ({ + path: "/p/a.ts", + relPath: "a.ts", + matches: [{ line: 5, text: " foo()", textOffset: 0, ranges: [[2, 5]] }], + }), + }); + render(); + const match = await screen.findByRole("treeitem", { name: /6.*foo/ }); + expect(match.querySelector("mark")).toHaveTextContent("foo"); + fireEvent.mouseDown(match, { button: 0 }); + expect(openFile).toHaveBeenCalledWith("/p/a.ts", { + focus: false, + selection: { line: 5, col: 2, endLine: 5, endCol: 5 }, + }); +}); diff --git a/src/features/search/__tests__/findWindow.test.ts b/src/features/search/__tests__/findWindow.test.ts index 5bd3e02c1..2b9ca9808 100644 --- a/src/features/search/__tests__/findWindow.test.ts +++ b/src/features/search/__tests__/findWindow.test.ts @@ -1,8 +1,15 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FileMatches, SearchQuery } from "@/platform/ipc"; import { buildFindTree } from "../find/FindToolWindow"; import type { FindDeps } from "../find/session"; -import { disposeFindWindow, openInFindWindow, pinFindTab, useFindWindow } from "../find/windowStore"; +import { + disposeFindWindow, + openEditorResults, + openInFindWindow, + pinFindTab, + rerunFindTab, + useFindWindow, +} from "../find/windowStore"; const q = (pattern: string): SearchQuery => ({ pattern, @@ -67,3 +74,23 @@ describe("buildFindTree", () => { ]); }); }); + +describe("editor-backed Find results", () => { + it("uses the shared session and recomputes live results on rerun without IPC", async () => { + const current = file("unsaved.ts"); + current.matches[0]!.text = "foo foo"; + current.matches[0]!.ranges.push([4, 7]); + const read = vi.fn(() => current); + const id = openEditorResults({ title: "Occurrences of 'foo'", query: q("foo"), read }); + await vi.waitFor(() => expect(useFindWindow.getState().tabs[0]?.state.status).toBe("done")); + expect(useFindWindow.getState().tabs[0]?.state.files).toEqual([current]); + expect(useFindWindow.getState().tabs[0]?.state.matchCount).toBe(2); + const tree = buildFindTree(useFindWindow.getState().tabs[0]!.state.files); + expect(tree[0]).toMatchObject({ kind: "file", count: 2 }); + read.mockReturnValue({ ...file("unsaved.ts"), matches: [] }); + rerunFindTab(id); + await vi.waitFor(() => expect(useFindWindow.getState().tabs[0]?.state.files).toEqual([])); + expect(read).toHaveBeenCalledTimes(2); + expect(useFindWindow.getState().tabs[0]?.state.summary?.matches).toBe(0); + }); +}); diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index 9dae9f0d5..db1ae34c1 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -24,6 +24,7 @@ import { import { notifications, useOccurrenceNavigator } from "@/workbench/contributions"; import { confirm } from "@/workbench/messages"; import { basename, openLocation, relDir } from "../common/open"; +import { countFileMatches } from "./session"; import { CodePreview, MatchText, type PreviewHighlight } from "./parts"; import { NO_MARKS, @@ -104,18 +105,19 @@ export function buildFindTree(files: readonly FileMatches[], groupByDirectory = starts: m.ranges.map(([s]) => ({ line: m.line, col: s + m.textOffset })), }; }); + const count = countFileMatches(f); const file: Node = { kind: "file", key: `f:${f.path}`, path: f.path, name: basename(f.relPath), dir, - count: f.matches.length, + count, children, }; if (d) { d.children.push(file); - d.count += f.matches.length; + d.count += count; } else flat.push(file); } if (!groupByDirectory) { @@ -434,6 +436,12 @@ function FindTabView({ tab }: { tab: FindTab }) { onSelectionChange={(next, lead) => { setSelection(next); setLeadNode(lead); + if (tab.editorResults && lead?.kind === "match") + void openLocation( + lead.path, + { line: lead.line, col: lead.col, endLine: lead.line, endCol: lead.endCol }, + { focus: false }, + ); }} multiSelect onActivate={(n) => { diff --git a/src/features/search/find/session.ts b/src/features/search/find/session.ts index 51b40e2d7..5cc0e9970 100644 --- a/src/features/search/find/session.ts +++ b/src/features/search/find/session.ts @@ -31,6 +31,9 @@ export interface FindDeps { nextRequestId(): RequestId; } +export const countFileMatches = (file: FileMatches): number => + file.matches.reduce((count, line) => count + line.ranges.length, 0); + export const IDLE: FindState = { status: "idle", query: null, files: [], matchCount: 0, summary: null, error: null }; function errorMessage(e: unknown): string { @@ -86,7 +89,7 @@ export class FindSession { const summary = await this.deps.start(request, query, (f) => { if (seq !== this.seq) return; files.push(f); - matchCount += f.matches.length; + matchCount += countFileMatches(f); this.update({ ...this.state, files: [...files], matchCount }); }); if (seq !== this.seq) return; diff --git a/src/features/search/find/windowStore.ts b/src/features/search/find/windowStore.ts index c92bb6645..de8aee1d7 100644 --- a/src/features/search/find/windowStore.ts +++ b/src/features/search/find/windowStore.ts @@ -4,11 +4,12 @@ */ import { create } from "zustand"; import { call, commands, nextRequestId, type SearchQuery } from "@/platform/ipc"; +import type { FindResultsSource } from "@/workbench/contributions"; import type { MenuItem } from "@/ui"; import { loadJson, saveJson } from "../common/open"; import { maybeToolWindowService } from "../common/services"; import { cancelRequest, streamSearch } from "../common/textSearch"; -import { FindSession, IDLE, type FindDeps, type FindState } from "./session"; +import { FindSession, IDLE, countFileMatches, type FindDeps, type FindState } from "./session"; export interface FindTab { id: number; @@ -20,6 +21,8 @@ export interface FindTab { /** Kept when a new search runs (Pin Tab). */ pinned?: boolean; state: FindState; + /** In-file results reveal their line on selection while keeping focus in the tree. */ + editorResults?: boolean; } interface FindWindowState { @@ -77,6 +80,23 @@ export function openInFindWindow( return id; } +/** Use the normal Find session/tab lifecycle for live editor results. */ +export function openEditorResults(source: FindResultsSource) { + const id = openInFindWindow(source.query, source.title, null, { + validate: async () => {}, + nextRequestId, + cancel: () => {}, + start: async (_request, _query, onFile) => { + const file = source.read(); + const matches = countFileMatches(file); + if (matches) onFile(file); + return { files: matches ? 1 : 0, matches, limitHit: false, cancelled: false, elapsedMs: 0 }; + }, + }); + patchTab(id, { editorResults: true }); + return id; +} + export function pinFindTab(id: number, pinned: boolean) { patchTab(id, { pinned }); } diff --git a/src/features/search/index.ts b/src/features/search/index.ts index 759e5c80b..dc7b2ec8f 100644 --- a/src/features/search/index.ts +++ b/src/features/search/index.ts @@ -11,7 +11,7 @@ import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { EDIT_SCOPES_ACTION } from "@/platform/search"; import { isVirtualUri } from "@/platform/virtualDocs"; -import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; +import { provideFindResults, registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; import { BookmarkPopupsOverlay } from "./bookmarks/BookmarkPopups"; import { bookmarkGutter } from "./bookmarks/gutter"; import { @@ -38,7 +38,7 @@ import { hideSearchEverywhere, showSearchEverywhere } from "./everywhere/store"; import { FindInFilesOverlay } from "./find/FindInFiles"; import { ScopesDialogOverlay, showScopesDialog } from "./find/ScopesDialog"; import { hideFindPopup, parseFindArg, runFindPopupCommand, showFindPopup, useFindPopup } from "./find/store"; -import { disposeFindWindow, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore"; +import { disposeFindWindow, openEditorResults, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore"; import { RecentPopupsOverlay } from "./recent/RecentPopups"; import { cycleSwitcher, hideRecentPopup, showRecentPopup } from "./recent/store"; import { FileStructureOverlay, useFileStructurePopup } from "./structure/StructureView"; @@ -123,6 +123,7 @@ export function activate(): void | (() => void) { const disposers: Array<() => void> = [ installTracking(), + provideFindResults(openEditorResults), registerOverlay({ id: "search.everywhere", component: SearchEverywhereOverlay }), registerOverlay({ id: "search.recent", component: RecentPopupsOverlay }), diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index ba090b4b9..8df2664a0 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -396,6 +396,7 @@ export const intellijMacos: Record = { "find.popup.openResult": ["F4", "Meta+Down"], "find.popup.recentSearches": ["Alt+Down"], // Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused. + "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Meta+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index cc9c0163f..64bb4ffb7 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -394,6 +394,7 @@ export const intellijWindows: Record = { "find.popup.openResult": ["F4"], "find.popup.recentSearches": ["Alt+Down"], // Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused. + "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Ctrl+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index a30459d6b..e9df6a033 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -383,6 +383,7 @@ export const vscodeMacos: Record = { "agents.showToolWindow": [], "agents.sendToAgent": [], // Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused. + "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Meta+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e34a71919..0defeaa62 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5249,5 +5249,10 @@ "A scheme named '{name}' already exists.": "名为“{name}”的配色方案已存在。", "Applied {applied} values; skipped {skipped} unsupported or invalid values.": "已应用 {applied} 个值;跳过了 {skipped} 个不支持或无效的值。", "Delete color scheme '{name}'?": "删除配色方案“{name}”?", - "Overwrite": "覆盖" + "Overwrite": "覆盖", + "Open Results in Find Window": "在查找窗口中打开结果", + "Multiple Cursors": "多个光标", + "Scroll to Results During Typing": "键入时滚动到结果", + "Find options": "查找选项", + "Occurrences of '{0}'": "“{0}”的出现位置" } diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index c2c14da99..e393b2e28 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -17,7 +17,7 @@ import { type ReactNode, type RefObject, } from "react"; -import type { RunConfig } from "@/bindings"; +import type { FileMatches, SearchQuery, RunConfig } from "@/bindings"; import type { MenuItem } from "@/ui/Menu"; import type { TreeReorder } from "@/ui/Tree"; import { create } from "zustand"; @@ -622,6 +622,26 @@ export const maybeEditorService = (): EditorService | undefined => services.edit /** Creates terminal widgets; provided by the terminal feature. */ export const embeddedTerminal = (): EmbeddedTerminalFactory | undefined => services.embeddedTerminal; +/** Editor-backed results, recomputed on rerun without reading files from disk. */ +export interface FindResultsSource { + title: string; + query: SearchQuery; + read(): FileMatches; +} + +let findResultsOpener: ((source: FindResultsSource) => void) | undefined; + +export function provideFindResults(opener: (source: FindResultsSource) => void): () => void { + findResultsOpener = opener; + return () => { + if (findResultsOpener === opener) findResultsOpener = undefined; + }; +} + +export function showFindResults(source: FindResultsSource) { + findResultsOpener?.(source); +} + // --------------------------------------------------------------------------- // File decorations: colour / badge / tooltip for a path, shown by the project // tree, editor tabs, Search Everywhere, etc. Providers (VCS status, problems) From c0f8557f05326165efbad8c4bc4b40970c2a2839 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:17:41 +0800 Subject: [PATCH 10/25] feat(vcs): show shelved changes and act on single files (#531) The shelf could only unshelve or delete a whole changelist, and could not show what it contained: file selection lost its scope and shelf data was not connected to the diff and patch workflows. The context menu now has Show Diff, Show Diff in a New Tab, Compare with Local, Create Patch, Copy as Patch to Clipboard and Import Patches, alongside Unshelve, Rename and Delete. Actions on a file node apply to that file only, including unshelve and delete. Diffs use the base saved with the shelf, and quoted, added, deleted and binary files are handled. Fixes #522 --- crates/ide-host/src/ops/vcs/commands.rs | 42 ++++- crates/ide-vcs/src/lib.rs | 2 +- crates/ide-vcs/src/patch.rs | 72 +++++++- crates/ide-vcs/src/shelf.rs | 173 +++++++++++++++++- crates/ide-vcs/tests/history_ops.rs | 92 ++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/vcs.rs | 37 +++- src/bindings.ts | 15 +- src/features/vcs/__tests__/shelf.test.tsx | 171 ++++++++++++++++++ src/features/vcs/actions.tsx | 4 +- src/features/vcs/commitStore.ts | 2 +- src/features/vcs/historyActions.tsx | 87 +++++++-- src/features/vcs/patches.tsx | 50 ++++-- src/features/vcs/shelf.tsx | 210 ++++++++++++++++++---- src/platform/locales/text/zh.json | 14 +- 15 files changed, 876 insertions(+), 97 deletions(-) create mode 100644 src/features/vcs/__tests__/shelf.test.tsx diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 5eaa490ab..964b130f6 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -806,17 +806,25 @@ pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult IdeResult { +pub async fn vcs_unshelve( + host: &Host, + window: &str, + id: String, + remove: bool, + path: Option, +) -> IdeResult { let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove)) + project.with_repo_recorded(None, Some("Unshelve"), |r| { + ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref()) + }) }) .await } -pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> { +pub fn vcs_shelf_delete(host: &Host, window: &str, id: String, path: Option) -> IdeResult<()> { let project = host.project(window)?; - ide_vcs::shelf::delete(&shelf_dir(&project.root), &id) + ide_vcs::shelf::delete_path(&shelf_dir(&project.root), &id, path.as_deref()) } pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> { @@ -824,9 +832,19 @@ pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name) } -pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult { +pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option) -> IdeResult { let project = host.project(window)?; - ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id) + ide_vcs::shelf::selected_patch(&shelf_dir(&project.root), &id, path.as_deref()) +} + +pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo(|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<()> { + let project = host.project(window)?; + blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await } pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult { @@ -1004,10 +1022,16 @@ pub(crate) fn register(d: &mut Dispatcher) { ); d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await }); d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) }); - d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await }); - d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) }); + d.add("vcs_unshelve", |c| async move { + vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?, c.arg("path")?).await + }); + d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); 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_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) }); + 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 + }); + 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) }); d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) }); d.add( diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 1c3509bc3..6059b2e9c 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -897,7 +897,7 @@ impl Repo { /// Patch of the local changes of `paths` against HEAD (untracked files included). pub fn create_patch(&self, paths: &[PathBuf], reverse: bool) -> IdeResult { let rel = self.rel_paths(paths)?; - Ok(patch::working_patch(self, &rel, reverse)?.0) + Ok(patch::working_patch(self, &rel, reverse, true)?.0) } /// Patch of the changes made by `commits` (oldest first). pub fn create_commits_patch(&self, commits: &[String], reverse: bool) -> IdeResult { diff --git a/crates/ide-vcs/src/patch.rs b/crates/ide-vcs/src/patch.rs index 15610cf8a..28f3869e8 100644 --- a/crates/ide-vcs/src/patch.rs +++ b/crates/ide-vcs/src/patch.rs @@ -36,7 +36,12 @@ impl Drop for TempIndex { /// Binary-safe patch of the working-tree changes (staged or not, untracked files /// included) of `rel_paths` against HEAD, plus the files it touches (renames split /// into delete + add). The real index is left untouched. -pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> IdeResult<(String, Vec)> { +pub(crate) fn working_patch( + r: &Repo, + rel_paths: &[String], + reverse: bool, + detect_renames: bool, +) -> IdeResult<(String, Vec)> { if rel_paths.is_empty() { return Err(IdeError::invalid("no files selected")); } @@ -48,8 +53,16 @@ pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> Id run_env(r.root(), &["read-tree", "--empty"], None, &env)?; } cli::run_paths_env(r.root(), &["--literal-pathspecs", "add", "-A", "--"], rel_paths, None, &env)?; - let mut diff = - vec!["diff", "--cached", "--binary", "--full-index", "-M", "--no-color", "--no-ext-diff", "--no-textconv"]; + let mut diff = vec![ + "diff", + "--cached", + "--binary", + "--full-index", + if detect_renames { "-M" } else { "--no-renames" }, + "--no-color", + "--no-ext-diff", + "--no-textconv", + ]; if reverse { diff.push("-R"); } @@ -163,13 +176,49 @@ fn unmerged(r: &Repo) -> IdeResult> { Ok(out.stdout_str().split('\0').filter(|s| !s.is_empty()).map(str::to_string).collect()) } +fn git_path(p: &str) -> String { + let Some(p) = p.strip_prefix('"').and_then(|p| p.strip_suffix('"')) else { return p.to_string() }; + let mut bytes = Vec::new(); + let mut chars = p.bytes().peekable(); + while let Some(c) = chars.next() { + if c != b'\\' { + bytes.push(c); + continue; + } + let Some(c) = chars.next() else { break }; + bytes.push(match c { + b'n' => b'\n', + b't' => b'\t', + b'r' => b'\r', + b'a' => 7, + b'b' => 8, + b'v' => 11, + b'f' => 12, + b'0'..=b'7' => { + let mut n = c - b'0'; + for _ in 0..2 { + if let Some(d @ b'0'..=b'7') = chars.peek().copied() { + chars.next(); + n = n.wrapping_mul(8).wrapping_add(d - b'0'); + } else { + break; + } + } + n + } + c => c, + }); + } + String::from_utf8_lossy(&bytes).into_owned() +} + fn strip_prefix(p: &str) -> Option { - let p = p.split('\t').next().unwrap_or(p).trim_end(); + let p = if p.starts_with('"') { p } else { p.split('\t').next().unwrap_or(p) }; if p == "/dev/null" { return None; } - let p = p.strip_prefix("\"").and_then(|q| q.strip_suffix('"')).unwrap_or(p); - Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(p).to_string()) + let p = git_path(p); + Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(&p).to_string()) } /// Files changed by a unified diff (git extended headers or plain `---`/`+++`). @@ -210,7 +259,12 @@ pub(crate) fn parse_patch(patch: &str) -> Vec { } if let Some(rest) = line.strip_prefix("diff --git ") { out.extend(cur.take()); - let path = rest.rsplit_once(" b/").map(|(_, b)| b.to_string()).unwrap_or_else(|| rest.to_string()); + let path = rest + .rsplit_once(" \"b/") + .map(|(_, b)| git_path(&format!("\"{b}"))) + .or_else(|| rest.rsplit_once(" b/").map(|(_, b)| b.to_string())) + .unwrap_or_else(|| rest.to_string()); + let path = path.strip_prefix("b/").unwrap_or(&path).to_string(); cur = Some(new_file(path)); fresh_header = true; } else if let Some(rest) = line.strip_prefix("--- ") { @@ -249,10 +303,10 @@ pub(crate) fn parse_patch(patch: &str) -> Vec { } else if line.starts_with("deleted file mode") { c.kind = ChangeKind::Deleted; } else if let Some(p) = line.strip_prefix("rename from ") { - c.old_path = Some(p.to_string()); + c.old_path = Some(git_path(p)); c.kind = ChangeKind::Renamed; } else if let Some(p) = line.strip_prefix("rename to ") { - c.path = p.to_string(); + c.path = git_path(p); } else if line.starts_with("GIT binary patch") || line.starts_with("Binary files ") { c.binary = true; } diff --git a/crates/ide-vcs/src/shelf.rs b/crates/ide-vcs/src/shelf.rs index 867a65d06..502867130 100644 --- a/crates/ide-vcs/src/shelf.rs +++ b/crates/ide-vcs/src/shelf.rs @@ -2,7 +2,7 @@ //! `.idea/shelf`. Each shelved change list is `//shelved.patch` plus //! `//shelf.json` with its name, date and files. -use crate::cli::run_paths; +use crate::cli::{run_env, run_paths}; use crate::{ApplyOutcome, ChangeKind, Repo, ShelvedChangeList}; use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; @@ -81,7 +81,10 @@ pub fn list(shelf: &Path) -> IdeResult> { name: info.name, time_ms: info.time_ms, branch: info.branch, - files: info.files, + 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 }) + .collect(), }); } out.sort_by(|a, b| b.time_ms.total_cmp(&a.time_ms).then_with(|| a.name.cmp(&b.name))); @@ -117,7 +120,7 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - if name.is_empty() { return Err(IdeError::invalid("name must not be empty")); } - let (patch, files) = crate::patch::working_patch(r, rel_paths, false)?; + let (patch, files) = crate::patch::working_patch(r, rel_paths, false, false)?; if files.is_empty() || patch.trim().is_empty() { return Err(IdeError::invalid("no changes to shelve")); } @@ -173,14 +176,174 @@ fn revert_files(r: &Repo, files: &[crate::ChangedFile]) -> IdeResult<()> { /// Applies a shelved change list; with `remove`, deletes it from the shelf once it /// applied without conflicts (conflicts are left for the merge UI and the entry is kept). pub(crate) fn unshelve(r: &Repo, shelf: &Path, id: &str, remove: bool) -> IdeResult { - let patch = patch_of(shelf, id)?; + unshelve_path(r, shelf, id, remove, None) +} + +pub fn unshelve_path(r: &Repo, shelf: &Path, id: &str, remove: bool, path: Option<&str>) -> IdeResult { + let patch = selected_patch(shelf, id, path)?; let outcome = crate::patch::apply(r, &patch)?; if remove && outcome.conflicts.is_empty() { - delete(shelf, id)?; + delete_path(shelf, id, path)?; } Ok(outcome) } +#[derive(Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ShelfDiffFile { + pub path: String, + pub patch: String, + pub before: Option, + pub after: Option, +} + +fn sections(patch: &str) -> Vec<(&str, crate::PatchFile)> { + let mut starts: Vec<_> = patch + .match_indices("diff --git ") + .filter(|(i, _)| *i == 0 || patch.as_bytes()[i - 1] == b'\n') + .map(|(i, _)| i) + .collect(); + if starts.is_empty() { + let (mut old, mut new) = (0u32, 0u32); + let mut offset = 0; + let mut lines = patch.split_inclusive('\n').peekable(); + while let Some(line) = lines.next() { + if old > 0 || new > 0 { + match line.as_bytes().first() { + Some(b'-') => old = old.saturating_sub(1), + Some(b'+') => new = new.saturating_sub(1), + Some(b' ') => { + old = old.saturating_sub(1); + new = new.saturating_sub(1); + } + _ => {} + } + } else if line.starts_with("--- ") && lines.peek().is_some_and(|l| l.starts_with("+++ ")) { + starts.push(offset); + } + if let Some(range) = line.strip_prefix("@@ ") { + let mut parts = range.split_whitespace(); + let count = |s: &str| s.split_once(',').map_or(1, |(_, n)| n.parse().unwrap_or(0)); + old = parts.next().map(count).unwrap_or(0); + new = parts.next().map(count).unwrap_or(0); + } + offset += line.len(); + } + } + starts + .iter() + .enumerate() + .filter_map(|(n, &start)| { + let section = &patch[start..starts.get(n + 1).copied().unwrap_or(patch.len())]; + crate::patch::parse_patch(section).into_iter().next().map(|file| (section, file)) + }) + .collect() +} + +pub fn selected_patch(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult { + let patch = patch_of(shelf, id)?; + let Some(path) = path else { return Ok(patch) }; + let selected: String = sections(&patch).into_iter().filter(|(_, f)| f.path == path).map(|(p, _)| p).collect(); + if selected.is_empty() { + return Err(IdeError::not_found("shelved file not found")); + } + Ok(selected) +} + +pub fn delete_path(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult<()> { + let Some(path) = path else { return delete(shelf, id) }; + selected_patch(shelf, id, Some(path))?; + let patch = patch_of(shelf, id)?; + let remaining: String = sections(&patch).into_iter().filter(|(_, f)| f.path != path).map(|(p, _)| p).collect(); + if remaining.is_empty() { + return delete(shelf, id); + } + let dir = entry_dir(shelf, id)?; + let mut info = read_info(&dir).ok_or_else(|| IdeError::invalid("shelf metadata is missing"))?; + info.files = crate::patch::parse_patch(&remaining) + .into_iter() + .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) + .collect(); + std::fs::write(dir.join(PATCH), remaining)?; + std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; + Ok(()) +} + +/// Reconstruct each side in a throwaway index; neither HEAD nor the working files are used. +pub fn diff_files(r: &Repo, shelf: &Path, id: &str) -> IdeResult> { + let patch = patch_of(shelf, id)?; + let mut out = Vec::new(); + for (section, file) in sections(&patch) { + let mut item = ShelfDiffFile { path: file.path.clone(), patch: section.to_string(), before: None, after: None }; + if !file.binary { + let reconstruct = || -> IdeResult<(String, String)> { + let temp = tempfile::tempdir()?; + let index = temp.path().join("index"); + let env = [("GIT_INDEX_FILE", index.as_os_str())]; + run_env( + r.root(), + &["apply", &format!("--build-fake-ancestor={}", index.display()), "-"], + Some(section.as_bytes()), + &env, + )?; + let read = |path: &str| -> IdeResult { + let bytes = run_env(r.root(), &["show", &format!(":{path}")], None, &env)?.stdout; + if bytes.contains(&0) { + return Err(IdeError::invalid("binary file")); + } + String::from_utf8(bytes).map_err(|_| IdeError::invalid("non-UTF-8 file")) + }; + let before = if file.kind == ChangeKind::Added { + String::new() + } else { + read(file.old_path.as_deref().unwrap_or(&file.path))? + }; + run_env( + r.root(), + &["apply", "--cached", "--binary", "--whitespace=nowarn", "-"], + Some(section.as_bytes()), + &env, + )?; + let after = if file.kind == ChangeKind::Deleted { String::new() } else { read(&file.path)? }; + Ok((before, after)) + }; + if let Ok((before, after)) = reconstruct() { + item.before = Some(before); + item.after = Some(after); + } + } + out.push(item); + } + Ok(out) +} + +/// Import into the shelf without applying to the working tree. +pub fn import_patch(shelf: &Path, name: &str, patch: &str) -> 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")); + } + std::fs::create_dir_all(shelf)?; + let mut id = slug(name); + while shelf.join(&id).exists() { + id.push('_'); + } + let dir = shelf.join(id); + std::fs::create_dir(&dir)?; + let info = Info { + name: name.to_string(), + time_ms: now_ms(), + branch: None, + files: files + .into_iter() + .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) + .collect(), + }; + std::fs::write(dir.join(PATCH), patch)?; + std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::slug; diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index 0ef3d0b78..39b035572 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -567,3 +567,95 @@ fn update_branch_fast_forwards_a_branch_not_checked_out() { // The checked-out branch and branches without upstream are refused. assert!(repo.update_branch("work", &cancel, &out).is_err()); } + +#[test] +fn shelf_diff_and_scoped_operations_preserve_siblings() { + let fx = Fixture::new(); + for path in ["a.txt", "b.txt", "quote\"\t.txt", "gone.txt"] { + fx.write(path, "base\n"); + } + fx.commit_all("base"); + for path in ["a.txt", "b.txt", "quote\"\t.txt"] { + fx.write(path, "shelved\n"); + } + std::fs::remove_file(fx.path("gone.txt")).unwrap(); + fx.write("new.txt", "new\n"); + std::fs::write(fx.path("binary.dat"), [0, 1, 255]).unwrap(); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + let paths = ["a.txt", "b.txt", "quote\"\t.txt", "gone.txt", "new.txt", "binary.dat"].map(|p| fx.path(p)); + let entry = repo.shelve(&shelf, "work", &paths).unwrap(); + fx.write("a.txt", "new head\n"); + fx.commit_all("advance head"); + fx.write("a.txt", "local edits\n"); + let status = fx.git(&["status", "--porcelain"]); + let index = fx.git(&["ls-files", "--stage"]); + let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap(); + for path in ["a.txt", "b.txt", "quote\"\t.txt"] { + let f = diffs.iter().find(|f| f.path == path).unwrap(); + assert_eq!(f.before.as_deref(), Some("base\n"), "{path}"); + assert_eq!(f.after.as_deref(), Some("shelved\n"), "{path}"); + } + assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().before.as_deref(), Some("")); + assert_eq!(diffs.iter().find(|f| f.path == "gone.txt").unwrap().after.as_deref(), Some("")); + assert!(diffs.iter().find(|f| f.path == "binary.dat").unwrap().before.is_none()); + assert_eq!(fx.git(&["status", "--porcelain"]), status); + assert_eq!(fx.git(&["ls-files", "--stage"]), index); + assert_eq!(fx.read("a.txt"), "local edits\n"); + let patch = shelf::selected_patch(&shelf, &entry.id, Some("b.txt")).unwrap(); + assert!(patch.contains("b/b.txt")); + assert!(!patch.contains("b/a.txt")); + assert!(shelf::selected_patch(&shelf, &entry.id, Some("missing")).is_err()); + shelf::unshelve_path(&repo, &shelf, &entry.id, true, Some("b.txt")).unwrap(); + assert_eq!(fx.read("b.txt"), "shelved\n"); + assert!(!shelf::list(&shelf).unwrap()[0].files.iter().any(|f| f.path == "b.txt")); + shelf::delete_path(&shelf, &entry.id, Some("quote\"\t.txt")).unwrap(); + assert!(shelf::selected_patch(&shelf, &entry.id, Some("a.txt")).is_ok()); + shelf::import_patch(&shelf, "imported", &patch).unwrap(); + assert_eq!(shelf::list(&shelf).unwrap().len(), 2); + assert_eq!(fx.read("a.txt"), "local edits\n"); +} + +#[test] +fn shelf_imported_rename_diff_and_last_file_delete() { + let fx = Fixture::new(); + fx.write("old.txt", "same\n"); + fx.commit_all("base"); + fx.git(&["mv", "old.txt", "new.txt"]); + let patch = fx.git(&["diff", "--cached", "--binary", "--full-index", "-M"]) + "\n"; + fx.git(&["reset", "--hard", "HEAD"]); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + shelf::import_patch(&shelf, "rename", &patch).unwrap(); + let id = &shelf::list(&shelf).unwrap()[0].id; + let diffs = shelf::diff_files(&repo, &shelf, id).unwrap(); + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].path, "new.txt"); + // A rename-only imported patch has no embedded blob ids: do not guess from HEAD. + assert!(diffs[0].before.is_none()); + assert!(diffs[0].patch.contains("rename from old.txt")); + shelf::delete_path(&shelf, id, Some("new.txt")).unwrap(); + assert!(shelf::list(&shelf).unwrap().is_empty()); +} + +#[test] +fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() { + let fx = Fixture::new(); + fx.write("old.txt", "renamed content\n"); + fx.commit_all("base"); + fx.git(&["mv", "old.txt", "new.txt"]); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + let entry = repo.shelve(&shelf, "rename", &[fx.path("old.txt"), fx.path("new.txt")]).unwrap(); + let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap(); + assert_eq!(diffs.len(), 2); + 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(); + let selected = shelf::selected_patch(&shelf, "plain", Some("one.txt")).unwrap(); + assert!(selected.contains("--- removed\n+++ added")); + assert!(!selected.contains("two.txt")); + shelf::delete_path(&shelf, "plain", Some("one.txt")).unwrap(); + assert_eq!(shelf::list(&shelf).unwrap().iter().find(|s| s.id == "plain").unwrap().files[0].path, "two.txt"); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f7ff09bd5..739271c7b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -277,6 +277,8 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_shelf_delete, vcs::vcs_shelf_rename, vcs::vcs_shelf_patch, + vcs::vcs_shelf_diff, + vcs::vcs_shelf_import, vcs::vcs_changelists_load, vcs::vcs_changelists_save, vcs::vcs_write_patch, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index cfde2042c..efe148c05 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -608,14 +608,15 @@ pub async fn vcs_unshelve( state: State<'_, AppState>, id: String, remove: bool, + path: Option, ) -> IdeResult { - ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove).await + ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove, path).await } #[tauri::command] #[specta::specta] -pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> { - ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id) +pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String, path: Option) -> IdeResult<()> { + ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id, path) } #[tauri::command] @@ -626,8 +627,13 @@ pub fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, #[tauri::command] #[specta::specta] -pub fn vcs_shelf_patch(window: Window, state: State<'_, AppState>, id: String) -> IdeResult { - ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id) +pub fn vcs_shelf_patch( + window: Window, + state: State<'_, AppState>, + id: String, + path: Option, +) -> IdeResult { + ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id, path) } #[tauri::command] @@ -648,3 +654,24 @@ pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changeli 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 } + +#[tauri::command] +#[specta::specta] +pub async fn vcs_shelf_diff( + window: Window, + state: State<'_, AppState>, + id: String, +) -> IdeResult> { + ide_host::ops::vcs::vcs_shelf_diff(&state.host, window.label(), id).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vcs_shelf_import( + window: Window, + state: State<'_, AppState>, + name: String, + patch: String, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), name, patch).await +} diff --git a/src/bindings.ts b/src/bindings.ts index 0ce6fda5f..22c09faed 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -517,10 +517,12 @@ export const commands = { * Unshelve: applies the shelved patch (3-way merging; conflicts are left for the * merge UI); with `remove`, deletes it from the shelf. */ - vcsUnshelve: (id: string, remove: boolean) => typedError(__TAURI_INVOKE("vcs_unshelve", { id, remove })), - vcsShelfDelete: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_delete", { id })), + vcsUnshelve: (id: string, remove: boolean, path: string | null) => typedError(__TAURI_INVOKE("vcs_unshelve", { id, remove, path })), + vcsShelfDelete: (id: string, path: string | null) => typedError(__TAURI_INVOKE("vcs_shelf_delete", { id, path })), vcsShelfRename: (id: string, name: string) => typedError(__TAURI_INVOKE("vcs_shelf_rename", { id, name })), - vcsShelfPatch: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_patch", { id })), + 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 })), vcsChangelistsLoad: () => typedError(__TAURI_INVOKE("vcs_changelists_load")), vcsChangelistsSave: (changelists: ChangelistState) => typedError(__TAURI_INVOKE("vcs_changelists_save", { changelists })), /** Create Patch › Save to file. */ @@ -5530,6 +5532,13 @@ export type Settings_Serialize = { export type Severity = "error" | "warning" | "info" | "hint"; +export type ShelfDiffFile = { + path: string, + patch: string, + before: string | null, + after: string | null, +}; + /** Version Control › Shelf. */ export type ShelfSettings = { /** Initial state of Unshelve's "Remove successfully applied files from the shelf". */ diff --git a/src/features/vcs/__tests__/shelf.test.tsx b/src/features/vcs/__tests__/shelf.test.tsx new file mode 100644 index 000000000..69ce6d02a --- /dev/null +++ b/src/features/vcs/__tests__/shelf.test.tsx @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor, cleanup } from "@testing-library/react"; +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() })); +vi.mock("../diffInput", async (original) => ({ + ...(await original()), + openDiff: mocks.openDiff, +})); +vi.mock("@/platform/actions", async (original) => ({ + ...(await original()), + executeAction: mocks.execute, +})); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + call: mocks.ipc, + commands: { + ...(await original()).commands, + vcsShelves: () => Promise.resolve(null), + vcsShelfDiff: () => Promise.resolve(null), + fsProbe: () => Promise.resolve(null), + vcsShelfPatch: mocks.patch, + }, +})); +import { ShelfPanel, copyShelfPatch, shelfDiffs, shelfTarget, showShelfDiff, useShelf } from "../shelf"; +import { useCommit } from "../commitStore"; +import { useVcs } from "../store"; +import { ContextMenuHost } from "@/ui"; +import { registerVcsActions } from "../actions"; +import { getAction, isActionKeyEnabled } from "@/platform/actions"; +import { intellijWindows } from "@/platform/keymaps/intellij-windows"; +import { intellijMacos } from "@/platform/keymaps/intellij-macos"; + +const entry: ShelvedChangeList = { + id: "work", + name: "Work", + timeMs: 0, + branch: null, + files: [ + { path: "a.txt", kind: "modified", oldPath: null }, + { path: "b.txt", kind: "added", oldPath: null }, + { path: "binary.dat", kind: "added", oldPath: null }, + ], +}; +const diffs = [ + { path: "a.txt", before: "base", after: "shelved", patch: "a patch" }, + { path: "b.txt", before: "", after: "added", patch: "b patch" }, + { path: "binary.dat", before: null, after: null, patch: "binary patch" }, +]; +beforeEach(() => { + vi.clearAllMocks(); + mocks.patch.mockResolvedValue(null); + useVcs.setState({ root: "/repo" }); + useCommit.setState({ selectedShelf: "work\u0000a.txt" }); + useShelf.setState({ shelves: [entry], loaded: true, diffs: { work: diffs } }); + mocks.ipc.mockResolvedValueOnce([entry]).mockResolvedValue(diffs); +}); +afterEach(cleanup); + +describe("shelf scope and diffs", () => { + it("keeps file scope from selection and explicit targets; rejects stale files", () => { + expect(shelfTarget()).toEqual({ entry, path: "a.txt" }); + expect(shelfTarget({ id: "work", path: "b.txt" })).toEqual({ entry, path: "b.txt" }); + expect(shelfTarget("work")).toEqual({ entry, path: undefined }); + expect(shelfTarget({ id: "work", path: "missing" })).toBeNull(); + expect(shelfTarget({ id: "missing" })).toBeNull(); + }); + it("compares saved sides, keeps binary files out of text diffs, and scopes chains", async () => { + expect(shelfDiffs({ id: "work" })).toHaveLength(2); + expect(shelfDiffs({ id: "work", path: "a.txt" })).toMatchObject([ + { path: "/repo/a.txt", left: { type: "text", text: "base" }, right: { type: "text", text: "shelved" } }, + ]); + expect(shelfDiffs({ id: "work", path: "a.txt" }, true)).toMatchObject([ + { left: { text: "shelved" }, right: { type: "worktree" } }, + ]); + await showShelfDiff(); + expect(mocks.openDiff).toHaveBeenLastCalledWith( + expect.objectContaining({ path: "/repo/a.txt", chain: undefined }), + { preview: true }, + ); + await showShelfDiff("work", false, true); + expect(mocks.openDiff).toHaveBeenLastCalledWith( + expect.objectContaining({ chain: expect.arrayContaining([expect.objectContaining({ path: "/repo/b.txt" })]) }), + { preview: false }, + ); + await showShelfDiff({ id: "work", path: "binary.dat" }); + expect(mocks.openDiff).toHaveBeenCalledTimes(2); + }); + it("compares shelf content with the live working file and skips missing or binary files", async () => { + mocks.ipc.mockReset().mockResolvedValue({ binary: false }); + await showShelfDiff({ id: "work", path: "a.txt" }, true); + expect(mocks.openDiff).toHaveBeenCalledWith( + expect.objectContaining({ + left: expect.objectContaining({ type: "text", text: "shelved" }), + right: expect.objectContaining({ type: "worktree" }), + }), + { preview: true }, + ); + mocks.ipc.mockResolvedValue({ binary: true }); + await showShelfDiff({ id: "work", path: "a.txt" }, true); + mocks.ipc.mockRejectedValue(new Error("missing")); + await showShelfDiff({ id: "work", path: "a.txt" }, true); + expect(mocks.openDiff).toHaveBeenCalledTimes(1); + }); + 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); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + mocks.ipc.mockReset().mockResolvedValue("selected file patch"); + try { + await copyShelfPatch(entry, "a.txt"); + expect(mocks.patch).toHaveBeenCalledWith("work", "a.txt"); + expect(writeText).toHaveBeenCalledWith("selected file patch"); + } finally { + if (previous) Object.defineProperty(navigator, "clipboard", previous); + else Reflect.deleteProperty(navigator, "clipboard"); + } + }); + it("routes the configured Show Diff shortcut to the focused shelf selection", async () => { + const off = registerVcsActions(); + try { + const action = getAction("vcs.showDiff")!; + expect(intellijWindows[action.id]).toEqual(["Ctrl+D"]); + expect(intellijMacos[action.id]).toEqual(["Meta+D"]); + expect(isActionKeyEnabled(action, { vcsAvailable: true, shelfFocus: true })).toBe(true); + expect(isActionKeyEnabled(action, { vcsAvailable: true, editorTextFocus: true })).toBe(false); + await action.run({ ctx: { shelfFocus: true } }); + expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: "/repo/a.txt" }), { preview: true }); + } finally { + off(); + } + }); + it("preserves file targets through selection, menus, toolbar, and activation", async () => { + render( + <> + + + , + ); + await waitFor(() => expect(screen.getByText("Work")).toBeTruthy()); + fireEvent.doubleClick(screen.getByText("Work")); + // Expand explicitly through the accessible tree keyboard navigation. + const tree = screen.getByRole("tree"); + fireEvent.keyDown(tree, { key: "ArrowRight" }); + const file = await screen.findByText("a.txt"); + fireEvent.click(file); + expect(useCommit.getState().selectedShelf).toBe("work\u0000a.txt"); + fireEvent.click(screen.getByRole("button", { name: "Unshelve…" })); + expect(mocks.execute).toHaveBeenCalledWith("vcs.unshelve", { id: "work", path: "a.txt" }); + fireEvent.doubleClick(file); + expect(mocks.execute).toHaveBeenCalledWith("vcs.shelf.showDiff", { id: "work", path: "a.txt" }); + await act(async () => useShelf.setState({ diffs: { work: diffs } })); + fireEvent.contextMenu(file); + await screen.findByRole("menuitem", { name: "Copy as Patch to Clipboard" }); + const labels = screen.getAllByRole("menuitem").map((e) => e.textContent); + for (const label of [ + "Show Diff", + "Show Diff in a New Tab", + "Compare with Local", + "Create Patch…", + "Copy as Patch to Clipboard", + "Import Patches…", + "Delete…", + ]) + expect(labels.some((s) => s?.includes(label))).toBe(true); + expect(screen.getByRole("menuitem", { name: "Rename…" }).getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(screen.getByRole("menuitem", { name: "Copy as Patch to Clipboard" })); + expect(mocks.execute).toHaveBeenCalledWith("vcs.shelf.copyPatch", { id: "work", path: "a.txt" }); + }); +}); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 8b5107e16..832583c65 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -1,3 +1,4 @@ +import { showShelfDiff } from "./shelf"; import { commitRepo } from "./multiRoot"; /** VCS actions (ids match keymaps/intellij-macos.ts where bound). */ import { registerActions, type Action } from "@/platform/actions"; @@ -306,7 +307,8 @@ export function registerVcsActions(): () => void { when: VCS, // ⌘D duplicates the line inside a text editor; Git › Show Diff still works there. keyWhen: "!editorTextFocus", - run: async ({ arg }) => { + run: async ({ arg, ctx }) => { + if (ctx.shelfFocus && arg === undefined) return showShelfDiff(); const line = arg && typeof arg === "object" && "line" in arg ? Number((arg as { line: unknown }).line) : undefined; const paths = contextPaths(arg).filter((p) => !p.endsWith("/")); diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index a0d3d682c..e609cec90 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -29,7 +29,7 @@ interface CommitState { tab: CommitTab; /** Change list of the selected tree node (list actions without an argument). */ selectedList: string | null; - /** Selected entry of the Shelf tab. */ + /** Selected shelf tree key: shelf id, optionally followed by NUL and a file path. */ selectedShelf: string | null; } diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 92a599f52..a45f689b0 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -45,7 +45,19 @@ import { undoCommit, } from "./rebase"; import { showApplyPatch, showCreatePatchForChanges, showCreatePatchForCommits } from "./patches"; -import { deleteShelf, renameShelf, shelveChanges, showShelf, unshelve, useShelf } from "./shelf"; +import { + copyShelfPatch, + createShelfPatch, + deleteShelf, + importShelfPatches, + loadShelves, + renameShelf, + shelveChanges, + shelfTarget, + showShelf, + showShelfDiff, + unshelve, +} from "./shelf"; import { currentRepo, focusRepo, useVcs } from "./store"; const VCS = "vcsAvailable"; @@ -123,12 +135,6 @@ function listArg(arg: unknown): string | null { return useCommit.getState().selectedList ?? null; } -function shelfArg(arg: unknown) { - const shelves = useShelf.getState().shelves; - const id = typeof arg === "string" ? arg : useCommit.getState().selectedShelf; - return shelves.find((s) => s.id === id) ?? null; -} - async function checkoutCommit(c: Commit) { await checkoutDetached(c.id, c.shortId); } @@ -496,8 +502,8 @@ export function registerHistoryActions(): () => void { icon: "unshelve", when: VCS, run: ({ arg }) => { - const entry = shelfArg(arg); - if (entry) unshelve(entry); + const target = shelfTarget(arg); + if (target) unshelve(target.entry, target.path); else showShelf(); }, }, @@ -508,8 +514,8 @@ export function registerHistoryActions(): () => void { when: VCS, hidden: true, run: ({ arg }) => { - const entry = shelfArg(arg); - if (entry) void deleteShelf(entry); + const target = shelfTarget(arg); + if (target) void deleteShelf(target.entry, target.path); }, }, { @@ -519,11 +525,66 @@ export function registerHistoryActions(): () => void { when: VCS, hidden: true, run: ({ arg }) => { - const entry = shelfArg(arg); - if (entry) void renameShelf(entry); + const target = shelfTarget(arg); + if (target && !target.path) void renameShelf(target.entry); }, }, + { + id: "vcs.shelf.refresh", + title: "Refresh Shelf", + category: "Git", + when: VCS, + run: async () => { + await loadShelves(); + }, + }, + { + id: "vcs.shelf.showDiff", + title: "Show Shelved Diff", + category: "Git", + when: VCS, + run: ({ arg }) => showShelfDiff(arg), + }, + { + id: "vcs.shelf.showDiffNewTab", + title: "Show Diff in a New Tab", + category: "Git", + when: VCS, + run: ({ arg }) => showShelfDiff(arg, false, true), + }, + { + id: "vcs.shelf.compareLocal", + title: "Compare Shelved Changes with Local", + category: "Git", + when: VCS, + run: ({ arg }) => showShelfDiff(arg, true), + }, + { + id: "vcs.shelf.createPatch", + title: "Create Patch from Shelved Changes…", + category: "Git", + when: VCS, + run: ({ arg }) => createShelfPatch(arg), + }, + { + id: "vcs.shelf.copyPatch", + title: "Copy as Patch to Clipboard", + category: "Git", + when: VCS, + run: async ({ arg }) => { + const t = shelfTarget(arg); + if (t) await copyShelfPatch(t.entry, t.path); + }, + }, + { + id: "vcs.shelf.importPatches", + title: "Import Patches…", + category: "Git", + when: VCS, + run: ({ arg }) => importShelfPatches(Array.isArray(arg) ? arg : undefined), + }, + // --- patches --- { id: "vcs.createPatch", diff --git a/src/features/vcs/patches.tsx b/src/features/vcs/patches.tsx index 16ef29057..ab3a58830 100644 --- a/src/features/vcs/patches.tsx +++ b/src/features/vcs/patches.tsx @@ -1,7 +1,7 @@ import { commitRepo } from "./multiRoot"; /** Create Patch… (from local changes or commits) and Apply Patch… (file or clipboard, with a preview). */ import { useEffect, useState } from "react"; -import type { Commit, FileStatus, PatchFile, PatchSource } from "@/bindings"; +import type { Commit, FileStatus, PatchFile, PatchSource, ShelvedChangeList } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { Button, Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName, T } from "@/ui"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -17,7 +17,10 @@ import { currentRepoRoot, useVcs } from "./store"; import styles from "./common.module.css"; import { tx } from "@/platform/i18n"; -export type PatchOrigin = { kind: "changes"; files: FileStatus[] } | { kind: "commits"; commits: Commit[] }; +export type PatchOrigin = + | { kind: "changes"; files: FileStatus[] } + | { kind: "commits"; commits: Commit[] } + | { kind: "shelf"; entry: ShelvedChangeList; path?: string }; /** File name IDEA suggests: the change list or commit subject, sanitised. */ export function suggestedPatchName(origin: PatchOrigin, listName: string): string { @@ -26,7 +29,9 @@ export function suggestedPatchName(origin: PatchOrigin, listName: string): strin ? origin.commits.length === 1 ? origin.commits[0]!.summary : `${origin.commits.length} commits` - : listName; + : origin.kind === "shelf" + ? origin.entry.name + : listName; const clean = base .replace(/[^\p{L}\p{N}._-]+/gu, "_") .replace(/^_+|_+$/g, "") @@ -48,6 +53,10 @@ export function showCreatePatchForCommits(commits: Commit[]) { openDialog((close) => ); } +export function showCreatePatchForShelf(entry: ShelvedChangeList, path?: string) { + openDialog((close) => ); +} + async function browseSave(current: string): Promise { try { const { save } = await import("@tauri-apps/plugin-dialog"); @@ -92,22 +101,27 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: if (empty || (toFile && !file.trim())) return; onClose(); await saveAll(); - const source: PatchSource = - origin.kind === "changes" - ? { - from: "changes", - paths: origin.files.filter((f) => selected.has(f.path)).map((f) => stripSlash(f.path)), - reverse, - } - : { - from: "commits", - commits: [...origin.commits].sort((a, b) => (a.timeMs ?? 0) - (b.timeMs ?? 0)).map((c) => c.id), - reverse, - }; + const source: PatchSource | null = + origin.kind === "shelf" + ? null + : origin.kind === "changes" + ? { + from: "changes", + paths: origin.files.filter((f) => selected.has(f.path)).map((f) => stripSlash(f.path)), + reverse, + } + : { + from: "commits", + commits: [...origin.commits].sort((a, b) => (a.timeMs ?? 0) - (b.timeMs ?? 0)).map((c) => c.id), + reverse, + }; const done = await runOp( "Create patch", async () => { - const patch = await call(commands.vcsCreatePatch(repo, source)); + const patch = + origin.kind === "shelf" + ? await call(commands.vcsShelfPatch(origin.entry.id, origin.path ?? null)) + : await call(commands.vcsCreatePatch(repo, source!)); if (toFile) await call(commands.vcsWritePatch(file.trim(), patch)); else await navigator.clipboard.writeText(patch); return true; @@ -134,6 +148,8 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
    + ) : origin.kind === "shelf" ? ( +
    {origin.path ?? origin.entry.name}
    ) : (
    {origin.commits.length === 1 @@ -167,7 +183,7 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: Copy to clipboard - + {origin.kind !== "shelf" && }
); diff --git a/src/features/vcs/shelf.tsx b/src/features/vcs/shelf.tsx index 67b43ad3d..0979e2b49 100644 --- a/src/features/vcs/shelf.tsx +++ b/src/features/vcs/shelf.tsx @@ -5,7 +5,11 @@ */ import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { create } from "zustand"; -import type { ApplyOutcome, FileStatus, ShelvedChangeList } from "@/bindings"; +import type { ApplyOutcome, FileStatus, ShelvedChangeList, ShelfDiffFile } from "@/bindings"; +import { setContext } from "@/platform/context"; +import { shortcutLabel } from "@/platform/keymap"; +import { openDiff, withChain, type DiffData } from "./diffInput"; +import { showCreatePatchForShelf } from "./patches"; import { executeAction } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -26,13 +30,24 @@ import { tx } from "@/platform/i18n"; interface ShelfState { shelves: ShelvedChangeList[]; loaded: boolean; + diffs: Record; } -export const useShelf = create(() => ({ shelves: [], loaded: false })); +export const useShelf = create(() => ({ shelves: [], loaded: false, diffs: {} })); export async function loadShelves(): Promise { + const root = useVcs.getState().root; const shelves = await call(commands.vcsShelves()); - useShelf.setState({ shelves, loaded: true }); + if (useVcs.getState().root !== root) return shelves; + useShelf.setState({ shelves, loaded: true, diffs: {} }); + for (const entry of shelves) { + void call(commands.vcsShelfDiff(entry.id)) + .then((files) => { + if (useShelf.getState().shelves !== shelves) return; + useShelf.setState((state) => ({ diffs: { ...state.diffs, [entry.id]: files } })); + }) + .catch(() => undefined); + } return shelves; } @@ -189,11 +204,11 @@ export async function reportApply(outcome: ApplyOutcome, what: string) { notifications().notify({ group: "vcs", level: "info", title: `${what}: ${n} file${n === 1 ? "" : "s"} changed` }); } -export function unshelve(entry: ShelvedChangeList) { - openDialog((close) => ); +export function unshelve(entry: ShelvedChangeList, path?: string) { + openDialog((close) => ); } -function UnshelveDialog({ entry, onClose }: { entry: ShelvedChangeList; onClose: () => void }) { +function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; path?: string; onClose: () => void }) { const lists = useChangelists((s) => s.state); const [target, setTarget] = useState(lists.active); const [remove, setRemove] = useState(() => workflowSettings().vcs.shelf.removeApplied); @@ -201,9 +216,13 @@ function UnshelveDialog({ entry, onClose }: { entry: ShelvedChangeList; onClose: const run = async () => { onClose(); await saveAll(); - const outcome = await runOp(`Unshelve “${entry.name}”`, () => call(commands.vcsUnshelve(entry.id, remove)), { - refresh: false, - }); + const outcome = await runOp( + `Unshelve “${entry.name}”`, + () => call(commands.vcsUnshelve(entry.id, remove, path ?? null)), + { + refresh: false, + }, + ); if (!outcome) return; await refreshNow(); const rels = [...outcome.applied, ...outcome.conflicts].map((p) => relOf(`${root}/${p}`)); @@ -223,7 +242,7 @@ function UnshelveDialog({ entry, onClose }: { entry: ShelvedChangeList; onClose: ]} >
- {entry.name} · {fileCount(entry.files.length)} + {path ?? entry.name} · {fileCount(path ? 1 : entry.files.length)}