From ab6fca01c4ee39007fa8bb2e4f6f033804ef4fcc Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Fri, 30 Oct 2026 13:34:18 +0800 Subject: [PATCH 1/3] fix(vcs): break the .git/index self-refresh loop that froze the UI (#339) With a project open, the app became slow and then unresponsive after 30-60 minutes. Git status writes refreshed stat data back to .git/index; the watcher reported that write as a file change and a VCS change; the UI scheduled another status ~250 ms later, which wrote the index again. With any stat-dirty file this never settled: a full status, IPC traffic and decoration rebuilds in an endless loop. Watcher batches now carry .git internals separately (FsBatch.vcs), so they no longer appear as worktree changes and no fsChanged is sent for .git-only batches. Status records the identity (size, mtime) of the index it writes, and the host skips VcsChanged only for that write; external index changes (git add / reset / stash from a terminal) and HEAD / refs / packed-refs changes still refresh as before. The UI's fsChanged handler also ignores .git-only batches. Fixes #311 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-host/src/project.rs | 53 +++++++++++++++++++- crates/ide-vcs/src/index_writes.rs | 77 ++++++++++++++++++++++++++++++ crates/ide-vcs/src/lib.rs | 2 + crates/ide-vcs/src/status.rs | 12 +++-- crates/ide-vfs/src/lib.rs | 7 ++- crates/ide-vfs/src/watch.rs | 23 +++++---- crates/ide-vfs/tests/vfs.rs | 2 +- src/features/vcs/store.ts | 7 ++- 8 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 crates/ide-vcs/src/index_writes.rs diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index a76a35ee..4e206979 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -234,7 +234,7 @@ impl Host { // A repository was cloned / initialised / removed below the project: its // `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went. let known = repos.read().roots(); - let git_dirs = changed.iter().chain(&batch.removed).any(|p| { + let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| { let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten() else { return false; @@ -244,7 +244,10 @@ impl Host { if git_dirs { scan_repositories(&root, &repos, &settings, &sink); } - sink.emit(IdeEvent::VcsChanged { root: root.clone().into() }); + let vcs_moved = batch.vcs.iter().any(|p| vcs_path_changed(p)); + if git_dirs || vcs_moved { + sink.emit(IdeEvent::VcsChanged { root: root.clone().into() }); + } }), )? }; @@ -1008,3 +1011,49 @@ mod maven_linkage_tests { assert!(model.maven.unwrap().projects.is_empty()); } } + +fn vcs_path_changed(path: &Path) -> bool { + !path.ends_with(".git/index") || !ide_vcs::is_own_index_write(path) +} + +#[cfg(test)] +mod index_event_tests { + use super::vcs_path_changed; + + #[test] + fn status_write_is_suppressed_but_external_index_and_refs_refresh() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let git = |args: &[&str]| { + let output = std::process::Command::new("git").current_dir(dir).args(args).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + }; + git(&["init", "-q"]); + let file = dir.join("file.txt"); + std::fs::write(&file, "original\n").unwrap(); + git(&["add", "file.txt"]); + // Same contents, different stat data: status must refresh the index. + std::fs::File::options() + .write(true) + .open(&file) + .unwrap() + .set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1)) + .unwrap(); + let index = dir.join(".git/index"); + let before = std::fs::read(&index).unwrap(); + let repo = ide_vcs::Repo::discover(dir).unwrap().unwrap(); + repo.status().unwrap(); + assert_ne!(before, std::fs::read(&index).unwrap(), "status must actually write the index"); + assert!(!vcs_path_changed(&index)); + assert!(!vcs_path_changed(&index), "duplicate notifications for the same identity"); + for path in [".git/HEAD", ".git/refs/heads/main", ".git/packed-refs"] { + assert!(vcs_path_changed(&dir.join(path))); + } + std::fs::write(&file, "external staged change\n").unwrap(); + git(&["add", "file.txt"]); + assert!(vcs_path_changed(&index)); + assert!(vcs_path_changed(&index)); + std::fs::remove_file(&index).unwrap(); + assert!(vcs_path_changed(&index)); + } +} diff --git a/crates/ide-vcs/src/index_writes.rs b/crates/ide-vcs/src/index_writes.rs new file mode 100644 index 00000000..236ba3bb --- /dev/null +++ b/crates/ide-vcs/src/index_writes.rs @@ -0,0 +1,77 @@ +//! Identities of index files last written by status, shared with filesystem watchers. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::SystemTime; + +#[derive(PartialEq, Eq)] +struct Identity { + len: u64, + modified: SystemTime, +} + +fn identity(path: &Path) -> Option { + let metadata = path.metadata().ok()?; + Some(Identity { len: metadata.len(), modified: metadata.modified().ok()? }) +} + +#[derive(Default)] +pub(crate) struct IndexWrites(HashMap); + +pub(crate) fn lock() -> MutexGuard<'static, IndexWrites> { + static WRITES: OnceLock> = OnceLock::new(); + WRITES.get_or_init(Mutex::default).lock().unwrap_or_else(|p| p.into_inner()) +} + +impl IndexWrites { + pub(crate) fn record(&mut self, path: &Path) { + let Ok(path) = path.canonicalize() else { return }; + // Bound memory even when many repositories are opened over the process lifetime. + if self.0.len() >= 256 && !self.0.contains_key(&path) { + self.0.clear(); + } + if let Some(identity) = identity(&path) { + self.0.insert(path, identity); + } + } +} + +/// Whether the current index still matches our last status write-back. Retain a +/// match for duplicate watcher notifications; invalidate it on an external write. +pub fn is_own_index_write(path: &Path) -> bool { + let mut writes = lock(); + let Ok(path) = path.canonicalize() else { return false }; + if let Some(current) = identity(&path) + && writes.0.get(&path) == Some(¤t) + { + return true; + } + writes.0.remove(&path); + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identities_are_scoped_to_paths_and_invalidated_by_metadata_changes() { + let tmp = tempfile::tempdir().unwrap(); + let first = tmp.path().join("index"); + let second = tmp.path().join("other-index"); + std::fs::write(&first, "first").unwrap(); + std::fs::write(&second, "first").unwrap(); + lock().record(&first); + assert!(is_own_index_write(&first)); + assert!(!is_own_index_write(&second)); + let file = std::fs::File::options().write(true).open(&first).unwrap(); + file.set_modified(SystemTime::UNIX_EPOCH).unwrap(); + assert!(!is_own_index_write(&first)); + lock().record(&first); + file.set_len(10).unwrap(); + assert!(!is_own_index_write(&first)); + std::fs::remove_file(&first).unwrap(); + assert!(!is_own_index_write(&first)); + } +} diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index bb25f4d5..d5a7ebc2 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -443,6 +443,7 @@ mod cli; mod diff; mod history; mod ignore; +mod index_writes; mod manage; mod ops; mod patch; @@ -452,6 +453,7 @@ mod repo; mod roots; pub mod shelf; mod status; +pub use index_writes::is_own_index_write; mod text_encoding; mod write; diff --git a/crates/ide-vcs/src/status.rs b/crates/ide-vcs/src/status.rs index 6fefb630..f87ccdf5 100644 --- a/crates/ide-vcs/src/status.rs +++ b/crates/ide-vcs/src/status.rs @@ -90,14 +90,16 @@ pub(crate) fn status(r: &Repo) -> IdeResult> { } } } - // Persist refreshed stat info so the next status is cheaper (like `git status` does). - // Only when something changed: `write_changes` writes even an empty change set, and - // the rewritten `.git/index` comes back as a VCS change that triggers the next status. + // Serialize write-back and identity capture with watcher queries. if let Some(mut outcome) = iter.into_outcome() && outcome.has_changes() - && let Some(Err(e)) = outcome.write_changes() { - tracing::debug!("could not write refreshed index: {e}"); + let mut writes = crate::index_writes::lock(); + match outcome.write_changes() { + Some(Ok(_)) => writes.record(&repo.index_path()), + Some(Err(e)) => tracing::debug!("could not write refreshed index: {e}"), + None => {} + } } Ok(map diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index fa56b02c..708bc954 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -305,11 +305,16 @@ pub struct FsBatch { pub created: Vec, pub modified: Vec, pub removed: Vec, + /// VCS metadata (`.git/HEAD`, `index`, `refs/**`): reported separately so + /// generic `fsChanged` consumers do not treat the backend's own index + /// refresh as a worktree edit and schedule another `vcs_status` (which + /// writes `.git/index` again — an idle refresh loop). + pub vcs: Vec, } impl FsBatch { pub fn is_empty(&self) -> bool { - self.created.is_empty() && self.modified.is_empty() && self.removed.is_empty() + self.created.is_empty() && self.modified.is_empty() && self.removed.is_empty() && self.vcs.is_empty() } } diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index 90970327..b2d3bc2a 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -77,11 +77,16 @@ impl Watcher { let shared = filter.excludes.clone(); let rules = filter.gitignore.clone(); let emit = move |batch: &FsBatch| { - sink.emit(IdeEvent::FsChanged { - created: batch.created.iter().map(Into::into).collect(), - modified: batch.modified.iter().map(Into::into).collect(), - removed: batch.removed.iter().map(Into::into).collect(), - }); + // VCS-metadata-only batches (`batch.vcs`) carry no worktree paths: + // consumers that refresh on any `fsChanged` would reschedule purely + // from the backend's own `.git/index` write. + if !(batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty()) { + sink.emit(IdeEvent::FsChanged { + created: batch.created.iter().map(Into::into).collect(), + modified: batch.modified.iter().map(Into::into).collect(), + removed: batch.removed.iter().map(Into::into).collect(), + }); + } on_change(batch); }; let inner = match mode { @@ -517,7 +522,7 @@ impl EventFilter { let mut batch = FsBatch::default(); for (path, change) in changes { if change == Change::Vcs { - batch.modified.push(path); + batch.vcs.push(path); continue; } let meta = path.symlink_metadata().ok(); @@ -594,10 +599,8 @@ mod tests { ]; let batch = filter.batch(&events); assert_eq!(batch.created, vec![root.join("a.txt")]); - assert_eq!( - batch.modified, - vec![root.join(".git/HEAD"), root.join(".git/refs/heads/feature"), root.join("kept.txt")] - ); + assert_eq!(batch.vcs, vec![root.join(".git/HEAD"), root.join(".git/refs/heads/feature")]); + assert_eq!(batch.modified, vec![root.join("kept.txt")]); assert_eq!(batch.removed, vec![root.join("from.txt"), root.join("gone.txt"), root.join("old.txt")]); } diff --git a/crates/ide-vfs/tests/vfs.rs b/crates/ide-vfs/tests/vfs.rs index efb4199a..029b8c13 100644 --- a/crates/ide-vfs/tests/vfs.rs +++ b/crates/ide-vfs/tests/vfs.rs @@ -297,7 +297,7 @@ fn watcher_reports_changes() { fs::remove_file(r.join("existing.txt")).unwrap(); let all = |b: &[FsBatch]| -> BTreeSet { - b.iter().flat_map(|b| b.created.iter().chain(&b.modified).chain(&b.removed).cloned()).collect() + b.iter().flat_map(|b| b.created.iter().chain(&b.modified).chain(&b.removed).chain(&b.vcs).cloned()).collect() }; assert!( wait_for(&batches, |b| { diff --git a/src/features/vcs/store.ts b/src/features/vcs/store.ts index 9983af82..b35476de 100644 --- a/src/features/vcs/store.ts +++ b/src/features/vcs/store.ts @@ -275,7 +275,12 @@ export function installVcsStore(): () => void { if (useVcs.getState().available) scheduleRefresh(200); else void discoverRoots(); }); - const offFs = onIdeEvent("fsChanged", () => scheduleRefresh(250)); + // `.git/**` internals show up through other watchers too; refreshing status + // on those alone rewrites `.git/index` and re-triggers this handler. + const offFs = onIdeEvent("fsChanged", (e) => { + const worktree = [...e.created, ...e.modified, ...e.removed].some((p) => !/(^|[/\\])\.git([/\\]|$)/.test(p)); + if (worktree) scheduleRefresh(250); + }); const offSettings = onWorkflowSettingsChange( (s) => s.vcs.showDirtyDirectories, (show) => { From 503bbb2f02311bfe61a88c2ad9c56d790aaa84e0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:37:58 +0800 Subject: [PATCH 2/3] fix(tools): install TypeScript 6 without a shell-unsafe version range (#359) Fixes #341 --- crates/ide-lsp/tests/real_servers.rs | 2 +- crates/ide-sdk/src/vue.rs | 2 +- crates/ide-tools/src/lib.rs | 2 +- crates/ide-tools/src/registry.rs | 2 +- crates/ide-tools/src/tests.rs | 2 +- languages/javascript.toml | 2 +- languages/javascriptreact.toml | 2 +- languages/typescript.toml | 2 +- languages/typescriptreact.toml | 2 +- languages/vue.toml | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index a76fca35..fb5b1330 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -297,7 +297,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option serde_json::Value { pub(crate) fn missing_typescript_message() -> String { "Vue Language Server needs TypeScript 5 or 6 (TypeScript 7 has no language service API). \ - Reinstall it in Settings › Languages & Frameworks › Language Servers, add typescript@\"<7\" \ + Reinstall it in Settings › Languages & Frameworks › Language Servers, add typescript@6 \ to the project, or pick a TypeScript version in Settings › Languages & Frameworks › TypeScript." .to_owned() } diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index e32d1002..1a87fea8 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -100,7 +100,7 @@ struct Manifest { const MANIFEST: &str = ".openidea-tool.json"; -/// `typescript@<7` → `typescript`, `@vue/language-server` unchanged. +/// `typescript@6` → `typescript`, `@vue/language-server` unchanged. fn npm_name(spec: &str) -> &str { match spec[1..].find('@') { Some(i) => &spec[..i + 1], diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index c3b2e9fd..b571b054 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -114,7 +114,7 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str] /// The TypeScript these npm servers load as a library (`tsserver.js`, `ts.server`): /// TypeScript 7 (the native port) ships neither, and `@latest` resolves to it. -pub const TYPESCRIPT: &str = "typescript@<7"; +pub const TYPESCRIPT: &str = "typescript@6"; pub static TOOLS: &[ToolSpec] = &[ ToolSpec { diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 02226cff..5711664b 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -335,7 +335,7 @@ done"#, } let installs = fs::read_to_string(&log).unwrap(); for line in installs.lines() { - assert!(line.contains("typescript@<7") && !line.contains("typescript@latest"), "{line}"); + assert!(line.contains("typescript@6") && !line.contains("typescript@latest"), "{line}"); } assert!(installs.contains("@vue/language-server@latest"), "{installs}"); diff --git a/languages/javascript.toml b/languages/javascript.toml index d16c1ad5..af10ad60 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -22,7 +22,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] -install-hint = "npm i -g typescript@\"<7\" typescript-language-server" +install-hint = "npm i -g typescript@6 typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 540722ee..7dd3ae69 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] -install-hint = "npm i -g typescript@\"<7\" typescript-language-server" +install-hint = "npm i -g typescript@6 typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/typescript.toml b/languages/typescript.toml index 536941b3..2de7b2cc 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] -install-hint = "npm i -g typescript@\"<7\" typescript-language-server" +install-hint = "npm i -g typescript@6 typescript-language-server" # Angular projects only: templates inline in components (`template: ...`), # and their usages and renames in templates. Last: TypeScript's own hover, diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index 8c5a439d..f1d8fc9c 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] -install-hint = "npm i -g typescript@\"<7\" typescript-language-server" +install-hint = "npm i -g typescript@6 typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/vue.toml b/languages/vue.toml index 1176b0ec..a7cdade0 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -10,7 +10,7 @@ name = "vue-language-server" command = "vue-language-server" args = ["--stdio"] root-markers = ["package.json", "vite.config.ts", "vite.config.js", "nuxt.config.ts", "tsconfig.json"] -install-hint = "npm i -g @vue/language-server typescript@\"<7\"" +install-hint = "npm i -g @vue/language-server typescript@6" # Hybrid mode: TypeScript in `.vue` files comes from tsserver with # `@vue/typescript-plugin` (configured by the settings hook); vue-language-server From 22039384910c815eb25f94278a6d25cb2662e337 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:46:15 +0800 Subject: [PATCH 3/3] fix(i18n): translate every action title (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Action titles passed to action factory helpers (editorAction and similar) were missed by the text-catalog check, so 63 of them, including Go to Declaration or Usages, showed in English and could not be found by Chinese text in Settings › Keymap. They now have Chinese entries, and a test checks that every literal action title, including factory arguments, has one (product names such as Vim stay as they are). Refs #353 --- .../settings/__tests__/keymap.test.ts | 20 ++++- src/platform/locales/actionTitles.test.ts | 89 +++++++++++++++++++ src/platform/locales/text/zh.json | 63 +++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src/platform/locales/actionTitles.test.ts diff --git a/src/features/settings/__tests__/keymap.test.ts b/src/features/settings/__tests__/keymap.test.ts index 4e8ac876..b9c5b4c7 100644 --- a/src/features/settings/__tests__/keymap.test.ts +++ b/src/features/settings/__tests__/keymap.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { registerAction } from "@/platform/actions"; +import { allActions, registerAction } from "@/platform/actions"; import { applyKeymapSettings, bindingsFor, @@ -37,6 +37,24 @@ import { } from "../keymap/model"; import { buildKeymapTree } from "../keymap/KeymapPage"; import { groupPath } from "../keymap/groups"; +import { registerLspActions } from "@/editor/lsp/actions"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; + +it("finds the translated declaration action by 声明 and its English title", async () => { + const dispose = registerLspActions(); + try { + await setLocale("zh"); + for (const query of ["声明", "Go to Declaration or Usages"]) { + const tree = buildKeymapTree(allActions(), { preset: "intellij-macos", overrides: {} }, query, ""); + const titles = (nodes: typeof tree): string[] => + nodes.flatMap((node) => (node.kind === "group" ? titles(node.children) : [node.action.title])); + expect(titles(tree)).toContain("转到声明或用法"); + } + } finally { + dispose(); + _resetI18nForTests(); + } +}); const key = ( code: string, diff --git a/src/platform/locales/actionTitles.test.ts b/src/platform/locales/actionTitles.test.ts new file mode 100644 index 00000000..18274c97 --- /dev/null +++ b/src/platform/locales/actionTitles.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment node +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { expect, it } from "vitest"; +import zh from "./text/zh.json"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const config = ts.readConfigFile(`${root}tsconfig.json`, ts.sys.readFile); +const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root); +const program = ts.createProgram(parsed.fileNames, parsed.options); +const checker = program.getTypeChecker(); + +/** Literal branches also cover conditional titles in generated action arrays. */ +function literals(node: ts.Expression): string[] { + if (ts.isStringLiteralLike(node)) return [node.text]; + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) + return literals(node.expression); + if (ts.isConditionalExpression(node)) return [...literals(node.whenTrue), ...literals(node.whenFalse)]; + if ( + ts.isBinaryExpression(node) && + [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind) + ) + return [...literals(node.left), ...literals(node.right)]; + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "tx") + return node.arguments[0] ? literals(node.arguments[0]) : []; + const type = checker.getTypeAtLocation(node); + return (type.isUnion() ? type.types : [type]).flatMap((part) => (part.isStringLiteral() ? [part.value] : [])); +} + +function isAction(type: ts.Type): boolean { + return ["id", "title", "run"].every((name) => checker.getPropertyOfType(type, name)); +} + +// Runtime user/plugin names have no source literal; tx templates are covered by textCatalog.test.ts. +function actionTitles(): Map { + const titles = new Map(); + const add = (node: ts.Expression) => { + const source = node.getSourceFile(); + const line = source.getLineAndCharacterOfPosition(node.getStart()).line + 1; + for (const title of literals(node)) titles.set(title, `${source.fileName.slice(root.length)}:${line}`); + }; + for (const source of program.getSourceFiles()) { + if (!source.fileName.startsWith(`${root}src/`) || /\.test\.tsx?$|\/__tests__\/|\/test\//.test(source.fileName)) + continue; + const visit = (node: ts.Node) => { + if ( + ts.isObjectLiteralExpression(node) && + isAction(checker.getContextualType(node) ?? checker.getTypeAtLocation(node)) + ) { + for (const property of node.properties) { + if (ts.isPropertyAssignment(property) && property.name.getText(source).replace(/["']/g, "") === "title") + add(property.initializer); + if (ts.isShorthandPropertyAssignment(property) && property.name.text === "title") add(property.name); + } + } + if (ts.isCallExpression(node)) { + const signature = checker.getResolvedSignature(node); + if ( + signature?.parameters.some((parameter) => parameter.name === "title") && + isAction(checker.getReturnTypeOfSignature(signature)) + ) { + signature.parameters.forEach((parameter, index) => { + const argument = node.arguments[index]; + if (parameter.name === "title" && argument) add(argument); + }); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + } + return titles; +} + +const titles = actionTitles(); +/** Product names shown as they are in every language. */ +const PROPER_NAMES = new Set(["Vim"]); + +it("has a Chinese entry for every literal action title, including action factory arguments", () => { + // Ensure the helper-argument regression cannot disappear through a scanner change. + expect(titles.has("Go to Declaration or Usages")).toBe(true); + expect(titles.has("Next Result")).toBe(true); + const catalog: Record = zh; + expect( + [...titles] + .filter(([title]) => !PROPER_NAMES.has(title) && (!catalog[title] || catalog[title] === title)) + .map(([title, at]) => `${at} ${JSON.stringify(title)}`), + ).toEqual([]); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 38824211..df54ae26 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,67 @@ { + "Semantic Highlighting": "语义高亮", + "Rainbow Brackets": "彩虹括号", + "Comment with Line Comment": "使用行注释", + "Comment with Block Comment": "使用块注释", + "Duplicate Line or Selection": "复制行或选区", + "Delete Line": "删除行", + "Move Line Up": "上移行", + "Move Line Down": "下移行", + "Move Statement Up": "上移语句", + "Move Statement Down": "下移语句", + "Extend Selection": "扩展选区", + "Shrink Selection": "缩小选区", + "Toggle Fold": "切换折叠", + "Cyclic Expand Word": "循环补全单词", + "Cyclic Expand Word (Backward)": "循环补全单词(反向)", + "Paste as Plain Text": "粘贴为纯文本", + "Add Selection for Next Occurrence": "添加下一个匹配项到选区", + "Select All Occurrences": "选择所有匹配项", + "Complete Current Statement": "补全当前语句", + "Start New Line": "开始新行", + "Start New Line Before Current": "在当前行前开始新行", + "Split Line": "拆分行", + "Scroll to Center": "滚动到中央", + "Move Caret to Code Block Start": "将光标移至代码块开头", + "Move Caret to Code Block End": "将光标移至代码块末尾", + "Clone Caret Above": "在上方添加光标", + "Clone Caret Below": "在下方添加光标", + "Join Lines": "合并行", + "Toggle Case": "切换大小写", + "Add Carets to Ends of Selected Lines": "在所选行末尾添加光标", + "Unselect Occurrence": "取消选择匹配项", + "Paste from History…": "从历史记录粘贴…", + "Sort Lines": "排序行", + "Reverse Lines": "反转行序", + "Unwrap/Remove…": "解包/移除…", + "Basic": "基本补全", + "Quick Definition": "快速定义", + "Reformat File…": "重新格式化文件…", + "Go to Declaration or Usages": "转到声明或用法", + "Go to Implementation(s)": "转到实现", + "Go to Type Declaration": "转到类型声明", + "Next Highlighted Error": "下一个高亮错误", + "Previous Highlighted Error": "上一个高亮错误", + "Highlight Usages in File": "高亮文件中的用法", + "Implement Methods…": "实现方法…", + "Override Methods…": "重写方法…", + "Expression Type Info": "表达式类型信息", + "Next Method": "下一个方法", + "Previous Method": "上一个方法", + "Copy Absolute Path": "复制绝对路径", + "Copy Path From Content Root": "复制相对于内容根目录的路径", + "Copy File Name": "复制文件名", + "Fixup…": "修补提交…", + "Step Over": "单步跳过", + "Step Out": "单步跳出", + "Zoom In": "放大", + "Zoom Out": "缩小", + "Actual Size": "实际大小", + "Fit Zoom to Window": "缩放以适应窗口", + "Show Chessboard": "显示棋盘格", + "Editor Only": "仅编辑器", + "Editor and Preview": "编辑器和预览", + "Preview Only": "仅预览", "Add Maven Projects": "添加 Maven 项目", "Unlink Maven Projects": "取消链接 Maven 项目", "Unlink Maven projects?": "取消链接 Maven 项目?",