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-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/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/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 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/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) => { 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 项目?",