@caozeal/dsh-next-git 0.1.4 → 0.1.5
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/README.md +24 -5
- package/lib/client.js +68 -19
- package/package.json +1 -1
- package/src/git.js +320 -2
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
>
|
|
5
5
|
> 原名 `dsh-plugin-git-manager`,2026-10 起包名改为 `@caozeal/dsh-next-git`(目录仍为 `dsh-next-git/`,功能不变)。
|
|
6
6
|
|
|
7
|
+
## 38 秒介绍片
|
|
8
|
+
|
|
9
|
+
https://github.com/user-attachments/assets/c57169b4-df00-450d-b4f0-7a2c64db03b6
|
|
10
|
+
|
|
11
|
+
*全片界面为渲染示意,不含真实会话;[完整播放页](https://dsh-next-git.1017835747.workers.dev/) 另有下载版。*
|
|
12
|
+
|
|
7
13
|
在 DSH Web GUI **会话的右侧边栏**加一个 Git tab(2026-10-22 从左侧全局面板迁移而来):
|
|
8
14
|
会话头部点 **Git 图标**直接打开(会自动展开右栏),或在右栏的「+ → 开始」指南页里
|
|
9
15
|
选「Git 管理」。面板内容:
|
|
@@ -17,9 +23,14 @@
|
|
|
17
23
|
- **AI 提交信息**:读取已暂存 diff + 最近提交风格,调用当前默认模型生成一行
|
|
18
24
|
Conventional Commits 提交信息,填入输入框供编辑;模型不可用时降级为
|
|
19
25
|
`chore: 更新 N 个文件`。
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
- **彩色提交图(graph)**:`git log --graph --all` 的图形化渲染。每条分支线一种颜色,
|
|
27
|
+
跨行保持同色;分支名徽章与它自己的线条严格同色,滚到哪都认得出。同一屏出现过的
|
|
28
|
+
分支保证互不撞色(按首次出现顺序分配,不是对名字取哈希——8 色槽下取模约 1/8 会撞)。
|
|
29
|
+
可点击任意提交查看该 commit 的 diff。
|
|
30
|
+
- **词级高亮 Diff**:hunk 逐行渲染,带旧/新行号、文件头与统计。真正变化的**词**
|
|
31
|
+
才有深色底,其余行内文字只是浅底——「改了什么」不用再靠眼力逐字比对。
|
|
32
|
+
增删配色直接复用平台自己的 `--dsw-alias-file-diff-*` token,深浅主题自动跟随,
|
|
33
|
+
和 GUI 里的代码块看起来是同一套东西。
|
|
23
34
|
|
|
24
35
|
面板跟随当前会话的工作目录:切换会话即切换仓库;非 Git 仓库 / 无会话 / 目录被删
|
|
25
36
|
时显示对应空态。右侧栏可以全屏(自带的「全屏」控件),窄列下是单列 tab 布局:
|
|
@@ -85,10 +96,12 @@ npm test # = node --test test/*.test.mjs
|
|
|
85
96
|
```
|
|
86
97
|
|
|
87
98
|
- `test/parsers.test.mjs`:porcelain / 分支头 / for-each-ref / graph / unified diff 解析,
|
|
88
|
-
|
|
99
|
+
`cleanGitEnv` 的 undefined 墓碑(删除继承自父进程的坏 `GIT_CONFIG_*`),以及
|
|
100
|
+
`diffInline`(词级切分 + CJK 逐字 + 超长退化)与 `assignGraphColors`
|
|
101
|
+
(跨行同色、同屏分支不撞色的回归用例)。
|
|
89
102
|
- `test/operations.test.mjs`:12 个 op 的假 exec 单测(含超时、非法参数、非仓库、降级)。
|
|
90
103
|
- `test/ai-commit.test.mjs`:AI 提交信息清理、截断/半句话回退、模型流错误转 `ai-unavailable`、
|
|
91
|
-
中文仓库的降级文案(单测合计
|
|
104
|
+
中文仓库的降级文案(单测合计 39 条,全绿)。
|
|
92
105
|
- `test/integration.test.mjs`:临时真实仓库跑 snapshot → diff → stage → commit →
|
|
93
106
|
graph → 建/切/删分支 → AI 降级;不依赖 dsh 运行时。
|
|
94
107
|
- `test/live/`:真实 Web GUI 的验证清单、smoke 脚本、截图证据与验证报告
|
|
@@ -137,6 +150,12 @@ op 一览:`snapshot` `graph` `diff` `stage` `unstage` `switchBranch` `deleteBr
|
|
|
137
150
|
SVG lane 渲染(20px 行高、最多 10 lane),长历史/多分支下不会挤掉文字。
|
|
138
151
|
- **失败可见**:git 非 0 退出把 stderr 摘要带回 UI,在底部状态条展示;
|
|
139
152
|
只读操作(snapshot/graph/diff)不会阻断面板。
|
|
153
|
+
- **配色跟随宿主**:diff 增删底色与 gutter 用 `--dsw-alias-file-diff-*`,词级高亮
|
|
154
|
+
用它算出的 34% 透明叠加;两张主题各自的值由平台提供,插件不写死颜色。
|
|
155
|
+
提交图的 8 色 lane 调色板是本插件自己的 `--gm-lane-*`(平台没有这个 token)。
|
|
156
|
+
- **词级算法在 Host 算、Client 只渲染**:`diffInline` 是纯函数(token LCS),
|
|
157
|
+
跟解析器一起单测;Client 拿到的 `inline` 片段拼接后必须精确等于原行文本,
|
|
158
|
+
所以渲染层不需要任何对齐逻辑。
|
|
140
159
|
- **AI 是可选能力**:缺少 `llm` / `agentDefaultModel` 时 `aiCommit` 自动降级,
|
|
141
160
|
不影响提交本身。
|
|
142
161
|
|
package/lib/client.js
CHANGED
|
@@ -277,6 +277,16 @@ window.__ModuleLoader__.load({
|
|
|
277
277
|
--gm-blue-18: color-mix(in srgb, var(--gm-blue) 18%, transparent);
|
|
278
278
|
--gm-blue-28: color-mix(in srgb, var(--gm-blue) 28%, transparent);
|
|
279
279
|
--gm-blue-40: color-mix(in srgb, var(--gm-blue) 40%, transparent);
|
|
280
|
+
/* 提交图 lane 调色板:8 色,相邻不撞色;每条分支线跨行保持同色。
|
|
281
|
+
色相刻意错开(蓝/紫/青/绿/橙/粉/黄/灰),深浅色主题下都够辨识。 */
|
|
282
|
+
--gm-lane-0: #4d6bfe;
|
|
283
|
+
--gm-lane-1: #8b5cf6;
|
|
284
|
+
--gm-lane-2: #06b6d4;
|
|
285
|
+
--gm-lane-3: #22c55e;
|
|
286
|
+
--gm-lane-4: #f59e0b;
|
|
287
|
+
--gm-lane-5: #ec4899;
|
|
288
|
+
--gm-lane-6: #14b8a6;
|
|
289
|
+
--gm-lane-7: #a855f7;
|
|
280
290
|
--gm-ok: var(--dsw-alias-state-success-primary);
|
|
281
291
|
--gm-err: var(--dsw-alias-state-error-primary);
|
|
282
292
|
--gm-warn: var(--dsw-alias-state-warn-primary);
|
|
@@ -414,10 +424,10 @@ window.__ModuleLoader__.load({
|
|
|
414
424
|
.gm-glane { flex:none; overflow:hidden;
|
|
415
425
|
-webkit-mask-image:linear-gradient(90deg, #000 88%, transparent 100%);
|
|
416
426
|
mask-image:linear-gradient(90deg, #000 88%, transparent 100%); }
|
|
417
|
-
.gm-glane line { stroke:var(--gm-blue); stroke-width:1.
|
|
418
|
-
.gm-glane circle { fill:var(--gm-blue); stroke:var(--gm-
|
|
419
|
-
.gm-grow.active .gm-glane line, .gm-grow:hover .gm-glane line { opacity
|
|
420
|
-
.gm-grow.active .gm-glane circle
|
|
427
|
+
.gm-glane line { stroke:var(--gm-blue); stroke-width:1.7; stroke-linecap:round; opacity:.78; }
|
|
428
|
+
.gm-glane circle { fill:var(--gm-blue); stroke:var(--gm-card); stroke-width:1.5; }
|
|
429
|
+
.gm-grow.active .gm-glane line, .gm-grow:hover .gm-glane line { opacity:1; }
|
|
430
|
+
.gm-grow.active .gm-glane circle { stroke:var(--gm-base); stroke-width:2; }
|
|
421
431
|
.gm-hash { flex:none; font-size:10.5px; color:var(--gm-blue); opacity:.75; }
|
|
422
432
|
.gm-subject { flex:1 1 auto; min-width:56px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:inherit; }
|
|
423
433
|
.gm-meta { flex:none; max-width:145px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
@@ -445,15 +455,23 @@ window.__ModuleLoader__.load({
|
|
|
445
455
|
border:1px solid var(--gm-blue-18); display:inline-block; max-width:calc(100% - 24px);
|
|
446
456
|
font-family:${mono}; font-size:10.5px; color:var(--gm-blue); white-space:pre-wrap; }
|
|
447
457
|
.gm-dlines { font-family:${mono}; font-size:11.5px; line-height:1.62; margin:0 6px 6px; }
|
|
448
|
-
.gm-dline { display:flex; align-items:baseline; white-space:pre-wrap; word-break:break-
|
|
449
|
-
|
|
450
|
-
|
|
458
|
+
.gm-dline { display:flex; align-items:baseline; white-space:pre-wrap; word-break:break-word; border-radius:4px; }
|
|
459
|
+
/* 增删底色优先用平台自己的 diff 配色(--dsw-alias-file-diff-*),深浅色主题自动跟随;
|
|
460
|
+
主题未注入时退回原来的 color-mix 兜底。 */
|
|
461
|
+
.gm-dline.add { background:var(--dsw-alias-file-diff-added-bg, color-mix(in srgb, var(--gm-ok) 10%, transparent)); }
|
|
462
|
+
.gm-dline.del { background:var(--dsw-alias-file-diff-deleted-bg, color-mix(in srgb, var(--gm-err) 10%, transparent)); }
|
|
451
463
|
.gm-ln { width:38px; flex:none; text-align:right; padding-right:8px; color:var(--gm-dim);
|
|
452
|
-
opacity:.6; user-select:none; font-variant-numeric:tabular-nums; }
|
|
464
|
+
opacity:.6; user-select:none; font-variant-numeric:tabular-nums; border-radius:3px; }
|
|
465
|
+
.gm-dline.add .gm-ln { background:var(--dsw-alias-file-diff-added-gutter, transparent); }
|
|
466
|
+
.gm-dline.del .gm-ln { background:var(--dsw-alias-file-diff-deleted-gutter, transparent); }
|
|
453
467
|
.gm-sign { width:14px; flex:none; text-align:center; font-weight:700; }
|
|
454
|
-
.gm-dline.add .gm-sign { color:var(--gm-ok); }
|
|
455
|
-
.gm-dline.del .gm-sign { color:var(--gm-err); }
|
|
468
|
+
.gm-dline.add .gm-sign { color:var(--dsw-alias-file-diff-added-marker, var(--gm-ok)); }
|
|
469
|
+
.gm-dline.del .gm-sign { color:var(--dsw-alias-file-diff-deleted-marker, var(--gm-err)); }
|
|
456
470
|
.gm-dtext { flex:1 1 auto; min-width:0; padding-right:10px; }
|
|
471
|
+
/* 词级高亮:只有真正变化的 token 才加深底色,一眼看出「改了哪个词」。 */
|
|
472
|
+
.gm-dword { border-radius:3px; padding:0 1px; }
|
|
473
|
+
.gm-dword.add { background:color-mix(in srgb, var(--dsw-alias-file-diff-added-marker, var(--gm-ok)) 34%, transparent); }
|
|
474
|
+
.gm-dword.del { background:color-mix(in srgb, var(--dsw-alias-file-diff-deleted-marker, var(--gm-err)) 34%, transparent); }
|
|
457
475
|
.gm-meta-line { padding:2px 12px; color:var(--gm-dim); font-style:italic; }
|
|
458
476
|
|
|
459
477
|
/* ---- commit card --------------------------------------------------- */
|
|
@@ -1360,6 +1378,7 @@ window.__ModuleLoader__.load({
|
|
|
1360
1378
|
prev: index > 0 ? (lines[index - 1].graph || '') : '',
|
|
1361
1379
|
next: index + 1 < lines.length ? (lines[index + 1].graph || '') : '',
|
|
1362
1380
|
cols: graphCols,
|
|
1381
|
+
colors: line.colors || [],
|
|
1363
1382
|
}))
|
|
1364
1383
|
}
|
|
1365
1384
|
var selected = state.selected && state.selected.kind === 'commit' && state.selected.hash === line.full
|
|
@@ -1374,13 +1393,21 @@ window.__ModuleLoader__.load({
|
|
|
1374
1393
|
prev: index > 0 ? (lines[index - 1].graph || '') : '',
|
|
1375
1394
|
next: index + 1 < lines.length ? (lines[index + 1].graph || '') : '',
|
|
1376
1395
|
cols: graphCols,
|
|
1396
|
+
colors: line.colors || [],
|
|
1377
1397
|
}),
|
|
1378
1398
|
h('span', { className: 'gm-hash' }, line.hash),
|
|
1379
1399
|
line.refs && line.refs.length > 0
|
|
1380
1400
|
? h('span', { className: 'gm-row', style: { gap: '4px', flex: 'none' } }, line.refs.map(function (ref, refIndex) {
|
|
1401
|
+
var isHead = ref.indexOf('HEAD') === 0
|
|
1402
|
+
// HEAD 用渐变主色强调;其余 ref 用分支自己的 lane 色,和左侧线条同色。
|
|
1381
1403
|
return h('span', {
|
|
1382
1404
|
key: 'r:' + refIndex,
|
|
1383
|
-
className: 'gm-badge gm-ref' + (
|
|
1405
|
+
className: 'gm-badge gm-ref' + (isHead ? ' brand' : ''),
|
|
1406
|
+
style: isHead ? undefined : {
|
|
1407
|
+
color: 'var(--gm-lane-' + (line.refColor !== undefined ? line.refColor : 0) + ')',
|
|
1408
|
+
borderColor: 'color-mix(in srgb, var(--gm-lane-' + (line.refColor !== undefined ? line.refColor : 0) + ') 42%, transparent)',
|
|
1409
|
+
background: 'color-mix(in srgb, var(--gm-lane-' + (line.refColor !== undefined ? line.refColor : 0) + ') 12%, transparent)',
|
|
1410
|
+
},
|
|
1384
1411
|
}, ref)
|
|
1385
1412
|
}))
|
|
1386
1413
|
: null,
|
|
@@ -1418,6 +1445,12 @@ window.__ModuleLoader__.load({
|
|
|
1418
1445
|
* Draws one row of the commit graph as vector lanes, so vertical lines stay
|
|
1419
1446
|
* continuous across rows (ASCII glyphs leave gaps at 20px row heights).
|
|
1420
1447
|
*/
|
|
1448
|
+
/** lane 色号 → CSS 变量名;-1(该格无连接)返回 undefined 让样式回退。 */
|
|
1449
|
+
function laneVar(color) {
|
|
1450
|
+
if (color === undefined || color === null || color < 0) return undefined
|
|
1451
|
+
return 'var(--gm-lane-' + color + ')'
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1421
1454
|
function GraphLane(props) {
|
|
1422
1455
|
var graph = props.graph || ''
|
|
1423
1456
|
var prev = props.prev || ''
|
|
@@ -1425,22 +1458,26 @@ window.__ModuleLoader__.load({
|
|
|
1425
1458
|
var cols = props.cols
|
|
1426
1459
|
var cell = GRAPH_CELL
|
|
1427
1460
|
var row = GRAPH_ROW
|
|
1461
|
+
var colors = props.colors || []
|
|
1428
1462
|
var parts = []
|
|
1429
1463
|
for (var i = 0; i < cols; i += 1) {
|
|
1430
1464
|
var ch = laneChar(graph, i)
|
|
1431
1465
|
var cx = (i + 0.5) * cell
|
|
1466
|
+
// 同一条分支线跨行保持同色:颜色由 Host 端 assignGraphColors 沿线段流动算出。
|
|
1467
|
+
var stroke = { stroke: laneVar(colors[i]) }
|
|
1432
1468
|
if (ch === '|') {
|
|
1433
|
-
parts.push(h('line', { key: 'v' + i, x1: cx, y1: 0, x2: cx, y2: row }))
|
|
1469
|
+
parts.push(h('line', { key: 'v' + i, x1: cx, y1: 0, x2: cx, y2: row, style: stroke }))
|
|
1434
1470
|
} else if (ch === '/') {
|
|
1435
|
-
parts.push(h('line', { key: 's' + i, x1: cx, y1: 0, x2: cx - cell, y2: row }))
|
|
1471
|
+
parts.push(h('line', { key: 's' + i, x1: cx, y1: 0, x2: cx - cell, y2: row, style: stroke }))
|
|
1436
1472
|
} else if (ch === '\\') {
|
|
1437
|
-
parts.push(h('line', { key: 'b' + i, x1: cx - cell, y1: 0, x2: cx, y2: row }))
|
|
1473
|
+
parts.push(h('line', { key: 'b' + i, x1: cx - cell, y1: 0, x2: cx, y2: row, style: stroke }))
|
|
1438
1474
|
} else if (ch === '_') {
|
|
1439
|
-
parts.push(h('line', { key: 'h' + i, x1: cx, y1: row - 1, x2: cx + cell, y2: row - 1 }))
|
|
1475
|
+
parts.push(h('line', { key: 'h' + i, x1: cx, y1: row - 1, x2: cx + cell, y2: row - 1, style: stroke }))
|
|
1440
1476
|
} else if (ch === '*') {
|
|
1441
|
-
if (laneFromAbove(prev, i)) parts.push(h('line', { key: 'u' + i, x1: cx, y1: 0, x2: cx, y2: row / 2 }))
|
|
1442
|
-
if (laneToBelow(next, i)) parts.push(h('line', { key: 'd' + i, x1: cx, y1: row / 2, x2: cx, y2: row }))
|
|
1443
|
-
parts.push(h('circle', { key: 'c' + i, cx: cx, cy: row / 2, r: 3.
|
|
1477
|
+
if (laneFromAbove(prev, i)) parts.push(h('line', { key: 'u' + i, x1: cx, y1: 0, x2: cx, y2: row / 2, style: stroke }))
|
|
1478
|
+
if (laneToBelow(next, i)) parts.push(h('line', { key: 'd' + i, x1: cx, y1: row / 2, x2: cx, y2: row, style: stroke }))
|
|
1479
|
+
parts.push(h('circle', { key: 'c' + i, cx: cx, cy: row / 2, r: 3.4,
|
|
1480
|
+
style: { fill: laneVar(colors[i]) } }))
|
|
1444
1481
|
}
|
|
1445
1482
|
}
|
|
1446
1483
|
return h('svg', {
|
|
@@ -1449,6 +1486,18 @@ window.__ModuleLoader__.load({
|
|
|
1449
1486
|
}, parts)
|
|
1450
1487
|
}
|
|
1451
1488
|
|
|
1489
|
+
/**
|
|
1490
|
+
* 渲染一行的文本部分;Host 在解析 diff 时已算好词级片段(line.inline),
|
|
1491
|
+
* 有就只把变化的片段包成 span,没有就退回整段纯文本。
|
|
1492
|
+
*/
|
|
1493
|
+
function inlineSpans(line) {
|
|
1494
|
+
if (!line.inline || line.inline.length === 0) return line.text
|
|
1495
|
+
return line.inline.map(function (part, index) {
|
|
1496
|
+
if (!part.changed) return part.text
|
|
1497
|
+
return h('span', { key: 'i:' + index, className: 'gm-dword' + (line.kind === 'del' ? ' del' : ' add') }, part.text)
|
|
1498
|
+
})
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1452
1501
|
function DiffView(props) {
|
|
1453
1502
|
var state = props.state
|
|
1454
1503
|
var diff = state.diff
|
|
@@ -1480,7 +1529,7 @@ window.__ModuleLoader__.load({
|
|
|
1480
1529
|
h('span', { className: 'gm-ln' }, line.old === undefined ? '' : String(line.old)),
|
|
1481
1530
|
h('span', { className: 'gm-ln' }, line.new === undefined ? '' : String(line.new)),
|
|
1482
1531
|
h('span', { className: 'gm-sign' }, sign),
|
|
1483
|
-
h('span', { className: 'gm-dtext' }, line
|
|
1532
|
+
h('span', { className: 'gm-dtext' }, inlineSpans(line)))
|
|
1484
1533
|
})))
|
|
1485
1534
|
})))
|
|
1486
1535
|
}
|
package/package.json
CHANGED
package/src/git.js
CHANGED
|
@@ -290,7 +290,7 @@ export function parseStashes(raw) {
|
|
|
290
290
|
* @param {string} raw
|
|
291
291
|
* @returns {Array<{graph: string, hash: string|null, full: string|null, refs: string[], author: string, date: string, subject: string}>}
|
|
292
292
|
*/
|
|
293
|
-
export function parseGraph(raw) {
|
|
293
|
+
export function parseGraph(raw, options = {}) {
|
|
294
294
|
/** @type {Array<object>} */
|
|
295
295
|
const lines = []
|
|
296
296
|
for (const line of String(raw ?? '').split('\n')) {
|
|
@@ -319,6 +319,19 @@ export function parseGraph(raw) {
|
|
|
319
319
|
subject: fields.slice(5).join('\x1f'),
|
|
320
320
|
})
|
|
321
321
|
}
|
|
322
|
+
// 逐行算 lane 色号,Client 直接用;ref 色号与线条色号来自同一次分配,
|
|
323
|
+
// 保证「分支徽章」和「分支线」严格同色。
|
|
324
|
+
const colors = assignGraphColors(lines, options.width)
|
|
325
|
+
const assignments = assignRefColors(lines)
|
|
326
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
327
|
+
const line = lines[index]
|
|
328
|
+
line.colors = colors[index]
|
|
329
|
+
if (line.refs && line.refs.length > 0) {
|
|
330
|
+
const head = line.refs.find((ref) => String(ref).indexOf('HEAD') === 0)
|
|
331
|
+
const color = assignments.get(canonicalRef(head || line.refs[0]))
|
|
332
|
+
if (color !== undefined) line.refColor = color
|
|
333
|
+
}
|
|
334
|
+
}
|
|
322
335
|
return lines
|
|
323
336
|
}
|
|
324
337
|
|
|
@@ -334,6 +347,309 @@ export function parseLines(raw) {
|
|
|
334
347
|
.filter((line) => line !== '')
|
|
335
348
|
}
|
|
336
349
|
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 把一对「删除行 / 新增行」切成词级片段,供 diff 渲染高亮真正变化的词。
|
|
353
|
+
*
|
|
354
|
+
* 算法:先按「词 / 空白 / 标点」粗切成 token,再对两组 token 做 LCS,
|
|
355
|
+
* 未匹配的 token 才是变化部分。纯函数、无依赖、可单测。
|
|
356
|
+
*
|
|
357
|
+
* 注意:中文等无空格语言按字符切,否则整行会变成一个 token(等于没高亮)。
|
|
358
|
+
*
|
|
359
|
+
* @param {string} before 删除行的文本(不含前缀符号)
|
|
360
|
+
* @param {string} after 新增行的文本(不含前缀符号)
|
|
361
|
+
* @returns {{before: Array<{text: string, changed: boolean}>, after: Array<{text: string, changed: boolean}>}}
|
|
362
|
+
*/
|
|
363
|
+
export function diffInline(before, after) {
|
|
364
|
+
const left = tokenizeInline(String(before ?? ''))
|
|
365
|
+
const right = tokenizeInline(String(after ?? ''))
|
|
366
|
+
// 任一侧为空(纯增/纯删)或任一侧过长(高亮收益低、LCS 变慢)时整体标记为变化。
|
|
367
|
+
if (left.length === 0 || right.length === 0 || left.length + right.length > MAX_INLINE_TOKENS) {
|
|
368
|
+
return {
|
|
369
|
+
before: [{ text: left.join(''), changed: left.length > 0 }],
|
|
370
|
+
after: [{ text: right.join(''), changed: right.length > 0 }],
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const matched = lcsMatches(left, right)
|
|
374
|
+
return {
|
|
375
|
+
before: groupInline(left, matched.left),
|
|
376
|
+
after: groupInline(right, matched.right),
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 单行词级比较的 token 上限,超过就退化成整行高亮。 */
|
|
381
|
+
const MAX_INLINE_TOKENS = 400
|
|
382
|
+
|
|
383
|
+
/** 是否把该 codepoint 当作可独立成词的表意字符(CJK、假名、全角标点等)。 */
|
|
384
|
+
function isIdeographic(code) {
|
|
385
|
+
return (
|
|
386
|
+
(code >= 0x3040 && code <= 0x30ff) || // 平假名 / 片假名
|
|
387
|
+
(code >= 0x3400 && code <= 0x4dbf) || // CJK 扩展 A
|
|
388
|
+
(code >= 0x4e00 && code <= 0x9fff) || // CJK 基本区
|
|
389
|
+
(code >= 0xf900 && code <= 0xfaff) || // CJK 兼容表意
|
|
390
|
+
(code >= 0xff00 && code <= 0xffef) || // 全角字符
|
|
391
|
+
(code >= 0xac00 && code <= 0xd7af) // 谚文
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 把一行切成分隔 token:连续标识符、连续空白、单个标点/表意字符。 */
|
|
396
|
+
function tokenizeInline(text) {
|
|
397
|
+
/** @type {string[]} */
|
|
398
|
+
const out = []
|
|
399
|
+
let buffer = ''
|
|
400
|
+
let mode = ''
|
|
401
|
+
for (const char of text) {
|
|
402
|
+
const code = char.codePointAt(0) ?? 0
|
|
403
|
+
const next = /\s/.test(char) ? 'space' : isIdeographic(code) ? 'ideograph' : /[A-Za-z0-9_$]/.test(char) ? 'word' : 'punct'
|
|
404
|
+
if (next === 'ideograph') {
|
|
405
|
+
if (buffer) { out.push(buffer); buffer = '' }
|
|
406
|
+
out.push(char)
|
|
407
|
+
mode = ''
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
if (next === mode) {
|
|
411
|
+
buffer += char
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
if (buffer) out.push(buffer)
|
|
415
|
+
buffer = char
|
|
416
|
+
mode = next
|
|
417
|
+
}
|
|
418
|
+
if (buffer) out.push(buffer)
|
|
419
|
+
return out
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 对左右 token 序列求最长公共子序列,返回两侧各自「被匹配上」的下标集合。
|
|
424
|
+
* 经典 DP;因为 MAX_INLINE_TOKENS 限制了规模,内存可以接受。
|
|
425
|
+
* @param {string[]} left
|
|
426
|
+
* @param {string[]} right
|
|
427
|
+
* @returns {{left: Set<number>, right: Set<number>}}
|
|
428
|
+
*/
|
|
429
|
+
function lcsMatches(left, right) {
|
|
430
|
+
const n = left.length
|
|
431
|
+
const m = right.length
|
|
432
|
+
/** @type {Uint16Array[]} */
|
|
433
|
+
const table = []
|
|
434
|
+
for (let i = 0; i <= n; i += 1) table.push(new Uint16Array(m + 1))
|
|
435
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
436
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
437
|
+
table[i][j] = left[i] === right[j]
|
|
438
|
+
? table[i + 1][j + 1] + 1
|
|
439
|
+
: Math.max(table[i + 1][j], table[i][j + 1])
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const matchedLeft = new Set()
|
|
443
|
+
const matchedRight = new Set()
|
|
444
|
+
let i = 0
|
|
445
|
+
let j = 0
|
|
446
|
+
while (i < n && j < m) {
|
|
447
|
+
if (left[i] === right[j]) {
|
|
448
|
+
matchedLeft.add(i)
|
|
449
|
+
matchedRight.add(j)
|
|
450
|
+
i += 1
|
|
451
|
+
j += 1
|
|
452
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
453
|
+
i += 1
|
|
454
|
+
} else {
|
|
455
|
+
j += 1
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { left: matchedLeft, right: matchedRight }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 把 token 按下标是否命中合并成连续片段,减少 DOM 节点数。
|
|
463
|
+
* @param {string[]} tokens
|
|
464
|
+
* @param {Set<number>} matched
|
|
465
|
+
* @returns {Array<{text: string, changed: boolean}>}
|
|
466
|
+
*/
|
|
467
|
+
function groupInline(tokens, matched) {
|
|
468
|
+
/** @type {Array<{text: string, changed: boolean}>} */
|
|
469
|
+
const out = []
|
|
470
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
471
|
+
const changed = !matched.has(index)
|
|
472
|
+
const last = out[out.length - 1]
|
|
473
|
+
if (last && last.changed === changed) last.text += tokens[index]
|
|
474
|
+
else out.push({ text: tokens[index], changed })
|
|
475
|
+
}
|
|
476
|
+
return out
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 在一个 hunk 的行序列上就地附加词级高亮片段。
|
|
481
|
+
*
|
|
482
|
+
* 配对策略:同一 hunk 内相邻的连续删除块与连续新增块「按下标逐行配对」——
|
|
483
|
+
* 这是 unified diff 的常见形态,也是用户直觉上认为「这一行改成了那一行」的对应关系。
|
|
484
|
+
* 多余的删除行或新增行保持整行高亮。
|
|
485
|
+
*
|
|
486
|
+
* @param {Array<{kind: string, text: string}>} lines
|
|
487
|
+
* @returns {Array<{kind: string, text: string, old?: number, new?: number, inline?: Array<{text: string, changed: boolean}>}>}
|
|
488
|
+
*/
|
|
489
|
+
export function attachInlineHunks(lines) {
|
|
490
|
+
const output = lines.map((line) => ({ ...line }))
|
|
491
|
+
let index = 0
|
|
492
|
+
while (index < output.length) {
|
|
493
|
+
if (output[index].kind !== 'del') {
|
|
494
|
+
index += 1
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
let delEnd = index
|
|
498
|
+
while (delEnd < output.length && output[delEnd].kind === 'del') delEnd += 1
|
|
499
|
+
let addEnd = delEnd
|
|
500
|
+
while (addEnd < output.length && output[addEnd].kind === 'add') addEnd += 1
|
|
501
|
+
const delCount = delEnd - index
|
|
502
|
+
const addCount = addEnd - delEnd
|
|
503
|
+
const pairs = Math.min(delCount, addCount)
|
|
504
|
+
for (let offset = 0; offset < pairs; offset += 1) {
|
|
505
|
+
const delLine = output[index + offset]
|
|
506
|
+
const addLine = output[delEnd + offset]
|
|
507
|
+
const inline = diffInline(delLine.text, addLine.text)
|
|
508
|
+
// 只在真的存在可见差异时才挂片段,纯「整行替换」不额外包 span。
|
|
509
|
+
if (inline.before.length > 1 || inline.after.length > 1 ||
|
|
510
|
+
(inline.before[0] && inline.before[0].changed) || (inline.after[0] && inline.after[0].changed)) {
|
|
511
|
+
delLine.inline = inline.before
|
|
512
|
+
addLine.inline = inline.after
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
index = addEnd
|
|
516
|
+
}
|
|
517
|
+
return output
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** 提交图配色槽位数;Client 侧 CSS 提供 --gm-lane-0 .. --gm-lane-{N-1}。 */
|
|
521
|
+
export const GRAPH_PALETTE_SIZE = 8
|
|
522
|
+
|
|
523
|
+
/** 图表单格的连接语义:'|' 竖直、'/' 从 lane i 顶到 lane i-1 底、'\\' 从 lane i-1 顶到 lane i 底。 */
|
|
524
|
+
function cellLink(char, lane) {
|
|
525
|
+
if (char === '|') return { top: lane, bottom: lane }
|
|
526
|
+
if (char === '/') return { top: lane, bottom: lane - 1 }
|
|
527
|
+
if (char === '\\') return { top: lane - 1, bottom: lane }
|
|
528
|
+
if (char === '*') return { top: lane, bottom: lane }
|
|
529
|
+
if (char === '_') return { top: lane, bottom: lane }
|
|
530
|
+
return null
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** 取图行第 lane 个字符;越界返回空格。 */
|
|
534
|
+
function graphChar(text, lane) {
|
|
535
|
+
return lane >= 0 && lane < text.length ? text[lane] : ' '
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 由分支名/远程名算出稳定色号:同名分支在任何仓库、任何一次打开都是同一种颜色。 */
|
|
539
|
+
export function refColorIndex(ref) {
|
|
540
|
+
const name = String(ref ?? '')
|
|
541
|
+
.replace(/^HEAD -> /, '')
|
|
542
|
+
.replace(/^tag: /, '')
|
|
543
|
+
.replace(/^origin\//, '')
|
|
544
|
+
let hash = 0
|
|
545
|
+
for (let index = 0; index < name.length; index += 1) {
|
|
546
|
+
hash = (hash * 31 + name.charCodeAt(index)) % 100003
|
|
547
|
+
}
|
|
548
|
+
return hash % GRAPH_PALETTE_SIZE
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 给提交图每一行算出 lane 的颜色号。
|
|
553
|
+
*
|
|
554
|
+
* 做法是模拟一条「颜色随线段流动」的轨道:每个格子有入口 lane 和出口 lane
|
|
555
|
+
* (见 {@link cellLink}),入口颜色沿线段流到出口,于是同一条分支线在跨行时
|
|
556
|
+
* 保持同色;分支点处新出现的线段取当前未被占用的色号,避免相邻两条线撞色。
|
|
557
|
+
*
|
|
558
|
+
* 有 ref(分支/tag)的提交点会强制使用该 ref 的稳定色,这样「分支自己的颜色」
|
|
559
|
+
* 和「分支名徽章的颜色」一致,滚到哪都能认出来。
|
|
560
|
+
*
|
|
561
|
+
* @param {Array<{graph: string, refs?: string[]}>} lines
|
|
562
|
+
* @param {number} [width] 参与绘制的 lane 数;省略时按实际最长图前缀推断
|
|
563
|
+
* @returns {Array<Array<number>>} 每行每个 lane 的色号
|
|
564
|
+
*/
|
|
565
|
+
export function assignGraphColors(lines, width) {
|
|
566
|
+
const rows = Array.isArray(lines) ? lines : []
|
|
567
|
+
let maxWidth = 0
|
|
568
|
+
for (const line of rows) {
|
|
569
|
+
const text = line && line.graph ? line.graph : ''
|
|
570
|
+
if (text.length > maxWidth) maxWidth = text.length
|
|
571
|
+
}
|
|
572
|
+
const cols = Number.isFinite(width) && width > 0 ? Math.min(width, maxWidth) : maxWidth
|
|
573
|
+
// 先给所有出现过的 ref 分配互不相同的色号(按首次出现顺序),而不是对名字取模。
|
|
574
|
+
// 取模在 8 色槽下约 1/8 概率撞色,两条分支同色会让整张图失去辨识度
|
|
575
|
+
// (实测 feat/streaming 与 fix/empty-input 就撞在同一个色号上)。
|
|
576
|
+
const refColors = assignRefColors(rows)
|
|
577
|
+
/** @type {Map<number, number>} */
|
|
578
|
+
let entering = new Map()
|
|
579
|
+
/** @type {Array<Array<number>>} */
|
|
580
|
+
const output = []
|
|
581
|
+
for (let row = 0; row < rows.length; row += 1) {
|
|
582
|
+
const text = rows[row] && rows[row].graph ? rows[row].graph : ''
|
|
583
|
+
const refs = rows[row] && Array.isArray(rows[row].refs) ? rows[row].refs : []
|
|
584
|
+
/** @type {Array<number>} */
|
|
585
|
+
const colors = new Array(cols).fill(-1)
|
|
586
|
+
/** @type {Map<number, number>} */
|
|
587
|
+
const next = new Map()
|
|
588
|
+
/** @type {Set<number>} */
|
|
589
|
+
const used = new Set()
|
|
590
|
+
for (let lane = 0; lane < cols; lane += 1) {
|
|
591
|
+
const link = cellLink(graphChar(text, lane), lane)
|
|
592
|
+
if (!link) continue
|
|
593
|
+
let color = entering.get(link.top)
|
|
594
|
+
if (color === undefined) {
|
|
595
|
+
// 新线段:挑一个这一行还没用过的色号,相邻线不会同色。
|
|
596
|
+
color = 0
|
|
597
|
+
for (let candidate = 0; candidate < GRAPH_PALETTE_SIZE; candidate += 1) {
|
|
598
|
+
if (!used.has(candidate)) { color = candidate; break }
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
used.add(color)
|
|
602
|
+
colors[lane] = color
|
|
603
|
+
if (!next.has(link.bottom)) next.set(link.bottom, color)
|
|
604
|
+
}
|
|
605
|
+
// 提交点:有 ref 时用该分支分配到的色号,并让它继续往下流。
|
|
606
|
+
if (refs.length > 0) {
|
|
607
|
+
const dot = text.indexOf('*')
|
|
608
|
+
if (dot >= 0 && dot < cols) {
|
|
609
|
+
const head = refs.find((ref) => String(ref).indexOf('HEAD') === 0)
|
|
610
|
+
const color = refColors.get(canonicalRef(head || refs[0]))
|
|
611
|
+
if (color !== undefined) {
|
|
612
|
+
colors[dot] = color
|
|
613
|
+
next.set(dot, color)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
output.push(colors)
|
|
618
|
+
entering = next
|
|
619
|
+
}
|
|
620
|
+
return output
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 给一组图行里出现过的每个 ref 分配色号(按首次出现顺序取模)。
|
|
625
|
+
* 顺序分配保证「同一屏里出现的分支一定不同色」,比按名字哈希取模更可靠。
|
|
626
|
+
* @param {Array<{refs?: string[]}>} rows
|
|
627
|
+
* @returns {Map<string, number>} 归一化分支名 → 色号
|
|
628
|
+
*/
|
|
629
|
+
function assignRefColors(rows) {
|
|
630
|
+
const out = new Map()
|
|
631
|
+
let cursor = 0
|
|
632
|
+
for (const row of rows) {
|
|
633
|
+
const refs = row && Array.isArray(row.refs) ? row.refs : []
|
|
634
|
+
for (const ref of refs) {
|
|
635
|
+
const name = canonicalRef(ref)
|
|
636
|
+
if (!name || out.has(name)) continue
|
|
637
|
+
out.set(name, cursor % GRAPH_PALETTE_SIZE)
|
|
638
|
+
cursor += 1
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return out
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** 把 `HEAD -> main`、`tag: v1`、`origin/main` 归一成用于配色的分支名。 */
|
|
645
|
+
function canonicalRef(ref) {
|
|
646
|
+
return String(ref ?? '')
|
|
647
|
+
.replace(/^HEAD -> /, '')
|
|
648
|
+
.replace(/^tag: /, '')
|
|
649
|
+
.replace(/^origin\//, '')
|
|
650
|
+
.trim()
|
|
651
|
+
}
|
|
652
|
+
|
|
337
653
|
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
|
|
338
654
|
|
|
339
655
|
/**
|
|
@@ -395,5 +711,7 @@ export function parseUnifiedDiff(raw) {
|
|
|
395
711
|
// 例如 `index abc..def 100644`、`--- a/x`、`+++ b/x` 等文件头:忽略。
|
|
396
712
|
}
|
|
397
713
|
}
|
|
714
|
+
// 每个 hunk 的行上附加词级高亮片段(纯展示数据,UI 可忽略)。
|
|
715
|
+
for (const hunk of hunks) hunk.lines = attachInlineHunks(hunk.lines)
|
|
398
716
|
return { binary, additions, deletions, hunks }
|
|
399
|
-
}
|
|
717
|
+
}
|