Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
l0ng-ai
2026-10-30 13:54:19 +08:00
21 changed files with 344 additions and 31 deletions
+51 -2
View File
@@ -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));
}
}
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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()
}
+1 -1
View File
@@ -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],
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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}");
+77
View File
@@ -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(&current)
{
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));
}
}
+2
View File
@@ -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;
+7 -5
View File
@@ -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
+6 -1
View File
@@ -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
View File
@@ -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")]);
}
+1 -1
View File
@@ -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| {
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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
+19 -1
View File
@@ -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,
+6 -1
View File
@@ -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) => {
+89
View File
@@ -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([]);
});
+63
View File
@@ -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 项目?",