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",