diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md
index f918195..31de2b1 100644
--- a/docs/.agents/issue.md
+++ b/docs/.agents/issue.md
@@ -123,3 +123,12 @@
- **同类问题影响**:所有用 `git log --topo-order` 取数、自计算或直接渲染提交图、且 UI 暴露「按时间浏览」预期的 Git GUI;凡把「lane 算法要求」误读为「必须 `--topo-order`」(实为「子在父之上」即可)的实现均会复现日期回跳;以及排序键与显示日期列不一致(author date vs committer date)导致的「列内看似乱序」类二次 bug。
+## #15 新增/删除/重命名文件在 Commit 视图与 Graph 视图无法打开差异(git 空树 ref 兜底缺失端)
+
+- **表因**:用户反馈在 **Commit 视图**与 **Graph 视图**点击一个**新增文件**时差异视图直接打不开(预期应展示「全绿新增」对比);删除、重命名文件同样失败;Graph 视图重命名文件点击更是彻底无效。
+- **根因**:两处「打开差异」都为差异的「缺失端」构造了指向**不存在对象**的 git URI:Commit 视图 `commands.ts` 的 `openDiff` 取 `toGitUri(change.uri, 'HEAD')`(新增文件在 HEAD 不存在);Graph 视图 `history-commands.ts` 的 `openCommitFileDiff` 取 `toGitUri(uri, `${hash}^`)`(新增文件在父提交不存在)。VS Code 的 git `GitFileSystemProvider.readFile` 行为随版本演进——**1.85**(本扩展 `engines.vscode` 下限)`catch` 吞掉一切取对象错误返空(容错),**当前主线**改为对不存在对象**抛 `FileNotFound`**、仅当 `ref === repository.getEmptyTree()`(空树)时才回空。故 `'HEAD'` / `${hash}^` 这类具名 ref 在新版会抛错致差异打不开(旧版恰好容错掩盖了缺陷)。附带:Graph 视图 `detailLeafHtml` 的 `data-path` 取的是展示串 `"old → new"`(`sendCommitFiles` 把 rename 拼进 `path`),`joinPath` 得伪路径致重命名彻底崩溃;点击仅回传提交级 `hasParent` 而无逐文件 `status`,宿主无法区分 A/D/M/R。
+- **处理方式**:缺失端统一改用 **git 空树 ref**(`4b825dc642cb6eb9a060e54bf8d69288fbee4904`)构造 URI——旧版容错、新版空树逃逸,两版皆稳定解析为空内容(这正是 VS Code 官方 git 扩展现今为「新增文件」左端的做法,复用非自造)。正交分解:① 纯状态分类器 `engine/diff/change-side.ts`(`diffShapeFromStatus` / `diffShapeFromCode` → `added`/`deleted`/`renamed`/`modified`);② adapter 层 `diff-sides.ts`(`GIT_EMPTY_TREE` + `resolveDiffSides` 按形态把缺失端置空树);③ `openDiff` / `openCommitFileDiff` 改为按 `status` 选端(Commit 视图签名不变;Graph 视图签名 `(hash, filePath, status?, oldPath?)`,移除 `hasParent`,由 `status` 取代);④ 协议 `log/openFile` payload 增 `status`/`oldPath`、去 `hasParent`;⑤ `sendCommitFiles` 的 `path` 改回干净新路径,展示串由 webview 端用 `oldPath` 拼出(数据与展示分离)。新增/根提交均不再依赖 `^`。同步补 `tests/unit/diff-change-side.test.ts`(分类器)与 `tests/suite/diff-open.test.js`(空树 URI 解析为空 + A/D/R/工作区新增打开差异)。
+- **后续防范**:① 为差异的「缺失端」构造 URI 时,**一律用 git 空树 ref**,不要对不存在对象取 `'HEAD'` / `${hash}^` 这类具名 ref——它们只在旧版 VS Code(容错 readFile)上侥幸可用,新版必抛 FileNotFound。② VS Code git 扩展内部行为(如 readFile 容错性)随版本变化,复用其 `toGitUri` 时须确认跨 `engines.vscode` 下限到当前主线的兼容矩阵;空树 ref 是少数有契约保障的「稳定回空」途径。③ webview 的 `data-*` 属性应承载**数据**(机器可用的稳定 key/路径),展示串(含 `"old → new"` 这类人为拼接)只放在可见标签文本里——二者混用会导致 `joinPath` 之类以数据为输入的下游崩溃。④ 逐文件级语义(status)须端到端透传到决策点(host 命令),勿用提交级布尔(`hasParent`)模糊替代——后者无法区分单文件是 A/D/M/R。⑤ 测试断言「差异已打开」时勿按标签计数(VS Code `{preview:true}` 会复用预览槽替换而非新增),应先 `closeAllEditors` 再按差异标题(含文件名)匹配标签。
+- **同类问题影响**:所有消费 vscode.git `toGitUri` 自建差异打开逻辑的扩展,凡为缺失端取具名 ref 的均在新版 VS Code 复现;凡 webview `data-path` 复用展示串(含分隔符)的实现均会在路径拼接处崩溃;以及任何「逐文件操作」误用「提交级 / 全局级」标志判定单文件形态的设计。
+
+
diff --git a/src/adapter/commands.ts b/src/adapter/commands.ts
index 4131ef3..4a22f2d 100644
--- a/src/adapter/commands.ts
+++ b/src/adapter/commands.ts
@@ -1,6 +1,8 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { FileStatus } from '../engine/model';
+import { diffShapeFromStatus } from '../engine/diff/change-side';
+import { resolveDiffSides } from './diff-sides';
import type { ChangelistRegistry } from './changelist-registry';
import type { ChangeItem, GitRepositoryService } from './git-repository-service';
@@ -121,11 +123,24 @@ export function registerChangesCommands(
if (!repo || !change) {
return;
}
- // 空仓库(无 HEAD)时用 originalUri 兜底,避免 git scheme 解析失败
- const left = repo.state.HEAD ? service.toGitUri(change.uri, 'HEAD') : change.originalUri;
- const right = change.uri;
+ // 按变更形态选择差异端点:新增置空旧端、删除置空新端、重命名旧端取原路径;缺失端统一走 git 空树 ref
+ // (跨 VS Code 版本稳定回空),避免对不存在对象取 'HEAD' 致新版差异打不开。空树在无 HEAD 的空仓库下亦成立。
+ const shape = diffShapeFromStatus(change.status);
+ const oldUri = shape === 'renamed' ? change.originalUri : change.uri;
+ const { left, right } = resolveDiffSides(
+ service,
+ shape,
+ oldUri,
+ service.toGitUri(oldUri, 'HEAD'),
+ change.uri,
+ change.uri,
+ );
const title = `${path.basename(change.relativePath)} (HEAD ↔ Working)`;
- await vscode.commands.executeCommand('vscode.diff', left, right, title);
+ try {
+ await vscode.commands.executeCommand('vscode.diff', left, right, title);
+ } catch (e) {
+ void vscode.window.showErrorMessage(`Failed to open diff: ${e instanceof Error ? e.message : String(e)}`);
+ }
}),
);
diff --git a/src/adapter/diff-sides.ts b/src/adapter/diff-sides.ts
new file mode 100644
index 0000000..24d21a5
--- /dev/null
+++ b/src/adapter/diff-sides.ts
@@ -0,0 +1,52 @@
+/**
+ * 差异左右端点解析(adapter 层,复用 vscode.git 的 `toGitUri` 生成 git scheme 资源)。
+ *
+ * 缺失端(新增文件的旧端 / 删除文件的新端)统一指向 git 空树 ref,以跨 VS Code 版本稳定地解析为空内容:
+ * - 旧版(≤1.85 等)git FileSystemProvider.readFile 对任意取不到的对象容错回空;
+ * - 新版对不存在对象抛 FileNotFound,仅当 ref 等于仓库空树时才回空(空树逃逸)。
+ * 二者叠加使「空树 ref」成为两版皆稳的空内容来源;而具名 ref('HEAD' / `${hash}^`)对不存在对象在新版会抛错,
+ * 正是「新增/删除文件差异打不开」的根因(详见 docs/.agents/issue.md)。
+ */
+
+import type * as vscode from 'vscode';
+import type { GitRepositoryService } from './git-repository-service';
+import type { DiffShape } from '../engine/diff/change-side';
+
+/**
+ * git 空树对象哈希(SHA-1:`git hash-object -t tree /dev/null` 的恒定值)。
+ * 缺失端指向它 → 旧版容错回空、新版空树逃逸回空,均稳定得到空文档。
+ * 注:SHA-256 仓库空树哈希不同,新版空树逃逸不匹配(极少见,暂不覆盖)。
+ */
+export const GIT_EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
+
+/** 缺失端资源 Uri:对目标路径取 git 空树 ref(稳定解析为空内容)。 */
+export function emptyTreeUri(service: GitRepositoryService, uri: vscode.Uri): vscode.Uri {
+ return service.toGitUri(uri, GIT_EMPTY_TREE);
+}
+
+/**
+ * 依差异形态选择 [left, right]。缺失端置空树,其余端用调用方已算好的「真实内容端」。
+ *
+ * @param oldUri 旧端路径(重命名时为源路径),置空树时以其定位
+ * @param oldSide 旧端真实内容(如 `toGitUri(oldUri, 'HEAD' | `${hash}^`)`)
+ * @param newUri 新端路径,置空树时以其定位
+ * @param newSide 新端真实内容(工作区 `change.uri` 或 `toGitUri(newUri, hash)`)
+ */
+export function resolveDiffSides(
+ service: GitRepositoryService,
+ shape: DiffShape,
+ oldUri: vscode.Uri,
+ oldSide: vscode.Uri,
+ newUri: vscode.Uri,
+ newSide: vscode.Uri,
+): { left: vscode.Uri; right: vscode.Uri } {
+ switch (shape) {
+ case 'added':
+ return { left: emptyTreeUri(service, newUri), right: newSide };
+ case 'deleted':
+ return { left: oldSide, right: emptyTreeUri(service, oldUri) };
+ default:
+ // renamed / modified:两端均取真实内容(重命名为异路径,修改为同路径)。
+ return { left: oldSide, right: newSide };
+ }
+}
diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts
index 902bdea..ac392dc 100644
--- a/src/adapter/history-commands.ts
+++ b/src/adapter/history-commands.ts
@@ -9,6 +9,8 @@ import { handleGitConflict } from './conflict-ui';
import type { MergeMode } from '../engine/log/log-filter';
import { selectedBranchRefs } from './branch-selection';
import { diffPrunedRefs, formatBranchDeleteConfirm, partitionByMerged, truncateNames } from '../engine/ref/cleanup';
+import { diffShapeFromCode } from '../engine/diff/change-side';
+import { resolveDiffSides } from './diff-sides';
/** 注册 Log/Branches/Blame/History/Tags 相关命令。 */
export function registerHistoryCommands(
@@ -504,20 +506,26 @@ export function registerHistoryCommands(
// —— Log 增强(Phase 2):高级过滤 + 提交详情 diff + per-commit 操作 ——
subs.push(
- vscode.commands.registerCommand('hyperGit.openCommitFileDiff', async (hash: string, filePath: string, hasParent: boolean) => {
+ vscode.commands.registerCommand('hyperGit.openCommitFileDiff', async (hash: string, filePath: string, status?: string, oldPath?: string) => {
const repo = service.repo;
if (!repo) {
return;
}
- const uri = vscode.Uri.joinPath(repo.rootUri, filePath);
- const right = service.toGitUri(uri, hash);
+ // 按变更形态选择差异端点:新增置空旧端、删除置空新端、重命名/复制两端取异路径。缺失端统一走 git 空树 ref
+ // (跨 VS Code 版本稳定回空),避免对父提交不存在的对象取 `${hash}^` 致新版差异打不开。新增/根提交均不依赖 `^`。
+ const shape = diffShapeFromCode(status ?? 'M');
+ const newUri = vscode.Uri.joinPath(repo.rootUri, filePath);
+ const oldUri = oldPath ? vscode.Uri.joinPath(repo.rootUri, oldPath) : newUri;
+ const { left, right } = resolveDiffSides(
+ service,
+ shape,
+ oldUri,
+ service.toGitUri(oldUri, `${hash}^`),
+ newUri,
+ service.toGitUri(newUri, hash),
+ );
try {
- if (hasParent) {
- const left = service.toGitUri(uri, `${hash}^`);
- await vscode.commands.executeCommand('vscode.diff', left, right, `${filePath} · ${hash.slice(0, 7)} (commit diff)`, { preview: true });
- } else {
- await vscode.commands.executeCommand('vscode.open', right);
- }
+ await vscode.commands.executeCommand('vscode.diff', left, right, `${filePath} · ${hash.slice(0, 7)} (commit diff)`, { preview: true });
} catch (e) {
void vscode.window.showErrorMessage(`Failed to open diff: ${errMsg(e)}`);
}
diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts
index 9b702bf..9b671e9 100644
--- a/src/adapter/webview/log-webview.ts
+++ b/src/adapter/webview/log-webview.ts
@@ -168,7 +168,8 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter
'hyperGit.openCommitFileDiff',
msg.payload.hash,
msg.payload.path,
- msg.payload.hasParent,
+ msg.payload.status,
+ msg.payload.oldPath,
);
break;
case 'log/setScope':
@@ -421,10 +422,11 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter
// 复用 Log 既有逻辑:diff-tree 取变更文件。
const out = await this.service.execGit(['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]);
const changes = parseNameStatus(out);
+ // path 取干净新路径(供 data-path/建树/端点定位);rename/copy 的 "old → new" 展示由 webview 端用 oldPath 拼出。
const files: LogCommitFileItem[] = changes.map((c) => ({
status: c.status,
statusLabel: statusLabel(c.status),
- path: c.oldPath ? `${c.oldPath} → ${c.path}` : c.path,
+ path: c.path,
oldPath: c.oldPath,
themeColor: fileIconColor(c.status),
}));
@@ -1082,20 +1084,20 @@ detailsList.addEventListener('click', function (e) {
const d = e.target.closest('.tree-dir');
if (d) { toggleDetailCollapse(d.getAttribute('data-dir')); return; }
const f = e.target.closest('.file'); if (!f) return;
- vscode.postMessage({ type: 'log/openFile', payload: { hash: f.getAttribute('data-hash'), path: f.getAttribute('data-path'), hasParent: f.getAttribute('data-hasparent') === '1' } });
+ vscode.postMessage({ type: 'log/openFile', payload: { hash: f.getAttribute('data-hash'), path: f.getAttribute('data-path'), status: f.getAttribute('data-status'), oldPath: f.getAttribute('data-oldpath') || undefined } });
});
const DINDENT = 14;
-function detailLeafHtml(hash, f, hasParent, depth, label) {
- return '
' + esc(f.statusLabel) + '' + esc(label) + '
';
+function detailLeafHtml(hash, f, depth, label) {
+ return '' + esc(f.statusLabel) + '' + esc(label) + '
';
}
-function renderDetailNode(node, depth, hash, hasParent, files, out) {
+function renderDetailNode(node, depth, hash, files, out) {
if (node.dir) {
const isCol = dcollapsed.has(node.path);
out.push('' + (isCol ? '\\u25B8' : '\\u25BE') + '' + esc(node.name) + '
');
- if (!isCol) { for (const c of node.children) renderDetailNode(c, depth + 1, hash, hasParent, files, out); }
+ if (!isCol) { for (const c of node.children) renderDetailNode(c, depth + 1, hash, files, out); }
} else {
- out.push(detailLeafHtml(hash, files[node.fileIndex], hasParent, depth, node.name));
+ out.push(detailLeafHtml(hash, files[node.fileIndex], depth, node.name));
}
}
function pruneDetailCollapsed(tree) {
@@ -1122,14 +1124,12 @@ function renderDetails(hash, files, tree) {
if (!hash) { detailsEl.classList.remove('show'); return; }
curDetailHash = hash; curDetailFiles = files || []; curDetailTree = tree || [];
updateDetailModeButtons();
- const row = model.rows.find(function (r) { return r.hash === hash; });
- const hasParent = row && row.parents && row.parents.length > 0 ? '1' : '0';
detailsTitleEl.textContent = 'Changed Files (' + curDetailFiles.length + ') · ' + hash.slice(0, 7);
if (curDetailFiles.length === 0) { detailsList.innerHTML = 'No changed files (may be a root or merge commit)
'; detailsEl.classList.add('show'); return; }
pruneDetailCollapsed(curDetailTree);
const out = [];
- if (detailsMode === 'tree') { for (const n of curDetailTree) renderDetailNode(n, 0, hash, hasParent, curDetailFiles, out); }
- else { for (const f of curDetailFiles) out.push(detailLeafHtml(hash, f, hasParent, 0, f.path)); }
+ if (detailsMode === 'tree') { for (const n of curDetailTree) renderDetailNode(n, 0, hash, curDetailFiles, out); }
+ else { for (const f of curDetailFiles) out.push(detailLeafHtml(hash, f, 0, (f.oldPath ? f.oldPath + ' → ' + f.path : f.path))); }
detailsList.innerHTML = out.join('');
detailsEl.classList.add('show');
}
diff --git a/src/engine/diff/change-side.ts b/src/engine/diff/change-side.ts
new file mode 100644
index 0000000..3f1d9c7
--- /dev/null
+++ b/src/engine/diff/change-side.ts
@@ -0,0 +1,53 @@
+/**
+ * 变更文件 → 差异端点形态(纯逻辑,零 vscode 依赖,可被 Vitest 与未来 CLI 双复用)。
+ *
+ * 「打开差异」需为左(旧)/右(新)两端各取一个内容源。当一端在对应 ref 下不存在(新增文件无旧端、
+ * 删除文件无新端)时,若仍对不存在对象取具名 ref('HEAD' / `${hash}^`),较新 VS Code 的 git
+ * FileSystemProvider.readFile 会抛 FileNotFound 致差异打不开(见 issue.md)。故先把「变更状态」正交地
+ * 归约为 DiffShape,再由 adapter 层据此决定哪一端置空(git 空树 ref)。
+ */
+
+import { FileStatus } from '../model';
+
+/** 差异端点形态:决定左右两端如何取(新增置空旧端 / 删除置空新端 / 重命名两端异路径 / 修改两端同路径)。 */
+export type DiffShape = 'added' | 'deleted' | 'renamed' | 'modified';
+
+/**
+ * 领域模型状态(Commit 视图,来自 vscode.git 映射)→ 差异形态。
+ * Copied 归为 added:工作区 copy 的源路径不可靠,置空旧端最稳妥(回落为「全新增」对比)。
+ */
+export function diffShapeFromStatus(status: FileStatus): DiffShape {
+ switch (status) {
+ case FileStatus.Added:
+ case FileStatus.Untracked:
+ case FileStatus.Copied:
+ return 'added';
+ case FileStatus.Deleted:
+ return 'deleted';
+ case FileStatus.Renamed:
+ return 'renamed';
+ default:
+ // Modified / Conflict / Ignored 等:两端同路径取真实内容对比。
+ return 'modified';
+ }
+}
+
+/**
+ * git diff-tree 字母状态码(Graph 视图,形如 A / M / D / R100 / C90 / T / U)→ 差异形态。
+ * 取首字母判定;C(copy)在 diff-tree 输出里携带可靠 oldPath,故按 renamed 处理(源↔目标内容对比)。
+ */
+export function diffShapeFromCode(code: string): DiffShape {
+ switch (code[0]) {
+ case 'A':
+ case 'U':
+ return 'added';
+ case 'D':
+ return 'deleted';
+ case 'R':
+ case 'C':
+ return 'renamed';
+ default:
+ // M(modified)/ T(type-changed)/ 其它:两端同路径取真实内容对比。
+ return 'modified';
+ }
+}
diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts
index c6b2f2d..6f35e84 100644
--- a/src/shared/protocol.ts
+++ b/src/shared/protocol.ts
@@ -231,7 +231,7 @@ export type LogWebviewToHostMessage =
| { readonly type: 'log/selectCommit'; readonly payload: { readonly hash: string } }
| { readonly type: 'log/commitAction'; readonly payload: { readonly op: LogCommitOp; readonly hash: string } }
| { readonly type: 'log/setScope'; readonly payload: { readonly scope: LogScope } }
- | { readonly type: 'log/openFile'; readonly payload: { readonly hash: string; readonly path: string; readonly hasParent: boolean } }
+ | { readonly type: 'log/openFile'; readonly payload: { readonly hash: string; readonly path: string; readonly status: string; readonly oldPath?: string } }
| { readonly type: 'log/requestCi'; readonly payload: { readonly hashes: readonly string[] } }
| { readonly type: 'log/ciSignIn' }
| { readonly type: 'log/openExternal'; readonly payload: { readonly url: string } }
diff --git a/tests/suite/diff-open.test.js b/tests/suite/diff-open.test.js
new file mode 100644
index 0000000..237aef6
--- /dev/null
+++ b/tests/suite/diff-open.test.js
@@ -0,0 +1,130 @@
+const assert = require('assert');
+const vscode = require('vscode');
+const fs = require('fs');
+const path = require('path');
+const cp = require('child_process');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const GIT_EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
+
+/**
+ * 「打开差异」集成测试:覆盖新增/删除/重命名/根提交文件在两个视图均能打开原生差异编辑器。
+ * 直接对核心修复点(缺失端走 git 空树 ref)做端到端验证:
+ * 1. 空树 URI 经 vscode.git 解析得到空文档(否则新增/删除文件差异在新版 VS Code 会 FileNotFound)。
+ * 2. `hyperGit.openCommitFileDiff`(Graph 视图)对 A/D/R 不抛错并打开差异。
+ * 3. `hyperGit.openDiff`(Commit 视图)对工作区未跟踪新增文件不抛错并打开差异。
+ *
+ * 注:VS Code 对 `{preview:true}` 的差异会复用预览标签页(后续差异替换而非新增),
+ * 故按「标签数自增」断言不可靠;这里每例先关闭全部编辑器,再按差异标题(含文件名)匹配标签。
+ */
+suite('打开差异(新增/删除/重命名/根提交)', function () {
+ this.timeout(60000);
+
+ let api;
+ let repo;
+ let root;
+
+ suiteSetup(async function () {
+ const gitExt = vscode.extensions.getExtension('vscode.git');
+ assert.ok(gitExt, 'vscode.git 扩展未找到');
+ if (!gitExt.isActive) {
+ await gitExt.activate();
+ }
+ api = gitExt.exports.getAPI(1);
+ for (let i = 0; i < 40; i++) {
+ repo = api.repositories[0];
+ if (repo) {
+ try {
+ await repo.status();
+ } catch {
+ /* ignore */
+ }
+ if (repo.state.workingTreeChanges.length > 0 || repo.state.untrackedChanges.length > 0) {
+ break;
+ }
+ }
+ await sleep(500);
+ }
+ assert.ok(repo, '未发现 git 仓库(fixture 未被 vscode.git 打开)');
+ root = repo.rootUri.fsPath;
+ });
+
+ async function waitFor(predicate, attempts = 50) {
+ for (let i = 0; i < attempts; i++) {
+ if (await predicate()) {
+ return true;
+ }
+ await sleep(200);
+ }
+ return false;
+ }
+
+ async function closeAllEditors() {
+ await vscode.commands.executeCommand('workbench.action.closeAllEditors');
+ await sleep(300);
+ }
+
+ /** 等待出现标签标题包含 nameContains 的标签(差异标题含文件名 + hash)。 */
+ async function waitForDiffTab(nameContains) {
+ return waitFor(() =>
+ vscode.window.tabGroups.all.some((g) => g.tabs.some((t) => (t.label || '').includes(nameContains))),
+ );
+ }
+
+ test('空树 URI 解析为空文档(缺失端锚点)', async function () {
+ // 任意路径 + 空树 ref 都应稳定得到空文档(与该文件是否存在/跟踪无关)。
+ const emptyUri = api.toGitUri(vscode.Uri.joinPath(repo.rootUri, 'any/file.txt'), GIT_EMPTY_TREE);
+ const doc = await vscode.workspace.openTextDocument(emptyUri);
+ assert.strictEqual(doc.getText(), '', '空树 ref 应解析为空文档');
+ });
+
+ test('Graph 视图:新增文件打开差异不抛错', async function () {
+ await closeAllEditors();
+ fs.writeFileSync(path.join(root, 'added.txt'), '新增内容\n');
+ cp.execFileSync('git', ['add', 'added.txt'], { cwd: root });
+ cp.execFileSync('git', ['commit', '-q', '-m', 'feat: 新增 added.txt'], { cwd: root });
+ const hash = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root }).toString().trim();
+
+ await vscode.commands.executeCommand('hyperGit.openCommitFileDiff', hash, 'added.txt', 'A');
+ assert.ok(await waitForDiffTab('added.txt'), '应打开 added.txt 差异');
+ });
+
+ test('Graph 视图:删除文件打开差异不抛错', async function () {
+ await closeAllEditors();
+ cp.execFileSync('git', ['rm', '-q', 'added.txt'], { cwd: root });
+ cp.execFileSync('git', ['commit', '-q', '-m', 'chore: 删除 added.txt'], { cwd: root });
+ const hash = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root }).toString().trim();
+
+ await vscode.commands.executeCommand('hyperGit.openCommitFileDiff', hash, 'added.txt', 'D');
+ assert.ok(await waitForDiffTab('added.txt'), '应打开 added.txt 差异');
+ });
+
+ test('Graph 视图:重命名文件打开差异不抛错', async function () {
+ await closeAllEditors();
+ // README.md 由 fixture 初始提交落库,git mv 制造可靠 rename(含 oldPath)。
+ cp.execFileSync('git', ['mv', 'README.md', 'renamed.md'], { cwd: root });
+ cp.execFileSync('git', ['commit', '-q', '-m', 'refactor: README 改名'], { cwd: root });
+ const hash = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root }).toString().trim();
+
+ // status=R100、oldPath=README.md:命中「重命名」分支,左端取旧路径@父、右端取新路径@本提交。
+ await vscode.commands.executeCommand('hyperGit.openCommitFileDiff', hash, 'renamed.md', 'R100', 'README.md');
+ assert.ok(await waitForDiffTab('renamed.md'), '应打开 renamed.md 差异');
+ });
+
+ test('Commit 视图:工作区未跟踪新增文件打开差异不抛错', async function () {
+ await closeAllEditors();
+ const file = path.join(root, 'untracked.txt');
+ fs.writeFileSync(file, '未跟踪\n');
+
+ // 模拟 Commit 视图点击:构造 ChangeItem(status='A' 即 FileStatus.Added)触发 openDiff。
+ const change = {
+ relativePath: 'untracked.txt',
+ uri: vscode.Uri.file(file),
+ originalUri: vscode.Uri.file(file),
+ status: 'A',
+ staged: false,
+ };
+ await vscode.commands.executeCommand('hyperGit.openDiff', change);
+ assert.ok(await waitForDiffTab('untracked.txt'), '应打开 untracked.txt 差异');
+ });
+});
diff --git a/tests/suite/index.js b/tests/suite/index.js
index 9fa5dc7..69f019e 100644
--- a/tests/suite/index.js
+++ b/tests/suite/index.js
@@ -9,6 +9,7 @@ async function run() {
const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 60000 });
mocha.addFile(path.resolve(__dirname, 'extension.test.js'));
mocha.addFile(path.resolve(__dirname, 'commit-flow.test.js'));
+ mocha.addFile(path.resolve(__dirname, 'diff-open.test.js'));
return new Promise((resolve, reject) => {
mocha.run((failures) => {
diff --git a/tests/unit/diff-change-side.test.ts b/tests/unit/diff-change-side.test.ts
new file mode 100644
index 0000000..dae7c3b
--- /dev/null
+++ b/tests/unit/diff-change-side.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest';
+import { diffShapeFromStatus, diffShapeFromCode } from '../../src/engine/diff/change-side';
+import { FileStatus } from '../../src/engine/model';
+
+describe('diffShapeFromStatus(Commit 视图领域模型状态 → 差异形态)', () => {
+ it('新增类(Added/Untracked/Copied)→ added(旧端置空)', () => {
+ expect(diffShapeFromStatus(FileStatus.Added)).toBe('added');
+ expect(diffShapeFromStatus(FileStatus.Untracked)).toBe('added');
+ expect(diffShapeFromStatus(FileStatus.Copied)).toBe('added');
+ });
+
+ it('Deleted → deleted(新端置空)', () => {
+ expect(diffShapeFromStatus(FileStatus.Deleted)).toBe('deleted');
+ });
+
+ it('Renamed → renamed(两端异路径)', () => {
+ expect(diffShapeFromStatus(FileStatus.Renamed)).toBe('renamed');
+ });
+
+ it('其余(Modified/Conflict/Ignored)→ modified(两端同路径真实内容)', () => {
+ expect(diffShapeFromStatus(FileStatus.Modified)).toBe('modified');
+ expect(diffShapeFromStatus(FileStatus.Conflict)).toBe('modified');
+ expect(diffShapeFromStatus(FileStatus.Ignored)).toBe('modified');
+ });
+});
+
+describe('diffShapeFromCode(Graph 视图 git diff-tree 字母码 → 差异形态)', () => {
+ it('A/U → added', () => {
+ expect(diffShapeFromCode('A')).toBe('added');
+ expect(diffShapeFromCode('U')).toBe('added');
+ });
+
+ it('D → deleted', () => {
+ expect(diffShapeFromCode('D')).toBe('deleted');
+ });
+
+ it('R/C(含相似度后缀 R100/C90)→ renamed', () => {
+ expect(diffShapeFromCode('R')).toBe('renamed');
+ expect(diffShapeFromCode('R100')).toBe('renamed');
+ expect(diffShapeFromCode('C')).toBe('renamed');
+ expect(diffShapeFromCode('C90')).toBe('renamed');
+ });
+
+ it('M/T 及未知码 → modified(默认回落)', () => {
+ expect(diffShapeFromCode('M')).toBe('modified');
+ expect(diffShapeFromCode('T')).toBe('modified');
+ expect(diffShapeFromCode('X')).toBe('modified');
+ });
+});