@caozeal/dsh-next-git 0.1.4 → 0.1.6
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 +43 -12
- package/assets/screenshots/01-changes.png +0 -0
- package/assets/screenshots/02-commit-graph.png +0 -0
- package/assets/screenshots/03-word-level-diff.png +0 -0
- package/assets/screenshots/04-branches.png +0 -0
- package/assets/screenshots/05-graph-multibranch.png +0 -0
- package/assets/screenshots/06-fullscreen.png +0 -0
- package/assets/screenshots/README.md +24 -0
- package/lib/client.js +359 -31
- package/package.json +3 -1
- package/screenshots.json +8 -0
- package/src/ai-commit.js +65 -22
- package/src/git.js +320 -2
- package/src/operations.js +122 -3
package/src/ai-commit.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* git-manager — AI 提交信息生成。
|
|
3
3
|
*
|
|
4
4
|
* 流程:收集「已暂存 diff(为空则工作区 diff)→ 最近提交风格 → 调用默认模型的
|
|
5
|
-
* llm.stream」→
|
|
6
|
-
*
|
|
5
|
+
* llm.stream」→ 清理成「标题 + 可选正文」。任何一步不可用都退回确定性文案
|
|
6
|
+
* (fallback),让面板始终能拿到一条可编辑的建议。
|
|
7
|
+
*
|
|
8
|
+
* 系统提示词可由仓库自定义(git config dsh.commitPrompt),只替换「怎么写」的规则,
|
|
9
|
+
* 不改变「给模型看什么数据」——diff/文件/最近提交这条装配逻辑始终由本模块掌握,
|
|
10
|
+
* 避免用户把模板写坏后模型看不到 diff 而胡乱编造。
|
|
7
11
|
*/
|
|
8
12
|
import { GitError } from './git.js'
|
|
9
13
|
|
|
@@ -11,11 +15,13 @@ const MAX_DIFF_CHARS = 12000
|
|
|
11
15
|
/** 单次生成的 token 预算:reasoning 模型会先消耗思考 token,200 太小容易截断。 */
|
|
12
16
|
const MAX_OUTPUT_TOKENS = 600
|
|
13
17
|
|
|
14
|
-
const
|
|
18
|
+
export const DEFAULT_SYSTEM_PROMPT = [
|
|
15
19
|
'你是一个 Git 提交信息生成器。',
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
20
|
+
'第一行是标题:Conventional Commits 风格(feat:/fix:/refactor:/docs:/chore: 等),不超过 72 个字符,不以句号结尾。',
|
|
21
|
+
'如果这次改动需要说明动机、影响面或取舍,空一行后写正文,正文每行不超过 72 个字符,可以分段。',
|
|
22
|
+
'改动很小、一句话能说清时不要硬凑正文。',
|
|
23
|
+
'不要解释、不要代码块、不要引号。',
|
|
24
|
+
'跟随仓库最近提交的语言:最近提交多为中文就用中文,否则用英文。',
|
|
19
25
|
].join('')
|
|
20
26
|
|
|
21
27
|
/** 从 diff 与最近提交构造 user prompt。 */
|
|
@@ -48,6 +54,44 @@ export function sanitizeSubject(text) {
|
|
|
48
54
|
return subject
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
/** 去掉整行的代码块围栏,保留内容。 */
|
|
58
|
+
function stripFences(text) {
|
|
59
|
+
return String(text ?? '').replace(/^[ \t]*```[a-zA-Z0-9_-]*[ \t]*$/gm, '')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 清理模型输出的正文部分:去围栏、去首尾空行、去「正文:」这类前缀、限长。
|
|
64
|
+
* 不做逐行 trim——正文里的缩进(列表、代码片段)要保留。
|
|
65
|
+
* @param {string} text
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function sanitizeBody(text) {
|
|
69
|
+
let value = stripFences(text).replace(/\r\n/g, '\n')
|
|
70
|
+
const lines = value.split('\n').map((line) => line.replace(/[ \t]+$/, ''))
|
|
71
|
+
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
|
|
72
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
|
73
|
+
let body = lines.join('\n').trim()
|
|
74
|
+
body = body.replace(/^(commit body|提交正文|正文)\s*[::]\s*/i, '')
|
|
75
|
+
if (body.length > 2000) body = body.slice(0, 2000).trim()
|
|
76
|
+
return body
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把模型输出拆成「标题 + 正文」。
|
|
81
|
+
* 模型偶尔只给标题,也可能给「标题 / 空行 / 正文」,两种都要能吃下。
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @returns {{subject: string, body: string}}
|
|
84
|
+
*/
|
|
85
|
+
export function parseCommitMessage(text) {
|
|
86
|
+
const cleaned = stripFences(text).replace(/\r\n/g, '\n')
|
|
87
|
+
const lines = cleaned.split('\n')
|
|
88
|
+
let index = 0
|
|
89
|
+
while (index < lines.length && lines[index].trim() === '') index += 1
|
|
90
|
+
const subjectLine = index < lines.length ? lines[index] : ''
|
|
91
|
+
const rest = lines.slice(index + 1).join('\n')
|
|
92
|
+
return { subject: sanitizeSubject(subjectLine), body: sanitizeBody(rest) }
|
|
93
|
+
}
|
|
94
|
+
|
|
51
95
|
/** 最近提交是否以中文为主。 */
|
|
52
96
|
function looksChinese(subjects) {
|
|
53
97
|
const joined = subjects.join('')
|
|
@@ -104,8 +148,9 @@ export function fallbackSubject({ files, stagedCount, Chinese }) {
|
|
|
104
148
|
* signal?: AbortSignal,
|
|
105
149
|
* llm?: unknown,
|
|
106
150
|
* selection?: {provider?: string, model?: string, reasoningEffort?: string},
|
|
151
|
+
* systemPrompt?: string,
|
|
107
152
|
* }} options
|
|
108
|
-
* @returns {Promise<{message: string, model: string|null, fallback: boolean}>}
|
|
153
|
+
* @returns {Promise<{message: string, subject: string, body: string, model: string|null, fallback: boolean}>}
|
|
109
154
|
*/
|
|
110
155
|
export async function generateCommitMessage(options) {
|
|
111
156
|
const {
|
|
@@ -117,6 +162,7 @@ export async function generateCommitMessage(options) {
|
|
|
117
162
|
selection,
|
|
118
163
|
files = [],
|
|
119
164
|
stagedCount = 0,
|
|
165
|
+
systemPrompt = '',
|
|
120
166
|
} = options
|
|
121
167
|
|
|
122
168
|
const staged = await runGit(['diff', '--cached', '--no-color'], { cwd, signal, allowFailure: true })
|
|
@@ -144,13 +190,12 @@ export async function generateCommitMessage(options) {
|
|
|
144
190
|
|
|
145
191
|
const canUseLlm = Boolean(llm && selection && selection.provider && selection.model && diff.trim())
|
|
146
192
|
if (!canUseLlm) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
model,
|
|
150
|
-
fallback: true,
|
|
151
|
-
}
|
|
193
|
+
const subject = fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese })
|
|
194
|
+
return { message: subject, subject, body: '', model, fallback: true }
|
|
152
195
|
}
|
|
153
196
|
|
|
197
|
+
// 用户自定义提示词:只覆盖「怎么写」的规则,装配数据仍由 buildPrompt 负责。
|
|
198
|
+
const customPrompt = typeof systemPrompt === 'string' ? systemPrompt.trim().slice(0, 4000) : ''
|
|
154
199
|
const prompt = buildPrompt({ diff, files: changedFiles, recent, hint })
|
|
155
200
|
let text = ''
|
|
156
201
|
try {
|
|
@@ -158,7 +203,7 @@ export async function generateCommitMessage(options) {
|
|
|
158
203
|
provider: selection.provider,
|
|
159
204
|
model: selection.model,
|
|
160
205
|
reasoningEffort: selection.reasoningEffort,
|
|
161
|
-
system:
|
|
206
|
+
system: customPrompt === '' ? DEFAULT_SYSTEM_PROMPT : customPrompt,
|
|
162
207
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
163
208
|
maxTokens: MAX_OUTPUT_TOKENS,
|
|
164
209
|
temperature: 0.2,
|
|
@@ -182,13 +227,11 @@ export async function generateCommitMessage(options) {
|
|
|
182
227
|
throw new GitError(`模型生成失败:${message}`, { code: 'ai-unavailable' })
|
|
183
228
|
}
|
|
184
229
|
|
|
185
|
-
const
|
|
186
|
-
if (!looksLikeSubject(subject)) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
model,
|
|
190
|
-
fallback: true,
|
|
191
|
-
}
|
|
230
|
+
const parsed = parseCommitMessage(text)
|
|
231
|
+
if (!looksLikeSubject(parsed.subject)) {
|
|
232
|
+
const subject = fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese })
|
|
233
|
+
return { message: subject, subject, body: '', model, fallback: true }
|
|
192
234
|
}
|
|
193
|
-
|
|
194
|
-
}
|
|
235
|
+
const message = parsed.body === '' ? parsed.subject : parsed.subject + '\n\n' + parsed.body
|
|
236
|
+
return { message, subject: parsed.subject, body: parsed.body, model, fallback: false }
|
|
237
|
+
}
|
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
|
+
}
|
package/src/operations.js
CHANGED
|
@@ -27,10 +27,17 @@ const DIFF_MAX_CHARS = 400000
|
|
|
27
27
|
|
|
28
28
|
/** 支持的操作名。 */
|
|
29
29
|
const OPS = [
|
|
30
|
-
'snapshot', 'graph', 'diff', 'stage', 'unstage', 'switchBranch', 'deleteBranch',
|
|
30
|
+
'snapshot', 'graph', 'diff', 'stage', 'unstage', 'discard', 'switchBranch', 'deleteBranch',
|
|
31
31
|
'commit', 'fetch', 'pull', 'push', 'aiCommit',
|
|
32
|
+
'stashPush', 'stashPop', 'stashDrop', 'setCommitPrompt',
|
|
32
33
|
]
|
|
33
34
|
|
|
35
|
+
/** 自定义提交提示词在 git config 里的键。用 git config 而不是新增工作区文件:
|
|
36
|
+
* 不污染 git status、不打扰 .gitignore,且天然区分本地/全局(写入用 --local,
|
|
37
|
+
* 读取走默认级联,所以用户可以先设一条全局默认再按仓库覆盖)。 */
|
|
38
|
+
const COMMIT_PROMPT_KEY = 'dsh.commitPrompt'
|
|
39
|
+
const COMMIT_PROMPT_MAX = 4000
|
|
40
|
+
|
|
34
41
|
/** 只接受安全的 ref/远程名,避免被当作命令行选项。 */
|
|
35
42
|
const SAFE_REF = /^[A-Za-z0-9._/-]+$/
|
|
36
43
|
|
|
@@ -160,10 +167,11 @@ export function createOperations(deps) {
|
|
|
160
167
|
remotes: [],
|
|
161
168
|
status: { clean: true, staged: [], unstaged: [], untracked: [], conflicted: [] },
|
|
162
169
|
stashes: [],
|
|
170
|
+
commitPrompt: '',
|
|
163
171
|
}
|
|
164
172
|
}
|
|
165
173
|
const root = resolved.root
|
|
166
|
-
const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes] = await Promise.all([
|
|
174
|
+
const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes, promptRes] = await Promise.all([
|
|
167
175
|
runGit(['status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], {
|
|
168
176
|
cwd: root,
|
|
169
177
|
signal,
|
|
@@ -178,6 +186,7 @@ export function createOperations(deps) {
|
|
|
178
186
|
runGit(['stash', 'list', '--format=%gd%x00%s'], { cwd: root, signal, allowFailure: true }),
|
|
179
187
|
runGit(['rev-parse', '--short', 'HEAD'], { cwd: root, signal, allowFailure: true }),
|
|
180
188
|
runGit(['config', '--get', 'remote.origin.url'], { cwd: root, signal, allowFailure: true }),
|
|
189
|
+
runGit(['config', '--get', COMMIT_PROMPT_KEY], { cwd: root, signal, allowFailure: true }),
|
|
181
190
|
])
|
|
182
191
|
const parsed = parsePorcelain(statusRes.stdout)
|
|
183
192
|
const head = parseBranchHeader(parsed.branchLine)
|
|
@@ -209,6 +218,7 @@ export function createOperations(deps) {
|
|
|
209
218
|
remotes,
|
|
210
219
|
status: { clean, ...groups },
|
|
211
220
|
stashes: parseStashes(stashRes.stdout),
|
|
221
|
+
commitPrompt: promptRes.code === 0 ? promptRes.stdout.trim() : '',
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
224
|
|
|
@@ -311,6 +321,31 @@ export function createOperations(deps) {
|
|
|
311
321
|
return { output: joinOutput(result.stdout, result.stderr) }
|
|
312
322
|
}
|
|
313
323
|
|
|
324
|
+
/**
|
|
325
|
+
* 丢弃改动。三种模式对应变更列表里的三个分组:
|
|
326
|
+
* worktree —— 未暂存(git restore --worktree)
|
|
327
|
+
* untracked —— 未跟踪(git clean -f -d)
|
|
328
|
+
* all —— 已暂存,连暂存区一起回退(git restore --staged --worktree)
|
|
329
|
+
* 都不可恢复,前端必须二次确认后才调用。
|
|
330
|
+
*/
|
|
331
|
+
async function discard(params, signal) {
|
|
332
|
+
const cwd = assertCwd(params.cwd)
|
|
333
|
+
const resolved = await repoRoot(cwd, signal)
|
|
334
|
+
const root = resolved.root
|
|
335
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
336
|
+
const mode = params.mode
|
|
337
|
+
if (!['worktree', 'untracked', 'all'].includes(mode)) {
|
|
338
|
+
throw new GitError(`未知的丢弃模式:${String(mode)}`, { code: 'invalid-params' })
|
|
339
|
+
}
|
|
340
|
+
const paths = assertPaths(params.paths)
|
|
341
|
+
let args
|
|
342
|
+
if (mode === 'untracked') args = ['clean', '-f', '-d', '--', ...paths]
|
|
343
|
+
else if (mode === 'all') args = ['restore', '--staged', '--worktree', '--', ...paths]
|
|
344
|
+
else args = ['restore', '--worktree', '--', ...paths]
|
|
345
|
+
const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
|
|
346
|
+
return { output: joinOutput(result.stdout, result.stderr) }
|
|
347
|
+
}
|
|
348
|
+
|
|
314
349
|
async function switchBranch(params, signal) {
|
|
315
350
|
const cwd = assertCwd(params.cwd)
|
|
316
351
|
const resolved = await repoRoot(cwd, signal)
|
|
@@ -429,6 +464,80 @@ export function createOperations(deps) {
|
|
|
429
464
|
return { branch, output: joinOutput(result.stdout, result.stderr) }
|
|
430
465
|
}
|
|
431
466
|
|
|
467
|
+
/** stash 索引只接受非负整数,避免拼进 stash@{...} 时被注入别的 ref。 */
|
|
468
|
+
function assertStashIndex(value, fallback) {
|
|
469
|
+
const raw = value === undefined || value === null ? fallback : value
|
|
470
|
+
const index = Number(raw)
|
|
471
|
+
if (!Number.isInteger(index) || index < 0 || index > 999) {
|
|
472
|
+
throw new GitError(`stash 索引非法:${String(raw)}`, { code: 'invalid-params' })
|
|
473
|
+
}
|
|
474
|
+
return index
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function stashPush(params, signal) {
|
|
478
|
+
const cwd = assertCwd(params.cwd)
|
|
479
|
+
const resolved = await repoRoot(cwd, signal)
|
|
480
|
+
const root = resolved.root
|
|
481
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
482
|
+
const args = ['stash', 'push']
|
|
483
|
+
if (params.includeUntracked === true) args.push('-u')
|
|
484
|
+
const message = typeof params.message === 'string' ? params.message.trim().slice(0, 200) : ''
|
|
485
|
+
if (message !== '') args.push('-m', message)
|
|
486
|
+
const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
|
|
487
|
+
return { output: joinOutput(result.stdout, result.stderr) }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function stashPop(params, signal) {
|
|
491
|
+
const cwd = assertCwd(params.cwd)
|
|
492
|
+
const resolved = await repoRoot(cwd, signal)
|
|
493
|
+
const root = resolved.root
|
|
494
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
495
|
+
const index = assertStashIndex(params.index, 0)
|
|
496
|
+
const result = await runGit(['stash', 'pop', `stash@{${index}}`], {
|
|
497
|
+
cwd: root, signal, timeoutMs: timeouts.write,
|
|
498
|
+
})
|
|
499
|
+
return { output: joinOutput(result.stdout, result.stderr) }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function stashDrop(params, signal) {
|
|
503
|
+
const cwd = assertCwd(params.cwd)
|
|
504
|
+
const resolved = await repoRoot(cwd, signal)
|
|
505
|
+
const root = resolved.root
|
|
506
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
507
|
+
const index = assertStashIndex(params.index, 0)
|
|
508
|
+
const result = await runGit(['stash', 'drop', `stash@{${index}}`], {
|
|
509
|
+
cwd: root, signal, timeoutMs: timeouts.write,
|
|
510
|
+
})
|
|
511
|
+
return { output: joinOutput(result.stdout, result.stderr) }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** 读写自定义提交提示词;传空串表示恢复默认。 */
|
|
515
|
+
async function setCommitPrompt(params, signal) {
|
|
516
|
+
const cwd = assertCwd(params.cwd)
|
|
517
|
+
const resolved = await repoRoot(cwd, signal)
|
|
518
|
+
const root = resolved.root
|
|
519
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
520
|
+
const raw = typeof params.prompt === 'string' ? params.prompt : ''
|
|
521
|
+
if (raw.length > COMMIT_PROMPT_MAX) {
|
|
522
|
+
throw new GitError(`提示词过长(上限 ${COMMIT_PROMPT_MAX} 字)`, { code: 'invalid-params' })
|
|
523
|
+
}
|
|
524
|
+
const value = raw.trim()
|
|
525
|
+
if (value === '') {
|
|
526
|
+
// 没设过时 git config --unset 退出码非 0,不算失败。
|
|
527
|
+
await runGit(['config', '--local', '--unset', COMMIT_PROMPT_KEY], {
|
|
528
|
+
cwd: root, signal, timeoutMs: timeouts.write, allowFailure: true,
|
|
529
|
+
})
|
|
530
|
+
} else {
|
|
531
|
+
await runGit(['config', '--local', COMMIT_PROMPT_KEY, value], {
|
|
532
|
+
cwd: root, signal, timeoutMs: timeouts.write,
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
const read = await runGit(['config', '--get', COMMIT_PROMPT_KEY], {
|
|
536
|
+
cwd: root, signal, allowFailure: true,
|
|
537
|
+
})
|
|
538
|
+
return { prompt: read.code === 0 ? read.stdout.trim() : '' }
|
|
539
|
+
}
|
|
540
|
+
|
|
432
541
|
async function aiCommit(params, signal) {
|
|
433
542
|
const cwd = assertCwd(params.cwd)
|
|
434
543
|
const resolved = await repoRoot(cwd, signal)
|
|
@@ -436,6 +545,10 @@ export function createOperations(deps) {
|
|
|
436
545
|
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
437
546
|
const hint = typeof params.hint === 'string' ? params.hint.slice(0, 500) : ''
|
|
438
547
|
const context = ai() ?? {}
|
|
548
|
+
const promptRes = await runGit(['config', '--get', COMMIT_PROMPT_KEY], {
|
|
549
|
+
cwd: root, signal, allowFailure: true,
|
|
550
|
+
})
|
|
551
|
+
const systemPrompt = promptRes.code === 0 ? promptRes.stdout : ''
|
|
439
552
|
return generate({
|
|
440
553
|
runGit: (args, options = {}) => runGit(args, {
|
|
441
554
|
cwd: root,
|
|
@@ -448,6 +561,7 @@ export function createOperations(deps) {
|
|
|
448
561
|
signal,
|
|
449
562
|
llm: context.llm,
|
|
450
563
|
selection: context.selection,
|
|
564
|
+
systemPrompt,
|
|
451
565
|
})
|
|
452
566
|
}
|
|
453
567
|
|
|
@@ -457,6 +571,7 @@ export function createOperations(deps) {
|
|
|
457
571
|
diff,
|
|
458
572
|
stage,
|
|
459
573
|
unstage,
|
|
574
|
+
discard,
|
|
460
575
|
switchBranch,
|
|
461
576
|
deleteBranch,
|
|
462
577
|
commit,
|
|
@@ -464,6 +579,10 @@ export function createOperations(deps) {
|
|
|
464
579
|
pull,
|
|
465
580
|
push,
|
|
466
581
|
aiCommit,
|
|
582
|
+
stashPush,
|
|
583
|
+
stashPop,
|
|
584
|
+
stashDrop,
|
|
585
|
+
setCommitPrompt,
|
|
467
586
|
}
|
|
468
587
|
|
|
469
588
|
/**
|
|
@@ -507,4 +626,4 @@ export function createOperations(deps) {
|
|
|
507
626
|
}
|
|
508
627
|
|
|
509
628
|
return { handle, ops: [...OPS], runGit }
|
|
510
|
-
}
|
|
629
|
+
}
|