diff --git a/CHANGELOG.i18n.yaml b/CHANGELOG.i18n.yaml
index 63cb566..f6f258f 100644
--- a/CHANGELOG.i18n.yaml
+++ b/CHANGELOG.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# git hash-object CHANGELOG.md CHANGELOG.zh.md
-CHANGELOG.md: 3dc51086634341c4337b7eaf7ad25550f41a662a
-CHANGELOG.zh.md: 8e209ab5efbc6f893b9c1008701a61030970a7fd
+CHANGELOG.md: 6eac2e89ff3ca4054339b9041cfc7375938262ad
+CHANGELOG.zh.md: 6da7452dc645ffc427aa7aebd3195e666acba123
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3dc5108..6eac2e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@ Compatibility release extending the exact DSH support list to `0.2.0-rc.2`, with
### Changed
+- Git file comparisons now use the official DSH `DiffBlock` instead of the plugin-owned line renderer and styles. The plugin retains Git fetching and sidebar addresses, supports the existing DSH matrix, and distinguishes binary, metadata-only, empty, incomplete, and end-of-file newline changes. Copying and folding are provided by DSH; newer hosts also provide line wrapping.
+
- The exact DSH compatibility matrix now includes `0.2.0-rc.1` and `0.2.0-rc.2`. Development dependencies and the committed lockfile pin `0.2.0-rc.2` (Cordis `4.0.4`, schemastery `3.18.4`). Existing structure probes in `src/compat/` still cover settings, schema, and icons. Lane-only packages that this pin publishes (`dsh-util-code-language`, `dsh-client-shortcuts`) are development dependencies only; they stay out of the peer OR.
### Fixed
diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md
index 8e209ab..6da7452 100644
--- a/CHANGELOG.zh.md
+++ b/CHANGELOG.zh.md
@@ -10,6 +10,8 @@
### Changed
+- Git 文件比较改为复用官方 DSH `DiffBlock`,删除插件自写的逐行渲染器与样式。插件保留 Git 取数和侧栏地址,继续支持现有 DSH 矩阵,并区分二进制、仅元数据、空 patch、不完整输出及末尾换行变化。复制和折叠由 DSH 提供,新宿主还提供换行控件。
+
- 精确 DSH 兼容矩阵加入 `0.2.0-rc.1` 与 `0.2.0-rc.2`。开发依赖与提交锁文件 pin 在 `0.2.0-rc.2`(Cordis `4.0.4`、schemastery `3.18.4`)。`src/compat/` 里已有的设置、schema 与图标结构探测继续覆盖这两个版本。该 pin 发布的车道专用包(`dsh-util-code-language`、`dsh-client-shortcuts`)只作为开发依赖,不进入 peer OR。
### Fixed
diff --git a/README.i18n.yaml b/README.i18n.yaml
index fca20bd..05f5d9e 100644
--- a/README.i18n.yaml
+++ b/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# git hash-object README.md README.zh.md
-README.md: 0ac474cf11ac7921db78f5333269f14f23e6f940
-README.zh.md: 3ae0617935f776950ee3c298275f0eae95888385
+README.md: 92e4b2be3a7e362fd4d34d60e144ddd0890de766
+README.zh.md: 58520684bb27c80f31bf5f5751a9d939f268c488
diff --git a/README.md b/README.md
index 0ac474c..92e4b2b 100644
--- a/README.md
+++ b/README.md
@@ -164,7 +164,7 @@ Repository controls (refresh / reveal) render inside each Git frame through a lo
In the conversation composer, Git contributes a branch selector and a changed-files indicator on the left of the input area. Clicking either control opens `git.changes` as a right-sidebar tab. Creating a branch opens a shared conversation Modal; after `git init` with no commits (unborn HEAD), the menu shows the symbolic default branch as disabled, explains that the first commit is required, and disables create until HEAD exists.
-The **Changes** surface shows the current branch beside refresh, then a single-line auto-growing commit message field with a wand **Generate** control and a split **Commit** button (Commit, Amend, Commit & Push, Commit & Sync). Staged, unstaged, and untracked paths follow as icon-action sections: plus or minus toggles the index, undo discards after a two-step confirm, and a porcelain letter badges the row. Clicking a path opens the matching diff. The **Diff** surface shows refresh in the top-right and renders one file's working-tree or staged diff per tab. The **Graph** surface shows Auto / All / First parent beside refresh, then the commit history as a canvas-drawn lane graph — one continuous coordinate space, so rails and merge edges never break at row boundaries — with subject, author, date, hash, and HEAD/branch/tag decoration badges, paged incrementally with a load-more control. When the host exposes an LLM runtime, a staged diff is sent to the session model — or to a custom provider/model from plugin configuration — and the streamed suggestion is written into the editable input. That same configuration page also edits the generation system message. Hosts through `0.1.6-alpha.1` show it under **Settings → Plugins → Plugin configuration**; from `0.1.6-alpha.2` it opens on that bundle's detail page in plugin management. Generation never stages, commits, or pushes anything. On Electron, optional Desktop enhancement adds reveal-in-folder and open-path actions when the Desktop provider is present.
+The **Changes** surface shows the current branch beside refresh, then a single-line auto-growing commit message field with a wand **Generate** control and a split **Commit** button (Commit, Amend, Commit & Push, Commit & Sync). Staged, unstaged, and untracked paths follow as icon-action sections: plus or minus toggles the index, undo discards after a two-step confirm, and a porcelain letter badges the row. Clicking a path opens the matching diff. The **Diff** adapter shows refresh in the top-right and supplies one file's working-tree or staged comparison to the official DSH `DiffBlock`, which owns copying, folding, and line wrapping where supported. Git patch conversion and non-text states are described in the [architecture overview](docs/architecture/overview.md); the original resource addresses remain restorable. The **Graph** surface shows Auto / All / First parent beside refresh, then the commit history as a canvas-drawn lane graph — one continuous coordinate space, so rails and merge edges never break at row boundaries — with subject, author, date, hash, and HEAD/branch/tag decoration badges, paged incrementally with a load-more control. When the host exposes an LLM runtime, a staged diff is sent to the session model — or to a custom provider/model from plugin configuration — and the streamed suggestion is written into the editable input. That same configuration page also edits the generation system message. Hosts through `0.1.6-alpha.1` show it under **Settings → Plugins → Plugin configuration**; from `0.1.6-alpha.2` it opens on that bundle's detail page in plugin management. Generation never stages, commits, or pushes anything. On Electron, optional Desktop enhancement adds reveal-in-folder and open-path actions when the Desktop provider is present.
## Composition
diff --git a/README.zh.md b/README.zh.md
index 3ae0617..5852068 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -164,7 +164,7 @@ declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
在会话输入区左侧,Git 贡献 branch selector 与 changed-files indicator。点击任一控件会在右侧边栏打开 `git.changes` 标签页。创建分支会打开共享的 conversation Modal;在仅有 `git init`、尚无提交(unborn HEAD)时,菜单以禁用态展示符号默认分支,说明需要先完成首次提交,并在 HEAD 存在前禁用创建。
-**Changes** surface 顶部展示当前 branch 与 refresh,其下是默认一行、随内容增高的 commit message 输入框,带魔法棒 **Generate** 控件,以及分裂式 **Commit** 按钮(Commit、Amend、Commit & Push、Commit & Sync)。Staged、unstaged 与 untracked 路径以图标操作分区列出:plus/minus 切换 index,undo 在两步确认后 discard,porcelain 字母标记行状态。点击路径打开对应 diff。**Diff** surface 在右上角展示 refresh,并在每个标签页渲染一个文件的 working-tree 或 staged diff。**Graph** surface 顶部在同一行展示自动/全部/首父链与 refresh,其下以 canvas 绘制的 lane graph 展示提交历史——整页共享一个连续坐标系,rail 与 merge 边不会在行边界断裂——包含 subject、author、date、hash 与 HEAD/branch/tag 装饰徽标,并通过 load-more 控件增量分页。当 host 暴露 LLM runtime 时,staged diff 会发送到会话模型——或发送到插件配置里的自定义 provider/model——流式生成的建议写入可编辑输入框。同一张配置页也可以编辑生成所用的 system message。`0.1.6-alpha.1` 及更早在 **设置 → 插件 → 插件配置**;自 `0.1.6-alpha.2` 起在插件管理页该组合包的详情页。生成绝不 stage、commit 或 push 任何内容。在 Electron 上,optional Desktop enhancement 在 Desktop provider 存在时提供 reveal-in-folder 与 open-path 操作。
+**Changes** surface 顶部展示当前 branch 与 refresh,其下是默认一行、随内容增高的 commit message 输入框,带魔法棒 **Generate** 控件,以及分裂式 **Commit** 按钮(Commit、Amend、Commit & Push、Commit & Sync)。Staged、unstaged 与 untracked 路径以图标操作分区列出:plus/minus 切换 index,undo 在两步确认后 discard,porcelain 字母标记行状态。点击路径打开对应 diff。**Diff** 适配层在右上角展示 refresh,将单文件的 working-tree 或 staged 比较交给官方 DSH `DiffBlock`;复制、折叠以及支持版本上的换行由官方组件负责。Git patch 转换与非文本状态见[架构总览](docs/architecture/overview.md),原有资源地址仍可恢复。**Graph** surface 顶部在同一行展示自动/全部/首父链与 refresh,其下以 canvas 绘制的 lane graph 展示提交历史——整页共享一个连续坐标系,rail 与 merge 边不会在行边界断裂——包含 subject、author、date、hash 与 HEAD/branch/tag 装饰徽标,并通过 load-more 控件增量分页。当 host 暴露 LLM runtime 时,staged diff 会发送到会话模型——或发送到插件配置里的自定义 provider/model——流式生成的建议写入可编辑输入框。同一张配置页也可以编辑生成所用的 system message。`0.1.6-alpha.1` 及更早在 **设置 → 插件 → 插件配置**;自 `0.1.6-alpha.2` 起在插件管理页该组合包的详情页。生成绝不 stage、commit 或 push 任何内容。在 Electron 上,optional Desktop enhancement 在 Desktop provider 存在时提供 reveal-in-folder 与 open-path 操作。
## 组合
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 7b886fc..05abe6d 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -27,6 +27,7 @@
- **RPC**:DSH Web 宿主存在时,Connection 子 fiber 注册 loopback `/git` 通道;过期输出(stale mutation)被丢弃,文件名按字面 pathspec 处理。
- **Client 刷新合并**:`GitClientController.refresh()` 对同一工作区复用在途刷新(复用键为 `path` + `operationGeneration`,只有同 `generation` 才复用;轮次结束在 `finally` 中释放槽位)。复用只在该轮尚未发出 `status` 读取时成立;请求在该轮读取之后到达时,该轮记一次 trailing 读取,合并进来的调用方 await 到这轮 trailing 读取落地,共享轮次失败(瞬时错误)也不例外——请求的依据是「读取已发出」,与那轮成败无关。一个 burst 最多两轮往返(trailing 不再递归链接,避免控制器自身的 Host 流量——Windows 下每条 git 命令弹出的控制台窗口带来的焦点事件——自激成永久循环)。composer 控件的 `window` focus 监听因此不会在焦点抖动时叠加多轮 discover/status,也不会让重叠轮次互相判旧。背景见 [troubleshooting](../troubleshooting/windows-desktop-console-flood.md)。
- **右侧边栏集成**:Client manifest 以 `inject` 声明运行时依赖 `ctx.sidebarRight` / `ctx.sidebarRightTabs`;对 sidebar 包只有 `import type`(动态 plugin 不得 import 其他包的运行时值),bundle 中类型被擦除,故无 `external`。阶段一向 `ctx.sidebarRightTabs.register` 注册三个 tab type(changes/graph 是 page type,diff 是 resource type,pattern `dsh-resource://git/diff/**`);阶段二把 body 注入 `sidebar.right.pane.tab` 座(key 为 definition id)。导航统一走 `ctx.sidebarRight.openTab(kind)` 与 `openResource(address, { params })`:changes/graph 由侧栏按 kind 每 pane 去重,diff 以精确地址为 tab 身份,重复打开 reveal 既有 tab 并递增 `navigation.revision`;参数类型通过 `SidebarRightResourceParamsMap` 模块扩充。tab 栏、guide 页、停靠几何与面板宽度由右侧边栏(基于 ui-dockkit)拥有。Diff 结果是面板局部的:controller 只提供无状态的 `fetchDiff`,每个 Diff surface 持有自己的在途请求与结果(按仓库根 + tab 地址为键,乱序完成互相不可见),故分屏、浮动窗口与同文件的 staged/worktree 两个 tab 永不互串正文。changes/graph 另在 `sidebar.right.pane.tab.title` 座注册 live title(diff chip 是语言无关的文件名,不注册),已打开的 chip 随语言切换更新,而非冻结在打开时刻的文本。provider 卸载只移除 tab type 注册,保留 tab 记录:上游 body 座转为其 "unavailable" 兜底,重新注册后正文恢复。
+- **Diff 显示**:插件不再维护逐行 patch 渲染器;`GitDiffSurface` 只负责 Git 取数、面板状态与侧栏适配,正文交给 `@deepseek-ai/dsh-client-ui-primitives` 公开导出的 `DiffBlock`。`git-diff-adapter.ts` 用已有 `diff` 包的 `parsePatch` 把 Git unified patch 转成组件接受的 old/new 文本片段,显示路径取用户选择的字面路径,不从 Git 引号头部反推。复制、折叠与新宿主的换行控件由官方组件拥有;Git 的工作区/index/HEAD 比较不转换成会话回合快照。二进制、仅元数据更改、空 patch 与不完整 patch 分别显示状态;末尾换行差异另有说明,因为官方组件会归一化行终止符;适配器分别记录旧、新文本是否缺少末尾换行,仅两侧状态不同时显示提示,双方均缺少换行(包括共享上下文中的标记)不算换行差异。支持版本上的上下文与 labels 差异集中在 `src/compat/dsh-diff.ts`,见[兼容性参考](../reference/dsh-compatibility.md#官方-diffblock-接入)。
- **可移植 / 原生分离**:主 fiber 不 require `desktop`;子 fiber `ctx.inject(['desktop'], ...)` 只接受 `shell.showItemInFolder`、`shell.openPath`、`notification.show`。缺 Desktop 时核心操作全部可用,仅原生动作不显示。
- **Graph layout engine**(`src/client/graph/`):纯逻辑模块——不访问 Git、不依赖 React、不触碰 DOM,可独立测试;输出 geometry 与 Canvas 2D 绘制解耦。设计决策与不变量见 [Git Graph Layout Engine](../reference/git-graph-layout.md)、[ADR-0001](../decisions/ADR-0001-graph-lane-not-owned-by-branch.md)、[ADR-0002](../decisions/ADR-0002-graph-lane-cap-max-three-lanes.md)。
- **Commit message 生成**:Host 侧独立 LLM 请求(不经会话上下文,无 KV Cache 影响);需要 LLM runtime 与可解析模型,否则 Client 报告 `git/generation-unavailable`。配置结构见根 README [Configuration](../../README.md#configuration)(canonical)。
diff --git a/docs/reference/dsh-compatibility.md b/docs/reference/dsh-compatibility.md
index 8d8bf4b..d3cc94e 100644
--- a/docs/reference/dsh-compatibility.md
+++ b/docs/reference/dsh-compatibility.md
@@ -82,6 +82,16 @@
- **0.2.0 新增包不进入 peer OR。** `@deepseek-ai/dsh-client-product-analytics`、`dsh-otel`、`dsh-experimental-schedule-bundle` 与 `dsh-client-ui-settings-session-log` 仅从 0.2.0-rc.1 发布。conversation 对 product-analytics 是类型侧 `import type {}` 与 devDependency;候选类型构建与测试未要求安装该包。把它们写成与支持清单相同的 peer OR,会让旧宿主的 `app-boot` 预检失败。
- **车道专用包继续按发布探测。** `dsh-util-code-language` 与 `dsh-client-shortcuts` 在 `0.1.7-rc.2`、`0.2.0-rc.1` 与 `0.2.0-rc.2` 均有发布。开发 pin 现为 `0.2.0-rc.2`,提交锁文件包含这两项;它们仍不进入 peer OR。升级脚本只在 `npm view` 看到目标精确版本时把它们写入该车道的 devDependencies,切到更早发行版时再移除。0.2.0 的 primitives 发布 bundle 仍顶层导入 `dsh-util-code-language`,并额外导入 `dsh-util-workspace-path`;后者由传递依赖装上,不进入 peer OR。已有的 `diff`、`simple-icons`、`zustand` 与 `immer` peer 继续覆盖更早的未声明导入。
+## 官方 DiffBlock 接入
+
+Git Client 从 primitives 的公开入口复用 `DiffBlock`,不导入官方 `ui-deliverables` 的私有 `FileDiff` 源码,也不要求改动 DSH 宿主。官方完整 `changes-review` 页面绑定会话回合的变更记录,不能直接代替 Git 的 index/HEAD/worktree 比较;本插件保留原有 `git.diff` 地址与取数适配,只删除自写的 patch 行渲染与样式。渲染职责见[架构总览](../architecture/overview.md)。
+
+`0.1.5-rc.2` 的组件把 old/new 片段中的每行当作移除/添加;自 `0.1.6-alpha.1` 起,组件比较片段并保留共享上下文。`src/compat/dsh-diff.ts` 用公开纯函数 `diffTotals` 对相同文本的计数探测该行为:支持上下文时传完整 hunk,否则只传由上下文隔开的修改片段,避免把未修改行当成更改。旧版的 `files(count)` labels 与新版的 code/wrap/unwrap labels 同时提供,中文和英文都由 Git 字典拥有。支持清单和开发 pin 不变。
+
+新增的 `tests/dsh-diff.client.spec.ts` 在矩阵每个车道使用该车道实际安装的官方组件计数,验证共享上下文不会增加编辑数;详情面板与并发面板测试直接渲染官方组件,覆盖复制控件、折叠、地址恢复、再次导航与结果隔离。新增 adapter 规格用临时仓库的真实 Git patch 验证增删、暂存/工作区、Unicode 路径、二进制、重命名与缺少末尾换行,并拒绝截断输出。
+
+本次迁移对开发 pin `0.2.0-rc.2` 运行完整测试、构建与产物检查;其余八个支持版本分别在临时目录重建安装树,通过兼容门禁、78 项相关回归、构建与产物检查。实际开发安装树、manifest 与 lockfile 保持原 pin。未进行真实 Web/Desktop 浏览器交互验证。
+
## 静态一致性门禁
上游 DSH 包把同级依赖写成 caret,例如 `^0.1.5-rc.2`。这个范围接受同一 core 上更后的 prerelease,pnpm 在 `autoInstallPeers` 下会装成 registry 里当前最高的匹配版本。只钉住根清单时,锁文件仍会混入 `0.1.5-rc.3`、`0.1.7-alpha.1` 或更旧的 `0.1.1-rc.2`。`.pnpmfile.cjs` 在每次安装时把传递依赖、可选依赖和 peer 里的全部 `@deepseek-ai/dsh-*` 改成当前精确版本。目标版本优先取 `DSH_COMPAT_VERSION`,否则取根 devDependency 的唯一 pin。根清单自己的 devDependency pin 和多版本 peer OR 范围保持不变。
diff --git a/docs/requirements/scope-and-requirements.md b/docs/requirements/scope-and-requirements.md
index 6e49b5f..2c45d0d 100644
--- a/docs/requirements/scope-and-requirements.md
+++ b/docs/requirements/scope-and-requirements.md
@@ -14,7 +14,7 @@
| --- | --- |
| FR-1 | 仓库发现、Git 版本、当前分支与 HEAD 读取 |
| FR-2 | staged / unstaged / untracked 状态查询(porcelain v2,NUL 路径分隔) |
-| FR-3 | 工作区与暂存区 diff 读取 |
+| FR-3 | 工作区与暂存区 diff 读取;复用官方 `DiffBlock` 显示文件比较,插件只拥有 Git 数据与侧栏适配 |
| FR-4 | stage / unstage / commit / amend / push / rebase-then-push 同步 |
| FR-5 | 丢弃单条变更(含暂存后修改的 add/rename),两步显式确认 |
| FR-6 | 本地分支创建与切换;unborn HEAD 时禁用创建并说明原因 |
diff --git a/src/client/GitDetailsSurface.module.css b/src/client/GitDetailsSurface.module.css
index bbeccf9..f1d5ea8 100644
--- a/src/client/GitDetailsSurface.module.css
+++ b/src/client/GitDetailsSurface.module.css
@@ -374,39 +374,6 @@
color: var(--dsw-alias-label-secondary);
}
-.diffBody {
- overflow: auto;
- border: 0.5px solid var(--dsw-alias-border-l2);
- border-radius: 8px;
- background: var(--dsw-specific-sidebar-fill);
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- line-height: 1.55;
-}
-
-.diffPlain,
-.diffMeta,
-.diffAdd,
-.diffDel {
- padding: 0 10px;
- white-space: pre;
-}
-
-.diffMeta {
- color: var(--dsw-alias-label-tertiary);
- background: var(--dsw-alias-interactive-bg-hover);
-}
-
-.diffAdd {
- color: var(--dsw-alias-state-success-primary);
- background: rgb(46 160 67 / 8%);
-}
-
-.diffDel {
- color: var(--dsw-alias-state-error-primary);
- background: rgb(207 34 46 / 8%);
-}
-
.generateButton {
position: absolute;
right: 6px;
diff --git a/src/client/details/DiffTab.tsx b/src/client/details/DiffTab.tsx
deleted file mode 100644
index e2cae82..0000000
--- a/src/client/details/DiffTab.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { ReactNode } from 'react'
-import type { GitDiff, GitRepositorySnapshot } from '../../types.ts'
-import type { GitLocaleKey } from '../locales.ts'
-import { splitRepoPath } from '../path-display.ts'
-import css from '../GitDetailsSurface.module.css'
-
-function DiffLine({ line }: { line: string }): ReactNode {
- let className = css.diffPlain
- if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) className = css.diffMeta
- else if (line.startsWith('+')) className = css.diffAdd
- else if (line.startsWith('-')) className = css.diffDel
- return
{line}
-}
-
-/** Render the Diff tab for the selected changed file. */
-export function DiffTab({ repository, payload, diff, clean, t, error }: {
- repository: GitRepositorySnapshot
- payload: { readonly path: string; readonly staged: boolean } | undefined
- diff: GitDiff | undefined
- clean: boolean
- t: (key: GitLocaleKey) => string
- error: string | undefined
-}): ReactNode {
- if (clean) return {t('details.noChangesDiff')}
- if (payload === undefined) return {t('details.noDiff')}
- const untracked = repository.untracked.includes(payload.path)
- if (untracked) return {t('details.untrackedDiff')}
- const { name, dir } = splitRepoPath(payload.path)
- const mode = payload.staged ? t('details.stagedLabel') : t('details.workingTree')
- return (
-
- {error !== undefined &&
{error}
}
-
- {name}
- {dir !== '' ? `${dir}${name}` : name}
- {mode}
-
-
- {diff === undefined
- ?
{t('details.loading')}
- : diff.text === ''
- ?
{t('details.clean')}
- : diff.text.split('\n').map((line, index) =>
)}
-
-
- )
-}
diff --git a/src/client/git-diff-adapter.ts b/src/client/git-diff-adapter.ts
new file mode 100644
index 0000000..86e2ddf
--- /dev/null
+++ b/src/client/git-diff-adapter.ts
@@ -0,0 +1,66 @@
+/** Convert Git's unified patch into the official DiffBlock's file fragments. */
+import { parsePatch } from 'diff'
+import type { DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
+
+/** A comparison the official text renderer can display, or a non-text status. */
+export type GitFileComparison =
+ | { kind: 'text'; diffs: DiffHunk[]; newlineChanged: boolean }
+ | { kind: 'empty' | 'binary' | 'metadata' | 'invalid' }
+
+/**
+ * Decode a single-path Git patch without deriving display paths from quoted Git headers.
+ * @param path - Literal repository-relative path selected by the user.
+ * @param text - Complete colorless unified patch.
+ * @param context - Whether the host's DiffBlock understands shared context.
+ * @returns File fragments, or a status for empty, binary, metadata-only or incomplete output.
+ */
+export function gitFileComparison(path: string, text: string, context = true): GitFileComparison {
+ if (text === '') return { kind: 'empty' }
+ if (!text.startsWith('diff --git ') || !text.endsWith('\n')) return { kind: 'invalid' }
+ try {
+ const patches = parsePatch(text)
+ if (patches.length === 0) return { kind: 'invalid' }
+ if (patches.some(patch => patch.isBinary)) return { kind: 'binary' }
+ const diffs: DiffHunk[] = []
+ let newlineChanged = false
+ for (const patch of patches) {
+ let oldMissingNewline = false
+ let newMissingNewline = false
+ for (const hunk of patch.hunks) {
+ let oldText = ''
+ let newText = ''
+ let previous = ''
+ const flush = (): void => {
+ if (oldText !== '' || newText !== '') diffs.push({ path, oldText, newText })
+ oldText = ''
+ newText = ''
+ }
+ for (const line of hunk.lines) {
+ const operation = line[0]
+ if (operation === '\\') {
+ if (previous === '-' || previous === ' ') {
+ oldText = oldText.slice(0, -1)
+ oldMissingNewline = true
+ }
+ if (previous === '+' || previous === ' ') {
+ newText = newText.slice(0, -1)
+ newMissingNewline = true
+ }
+ } else if (operation === ' ' && !context) {
+ flush()
+ } else {
+ if (operation !== '+') oldText += `${line.slice(1)}\n`
+ if (operation !== '-') newText += `${line.slice(1)}\n`
+ }
+ previous = operation ?? ''
+ }
+ flush()
+ }
+ newlineChanged ||= oldMissingNewline !== newMissingNewline
+ }
+ return diffs.length === 0 ? { kind: 'metadata' } : { kind: 'text', diffs, newlineChanged }
+ } catch {
+ // The subprocess collector can retain only a tail: never show a partial patch as a clean file.
+ return { kind: 'invalid' }
+ }
+}
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 0c0f178..cfbb30d 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -69,6 +69,20 @@ export const en = {
'tab.changes': 'Git Changes',
'tab.graph': 'Git Graph',
'details.missingDiff': 'Cannot resolve this diff',
+ 'diff.copy': 'Copy changes',
+ 'diff.copied': 'Copied',
+ 'diff.collapse': 'Collapse',
+ 'diff.collapseAria': 'Collapse changes',
+ 'diff.expand': 'Show {count} more lines',
+ 'diff.expandAria': 'Show {count} hidden lines',
+ 'diff.files': '{count} files',
+ 'diff.code': 'Code',
+ 'diff.wrap': 'Enable line wrap',
+ 'diff.unwrap': 'Disable line wrap',
+ 'diff.binary': 'Binary file; text changes cannot be shown.',
+ 'diff.metadata': 'File metadata changed; there are no text hunks to display.',
+ 'diff.invalid': 'The Git patch is incomplete or unsupported. Refresh, or increase the host output limit.',
+ 'diff.newline': 'The end-of-file newline changed on one or both sides.',
'settings.title': 'Git',
'settings.description': 'Commit message generation for staged changes.',
'settings.expand': 'Show settings',
@@ -165,6 +179,20 @@ export const zh: Record = {
'tab.changes': 'Git 更改',
'tab.graph': 'Git 图谱',
'details.missingDiff': '无法解析该对比',
+ 'diff.copy': '复制更改',
+ 'diff.copied': '已复制',
+ 'diff.collapse': '折叠',
+ 'diff.collapseAria': '折叠更改',
+ 'diff.expand': '显示另外 {count} 行',
+ 'diff.expandAria': '显示 {count} 行隐藏内容',
+ 'diff.files': '{count} 个文件',
+ 'diff.code': '代码',
+ 'diff.wrap': '启用自动换行',
+ 'diff.unwrap': '关闭自动换行',
+ 'diff.binary': '二进制文件,无法显示文本差异。',
+ 'diff.metadata': '文件元数据已更改,没有可显示的文本差异。',
+ 'diff.invalid': 'Git 差异不完整或格式不受支持。请刷新,或提高宿主输出上限。',
+ 'diff.newline': '比较内容涉及文件末尾换行的变化。',
'settings.title': 'Git',
'settings.description': '为暂存更改生成提交信息。',
'settings.expand': '显示设置',
diff --git a/src/client/surfaces/GitDiffSurface.tsx b/src/client/surfaces/GitDiffSurface.tsx
index 4af18e1..7d59a73 100644
--- a/src/client/surfaces/GitDiffSurface.tsx
+++ b/src/client/surfaces/GitDiffSurface.tsx
@@ -1,10 +1,11 @@
/**
- * Git Diff surface: one changed path per tab. The tab's navigation carries
+ * Git comparison adapter: the official DSH DiffBlock draws one changed path per tab.
+ * The tab's navigation carries
* the compared sides (worktree↔index or index↔HEAD): `params` when the
* opener supplied them, otherwise decoded from the resource address (which
* session restore replays without params).
*/
-import { useEffect, useState, useSyncExternalStore } from 'react'
+import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import type { ReactNode } from 'react'
import type { GitDiff } from '../../types.ts'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -12,7 +13,10 @@ import type { GitClientController } from '../controller.ts'
import { parseGitDiffAddress, type GitDiffPayload } from '../contract.ts'
import { GitDetailsHeaderActions } from '../GitDetailsHeaderActions.tsx'
import { useGitWorkspace } from '../use-git-workspace.ts'
-import { DiffTab } from '../details/DiffTab.tsx'
+import { DiffBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import { dshDiffLabels, dshDiffSupportsContext } from '../../compat/dsh-diff.ts'
+import { gitFileComparison } from '../git-diff-adapter.ts'
+import { splitRepoPath } from '../path-display.ts'
import css from '../GitDetailsSurface.module.css'
/** Identity of one panel's diff result: repository root plus tab address. */
@@ -49,7 +53,7 @@ export function GitDiffSurface({ controller, t, useSessions, sessionId, useTabIn
useEffect(() => {
if (payload === undefined) return
if (state.repository === undefined || state.repository === null) return
- if (state.repository.untracked.includes(payload.path)) return // DiffTab renders the untracked notice from `repository` alone
+ if (state.repository.untracked.includes(payload.path)) return // The repository snapshot supplies the untracked notice.
const root = state.repository.root
let active = true
setResult({ key: keyOf(root, address), diff: undefined, error: undefined })
@@ -70,6 +74,13 @@ export function GitDiffSurface({ controller, t, useSessions, sessionId, useTabIn
? state.error
: `${state.error}\n${fetchError}`
+ const comparison = useMemo(() => payload === undefined || result?.diff === undefined
+ ? undefined
+ : gitFileComparison(payload.path, result.diff.text, dshDiffSupportsContext), [payload?.path, result?.diff])
+ const labels = dshDiffLabels(t)
+ const current = state.repository != null && result?.key === keyOf(state.repository.root, address)
+ const untracked = payload !== undefined && state.repository?.untracked.includes(payload.path) === true
+
return (
@@ -79,17 +90,26 @@ export function GitDiffSurface({ controller, t, useSessions, sessionId, useTabIn
{payload === undefined &&
{t('details.missingDiff')}
}
{state.workspacePath === undefined &&
{t('details.noWorkspace')}
}
{state.repository === null &&
{t('details.notRepository')}
}
- {payload !== undefined && state.repository !== undefined && state.repository !== null && (
- state.repository.untracked.includes(payload.path) || result?.key === keyOf(state.repository.root, address)
- ) && (
-
+ {error !== undefined &&
{error}
}
+ {untracked &&
{t('details.untrackedDiff')}
}
+ {payload !== undefined && state.repository != null && !untracked && (
+
+
+ {splitRepoPath(payload.path).name}
+ {payload.path}
+ {t(payload.staged ? 'details.stagedLabel' : 'details.workingTree')}
+
+ {!current || comparison === undefined
+ ? error === undefined &&
{t('details.loading')}
+ : comparison.kind === 'text'
+ ? <>
+ {comparison.newlineChanged &&
{t('diff.newline')}
}
+
+ >
+ :
+ {t(comparison.kind === 'empty' ? 'details.noChangesDiff' : `diff.${comparison.kind}`)}
+
}
+
)}
diff --git a/src/compat/dsh-diff.ts b/src/compat/dsh-diff.ts
new file mode 100644
index 0000000..56d2392
--- /dev/null
+++ b/src/compat/dsh-diff.ts
@@ -0,0 +1,24 @@
+/** Official DiffBlock integration across the supported DSH releases. */
+import { diffTotals } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { DiffBlockLabels } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { GitLocaleKey } from '../client/locales.ts'
+
+// 0.1.5 counts both complete fragments as replacements; later versions compare shared context.
+const totals = diffTotals([{ path: '', oldText: 'context\n', newText: 'context\n' }])
+export const dshDiffSupportsContext = totals.added === 0 && totals.removed === 0
+
+/**
+ * Supply both legacy file-count copy and the current code-toolbar labels.
+ * @param t - Git namespace translator.
+ * @returns Labels accepted by every supported official DiffBlock.
+ */
+export function dshDiffLabels(t: (key: GitLocaleKey) => string): DiffBlockLabels & { files: (count: number) => string; codeLabel: string; wrapLabel: string; unwrapLabel: string } {
+ const counted = (key: GitLocaleKey) => (count: number): string => t(key).replace('{count}', String(count))
+ return {
+ copy: t('diff.copy'), copied: t('diff.copied'),
+ collapse: t('diff.collapse'), collapseAria: t('diff.collapseAria'),
+ expand: counted('diff.expand'), expandAria: counted('diff.expandAria'),
+ files: counted('diff.files'), codeLabel: t('diff.code'),
+ wrapLabel: t('diff.wrap'), unwrapLabel: t('diff.unwrap'),
+ }
+}
diff --git a/tests/client-bundle.spec.ts b/tests/client-bundle.spec.ts
index 41beee9..2c3c1ad 100644
--- a/tests/client-bundle.spec.ts
+++ b/tests/client-bundle.spec.ts
@@ -6,6 +6,15 @@ import { SUPPORTED_DSH_RELEASES } from '../src/compat/dsh-version.ts'
const packageRoot = resolve(import.meta.dirname, '..')
describe('Git client bundle', () => {
+ it('uses the host-owned DiffBlock without bundling a private renderer', () => {
+ const client = readFileSync(join(packageRoot, 'lib/client.js'), 'utf8')
+ expect(client).toMatch(/\.DiffBlock\b/)
+ expect(client).not.toMatch(/function (DiffBlock|DiffLine)\b/)
+ expect(client).not.toContain('diffPlain')
+ expect(client).not.toContain('diffAdd')
+ expect(client).not.toContain('diffDel')
+ })
+
it('erases the sidebar type-only import from the client artifact', () => {
const client = readFileSync(join(packageRoot, 'lib/client.js'), 'utf8')
expect(client).not.toContain('@dsh-electron/dsh-client-ui-details-host')
diff --git a/tests/details-surface.client.spec.tsx b/tests/details-surface.client.spec.tsx
index 088ff82..7fa0e93 100644
--- a/tests/details-surface.client.spec.tsx
+++ b/tests/details-surface.client.spec.tsx
@@ -102,7 +102,7 @@ function controllerOf(state: ReturnType) {
return () => { listeners.delete(listener) }
},
refresh: vi.fn(async () => {}),
- fetchDiff: vi.fn(async (path: string, staged: boolean) => ({ repository: '/repo', path, staged, text: '+added\n' })),
+ fetchDiff: vi.fn(async (path: string, staged: boolean) => ({ repository: '/repo', path, staged, text: 'diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -1,1 +1,1 @@\n-old\n+added\n' })),
openDiff: vi.fn(),
stage: vi.fn(async () => {}),
unstage: vi.fn(async () => {}),
@@ -172,13 +172,44 @@ describe('GitDiffSurface', () => {
useTabInfo: tabInfoOf(gitDiffAddress('src/a.ts', false), { path: 'src/a.ts', staged: false }),
}) as unknown as GitDiffSurfaceProps
- it('loads the payload diff and renders the file header', async () => {
+ it('loads the payload and delegates file changes to the official DiffBlock', async () => {
const controller = controllerOf(baseState())
render()
expect(controller.fetchDiff).toHaveBeenCalledWith('src/a.ts', false)
expect(screen.getByText('a.ts')).toBeTruthy()
expect(screen.getByText(en['details.workingTree'])).toBeTruthy()
- await waitFor(() => { expect(screen.getByText('+added')).toBeTruthy() })
+ await waitFor(() => { expect(screen.getByText('added')).toBeTruthy() })
+ expect(document.querySelector('[data-diff]')).toBeTruthy()
+ expect(screen.getByRole('button', { name: en['diff.copy'] })).toBeTruthy()
+ })
+
+ it.each([
+ ['', 'details.noChangesDiff'],
+ ['diff --git a/file b/file\nBinary files a/file and b/file differ\n', 'diff.binary'],
+ ['diff --git a/file b/file\nold mode 100644\nnew mode 100755\n', 'diff.metadata'],
+ ['+only the collector tail\n', 'diff.invalid'],
+ ] as const)('shows the non-text state for %s', async (text, key) => {
+ const controller = controllerOf(baseState())
+ controller.fetchDiff.mockResolvedValue({ repository: '/repo', path: 'src/a.ts', staged: false, text })
+ const { container } = render()
+ await waitFor(() => { expect(screen.getByText(en[key])).toBeTruthy() })
+ expect(container.querySelector('[data-diff]')).toBeNull()
+ })
+
+ it('uses official folding controls to reveal the complete comparison', async () => {
+ const controller = controllerOf(baseState())
+ const lines = Array.from({ length: 30 }, (_, index) => `+line ${index}`)
+ controller.fetchDiff.mockResolvedValue({
+ repository: '/repo', path: 'src/a.ts', staged: false,
+ text: `diff --git a/file b/file\n--- /dev/null\n+++ b/file\n@@ -0,0 +1,30 @@\n${lines.join('\n')}\n`,
+ })
+ render()
+ await waitFor(() => { expect(screen.getByText('line 0')).toBeTruthy() })
+ expect(screen.queryByText('line 15')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: /hidden lines/ }))
+ expect(screen.getByText('line 15')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: en['diff.collapseAria'] }))
+ expect(screen.queryByText('line 15')).toBeNull()
})
it('renders the untracked empty state for an untracked payload', () => {
@@ -189,7 +220,7 @@ describe('GitDiffSurface', () => {
{...props(controller)}
useTabInfo={tabInfoOf(gitDiffAddress('notes.txt', false), { path: 'notes.txt', staged: false })}
/>)
- // Untracked paths never hit the diff RPC: DiffTab shows the notice from
+ // Untracked paths never hit the diff RPC: the adapter shows the notice from
// the repository snapshot alone.
expect(controller.fetchDiff).not.toHaveBeenCalled()
expect(screen.getByText(en['details.untrackedDiff'])).toBeTruthy()
diff --git a/tests/diff-panels.client.spec.tsx b/tests/diff-panels.client.spec.tsx
index 9c8b7ff..daa6d3f 100644
--- a/tests/diff-panels.client.spec.tsx
+++ b/tests/diff-panels.client.spec.tsx
@@ -111,7 +111,7 @@ function release(harness: ReturnType, path: string, staged
const index = harness.diffs.findIndex(entry => pathOf(entry.payload) === path && stagedOf(entry.payload) === staged)
const entry = harness.diffs[index]!
harness.diffs.splice(index, 1)
- entry.resolve({ ok: true, value: { repository: '/repo', path, staged, text } })
+ entry.resolve({ ok: true, value: { repository: '/repo', path, staged, text: `diff --git a/file b/file\n--- /dev/null\n+++ b/file\n@@ -0,0 +1,1 @@\n${text}\n` } })
}
function fail(harness: ReturnType, path: string, staged: boolean, message: string): void {
@@ -143,12 +143,12 @@ describe('concurrent Diff panels', () => {
release(harness, 'src/a.ts', false, '+a-line')
const panelA = container.querySelector('[data-panel="a"]')!
const panelB = container.querySelector('[data-panel="b"]')!
- await waitFor(() => { expect(within(panelA).getByText('+a-line')).toBeTruthy() })
- await waitFor(() => { expect(within(panelB).getByText('+b-line')).toBeTruthy() })
+ await waitFor(() => { expect(within(panelA).getByText('a-line')).toBeTruthy() })
+ await waitFor(() => { expect(within(panelB).getByText('b-line')).toBeTruthy() })
expect(within(panelA).getByText('a.ts')).toBeTruthy()
- expect(within(panelA).queryByText('+b-line')).toBeNull()
+ expect(within(panelA).queryByText('b-line')).toBeNull()
expect(within(panelB).getByText('b.ts')).toBeTruthy()
- expect(within(panelB).queryByText('+a-line')).toBeNull()
+ expect(within(panelB).queryByText('a-line')).toBeNull()
})
it('keeps staged and worktree panels of one file apart', async () => {
@@ -168,10 +168,10 @@ describe('concurrent Diff panels', () => {
release(harness, 'src/a.ts', false, '+worktree-line')
const panelStaged = container.querySelector('[data-panel="staged"]')!
const panelWorktree = container.querySelector('[data-panel="worktree"]')!
- await waitFor(() => { expect(within(panelStaged).getByText('+staged-line')).toBeTruthy() })
- await waitFor(() => { expect(within(panelWorktree).getByText('+worktree-line')).toBeTruthy() })
- expect(within(panelStaged).queryByText('+worktree-line')).toBeNull()
- expect(within(panelWorktree).queryByText('+staged-line')).toBeNull()
+ await waitFor(() => { expect(within(panelStaged).getByText('staged-line')).toBeTruthy() })
+ await waitFor(() => { expect(within(panelWorktree).getByText('worktree-line')).toBeTruthy() })
+ expect(within(panelStaged).queryByText('worktree-line')).toBeNull()
+ expect(within(panelWorktree).queryByText('staged-line')).toBeNull()
expect(within(panelStaged).getByText(en['details.stagedLabel'])).toBeTruthy()
expect(within(panelWorktree).getByText(en['details.workingTree'])).toBeTruthy()
})
@@ -196,7 +196,7 @@ describe('concurrent Diff panels', () => {
await waitFor(() => { expect(harness.diffs).toHaveLength(1) })
release(harness, 'src/a.ts', false, '+a-line')
const panelA = container.querySelector('[data-panel="a"]')!
- await waitFor(() => { expect(within(panelA).getByText('+a-line')).toBeTruthy() })
+ await waitFor(() => { expect(within(panelA).getByText('a-line')).toBeTruthy() })
await harness.controller.refresh()
await waitFor(() => { expect(within(panelA).getByText('status exploded')).toBeTruthy() })
// The diff is not refetched: the repository snapshot never changed.
diff --git a/tests/dsh-diff.client.spec.ts b/tests/dsh-diff.client.spec.ts
new file mode 100644
index 0000000..d2d41a3
--- /dev/null
+++ b/tests/dsh-diff.client.spec.ts
@@ -0,0 +1,27 @@
+// @vitest-environment jsdom
+import { diffTotals } from '@deepseek-ai/dsh-client-ui-primitives'
+import { describe, expect, it } from 'vitest'
+import { dshDiffLabels, dshDiffSupportsContext } from '../src/compat/dsh-diff.ts'
+import { gitFileComparison } from '../src/client/git-diff-adapter.ts'
+import { en, zh } from '../src/client/locales.ts'
+
+describe('Official DSH diff compatibility', () => {
+ it('keeps unchanged context out of edit totals on the installed host', () => {
+ const patch = 'diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,3 +1,3 @@\n shared\n-old\n+new\n tail\n'
+ const comparison = gitFileComparison('a', patch, dshDiffSupportsContext)
+ expect(comparison.kind).toBe('text')
+ if (comparison.kind !== 'text') throw new Error('Expected file fragments')
+ expect(diffTotals(comparison.diffs)).toEqual({ added: 1, removed: 1 })
+ })
+
+ it.each([en, zh])('supplies localized legacy and current toolbar copy', dictionary => {
+ const labels = dshDiffLabels(key => dictionary[key])
+ expect(labels.copy).toBe(dictionary['diff.copy'])
+ expect(labels.codeLabel).toBe(dictionary['diff.code'])
+ expect(labels.wrapLabel).toBe(dictionary['diff.wrap'])
+ expect(labels.unwrapLabel).toBe(dictionary['diff.unwrap'])
+ expect(labels.expand(7)).toBe(dictionary['diff.expand'].replace('{count}', '7'))
+ expect(labels.expandAria(7)).toBe(dictionary['diff.expandAria'].replace('{count}', '7'))
+ expect(labels.files(2)).toBe(dictionary['diff.files'].replace('{count}', '2'))
+ })
+})
diff --git a/tests/git-diff-adapter.spec.ts b/tests/git-diff-adapter.spec.ts
new file mode 100644
index 0000000..4c0d01f
--- /dev/null
+++ b/tests/git-diff-adapter.spec.ts
@@ -0,0 +1,126 @@
+import { execFileSync } from 'node:child_process'
+import { mkdtempSync, writeFileSync } from 'node:fs'
+import { rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { gitFileComparison } from '../src/client/git-diff-adapter.ts'
+
+const roots: string[] = []
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
+})
+
+function repository(): { root: string; git: (...args: string[]) => string } {
+ const root = mkdtempSync(join(tmpdir(), 'git-official-diff-'))
+ roots.push(root)
+ const git = (...args: string[]): string => execFileSync('git', args, { cwd: root, encoding: 'utf8' })
+ git('init', '-b', 'main')
+ git('config', 'user.name', 'Diff Test')
+ git('config', 'user.email', 'diff@example.test')
+ git('config', 'core.autocrlf', 'false')
+ return { root, git }
+}
+
+describe('Git patch → official file changes', () => {
+ it('keeps staged and worktree content separate and preserves literal Unicode paths', () => {
+ const { root, git } = repository()
+ const path = '文件 "quoted" name.txt'
+ writeFileSync(join(root, path), 'shared\nbefore\ntail\n')
+ git('add', '--', path)
+ git('commit', '-m', 'initial')
+ writeFileSync(join(root, path), 'shared\nstaged\ntail\n')
+ git('add', '--', path)
+ writeFileSync(join(root, path), 'shared\nworktree\ntail\n')
+ const staged = gitFileComparison(path, git('diff', '--cached', '--no-color', '--', path))
+ const worktree = gitFileComparison(path, git('diff', '--no-color', '--', path))
+ expect(staged).toEqual({ kind: 'text', newlineChanged: false, diffs: [
+ { path, oldText: 'shared\nbefore\ntail\n', newText: 'shared\nstaged\ntail\n' },
+ ] })
+ expect(worktree).toEqual({ kind: 'text', newlineChanged: false, diffs: [
+ { path, oldText: 'shared\nstaged\ntail\n', newText: 'shared\nworktree\ntail\n' },
+ ] })
+ expect(gitFileComparison(path, git('diff', '--cached', '--no-color', '--', path), false)).toEqual({
+ kind: 'text', newlineChanged: false, diffs: [{ path, oldText: 'before\n', newText: 'staged\n' }],
+ })
+ })
+
+ it('converts staged additions, deletions and missing terminal newlines', () => {
+ const { root, git } = repository()
+ writeFileSync(join(root, 'deleted.txt'), 'removed\n')
+ git('add', '.')
+ git('commit', '-m', 'initial')
+ git('rm', 'deleted.txt')
+ writeFileSync(join(root, 'added.txt'), 'added without newline')
+ git('add', 'added.txt')
+ expect(gitFileComparison('deleted.txt', git('diff', '--cached', '--', 'deleted.txt'))).toEqual({
+ kind: 'text', newlineChanged: false, diffs: [{ path: 'deleted.txt', oldText: 'removed\n', newText: '' }],
+ })
+ expect(gitFileComparison('added.txt', git('diff', '--cached', '--', 'added.txt'))).toEqual({
+ kind: 'text', newlineChanged: true, diffs: [{ path: 'added.txt', oldText: '', newText: 'added without newline' }],
+ })
+ })
+
+ it.each([
+ ['both missing', 'before', 'after', false],
+ ['both present', 'before\n', 'after\n', false],
+ ['newline added', 'same', 'same\n', true],
+ ['newline removed', 'same\n', 'same', true],
+ ['shared unterminated context', 'before\ntail', 'after\ntail', false],
+ ] as const)('compares terminal newline states: %s', (_label, before, after, newlineChanged) => {
+ const { root, git } = repository()
+ const path = 'newline.txt'
+ writeFileSync(join(root, path), before)
+ git('add', '--', path)
+ git('commit', '-m', 'initial')
+ writeFileSync(join(root, path), after)
+ const patch = git('diff', '--no-color', '--', path)
+ expect(gitFileComparison(path, patch)).toEqual({
+ kind: 'text', newlineChanged, diffs: [{ path, oldText: before, newText: after }],
+ })
+ expect(gitFileComparison(path, patch, false)).toEqual({
+ kind: 'text', newlineChanged, diffs: [{
+ path,
+ oldText: before.replace('tail', ''),
+ newText: after.replace('tail', ''),
+ }],
+ })
+ })
+
+ it('recognizes binary and rename-only changes instead of showing a clean file', () => {
+ const { root, git } = repository()
+ writeFileSync(join(root, 'binary.dat'), Buffer.from([0, 1, 2]))
+ writeFileSync(join(root, 'before.txt'), 'same content\n')
+ git('add', '.')
+ git('commit', '-m', 'initial')
+ writeFileSync(join(root, 'binary.dat'), Buffer.from([0, 3, 4]))
+ git('mv', 'before.txt', 'after.txt')
+ expect(gitFileComparison('binary.dat', git('diff', '--', 'binary.dat'))).toEqual({ kind: 'binary' })
+ expect(gitFileComparison('after.txt', git('diff', '--cached'))).toEqual({ kind: 'metadata' })
+ expect(gitFileComparison('clean.txt', '')).toEqual({ kind: 'empty' })
+ })
+
+ it('keeps separated edit fragments and does not count shared context as edits on legacy hosts', () => {
+ const patch = 'diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,5 +1,5 @@\n top\n-old\n+new\n middle\n-last\n+end\n tail\n'
+ expect(gitFileComparison('a', patch, false)).toEqual({ kind: 'text', newlineChanged: false, diffs: [
+ { path: 'a', oldText: 'old\n', newText: 'new\n' },
+ { path: 'a', oldText: 'last\n', newText: 'end\n' },
+ ] })
+ })
+
+ it('reports newline-only changes even though the official primitive normalizes line terminators', () => {
+ const patch = 'diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-same\n\\ No newline at end of file\n+same\n'
+ expect(gitFileComparison('a', patch)).toEqual({ kind: 'text', newlineChanged: true, diffs: [
+ { path: 'a', oldText: 'same', newText: 'same\n' },
+ ] })
+ })
+
+ it.each([
+ '+tail only\n',
+ '@@ -1 +1 @@\n-old\n+new\n',
+ 'diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,2 +1,2 @@\n-old\n+new\n',
+ 'diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new',
+ ])('rejects incomplete or headerless output: %s', patch => {
+ expect(gitFileComparison('a', patch)).toEqual({ kind: 'invalid' })
+ })
+})
diff --git a/tsconfig.client.json b/tsconfig.client.json
index ece6ff4..e2da6d0 100644
--- a/tsconfig.client.json
+++ b/tsconfig.client.json
@@ -17,7 +17,6 @@
"src/client/GitBranchControl.tsx",
"src/client/GitDetailsHeaderActions.tsx",
"src/client/details/ChangesTab.tsx",
- "src/client/details/DiffTab.tsx",
"src/client/details/ChangeSection.tsx",
"src/client/details/ChangeRow.tsx",
"src/client/locales.ts",
diff --git a/tsconfig.standalone.client.json b/tsconfig.standalone.client.json
index a48a273..44f2b6d 100644
--- a/tsconfig.standalone.client.json
+++ b/tsconfig.standalone.client.json
@@ -38,7 +38,6 @@
"src/client/GitBranchControl.tsx",
"src/client/GitDetailsHeaderActions.tsx",
"src/client/details/ChangesTab.tsx",
- "src/client/details/DiffTab.tsx",
"src/client/details/ChangeSection.tsx",
"src/client/details/ChangeRow.tsx",
"src/client/locales.ts",