@konnga/dsh-hud 0.9.0 → 0.9.2
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.en.md +179 -0
- package/README.md +105 -68
- package/cordis.patch.yml +1 -1
- package/docs/images/capsules.webp +0 -0
- package/docs/images/image-diff.webp +0 -0
- package/docs/images/pill-tip.webp +0 -0
- package/lib/client.js +5059 -4365
- package/lib/client.js.map +1 -0
- package/lib/host/git.js +188 -0
- package/lib/host/image.js +265 -0
- package/lib/host/index.js +90 -0
- package/lib/host/parse.js +297 -0
- package/lib/host/readers.js +458 -0
- package/lib/host/rpc.js +317 -0
- package/lib/host/services.js +124 -0
- package/lib/host/session.js +1009 -0
- package/lib/host/types.js +12 -0
- package/package.json +30 -9
- package/lib/index.js +0 -2169
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git 输出的纯解析器:`git status --porcelain` / unified diff / `--name-status`。
|
|
3
|
+
*
|
|
4
|
+
* 全是**纯函数**(只吃字符串、只吐结构),没有 I/O、没有 ctx、也没有模块级状态 ——
|
|
5
|
+
* 所以它们最先从 `index.ts` 里搬出来:搬运零风险,且 diff 那几家读法都靠它们。
|
|
6
|
+
*/
|
|
7
|
+
/** 改动清单上限;超出只计数并置 `truncated`。 */
|
|
8
|
+
export const MAX_CHANGES = 200;
|
|
9
|
+
/** 单文件路径上限,防御异常仓库。 */
|
|
10
|
+
/**
|
|
11
|
+
* 解析 `git status --porcelain=v2 --branch -z` 的输出。
|
|
12
|
+
*
|
|
13
|
+
* porcelain-v2 的记录以 NUL 分隔,重命名/复制的记录会多跟一条 NUL 记录作为原路径。
|
|
14
|
+
* 路径本身可能含空格,所以绝不能整行 `split(' ')` —— 按字段数用 `indexOf` 切分。
|
|
15
|
+
*
|
|
16
|
+
* @param raw - git 的原始 stdout。
|
|
17
|
+
* @returns 与 `HudGitStatus` 同形的结果。
|
|
18
|
+
*/
|
|
19
|
+
export function parseStatus(raw) {
|
|
20
|
+
const records = String(raw).split('\0');
|
|
21
|
+
let branch = null;
|
|
22
|
+
let oid = null;
|
|
23
|
+
let upstream = null;
|
|
24
|
+
let ahead = 0;
|
|
25
|
+
let behind = 0;
|
|
26
|
+
let detached = false;
|
|
27
|
+
let truncated = false;
|
|
28
|
+
const counts = {
|
|
29
|
+
modified: 0,
|
|
30
|
+
added: 0,
|
|
31
|
+
deleted: 0,
|
|
32
|
+
renamed: 0,
|
|
33
|
+
copied: 0,
|
|
34
|
+
typeChanged: 0,
|
|
35
|
+
untracked: 0,
|
|
36
|
+
conflicted: 0,
|
|
37
|
+
};
|
|
38
|
+
const changes = [];
|
|
39
|
+
/** 跳过 `count` 个空格分隔的字段,返回其后剩余部分(保留路径里的空格)。 */
|
|
40
|
+
const afterFields = (record, count) => {
|
|
41
|
+
let index = 0;
|
|
42
|
+
for (let seen = 0; seen < count; seen += 1) {
|
|
43
|
+
const space = record.indexOf(' ', index);
|
|
44
|
+
if (space < 0)
|
|
45
|
+
return null;
|
|
46
|
+
index = space + 1;
|
|
47
|
+
}
|
|
48
|
+
return record.slice(index);
|
|
49
|
+
};
|
|
50
|
+
const push = (status, index, worktree, file, origFile) => {
|
|
51
|
+
if (changes.length < MAX_CHANGES) {
|
|
52
|
+
changes.push({
|
|
53
|
+
status,
|
|
54
|
+
index,
|
|
55
|
+
worktree,
|
|
56
|
+
path: origFile ? `${origFile} → ${file}` : file,
|
|
57
|
+
file,
|
|
58
|
+
origFile,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
truncated = true;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
for (let cursor = 0; cursor < records.length; cursor += 1) {
|
|
66
|
+
const record = records[cursor];
|
|
67
|
+
if (!record)
|
|
68
|
+
continue;
|
|
69
|
+
if (record.startsWith('# branch.head ')) {
|
|
70
|
+
const value = record.slice('# branch.head '.length);
|
|
71
|
+
detached = value === '(detached)';
|
|
72
|
+
branch = detached ? null : value;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (record.startsWith('# branch.oid ')) {
|
|
76
|
+
const value = record.slice('# branch.oid '.length);
|
|
77
|
+
oid = value === '(initial)' ? null : value.slice(0, 7);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (record.startsWith('# branch.upstream ')) {
|
|
81
|
+
upstream = record.slice('# branch.upstream '.length) || null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (record.startsWith('# branch.ab ')) {
|
|
85
|
+
const match = /\+(\d+) -(\d+)/.exec(record);
|
|
86
|
+
ahead = Number(match?.[1] ?? 0);
|
|
87
|
+
behind = Number(match?.[2] ?? 0);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (record.startsWith('? ')) {
|
|
91
|
+
counts.untracked += 1;
|
|
92
|
+
push('?', '.', '?', record.slice(2));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (record.startsWith('! ')) {
|
|
96
|
+
// 未使用 --ignored,正常不会出现;出现时按未跟踪处理更贴近用户直觉。
|
|
97
|
+
counts.untracked += 1;
|
|
98
|
+
push('!', '.', '!', record.slice(2));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (record.startsWith('u ')) {
|
|
102
|
+
counts.conflicted += 1;
|
|
103
|
+
const file = afterFields(record, 10);
|
|
104
|
+
if (file !== null)
|
|
105
|
+
push('!', record.slice(2, 4)[0], record.slice(2, 4)[1], file);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (record.startsWith('1 ') || record.startsWith('2 ')) {
|
|
109
|
+
const renamed = record.startsWith('2 ');
|
|
110
|
+
const xy = record.slice(2, 4);
|
|
111
|
+
// 展示字母:优先取 index 侧,index 为 '.' 时取 worktree 侧(与 codex-hud 同规则)。
|
|
112
|
+
const letter = xy[0] !== '.' ? xy[0] : xy[1];
|
|
113
|
+
const file = afterFields(record, renamed ? 9 : 8);
|
|
114
|
+
let origFile = null;
|
|
115
|
+
if (renamed) {
|
|
116
|
+
// 重命名/复制的原路径是紧随其后的一条 NUL 记录。
|
|
117
|
+
cursor += 1;
|
|
118
|
+
origFile = records[cursor] || null;
|
|
119
|
+
}
|
|
120
|
+
if (letter === 'R')
|
|
121
|
+
counts.renamed += 1;
|
|
122
|
+
else if (letter === 'C')
|
|
123
|
+
counts.copied += 1;
|
|
124
|
+
else if (letter === 'A')
|
|
125
|
+
counts.added += 1;
|
|
126
|
+
else if (letter === 'D')
|
|
127
|
+
counts.deleted += 1;
|
|
128
|
+
else if (letter === 'T')
|
|
129
|
+
counts.typeChanged += 1;
|
|
130
|
+
else
|
|
131
|
+
counts.modified += 1;
|
|
132
|
+
if (file !== null)
|
|
133
|
+
push(letter, xy[0], xy[1], file, origFile);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const dirty = Object.values(counts).some((count) => count > 0);
|
|
137
|
+
return {
|
|
138
|
+
repo: null, // 由调用方填入仓库根
|
|
139
|
+
branch: branch ?? oid,
|
|
140
|
+
detached,
|
|
141
|
+
oid,
|
|
142
|
+
upstream,
|
|
143
|
+
ahead,
|
|
144
|
+
behind,
|
|
145
|
+
dirty,
|
|
146
|
+
counts,
|
|
147
|
+
changes,
|
|
148
|
+
truncated,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** 单侧 diff 的文本上限(先按行截断再解析,保证解析量有界)。 */
|
|
152
|
+
export const MAX_DIFF_CHARS = 200_000;
|
|
153
|
+
/** 单侧 diff 的行数上限。 */
|
|
154
|
+
export const MAX_DIFF_ROWS = 2_000;
|
|
155
|
+
/** diff 行的种类:增 / 删 / 上下文 / hunk 头 / git 自己的文件头。 */
|
|
156
|
+
export const DIFF_ADD = 'add';
|
|
157
|
+
export const DIFF_DEL = 'del';
|
|
158
|
+
export const DIFF_CTX = 'ctx';
|
|
159
|
+
export const DIFF_HUNK = 'hunk';
|
|
160
|
+
export const DIFF_META = 'meta';
|
|
161
|
+
/**
|
|
162
|
+
* 把 `git diff` 文本解析成带行号的 rows。
|
|
163
|
+
*
|
|
164
|
+
* 放在宿主半而不是浏览器半:解析是纯函数,可以直接单测,客户端只负责渲染。
|
|
165
|
+
* 行号从每个 hunk 头 `@@ -a,b +c,d @@` 起算;git 自己的文件头(`diff --git`、
|
|
166
|
+
* `index`、`---`、`+++`)只在 hunk 之外识别,因此**内容行**里以 `--`/`++`/`@@`
|
|
167
|
+
* 开头的文本不会被误判。
|
|
168
|
+
*
|
|
169
|
+
* @param raw - git 的原始 stdout。
|
|
170
|
+
* @returns `{ rows, additions, deletions, hunks, binary, truncated }`。
|
|
171
|
+
*/
|
|
172
|
+
export function parseDiff(raw) {
|
|
173
|
+
const lines = String(raw).split('\n');
|
|
174
|
+
const rows = [];
|
|
175
|
+
let oldLine = 0;
|
|
176
|
+
let newLine = 0;
|
|
177
|
+
let inHunk = false;
|
|
178
|
+
let additions = 0;
|
|
179
|
+
let deletions = 0;
|
|
180
|
+
let hunks = 0;
|
|
181
|
+
let binary = false;
|
|
182
|
+
let truncated = false;
|
|
183
|
+
const push = (kind, text, old, next) => {
|
|
184
|
+
if (rows.length >= MAX_DIFF_ROWS) {
|
|
185
|
+
truncated = true;
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
rows.push({ kind, text, oldLine: old, newLine: next });
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
192
|
+
const line = lines[index];
|
|
193
|
+
// 末尾那一行空串是 split 的产物,不是 diff 内容。
|
|
194
|
+
if (index === lines.length - 1 && line === '')
|
|
195
|
+
break;
|
|
196
|
+
if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) {
|
|
197
|
+
binary = true;
|
|
198
|
+
push(DIFF_META, line, null, null);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (line.startsWith('diff --git ')) {
|
|
202
|
+
// 多文件 diff(路径是目录时):新的一段从这里开始。
|
|
203
|
+
inHunk = false;
|
|
204
|
+
push(DIFF_META, line, null, null);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
208
|
+
if (hunk !== null) {
|
|
209
|
+
inHunk = true;
|
|
210
|
+
hunks += 1;
|
|
211
|
+
oldLine = Number(hunk[1]);
|
|
212
|
+
newLine = Number(hunk[2]);
|
|
213
|
+
push(DIFF_HUNK, line, null, null);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!inHunk) {
|
|
217
|
+
push(DIFF_META, line, null, null);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (line.startsWith('+')) {
|
|
221
|
+
additions += 1;
|
|
222
|
+
push(DIFF_ADD, line.slice(1), null, newLine);
|
|
223
|
+
newLine += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (line.startsWith('-')) {
|
|
227
|
+
deletions += 1;
|
|
228
|
+
push(DIFF_DEL, line.slice(1), oldLine, null);
|
|
229
|
+
oldLine += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (line.startsWith(' ')) {
|
|
233
|
+
push(DIFF_CTX, line.slice(1), oldLine, newLine);
|
|
234
|
+
oldLine += 1;
|
|
235
|
+
newLine += 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// 反斜杠行("")等。
|
|
239
|
+
push(DIFF_META, line, null, null);
|
|
240
|
+
}
|
|
241
|
+
return { rows, additions, deletions, hunks, binary, truncated };
|
|
242
|
+
}
|
|
243
|
+
/** 按行边界截断文本。 */
|
|
244
|
+
export function truncateDiff(text) {
|
|
245
|
+
if (text.length <= MAX_DIFF_CHARS)
|
|
246
|
+
return { text, truncated: false };
|
|
247
|
+
const head = text.slice(0, MAX_DIFF_CHARS);
|
|
248
|
+
const lastBreak = head.lastIndexOf('\n');
|
|
249
|
+
return { text: lastBreak > 0 ? head.slice(0, lastBreak) : head, truncated: true };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 解析 `git diff --name-status -z` 的输出。
|
|
253
|
+
*
|
|
254
|
+
* `-z` 用 NUL 收尾,所以含空格/引号的路径原样给出(不会像默认输出那样带引号转义):
|
|
255
|
+
* 一段是 `STATUS\0PATH\0`,重命名/复制则是 `R075\0OLD\0NEW\0`。返回的每条与
|
|
256
|
+
* `status.changes` 同形(`status` 只留 M/A/D/R/C/T/U/X 这类单字母),这样左栏的
|
|
257
|
+
* 改动清单组件可以直接渲染这次的提交。
|
|
258
|
+
*
|
|
259
|
+
* @param raw - git 的原始 stdout。
|
|
260
|
+
* @returns `{ files, truncated }`,条数上限与工作区清单一致。
|
|
261
|
+
*/
|
|
262
|
+
export function parseNameStatus(raw) {
|
|
263
|
+
const tokens = String(raw).split('\0');
|
|
264
|
+
const files = [];
|
|
265
|
+
let truncated = false;
|
|
266
|
+
for (let cursor = 0; cursor < tokens.length;) {
|
|
267
|
+
const status = tokens[cursor];
|
|
268
|
+
cursor += 1;
|
|
269
|
+
// 收尾的 NUL 会留一个空段;非状态行的段落一律跳过,而不是把垃圾当路径。
|
|
270
|
+
if (!status || !/^[A-Z]/.test(status))
|
|
271
|
+
continue;
|
|
272
|
+
const letter = status.slice(0, 1);
|
|
273
|
+
const paired = letter === 'R' || letter === 'C';
|
|
274
|
+
const first = tokens[cursor];
|
|
275
|
+
cursor += 1;
|
|
276
|
+
const second = paired ? tokens[cursor] : null;
|
|
277
|
+
if (paired)
|
|
278
|
+
cursor += 1;
|
|
279
|
+
if (typeof first !== 'string' || first === '')
|
|
280
|
+
continue;
|
|
281
|
+
const file = paired ? second : first;
|
|
282
|
+
const origFile = paired ? first : null;
|
|
283
|
+
if (typeof file !== 'string' || file === '')
|
|
284
|
+
continue;
|
|
285
|
+
if (files.length >= MAX_CHANGES) {
|
|
286
|
+
truncated = true;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
files.push({
|
|
290
|
+
status: letter,
|
|
291
|
+
path: origFile === null ? file : `${origFile} → ${file}`,
|
|
292
|
+
file,
|
|
293
|
+
origFile,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return { files, truncated };
|
|
297
|
+
}
|