@konnga/dsh-hud 0.7.1 → 0.9.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/README.md +91 -59
- package/docs/images/capsules.webp +0 -0
- package/docs/images/panel.webp +0 -0
- package/docs/images/turn-images.webp +0 -0
- package/lib/client.js +1174 -253
- package/lib/index.js +569 -4
- package/package.json +14 -4
package/lib/index.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* - `diff { cwd, path, origPath? }` → 一个改动文件的**两侧** unified diff
|
|
18
18
|
* (工作区 / 已暂存),已解析成带行号的 rows;未跟踪文件与 `--no-index` 对齐,
|
|
19
19
|
* 文本按行截断、行数有上限,二进制只报 `binary`。
|
|
20
|
+
* - `image { cwd, path, origPath?, side?, sha? }` → 图片改动**两侧**的字节与像素尺寸
|
|
21
|
+
* (`kind` 为 `image` / `too-large` / `not-image` / `missing`),给二进制预览用;
|
|
22
|
+
* 单侧上限 `MAX_IMAGE_BYTES`,超限只回报字节数。
|
|
20
23
|
* - `log { cwd, count? }` → 最近提交(短 sha、作者、日期、refs、标题),条数钳制 1..50。
|
|
21
24
|
* - `commit { cwd, sha }` → 某次提交的元信息与改动清单(相对**第一个父提交**,
|
|
22
25
|
* 合并提交也按第一父提交看),清单形状与 `status.changes` 一致。
|
|
@@ -256,7 +259,10 @@ export async function readStatus(cwd) {
|
|
|
256
259
|
return { repo: null, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, dirty: false, counts: emptyCounts(), changes: [], truncated: false }
|
|
257
260
|
}
|
|
258
261
|
|
|
259
|
-
|
|
262
|
+
// `--untracked-files=all`:未跟踪**按文件**计,而不是把整个未跟踪目录折成一条。
|
|
263
|
+
// 不这样写的话,`?n` 会比编辑器(以及 `git status -uall`)报的数小得多 —— 目录里 200 个新文件
|
|
264
|
+
// 只算 1 条。代价是 git 要走一遍未跟踪树,所以清单仍受 `MAX_CHANGES` 上限保护(超出置 truncated)。
|
|
265
|
+
const status = await runGit(repoRoot, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'])
|
|
260
266
|
if (!status.ok) {
|
|
261
267
|
if (status.killed) throw Object.assign(new Error('git status timed out'), { code: 'timeout' })
|
|
262
268
|
throw Object.assign(new Error(status.stderr.trim() || status.message || 'git status failed'), { code: 'git-failed' })
|
|
@@ -318,12 +324,29 @@ export async function readSessionOverview() {
|
|
|
318
324
|
mine: entry === undefined ? 0 : entry.files.size,
|
|
319
325
|
possible: entry === undefined ? 0 : entry.possible.size,
|
|
320
326
|
}
|
|
321
|
-
|
|
327
|
+
/**
|
|
328
|
+
* 「可能相关」里**此刻仍然有差异**的个数(与这份 status 的清单求交)。
|
|
329
|
+
*
|
|
330
|
+
* 为什么要两个数:`possible` 是"命令里提到过的路径"(历史,含已经还原/被忽略的),
|
|
331
|
+
* 面板那一行只数"现在还有差异"的。同一个概念两个口径,界面上必须分别说清,
|
|
332
|
+
* 否则就会出现"工作区干净 + 14 个可能相关"这种让人以为算错了的组合。
|
|
333
|
+
*/
|
|
334
|
+
const possibleNow = entry === undefined
|
|
335
|
+
? 0
|
|
336
|
+
: (() => {
|
|
337
|
+
const status = row.cwd === null ? undefined : byCwd.get(row.cwd)
|
|
338
|
+
if (status === undefined || status === null || Array.isArray(status.changes) !== true) return 0
|
|
339
|
+
let count = 0
|
|
340
|
+
for (const change of status.changes) if (entry.possible.has(change.file)) count += 1
|
|
341
|
+
return count
|
|
342
|
+
})()
|
|
343
|
+
if (row.cwd === null) return { ...summary, possibleNow: 0, repo: false, error: null }
|
|
322
344
|
const status = byCwd.get(row.cwd)
|
|
323
|
-
if (status === undefined || status === null) return { ...summary, repo: false, error: null, pending: true }
|
|
324
|
-
if (status.error !== undefined) return { ...summary, repo: false, error: status.error }
|
|
345
|
+
if (status === undefined || status === null) return { ...summary, possibleNow: 0, repo: false, error: null, pending: true }
|
|
346
|
+
if (status.error !== undefined) return { ...summary, possibleNow: 0, repo: false, error: status.error }
|
|
325
347
|
return {
|
|
326
348
|
...summary,
|
|
349
|
+
possibleNow,
|
|
327
350
|
repo: status.repo !== null,
|
|
328
351
|
branch: status.branch,
|
|
329
352
|
detached: status.detached === true,
|
|
@@ -758,6 +781,78 @@ async function resolveCommit(cwd, sha) {
|
|
|
758
781
|
}
|
|
759
782
|
|
|
760
783
|
/** 这次提交的改动清单 + 元信息(只读 `git diff` / `git show`)。 */
|
|
784
|
+
/** 标签列表上限(超出的只报 truncated,不硬塞)。 */
|
|
785
|
+
const TAGS_MAX = 100
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* 读标签列表(`refs/tags`,按 creatordate 倒序 = 最新的在前)。
|
|
789
|
+
*
|
|
790
|
+
* 两处容易搞错、这里显式处理:
|
|
791
|
+
* - **附注 tag**(annotated)的 `%(objectname)` 是 **tag 对象**的 sha,不是它指向的提交;
|
|
792
|
+
* 要拿提交得用 `%(*objectname)`。轻量 tag 两者相同。列表里显示提交 sha(能对上“最近提交”)。
|
|
793
|
+
* - 主题用 `%(contents:subject)`:附注 tag 是 tag 说明的首行,轻量 tag 拿到的是提交标题 ——
|
|
794
|
+
* 两种情况都比只给一个名字有用;它理论上可以是多行,所以最后一个字段按“剩下全部”再取首行。
|
|
795
|
+
*/
|
|
796
|
+
export async function readTags(cwd, count = TAGS_MAX) {
|
|
797
|
+
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
798
|
+
if (!root.ok) {
|
|
799
|
+
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
800
|
+
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
801
|
+
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
802
|
+
return { repo: null, tags: [], truncated: false }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
806
|
+
if (!repoRoot) return { repo: null, tags: [], truncated: false }
|
|
807
|
+
|
|
808
|
+
const requested = Number.isFinite(count) ? Math.floor(count) : TAGS_MAX
|
|
809
|
+
const limit = Math.min(Math.max(1, requested), TAGS_MAX)
|
|
810
|
+
|
|
811
|
+
// 多取一条用来判断“还有更多”(比事后猜“正好等于上限”准确)。
|
|
812
|
+
const result = await runGit(repoRoot, [
|
|
813
|
+
'for-each-ref',
|
|
814
|
+
'--sort=-creatordate',
|
|
815
|
+
`--count=${String(limit + 1)}`,
|
|
816
|
+
'--format=%(refname:short)%00%(objecttype)%00%(objectname:short)%00%(*objectname:short)%00%(creatordate:short)%00%(creatordate:unix)%00%(contents:subject)',
|
|
817
|
+
'refs/tags',
|
|
818
|
+
])
|
|
819
|
+
// 没有 tag 时 git 也返回 0(输出为空),不是错误。
|
|
820
|
+
if (!result.ok) return { repo: repoRoot, tags: [], truncated: false }
|
|
821
|
+
|
|
822
|
+
const lines = result.stdout.split('\n').filter(line => line.length > 0)
|
|
823
|
+
const tags = lines.map((line) => {
|
|
824
|
+
const parts = line.split('\0')
|
|
825
|
+
const [name, objecttype, objectname, peeled, date, seconds] = parts
|
|
826
|
+
// 最后一个字段是主题:附注 tag 的说明可能是多行,这里只取首行。
|
|
827
|
+
const subject = parts.slice(6).join('\0').split('\n')[0] ?? ''
|
|
828
|
+
const at = Number(seconds)
|
|
829
|
+
const annotated = objecttype === 'tag'
|
|
830
|
+
// 附注 tag 要“剥一层”拿提交 sha;剥不出来(比如 tag 指向 blob/tree)就退回对象自身的 sha。
|
|
831
|
+
const sha = annotated && peeled ? peeled : objectname
|
|
832
|
+
return {
|
|
833
|
+
name: name ?? '',
|
|
834
|
+
sha: sha ?? '',
|
|
835
|
+
annotated,
|
|
836
|
+
date: date ?? '',
|
|
837
|
+
committedAt: Number.isFinite(at) ? at * 1000 : null,
|
|
838
|
+
subject,
|
|
839
|
+
}
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
// creatordate 同一个秒内的先后 git 不保证(测试里三个 tag 常在同一秒建出来),
|
|
843
|
+
// 再按名字倒序定死 —— 否则界面上同批 tag 每次刷新都可能换位置。
|
|
844
|
+
//
|
|
845
|
+
// 顺序:**先排序再截断**。反过来的话截出来的是 git 的任意顺序(t1、t2),排序再好也没用。
|
|
846
|
+
tags.sort((left, right) => {
|
|
847
|
+
const diff = (right.committedAt ?? 0) - (left.committedAt ?? 0)
|
|
848
|
+
if (diff !== 0) return diff
|
|
849
|
+
return left.name < right.name ? 1 : (left.name > right.name ? -1 : 0)
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
const truncated = tags.length > limit
|
|
853
|
+
return { repo: repoRoot, tags: tags.slice(0, limit), truncated }
|
|
854
|
+
}
|
|
855
|
+
|
|
761
856
|
export async function readCommit(cwd, sha) {
|
|
762
857
|
const commit = await resolveCommit(cwd, sha)
|
|
763
858
|
if (!commit.repo) return { repo: null, commit: null, files: [], truncated: false }
|
|
@@ -824,6 +919,270 @@ export async function readCommitDiff(cwd, sha, file, origFile = null) {
|
|
|
824
919
|
}
|
|
825
920
|
}
|
|
826
921
|
|
|
922
|
+
/* --------------------------------------------- 二进制预览(图片,只读) */
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* 单侧图片预览的字节上限。
|
|
926
|
+
*
|
|
927
|
+
* 超限不等于"看不了这个改动":仍然回报真实字节数,只是不把内容塞进 JSON
|
|
928
|
+
* (base64 还要再涨 1/3,RPC 体量必须有界)。
|
|
929
|
+
*/
|
|
930
|
+
export const MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
|
931
|
+
|
|
932
|
+
/** PNG 的签名(8 字节)。 */
|
|
933
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* 从**内容**而不是扩展名认图片类型(扩展名会骗人:`.png` 里放 gif 很常见)。
|
|
937
|
+
*
|
|
938
|
+
* @param buffer - 文件头若干字节。
|
|
939
|
+
* @returns MIME 字符串,认不出返回 null。
|
|
940
|
+
*/
|
|
941
|
+
export function sniffImageMime(buffer) {
|
|
942
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null
|
|
943
|
+
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return 'image/png'
|
|
944
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
|
|
945
|
+
const gif = buffer.subarray(0, 6).toString('latin1')
|
|
946
|
+
if (gif === 'GIF87a' || gif === 'GIF89a') return 'image/gif'
|
|
947
|
+
if (buffer.subarray(0, 4).toString('latin1') === 'RIFF' && buffer.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
|
948
|
+
if (buffer.subarray(0, 2).toString('latin1') === 'BM') return 'image/bmp'
|
|
949
|
+
return null
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** PNG:IHDR 紧跟签名,宽高各 4 字节大端。 */
|
|
953
|
+
function pngSize(buffer) {
|
|
954
|
+
if (buffer.length < 24) return null
|
|
955
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** GIF:逻辑屏幕描述符,宽高各 2 字节小端。 */
|
|
959
|
+
function gifSize(buffer) {
|
|
960
|
+
if (buffer.length < 10) return null
|
|
961
|
+
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* JPEG:从头扫段,找 SOFn(C0–CF,但 C4/C8/CC 不是)里的高/宽。
|
|
966
|
+
*
|
|
967
|
+
* 段长字段是**含自身两字节**的长度,所以下一段起点是 `index + 2 + length`;
|
|
968
|
+
* 填充 0xFF、TEM 与 RSTn 都没有长度字段,只前进一字节。
|
|
969
|
+
*/
|
|
970
|
+
function jpegSize(buffer) {
|
|
971
|
+
let index = 2
|
|
972
|
+
while (index + 9 < buffer.length) {
|
|
973
|
+
if (buffer[index] !== 0xff) {
|
|
974
|
+
index += 1
|
|
975
|
+
continue
|
|
976
|
+
}
|
|
977
|
+
const marker = buffer[index + 1]
|
|
978
|
+
if (marker === 0xff || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
979
|
+
index += 1
|
|
980
|
+
continue
|
|
981
|
+
}
|
|
982
|
+
const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
|
|
983
|
+
if (isFrame) return { width: buffer.readUInt16BE(index + 7), height: buffer.readUInt16BE(index + 5) }
|
|
984
|
+
const length = buffer.readUInt16BE(index + 2)
|
|
985
|
+
if (length < 2) return null
|
|
986
|
+
index += 2 + length
|
|
987
|
+
}
|
|
988
|
+
return null
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** WebP:VP8X 是 24 位减一、VP8(有损)内嵌 14 位、VP8L(无损)打包在 32 位里。 */
|
|
992
|
+
function webpSize(buffer) {
|
|
993
|
+
if (buffer.length < 30) return null
|
|
994
|
+
const chunk = buffer.subarray(12, 16).toString('latin1')
|
|
995
|
+
if (chunk === 'VP8X') return { width: buffer.readUIntLE(24, 3) + 1, height: buffer.readUIntLE(27, 3) + 1 }
|
|
996
|
+
if (chunk === 'VP8 ' && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) {
|
|
997
|
+
return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }
|
|
998
|
+
}
|
|
999
|
+
if (chunk === 'VP8L' && buffer[20] === 0x2f) {
|
|
1000
|
+
const packed = buffer.readUInt32LE(21)
|
|
1001
|
+
return { width: (packed & 0x3fff) + 1, height: ((packed >> 14) & 0x3fff) + 1 }
|
|
1002
|
+
}
|
|
1003
|
+
return null
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** BMP:BITMAPINFOHEADER 的宽为有符号、高可为负(自上而下)。 */
|
|
1007
|
+
function bmpSize(buffer) {
|
|
1008
|
+
if (buffer.length < 26) return null
|
|
1009
|
+
const width = buffer.readInt32LE(18)
|
|
1010
|
+
const height = buffer.readInt32LE(22)
|
|
1011
|
+
if (width <= 0 || height === 0) return null
|
|
1012
|
+
return { width, height: Math.abs(height) }
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* 读出图片的像素尺寸。纯函数,只读头部,认不出返回 `null`(绝不抛)。
|
|
1017
|
+
*
|
|
1018
|
+
* @param buffer - 文件内容(头部足够即可,但这里拿的是完整内容)。
|
|
1019
|
+
* @param mime - `sniffImageMime` 的结果。
|
|
1020
|
+
* @returns `{ width, height }` 或 null。
|
|
1021
|
+
*/
|
|
1022
|
+
export function readImageSize(buffer, mime) {
|
|
1023
|
+
try {
|
|
1024
|
+
if (mime === 'image/png') return pngSize(buffer)
|
|
1025
|
+
if (mime === 'image/jpeg') return jpegSize(buffer)
|
|
1026
|
+
if (mime === 'image/gif') return gifSize(buffer)
|
|
1027
|
+
if (mime === 'image/webp') return webpSize(buffer)
|
|
1028
|
+
if (mime === 'image/bmp') return bmpSize(buffer)
|
|
1029
|
+
return null
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
return null
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* `runGit` 的字节版。
|
|
1038
|
+
*
|
|
1039
|
+
* `cat-file blob` 的输出是任意二进制,按 utf8 解码会毁掉它(非法序列变成 U+FFFD,
|
|
1040
|
+
* 再编码回去就不是原文件了),所以这条路径必须单独走 buffer 编码。
|
|
1041
|
+
*/
|
|
1042
|
+
function runGitBytes(cwd, args, maxBytes) {
|
|
1043
|
+
return new Promise((resolve) => {
|
|
1044
|
+
execFile(
|
|
1045
|
+
'git',
|
|
1046
|
+
args,
|
|
1047
|
+
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: maxBytes, windowsHide: true, encoding: 'buffer' },
|
|
1048
|
+
(error, stdout, stderr) => {
|
|
1049
|
+
if (!error) {
|
|
1050
|
+
resolve({ ok: true, stdout })
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
resolve({
|
|
1054
|
+
ok: false,
|
|
1055
|
+
stdout,
|
|
1056
|
+
stderr: stderr ? stderr.toString('utf8') : '',
|
|
1057
|
+
killed: Boolean(error.killed) || Boolean(error.signal),
|
|
1058
|
+
code: typeof error.code === 'number' ? error.code : (error.code ?? null),
|
|
1059
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1060
|
+
})
|
|
1061
|
+
},
|
|
1062
|
+
)
|
|
1063
|
+
})
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** 一个 git 对象的字节数;对象不存在(新文件还没有索引副本)返回 null。 */
|
|
1067
|
+
async function gitBlobSize(repoRoot, spec) {
|
|
1068
|
+
const result = await runGit(repoRoot, ['cat-file', '-s', spec])
|
|
1069
|
+
if (!result.ok) return null
|
|
1070
|
+
const size = Number.parseInt(result.stdout.trim(), 10)
|
|
1071
|
+
return Number.isFinite(size) && size >= 0 ? size : null
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** 判断一侧的内容:能预览就带上 base64 与像素尺寸,否则只说"为什么不能"。 */
|
|
1075
|
+
function describeSide(buffer) {
|
|
1076
|
+
const bytes = buffer.length
|
|
1077
|
+
const mime = sniffImageMime(buffer)
|
|
1078
|
+
if (mime === null) return { kind: 'not-image', bytes }
|
|
1079
|
+
const size = readImageSize(buffer, mime)
|
|
1080
|
+
return {
|
|
1081
|
+
kind: 'image',
|
|
1082
|
+
mime,
|
|
1083
|
+
bytes,
|
|
1084
|
+
width: size === null ? null : size.width,
|
|
1085
|
+
height: size === null ? null : size.height,
|
|
1086
|
+
base64: buffer.toString('base64'),
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** 读工作区里的一个文件;不是普通文件、或超过上限,都只回报状态。 */
|
|
1091
|
+
async function readWorktreeSide(repoRoot, file) {
|
|
1092
|
+
const full = path.resolve(repoRoot, file)
|
|
1093
|
+
const rootPrefix = repoRoot.endsWith(path.sep) ? repoRoot : repoRoot + path.sep
|
|
1094
|
+
// validateDiffPath 已经挡掉绝对路径与 `..`;这里再确认落在仓库内(软链接不越界)。
|
|
1095
|
+
if (!full.startsWith(rootPrefix)) return { kind: 'missing' }
|
|
1096
|
+
let stats
|
|
1097
|
+
try {
|
|
1098
|
+
stats = await fs.promises.stat(full)
|
|
1099
|
+
}
|
|
1100
|
+
catch {
|
|
1101
|
+
return { kind: 'missing' }
|
|
1102
|
+
}
|
|
1103
|
+
if (!stats.isFile()) return { kind: 'missing' }
|
|
1104
|
+
if (stats.size > MAX_IMAGE_BYTES) return { kind: 'too-large', bytes: stats.size, limit: MAX_IMAGE_BYTES }
|
|
1105
|
+
try {
|
|
1106
|
+
return describeSide(await fs.promises.readFile(full))
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
return { kind: 'missing' }
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** 读一个 git 对象(`<rev>:<path>`,rev 为空串即索引):不存在即 missing,超限只回报字节数。 */
|
|
1114
|
+
async function readGitSide(repoRoot, rev, file) {
|
|
1115
|
+
const spec = `${rev}:${file}`
|
|
1116
|
+
const size = await gitBlobSize(repoRoot, spec)
|
|
1117
|
+
if (size === null) return { kind: 'missing' }
|
|
1118
|
+
if (size > MAX_IMAGE_BYTES) return { kind: 'too-large', bytes: size, limit: MAX_IMAGE_BYTES }
|
|
1119
|
+
const result = await runGitBytes(repoRoot, ['cat-file', 'blob', spec], MAX_IMAGE_BYTES + 64 * 1024)
|
|
1120
|
+
if (!result.ok) {
|
|
1121
|
+
if (result.killed) throw Object.assign(new Error('git cat-file timed out'), { code: 'timeout' })
|
|
1122
|
+
return { kind: 'missing' }
|
|
1123
|
+
}
|
|
1124
|
+
return describeSide(result.stdout)
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* 读一个二进制(图片)改动的两侧字节与像素尺寸,全部只读。
|
|
1129
|
+
*
|
|
1130
|
+
* `side` 与文本 diff 的两枚页签一一对应,这样"未暂存 / 已暂存"在两个视图里指的是同一件事:
|
|
1131
|
+
* - `worktree`(未暂存):基线 = 索引 `:<path>`,新 = 工作区文件;
|
|
1132
|
+
* - `index`(已暂存):基线 = `HEAD`,新 = 索引 `:<path>`;
|
|
1133
|
+
* - 给了 `sha`(提交明细):基线 = 第一父提交(根提交则为空),新 = 该提交。
|
|
1134
|
+
*
|
|
1135
|
+
* 两侧各自回答"能不能预览":`kind` 为 `image` / `too-large` / `not-image` / `missing`。
|
|
1136
|
+
* 任一侧不可预览都不算失败 —— 另一侧照样能看。
|
|
1137
|
+
*
|
|
1138
|
+
* @param cwd - 会话工作区(绝对路径)。
|
|
1139
|
+
* @param file - 仓库内相对路径。
|
|
1140
|
+
* @param origFile - 重命名/复制的原路径,可选。
|
|
1141
|
+
* @param side - `'worktree'` | `'index'`,给了 sha 时忽略。
|
|
1142
|
+
* @param sha - 提交 sha(提交明细用),可选。
|
|
1143
|
+
* @returns `{ repo, path, origPath, side, sha, before, after }`。
|
|
1144
|
+
*/
|
|
1145
|
+
export async function readImagePair(cwd, file, origFile = null, side = 'worktree', sha = null) {
|
|
1146
|
+
const base = { path: file, origPath: origFile ?? null, side: sha === null ? side : 'commit', sha }
|
|
1147
|
+
const empty = { repo: null, ...base, before: { kind: 'missing' }, after: { kind: 'missing' } }
|
|
1148
|
+
|
|
1149
|
+
if (sha !== null) {
|
|
1150
|
+
const commit = await resolveCommit(cwd, sha)
|
|
1151
|
+
if (!commit.repo) return empty
|
|
1152
|
+
return {
|
|
1153
|
+
repo: commit.repo,
|
|
1154
|
+
shortSha: commit.shortSha,
|
|
1155
|
+
...base,
|
|
1156
|
+
before: commit.base === null ? { kind: 'missing' } : await readGitSide(commit.repo, commit.base, origFile ?? file),
|
|
1157
|
+
after: await readGitSide(commit.repo, commit.sha, file),
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
1162
|
+
if (!root.ok) {
|
|
1163
|
+
if (root.killed) throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' })
|
|
1164
|
+
if (root.code === 'ENOENT' && !fs.existsSync(cwd)) throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' })
|
|
1165
|
+
if (root.code === 'ENOENT') throw Object.assign(new Error('git executable not found'), { code: 'git-missing' })
|
|
1166
|
+
return empty
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const repoRoot = root.stdout.split('\n')[0].trim()
|
|
1170
|
+
if (side === 'index') {
|
|
1171
|
+
return {
|
|
1172
|
+
repo: repoRoot,
|
|
1173
|
+
...base,
|
|
1174
|
+
before: await readGitSide(repoRoot, 'HEAD', origFile ?? file),
|
|
1175
|
+
after: await readGitSide(repoRoot, '', file),
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
repo: repoRoot,
|
|
1180
|
+
...base,
|
|
1181
|
+
before: await readGitSide(repoRoot, '', origFile ?? file),
|
|
1182
|
+
after: await readWorktreeSide(repoRoot, file),
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
827
1186
|
/* ------------------------------------------------- 会话改动(按会话归因) */
|
|
828
1187
|
|
|
829
1188
|
/**
|
|
@@ -858,6 +1217,161 @@ const SESSION_COMMAND_MAX = 20
|
|
|
858
1217
|
const COMMAND_TEXT_MAX = 140
|
|
859
1218
|
/** 一条命令里最多抽多少个路径(防御性上限)。 */
|
|
860
1219
|
const COMMAND_PATHS_MAX = 20
|
|
1220
|
+
/** 每个会话留多少条"最近工具调用"(胶囊悬停里列最近几条)。 */
|
|
1221
|
+
const TOOL_RECENT_MAX = 12
|
|
1222
|
+
/** 最多同时记多少个会话的工具活动(与归因那份各自独立淘汰)。 */
|
|
1223
|
+
const TOOL_SESSIONS_MAX = 32
|
|
1224
|
+
/** 每个会话最多记多少个技能(超出的只计数不列名)。 */
|
|
1225
|
+
const TOOL_SKILLS_MAX = 20
|
|
1226
|
+
/** 参数摘要的显示上限(超长命令/路径截断,别把胶囊撑爆)。 */
|
|
1227
|
+
const TOOL_SUMMARY_MAX = 60
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* sessionId → { active, recent, counts, total }:这个会话**跑到哪些工具**。
|
|
1231
|
+
*
|
|
1232
|
+
* 与归因那份记录分开存:归因关心"改了哪些文件",这份关心"跑了什么"。
|
|
1233
|
+
*/
|
|
1234
|
+
const sessionTools = new Map()
|
|
1235
|
+
|
|
1236
|
+
/** 取(必要时新建)某个会话的工具活动记录,并做数量上限淘汰。 */
|
|
1237
|
+
function sessionToolEntry(sessionId) {
|
|
1238
|
+
let entry = sessionTools.get(sessionId)
|
|
1239
|
+
if (entry === undefined) {
|
|
1240
|
+
entry = { active: null, recent: [], counts: new Map(), status: new Map(), skills: new Map(), total: 0 }
|
|
1241
|
+
sessionTools.set(sessionId, entry)
|
|
1242
|
+
if (sessionTools.size > TOOL_SESSIONS_MAX) {
|
|
1243
|
+
const oldest = sessionTools.keys().next().value
|
|
1244
|
+
if (oldest !== undefined && oldest !== sessionId) sessionTools.delete(oldest)
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return entry
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* 参数里可以安全拿出来做"一行摘要"的键。
|
|
1252
|
+
*
|
|
1253
|
+
* 只认这些**路径/命令类**的键:工具参数里可能带着整个文件内容(write 的 `content`),
|
|
1254
|
+
* 随便取"第一个字符串"就会把内容贴到界面上 —— 所以认不出来就干脆不写摘要。
|
|
1255
|
+
*/
|
|
1256
|
+
const TOOL_SUMMARY_KEYS = ['command', 'cmd', 'script', 'filePath', 'path', 'file', 'pattern', 'query', 'url']
|
|
1257
|
+
|
|
1258
|
+
/** 「调用技能」那件工具的工具名(`dsh-tool-skill` 注册的名字)。 */
|
|
1259
|
+
const SKILL_TOOL_NAME = 'skill'
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* 技能名:`skill` 工具的入参是 `{ name: '技能名' }`。
|
|
1263
|
+
*
|
|
1264
|
+
* 只对这一个工具认 `name` 键 —— 别的工具用 `name` 表达别的东西,不能被当技能名抓走。
|
|
1265
|
+
*/
|
|
1266
|
+
function skillNameOf(exec) {
|
|
1267
|
+
if (exec === null || typeof exec !== 'object' || exec.name !== SKILL_TOOL_NAME) return null
|
|
1268
|
+
const args = exec.arguments
|
|
1269
|
+
if (args === null || typeof args !== 'object') return null
|
|
1270
|
+
const value = args.name
|
|
1271
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim().slice(0, TOOL_SUMMARY_MAX) : null
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** 从工具调用参数里取一行摘要(认不出来返回 null,不用兜底去猜)。 */
|
|
1275
|
+
function toolSummaryOf(exec) {
|
|
1276
|
+
const skill = skillNameOf(exec)
|
|
1277
|
+
if (skill !== null) return skill
|
|
1278
|
+
const args = exec && exec.arguments
|
|
1279
|
+
if (args === null || typeof args !== 'object') return null
|
|
1280
|
+
for (const key of TOOL_SUMMARY_KEYS) {
|
|
1281
|
+
const value = args[key]
|
|
1282
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
1283
|
+
return value.replace(/\s+/g, ' ').trim().slice(0, TOOL_SUMMARY_MAX)
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return null
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** 工具开跑:记下"现在是哪个工具、什么时候开始的"(供胶囊显示实时秒数)。 */
|
|
1290
|
+
function noteToolStart(exec) {
|
|
1291
|
+
try {
|
|
1292
|
+
const identity = sessionIdentityOf(exec)
|
|
1293
|
+
if (identity === null) return
|
|
1294
|
+
const entry = sessionToolEntry(identity.sessionId)
|
|
1295
|
+
entry.active = {
|
|
1296
|
+
callId: identity.callId,
|
|
1297
|
+
name: typeof exec.name === 'string' && exec.name.length > 0 ? exec.name : '?',
|
|
1298
|
+
summary: toolSummaryOf(exec),
|
|
1299
|
+
startedAt: Date.now(),
|
|
1300
|
+
}
|
|
1301
|
+
entry.total += 1
|
|
1302
|
+
const name = entry.active.name
|
|
1303
|
+
entry.counts.set(name, (entry.counts.get(name) || 0) + 1)
|
|
1304
|
+
const skill = skillNameOf(exec)
|
|
1305
|
+
if (skill !== null) {
|
|
1306
|
+
const seen = entry.skills.get(skill)
|
|
1307
|
+
entry.skills.set(skill, { count: (seen ? seen.count : 0) + 1, ok: seen ? seen.ok : null })
|
|
1308
|
+
}
|
|
1309
|
+
} catch {
|
|
1310
|
+
// 旁听失败不影响工具本身。
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** 工具结束:把这一次挪进"最近",并清掉 active(只有同一个 callId 才清,避免并发调用互相抹掉)。 */
|
|
1315
|
+
function noteToolFinish(exec, result) {
|
|
1316
|
+
try {
|
|
1317
|
+
const identity = sessionIdentityOf(exec)
|
|
1318
|
+
if (identity === null) return
|
|
1319
|
+
const entry = sessionToolEntry(identity.sessionId)
|
|
1320
|
+
const active = entry.active
|
|
1321
|
+
const startedAt = active !== null && active.callId === identity.callId ? active.startedAt : null
|
|
1322
|
+
entry.recent.push({
|
|
1323
|
+
name: active !== null && active.callId === identity.callId ? active.name : (typeof exec.name === 'string' ? exec.name : '?'),
|
|
1324
|
+
summary: active !== null && active.callId === identity.callId ? active.summary : toolSummaryOf(exec),
|
|
1325
|
+
ok: !resultFailed(result),
|
|
1326
|
+
at: Date.now(),
|
|
1327
|
+
ms: startedAt === null ? null : Math.max(0, Date.now() - startedAt),
|
|
1328
|
+
})
|
|
1329
|
+
if (entry.recent.length > TOOL_RECENT_MAX) entry.recent.splice(0, entry.recent.length - TOOL_RECENT_MAX)
|
|
1330
|
+
// 每个工具"最近一次调用"的结果:胶囊上每个工具前面那个 ✓/✗ 用它。
|
|
1331
|
+
const finished = entry.recent[entry.recent.length - 1]
|
|
1332
|
+
if (finished !== undefined) entry.status.set(finished.name, finished.ok === true)
|
|
1333
|
+
// 技能自己的最近一次结果(胶囊上可以据此打 ✓/✗)。
|
|
1334
|
+
const skill = skillNameOf(exec)
|
|
1335
|
+
if (skill !== null && entry.skills.has(skill)) {
|
|
1336
|
+
entry.skills.set(skill, { count: entry.skills.get(skill).count, ok: !resultFailed(result) })
|
|
1337
|
+
}
|
|
1338
|
+
if (active !== null && active.callId === identity.callId) entry.active = null
|
|
1339
|
+
} catch {
|
|
1340
|
+
// 同上。
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* 读出某个会话的工具活动(端点载荷)。
|
|
1346
|
+
*
|
|
1347
|
+
* `active` 带 `startedAt`:实时秒数由客户端本地计时(否则要为了一个秒数把轮询压到亚秒级 ✗)。
|
|
1348
|
+
*/
|
|
1349
|
+
export function readSessionTools(sessionId) {
|
|
1350
|
+
const entry = sessionId === null || sessionId === undefined ? undefined : sessionTools.get(sessionId)
|
|
1351
|
+
if (entry === undefined) {
|
|
1352
|
+
return { sessionId: sessionId ?? null, tracking: true, active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0 }
|
|
1353
|
+
}
|
|
1354
|
+
const counts = [...entry.counts].map(([name, count]) => ({
|
|
1355
|
+
name,
|
|
1356
|
+
count,
|
|
1357
|
+
// `null` = 还没看到它结束过(比如正在跑第一次),别替它编一个 ✓。
|
|
1358
|
+
ok: entry.status.has(name) ? entry.status.get(name) : null,
|
|
1359
|
+
}))
|
|
1360
|
+
counts.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1))
|
|
1361
|
+
// 技能按次数排序(用得多在前),超过上限只报前若干个。
|
|
1362
|
+
const skills = [...entry.skills].map(([name, item]) => ({ name, count: item.count, ok: item.ok }))
|
|
1363
|
+
skills.sort((left, right) => right.count - left.count || (left.name < right.name ? -1 : 1))
|
|
1364
|
+
return {
|
|
1365
|
+
sessionId,
|
|
1366
|
+
tracking: true,
|
|
1367
|
+
active: entry.active === null ? null : { ...entry.active },
|
|
1368
|
+
recent: entry.recent.slice(-5).reverse(),
|
|
1369
|
+
counts,
|
|
1370
|
+
skills: skills.slice(0, TOOL_SKILLS_MAX),
|
|
1371
|
+
skillsDropped: Math.max(0, skills.length - TOOL_SKILLS_MAX),
|
|
1372
|
+
total: entry.total,
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
861
1375
|
|
|
862
1376
|
/** sessionId → { cwd, since, files: Map<relPath, {...}>, dropped, unconfirmed }。 */
|
|
863
1377
|
const sessionChanges = new Map()
|
|
@@ -1316,8 +1830,15 @@ function trackSessionChanges(ctx) {
|
|
|
1316
1830
|
}),
|
|
1317
1831
|
// 命令旁听:`tools/post-execute` 是 waterfall,**必须把 next() 的判定原样透传**
|
|
1318
1832
|
// (兜底是 `{ kind: 'accept' }`,吞掉就等于把工具结果的裁决改掉了)。
|
|
1833
|
+
// 工具开跑:记"现在在跑哪个工具"(胶囊显示实时秒数用)。
|
|
1834
|
+
// `tools/pre-execute` 同样是 waterfall(兜底 `{ kind: 'allow' }`),必须原样透传判定。
|
|
1835
|
+
ctx.on('tools/pre-execute', (exec, next) => {
|
|
1836
|
+
noteToolStart(exec)
|
|
1837
|
+
return next()
|
|
1838
|
+
}),
|
|
1319
1839
|
ctx.on('tools/post-execute', (exec, result, next) => {
|
|
1320
1840
|
noteShellCommand(exec, result)
|
|
1841
|
+
noteToolFinish(exec, result)
|
|
1321
1842
|
return next()
|
|
1322
1843
|
}),
|
|
1323
1844
|
]
|
|
@@ -1351,6 +1872,7 @@ function trackSessionChanges(ctx) {
|
|
|
1351
1872
|
for (const stop of stops) if (typeof stop === 'function') stop()
|
|
1352
1873
|
sessionChanges.clear()
|
|
1353
1874
|
pendingChangeIntents.clear()
|
|
1875
|
+
sessionTools.clear()
|
|
1354
1876
|
sessionChangeTrackingSince = null
|
|
1355
1877
|
}
|
|
1356
1878
|
}, 'dsh-hud: session change tracking')
|
|
@@ -1373,6 +1895,12 @@ const ENDPOINTS = {
|
|
|
1373
1895
|
return status
|
|
1374
1896
|
},
|
|
1375
1897
|
|
|
1898
|
+
async 'session-tools'(args) {
|
|
1899
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1900
|
+
const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null
|
|
1901
|
+
return readSessionTools(sessionId)
|
|
1902
|
+
},
|
|
1903
|
+
|
|
1376
1904
|
async 'session-statuses'() {
|
|
1377
1905
|
return readSessionOverview()
|
|
1378
1906
|
},
|
|
@@ -1394,6 +1922,36 @@ const ENDPOINTS = {
|
|
|
1394
1922
|
return readDiff(cwd.value, file.value, origFile)
|
|
1395
1923
|
},
|
|
1396
1924
|
|
|
1925
|
+
/**
|
|
1926
|
+
* 图片预览:一个二进制改动两侧的字节与像素尺寸。
|
|
1927
|
+
*
|
|
1928
|
+
* `diff` 对二进制只回一句 `binary: true`(文本 diff 里没有可看的东西),
|
|
1929
|
+
* 浏览器半靠这个端点把"改了哪张图、尺寸变没变"画出来。只读,不写 index / 工作区。
|
|
1930
|
+
*/
|
|
1931
|
+
async image(args) {
|
|
1932
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1933
|
+
const cwd = validateCwd(input.cwd)
|
|
1934
|
+
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
1935
|
+
const file = validateDiffPath(input.path)
|
|
1936
|
+
if (!file.ok) throw Object.assign(new Error(file.message), { code: file.code })
|
|
1937
|
+
|
|
1938
|
+
let origFile = null
|
|
1939
|
+
if (input.origPath !== undefined && input.origPath !== null && input.origPath !== '') {
|
|
1940
|
+
const checked = validateDiffPath(input.origPath, 'origPath')
|
|
1941
|
+
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
1942
|
+
origFile = checked.value
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
let sha = null
|
|
1946
|
+
if (input.sha !== undefined && input.sha !== null && input.sha !== '') {
|
|
1947
|
+
const checked = validateCommitSha(input.sha)
|
|
1948
|
+
if (!checked.ok) throw Object.assign(new Error(checked.message), { code: checked.code })
|
|
1949
|
+
sha = checked.value
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
return readImagePair(cwd.value, file.value, origFile, input.side === 'index' ? 'index' : 'worktree', sha)
|
|
1953
|
+
},
|
|
1954
|
+
|
|
1397
1955
|
async log(args) {
|
|
1398
1956
|
const input = args && typeof args === 'object' ? args : {}
|
|
1399
1957
|
const cwd = validateCwd(input.cwd)
|
|
@@ -1401,6 +1959,13 @@ const ENDPOINTS = {
|
|
|
1401
1959
|
return readLog(cwd.value, input.count)
|
|
1402
1960
|
},
|
|
1403
1961
|
|
|
1962
|
+
/** 标签列表(只读)。点一个 tag 复用 `commit` 那条管道去看它指向的提交。 */
|
|
1963
|
+
async tags(args) {
|
|
1964
|
+
const input = args && typeof args === 'object' ? args : {}
|
|
1965
|
+
const cwd = validateCwd(input.cwd)
|
|
1966
|
+
if (!cwd.ok) throw Object.assign(new Error(cwd.message), { code: cwd.code })
|
|
1967
|
+
return readTags(cwd.value, input.count)
|
|
1968
|
+
},
|
|
1404
1969
|
async commit(args) {
|
|
1405
1970
|
const input = args && typeof args === 'object' ? args : {}
|
|
1406
1971
|
const cwd = validateCwd(input.cwd)
|