mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
|
||||
/// Vue Language Server 3 in hybrid mode: its `tsserver/request`s go through the
|
||||
/// client to vtsls with `@vue/typescript-plugin` (issue #25). Set
|
||||
/// `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i @vue/language-server typescript@"<7" @vtsls/language-server`.
|
||||
/// `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i @vue/language-server typescript@6 @vtsls/language-server`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires @vue/language-server and @vtsls/language-server (OPENIDEA_TEST_NPM_TOOLS)"]
|
||||
async fn vue_hybrid_mode_end_to_end() {
|
||||
|
||||
@@ -38,7 +38,7 @@ pub(crate) fn tsserver_plugin(package: &Path) -> 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()
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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<Identity> {
|
||||
let metadata = path.metadata().ok()?;
|
||||
Some(Identity { len: metadata.len(), modified: metadata.modified().ok()? })
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct IndexWrites(HashMap<PathBuf, Identity>);
|
||||
|
||||
pub(crate) fn lock() -> MutexGuard<'static, IndexWrites> {
|
||||
static WRITES: OnceLock<Mutex<IndexWrites>> = 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -90,14 +90,16 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -305,11 +305,16 @@ pub struct FsBatch {
|
||||
pub created: Vec<PathBuf>,
|
||||
pub modified: Vec<PathBuf>,
|
||||
pub removed: Vec<PathBuf>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-10
@@ -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")]);
|
||||
}
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ fn watcher_reports_changes() {
|
||||
fs::remove_file(r.join("existing.txt")).unwrap();
|
||||
|
||||
let all = |b: &[FsBatch]| -> BTreeSet<PathBuf> {
|
||||
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| {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, string> {
|
||||
const titles = new Map<string, string>();
|
||||
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<string, string> = zh;
|
||||
expect(
|
||||
[...titles]
|
||||
.filter(([title]) => !PROPER_NAMES.has(title) && (!catalog[title] || catalog[title] === title))
|
||||
.map(([title, at]) => `${at} ${JSON.stringify(title)}`),
|
||||
).toEqual([]);
|
||||
});
|
||||
@@ -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 项目?",
|
||||
|
||||
Reference in New Issue
Block a user