dsh-edit-diff 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/cordis.patch.yml +13 -0
- package/docs//345/216/273/351/207/215/345/244/215diff.webp +0 -0
- package/lib/client.js +424 -0
- package/lib/index.js +15 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-edit-diff contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# dsh·去重复 diff 展示插件
|
|
2
|
+
|
|
3
|
+
接管 DSH 浏览器的 `edit` / `write` 工具卡片,用近线性行级 diff 做真正的差异展示,消除内置 DiffBlock 把「未变化的相同行」在删除区红 `-` 行和新增区绿 `+` 行各渲染一遍的重复。
|
|
4
|
+
|
|
5
|
+
纯浏览器端 client 插件,单击即换新渲染,不碰 DSH 源码。
|
|
6
|
+
|
|
7
|
+
## 引言
|
|
8
|
+
|
|
9
|
+
DeepSeek Harness 内置的 DiffBlock 展示工具改动时有个毛病:它拿到旧文件全文和新文件全文,就把旧的全染成红色 `-` 行、新的全染成绿色 `+` 行——**完全不做行级匹配**。
|
|
10
|
+
|
|
11
|
+
结果呢?一次只改了一小段的 edit,一堆根本没变的注释、空行、样板代码,在红绿两区各出现一遍,仿佛同一句话被说了两次。大文件改起来更是重复加刷屏,想扫一眼真正改了什么,得靠肉眼在一大摊重复里找不同。
|
|
12
|
+
|
|
13
|
+
这哪行。改个文件就该像看 git diff 一样:**只亮出真正的差异,相同行不该凑热闹**。
|
|
14
|
+
|
|
15
|
+
于是这个插件接管了 `edit` 和 `write` 两个工具卡片,用近线性算法算出真实差异,相同行一行不渲染,替换行还把改动到的具体字符用下划线标出来。一眼就知道到底改了什么。
|
|
16
|
+
|
|
17
|
+
## 功能
|
|
18
|
+
|
|
19
|
+
- **仅差异行**:完全相同行直接不渲染,只显示真实变化的删除行(红 `-`)与新增行(绿 `+`),消除重复。
|
|
20
|
+
- **行内字符高亮**:删/增行数相等的替换对,再做字符级 diff,用下划线在行内标出真正改动的字符,而不是整行糊在一起。
|
|
21
|
+
- **近线性算法**:先做公共前缀/后缀收缩(线性),仅对中间小差异带运行 Myers diff,复杂度与差异量成正比而非平方,超大文件也流畅。
|
|
22
|
+
- **默认收起**:卡片默认一行摘要,点开才显示 diff 体,不刷屏。
|
|
23
|
+
- **长 diff 二次折叠**:展开后若差异行超 14 行,折成头 +「⋯ 展开其余 N 行差异」+ 尾,点开才全量。
|
|
24
|
+
- **对齐原生观感**:字号、圆角、配色复用内置 DiffBlock/ToolRow 同一批 `--dsw-*` 主题变量,视觉上就是原生卡片。
|
|
25
|
+
- **复制与统计**:复制(只含差异行,`- `/`+ ` 前缀);底部 `└ +A -R · N file(s)` 变更统计。
|
|
26
|
+
- **单文件与多文件**:`edit` / `write` 双双接管,覆盖新建(只显示绿色新增)、纯删、多 hunk 等场景。
|
|
27
|
+
|
|
28
|
+
## 它长什么样
|
|
29
|
+
|
|
30
|
+
`edit` 只改动一小段后的实际效果:
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
- **顶部行**:`✎ Edit · dsh-cache-billing\src\client.ts`,右侧 chevron 可点开收起。
|
|
35
|
+
- **diff 体**:只显示真实变化的删除行(红色 `-`)与新增行(绿色 `+`),完全相同行不重复渲染。
|
|
36
|
+
- **行内高亮**:把 `峰谷价标注` 改成 `峰谷价标注附模型名`,改动到的字符 `附模型名` 以下划线加粗标出;右下角为复制按钮。
|
|
37
|
+
- **底部统计**:`└ +1 -1 · 1 file`,列明净增删行数与文件数。
|
|
38
|
+
|
|
39
|
+
## 安装
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
dsh plugin --profile web add github:better-er/dsh-edit-diff
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
或安装 npm 发布的版本:`dsh plugin --profile web add dsh-edit-diff`。
|
|
46
|
+
|
|
47
|
+
一条命令装完即生效,自动挂载,重启 DSH web 后启用,无需手工编辑任何组合文件。
|
|
48
|
+
|
|
49
|
+
## 卸载
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
dsh plugin --profile web remove dsh-edit-diff
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
彻底移除,重启 DSH web 后不再加载。
|
|
56
|
+
|
|
57
|
+
## 要求与开发
|
|
58
|
+
|
|
59
|
+
- 是**标准形态的 dsh client 插件**,声明 `dsh.client`,导出 `./client`。
|
|
60
|
+
- 同时声明了 `dsh.bundle`,因此也是一个**自挂载的 bundle 层插件**:用 `dsh plugin --profile <name> add` 从 GitHub 安装后,会被自动识别为 profile layer 并挂载,无需手工写组合 entry。
|
|
61
|
+
- 纯浏览器半身,无 host 行为;`lib/index.js` 是无操作的 no-op 主机插件(标准双面包约定)。
|
|
62
|
+
- **UI 挂载点**:接管 keyed 槽位 `tool.call.toolview` 的 `edit` 与 `write` 两个 key。
|
|
63
|
+
- **遮蔽而非冲突**:注册时显式传 `priority: -1`(低于内置 `file-mutation-toolview` 的默认 0),用更低优先级遮蔽默认渲染,而不是在同一优先级上 clash。
|
|
64
|
+
- **无构建**:`lib/client.js` 是按 DSH client bundle 产出的注册式模块,源码即产物,改完即用。
|
|
65
|
+
- **硬性约束**:`lib/client.js` 的 `factory` 必须以 `return module.exports` 结尾,否则模块导出为 `undefined`,DSH 启动即 fail-loud。
|
|
66
|
+
- 纯 JavaScript 单文件,不引入任何额外 npm 依赖(diff 算法就地内联)。
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
[MIT](./LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# dsh-edit-diff bundle patch: mount the edit/write diff-card plugin.
|
|
2
|
+
#
|
|
3
|
+
# Applied when this bundle appears in a profile's dsh.profile.bundles list.
|
|
4
|
+
# The node half (package root) is a no-op Cordis plugin; the browser half
|
|
5
|
+
# (dsh.client) is discovered automatically through the package's
|
|
6
|
+
# dsh.client declaration + exports["./client"]. It registers two keyed
|
|
7
|
+
# `tool.call.toolview` occupants (`edit`, `write`) that replace the shipped
|
|
8
|
+
# DiffBlock render, eliminating same-line duplication with a near-linear
|
|
9
|
+
# line diff + intra-line character highlight.
|
|
10
|
+
|
|
11
|
+
- insert:
|
|
12
|
+
- id: dsh-edit-diff
|
|
13
|
+
name: dsh-edit-diff
|
|
Binary file
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// Browser half of dsh-edit-diff, built as a standard dsh client bundle.
|
|
2
|
+
//
|
|
3
|
+
// Artifact shape follows the deployment's own client bundles: register a module
|
|
4
|
+
// table entry through window.__ModuleLoader__.load({ id, factory }). The factory
|
|
5
|
+
// resolves its externals through the loader's injected require, which for
|
|
6
|
+
// `react` returns the platform module-table instance kept alive by the shell
|
|
7
|
+
// seed. The default export is the Cordis plugin object the browser kernel
|
|
8
|
+
// adopts and mounts.
|
|
9
|
+
//
|
|
10
|
+
// Feature: replace the shipped `edit`/`write` tool-call cards. The built-in
|
|
11
|
+
// DiffBlock renders the old file's every line as a removed row and the new
|
|
12
|
+
// file's every line as an added row with NO line matching, so unchanged lines
|
|
13
|
+
// are duplicated across the `-` and `+` blocks. This plugin takes over the
|
|
14
|
+
// keyed `tool.call.toolview` slot for both tool names and renders a near-linear
|
|
15
|
+
// line-level diff (common-prefix/suffix shrink + Myers on the trimmed core),
|
|
16
|
+
// skipping identical lines entirely and highlighting real intra-line character
|
|
17
|
+
// changes. Visuals mirror the shipped DiffBlock/ToolRow so the card reads as
|
|
18
|
+
// native. Cards collapse to a single summary row by default; opening shows the
|
|
19
|
+
// body, and a very long diff folds its middle behind a "⋯ 展开其余 N 行差异"
|
|
20
|
+
// button instead of flooding the flow.
|
|
21
|
+
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: 'dsh-edit-diff',
|
|
24
|
+
factory: (require) => {
|
|
25
|
+
const module = { exports: {} }
|
|
26
|
+
const exports = module.exports
|
|
27
|
+
|
|
28
|
+
const React = require('react')
|
|
29
|
+
const DOC = typeof document !== 'undefined' ? document : null
|
|
30
|
+
|
|
31
|
+
// ==================== 近线性 diff 核心 ====================
|
|
32
|
+
|
|
33
|
+
/** 行级 Myers:前缀/后缀收缩后仅对中间核心区跑 diff,返回 op 数组。 */
|
|
34
|
+
function myersDiff(aList, bList) {
|
|
35
|
+
const N = aList.length, M = bList.length
|
|
36
|
+
const max = N + M
|
|
37
|
+
let prev = { 1: 0 }
|
|
38
|
+
const trace = []
|
|
39
|
+
let dMax = 0, found = false
|
|
40
|
+
for (let d = 0; d <= max; d++) {
|
|
41
|
+
trace.push({ ...prev })
|
|
42
|
+
const cur = {}
|
|
43
|
+
for (let k = -d; k <= d; k += 2) {
|
|
44
|
+
let x
|
|
45
|
+
if (k === -d || (k !== d && (prev[k - 1] ?? -Infinity) < (prev[k + 1] ?? -Infinity))) x = (prev[k + 1] ?? 0)
|
|
46
|
+
else x = (prev[k - 1] ?? -1) + 1
|
|
47
|
+
let y = x - k
|
|
48
|
+
while (x < N && y < M && aList[x] === bList[y]) { x++; y++ }
|
|
49
|
+
cur[k] = x
|
|
50
|
+
if (x >= N && y >= M) { dMax = d; found = true; break }
|
|
51
|
+
}
|
|
52
|
+
prev = cur
|
|
53
|
+
if (found) break
|
|
54
|
+
}
|
|
55
|
+
if (!found) {
|
|
56
|
+
const ops = []
|
|
57
|
+
for (let i = 0; i < N; i++) ops.push({ type: 'del', a: i, b: -1 })
|
|
58
|
+
for (let j = 0; j < M; j++) ops.push({ type: 'add', a: -1, b: j })
|
|
59
|
+
return ops
|
|
60
|
+
}
|
|
61
|
+
const ops = []
|
|
62
|
+
let x = N, y = M
|
|
63
|
+
for (let d = dMax; d > 0; d--) {
|
|
64
|
+
const t = trace[d]
|
|
65
|
+
const k = x - y
|
|
66
|
+
let prevK
|
|
67
|
+
if (k === -d || (k !== d && (t[k - 1] ?? -Infinity) < (t[k + 1] ?? -Infinity))) prevK = k + 1
|
|
68
|
+
else prevK = k - 1
|
|
69
|
+
const prevX = t[prevK] ?? 0
|
|
70
|
+
const prevY = prevX - prevK
|
|
71
|
+
while (x > prevX && y > prevY) { ops.push({ type: 'same', a: x - 1, b: y - 1 }); x--; y-- }
|
|
72
|
+
if (x === prevX) { ops.push({ type: 'add', a: -1, b: y - 1 }); y-- }
|
|
73
|
+
else { ops.push({ type: 'del', a: x - 1, b: -1 }); x-- }
|
|
74
|
+
}
|
|
75
|
+
while (x > 0 && y > 0) { ops.push({ type: 'same', a: x - 1, b: y - 1 }); x--; y-- }
|
|
76
|
+
ops.reverse()
|
|
77
|
+
return ops
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 行级 diff:线性前缀/后缀收缩,仅对中间核心区跑 Myers。 */
|
|
81
|
+
function lineDiff(aLines, bLines) {
|
|
82
|
+
let p = 0
|
|
83
|
+
const minLen = Math.min(aLines.length, bLines.length)
|
|
84
|
+
while (p < minLen && aLines[p] === bLines[p]) p++
|
|
85
|
+
let s = 0
|
|
86
|
+
const al = aLines.length, bl = bLines.length
|
|
87
|
+
while (s < minLen - p && aLines[al - 1 - s] === bLines[bl - 1 - s]) s++
|
|
88
|
+
const ops = []
|
|
89
|
+
for (let i = 0; i < p; i++) ops.push({ type: 'same', a: i, b: i })
|
|
90
|
+
const aMid = aLines.slice(p, al - s)
|
|
91
|
+
const bMid = bLines.slice(p, bl - s)
|
|
92
|
+
for (const op of myersDiff(aMid, bMid)) {
|
|
93
|
+
ops.push({ type: op.type, a: op.a === -1 ? -1 : op.a + p, b: op.b === -1 ? -1 : op.b + p })
|
|
94
|
+
}
|
|
95
|
+
for (let i = 0; i < s; i++) ops.push({ type: 'same', a: al - s + i, b: bl - s + i })
|
|
96
|
+
return ops
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 把字符级 op 汇总成高亮区间([start, end))。 */
|
|
100
|
+
function charRanges(ops) {
|
|
101
|
+
const delR = [], addR = []
|
|
102
|
+
for (const op of ops) {
|
|
103
|
+
if (op.type === 'del') delR.push(op.a)
|
|
104
|
+
else if (op.type === 'add') addR.push(op.b)
|
|
105
|
+
}
|
|
106
|
+
const mk = (idx) => {
|
|
107
|
+
if (idx.length === 0) return []
|
|
108
|
+
idx.sort((a, b) => a - b)
|
|
109
|
+
const out = []; let s = idx[0], e = idx[0]
|
|
110
|
+
for (let i = 1; i < idx.length; i++) {
|
|
111
|
+
if (idx[i] === e + 1) e = idx[i]
|
|
112
|
+
else { out.push([s, e + 1]); s = idx[i]; e = idx[i] }
|
|
113
|
+
}
|
|
114
|
+
out.push([s, e + 1])
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
return { del: mk(delR), add: mk(addR) }
|
|
118
|
+
}
|
|
119
|
+
function lineHighlight(oldStr, newStr) {
|
|
120
|
+
return charRanges(myersDiff([...oldStr], [...newStr]))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function contentLines(text) {
|
|
124
|
+
if (text === '') return []
|
|
125
|
+
return (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 从 ops 构建渲染行,仅含真实差异;替换对(删/增行数相等)做行内字符高亮。 */
|
|
129
|
+
function buildDiffLines(oldLines, newLines) {
|
|
130
|
+
const ops = lineDiff(oldLines, newLines)
|
|
131
|
+
const out = []
|
|
132
|
+
let i = 0
|
|
133
|
+
while (i < ops.length) {
|
|
134
|
+
if (ops[i].type === 'same') { i++; continue }
|
|
135
|
+
const dStart = i
|
|
136
|
+
while (i < ops.length && ops[i].type === 'del') i++
|
|
137
|
+
const addStart = i
|
|
138
|
+
while (i < ops.length && ops[i].type === 'add') i++
|
|
139
|
+
const delOps = ops.slice(dStart, addStart)
|
|
140
|
+
const addOps = ops.slice(addStart, i)
|
|
141
|
+
if (delOps.length === addOps.length && delOps.length > 0) {
|
|
142
|
+
for (let k = 0; k < delOps.length; k++) {
|
|
143
|
+
const hl = lineHighlight(oldLines[delOps[k].a], newLines[addOps[k].b])
|
|
144
|
+
out.push({ kind: 'del', text: oldLines[delOps[k].a], hl: hl.del })
|
|
145
|
+
out.push({ kind: 'add', text: newLines[addOps[k].b], hl: hl.add })
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
for (const op of delOps) out.push({ kind: 'del', text: oldLines[op.a], hl: [] })
|
|
149
|
+
for (const op of addOps) out.push({ kind: 'add', text: newLines[op.b], hl: [] })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ==================== 卡片模型 ====================
|
|
156
|
+
|
|
157
|
+
function narrowDiffs(diffs) {
|
|
158
|
+
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
|
159
|
+
const out = []
|
|
160
|
+
for (const h of diffs) {
|
|
161
|
+
if (typeof h !== 'object' || h === null) return null
|
|
162
|
+
const path = h.path, oldText = h.oldText, newText = h.newText
|
|
163
|
+
if (typeof path !== 'string') return null
|
|
164
|
+
if (oldText !== null && typeof oldText !== 'string') return null
|
|
165
|
+
if (typeof newText !== 'string') return null
|
|
166
|
+
out.push({ path, oldText, newText })
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractDiffs(block) {
|
|
172
|
+
if (!('kind' in block)) {
|
|
173
|
+
const call = block.callView && block.callView.card === 'diff' ? block.callView : null
|
|
174
|
+
return call ? narrowDiffs(call.diffs) : null
|
|
175
|
+
}
|
|
176
|
+
const result = block.resultView && block.resultView.card === 'diff' ? block.resultView : null
|
|
177
|
+
return result ? narrowDiffs(result.diffs) : null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resultText(block) {
|
|
181
|
+
if (!('kind' in block)) return ''
|
|
182
|
+
const parts = []
|
|
183
|
+
for (const b of block.content) {
|
|
184
|
+
parts.push(b.type === 'text' ? b.text : JSON.stringify(b))
|
|
185
|
+
}
|
|
186
|
+
if (parts.length === 0 && block.error !== undefined) parts.push(block.error.name + ': ' + block.error.code)
|
|
187
|
+
return parts.join('\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function firstLine(t) { const n = t.indexOf('\n'); return n === -1 ? t : t.slice(0, n) }
|
|
191
|
+
|
|
192
|
+
function buildCardModel(block, toolName, cwd) {
|
|
193
|
+
const done = 'kind' in block
|
|
194
|
+
const argsRaw = (done ? (block.call ? block.call.argsRaw : '') : (block.argsRaw ?? ''))
|
|
195
|
+
const state = !done ? 'running' : (block.error && block.error.code === 'interrupted') ? 'stopped' : block.isError ? 'error' : 'ok'
|
|
196
|
+
let filePath
|
|
197
|
+
let summary = ''
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(argsRaw)
|
|
200
|
+
if (parsed && typeof parsed === 'object') {
|
|
201
|
+
const p = parsed.file_path ?? parsed.path
|
|
202
|
+
if (typeof p === 'string' && p !== '') filePath = firstLine(p)
|
|
203
|
+
const vs = Object.values(parsed).filter(v => typeof v === 'string' && v !== '')
|
|
204
|
+
summary = vs.length > 0 ? firstLine(vs[0]) : argsRaw
|
|
205
|
+
} else summary = firstLine(argsRaw)
|
|
206
|
+
} catch { summary = firstLine(argsRaw) }
|
|
207
|
+
if (filePath !== undefined) summary = filePath
|
|
208
|
+
if (filePath !== undefined && cwd) {
|
|
209
|
+
const root = cwd.replace(/[/\\]+$/, '')
|
|
210
|
+
if (filePath.startsWith(root + '/') || filePath.startsWith(root + '\\')) summary = filePath.slice(root.length + 1)
|
|
211
|
+
}
|
|
212
|
+
const title = toolName === 'edit' ? 'Edit' : toolName === 'write' ? 'Write' : (toolName || '')
|
|
213
|
+
const diffs = extractDiffs(block)
|
|
214
|
+
const output = done ? (resultText(block) || null) : null
|
|
215
|
+
return { title, summary, filePath, state, diffs, output }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ==================== 组件 ====================
|
|
219
|
+
|
|
220
|
+
function DiffCard(props) {
|
|
221
|
+
const block = props.block, cwd = props.cwd, openFile = props.openFile, inspect = props.inspect, toolName = props.toolName
|
|
222
|
+
// open:点行头展开整个卡片;revealed:展开后点「展开其余」显示全部行
|
|
223
|
+
const [open, setOpen] = React.useState(false)
|
|
224
|
+
const [revealed, setRevealed] = React.useState(false)
|
|
225
|
+
const [copied, setCopied] = React.useState(false)
|
|
226
|
+
const m = buildCardModel(block, toolName, cwd)
|
|
227
|
+
const fileLines = m.diffs === null ? null : m.diffs.map(d => ({ path: d.path, lines: buildDiffLines(contentLines(d.oldText ?? ''), contentLines(d.newText)) }))
|
|
228
|
+
const hasAny = fileLines !== null && fileLines.some(f => f.lines.length > 0)
|
|
229
|
+
const displayLines = hasAny ? fileLines : null
|
|
230
|
+
const addCount = displayLines ? displayLines.reduce((s, f) => s + f.lines.filter(l => l.kind === 'add').length, 0) : 0
|
|
231
|
+
const delCount = displayLines ? displayLines.reduce((s, f) => s + f.lines.filter(l => l.kind === 'del').length, 0) : 0
|
|
232
|
+
const files = m.diffs ? m.diffs.length : 0
|
|
233
|
+
const hasContent = displayLines !== null || m.output !== null
|
|
234
|
+
const expandable = hasContent
|
|
235
|
+
const status = m.state === 'running' ? '运行中' : m.state === 'error' ? '失败' : m.state === 'stopped' ? '已中断' : null
|
|
236
|
+
const failureSummary = m.state === 'error' && m.output !== null ? firstLine(m.output) : null
|
|
237
|
+
const summaryText = failureSummary !== null ? failureSummary : (m.summary || m.title)
|
|
238
|
+
const fileLink = m.filePath !== undefined && openFile !== undefined && failureSummary === null
|
|
239
|
+
const MAX = 14
|
|
240
|
+
let flatLines = null
|
|
241
|
+
let hidden = 0
|
|
242
|
+
if (displayLines && open) {
|
|
243
|
+
flatLines = []
|
|
244
|
+
for (const f of displayLines) {
|
|
245
|
+
flatLines.push({ kind: 'path', text: f.path, hl: [] })
|
|
246
|
+
flatLines.push(...f.lines)
|
|
247
|
+
}
|
|
248
|
+
hidden = flatLines.length - MAX
|
|
249
|
+
if (hidden < 0) hidden = 0
|
|
250
|
+
if (hidden > 0 && !revealed) {
|
|
251
|
+
const headN = Math.ceil(MAX / 2)
|
|
252
|
+
flatLines = flatLines.slice(0, headN)
|
|
253
|
+
.concat({ kind: 'mid', text: '⋯ 展开其余 ' + hidden + ' 行差异', hl: [] })
|
|
254
|
+
.concat(flatLines.slice(flatLines.length - (MAX - headN)))
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const onToggle = () => { setOpen(v => !v); setRevealed(false) }
|
|
258
|
+
const onOpenFile = (e) => { e.stopPropagation(); if (m.filePath !== undefined) openFile(m.filePath) }
|
|
259
|
+
const onCopy = () => {
|
|
260
|
+
if (copied || flatLines === null || flatLines.length === 0) return
|
|
261
|
+
const txt = flatLines.map(l => l.kind === 'del' ? '- ' + l.text : l.kind === 'add' ? '+ ' + l.text : l.text).join('\n')
|
|
262
|
+
copyTextIntoClipboard(txt)
|
|
263
|
+
setCopied(true)
|
|
264
|
+
timerRef.timeout(() => setCopied(false), 1200)
|
|
265
|
+
}
|
|
266
|
+
const rowType = m.title === 'Write' ? 'write' : m.title === 'Edit' ? 'edit' : 'file'
|
|
267
|
+
return React.createElement('div', { className: 'edd-root', 'data-variant': rowType, 'data-state': m.state },
|
|
268
|
+
status !== null && React.createElement('span', { className: 'edd-vh' }, status),
|
|
269
|
+
React.createElement('div', { className: 'edd-row', onClick: onToggle, role: 'button', tabIndex: expandable ? 0 : -1, 'aria-expanded': open },
|
|
270
|
+
React.createElement('span', { className: 'edd-icon' }, '✎'),
|
|
271
|
+
React.createElement('span', { className: 'edd-title' }, m.title),
|
|
272
|
+
summaryText !== '' && React.createElement(React.Fragment, null,
|
|
273
|
+
React.createElement('span', { className: 'edd-sep', 'aria-hidden': true }),
|
|
274
|
+
fileLink
|
|
275
|
+
? React.createElement('button', { type: 'button', className: 'edd-filelink', onClick: onOpenFile }, summaryText)
|
|
276
|
+
: React.createElement('span', { className: 'edd-summary' + (failureSummary !== null ? ' edd-err' : '') }, summaryText)
|
|
277
|
+
),
|
|
278
|
+
expandable && React.createElement('span', { className: 'edd-chev' + (open ? ' edd-chev-open' : '') }, '›')
|
|
279
|
+
),
|
|
280
|
+
React.createElement('div', { className: 'edd-bodyWrap' },
|
|
281
|
+
flatLines && React.createElement('div', { className: 'edd-body' },
|
|
282
|
+
React.createElement('button', { type: 'button', className: 'edd-copy', onClick: onCopy }, copied ? '复制成功' : '复制'),
|
|
283
|
+
flatLines.map((l, idx) => {
|
|
284
|
+
if (l.kind === 'mid') return React.createElement('button', { key: idx, type: 'button', className: 'edd-expand', onClick: () => setRevealed(true) }, l.text)
|
|
285
|
+
const prefix = l.kind === 'del' ? '-' : l.kind === 'add' ? '+' : ''
|
|
286
|
+
const kids = []
|
|
287
|
+
if (prefix) kids.push(React.createElement('span', { key: 'p', className: 'edd-marker' }, prefix))
|
|
288
|
+
if (l.kind === 'del' || l.kind === 'add') {
|
|
289
|
+
if (l.text.length === 0) {
|
|
290
|
+
kids.push(React.createElement('span', { key: 'e', className: 'edd-empty' }, '␣'))
|
|
291
|
+
} else if (l.hl.length > 0) {
|
|
292
|
+
let last = 0
|
|
293
|
+
for (const r of l.hl) {
|
|
294
|
+
if (r[0] > last) kids.push(React.createElement('span', { key: 't' + last, className: 'edd-plain' }, l.text.slice(last, r[0])))
|
|
295
|
+
kids.push(React.createElement('span', { key: 'h' + r[0], className: 'edd-hl' }, l.text.slice(r[0], r[1])))
|
|
296
|
+
last = r[1]
|
|
297
|
+
}
|
|
298
|
+
if (last < l.text.length) kids.push(React.createElement('span', { key: 't' + last, className: 'edd-plain' }, l.text.slice(last)))
|
|
299
|
+
} else {
|
|
300
|
+
kids.push(React.createElement('span', { key: 't', className: 'edd-plain' }, l.text))
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
kids.push(React.createElement('span', { key: 't', className: 'edd-plain' }, l.text))
|
|
304
|
+
}
|
|
305
|
+
return React.createElement('div', { key: idx, className: 'edd-line ' + (l.kind === 'del' ? 'edd-del' : l.kind === 'add' ? 'edd-add' : l.kind === 'path' ? 'edd-path' : 'edd-plain-line') }, kids)
|
|
306
|
+
})
|
|
307
|
+
),
|
|
308
|
+
open && m.output !== null && flatLines === null && React.createElement('div', { className: 'edd-io' },
|
|
309
|
+
React.createElement('span', { className: 'edd-iolabel' }, 'OUT'),
|
|
310
|
+
React.createElement('span', { className: 'edd-iotext' }, m.output)
|
|
311
|
+
),
|
|
312
|
+
open && ((flatLines && flatLines.length > 0) || m.output !== null) && React.createElement('div', { className: 'edd-foot' }, '└ +' + addCount + ' -' + delCount + ' · ' + files + ' file' + (files === 1 ? '' : 's')),
|
|
313
|
+
open && React.createElement('div', { className: 'edd-actions' },
|
|
314
|
+
inspect !== undefined && ((flatLines && flatLines.length > 0) || m.output !== null) && React.createElement('button', { type: 'button', className: 'edd-inspect', onClick: inspect }, 'Inspect')
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ==================== 剪贴板与样式 ====================
|
|
321
|
+
|
|
322
|
+
let timerRef = { timeout: null }
|
|
323
|
+
|
|
324
|
+
function copyTextIntoClipboard(txt) {
|
|
325
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
|
326
|
+
navigator.clipboard.writeText(txt).catch(() => {})
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (!DOC) return
|
|
330
|
+
const ta = DOC.createElement('textarea')
|
|
331
|
+
ta.value = txt
|
|
332
|
+
ta.style.position = 'fixed'
|
|
333
|
+
ta.style.opacity = '0'
|
|
334
|
+
DOC.body.appendChild(ta)
|
|
335
|
+
ta.select()
|
|
336
|
+
try { DOC.execCommand('copy') } catch {}
|
|
337
|
+
DOC.body.removeChild(ta)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 视觉对齐内置 DiffBlock / ToolRow:复用同一批主题 token,字号/圆角/背景保持一致。
|
|
341
|
+
const CSS = [
|
|
342
|
+
'.edd-root{box-sizing:border-box;color:var(--dsw-alias-label-primary);}',
|
|
343
|
+
'.edd-vh{position:absolute;width:1px;height:1px;clip:rect(0 0 0 0);overflow:hidden;white-space:nowrap;}',
|
|
344
|
+
'.edd-row{display:flex;align-items:center;gap:6px;cursor:pointer;min-height:24px;padding:4px 0;font-size:14px;line-height:24px;}',
|
|
345
|
+
'.edd-icon{color:var(--dsw-alias-label-secondary);flex:none;font-style:normal;}',
|
|
346
|
+
'.edd-title{font-weight:400;flex:none;color:var(--dsw-alias-label-primary);}',
|
|
347
|
+
'.edd-sep{flex:none;width:2px;height:2px;border-radius:1px;margin:0 8px;background:var(--dsw-alias-label-caption);}',
|
|
348
|
+
'.edd-summary{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:24px;color:var(--dsw-alias-label-tertiary);text-align:left;}',
|
|
349
|
+
'.edd-err{color:var(--dsw-alias-state-error-primary);}',
|
|
350
|
+
'.edd-filelink{flex:1 1 auto;min-width:0;margin:0;padding:0;border:none;background:none;font-family:inherit;font-size:14px;line-height:24px;color:var(--dsw-alias-label-secondary);text-decoration:underline;text-decoration-color:var(--dsw-alias-label-quaternary);text-underline-offset:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;cursor:pointer;}',
|
|
351
|
+
'.edd-filelink:hover{color:var(--dsw-alias-label-primary);text-decoration-color:currentColor;}',
|
|
352
|
+
'.edd-chev{margin-left:auto;color:var(--dsw-alias-label-secondary);flex:none;transition:transform .12s;display:inline-block;}',
|
|
353
|
+
'.edd-chev-open{transform:rotate(90deg);}',
|
|
354
|
+
'.edd-bodyWrap{overflow:hidden;}',
|
|
355
|
+
'.edd-body{position:relative;margin:8px 0;background:var(--dsw-alias-markdown-code-block);border-radius:12px;font:var(--dsw-font-markdown-code-block);overflow-x:auto;overflow-y:hidden;padding:12px 14px 12px 14px;box-sizing:border-box;}',
|
|
356
|
+
'.edd-copy{position:absolute;top:8px;right:12px;z-index:1;background-color:transparent;border:none;padding:0;margin:0;color:var(--dsw-alias-label-secondary);cursor:pointer;font:var(--dsw-font-xs-13);}',
|
|
357
|
+
'.edd-copy:hover{color:var(--dsw-alias-label-primary);}',
|
|
358
|
+
'.edd-path{color:var(--dsw-alias-label-primary);font-weight:600;padding-right:56px;}',
|
|
359
|
+
'.edd-line{min-height:22px;white-space:pre;}',
|
|
360
|
+
'.edd-del{color:var(--dsw-alias-state-error-primary);}',
|
|
361
|
+
'.edd-add{color:var(--dsw-alias-state-success-primary);}',
|
|
362
|
+
'.edd-marker{color:inherit;opacity:1;user-select:none;}',
|
|
363
|
+
'.edd-plain{white-space:pre;}',
|
|
364
|
+
'.edd-empty{opacity:.5;font-style:italic;}',
|
|
365
|
+
'.edd-hl{text-decoration:underline;text-decoration-thickness:2px;text-decoration-color:currentColor;font-weight:600;}',
|
|
366
|
+
'.edd-expand{display:inline-block;margin:2px 0;padding:1px 0;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font:var(--dsw-font-markdown-code-block);text-align:left;}',
|
|
367
|
+
'.edd-expand:hover{color:var(--dsw-alias-label-secondary);}',
|
|
368
|
+
'.edd-foot{padding:0 14px 12px;font:var(--dsw-font-markdown-code-block);color:var(--dsw-alias-label-tertiary);}',
|
|
369
|
+
'.edd-io{padding:8px 14px;}',
|
|
370
|
+
'.edd-iolabel{margin-right:8px;color:var(--dsw-alias-label-caption);font-weight:600;}',
|
|
371
|
+
'.edd-iotext{color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;}',
|
|
372
|
+
'.edd-actions{padding:4px 0 2px 4px;}',
|
|
373
|
+
'.edd-inspect{display:inline-flex;align-items:center;gap:4px;margin:0;padding:2px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;cursor:pointer;opacity:0;transition:opacity .12s ease;}',
|
|
374
|
+
'.edd-root:hover .edd-inspect,.edd-inspect:focus-visible{opacity:1;}',
|
|
375
|
+
'.edd-inspect:hover{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-label-primary);}',
|
|
376
|
+
].join('')
|
|
377
|
+
|
|
378
|
+
function ensureStyle() {
|
|
379
|
+
if (!DOC || styleTag) return
|
|
380
|
+
const tag = DOC.createElement('style')
|
|
381
|
+
tag.dataset.plugin = 'dsh-edit-diff'
|
|
382
|
+
tag.textContent = CSS
|
|
383
|
+
DOC.head.appendChild(tag)
|
|
384
|
+
styleTag = tag
|
|
385
|
+
}
|
|
386
|
+
let styleTag = null
|
|
387
|
+
|
|
388
|
+
// ==================== 插件 ====================
|
|
389
|
+
|
|
390
|
+
const plugin = {
|
|
391
|
+
name: 'dsh-edit-diff',
|
|
392
|
+
inject: ['slots', 'timer'],
|
|
393
|
+
apply(ctx) {
|
|
394
|
+
const slots = ctx.get && ctx.get('slots')
|
|
395
|
+
if (!slots) return
|
|
396
|
+
const timer = ctx.get && ctx.get('timer')
|
|
397
|
+
if (timer && timer.timeout) timerRef.timeout = (fn, ms) => timer.timeout(fn, ms)
|
|
398
|
+
|
|
399
|
+
ctx.effect(function () {
|
|
400
|
+
ensureStyle()
|
|
401
|
+
return function () {
|
|
402
|
+
if (styleTag && styleTag.parentNode) styleTag.parentNode.removeChild(styleTag)
|
|
403
|
+
styleTag = null
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
slots.inject('tool.call.toolview', () => {
|
|
408
|
+
// priority 要低于默认 0,遮蔽 file-mutation-toolview 的 edit/write(最低者渲染);
|
|
409
|
+
// 若也传 0 会在同一 key 上 clash,而非替换。
|
|
410
|
+
slots.register({ name: 'tool.call.toolview', key: 'edit', priority: -1 }, DiffCard)
|
|
411
|
+
slots.register({ name: 'tool.call.toolview', key: 'write', priority: -1 }, DiffCard)
|
|
412
|
+
})
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
exports.default = plugin
|
|
417
|
+
exports.name = plugin.name
|
|
418
|
+
exports.inject = plugin.inject
|
|
419
|
+
exports.apply = plugin.apply
|
|
420
|
+
|
|
421
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
422
|
+
return module.exports
|
|
423
|
+
},
|
|
424
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node half: a no-op Cordis plugin. All behavior lives in the browser half
|
|
3
|
+
* (lib/client.js), which the web client module system discovers through the
|
|
4
|
+
* package.json `dsh.client` declaration. This half exists so the plugin root
|
|
5
|
+
* is a complete dual-face package and shows up as one entry in the host
|
|
6
|
+
* Loader — and, via `dsh.bundle`, as a self-mounting bundle layer so that a
|
|
7
|
+
* single `dsh plugin --profile web add …` fully activates it.
|
|
8
|
+
*/
|
|
9
|
+
/** Plugin name (= the config entry id). */
|
|
10
|
+
const name = "dsh-edit-diff";
|
|
11
|
+
/** No host-side services are used. */
|
|
12
|
+
const inject = [];
|
|
13
|
+
/** No host-side behavior for this browser-surface plugin. */
|
|
14
|
+
function apply() {}
|
|
15
|
+
export { apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-edit-diff",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "质量 diff 展示:接管 edit/write 工具卡片,用近线性行级 diff + 行内字符高亮,消除相同内容在删除/新增两区的重复(标准可安装 dsh 客户端插件)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"dsh": {
|
|
14
|
+
"bundle": {
|
|
15
|
+
"patch": "./cordis.patch.yml"
|
|
16
|
+
},
|
|
17
|
+
"client": {
|
|
18
|
+
"platform": "web",
|
|
19
|
+
"inject": [
|
|
20
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"lib/index.js",
|
|
26
|
+
"lib/client.js",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"docs/"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|