@konnga/dsh-hud 0.8.0 → 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 +15 -1
- package/docs/images/turn-images.webp +0 -0
- package/lib/client.js +641 -7
- package/lib/index.js +297 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,19 @@ dsh-hud · git:(main*) ↑1 ↓2 M4 A1 ?1 │ ●3
|
|
|
67
67
|
- **看差异**:两栏 —— 左边还是面板,右边是差异。工具栏有 `未暂存` / `已暂存` 页签、`自动换行`、`复制差异`。点 hunk 头可折叠那一段。
|
|
68
68
|
- **看提交**:三栏 —— 面板 │ 这次提交 │ 差异,并自动打开这次提交的第一个文件。`Esc` 或 `✕` 收起。
|
|
69
69
|
- **两条抓握可以拖**:右边缘那条调差异栏宽度(默认 470px);提交明细栏右边那条调提交明细栏(默认 240px)—— 路径被截断就往左拖。两条宽度都会记住。
|
|
70
|
+
- **看图片改动**:二进制文件里认得出是图片时(PNG / JPEG / GIF / WebP / BMP,**按文件头认,不看扩展名**),差异栏画成两张图并排 —— 也可以切成单张来回看 —— 并给出尺寸变化与字节变化(`1024×768 → 1024×512`、`2.0 KB → 1.0 KB`)。两侧各自说明"为什么画不出来":超过单侧 4 MB 预览上限、不是可预览的图片、或缺这一侧(新增 / 删除)。未暂存看「索引 → 工作区」,已暂存看「HEAD → 索引」,提交明细看「父提交 → 这次提交」—— 与文本 diff 的页签语义完全一致。
|
|
71
|
+
- **图片缩放**:工具条上是常显的 `−` / 档位 / `+`:默认「适应宽度」,档位走 25% / 50% / 100% / 150% / 200%(200% 封顶),点档位本身回到「适应宽度」。固定档位按**图片固有像素**算宽度(宽高由宿主一并给出,第一帧就算对,不用等图片加载),超出部分由差异栏横向滚动。档位**会记住**(与「自动换行」一样落盘)。<br>内置的右侧文档预览也有缩放,但它的控件是**藏在"预览区底部 72px 的悬停感应带"里的浮层**(CSS 默认 `opacity:0`,指针离开 420ms 收起),滚轮缩放还必须按 Ctrl/⌘ —— 看不见按钮是正常的;这一排按钮就是为了不依赖它。
|
|
72
|
+
|
|
73
|
+
### 轮尾的「本轮图片改动」(在内置改动卡片旁边)
|
|
74
|
+
|
|
75
|
+
内置那张「已编辑 N 个文件」的卡片,点一行是打开本轮的 review tab —— 图片在那里看不见(内置正文对二进制只给一句文案,而且没有给插件留正文槽位)。所以本插件在同一处轮尾放了一条自己的门:
|
|
76
|
+
|
|
77
|
+

|
|
78
|
+
|
|
79
|
+
- **点缩略图 → 在右侧内置文档预览里打开这张图**(图片原生渲染、可缩放);
|
|
80
|
+
- 右边那个 `⇄` → 打开本插件的面板,看这张图的**前后对比**(内置预览只有当前这一版);
|
|
81
|
+
- 只列**这一轮**改过的图片(按会话归因里的回合号收窄),最近改的在前,最多 4 张 + `还有 N 个`;这一轮没动过图片时整条不渲染;
|
|
82
|
+
- 归因**两档都收**:写文件/编辑工具改的算确定(不标符号),从 shell 命令里认出来的标 `◐`、悬停给出那条命令 —— 图基本是脚本产出的(`python gen.py`、`cp`、`curl -o`),只收确定档等于收不到东西。
|
|
70
83
|
|
|
71
84
|
### 侧栏会话行(悬停或点行尾 `⋯`)
|
|
72
85
|
|
|
@@ -98,10 +111,11 @@ main* ↑0 ↓0 · 已修改 7 未跟踪 2
|
|
|
98
111
|
|
|
99
112
|
## 它不做什么
|
|
100
113
|
|
|
101
|
-
- **只读**:不写 index、不动工作区与 git 配置,不执行 `add` / `commit` / `checkout` / `fetch
|
|
114
|
+
- **只读**:不写 index、不动工作区与 git 配置,不执行 `add` / `commit` / `checkout` / `fetch`;不显示凭证。**读文件内容只有一种情形**:看图片改动时读那两侧的字节 —— 只读、只在内存里、走数据 URL 画出来,不落盘、不外链。
|
|
102
115
|
- **归因是两档估计**:经写文件 / 编辑工具的算「确定改过」;从 shell 命令里认出来的算「可能相关」(`>` `>>` `tee`、`sed -i` / `perl -i`、`mv` / `cp` / `rm` / `touch` / `dd of=`、`git checkout|restore <路径>`)。**会漏也会多报**,所以永远标 `◐`,不冒充 `●`;其余命令只记进命令历史,不猜路径。
|
|
103
116
|
- **记录不落盘**:会话改动与工具活动都活在宿主进程里,重启归零;每个会话会写明「自 … 起记录」。
|
|
104
117
|
- **上限**:改动清单 200 条、单会话归因 500 个文件、标签 100 个、最近提交 10 条 —— 超出只计数并标 `truncated`。
|
|
118
|
+
- **图片预览的边界**:只认 PNG / JPEG / GIF / WebP / BMP(按文件头,不认 SVG);单侧超过 4 MB 只报大小不画图;**只在本插件的差异栏里生效** —— DSH 自带的改动对比页(内置 `deliverables` 的 `changes-review` 页签)没有给"文件正文"留槽位,插件改不了那一页。
|
|
105
119
|
- **审阅签名用 `size + mtimeMs`**:`touch` 一下(内容没变)也会让「已审」失效 —— 宁可让你多看一眼。
|
|
106
120
|
- **计数按文件算**:未跟踪不折叠目录(与编辑器一致);同一个文件「既暂存又改动」算 1 条。
|
|
107
121
|
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -120,6 +120,31 @@ window.__ModuleLoader__.load({
|
|
|
120
120
|
"diff.copied": "已复制",
|
|
121
121
|
"diff.empty": "这一侧没有差异",
|
|
122
122
|
"diff.binary": "二进制文件,没有可显示的文本差异",
|
|
123
|
+
// ── 图片预览(二进制改动里认得出的那几种) ──
|
|
124
|
+
"image.loading": "读取图片…",
|
|
125
|
+
"image.pair": "并排",
|
|
126
|
+
"image.flip": "切换",
|
|
127
|
+
"image.beforeWorktree": "索引",
|
|
128
|
+
"image.afterWorktree": "工作区",
|
|
129
|
+
"image.beforeIndex": "HEAD",
|
|
130
|
+
"image.afterIndex": "索引",
|
|
131
|
+
"image.beforeCommit": "父提交",
|
|
132
|
+
"image.afterCommit": "这次提交",
|
|
133
|
+
"image.dims": "{w}×{h}",
|
|
134
|
+
"image.dimsChanged": "尺寸 {from} → {to}",
|
|
135
|
+
"image.tooLarge": "超过 {limit} MB 预览上限({size})",
|
|
136
|
+
"image.notImage": "不是可预览的图片({size})",
|
|
137
|
+
"image.missing": "这一侧没有这个文件",
|
|
138
|
+
"image.zoomOut": "缩小",
|
|
139
|
+
"image.zoomIn": "放大",
|
|
140
|
+
"image.fitWidth": "适应宽度",
|
|
141
|
+
"image.zoomValue": "{percent}%",
|
|
142
|
+
// ── 轮尾的「本轮图片改动」条 ──
|
|
143
|
+
"turnImages.title": "本轮图片改动",
|
|
144
|
+
"turnImages.titleSession": "本会话图片改动",
|
|
145
|
+
"turnImages.open": "在内置预览里打开",
|
|
146
|
+
"turnImages.compare": "在 dsh-hud 里前后对比",
|
|
147
|
+
"turnImages.more": "还有 {n} 个",
|
|
123
148
|
"diff.truncated": "差异过大,仅显示前面部分",
|
|
124
149
|
"diff.untrackedHint": "未跟踪文件,与空文件对比",
|
|
125
150
|
"diff.commitHint": "这次提交相对其父提交的差异(只读)",
|
|
@@ -231,6 +256,31 @@ window.__ModuleLoader__.load({
|
|
|
231
256
|
"diff.copied": "Copied",
|
|
232
257
|
"diff.empty": "No changes on this side",
|
|
233
258
|
"diff.binary": "Binary file — no text diff to show",
|
|
259
|
+
// ── Image preview (binary changes we can recognise) ──
|
|
260
|
+
"image.loading": "Reading image…",
|
|
261
|
+
"image.pair": "Side by side",
|
|
262
|
+
"image.flip": "Flip",
|
|
263
|
+
"image.beforeWorktree": "index",
|
|
264
|
+
"image.afterWorktree": "worktree",
|
|
265
|
+
"image.beforeIndex": "HEAD",
|
|
266
|
+
"image.afterIndex": "index",
|
|
267
|
+
"image.beforeCommit": "parent",
|
|
268
|
+
"image.afterCommit": "this commit",
|
|
269
|
+
"image.dims": "{w}×{h}",
|
|
270
|
+
"image.dimsChanged": "size {from} → {to}",
|
|
271
|
+
"image.tooLarge": "over the {limit} MB preview limit ({size})",
|
|
272
|
+
"image.notImage": "not a previewable image ({size})",
|
|
273
|
+
"image.missing": "not present on this side",
|
|
274
|
+
"image.zoomOut": "Zoom out",
|
|
275
|
+
"image.zoomIn": "Zoom in",
|
|
276
|
+
"image.fitWidth": "Fit width",
|
|
277
|
+
"image.zoomValue": "{percent}%",
|
|
278
|
+
// ── Turn-tail "images this turn" strip ──
|
|
279
|
+
"turnImages.title": "Images this turn",
|
|
280
|
+
"turnImages.titleSession": "Images this session",
|
|
281
|
+
"turnImages.open": "Open in the built-in preview",
|
|
282
|
+
"turnImages.compare": "Compare before/after in dsh-hud",
|
|
283
|
+
"turnImages.more": "{n} more",
|
|
234
284
|
"diff.truncated": "Diff is large; showing the beginning only",
|
|
235
285
|
"diff.untrackedHint": "Untracked file, diffed against an empty file",
|
|
236
286
|
"diff.commitHint": "What this commit changed against its parent (read-only)",
|
|
@@ -321,11 +371,14 @@ window.__ModuleLoader__.load({
|
|
|
321
371
|
added: "var(--dsw-alias-state-success-primary, #3fb950)",
|
|
322
372
|
deleted: "var(--dsw-alias-state-error-primary, #f06a6a)",
|
|
323
373
|
// 与 codex-hud 的 `statEntries` 一一对应:R→cyan、C→brightBlue(我们的品牌蓝就是这个位)、
|
|
324
|
-
// T→magenta
|
|
374
|
+
// T→magenta、?→灰。M/A/D/! 本来就一致(yellow/green/red/critical)。
|
|
375
|
+
// `?` 用 `label-secondary` 而不是 `label-dimmed`:后者是**反相的表面色**(浅 #e1e5ee、深 #43454a),
|
|
376
|
+
// 当文字压在面板底色上只有 1.26:1 / 1.64:1 —— 看不见(issue:未跟踪计数读不出来)。
|
|
377
|
+
// secondary 仍是中性灰、比彩色状态弱一档,但浅 5.8:1 / 深 10.4:1 都读得清。
|
|
325
378
|
renamed: "var(--dsh-hud-cyan, #22d3ee)",
|
|
326
379
|
copied: "var(--dsw-alias-brand-primary, #4b8bf5)",
|
|
327
380
|
typeChanged: "var(--dsh-hud-magenta, #d946ef)",
|
|
328
|
-
untracked: "var(--dsw-alias-label-
|
|
381
|
+
untracked: "var(--dsw-alias-label-secondary, #8a8f98)",
|
|
329
382
|
conflicted: "var(--dsw-alias-state-error-primary, #f06a6a)",
|
|
330
383
|
};
|
|
331
384
|
|
|
@@ -359,6 +412,29 @@ window.__ModuleLoader__.load({
|
|
|
359
412
|
|
|
360
413
|
/* --------------------------------------------------------- 本地 UI 状态 */
|
|
361
414
|
|
|
415
|
+
/** 固定缩放档位:与内置文档预览的菜单一致(25% / 50% / 100% / 150% / 200%)。 */
|
|
416
|
+
var ZOOM_STEPS = [0.25, 0.5, 1, 1.5, 2];
|
|
417
|
+
|
|
418
|
+
/** 落盘/读回的缩放值:只认档位里的数,其他一律当"适应宽度"。 */
|
|
419
|
+
function readZoom(ui) {
|
|
420
|
+
return ui && ZOOM_STEPS.indexOf(ui.zoom) >= 0 ? ui.zoom : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 走一档缩放。
|
|
425
|
+
*
|
|
426
|
+
* `null`(适应宽度)出发:`+` 从 100% 开始、`−` 从 50% 开始 —— 和"适应宽度"的直觉一致,
|
|
427
|
+
* 不用先经过 25% 才知道按了有反应。
|
|
428
|
+
*/
|
|
429
|
+
function stepZoom(current, direction) {
|
|
430
|
+
if (current === null || current === undefined) return direction > 0 ? 1 : 0.5;
|
|
431
|
+
var index = ZOOM_STEPS.indexOf(current);
|
|
432
|
+
if (index < 0) return direction > 0 ? 1 : 0.5;
|
|
433
|
+
var next = index + (direction > 0 ? 1 : -1);
|
|
434
|
+
if (next < 0 || next >= ZOOM_STEPS.length) return current;
|
|
435
|
+
return ZOOM_STEPS[next];
|
|
436
|
+
}
|
|
437
|
+
|
|
362
438
|
function readUi() {
|
|
363
439
|
try {
|
|
364
440
|
var raw = window.localStorage.getItem(UI_KEY);
|
|
@@ -754,6 +830,7 @@ window.__ModuleLoader__.load({
|
|
|
754
830
|
open: state.panelOpen,
|
|
755
831
|
pos: state.pos,
|
|
756
832
|
wrap: state.diff ? state.diff.wrap === true : false,
|
|
833
|
+
zoom: state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : null,
|
|
757
834
|
diffWidth: state.diffWidth,
|
|
758
835
|
commitWidth: state.commitWidth,
|
|
759
836
|
});
|
|
@@ -783,11 +860,12 @@ window.__ModuleLoader__.load({
|
|
|
783
860
|
if (!state.cwd || !change || !change.file) return Promise.resolve();
|
|
784
861
|
var cwd = state.cwd;
|
|
785
862
|
var wrap = state.diff ? state.diff.wrap === true : readUi().wrap === true;
|
|
863
|
+
var zoom = state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : readZoom(readUi());
|
|
786
864
|
set({
|
|
787
865
|
// 工作区差异与「某次提交的明细」是互斥的两种右栏内容,换过去就收起明细。
|
|
788
866
|
commit: null,
|
|
789
867
|
cursor: change.file,
|
|
790
|
-
diff: { source: "worktree", phase: "loading", path: change.path, file: change.file, origPath: change.origFile || null, data: null, side: "worktree", wrap: wrap, hunk: 0, collapsed: [], errorCode: null },
|
|
868
|
+
diff: { source: "worktree", phase: "loading", path: change.path, file: change.file, origPath: change.origFile || null, data: null, side: "worktree", wrap: wrap, zoom: zoom, hunk: 0, collapsed: [], errorCode: null },
|
|
791
869
|
});
|
|
792
870
|
|
|
793
871
|
diffInflight = Promise.resolve()
|
|
@@ -815,6 +893,7 @@ window.__ModuleLoader__.load({
|
|
|
815
893
|
data: value,
|
|
816
894
|
side: worktreeRows > 0 || indexRows === 0 ? "worktree" : "index",
|
|
817
895
|
wrap: current.wrap,
|
|
896
|
+
zoom: current.zoom,
|
|
818
897
|
hunk: 0,
|
|
819
898
|
collapsed: [],
|
|
820
899
|
errorCode: null,
|
|
@@ -956,6 +1035,7 @@ window.__ModuleLoader__.load({
|
|
|
956
1035
|
data: null,
|
|
957
1036
|
side: "commit",
|
|
958
1037
|
wrap: wrap,
|
|
1038
|
+
zoom: state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : readZoom(readUi()),
|
|
959
1039
|
hunk: 0,
|
|
960
1040
|
collapsed: [],
|
|
961
1041
|
errorCode: null,
|
|
@@ -1012,6 +1092,52 @@ window.__ModuleLoader__.load({
|
|
|
1012
1092
|
set({ diff: null });
|
|
1013
1093
|
}
|
|
1014
1094
|
|
|
1095
|
+
/* --------------------------------------------------- 图片预览(二进制) */
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* 图片两侧的缓存:键与请求一一对应。
|
|
1099
|
+
*
|
|
1100
|
+
* 一张图的 base64 可能上兆,所以命中就不重取;条目数有上限(超了整表清空,
|
|
1101
|
+
* 和宿主的 `CACHE_MAX_ENTRIES` 同一个思路:宁可少缓存,也不要无界增长)。
|
|
1102
|
+
*/
|
|
1103
|
+
var imagePairs = new Map();
|
|
1104
|
+
var IMAGE_CACHE_MAX = 8;
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* 同步看一眼缓存:命中就返回条目,否则 null。
|
|
1108
|
+
*
|
|
1109
|
+
* 有了它,`ImageDiff` 的**第一次渲染**就能把已经取过的图画出来 ——
|
|
1110
|
+
* 关掉再打开同一张图不该先闪一下"读取图片…",也不该再发一次请求。
|
|
1111
|
+
*/
|
|
1112
|
+
function cachedImagePair(request) {
|
|
1113
|
+
if (request === null || request === undefined) return null;
|
|
1114
|
+
var entry = imagePairs.get(imagePairKey(request));
|
|
1115
|
+
return entry === undefined ? null : entry;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** 供 `ImageDiff` 用:取一个二进制改动两侧的字节与尺寸(同一个请求只发一次)。 */
|
|
1119
|
+
function loadImagePair(request) {
|
|
1120
|
+
var key = imagePairKey(request);
|
|
1121
|
+
var cached = imagePairs.get(key);
|
|
1122
|
+
if (cached !== undefined) return Promise.resolve(cached);
|
|
1123
|
+
|
|
1124
|
+
return Promise.resolve()
|
|
1125
|
+
.then(function () {
|
|
1126
|
+
return ctx.connection.rpc.call(CHANNEL, "image", { args: request });
|
|
1127
|
+
})
|
|
1128
|
+
.then(function (result) {
|
|
1129
|
+
var entry = result && result.ok === true && result.value
|
|
1130
|
+
? { phase: "ready", value: result.value }
|
|
1131
|
+
: { phase: "error", code: result && result.error && result.error.details ? result.error.details.code : null };
|
|
1132
|
+
if (imagePairs.size >= IMAGE_CACHE_MAX) imagePairs.clear();
|
|
1133
|
+
imagePairs.set(key, entry);
|
|
1134
|
+
return entry;
|
|
1135
|
+
})
|
|
1136
|
+
.catch(function () {
|
|
1137
|
+
return { phase: "error", code: "transport" };
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1015
1141
|
/**
|
|
1016
1142
|
* 收起右栏。提交明细把差异包在里面,所以它优先整体退掉;
|
|
1017
1143
|
* 只有工作区差异时才只是关掉差异。
|
|
@@ -1134,6 +1260,19 @@ window.__ModuleLoader__.load({
|
|
|
1134
1260
|
persist();
|
|
1135
1261
|
}
|
|
1136
1262
|
|
|
1263
|
+
/**
|
|
1264
|
+
* 图片缩放的档位(`null` = 适应宽度)。
|
|
1265
|
+
*
|
|
1266
|
+
* 放在 store 而不是组件里:一来记住(和"自动换行"一样落盘),二来组件的 hooks
|
|
1267
|
+
* 在这个假 React 里驱动不了嵌套状态 —— store 的状态任何位置都读得到。
|
|
1268
|
+
*/
|
|
1269
|
+
function setZoom(value) {
|
|
1270
|
+
if (!state.diff) return;
|
|
1271
|
+
var next = value === null || ZOOM_STEPS.indexOf(value) >= 0 ? value : null;
|
|
1272
|
+
set({ diff: Object.assign({}, state.diff, { zoom: next }) });
|
|
1273
|
+
persist();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1137
1276
|
/** dock 行的位置变了才写,避免每次测量都触发重渲染。 */
|
|
1138
1277
|
function setAnchorTop(top) {
|
|
1139
1278
|
if (state.anchorTop === top) return;
|
|
@@ -1624,10 +1763,14 @@ window.__ModuleLoader__.load({
|
|
|
1624
1763
|
setCommitWidth: setCommitWidth,
|
|
1625
1764
|
persistLayout: persist,
|
|
1626
1765
|
openDiff: openDiff,
|
|
1766
|
+
loadImagePair: loadImagePair,
|
|
1767
|
+
cachedImagePair: cachedImagePair,
|
|
1768
|
+
loadSessionChanges: loadSessionChanges,
|
|
1627
1769
|
closeDiff: closeDiff,
|
|
1628
1770
|
closeDetail: closeDetail,
|
|
1629
1771
|
setDiffSide: setDiffSide,
|
|
1630
1772
|
toggleDiffWrap: toggleDiffWrap,
|
|
1773
|
+
setZoom: setZoom,
|
|
1631
1774
|
openCommit: openCommit,
|
|
1632
1775
|
openCommitDiff: openCommitDiff,
|
|
1633
1776
|
toggleCommit: toggleCommit,
|
|
@@ -2237,10 +2380,432 @@ window.__ModuleLoader__.load({
|
|
|
2237
2380
|
* 差异视图:两侧页签(宿主半一次把两侧都取回来了,切页签不再发请求)、
|
|
2238
2381
|
* 自动换行、复制差异。全部只读——它只是把 git 的输出画出来。
|
|
2239
2382
|
*/
|
|
2383
|
+
/* ------------------------------------------------ 图片预览(二进制改动) */
|
|
2384
|
+
|
|
2385
|
+
/** 图片请求的缓存键:把请求的每个字段都带上,免得两侧共用一个键。 */
|
|
2386
|
+
function imagePairKey(request) {
|
|
2387
|
+
return [request.cwd || "", request.path || "", request.origPath || "", request.side || "", request.sha || ""].join("\u0000");
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/**
|
|
2391
|
+
* 由 diff 状态推出 `image` 请求;推不出(没有 cwd / 文件)返回 null。
|
|
2392
|
+
*
|
|
2393
|
+
* 页签语义与文本 diff 完全一致:未暂存看「索引 → 工作区」,已暂存看「HEAD → 索引」,
|
|
2394
|
+
* 提交明细按 sha 取,所以两个视图里"未暂存 / 已暂存"指的是同一件事。
|
|
2395
|
+
*/
|
|
2396
|
+
function imageRequest(diff, cwd) {
|
|
2397
|
+
if (!diff || !diff.file || !cwd) return null;
|
|
2398
|
+
var request = { cwd: cwd, path: diff.file };
|
|
2399
|
+
if (diff.origPath) request.origPath = diff.origPath;
|
|
2400
|
+
if (diff.source === "commit" && diff.sha) request.sha = diff.sha;
|
|
2401
|
+
else request.side = diff.side === "index" ? "index" : "worktree";
|
|
2402
|
+
return request;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
/** 两侧的标签跟着"看的到底是哪两条记录"走。 */
|
|
2406
|
+
function imageLabels(diff, t) {
|
|
2407
|
+
if (diff.source === "commit") return { before: t("image.beforeCommit"), after: t("image.afterCommit") };
|
|
2408
|
+
if (diff.side === "index") return { before: t("image.beforeIndex"), after: t("image.afterIndex") };
|
|
2409
|
+
return { before: t("image.beforeWorktree"), after: t("image.afterWorktree") };
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/** 字节数:十进制 KB/MB,和文件管理器里看到的一致。 */
|
|
2413
|
+
function formatBytes(bytes) {
|
|
2414
|
+
if (typeof bytes !== "number" || !isFinite(bytes) || bytes < 0) return "?";
|
|
2415
|
+
if (bytes < 1000) return String(bytes) + " B";
|
|
2416
|
+
if (bytes < 1000 * 1000) return (bytes / 1000).toFixed(1) + " KB";
|
|
2417
|
+
return (bytes / 1000 / 1000).toFixed(2) + " MB";
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/** 一侧画不出来时的原因文案(每一侧各有各的原因)。 */
|
|
2421
|
+
function sideReasonText(side, t) {
|
|
2422
|
+
if (side.kind === "too-large") {
|
|
2423
|
+
return t("image.tooLarge", {
|
|
2424
|
+
limit: String(Math.round((typeof side.limit === "number" ? side.limit : 0) / 1024 / 1024)),
|
|
2425
|
+
size: formatBytes(side.bytes),
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
if (side.kind === "not-image") return t("image.notImage", { size: formatBytes(side.bytes) });
|
|
2429
|
+
return t("image.missing");
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/**
|
|
2433
|
+
* 两张都画不出来时说什么:能说清原因就说原因,否则退回原来那句话。
|
|
2434
|
+
* "太大"优先于"不是图" —— 前者才是用户会想处理的那件事。
|
|
2435
|
+
*/
|
|
2436
|
+
function imageFallbackText(t, before, after) {
|
|
2437
|
+
var sides = [before, after];
|
|
2438
|
+
var tooLarge = sides.filter(function (side) { return side.kind === "too-large"; })[0];
|
|
2439
|
+
if (tooLarge !== undefined) return sideReasonText(tooLarge, t);
|
|
2440
|
+
var notImage = sides.filter(function (side) { return side.kind === "not-image"; })[0];
|
|
2441
|
+
if (notImage !== undefined) return sideReasonText(notImage, t);
|
|
2442
|
+
return t("diff.binary");
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* 二进制(图片)改动的预览:并排两张图,或按需在两张之间切换。
|
|
2447
|
+
*
|
|
2448
|
+
* 只在文本 diff 报 `binary` 时挂上来。字节来自宿主的 `image` 端点(只读),
|
|
2449
|
+
* 这里只负责画:单侧画不出来就画那一侧的原因,两侧都不行才退回原来那句话。
|
|
2450
|
+
*/
|
|
2451
|
+
function ImageDiff(props) {
|
|
2452
|
+
var t = props.t;
|
|
2453
|
+
var store = props.store;
|
|
2454
|
+
var diff = props.diff;
|
|
2455
|
+
var request = imageRequest(diff, props.cwd);
|
|
2456
|
+
var key = request === null ? null : imagePairKey(request);
|
|
2457
|
+
// 先同步看缓存:已经取过的图第一帧就画出来,不闪"读取图片…"。
|
|
2458
|
+
var viewRef = React.useState(function () {
|
|
2459
|
+
return store.cachedImagePair ? store.cachedImagePair(request) : null;
|
|
2460
|
+
});
|
|
2461
|
+
var modeRef = React.useState("pair");
|
|
2462
|
+
var flipRef = React.useState("after");
|
|
2463
|
+
|
|
2464
|
+
React.useEffect(function () {
|
|
2465
|
+
if (key === null) return undefined;
|
|
2466
|
+
var cancelled = false;
|
|
2467
|
+
var cached = store.cachedImagePair ? store.cachedImagePair(request) : null;
|
|
2468
|
+
if (cached !== null) {
|
|
2469
|
+
viewRef[1](cached);
|
|
2470
|
+
return undefined;
|
|
2471
|
+
}
|
|
2472
|
+
viewRef[1](null);
|
|
2473
|
+
store.loadImagePair(request).then(function (entry) {
|
|
2474
|
+
if (!cancelled) viewRef[1](entry);
|
|
2475
|
+
});
|
|
2476
|
+
return function () { cancelled = true; };
|
|
2477
|
+
}, [key]);
|
|
2478
|
+
|
|
2479
|
+
if (key === null) return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, t("diff.binary"));
|
|
2480
|
+
|
|
2481
|
+
var view = viewRef[0];
|
|
2482
|
+
if (view === null) return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, t("image.loading"));
|
|
2483
|
+
if (view.phase === "error") return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, errorText(view.code, t));
|
|
2484
|
+
|
|
2485
|
+
var value = view.value || {};
|
|
2486
|
+
var before = value.before || { kind: "missing" };
|
|
2487
|
+
var after = value.after || { kind: "missing" };
|
|
2488
|
+
if (before.kind !== "image" && after.kind !== "image") {
|
|
2489
|
+
return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, imageFallbackText(t, before, after));
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
var labels = imageLabels(diff, t);
|
|
2493
|
+
var mode = modeRef[0];
|
|
2494
|
+
var flip = flipRef[0];
|
|
2495
|
+
// 缩放住在 store 里(和"自动换行"一样记住):`null` = 适应宽度,数字 = 固定档位。
|
|
2496
|
+
var zoom = diff.zoom === undefined ? null : diff.zoom;
|
|
2497
|
+
var cells = mode === "pair"
|
|
2498
|
+
? [{ key: "before", label: labels.before, side: before }, { key: "after", label: labels.after, side: after }]
|
|
2499
|
+
: [{ key: flip, label: flip === "after" ? labels.after : labels.before, side: flip === "after" ? after : before }];
|
|
2500
|
+
|
|
2501
|
+
function cell(entry) {
|
|
2502
|
+
var side = entry.side;
|
|
2503
|
+
var body = side.kind === "image"
|
|
2504
|
+
? h("img", {
|
|
2505
|
+
className: "dshh-img",
|
|
2506
|
+
alt: entry.label,
|
|
2507
|
+
// 缩放:适应宽度交给 CSS(`max-width:100%`);固定档位按**固有像素**算宽度,
|
|
2508
|
+
// 因为宿主已经把宽高一起给我们了 —— 不用等图片加载就能算对。
|
|
2509
|
+
style: zoom === null || typeof side.width !== "number"
|
|
2510
|
+
? undefined
|
|
2511
|
+
: { width: Math.round(side.width * zoom) + "px", maxWidth: "none" },
|
|
2512
|
+
src: "data:" + side.mime + ";base64," + String(side.base64 || ""),
|
|
2513
|
+
})
|
|
2514
|
+
: h("div", { className: "dshh-imgNote" }, sideReasonText(side, t));
|
|
2515
|
+
return h("div", { className: "dshh-imgCell", key: entry.key },
|
|
2516
|
+
h("div", { className: "dshh-imgLabel" }, entry.label),
|
|
2517
|
+
body);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function tab(id, label) {
|
|
2521
|
+
var active = mode === id;
|
|
2522
|
+
return h("button", {
|
|
2523
|
+
type: "button",
|
|
2524
|
+
className: "dshh-tab" + (active ? " dshh-tabActive" : ""),
|
|
2525
|
+
"aria-pressed": active ? "true" : "false",
|
|
2526
|
+
onClick: function () { modeRef[1](id); },
|
|
2527
|
+
}, label);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// 尺寸变了才是这张图最值得一眼看到的信息(改图最常见的就是换尺寸)。
|
|
2531
|
+
var dimsChanged = before.kind === "image" && after.kind === "image"
|
|
2532
|
+
&& typeof before.width === "number" && typeof after.width === "number"
|
|
2533
|
+
&& (before.width !== after.width || before.height !== after.height)
|
|
2534
|
+
? h("span", { className: "dshh-imgDims" }, t("image.dimsChanged", {
|
|
2535
|
+
from: t("image.dims", { w: String(before.width), h: String(before.height) }),
|
|
2536
|
+
to: t("image.dims", { w: String(after.width), h: String(after.height) }),
|
|
2537
|
+
}))
|
|
2538
|
+
: null;
|
|
2539
|
+
var bytes = before.kind === "image" && after.kind === "image"
|
|
2540
|
+
? h("span", { className: "dshh-imgBytes" }, formatBytes(before.bytes) + " → " + formatBytes(after.bytes))
|
|
2541
|
+
: null;
|
|
2542
|
+
|
|
2543
|
+
// 缩放按钮:内置那个文档预览把缩放藏在"预览区底部 72px 的悬停感应带"里
|
|
2544
|
+
// (CSS 默认 opacity:0,还得 Ctrl+滚轮),这里给一排看得见、点的着的。
|
|
2545
|
+
var zoomControls = h("span", { className: "dshh-imgZoom", "data-dsh-hud": "image-zoom" }, [
|
|
2546
|
+
h("button", {
|
|
2547
|
+
key: "out",
|
|
2548
|
+
type: "button",
|
|
2549
|
+
className: "dshh-imgZoomBtn",
|
|
2550
|
+
title: t("image.zoomOut"),
|
|
2551
|
+
"data-dsh-hud": "image-zoom-out",
|
|
2552
|
+
onClick: function () { store.setZoom(stepZoom(zoom, -1)); },
|
|
2553
|
+
}, "−"),
|
|
2554
|
+
h("button", {
|
|
2555
|
+
key: "value",
|
|
2556
|
+
type: "button",
|
|
2557
|
+
className: "dshh-imgZoomBtn dshh-imgZoomValue",
|
|
2558
|
+
title: t("image.fitWidth"),
|
|
2559
|
+
"data-dsh-hud": "image-zoom-value",
|
|
2560
|
+
onClick: function () { store.setZoom(null); },
|
|
2561
|
+
}, zoom === null ? t("image.fitWidth") : t("image.zoomValue", { percent: String(Math.round(zoom * 100)) })),
|
|
2562
|
+
h("button", {
|
|
2563
|
+
key: "in",
|
|
2564
|
+
type: "button",
|
|
2565
|
+
className: "dshh-imgZoomBtn",
|
|
2566
|
+
title: t("image.zoomIn"),
|
|
2567
|
+
"data-dsh-hud": "image-zoom-in",
|
|
2568
|
+
onClick: function () { store.setZoom(stepZoom(zoom, 1)); },
|
|
2569
|
+
}, "+"),
|
|
2570
|
+
]);
|
|
2571
|
+
|
|
2572
|
+
return h("div", { className: "dshh-imgWrap", "data-dsh-hud": "image" },
|
|
2573
|
+
h("div", { className: "dshh-imgMeta" },
|
|
2574
|
+
tab("pair", t("image.pair")),
|
|
2575
|
+
mode === "flip"
|
|
2576
|
+
? h("button", {
|
|
2577
|
+
type: "button",
|
|
2578
|
+
className: "dshh-tab",
|
|
2579
|
+
title: t("image.flip"),
|
|
2580
|
+
"data-dsh-hud": "image-flip",
|
|
2581
|
+
onClick: function () { flipRef[1](flip === "after" ? "before" : "after"); },
|
|
2582
|
+
}, t("image.flip"))
|
|
2583
|
+
: null,
|
|
2584
|
+
zoomControls,
|
|
2585
|
+
dimsChanged,
|
|
2586
|
+
bytes),
|
|
2587
|
+
h("div", {
|
|
2588
|
+
className: "dshh-imgPair" + (mode === "flip" ? " dshh-imgPairSingle" : "") + (zoom === null ? "" : " dshh-imgPairZoomed"),
|
|
2589
|
+
}, cells.map(cell)));
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
/* ----------------------------------------- 轮尾:本轮改过的图片(一条门) */
|
|
2593
|
+
|
|
2594
|
+
/** 图片类文件的扩展名。这里只做粗筛 —— 真正"是不是图"由宿主按文件头判断。 */
|
|
2595
|
+
var IMAGE_FILE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "avif"];
|
|
2596
|
+
|
|
2597
|
+
/** 按扩展名粗筛"这看起来是张图"。 */
|
|
2598
|
+
function looksLikeImage(file) {
|
|
2599
|
+
if (typeof file !== "string") return false;
|
|
2600
|
+
var dot = file.lastIndexOf(".");
|
|
2601
|
+
if (dot < 0 || dot === file.length - 1) return false;
|
|
2602
|
+
return IMAGE_FILE_EXTENSIONS.indexOf(file.slice(dot + 1).toLowerCase()) >= 0;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/** 只取文件名(chip 上放不下整条路径)。 */
|
|
2606
|
+
function baseNameOf(file) {
|
|
2607
|
+
var parts = String(file).split("/");
|
|
2608
|
+
return parts[parts.length - 1] || String(file);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
/**
|
|
2612
|
+
* 槽位给的是宿主的 `TurnLocation`。
|
|
2613
|
+
*
|
|
2614
|
+
* 形状属于宿主内部约定(槽位文档只说"receives the Turn"),所以这里宽松取值:
|
|
2615
|
+
* 取不到就回 null,由调用方退到"本会话"的说法,而不是整条不显示。
|
|
2616
|
+
*/
|
|
2617
|
+
function turnNumberOf(turn) {
|
|
2618
|
+
if (typeof turn === "number" && isFinite(turn)) return turn;
|
|
2619
|
+
if (turn && typeof turn === "object" && typeof turn.turn === "number") return turn.turn;
|
|
2620
|
+
return null;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/**
|
|
2624
|
+
* 从会话改动里挑出"这一轮改过的图片",最近的在前。
|
|
2625
|
+
*
|
|
2626
|
+
* **两档都收**:`●` 确定改过(写文件 / 编辑工具)与 `◐` 命令旁听(`>`、`tee`、`cp`、`rm`…)。
|
|
2627
|
+
* 只收确定档不够用 —— 图几乎都是脚本/命令产出的(`python gen.py`、`cp`、`curl -o`),
|
|
2628
|
+
* 那些永远进不了确定档,而"点一下看图"对它们一样有用。`◐` 是猜的,所以 chip 上带 `◐`、
|
|
2629
|
+
* 悬停给出那条命令(措辞与面板里改动清单一致)。
|
|
2630
|
+
*
|
|
2631
|
+
* 回合号拿不到(`turnNumberOf` 回 null)就不按回合收窄,退回"本会话最近的图片改动"。
|
|
2632
|
+
*
|
|
2633
|
+
* @returns `{ files, exact }`;`exact` 为 false 表示标题该说"本会话"而不是"本轮"。
|
|
2634
|
+
*/
|
|
2635
|
+
function imageChangesOf(changes, turnNumber) {
|
|
2636
|
+
if (changes === null || changes === undefined) return { files: [], exact: turnNumber !== null };
|
|
2637
|
+
var confirmed = changes.files ? changes.files : {};
|
|
2638
|
+
var guessed = changes.possible ? changes.possible : {};
|
|
2639
|
+
var picked = [];
|
|
2640
|
+
var seen = {};
|
|
2641
|
+
|
|
2642
|
+
for (var file in confirmed) {
|
|
2643
|
+
if (!Object.prototype.hasOwnProperty.call(confirmed, file)) continue;
|
|
2644
|
+
if (!looksLikeImage(file)) continue;
|
|
2645
|
+
var record = confirmed[file] || {};
|
|
2646
|
+
if (turnNumber !== null) {
|
|
2647
|
+
var turns = Array.isArray(record.turns) ? record.turns : [];
|
|
2648
|
+
if (turns.indexOf(turnNumber) < 0 && record.lastTurn !== turnNumber) continue;
|
|
2649
|
+
}
|
|
2650
|
+
seen[file] = true;
|
|
2651
|
+
picked.push({
|
|
2652
|
+
file: file,
|
|
2653
|
+
name: baseNameOf(file),
|
|
2654
|
+
tier: "confirmed",
|
|
2655
|
+
reason: "",
|
|
2656
|
+
at: typeof record.lastAt === "number" ? record.lastAt : 0,
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
for (var guess in guessed) {
|
|
2661
|
+
if (!Object.prototype.hasOwnProperty.call(guessed, guess)) continue;
|
|
2662
|
+
if (seen[guess] === true) continue;
|
|
2663
|
+
if (!looksLikeImage(guess)) continue;
|
|
2664
|
+
var item = guessed[guess] || {};
|
|
2665
|
+
// 命令旁听只记"最近一次"的回合号(没有 turns 列表),所以只能按 lastTurn 收窄。
|
|
2666
|
+
if (turnNumber !== null && item.lastTurn !== turnNumber) continue;
|
|
2667
|
+
seen[guess] = true;
|
|
2668
|
+
picked.push({
|
|
2669
|
+
file: guess,
|
|
2670
|
+
name: baseNameOf(guess),
|
|
2671
|
+
tier: "possible",
|
|
2672
|
+
reason: typeof item.reason === "string" ? item.reason : "",
|
|
2673
|
+
at: typeof item.lastAt === "number" ? item.lastAt : 0,
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
picked.sort(function (left, right) { return right.at - left.at; });
|
|
2678
|
+
return { files: picked, exact: turnNumber !== null };
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/** 缩略图用哪一侧的字节:优先工作区,其次基线(新增/删除时只有一侧有)。 */
|
|
2682
|
+
function thumbSideOf(entry) {
|
|
2683
|
+
if (entry === null || entry.phase !== "ready" || !entry.value) return null;
|
|
2684
|
+
var pair = entry.value;
|
|
2685
|
+
if (pair.after && pair.after.kind === "image") return pair.after;
|
|
2686
|
+
if (pair.before && pair.before.kind === "image") return pair.before;
|
|
2687
|
+
return null;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
/**
|
|
2691
|
+
* 轮尾那条「本轮图片改动」:每项一张缩略图 + 文件名。
|
|
2692
|
+
*
|
|
2693
|
+
* 内置的改动卡片点一行是打开 review tab(图片在那儿看不见),这条是补上的门:
|
|
2694
|
+
* **点缩略图 → 槽位给的 `openFile(path)`**,即内置的右侧文档预览(图片原生渲染、可缩放);
|
|
2695
|
+
* 旁边那个 `⇄` 才打开 dsh-hud 面板看前后两张 —— 前后对比只有我们有。
|
|
2696
|
+
*/
|
|
2697
|
+
function TurnImages(props) {
|
|
2698
|
+
var t = props.t;
|
|
2699
|
+
var store = props.store;
|
|
2700
|
+
var sessionId = props.sessionId;
|
|
2701
|
+
var useSessions = props.useSessions;
|
|
2702
|
+
var openFile = props.openFile;
|
|
2703
|
+
var turnNumber = turnNumberOf(props.turn);
|
|
2704
|
+
|
|
2705
|
+
// useSessions 是选择器 hook:选择器参数是必需的。
|
|
2706
|
+
var sessions = typeof useSessions === "function" ? useSessions(function (snapshot) { return snapshot; }) : null;
|
|
2707
|
+
var summary = sessionId !== undefined && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
|
|
2708
|
+
var cwd = summary ? summary.cwd : undefined;
|
|
2709
|
+
|
|
2710
|
+
var state = useStore(store);
|
|
2711
|
+
// 缩略图要等字节;缓存本身不在快照里,所以取完自增一下触发重画。
|
|
2712
|
+
var tick = React.useState(0);
|
|
2713
|
+
var bump = tick[1];
|
|
2714
|
+
|
|
2715
|
+
// 轮尾每个已完成的回合都会挂一次:绑定会话 + 没取过会话改动就取一次(宿主侧是内存读,很便宜)。
|
|
2716
|
+
React.useEffect(function () {
|
|
2717
|
+
if (sessionId === undefined || cwd === undefined) return undefined;
|
|
2718
|
+
store.bindSession(sessionId, cwd);
|
|
2719
|
+
if (state.sessionChanges === undefined || state.sessionChanges.phase === "idle") store.loadSessionChanges();
|
|
2720
|
+
return undefined;
|
|
2721
|
+
}, [sessionId, cwd]);
|
|
2722
|
+
|
|
2723
|
+
var picked = imageChangesOf(state.sessionChanges, turnNumber);
|
|
2724
|
+
var shown = picked.files.slice(0, 4);
|
|
2725
|
+
var keys = shown.map(function (entry) { return entry.file; }).join("\u0000");
|
|
2726
|
+
|
|
2727
|
+
React.useEffect(function () {
|
|
2728
|
+
if (keys === "" || cwd === undefined) return undefined;
|
|
2729
|
+
var cancelled = false;
|
|
2730
|
+
var wanted = shown.map(function (entry) {
|
|
2731
|
+
return { cwd: cwd, path: entry.file, side: "worktree" };
|
|
2732
|
+
}).filter(function (request) {
|
|
2733
|
+
return store.cachedImagePair(request) === null;
|
|
2734
|
+
});
|
|
2735
|
+
if (wanted.length === 0) return undefined;
|
|
2736
|
+
Promise.all(wanted.map(function (request) { return store.loadImagePair(request); })).then(function () {
|
|
2737
|
+
if (!cancelled) bump(function (count) { return count + 1; });
|
|
2738
|
+
});
|
|
2739
|
+
return function () { cancelled = true; };
|
|
2740
|
+
}, [keys, cwd]);
|
|
2741
|
+
|
|
2742
|
+
// 会话改动还没到、或这一轮没动过图:整条不渲染(不在每条轮尾留个空壳)。
|
|
2743
|
+
if (state.sessionChanges === undefined || state.sessionChanges.phase !== "ready") return null;
|
|
2744
|
+
if (shown.length === 0) return null;
|
|
2745
|
+
|
|
2746
|
+
var rest = picked.files.length - shown.length;
|
|
2747
|
+
|
|
2748
|
+
function chip(entry) {
|
|
2749
|
+
var thumb = thumbSideOf(cwd === undefined ? null : store.cachedImagePair({ cwd: cwd, path: entry.file, side: "worktree" }));
|
|
2750
|
+
var dot = entry.name.lastIndexOf(".");
|
|
2751
|
+
var extension = dot > 0 ? entry.name.slice(dot + 1).toUpperCase() : "?";
|
|
2752
|
+
// `◐` 是命令旁听来的(猜的):说清这一点,并把那条命令放进悬停里。
|
|
2753
|
+
var hint = entry.file + " · " + t("turnImages.open");
|
|
2754
|
+
if (entry.tier === "possible" && entry.reason !== "") hint += " · " + t("session.possibleTitle", { reason: entry.reason });
|
|
2755
|
+
return h("span", {
|
|
2756
|
+
key: entry.file,
|
|
2757
|
+
className: "dshh-imgstripItem",
|
|
2758
|
+
"data-dsh-hud": "turn-image",
|
|
2759
|
+
"data-file": entry.file,
|
|
2760
|
+
"data-tier": entry.tier,
|
|
2761
|
+
}, [
|
|
2762
|
+
h("button", {
|
|
2763
|
+
key: "open",
|
|
2764
|
+
type: "button",
|
|
2765
|
+
className: "dshh-imgstripOpen",
|
|
2766
|
+
title: hint,
|
|
2767
|
+
onClick: function () { if (typeof openFile === "function") openFile(entry.file); },
|
|
2768
|
+
}, [
|
|
2769
|
+
thumb === null
|
|
2770
|
+
? h("span", { key: "ph", className: "dshh-imgstripThumb dshh-imgstripThumbEmpty" }, extension)
|
|
2771
|
+
: h("img", {
|
|
2772
|
+
key: "img",
|
|
2773
|
+
className: "dshh-imgstripThumb",
|
|
2774
|
+
alt: entry.name,
|
|
2775
|
+
src: "data:" + thumb.mime + ";base64," + String(thumb.base64 || ""),
|
|
2776
|
+
}),
|
|
2777
|
+
entry.tier === "possible"
|
|
2778
|
+
? h("span", { key: "tier", className: "dshh-imgstripTier", title: hint }, "◐")
|
|
2779
|
+
: null,
|
|
2780
|
+
h("span", { key: "name", className: "dshh-imgstripName" }, entry.name),
|
|
2781
|
+
]),
|
|
2782
|
+
h("button", {
|
|
2783
|
+
key: "compare",
|
|
2784
|
+
type: "button",
|
|
2785
|
+
className: "dshh-imgstripCompare",
|
|
2786
|
+
title: t("turnImages.compare"),
|
|
2787
|
+
"data-dsh-hud": "turn-image-compare",
|
|
2788
|
+
onClick: function () {
|
|
2789
|
+
store.openPanel(true);
|
|
2790
|
+
store.openDiff({ file: entry.file, path: entry.file, origFile: null });
|
|
2791
|
+
},
|
|
2792
|
+
}, "⇄"),
|
|
2793
|
+
]);
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
return h("div", { className: "dshh-imgstrip", "data-dsh-hud": "turn-images" }, [
|
|
2797
|
+
h("span", { key: "title", className: "dshh-imgstripTitle" }, picked.exact ? t("turnImages.title") : t("turnImages.titleSession")),
|
|
2798
|
+
h("span", { key: "items", className: "dshh-imgstripItems" }, shown.map(chip)),
|
|
2799
|
+
rest > 0 ? h("span", { key: "more", className: "dshh-imgstripMore" }, t("turnImages.more", { n: String(rest) })) : null,
|
|
2800
|
+
]);
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2240
2803
|
function DiffView(props) {
|
|
2241
2804
|
var t = props.t;
|
|
2242
2805
|
var diff = props.diff;
|
|
2243
2806
|
var store = props.store;
|
|
2807
|
+
// 图片预览要按工作区发请求;文本 diff 用不到它。
|
|
2808
|
+
var cwd = props.cwd;
|
|
2244
2809
|
|
|
2245
2810
|
var copiedRef = React.useState(false);
|
|
2246
2811
|
var copied = copiedRef[0];
|
|
@@ -2308,7 +2873,8 @@ window.__ModuleLoader__.load({
|
|
|
2308
2873
|
} else if (!data) {
|
|
2309
2874
|
body = h("div", { className: "dshh-empty" }, t("diff.empty"));
|
|
2310
2875
|
} else if (data.binary === true) {
|
|
2311
|
-
|
|
2876
|
+
// 二进制里最常被改的就是图:能认出来就画出来,认不出才退回那句话。
|
|
2877
|
+
body = h(ImageDiff, { t: t, store: store, diff: diff, cwd: cwd });
|
|
2312
2878
|
} else if (rows.length === 0) {
|
|
2313
2879
|
body = h("div", { className: "dshh-empty" }, t("diff.empty"));
|
|
2314
2880
|
} else {
|
|
@@ -3310,7 +3876,7 @@ window.__ModuleLoader__.load({
|
|
|
3310
3876
|
onPointerDown: function (event) { onGripPointerDown("commit", event); },
|
|
3311
3877
|
})
|
|
3312
3878
|
: null,
|
|
3313
|
-
diffMode ? h(DiffView, { t: t, diff: state.diff, store: store }) : null)
|
|
3879
|
+
diffMode ? h(DiffView, { t: t, diff: state.diff, store: store, cwd: state.cwd }) : null)
|
|
3314
3880
|
: listBody;
|
|
3315
3881
|
|
|
3316
3882
|
return h("div", { className: "dshh-panel", "data-dsh-hud": "panel", style: panelStyle },
|
|
@@ -3446,14 +4012,17 @@ window.__ModuleLoader__.load({
|
|
|
3446
4012
|
".dshh-rowhover{display:flex;flex-direction:column;gap:2px;font-size:11px;",
|
|
3447
4013
|
"color:var(--dsw-alias-label-secondary,#8a8f98);min-width:0;}",
|
|
3448
4014
|
".dshh-rowhoverLine{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
|
|
4015
|
+
// 「已审 x/y」是个可点控件:同样不能用 label-dimmed(反相表面色,1.26:1 / 1.64:1),
|
|
4016
|
+
// 平时用次级文字色,悬停才升到正文色。
|
|
3449
4017
|
".dshh-review{flex:none;margin-left:auto;padding:0 2px;font-size:10.5px;cursor:pointer;",
|
|
3450
|
-
"color:var(--dsw-alias-label-
|
|
4018
|
+
"color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
3451
4019
|
".dshh-review:hover{color:var(--dsw-alias-label-primary,#e5e7eb);}",
|
|
3452
4020
|
".dshh-reviewOn{color:var(--dsw-alias-state-success-primary,#3fb950);}",
|
|
3453
4021
|
".dshh-reviewStale{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
|
|
3454
4022
|
".dshh-ctag{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;",
|
|
3455
4023
|
"font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
|
|
3456
|
-
"
|
|
4024
|
+
// 行首的 `○`(与本会话无关):要"最不显眼",但同样得看得见 —— 与上面同一个理由。
|
|
4025
|
+
".dshh-markerOther{color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
3457
4026
|
// 工具活动胶囊:与 Git 胶囊同形,但它是只读读数,指针不该是“可点”。
|
|
3458
4027
|
// 与 codex-hud 对齐的几支色(它的默认配色表:git=magenta、gitBranch=cyan、project=yellow):
|
|
3459
4028
|
// 产品没有青/品红的语义变量(只有 button-info-*,那是按钮用的),所以这两支写死值,
|
|
@@ -3520,6 +4089,57 @@ window.__ModuleLoader__.load({
|
|
|
3520
4089
|
".dshh-cwhen{flex:none;font-size:10.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
3521
4090
|
".dshh-note{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
3522
4091
|
".dshh-empty{padding:14px 12px;font-size:12px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4092
|
+
// ---- 图片预览(二进制改动) ----
|
|
4093
|
+
".dshh-imgWrap{flex:1 1 auto;min-height:0;overflow:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}",
|
|
4094
|
+
".dshh-imgMeta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;",
|
|
4095
|
+
"color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4096
|
+
".dshh-imgDims{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
|
|
4097
|
+
".dshh-imgBytes{font-family:ui-monospace,Consolas,monospace;}",
|
|
4098
|
+
".dshh-imgPair{display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;}",
|
|
4099
|
+
".dshh-imgPairSingle{flex-wrap:nowrap;}",
|
|
4100
|
+
// 固定档位(非"适应宽度")时:图片按固有像素算宽度,容器随内容撑开,外层横向滚动。
|
|
4101
|
+
".dshh-imgPairZoomed{flex-wrap:nowrap;width:max-content;align-items:flex-start;}",
|
|
4102
|
+
".dshh-imgPairZoomed .dshh-imgCell{flex:0 0 auto;}",
|
|
4103
|
+
".dshh-imgPairZoomed .dshh-img{max-height:none;}",
|
|
4104
|
+
".dshh-imgZoom{display:inline-flex;align-items:center;gap:2px;margin-left:auto;}",
|
|
4105
|
+
".dshh-imgZoomBtn{border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));",
|
|
4106
|
+
"background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;cursor:pointer;font:inherit;",
|
|
4107
|
+
"min-width:22px;height:20px;padding:0 6px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;}",
|
|
4108
|
+
".dshh-imgZoomBtn:hover{color:var(--dsw-alias-label-primary,#e5e7eb);",
|
|
4109
|
+
"border-color:var(--dsw-alias-border-l2,rgba(127,127,127,0.45));}",
|
|
4110
|
+
".dshh-imgZoomValue{min-width:74px;font-variant-numeric:tabular-nums;}",
|
|
4111
|
+
".dshh-imgCell{flex:1 1 150px;min-width:0;display:flex;flex-direction:column;gap:4px;}",
|
|
4112
|
+
".dshh-imgLabel{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4113
|
+
".dshh-imgNote{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);padding:6px 0;}",
|
|
4114
|
+
// 透明像素要有底色才看得出边界:棋盘格由四段半透明渐变拼出来,两种主题都不用另配颜色。
|
|
4115
|
+
".dshh-img{max-width:100%;max-height:420px;object-fit:contain;border-radius:6px;",
|
|
4116
|
+
"border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.28));",
|
|
4117
|
+
"background-image:linear-gradient(45deg,rgba(127,127,127,0.12) 25%,transparent 25%),",
|
|
4118
|
+
"linear-gradient(-45deg,rgba(127,127,127,0.12) 25%,transparent 25%),",
|
|
4119
|
+
"linear-gradient(45deg,transparent 75%,rgba(127,127,127,0.12) 75%),",
|
|
4120
|
+
"linear-gradient(-45deg,transparent 75%,rgba(127,127,127,0.12) 75%);",
|
|
4121
|
+
"background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;}",
|
|
4122
|
+
// ---- 轮尾「本轮图片改动」条 ----
|
|
4123
|
+
".dshh-imgstrip{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;",
|
|
4124
|
+
"color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4125
|
+
".dshh-imgstripTitle{flex:none;}",
|
|
4126
|
+
".dshh-imgstripItems{display:flex;gap:6px;flex-wrap:wrap;}",
|
|
4127
|
+
".dshh-imgstripItem{display:inline-flex;align-items:center;gap:2px;padding:2px 4px 2px 2px;",
|
|
4128
|
+
"border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));border-radius:8px;",
|
|
4129
|
+
"background:var(--dsw-alias-bg-layer-1,transparent);}",
|
|
4130
|
+
".dshh-imgstripOpen{display:inline-flex;align-items:center;gap:6px;border:0;background:transparent;",
|
|
4131
|
+
"color:inherit;cursor:pointer;padding:0;font:inherit;}",
|
|
4132
|
+
".dshh-imgstripOpen:hover{color:var(--dsw-alias-label-primary,#e5e7eb);}",
|
|
4133
|
+
".dshh-imgstripThumb{width:28px;height:28px;object-fit:cover;border-radius:5px;display:block;",
|
|
4134
|
+
"background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.14));}",
|
|
4135
|
+
".dshh-imgstripThumbEmpty{align-items:center;justify-content:center;font-size:8px;letter-spacing:0.02em;",
|
|
4136
|
+
"display:flex;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4137
|
+
".dshh-imgstripName{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
4138
|
+
".dshh-imgstripTier{flex:none;opacity:.7;}",
|
|
4139
|
+
".dshh-imgstripCompare{border:0;background:transparent;color:inherit;cursor:pointer;padding:0 2px;",
|
|
4140
|
+
"font:inherit;opacity:.7;}",
|
|
4141
|
+
".dshh-imgstripCompare:hover{opacity:1;color:var(--dsw-alias-brand-primary,#4b8bf5);}",
|
|
4142
|
+
".dshh-imgstripMore{flex:none;}",
|
|
3523
4143
|
".dshh-footer{display:flex;justify-content:space-between;gap:8px;padding:6px 10px;flex:none;",
|
|
3524
4144
|
"border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));",
|
|
3525
4145
|
"font-size:10.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
@@ -3730,6 +4350,20 @@ window.__ModuleLoader__.load({
|
|
|
3730
4350
|
},
|
|
3731
4351
|
}, HudDockPill);
|
|
3732
4352
|
});
|
|
4353
|
+
|
|
4354
|
+
// 轮尾(内置「已编辑 N 个文件」卡片所在的那条尾巴)的一扇门:本轮动过图,
|
|
4355
|
+
// 点缩略图就在内置文档预览里打开;`⇄` 才进本面板看前后对比。
|
|
4356
|
+
ctx.slots.inject("conversation.chat.turnTail", function () {
|
|
4357
|
+
return ctx.slots.register({
|
|
4358
|
+
name: "conversation.chat.turnTail",
|
|
4359
|
+
id: "dsh-hud-turn-images",
|
|
4360
|
+
order: 30,
|
|
4361
|
+
locale: NS,
|
|
4362
|
+
inject: function (sessionId) {
|
|
4363
|
+
return { store: store, sessionId: sessionId };
|
|
4364
|
+
},
|
|
4365
|
+
}, TurnImages);
|
|
4366
|
+
});
|
|
3733
4367
|
}
|
|
3734
4368
|
|
|
3735
4369
|
exports.apply = apply;
|
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` 一致。
|
|
@@ -916,6 +919,270 @@ export async function readCommitDiff(cwd, sha, file, origFile = null) {
|
|
|
916
919
|
}
|
|
917
920
|
}
|
|
918
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
|
+
|
|
919
1186
|
/* ------------------------------------------------- 会话改动(按会话归因) */
|
|
920
1187
|
|
|
921
1188
|
/**
|
|
@@ -1655,6 +1922,36 @@ const ENDPOINTS = {
|
|
|
1655
1922
|
return readDiff(cwd.value, file.value, origFile)
|
|
1656
1923
|
},
|
|
1657
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
|
+
|
|
1658
1955
|
async log(args) {
|
|
1659
1956
|
const input = args && typeof args === 'object' ? args : {}
|
|
1660
1957
|
const cwd = validateCwd(input.cwd)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@konnga/dsh-hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "DeepSeek Harness 的只读工作区 Git 面板:分支、ahead/behind、改动清单与差异,外加「这个会话改过哪些文件、审到哪了、跑了什么工具」。A read-only workspace Git panel for DeepSeek Harness — branch, changed files, diffs, plus per-session change attribution, review progress, and tool activity.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|