From 280c2da48ffdb01fb9ef5fab325f12eef285b14b Mon Sep 17 00:00:00 2001 From: Park Jong Hyun <81476595+fhrmrk@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:07:04 +0900 Subject: [PATCH 1/7] fix(editor): keep an editor color scheme chosen together with the theme (#1138) Since #1100 a theme that turns light or dark switches a chosen scheme of the other kind back to the theme's own. It did so also when the scheme was chosen in the same Apply as the theme, so One Light + Solarized Dark left the default light editor. A scheme set by the settings change that also changes the theme now goes with it; a later theme change on its own still switches it back. Closes #1136 Co-authored-by: fhrmrk <81476595+fhrmrk@users.noreply.github.com> --- src/editor/core/colorSchemeRuntime.ts | 17 +++++++++++++++-- src/platform/colorScheme.test.ts | 13 +++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/editor/core/colorSchemeRuntime.ts b/src/editor/core/colorSchemeRuntime.ts index 41e3a68f..443dc91c 100644 --- a/src/editor/core/colorSchemeRuntime.ts +++ b/src/editor/core/colorSchemeRuntime.ts @@ -2,7 +2,8 @@ * Applies Settings › Editor › Color Scheme (scheme + attribute overrides) as one * stylesheet, and Editor › Font › Typography Settings as the root's * `--fw-code` / `--fw-code-bold`. When the IDE theme turns light or dark, a chosen - * scheme of the other kind is switched back to the theme's own. + * scheme of the other kind is switched back to the theme's own, unless it was chosen + * in the same change as the theme (one Apply of both). */ import { useProject } from "@/platform/project"; import { editorPrefs, fontWeightVars } from "@/platform/editorSettings"; @@ -24,6 +25,10 @@ export function installColorScheme(): () => void { if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value); }; let kind = root.dataset.theme; + // The scheme set by the latest settings change, until the next task: the theme's + // `data-theme` change from the same change is observed before then. + let picked: string | undefined; + let clearPicked: ReturnType | undefined; const followTheme = () => { const next = root.dataset.theme; if (next === kind) return; @@ -31,6 +36,7 @@ export function installColorScheme(): () => void { kind = next; const settings = useProject.getState().settings; if (!previous || !settings || (next !== "light" && next !== "dark")) return; + if (picked !== undefined && picked === settings.editor?.colorScheme) return; const scheme = schemeAfterThemeChange(settings.editor?.colorScheme, next, editorPrefs(settings).customSchemes); if (scheme === null) return; if (!isTauri()) { @@ -44,7 +50,13 @@ export function installColorScheme(): () => void { }; apply(); const unsubscribe = useProject.subscribe((s, prev) => { - if (s.settings !== prev.settings) apply(); + if (s.settings === prev.settings) return; + apply(); + const scheme = s.settings?.editor?.colorScheme; + if (scheme === prev.settings?.editor?.colorScheme) return; + picked = scheme; + clearTimeout(clearPicked); + clearPicked = setTimeout(() => (picked = undefined)); }); const observer = typeof MutationObserver !== "undefined" @@ -59,6 +71,7 @@ export function installColorScheme(): () => void { }); return () => { unsubscribe(); + clearTimeout(clearPicked); observer?.disconnect(); el.remove(); root.style.removeProperty("--fw-code"); diff --git a/src/platform/colorScheme.test.ts b/src/platform/colorScheme.test.ts index 67cecc22..08e95786 100644 --- a/src/platform/colorScheme.test.ts +++ b/src/platform/colorScheme.test.ts @@ -92,6 +92,19 @@ describe("color schemes", () => { root.dataset.theme = "dark"; await Promise.resolve(); expect(scheme()).toBe("dark"); + // A scheme chosen in the same change as the theme (one Apply) goes with it. + await new Promise((r) => setTimeout(r)); + useProject.setState({ settings: { editor: { colorScheme: "solarized-dark" } } as unknown as Settings }); + root.dataset.theme = "light"; + await Promise.resolve(); + expect(scheme()).toBe("solarized-dark"); + // A later theme change on its own still switches it back. + await new Promise((r) => setTimeout(r)); + root.dataset.theme = "dark"; + await Promise.resolve(); + root.dataset.theme = "light"; + await Promise.resolve(); + expect(scheme()).toBe("theme-default"); } finally { dispose(); delete root.dataset.colorTheme; From 1bc7fd06c17200ae94c5c942897a10549e15e960 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:14:05 +0800 Subject: [PATCH 2/7] feat(gradle): navigate to version catalogs imported from published artifacts (#1149) A catalog declared in settings as create("name") { from("group:artifact:version") } is a published .toml Gradle downloads into its cache. Build-script accessors of such a catalog now go to and describe its entries like a local catalog's: gradle_published_catalog finds the file in Gradle's cache or the local Maven repository on the project's host. A catalog not downloaded yet is looked up again the next time the script opens. Closes #1006 --- crates/ide-host/src/ops/build.rs | 19 +++++++ crates/ide-sdk/src/libraries.rs | 50 +++++++++++++++++++ src-tauri/src/commands/build.rs | 12 +++++ src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 5 ++ .../build-tools/versionCatalog.test.ts | 47 ++++++++++++++++- src/features/build-tools/versionCatalog.ts | 49 ++++++++++++------ 7 files changed, 165 insertions(+), 18 deletions(-) diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index d024fd0c..472411a3 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -222,6 +222,22 @@ fn declaring_pom( ))) } +/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a +/// build has downloaded it (`ide_sdk::libraries::published_version_catalog`). +pub async fn gradle_published_catalog( + host: &Host, + window: &str, + coordinates: String, +) -> IdeResult> { + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + ide_sdk::libraries::published_version_catalog(&project.root, &project.settings.effective(), &coordinates) + .map(Into::into) + }) + .await + .map_err(join_err) +} + /// Where the direct dependency `dependency` of the Maven module `pom` is declared. pub async fn maven_dependency_declaration( host: &Host, @@ -348,6 +364,9 @@ pub(crate) fn register(d: &mut Dispatcher) { ) .await }); + d.add("gradle_published_catalog", |c| async move { + gradle_published_catalog(&c.host, &c.window, c.arg("coordinates")?).await + }); d.add("maven_dependency_declaration", |c| async move { maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await }); diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 0871150d..40a61920 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -294,6 +294,36 @@ pub(crate) fn dependency_libraries( .collect() } +/// The `.toml` of a version catalog published as `group:artifact:version` (Gradle's +/// `version-catalog` plugin) that a build imports with `from("…")`: from Gradle's cache, else +/// the local Maven repository. `None` until a build has downloaded it. +pub fn published_version_catalog(root: &Path, s: &Settings, coordinates: &str) -> Option { + let mut parts = coordinates.trim().split(':'); + let d = BuildDependency { + gradle: true, + group: parts.next()?.to_owned(), + artifact: parts.next()?.to_owned(), + version: parts.next()?.to_owned(), + }; + if parts.next().is_some() || !valid_coordinate(&d) { + return None; + } + let file = format!("{}-{}.toml", d.artifact, d.version); + let gradle = crate::apply::setting_path(root, &s.languages.java.gradle.user_home).or_else(gradle_user_home); + let cached = gradle.and_then(|home| { + let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version); + let mut found: Vec = + std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path().join(&file)).filter(|p| p.is_file()).collect(); + found.sort(); + found.into_iter().next() + }); + cached.or_else(|| { + let repo = crate::apply::maven_local_repository(root, s).or_else(default_maven_repository)?; + let path = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version).join(&file); + path.is_file().then_some(path) + }) +} + /// No path separators, interpolations or `..` segments (coordinates become paths). fn valid_coordinate(d: &BuildDependency) -> bool { [&d.group, &d.artifact, &d.version] @@ -530,6 +560,26 @@ pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> { #[cfg(test)] mod go_cache_tests { use super::*; + + #[test] + fn finds_published_version_catalogs_in_the_gradle_cache_and_maven_repository() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut s = Settings::default(); + s.languages.java.gradle.user_home = root.join("gradle").to_string_lossy().into_owned(); + s.languages.java.maven.local_repository = root.join("m2").to_string_lossy().into_owned(); + let cached = root.join("gradle/caches/modules-2/files-2.1/com.acme/catalog/1.2/abc123/catalog-1.2.toml"); + std::fs::create_dir_all(cached.parent().unwrap()).unwrap(); + std::fs::write(&cached, "[libraries]").unwrap(); + assert_eq!(published_version_catalog(root, &s, "com.acme:catalog:1.2"), Some(cached)); + let local = root.join("m2/com/acme/other/2.0/other-2.0.toml"); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + std::fs::write(&local, "[libraries]").unwrap(); + assert_eq!(published_version_catalog(root, &s, " com.acme:other:2.0 "), Some(local)); + for missing in ["com.acme:catalog:9", "com.acme:catalog", "a:b:c:d", "../x:y:z"] { + assert_eq!(published_version_catalog(root, &s, missing), None, "{missing}"); + } + } #[test] fn vendor_roots_include_local_replacements_once() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 9f661d5b..7daaf11e 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -43,6 +43,18 @@ pub async fn build_sync( .await } +/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a +/// build has downloaded it. +#[tauri::command] +#[specta::specta] +pub async fn gradle_published_catalog( + window: Window, + state: State<'_, AppState>, + coordinates: String, +) -> IdeResult> { + ide_host::ops::build::gradle_published_catalog(&state.host, window.label(), coordinates).await +} + /// Where the direct dependency `dependency` of the Maven module `pom` is declared. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 16e6d465..c3b6a0f0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -337,6 +337,7 @@ pub fn builder() -> tauri_specta::Builder { run::run_test_reports, build::build_model, build::build_sync, + build::gradle_published_catalog, build::maven_dependency_declaration, build::maven_exclude_dependency, build::build_run, diff --git a/src/bindings.ts b/src/bindings.ts index 17ffc6c1..156b1269 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -638,6 +638,11 @@ export const commands = { * Returns the refreshed model; a failed build is reported in its `syncError`. */ buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel) => typedError(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })), + /** + * The `.toml` of a version catalog the build imports as `group:artifact:version`, once a + * build has downloaded it. + */ + gradlePublishedCatalog: (coordinates: string) => typedError(__TAURI_INVOKE("gradle_published_catalog", { coordinates })), /** Where the direct dependency `dependency` of the Maven module `pom` is declared. */ mavenDependencyDeclaration: (pom: string, dependency: ArtifactKey) => typedError(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })), /** diff --git a/src/features/build-tools/versionCatalog.test.ts b/src/features/build-tools/versionCatalog.test.ts index bc2c695a..24968aa5 100644 --- a/src/features/build-tools/versionCatalog.test.ts +++ b/src/features/build-tools/versionCatalog.test.ts @@ -1,5 +1,24 @@ -import { describe, expect, it } from "vitest"; -import { accessorSegments, catalogEntry, chainAt, declaredCatalogs, parseCatalog } from "./versionCatalog"; +import { describe, expect, it, vi } from "vitest"; + +const ipc = vi.hoisted(() => ({ fsReadText: vi.fn(), gradlePublishedCatalog: vi.fn() })); +vi.mock("@/platform/ipc", () => ({ + call: async (p: Promise<{ status: string; data?: unknown; error?: unknown }>) => { + const r = await p; + if (r.status !== "ok") throw r.error; + return r.data; + }, + commands: ipc, + onIdeEvent: () => () => {}, +})); + +import { + accessorSegments, + catalogEntry, + chainAt, + declaredCatalogs, + parseCatalog, + readCatalogs, +} from "./versionCatalog"; const toml = `[versions] springBoot = "3.5.16" @@ -64,11 +83,35 @@ describe("Gradle version catalogs", () => { create('tools') { from(files('gradle/tools.versions.toml')) } + create("platformLibs") { from("com.acme:platform-catalog:1.4.0") } } }`; expect(declaredCatalogs(settings)).toEqual([ { name: "commonLibs", file: "../shared/common.versions.toml" }, { name: "tools", file: "gradle/tools.versions.toml" }, + { name: "platformLibs", artifact: "com.acme:platform-catalog:1.4.0" }, ]); }); + + it("reads a published catalog from where a build downloaded it", async () => { + const files: Record = { + "/p/settings.gradle.kts": `dependencyResolutionManagement { versionCatalogs { + create("platformLibs") { from("com.acme:platform-catalog:1.4.0") } + create("later") { from("com.acme:later:1.0") } + } }`, + "/cache/platform-catalog-1.4.0.toml": '[libraries]\nspring-core = "org.springframework:spring-core:6.2.0"\n', + }; + ipc.fsReadText.mockImplementation(async (path: string) => + path in files ? { status: "ok", data: files[path] } : { status: "error", error: { kind: "notFound" } }, + ); + ipc.gradlePublishedCatalog.mockImplementation(async (coordinates: string) => ({ + status: "ok", + data: coordinates === "com.acme:platform-catalog:1.4.0" ? "/cache/platform-catalog-1.4.0.toml" : null, + })); + const { catalogs, complete } = await readCatalogs("/p/build.gradle.kts", "/p"); + expect(catalogs.map((c) => [c.name, c.path])).toEqual([["platformLibs", "/cache/platform-catalog-1.4.0.toml"]]); + expect(catalogEntry(catalogs[0]!, ["spring", "core"])?.value).toBe('"org.springframework:spring-core:6.2.0"'); + // `later` isn't downloaded yet: read again next time. + expect(complete).toBe(false); + }); }); diff --git a/src/features/build-tools/versionCatalog.ts b/src/features/build-tools/versionCatalog.ts index 64330a12..803ad380 100644 --- a/src/features/build-tools/versionCatalog.ts +++ b/src/features/build-tools/versionCatalog.ts @@ -99,19 +99,29 @@ export function chainAt(line: string, pos: number): { from: number; to: number; return segments.length > 1 ? { from, to, segments } : null; } -/** Catalogs declared in settings: `create("name") { from(files("path")) }`, paths relative to the build root. */ -export function declaredCatalogs(settings: string): Array<{ name: string; file: string }> { - const out: Array<{ name: string; file: string }> = []; - const re = /create\(\s*["'](\w+)["']\s*\)\s*\{[^}]*?from\(\s*files\(\s*["']([^"']+)["']/g; - for (let m = re.exec(settings); m; m = re.exec(settings)) out.push({ name: m[1]!, file: m[2]! }); +export type DeclaredCatalog = { name: string; file: string } | { name: string; artifact: string }; + +/** + * Catalogs declared in settings: `create("name") { from(files("path")) }`, paths relative to the + * build root, or `create("name") { from("group:artifact:version") }`, a published catalog. + */ +export function declaredCatalogs(settings: string): DeclaredCatalog[] { + const out: DeclaredCatalog[] = []; + const re = + /create\(\s*["'](\w+)["']\s*\)\s*\{[^}]*?from\(\s*(?:files\(\s*["']([^"']+)["']|["']([^"':]+:[^"':]+:[^"']+)["'])/g; + for (let m = re.exec(settings); m; m = re.exec(settings)) + out.push(m[2] !== undefined ? { name: m[1]!, file: m[2] } : { name: m[1]!, artifact: m[3]! }); return out; } const readText = (path: string) => call(commands.fsReadText(path)).catch(() => null); const join = (dir: string, rel: string) => (rel.startsWith("/") ? rel : `${dir}/${rel.replace(/^\.\//, "")}`); -/** The catalogs a build script sees: those of the nearest enclosing build (its settings file). */ -async function readCatalogs(script: string, root: string): Promise { +/** + * The catalogs a build script sees: those of the nearest enclosing build (its settings file). + * `complete` is false while a published catalog isn't downloaded yet. + */ +export async function readCatalogs(script: string, root: string): Promise<{ catalogs: Catalog[]; complete: boolean }> { let dir = dirname(script); let settings: string | null = null; for (;;) { @@ -123,13 +133,17 @@ async function readCatalogs(script: string, root: string): Promise { const declared = declaredCatalogs(settings ?? ""); if (!declared.some((d) => d.name === "libs")) declared.push({ name: "libs", file: "gradle/libs.versions.toml" }); const catalogs = await Promise.all( - declared.map(async ({ name, file }) => { - const path = join(buildRoot, file); - const text = await readText(path); - return text === null ? null : parseCatalog(name, path, text); + declared.map(async (d) => { + const path = + "file" in d + ? join(buildRoot, d.file) + : await call(commands.gradlePublishedCatalog(d.artifact)).catch(() => null); + const text = path ? await readText(path) : null; + return text === null ? null : parseCatalog(d.name, path!, text); }), ); - return catalogs.filter((c): c is Catalog => c !== null); + const complete = declared.every((d, i) => "file" in d || catalogs[i] !== null); + return { catalogs: catalogs.filter((c): c is Catalog => c !== null), complete }; } const loaded = new Map(); @@ -148,11 +162,14 @@ function load(script: string): void { pending.clear(); scripts.forEach(load); }); - const p = readCatalogs(script, root); - pending.set(script, p); - void p.then((c) => { - if (pending.get(script) === p) loaded.set(script, c); + const p = readCatalogs(script, root).then(({ catalogs, complete }) => { + if (pending.get(script) !== p) return catalogs; + loaded.set(script, catalogs); + // Read again when the script is next opened, once a build has downloaded the catalog. + if (!complete) pending.delete(script); + return catalogs; }); + pending.set(script, p); } function open(entry: CatalogEntry, path: string) { From fa6d7276b851e213ca33d7d71bcb647c5b2bfa17 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:16:15 +0800 Subject: [PATCH 3/7] fix(i18n): name each Docker prune and Compose operation in a full sentence (#1150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prune and Compose task titles spliced English into a sentence ("Pruning ${target}…", "Compose ${op} ${project}…"), so they stayed partly or wholly English. Each prune target and Compose operation now has its own title and failure text. --- src/features/services/store.ts | 27 ++++++++++++++++++++++++--- src/platform/locales/text/zh.json | 17 ++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/features/services/store.ts b/src/features/services/store.ts index 27d1b835..a9f5bd8f 100644 --- a/src/features/services/store.ts +++ b/src/features/services/store.ts @@ -363,13 +363,34 @@ export const pullImage = (connId: string, reference: string) => call(commands.dockerPull(r, endpointOf(connId), reference, out)), ); -export const prune = (connId: string, target: PruneTarget) => - task(connId, `Pruning ${target === "allImages" ? "unused images" : target}…`, tx("Could not prune"), (r, out) => +export function prune(connId: string, target: PruneTarget) { + const title = { + containers: tx("Pruning stopped containers…"), + images: tx("Pruning dangling images…"), + allImages: tx("Pruning unused images…"), + volumes: tx("Pruning unused volumes…"), + networks: tx("Pruning unused networks…"), + system: tx("Pruning the system…"), + }[target]; + return task(connId, title, tx("Could not prune"), (r, out) => call(commands.dockerPrune(r, endpointOf(connId), target, out)), ); +} export function composeOp(connId: string, project: string, files: string[], workingDir: string | null, op: ComposeOp) { - return task(connId, `Compose ${op} ${project}…`, tx`Compose ${op} failed for ${project}`, (r, out) => + const { title, failure } = { + start: { title: tx`Starting Compose project ${project}…`, failure: tx`Could not start Compose project ${project}` }, + stop: { title: tx`Stopping Compose project ${project}…`, failure: tx`Could not stop Compose project ${project}` }, + restart: { + title: tx`Restarting Compose project ${project}…`, + failure: tx`Could not restart Compose project ${project}`, + }, + down: { + title: tx`Taking down Compose project ${project}…`, + failure: tx`Could not take down Compose project ${project}`, + }, + }[op]; + return task(connId, title, failure, (r, out) => call(commands.dockerComposeOp(r, endpointOf(connId), project, files, workingDir, op, out)), ); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ed0d5867..9771374a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3420,13 +3420,11 @@ "Remove all networks not used by a container?": "移除所有未被容器使用的网络?", "Remove stopped containers, unused networks, dangling images and the build cache?": "移除已停止的容器、未使用的网络、悬空镜像和构建缓存?", "Prune {target}": "清理{target}", - "Pruning {target}…": "正在清理 {target}…", "Pulling {name}…": "正在拉取 {name}…", "containers": "容器", "images": "镜像", "volumes": "卷", "networks": "网络", - "unused images": "未使用的镜像", "Compose project '{name}'": "Compose 项目 '{name}'", "Engine {server} · CLI {client}": "引擎 {server} · CLI {client}", " · context {name}": " · 上下文 {name}", @@ -5847,5 +5845,18 @@ "Could not remove network": "无法删除网络", "Could not pull {reference}": "无法拉取 {reference}", "Could not prune": "无法清理", - "Compose {op} failed for {project}": "Compose {op} 在 {project} 上失败" + "Pruning stopped containers…": "正在清理已停止的容器…", + "Pruning dangling images…": "正在清理悬空镜像…", + "Pruning unused images…": "正在清理未使用的镜像…", + "Pruning unused volumes…": "正在清理未使用的卷…", + "Pruning unused networks…": "正在清理未使用的网络…", + "Pruning the system…": "正在清理系统…", + "Starting Compose project {project}…": "正在启动 Compose 项目 {project}…", + "Stopping Compose project {project}…": "正在停止 Compose 项目 {project}…", + "Restarting Compose project {project}…": "正在重启 Compose 项目 {project}…", + "Taking down Compose project {project}…": "正在停止并移除 Compose 项目 {project}…", + "Could not start Compose project {project}": "无法启动 Compose 项目 {project}", + "Could not stop Compose project {project}": "无法停止 Compose 项目 {project}", + "Could not restart Compose project {project}": "无法重启 Compose 项目 {project}", + "Could not take down Compose project {project}": "无法停止并移除 Compose 项目 {project}" } From c86928eee7e7d6356822d215ec9cd3f8535baa28 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:27:50 +0800 Subject: [PATCH 4/7] fix(deployment): Windows absolute mapping paths, and a flaky plugin syntax test (#1130) An absolute local mapping path outside the project falls back to the same path under the project. On Windows, joining it onto the project root kept its drive and replaced the root, so an existing outside folder became a mapping. The drive and root are now dropped first, and the mapping tests use the platform's absolute paths. The Solidity syntax test asks again when the first call of a new instance runs past the edit-path budget and its answer is dropped. --- crates/ide-host/tests/plugins.rs | 11 ++++++++++- crates/ide-remote/src/mapping.rs | 28 +++++++++++++++++----------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 35139a57..b052cd72 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -1064,7 +1064,16 @@ fn plugin_syntax_for_languages_without_a_grammar() { let sol = LanguageId::new("solidity"); let text = "// c\ncontract A {\n uint256 public x;\n function f() public {}\n}\n"; assert!(provider.highlights(&LanguageId::new("rust"), text).is_none(), "only its own languages"); - let tokens = provider.highlights(&sol, text).unwrap(); + // The first call of a new instance can run past the edit-path budget on a + // slow machine; its answer is dropped and a later call asks again. + let deadline = Instant::now() + Duration::from_secs(10); + let tokens = loop { + match provider.highlights(&sol, text) { + Some(tokens) => break tokens, + None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + None => panic!("no highlights"), + } + }; let kind_at = |s: &str| tokens.iter().find(|t| &text[t.start as usize..t.end as usize] == s).map(|t| t.kind.as_str()); assert_eq!(kind_at("contract"), Some("keyword")); diff --git a/crates/ide-remote/src/mapping.rs b/crates/ide-remote/src/mapping.rs index b9bbaefa..c8a2c36c 100644 --- a/crates/ide-remote/src/mapping.rs +++ b/crates/ide-remote/src/mapping.rs @@ -52,11 +52,16 @@ fn local_folder(local: &str, project_root: &Path) -> Option { { return Some(project_root.join(rel)); } - let rel = local.trim_matches('/'); - let relative = project_root.join(rel); + let relative = project_root.join(without_root(path)); if !path.is_absolute() || relative.is_dir() || !path.exists() { Some(relative) } else { None } } +/// `path` without its drive and root (`/a/b`, `C:\a\b` → `a/b`), to read it +/// relative to the project: joining an absolute path would replace the root. +fn without_root(path: &Path) -> PathBuf { + path.components().filter(|c| !matches!(c, Component::Prefix(_) | Component::RootDir)).collect() +} + impl Mappings { /// `remote_root` is the server root resolved on the host (its login /// directory when the setting is empty). A server without mappings maps the @@ -247,14 +252,15 @@ mod tests { #[test] fn local_paths_may_be_absolute_inside_the_project() { - let root = Path::new("/p"); - let m = Mappings::new(&server(vec![("/p", "/", ""), ("/p/static/", "cdn", "")]), root, "/mnt/deploy"); - assert_eq!(m.remote(Path::new("/p/src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py")); - assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css")); - assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(PathBuf::from("/p/static/x.css"))); + let root = Path::new(if cfg!(windows) { r"C:\p" } else { "/p" }); + let (inside, statics) = (root.to_str().unwrap(), format!("{}/static/", root.display())); + let m = Mappings::new(&server(vec![(inside, "/", ""), (&statics, "cdn", "")]), root, "/mnt/deploy"); + assert_eq!(m.remote(&root.join("src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py")); + assert_eq!(m.remote(&root.join("static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css")); + assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(root.join("static/x.css"))); // A leading `/` that is not the project stays project-relative. let m = Mappings::new(&server(vec![("/static/", "", "")]), root, "/mnt/deploy"); - assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/x.css")); + assert_eq!(m.remote(&root.join("static/x.css")).as_deref(), Some("/mnt/deploy/x.css")); } #[test] @@ -264,7 +270,7 @@ mod tests { let outside = tmp.path().join("outside"); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir(&outside).unwrap(); - let nested = root.join(outside.strip_prefix("/").unwrap()).join("a.txt"); + let nested = root.join(without_root(&outside)).join("a.txt"); std::fs::create_dir_all(nested.parent().unwrap().parent().unwrap()).unwrap(); // A nested project file at the outside path's spelling is not a mapping folder. std::fs::write(nested.parent().unwrap(), "nested").unwrap(); @@ -288,7 +294,7 @@ mod tests { assert_eq!(local_folder("/", &root), Some(root.clone())); let outside = tmp.path().join("outside"); std::fs::create_dir(&outside).unwrap(); - let relative = root.join(outside.strip_prefix("/").unwrap()); + let relative = root.join(without_root(&outside)); std::fs::create_dir_all(&relative).unwrap(); assert_eq!(local_folder(outside.to_str().unwrap(), &root), Some(relative.clone())); let nested = relative.join("a.txt"); @@ -297,7 +303,7 @@ mod tests { assert_eq!(m.remote(&nested).as_deref(), Some("/srv/a.txt")); assert_eq!(m.local("/srv/a.txt"), Some(nested)); let missing = tmp.path().join("missing"); - assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(missing.strip_prefix("/").unwrap()))); + assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(without_root(&missing)))); assert_eq!(local_folder("/src/", &root), Some(root.join("src"))); } From e88e7e6459380ce1cc0c96bc6e81d3b0a9248fcc Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:57:10 +0800 Subject: [PATCH 5/7] fix(i18n): translate Git operation titles and their notifications (#1154) Git operations passed their title, success and failure text to runOp / runGitOperation as plain strings the text catalog check doesn't see, so notifications after a commit, merge, rebase, stash, shelve, tag, patch or rollback stayed in English, several splicing plurals or English words. Call sites now tag the text with tx, each count and variant has its own sentence, and a failure is titled "{title} failed" from the translated title. --- src/features/vcs/DiffEditor.tsx | 2 +- src/features/vcs/LocalHistory.tsx | 5 +-- src/features/vcs/MergeEditor.tsx | 4 +-- src/features/vcs/branchOps.ts | 38 +++++++++++--------- src/features/vcs/changeOps.tsx | 39 ++++++++++++++------- src/features/vcs/confirmations.tsx | 9 +++-- src/features/vcs/conflictsUi.tsx | 4 +-- src/features/vcs/gitMenuDialogs.tsx | 8 ++--- src/features/vcs/logChanges.ts | 4 +-- src/features/vcs/operations.tsx | 20 +++++------ src/features/vcs/ops.ts | 2 +- src/features/vcs/patches.tsx | 4 +-- src/features/vcs/rebase.tsx | 6 ++-- src/features/vcs/shelf.tsx | 10 +++--- src/platform/locales/text/zh.json | 54 ++++++++++++++++++++++++++++- 15 files changed, 143 insertions(+), 66 deletions(-) diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index ae46a328..b00dd6e7 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -724,7 +724,7 @@ export function DiffEditor({ input, toolbarActions }: { input: EditorInput; tool logConsole("info", "Nothing to stage for this change"); return; } - await runOp(`Stage hunk of ${path}`, () => call(commands.vcsStageHunks(path, indices))); + await runOp(tx`Stage hunk of ${path}`, () => call(commands.vcsStageHunks(path, indices))); await refreshNow(); } finally { setBusy(false); diff --git a/src/features/vcs/LocalHistory.tsx b/src/features/vcs/LocalHistory.tsx index da3c35f9..2c467bcf 100644 --- a/src/features/vcs/LocalHistory.tsx +++ b/src/features/vcs/LocalHistory.tsx @@ -97,12 +97,13 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) { const ok = await confirmDialog({ title: "Revert", message: question, okLabel: "Revert" }); if (!ok) return; await runOp( - "Revert from Local History", + tx("Revert from Local History"), async () => { for (const e of targets) await call(commands.historyRestore(e.path, e.id)); }, { - success: targets.length === 1 ? `${baseName(targets[0]!.path)} reverted` : `${targets.length} files reverted`, + success: + targets.length === 1 ? tx`${baseName(targets[0]!.path)} reverted` : tx`${targets.length} files reverted`, refresh: true, }, ); diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 0d5c7796..6244d82a 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -259,14 +259,14 @@ export function MergeEditor({ input }: { input: EditorInput }) { /** Saves `text` as the resolved file and stages it; like IDEA, that closes the merge editor. */ const writeResolved = async (text: string | null) => { const ok = await runOp( - `Resolve ${baseName(path)}`, + tx`Resolve ${baseName(path)}`, async () => { if (text === null) await call(commands.fsDelete(path)); else await writeFileText(path, text, { save: true, exact: true }); await call(commands.vcsStage([path])); return true; }, - { success: `${baseName(path)}: conflicts resolved` }, + { success: tx`${baseName(path)}: conflicts resolved` }, ); if (ok) { setResolved(true); diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index b5f4adb0..6bd3f894 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -79,7 +79,7 @@ async function performCheckout( let options: CheckoutOptions = { mode: "normal", diverged: null }; for (;;) { const opts = options; - const outcome = await runOp(`Checkout ${rev}`, () => call(commands.vcsCheckout(repo, rev, false, opts))); + const outcome = await runOp(tx`Checkout ${rev}`, () => call(commands.vcsCheckout(repo, rev, false, opts))); if (!outcome) return false; // `success` may name the branch now checked out. if (outcome.kind === "done" || outcome.kind === "conflicts") await refreshNow(); @@ -167,7 +167,7 @@ export async function newBranch(from?: string) { const previous = useVcs.getState().branch?.head; if (checkout) await saveAll(); const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout)); - const ok = await runOp(`Create branch ${r.value}`, create, { + const ok = await runOp(tx`Create branch ${r.value}`, create, { success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`, }); if (ok !== undefined && checkout) { @@ -208,8 +208,8 @@ export async function openConflictsInstead(): Promise { export async function mergeIntoCurrent(branch: string, repo: string | null = currentRepo()) { const current = (repo === currentRepo() ? useVcs.getState().branch?.head : null) ?? "HEAD"; await saveAll(); - await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, branch)), { - success: `Merged ${branch} into ${current}`, + await runOp(tx`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, branch)), { + success: tx`Merged ${branch} into ${current}`, errorActions: conflictActions, onError: openConflictsInstead, }); @@ -219,8 +219,8 @@ export async function rebaseOnto(branch: string) { const repo = currentRepo(); const current = useVcs.getState().branch?.head ?? "HEAD"; await saveAll(); - await runOp(`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(repo, branch)), { - success: `Rebased ${current} onto ${branch}`, + await runOp(tx`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(repo, branch)), { + success: tx`Rebased ${current} onto ${branch}`, errorActions: conflictActions, onError: openConflictsInstead, }); @@ -238,8 +238,8 @@ export async function renameBranch(name: string) { }); if (!r) return; const to = r.value.trim(); - const ok = await runOp(`Rename branch ${name}`, () => call(commands.vcsRenameBranch(repo, name, to)), { - success: `Renamed branch ${name} to ${to}`, + const ok = await runOp(tx`Rename branch ${name}`, () => call(commands.vcsRenameBranch(repo, name, to)), { + success: tx`Renamed branch ${name} to ${to}`, }); if (ok !== undefined) { try { @@ -257,7 +257,7 @@ export async function renameBranch(name: string) { */ export async function deleteBranch(name: string) { const repo = currentRepo(); - const deleted = await runOp(`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(repo, name))); + const deleted = await runOp(tx`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(repo, name))); if (!deleted) return; const tracked = deleted.upstream; const otherTracking = useVcs.getState().branches.some((b) => !b.remote && b.name !== name && b.upstream === tracked); @@ -274,8 +274,8 @@ export async function deleteBranch(name: string) { label: "Restore", run: () => { done(); - void runOp(`Restore branch ${name}`, () => call(commands.vcsRestoreBranch(repo, deleted)), { - success: `Restored branch ${name}`, + void runOp(tx`Restore branch ${name}`, () => call(commands.vcsRestoreBranch(repo, deleted)), { + success: tx`Restored branch ${name}`, }); }, }, @@ -379,9 +379,13 @@ export async function newBranchInRepos(roots: readonly string[], from?: string) await eachRepo( roots, async (root) => - (await runOp(`Create branch ${name}`, () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), { - refresh: false, - })) !== undefined, + (await runOp( + tx`Create branch ${name}`, + () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), + { + refresh: false, + }, + )) !== undefined, r.checked ? `Created and checked out ${name}` : `Created branch ${name}`, ); } @@ -392,7 +396,7 @@ export async function mergeInRepos(branch: string, roots: readonly string[]) { await eachRepo( roots, async (root) => - (await runOp(`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), { + (await runOp(tx`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), { refresh: false, errorActions: conflictActions, onError: openConflictsInstead, @@ -407,7 +411,7 @@ export async function rebaseInRepos(branch: string, roots: readonly string[]) { await eachRepo( roots, async (root) => - (await runOp(`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), { + (await runOp(tx`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), { refresh: false, errorActions: conflictActions, onError: openConflictsInstead, @@ -428,7 +432,7 @@ export async function deleteInRepos(name: string, roots: readonly string[]) { await eachRepo( roots, async (root) => - (await runOp(`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), { + (await runOp(tx`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), { refresh: false, })) !== undefined, `Deleted branch ${name}`, diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index be93de05..a50adc30 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -90,8 +90,23 @@ export async function commitChanges(): Promise { success: () => { const n = plan.included.length; const summary = firstLine(message); - const where = repos.length > 1 ? ` in ${repos.length} repositories` : ""; - return `${n} file${n === 1 ? "" : "s"} ${amend ? "amended" : "committed"}${where}${summary ? `: ${summary}` : ""}`; + const several = repos.length; + const done = amend + ? several > 1 + ? n === 1 + ? tx`1 file amended in ${several} repositories` + : tx`${n} files amended in ${several} repositories` + : n === 1 + ? tx("1 file amended") + : tx`${n} files amended` + : several > 1 + ? n === 1 + ? tx`1 file committed in ${several} repositories` + : tx`${n} files committed in ${several} repositories` + : n === 1 + ? tx("1 file committed") + : tx`${n} files committed`; + return summary ? `${done}: ${summary}` : done; }, }, ); @@ -199,12 +214,12 @@ function RollbackDialog({ files, onClose }: { files: FileStatus[]; onClose: () = await saveAll(); const { unstage, discard } = rollbackPlan(chosen, deleteAdded); await runOp( - "Rollback", + tx("Rollback"), async () => { if (unstage.length) await call(commands.vcsUnstage(unstage)); if (discard.length) await call(commands.vcsDiscard(discard)); }, - { success: `Rolled back ${chosen.length} file${chosen.length === 1 ? "" : "s"}` }, + { success: chosen.length === 1 ? tx("Rolled back 1 file") : tx`Rolled back ${chosen.length} files` }, ); }; return ( @@ -284,7 +299,7 @@ export async function addToVcs(paths: readonly string[]) { }); return; } - await runOp(`Add ${targets.length} file${targets.length === 1 ? "" : "s"} to Git`, () => + await runOp(targets.length === 1 ? tx("Add 1 file to Git") : tx`Add ${targets.length} files to Git`, () => call(commands.vcsStage(targets.map(stripSlash))), ); } @@ -299,9 +314,9 @@ export function stashChanges(repo = currentRepo()): Promise { if (r) { await saveAll(); await runOp( - "Stash changes", + tx("Stash changes"), () => call(commands.vcsStashPush(repo, r.message || null, r.includeUntracked, r.keepIndex)), - { success: "Changes stashed" }, + { success: tx("Changes stashed") }, ); void loadStashes().catch(() => undefined); } @@ -372,10 +387,10 @@ export interface UnstashOptions { export function unstashTitles(o: UnstashOptions): { title: string; success: string } { const ref = `stash@{${o.index}}`; if (o.branch) - return { title: tx`Unstash ${ref} as branch ${o.branch}`, success: `Unstashed ${ref} on branch ${o.branch}` }; + return { title: tx`Unstash ${ref} as branch ${o.branch}`, success: tx`Unstashed ${ref} on branch ${o.branch}` }; return o.pop - ? { title: tx`Pop ${ref}`, success: `Unstashed ${ref}` } - : { title: tx`Apply ${ref}`, success: `Applied ${ref}` }; + ? { title: tx`Pop ${ref}`, success: tx`Unstashed ${ref}` } + : { title: tx`Apply ${ref}`, success: tx`Applied ${ref}` }; } export async function unstash(o: UnstashOptions, repo = currentRepo()) { @@ -482,8 +497,8 @@ export async function dropStash(index: number, message: string, repo = currentRe danger: true, }); if (!ok) return; - await runOp(`Drop stash@{${index}}`, () => call(commands.vcsStashDrop(repo, index)), { - success: `Dropped stash@{${index}}`, + await runOp(tx`Drop ${`stash@{${index}}`}`, () => call(commands.vcsStashDrop(repo, index)), { + success: tx`Dropped ${`stash@{${index}}`}`, }); void loadStashes().catch((e) => logConsole("err", String(e))); } diff --git a/src/features/vcs/confirmations.tsx b/src/features/vcs/confirmations.tsx index e71a546c..b0a0ab7c 100644 --- a/src/features/vcs/confirmations.tsx +++ b/src/features/vcs/confirmations.tsx @@ -4,6 +4,7 @@ * the deletion staged (`git rm`), with the same three choices. */ import { onFileOperation, type FileOperation } from "@/platform/fileOps"; +import { tx } from "@/platform/i18n"; import { call, commands, isTauri } from "@/platform/ipc"; import { loadSettings } from "@/platform/project"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -80,8 +81,12 @@ async function handle(op: FileOperation) { const n = files.length; await runOp( op.kind === "created" - ? `Add ${n} file${n === 1 ? "" : "s"} to Git` - : `Remove ${n} file${n === 1 ? "" : "s"} from Git`, + ? n === 1 + ? tx("Add 1 file to Git") + : tx`Add ${n} files to Git` + : n === 1 + ? tx("Remove 1 file from Git") + : tx`Remove ${n} files from Git`, () => call(commands.vcsStage(files)), ); } diff --git a/src/features/vcs/conflictsUi.tsx b/src/features/vcs/conflictsUi.tsx index c24969bd..2777e3ea 100644 --- a/src/features/vcs/conflictsUi.tsx +++ b/src/features/vcs/conflictsUi.tsx @@ -31,7 +31,7 @@ export function openMergeEditor(path: string) { async function acceptSide(f: FileStatus, side: "ours" | "theirs") { const path = stripSlash(f.path); await runOp( - `Accept ${side === "ours" ? "yours" : "theirs"} for ${baseName(path)}`, + side === "ours" ? tx`Accept yours for ${baseName(path)}` : tx`Accept theirs for ${baseName(path)}`, async () => { const v = await call(commands.vcsConflictVersions(path)); const text = side === "ours" ? v.ours : v.theirs; @@ -43,7 +43,7 @@ async function acceptSide(f: FileStatus, side: "ours" | "theirs") { } await call(commands.vcsStage([path])); }, - { success: `${baseName(path)} resolved` }, + { success: tx`${baseName(path)} resolved` }, ); } diff --git a/src/features/vcs/gitMenuDialogs.tsx b/src/features/vcs/gitMenuDialogs.tsx index 12171251..cc19fb22 100644 --- a/src/features/vcs/gitMenuDialogs.tsx +++ b/src/features/vcs/gitMenuDialogs.tsx @@ -238,9 +238,9 @@ function MergeDialog({ onClose }: { onClose: () => void }) { await saveAll(); const target = head ?? "HEAD"; await runOp( - `Merge ${branch} into ${target}`, + tx`Merge ${branch} into ${target}`, () => call(commands.vcsMergeWith(repo, [branch], mergeOptionsOf(flags, withMessage ? message : null))), - { success: `Merged ${branch} into ${target}`, errorActions: conflictActions, onError: openConflictsInstead }, + { success: tx`Merged ${branch} into ${target}`, errorActions: conflictActions, onError: openConflictsInstead }, ); await refreshNow(); }; @@ -313,7 +313,7 @@ function RebaseDialog({ onClose }: { onClose: () => void }) { const target = useOnto && onto ? onto : upstream; const current = head ?? "HEAD"; await runOp( - `Rebase ${current} onto ${target}`, + tx`Rebase ${current} onto ${target}`, () => call( commands.vcsRebaseWith(repo, upstream, { @@ -322,7 +322,7 @@ function RebaseDialog({ onClose }: { onClose: () => void }) { keepEmpty, }), ), - { success: `Rebased ${current} onto ${target}`, errorActions: conflictActions, onError: openConflictsInstead }, + { success: tx`Rebased ${current} onto ${target}`, errorActions: conflictActions, onError: openConflictsInstead }, ); await refreshNow(); }; diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts index 87e1b0d2..c292d945 100644 --- a/src/features/vcs/logChanges.ts +++ b/src/features/vcs/logChanges.ts @@ -184,7 +184,7 @@ export async function getFromRevision(changes: readonly LogChange[]) { if (!ok) return; await saveAll(); await runOp( - "Get from Revision", + tx("Get from Revision"), async () => { for (const c of changes) { const path = absolutePath(c.path, c.repo); @@ -211,7 +211,7 @@ export async function revertSelectedChanges(changes: readonly LogChange[], commi const ids = [...commits].reverse().map((c) => c.id); const paths = new Set(changes.flatMap((c) => (c.oldPath ? [c.path, c.oldPath] : [c.path]))); const outcome = await runOp( - "Revert selected changes", + tx("Revert selected changes"), async () => { const patch = filterPatch( await call( diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index d0a9bbe0..51ac6ec4 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -165,7 +165,7 @@ export async function controlOperation(control: "continue" | "skip" | "abort", e { progress, success, - failure: `${controlLabel(op, control)} failed`, + failure: tx`${controlLabel(op, control)} failed`, onError: control === "abort" ? undefined : conflictsInstead, }, ); @@ -241,8 +241,8 @@ export async function cherryPick(commits: Commit[]) { { op: "cherryPick", commits: ordered.map((c) => c.id) }, { progress: "Cherry-picking", - success: `Cherry-picked ${label}`, - failure: "Cherry-pick failed", + success: tx`Cherry-picked ${label}`, + failure: tx("Cherry-pick failed"), onError: conflictsInstead, }, commitRepo(commits[0]!) ?? currentRepo(), @@ -256,7 +256,7 @@ export async function revertCommits(commits: Commit[]) { ordered.length === 1 ? `${short(ordered[0]!.id)} “${ordered[0]!.summary}”` : `${ordered.length} commits`; await runGitOperation( { op: "revert", commits: ordered.map((c) => c.id) }, - { progress: "Reverting", success: `Reverted ${label}`, failure: "Revert failed", onError: conflictsInstead }, + { progress: "Reverting", success: tx`Reverted ${label}`, failure: tx("Revert failed"), onError: conflictsInstead }, commitRepo(commits[0]!) ?? currentRepo(), ); } @@ -294,8 +294,8 @@ function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void { op: "reset", commit: rev, mode }, { progress: "Resetting", - success: `Reset ${head} to ${commit ? short(commit.id) : rev} (${mode})`, - failure: "Reset failed", + success: tx`Reset ${head} to ${commit ? short(commit.id) : rev} (${mode})`, + failure: tx("Reset failed"), }, repo, ); @@ -374,8 +374,8 @@ export async function newTagOn(commit: Commit) { }); if (!r) return; const create = () => call(commands.vcsCreateTag(repo, r.value, commit.id, null, false)); - const ok = await runOp(`Create tag ${r.value}`, create, { - success: `Created tag ${r.value} on ${short(commit.id)}`, + const ok = await runOp(tx`Create tag ${r.value}`, create, { + success: tx`Created tag ${r.value} on ${short(commit.id)}`, }); // Tags are not part of the refs signature that reloads the log. if (ok !== undefined) void reloadLog(); @@ -402,9 +402,9 @@ function TagDialog({ onClose }: { onClose: () => void }) { onClose(); const tag = name.trim(); const ok = await runOp( - `Create tag ${tag}`, + tx`Create tag ${tag}`, () => call(commands.vcsCreateTag(repo, tag, commit.trim() || "HEAD", message || null, force)), - { success: `Created tag ${tag}` }, + { success: tx`Created tag ${tag}` }, ); if (ok !== undefined && useGitLog.getState().commits.length) void reloadLog(); }; diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index db134452..504dd309 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -89,7 +89,7 @@ export async function runOp(title: string, fn: () => Promise, opts: OpOpti notifications().notify({ group: "vcs", level: "error", - title: `${title} failed`, + title: tx`${title} failed`, body: message, actions: opts.errorActions?.(message), }); diff --git a/src/features/vcs/patches.tsx b/src/features/vcs/patches.tsx index ab3a5883..0f4ee082 100644 --- a/src/features/vcs/patches.tsx +++ b/src/features/vcs/patches.tsx @@ -116,7 +116,7 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: reverse, }; const done = await runOp( - "Create patch", + tx("Create patch"), async () => { const patch = origin.kind === "shelf" @@ -250,7 +250,7 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s const run = async () => { onClose(); await saveAll(); - const outcome = await runOp(`Apply patch (${source})`, () => call(commands.vcsApplyPatch(repo, patch)), { + const outcome = await runOp(tx`Apply patch (${source})`, () => call(commands.vcsApplyPatch(repo, patch)), { refresh: false, }); if (outcome) await reportApply(outcome, "Patch applied"); diff --git a/src/features/vcs/rebase.tsx b/src/features/vcs/rebase.tsx index 044b0218..a6a74155 100644 --- a/src/features/vcs/rebase.tsx +++ b/src/features/vcs/rebase.tsx @@ -62,8 +62,8 @@ export async function undoCommit(commit: Commit) { if (!ok) return; } const undo = () => call(commands.vcsUndoCommit(repo, commit.id)); - const message = await runOp(`Undo commit ${short(commit.id)}`, undo, { - success: `Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`, + const message = await runOp(tx`Undo commit ${short(commit.id)}`, undo, { + success: tx`Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`, }); // IDEA offers the undone message for the next commit. if (message !== undefined && !useCommit.getState().message.trim()) useCommit.setState({ message }); @@ -123,7 +123,7 @@ async function runRebase(base: string | null, entries: RebaseEntry[], done: stri { progress: "Rebasing", success: () => (inProgress() === "rebase" ? "Rebase stopped for editing; continue the rebase when done" : done), - failure: "Rebase failed", + failure: tx("Rebase failed"), onError: conflictsInstead, }, repo, diff --git a/src/features/vcs/shelf.tsx b/src/features/vcs/shelf.tsx index 9e808b91..07d8bf59 100644 --- a/src/features/vcs/shelf.tsx +++ b/src/features/vcs/shelf.tsx @@ -144,7 +144,7 @@ function ShelveDialog({ if (!chosen.length || !name.trim()) return; onClose(); await saveAll(); - const entry = await runOp(`Shelve changes “${name.trim()}”`, () => call(commands.vcsShelve(name.trim(), chosen))); + const entry = await runOp(tx`Shelve changes “${name.trim()}”`, () => call(commands.vcsShelve(name.trim(), chosen))); if (!entry) return; void loadShelves().catch(() => undefined); notifications().notify({ @@ -218,7 +218,7 @@ function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; pa onClose(); await saveAll(); const outcome = await runOp( - `Unshelve “${entry.name}”`, + tx`Unshelve “${entry.name}”`, () => call(commands.vcsUnshelve(entry.id, remove, path ?? null)), { refresh: false, @@ -272,7 +272,7 @@ export async function deleteShelf(entry: ShelvedChangeList, path?: string) { danger: true, }); if (!ok) return; - await runOp(`Delete shelved changes “${entry.name}”`, () => call(commands.vcsShelfDelete(entry.id, path ?? null)), { + await runOp(tx`Delete shelved changes “${entry.name}”`, () => call(commands.vcsShelfDelete(entry.id, path ?? null)), { refresh: false, }); void loadShelves().catch(() => undefined); @@ -286,7 +286,7 @@ export async function renameShelf(entry: ShelvedChangeList) { okLabel: "Rename", }); if (!r || r.value === entry.name) return; - await runOp("Rename shelved changes", () => call(commands.vcsShelfRename(entry.id, r.value)), { refresh: false }); + await runOp(tx("Rename shelved changes"), () => call(commands.vcsShelfRename(entry.id, r.value)), { refresh: false }); void loadShelves().catch(() => undefined); } @@ -388,7 +388,7 @@ export async function importShelfPatches(paths?: string[]) { if (!picked) return; for (const path of typeof picked === "string" ? [picked] : picked) { await runOp( - "Import patches", + tx("Import patches"), async () => call(commands.vcsShelfImport(repo, baseName(path), await call(commands.fsReadText(path)))), { refresh: false }, ); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 9771374a..9bd26e1d 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5858,5 +5858,57 @@ "Could not start Compose project {project}": "无法启动 Compose 项目 {project}", "Could not stop Compose project {project}": "无法停止 Compose 项目 {project}", "Could not restart Compose project {project}": "无法重启 Compose 项目 {project}", - "Could not take down Compose project {project}": "无法停止并移除 Compose 项目 {project}" + "Could not take down Compose project {project}": "无法停止并移除 Compose 项目 {project}", + "Stage hunk of {path}": "暂存 {path} 的块", + "{path} reverted": "已还原 {path}", + "{length} files reverted": "已还原 {length} 个文件", + "Resolve {path}": "解决 {path}", + "{path}: conflicts resolved": "{path}:冲突已解决", + "Create branch {value}": "创建分支 {value}", + "Merge {branch} into {current}": "将 {branch} 合并到 {current}", + "Merged {branch} into {current}": "已将 {branch} 合并到 {current}", + "Rebase {current} onto {branch}": "将 {current} 变基到 {branch}", + "Rebased {current} onto {branch}": "已将 {current} 变基到 {branch}", + "Rename branch {name}": "重命名分支 {name}", + "Renamed branch {name} to {to}": "已将分支 {name} 重命名为 {to}", + "Delete branch {name}": "删除分支 {name}", + "Restore branch {name}": "恢复分支 {name}", + "Merge {branch} ({root})": "合并 {branch}({root})", + "Rebase onto {branch} ({root})": "变基到 {branch}({root})", + "Delete branch {name} ({root})": "删除分支 {name}({root})", + "1 file amended in {several} repositories": "已在 {several} 个仓库中修改 1 个文件的提交", + "{n} files amended in {several} repositories": "已在 {several} 个仓库中修改 {n} 个文件的提交", + "1 file amended": "已修改 1 个文件的提交", + "{n} files amended": "已修改 {n} 个文件的提交", + "1 file committed in {several} repositories": "已在 {several} 个仓库中提交 1 个文件", + "{n} files committed in {several} repositories": "已在 {several} 个仓库中提交 {n} 个文件", + "1 file committed": "已提交 1 个文件", + "{n} files committed": "已提交 {n} 个文件", + "Rolled back 1 file": "已回滚 1 个文件", + "Rolled back {length} files": "已回滚 {length} 个文件", + "Add 1 file to Git": "将 1 个文件添加到 Git", + "Add {length} files to Git": "将 {length} 个文件添加到 Git", + "Stash changes": "储藏更改", + "Changes stashed": "已储藏更改", + "Unstashed {ref} on branch {branch}": "已在分支 {branch} 上取消储藏 {ref}", + "Unstashed {ref}": "已取消储藏 {ref}", + "Applied {ref}": "已应用 {ref}", + "Dropped {ref}": "已删除 {ref}", + "Remove 1 file from Git": "从 Git 中移除 1 个文件", + "Remove {n} files from Git": "从 Git 中移除 {n} 个文件", + "Accept yours for {path}": "对 {path} 接受你的版本", + "Accept theirs for {path}": "对 {path} 接受他们的版本", + "{path} resolved": "{path} 已解决", + "Revert selected changes": "还原所选更改", + "Create tag {value}": "创建标签 {value}", + "Created tag {value} on {id}": "已在 {id} 上创建标签 {value}", + "Created tag {tag}": "已创建标签 {tag}", + "Create patch": "创建补丁", + "Apply patch ({source})": "应用补丁({source})", + "Undo commit {id}": "撤销提交 {id}", + "Undid commit {id} “{summary}”; its changes are staged": "已撤销提交 {id}“{summary}”,其更改已暂存", + "Shelve changes “{trim}”": "搁置更改“{trim}”", + "Unshelve “{name}”": "取消搁置“{name}”", + "Delete shelved changes “{name}”": "删除搁置的更改“{name}”", + "Rename shelved changes": "重命名搁置的更改" } From 5e73b1634fbed8775f0f2b781f066e5154effbb8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:07:52 +0800 Subject: [PATCH 6/7] feat(agents): environment variables for built-in agents (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Tools › Agents › Built-in has an Environment variables field under the executable. They are saved in the agent's file like a command override, and the agent starts with them on top of the shell environment, replacing variables of the same name. Changing the command or the environment keeps the other; a file left with only the built-in's values is removed. Closes #1133 --- crates/ide-agent/src/lib.rs | 8 ++++ crates/ide-agent/src/model.rs | 3 ++ crates/ide-agent/src/provider.rs | 32 ++++++++++++- crates/ide-host/src/ops/agent.rs | 12 +++++ src-tauri/src/commands/agent.rs | 13 +++++ src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 7 +++ src/features/agents/BuiltinAgents.tsx | 47 +++++++++++++++++++ src/features/agents/agentRegistry.test.tsx | 24 ++++++++++ src/features/agents/composerCommands.test.tsx | 1 + src/features/agents/registry.ts | 4 ++ src/platform/locales/text/zh.json | 4 +- 12 files changed, 153 insertions(+), 3 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 66786c1d..a65400a6 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -345,6 +345,14 @@ impl AgentManager { provider::set_builtin_command(&dir, id, command.as_deref()) } + /// Sets the environment variables built-in `id` starts with; empty clears them. + pub fn set_builtin_env(&self, id: &str, env: std::collections::BTreeMap) -> IdeResult<()> { + let Some(dir) = self.shared.paths.custom_agents_dir.clone() else { + return Err(IdeError::unavailable("there is no custom agents directory")); + }; + provider::set_builtin_env(&dir, id, env) + } + /// Re-reads configured/installed agents, drops cached probes, and notifies subscribers. pub fn reload_providers(&self) { let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index a6a96e77..41cf2875 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -3,6 +3,7 @@ use ide_core::IpcPath; use serde::{Deserialize, Serialize}; use specta::Type; +use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -357,6 +358,8 @@ pub struct AgentProvider { pub command_read_only_reason: Option, /// Whether the command was found (on `PATH`). pub installed: bool, + /// Environment variables it starts with (from its agent file). + pub env: BTreeMap, /// Settings (models, modes…) the agent offered last time it ran; empty before its first session. pub config: Vec, /// Slash commands it offered last time. diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 0b39eade..410bb89e 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -124,6 +124,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { command_editable: reason.is_none(), command_read_only_reason: reason, installed: installed(&spec.command), + env: spec.env.iter().cloned().collect(), config: vec![], commands: vec![], source: spec.source.clone(), @@ -133,6 +134,16 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { /// Overrides built-in `id`'s command through its agent file, preserving other /// customizations. `None` restores the built-in command and removes a redundant file. pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> { + update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned()) +} + +/// The environment built-in `id` starts with, through its agent file (empty clears it). +pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap) -> IdeResult<()> { + update_builtin(custom_dir, id, |agent, _| agent.env = env) +} + +/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed. +fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> { let Some(builtin) = builtin_spec(id) else { return Err(IdeError::invalid(format!("{id} is not a built-in agent"))); }; @@ -152,7 +163,8 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<& if agent.registry.is_some() { return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry"))); } - if command.is_none() + change(&mut agent, &builtin); + if agent.command == builtin.command && agent.name.as_deref() == Some(builtin.name.as_str()) && agent.driver == builtin.driver && agent.args == builtin.args @@ -164,7 +176,6 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<& } return Ok(()); } - agent.command = command.unwrap_or(&builtin.command).to_owned(); agent.write(&file)?; Ok(()) } @@ -368,6 +379,23 @@ mod tests { assert!(installed("sh")); } + #[test] + fn set_builtin_env_writes_and_clears_the_environment() { + let dir = tempfile::tempdir().unwrap(); + let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]); + set_builtin_env(dir.path(), "claude", env.clone()).unwrap(); + let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap(); + assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]); + assert_eq!(describe(&claude).env, env); + // The command override and the environment are kept independently. + set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap(); + set_builtin_command(dir.path(), "claude", None).unwrap(); + assert!(dir.path().join("claude.toml").is_file()); + set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap(); + assert!(!dir.path().join("claude.toml").exists()); + set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err(); + } + #[test] fn set_builtin_command_overrides_and_clears_a_builtin() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 758cebb9..9662dc07 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -30,6 +30,17 @@ pub fn set_command(host: &Host, window: &str, id: String, command: Option, +) -> IdeResult<()> { + host.require_trust(window)?; + host.project(window)?.agents.set_builtin_env(&id, env) +} + /// Where ACP Registry agents are saved (`/agents`, like the user's own) and installed. pub(crate) fn registry_paths(host: &Host) -> RegistryPaths { RegistryPaths::new(&host.config_dir, &host.local_data_dir) @@ -195,6 +206,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) }); d.add("agent_providers", |c| async move { providers(&c.host, &c.window) }); d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) }); + d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) }); d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await }); d.add("agent_registry_install", |c| async move { registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index cf870663..f26c37bf 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -49,6 +49,19 @@ pub fn agent_set_command( agent::set_command(&state.host, window.label(), id, command) } +/// Environment variables built-in agent `id` starts with (saved in its agent file); +/// an empty map clears them. +#[tauri::command] +#[specta::specta] +pub fn agent_set_env( + window: Window, + state: State<'_, AppState>, + id: String, + env: std::collections::BTreeMap, +) -> IdeResult<()> { + agent::set_env(&state.host, window.label(), id, env) +} + /// Agents of the ACP Registry this machine can run, with what is installed; the list /// saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`. #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c3b6a0f0..af70e0da 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -98,6 +98,7 @@ pub fn builder() -> tauri_specta::Builder { agent::agent_subscribe, agent::agent_providers, agent::agent_set_command, + agent::agent_set_env, agent::agent_registry_list, agent::agent_registry_install, agent::agent_registry_remove, diff --git a/src/bindings.ts b/src/bindings.ts index 156b1269..3367ae16 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -91,6 +91,11 @@ export const commands = { * built-in's driver and arguments), or returns it to auto-detection with `None`. */ agentSetCommand: (id: string, command: string | null) => typedError(__TAURI_INVOKE("agent_set_command", { id, command })), + /** + * Environment variables built-in agent `id` starts with (saved in its agent file); + * an empty map clears them. + */ + agentSetEnv: (id: string, env: { [key in string]: string }) => typedError(__TAURI_INVOKE("agent_set_env", { id, env })), /** * Agents of the ACP Registry this machine can run, with what is installed; the list * saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`. @@ -1393,6 +1398,8 @@ export type AgentProvider = { commandReadOnlyReason: AgentCommandReadOnlyReason | null, /** Whether the command was found (on `PATH`). */ installed: boolean, + /** Environment variables it starts with (from its agent file). */ + env: { [key in string]: string }, /** Settings (models, modes…) the agent offered last time it ran; empty before its first session. */ config: AgentConfigOption[], /** Slash commands it offered last time. */ diff --git a/src/features/agents/BuiltinAgents.tsx b/src/features/agents/BuiltinAgents.tsx index 760a5eb2..13a4c879 100644 --- a/src/features/agents/BuiltinAgents.tsx +++ b/src/features/agents/BuiltinAgents.tsx @@ -93,6 +93,14 @@ function Details({ onChanged?.(); return command === agent.defaultProgram ? "" : (command ?? ""); }); + const envBase = formatEnv(agent.env); + const env = form.useExternal(`agents.${agent.id}.env`, envBase, async (draft) => { + const parsed = parseEnv(draft); + await backend.setEnv(agent.id, parsed); + await refresh(); + onChanged?.(); + return formatEnv(parsed); + }); const [error, setError] = useState(null); // An edit can have been staged by a previous page instance. Its Apply callback // refreshes that instance; the mounted one follows the newly applied value too. @@ -174,6 +182,26 @@ function Details({ )}

)} + {agent.commandReadOnlyReason !== "registry" && agent.commandReadOnlyReason !== "customAgent" && ( + <> + +
+ env.set(e.target.value)} + /> +
+

+ Passed to the agent process, replacing variables of the same name. Changes apply to new sessions. +

+ + )} {agent.loginHint && ( <>

@@ -193,6 +221,25 @@ function Details({ ); } +/** `NAME=value` pairs separated by `;` or new lines. */ +function parseEnv(text: string): Record { + const env: Record = {}; + for (const raw of text.split(/[;\n]/)) { + const pair = raw.trim(); + if (!pair) continue; + const eq = pair.indexOf("="); + const key = eq < 0 ? "" : pair.slice(0, eq).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(tx`Expected NAME=value: ${pair}`); + env[key] = pair.slice(eq + 1).trim(); + } + return env; +} + +const formatEnv = (env: Record) => + Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + /** Login hints are plain text with optional backtick-delimited command spans. */ function LoginHint({ text }: { text: string }) { const translate = useTx(); diff --git a/src/features/agents/agentRegistry.test.tsx b/src/features/agents/agentRegistry.test.tsx index 98624633..76aadae1 100644 --- a/src/features/agents/agentRegistry.test.tsx +++ b/src/features/agents/agentRegistry.test.tsx @@ -145,6 +145,7 @@ describe("agent picker", () => { commandEditable: true, commandReadOnlyReason: null, installed: true, + env: {}, config: [], commands: [], ...p, @@ -312,6 +313,11 @@ class FakeCommands implements CommandsBackend { saved: [string, string | null][] = []; providers = vi.fn(async () => this.list); chooseExecutable = vi.fn(async () => "/chosen/agent"); + savedEnv: [string, Record][] = []; + async setEnv(id: string, env: Record) { + this.savedEnv.push([id, env]); + this.list = this.list.map((p) => (p.id === id ? { ...p, env } : p)); + } async set(id: string, command: string | null) { this.saved.push([id, command]); // What the agent list shows after the override takes effect. @@ -341,6 +347,7 @@ const claudeProvider = (command: string): AgentProvider => ({ commandEditable: true, commandReadOnlyReason: null, installed: true, + env: {}, config: [], commands: [], source: { kind: "builtin" }, @@ -436,6 +443,23 @@ describe("Built-in executable overrides", () => { } else expect(screen.queryByRole("dialog")).toBeNull(); }); + it("writes the agent's environment variables on Apply and shows them again", async () => { + const commands = new FakeCommands(); + commands.list = [claudeProvider("claude")]; + await openCommands(commands); + const env = () => screen.getByRole("textbox", { name: "Environment variables" }); + fireEvent.change(env(), { target: { value: "CLAUDE_CODE_ENTRYPOINT=openidea; HTTPS_PROXY=http://proxy:8080" } }); + expect(commands.savedEnv).toEqual([]); + await act(async () => fireEvent.click(apply())); + expect(commands.savedEnv).toEqual([ + ["claude", { CLAUDE_CODE_ENTRYPOINT: "openidea", HTTPS_PROXY: "http://proxy:8080" }], + ]); + expect(env()).toHaveValue("CLAUDE_CODE_ENTRYPOINT=openidea; HTTPS_PROXY=http://proxy:8080"); + fireEvent.change(env(), { target: { value: "" } }); + await act(async () => fireEvent.click(apply())); + expect(commands.savedEnv.at(-1)).toEqual(["claude", {}]); + }); + it("Cancel discards command edits without writing", async () => { const commands = new FakeCommands(); commands.list = [ diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index d4d38312..a0675ba2 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -50,6 +50,7 @@ const claude: AgentProvider = { commandOverridden: false, commandEditable: true, commandReadOnlyReason: null, + env: {}, // Offered by the agent's last session in this project. commands: [command("compact", "Free up context")], }; diff --git a/src/features/agents/registry.ts b/src/features/agents/registry.ts index 60cb80b8..04780dd4 100644 --- a/src/features/agents/registry.ts +++ b/src/features/agents/registry.ts @@ -22,6 +22,7 @@ export interface RegistryBackend { export interface CommandsBackend { providers(): Promise; set(id: string, command: string | null): Promise; + setEnv(id: string, env: Record): Promise; chooseExecutable(title: string, current?: string): Promise; } @@ -47,6 +48,9 @@ export const tauriCommands: CommandsBackend = { set: async (id, command) => { await call(commands.agentSetCommand(id, command)); }, + setEnv: async (id, env) => { + await call(commands.agentSetEnv(id, env)); + }, }; export const DISTRIBUTION_LABEL: Record = { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 9bd26e1d..08db9818 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5910,5 +5910,7 @@ "Shelve changes “{trim}”": "搁置更改“{trim}”", "Unshelve “{name}”": "取消搁置“{name}”", "Delete shelved changes “{name}”": "删除搁置的更改“{name}”", - "Rename shelved changes": "重命名搁置的更改" + "Rename shelved changes": "重命名搁置的更改", + "Passed to the agent process, replacing variables of the same name. Changes apply to new sessions.": "传给智能体进程,覆盖同名变量。对新会话生效。", + "Expected NAME=value: {pair}": "应为 NAME=value:{pair}" } From fc6a27d4baaee50bf4e239d5fb07c0a58f73c108 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:40:02 +0800 Subject: [PATCH 7/7] fix(java): give jdtls memory back when idle and stop it showing as not responding (#1137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jdtls ran on the parallel collector, which never shrinks the heap while idle, so each server kept its import/build peak committed for as long as it lived. It now runs on G1 with a periodic collection every minute and a 10–30% free-heap band, so memory goes back to the OS once the work is done (a SkyWalking checkout: 1.9 GB → 1.25–1.3 GB idle). A collector set in the JVM options still replaces ours with its tuning; setting either heap-free ratio drops both of ours. The Equinox launcher gets -nosplash: without it its native library loads AppKit on macOS and every jdtls shows as Not Responding in Activity Monitor. Part of #1008. --- crates/ide-sdk/src/jdtls.rs | 66 ++++++++++++------- .../settings/languages/LanguagePages.tsx | 2 +- src/platform/locales/text/zh.json | 2 +- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/crates/ide-sdk/src/jdtls.rs b/crates/ide-sdk/src/jdtls.rs index 937aeff5..6bd8ae15 100644 --- a/crates/ide-sdk/src/jdtls.rs +++ b/crates/ide-sdk/src/jdtls.rs @@ -105,15 +105,17 @@ fn split_script_args(args: &[String]) -> ScriptArgs { out } -/// JVM options jdtls runs with unless the user's set the same thing: a parallel collector -/// tuned to give memory back to the OS (resident memory stays near what is live instead of -/// growing to the maximum heap), a 2 GB heap limit (the JVM's own is a quarter of physical +/// JVM options jdtls runs with unless the user's set the same thing: G1 with a periodic +/// collection while idle and a narrow free-heap band, so the heap shrinks and memory goes +/// back to the OS once an import or a build is over (a parallel collector keeps its peak +/// committed for good), a 2 GB heap limit (the JVM's own is a quarter of physical /// memory), no JVM logging on stdout (the LSP channel), and no scan for installed JDKs at /// start (they are passed as `java.configuration.runtimes`). pub const DEFAULT_VM_ARGS: &[&str] = &[ - "-XX:+UseParallelGC", - "-XX:GCTimeRatio=4", - "-XX:AdaptiveSizePolicyWeight=90", + "-XX:+UseG1GC", + "-XX:G1PeriodicGCInterval=60000", + "-XX:MinHeapFreeRatio=10", + "-XX:MaxHeapFreeRatio=30", "-Dsun.zip.disableMemoryMapping=true", "-Xmx2G", "-Xms100m", @@ -130,6 +132,8 @@ fn option_key(arg: &str) -> Option { let name = name.split_once('=').map_or(name, |(n, _)| n); match name { "MaxHeapSize" | "MaxRAM" | "MaxRAMPercentage" | "MaxRAMFraction" => "Xmx".to_owned(), + // The JVM rejects a minimum above the maximum, so they go together. + "MinHeapFreeRatio" | "MaxHeapFreeRatio" => "XX:HeapFreeRatio".to_owned(), "InitialHeapSize" | "InitialRAMPercentage" | "InitialRAMFraction" => "Xms".to_owned(), _ if is_collector(name) => "collector".to_owned(), _ => format!("XX:{name}"), @@ -153,8 +157,8 @@ fn is_collector(name: &str) -> bool { name.starts_with("Use") && name.ends_with("GC") && name.len() > "UseGC".len() } -/// Our tuning of the parallel collector, meaningless (or rejected) with another one. -const COLLECTOR_TUNING: &[&str] = &["GCTimeRatio", "AdaptiveSizePolicyWeight"]; +/// Our tuning of the default collector, meant for it alone. +const COLLECTOR_TUNING: &[&str] = &["G1PeriodicGCInterval", "HeapFreeRatio"]; /// [`DEFAULT_VM_ARGS`] less what `user` (the JVM options already on the command line) /// sets itself. Choosing a collector drops our collector and its tuning altogether. @@ -180,7 +184,10 @@ pub fn add_default_vm_args(args: &mut Vec) { } /// The JVM command line `bin/jdtls` builds: its system properties and module -/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`. +/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-nosplash` and +/// `-data`. Without `-nosplash` the launcher's native library loads AppKit on macOS, +/// which registers the server as an app that never answers events and shows as +/// "Not Responding" in Activity Monitor. pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec { let parsed = split_script_args(script_args); let mut args: Vec = Vec::new(); @@ -211,6 +218,9 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String] ); args.extend(parsed.jvm); args.extend(["-jar".to_owned(), install.launcher.to_string_lossy().into_owned()]); + if !parsed.rest.iter().any(|a| a == "-nosplash") { + args.push("-nosplash".to_owned()); + } if let Some(data) = parsed.data { args.extend(["-data".to_owned(), data]); } @@ -342,6 +352,7 @@ mod tests { &format!("-javaagent:{agent}"), "-jar", &format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"), + "-nosplash", "-data", data, ]) @@ -356,7 +367,9 @@ mod tests { DEFAULT_VM_ARGS.iter().filter(|a| !drop.contains(a)).map(|a| a.to_string()).collect() } - const PARALLEL: &[&str] = &["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-XX:AdaptiveSizePolicyWeight=90"]; + const G1: &[&str] = + &["-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=60000", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=30"]; + const HEAP_FREE: &[&str] = &["-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=30"]; #[test] fn user_options_override_the_defaults() { @@ -369,13 +382,15 @@ mod tests { (&["-XX:MaxRAMPercentage=40"], &["-Xmx2G"]), (&["-Xms1G"], &["-Xms100m"]), (&["-XX:InitialHeapSize=256m"], &["-Xms100m"]), - (&["-XX:+UseG1GC"], PARALLEL), - (&["-XX:+UseZGC", "-XX:+ZGenerational"], PARALLEL), - (&["-XX:+UseShenandoahGC"], PARALLEL), - (&["-XX:+UseSerialGC"], PARALLEL), - (&["-XX:-UseParallelGC"], PARALLEL), - (&["-XX:+UseParallelGC"], PARALLEL), - (&["-XX:GCTimeRatio=9"], &["-XX:GCTimeRatio=4"]), + (&["-XX:+UseParallelGC"], G1), + (&["-XX:+UseZGC", "-XX:+ZGenerational"], G1), + (&["-XX:+UseShenandoahGC"], G1), + (&["-XX:+UseSerialGC"], G1), + (&["-XX:-UseG1GC"], G1), + (&["-XX:+UseG1GC"], G1), + (&["-XX:G1PeriodicGCInterval=0"], &["-XX:G1PeriodicGCInterval=60000"]), + (&["-XX:MinHeapFreeRatio=40"], HEAP_FREE), + (&["-XX:MaxHeapFreeRatio=70"], HEAP_FREE), (&["-Xlog:gc*:file=gc.log"], &["-Xlog:disable"]), (&["-Dsun.zip.disableMemoryMapping=false"], &["-Dsun.zip.disableMemoryMapping=true"]), (&["-Daether.dependencyCollector.impl=df"], &["-Daether.dependencyCollector.impl=bf"]), @@ -389,10 +404,10 @@ mod tests { #[test] fn defaults_go_ahead_of_the_users_options_once() { - let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseG1GC"]); + let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseZGC"]); let mut args = user.clone(); add_default_vm_args(&mut args); - let dropped: Vec<&str> = PARALLEL.iter().copied().chain(["-Xmx2G"]).collect(); + let dropped: Vec<&str> = G1.iter().copied().chain(["-Xmx2G"]).collect(); let expected: Vec = without(&dropped).into_iter().map(|a| format!("--jvm-arg={a}")).chain(user).collect(); assert_eq!(args, expected); @@ -413,14 +428,14 @@ mod tests { let args = java_args(&install, 25, &script); let jar = args.iter().position(|a| a == "-jar").unwrap(); let jvm = &args[..jar]; - for flag in ["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-Xms100m", "-Xlog:disable", "-Xmx3G"] { + for flag in ["-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=60000", "-Xms100m", "-Xlog:disable", "-Xmx3G"] { assert!(jvm.contains(&flag.to_owned()), "{flag}: {jvm:?}"); } let heap: Vec<&String> = jvm.iter().filter(|a| option_key(a).as_deref() == Some("Xmx")).collect(); assert_eq!(heap, ["-Xmx3G"], "one heap limit, the user's"); assert!(!jvm.iter().any(|a| a == "-Xms1G"), "{jvm:?}"); assert_eq!(args[jar + 1], format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar")); - assert_eq!(args[jar + 2..], strings(&["-data", r"C:\Users\张 三\ws"])); + assert_eq!(args[jar + 2..], strings(&["-nosplash", "-data", r"C:\Users\张 三\ws"])); } #[test] @@ -439,18 +454,21 @@ mod tests { assert_eq!(args[0], "-Declipse.application=org.eclipse.jdt.ls.core.id1", "no JDK 24+ flags on 21"); assert!(args.contains(&"-Dosgi.sharedConfiguration.area=/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm".to_owned())); assert_eq!( - args[args.len() - 7..], + args[args.len() - 8..], strings(&[ "java.base/java.lang=ALL-UNNAMED", "-Dlog.level=ALL", "-jar", "/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar", + "-nosplash", "-data", "/d", "--extra", ]) ); assert!(!args.iter().any(|a| a.contains("validate-java-version"))); + let own = java_args(&install, 21, &strings(&["-data", "/d", "-nosplash"])); + assert_eq!(own.iter().filter(|a| *a == "-nosplash").count(), 1, "{own:?}"); } #[test] @@ -526,7 +544,7 @@ mod tests { assert!(direct.args.iter().any(|a| a == "-Xmx1G")); let jar = direct.args.iter().position(|a| a == "-jar").unwrap(); assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar")); - assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"])); + assert_eq!(direct.args[jar + 2..], strings(&["-nosplash", "-data", "/ws"])); let jre = dir.path().join("jre-25"); std::fs::create_dir_all(jre.join("bin")).unwrap(); @@ -640,7 +658,7 @@ mod tests { assert!(direct.args.iter().any(|a| a == "-Xmx1G")); let jar = direct.args.iter().position(|a| a == "-jar").unwrap(); assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar")); - assert_eq!(direct.args[jar + 2..], strings(&["-data", r"C:\ws"])); + assert_eq!(direct.args[jar + 2..], strings(&["-nosplash", "-data", r"C:\ws"])); // A shim whose descriptor is gone is someone's own wrapper: keep the command. std::fs::remove_file(shims.join("jdtls.shim")).unwrap(); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index 8357c230..aef869b3 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -297,7 +297,7 @@ export function JavaPage() {