@qzsy/dsh-git-panel 0.0.0-stage → 0.1.1

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/lib/client.js ADDED
@@ -0,0 +1,2498 @@
1
+ /**
2
+ * Browser half of `dsh-git-panel`.
3
+ *
4
+ * A Git section for the right Sidebar, modelled on the Git module of the local
5
+ * Cursor IDE: the Source Control panel (`CHANGES`) plus the Source Control Graph
6
+ * it ships below. Two tab types are registered here:
7
+ *
8
+ * `git` the panel itself — branch header, changes groups, commit box,
9
+ * the commit graph, and a commit description panel
10
+ * `git-diff` a diff preview page opened in a new tab — one file, all working
11
+ * tree changes, or one commit
12
+ *
13
+ * Everything that runs git lives in the Host half behind `POST /api/git-panel`;
14
+ * this half only renders and calls it. Opening a file is the Sidebar's own
15
+ * `openResource` (`dsh-resource://file/...`), so a changed file lands in the
16
+ * same previewer any other file link uses.
17
+ */
18
+ window.__ModuleLoader__.load({
19
+ id: '@qzsy/dsh-git-panel',
20
+ factory(require) {
21
+ const React = require('react')
22
+ const h = React.createElement
23
+ const { useCallback, useEffect, useMemo, useRef, useState } = React
24
+
25
+ /** Plugin context, kept so the panel can reach the conversation input. */
26
+ let pluginCtx
27
+
28
+ /** Copy namespace. */
29
+ const NS = 'gitPanel'
30
+ /** This package's tab-type identity (the registry `id`). */
31
+ const PANEL_ID = 'dsh-git-panel'
32
+ /** The diff page type; a different id so both types can coexist. */
33
+ const DIFF_ID = 'dsh-git-panel/diff'
34
+ /** Page kind of the panel. */
35
+ const GIT_KIND = 'git'
36
+ /** Page kind of the diff preview. */
37
+ const DIFF_KIND = 'git-diff'
38
+ /** Host route, relative so a forwarded deployment keeps working. */
39
+ const ROUTE = 'api/git-panel'
40
+ /** One `<style>` element per page. */
41
+ const STYLE_ID = 'dsh-git-panel/styles'
42
+
43
+ /** Short poll while the panel is visible: the work tree changes under it. */
44
+ const POLL_MS = 6000
45
+ /** Lane geometry of the graph, in CSS pixels. */
46
+ const LANE_WIDTH = 16
47
+ const ROW_HEIGHT = 38
48
+ const NODE_RADIUS = 4.8
49
+ /** Left/right breathing room inside the lane canvas. */
50
+ const GRAPH_PAD = 8
51
+ /** Curve tension as a fraction of the segment: Git Graph uses 0.8 for its rounded style. */
52
+ const CURVE_TENSION = 0.8
53
+ /**
54
+ * Horizontal budget of one lane canvas, in CSS pixels. Without it the
55
+ * canvas is `18px × lanes` wide, which overflows a sidebar long before the
56
+ * 400-commit ceiling is reached (59 lanes = 1060px).
57
+ */
58
+ const GRAPH_MAX_WIDTH = 260
59
+ /**
60
+ * Lanes never get thinner than this. Squeezing 59 lanes into the 260px budget
61
+ * put the pitch at 4.2px, and a 0.2px pitch on a 1161-lane history is not a
62
+ * readable graph — below this floor the canvas grows and the pane scrolls.
63
+ */
64
+ const LANE_MIN_WIDTH = 6
65
+ /**
66
+ * Branch-line palette, taken from VS Code Git Graph's default
67
+ * `git-graph.graph.colours` (12 entries) so the graph reads the same way.
68
+ * Order matters: adjacent lanes differ most at the front of the list.
69
+ */
70
+ const LANE_COLORS = [
71
+ '#0085d9', '#d9008f', '#00d90a', '#d98500', '#a300d9', '#ff0000',
72
+ '#00d9cc', '#e138e8', '#85d900', '#dc5b23', '#6f24d6', '#ffcc00',
73
+ ]
74
+
75
+ /**
76
+ * 评审期临时回退点(TEMP)。
77
+ *
78
+ * 会话工作目录不是 Git 仓库时(例如日常会话停在 ~/WorkSpace 这类聚合目录),
79
+ * Git 分区退回到这个仓库,方便一边改插件源码一边看改动。
80
+ * 评审结束把 `FALLBACK_REPO_CWD` 设为 '' 即可恢复原状。
81
+ */
82
+ const FALLBACK_REPO_CWD = '/home/yxw/WorkSpace/dsh-plugins'
83
+
84
+ /** True on a Mac, where the commit chord is ⌘ instead of Ctrl. */
85
+ const IS_MAC = (() => {
86
+ try {
87
+ return /mac|iphone|ipad/i.test(String(navigator.platform || navigator.userAgent))
88
+ } catch {
89
+ return false
90
+ }
91
+ })()
92
+ /** The visible chord label. */
93
+ const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl'
94
+
95
+ /* ------------------------------------------------------------------ */
96
+ /* Copy */
97
+ /* ------------------------------------------------------------------ */
98
+
99
+ const zh = {
100
+ 'type.label': 'Git',
101
+ 'guide.title': 'Git',
102
+ 'guide.description': '改动 · 提交 · 分支图',
103
+ 'diff.type.label': '改动预览',
104
+ 'diff.title': '改动',
105
+ 'diff.title.path': '{path} · 改动',
106
+ 'diff.mode.staged': '已暂存',
107
+ 'diff.mode.worktree': '未暂存',
108
+ 'diff.mode.untracked': '新文件',
109
+ 'diff.mode.commit': '提交',
110
+ 'diff.openFile': '打开文件',
111
+ 'diff.refresh': '刷新',
112
+ 'diff.empty': '这个范围没有可显示的改动',
113
+ 'diff.binary': '二进制文件,无法预览',
114
+ 'diff.lines': '{count} 行',
115
+ 'error.title': '操作没有完成',
116
+ 'error.dismiss': '知道了',
117
+ 'empty.noSession': '这个面板需要一个打开的会话',
118
+ 'empty.noRepo': '当前会话的工作目录不在 Git 仓库里',
119
+ 'empty.clean': '工作区干净,没有待提交的改动',
120
+ 'empty.loading': '正在读取仓库…',
121
+ 'branch.detached': '游离 HEAD',
122
+ 'branch.ahead': '领先 {count}',
123
+ 'branch.behind': '落后 {count}',
124
+ 'branch.switch': '切换分支',
125
+ 'branch.current': '当前分支',
126
+ 'status.refresh': '刷新',
127
+ 'status.fetch': '抓取',
128
+ 'status.pull': '拉取',
129
+ 'status.push': '推送',
130
+ 'status.sync': '同步',
131
+ 'status.busy': '执行中…',
132
+ 'changes.title': '改动',
133
+ 'changes.staged': '暂存的改动',
134
+ 'changes.worktree': '改动',
135
+ 'changes.untracked': '未跟踪',
136
+ 'changes.conflicts': '合并改动',
137
+ 'changes.stageAll': '全部暂存',
138
+ 'changes.unstageAll': '全部取消暂存',
139
+ 'changes.discardAll': '全部丢弃',
140
+ 'changes.previewAll': '预览全部改动',
141
+ 'changes.collapse': '折叠',
142
+ 'changes.expand': '展开',
143
+ 'row.stage': '暂存这个文件',
144
+ 'row.unstage': '取消暂存',
145
+ 'row.discard': '丢弃改动',
146
+ 'row.openDiff': '在新标签里查看改动',
147
+ 'row.openFile': '在新标签里打开文件',
148
+ 'row.delete': '删除这个未跟踪文件',
149
+ 'change.M': '已修改',
150
+ 'change.A': '新增文件(已暂存)',
151
+ 'change.U': '新增文件(未跟踪)',
152
+ 'change.D': '已删除',
153
+ 'change.R': '重命名',
154
+ 'change.C': '复制',
155
+ 'change.T': '类型变更',
156
+ 'change.conflict': '冲突(双方都改过)',
157
+ 'confirm.discardOne': '丢弃 {path} 的改动?这一步不能撤销。',
158
+ 'confirm.discardAll': '丢弃全部改动?{count} 个文件会回到 HEAD 的状态,未跟踪文件会被删除。这一步不能撤销。',
159
+ 'confirm.discard': '丢弃',
160
+ 'confirm.cancel': '取消',
161
+ 'discard.done': '已丢弃 {count} 个文件的改动',
162
+ 'commit.placeholder': '提交信息({mod}+↵ 提交到 “{branch}”)',
163
+ 'commit.draft': '按当前改动生成提交信息草稿',
164
+ 'commit.draft.one': '更新 {name}',
165
+ 'commit.draft.many': '更新 {name} 等 {count} 个文件',
166
+ 'commit.button': '提交',
167
+ 'commit.sync': '提交并推送到远端',
168
+ 'commit.continue': '继续',
169
+ 'commit.conflictsLeft': '还有 {count} 个冲突没解决',
170
+ 'conflict.badge': '冲突 · 双方都修改',
171
+ 'conflict.quote': '引用到对话',
172
+ 'conflict.quoteHeader': 'Git 冲突:{path}(当前分支 {branch})\n下面是一处冲突,请给出合并后的完整内容,并说明保留哪一边更合理。',
173
+ 'conflict.quoted': '已把冲突引用到对话输入框',
174
+ 'review.title': '智能审查',
175
+ 'review.find': '找问题',
176
+ 'review.empty': '没有可审查的改动',
177
+ 'review.quoted': '已把审查请求发到输入框',
178
+ 'review.copied': '已复制审查请求(输入框不可达)',
179
+ 'conflict.copied': '输入框不可用,冲突片段已复制到剪贴板',
180
+ 'commit.staged': '提交(已暂存)',
181
+ 'commit.all': '提交全部改动',
182
+ 'commit.amend': '修订上一次提交',
183
+ 'commit.noVerify': '跳过校验钩子提交',
184
+ 'commit.push': '提交后推送',
185
+ 'commit.hint': '没有暂存的改动,将提交全部改动',
186
+ 'commit.clean': '工作区没有改动',
187
+ 'commit.subjectTooLong': '标题 {count} 字符,建议不超过 50',
188
+ 'commit.empty': '提交信息不能为空',
189
+ 'commit.done': '已提交 {hash}',
190
+ 'commit.donePushed': '已提交并推送 {hash}',
191
+ 'graph.title': '分支图',
192
+ 'graph.resize': '拖动调整分支图高度',
193
+ 'graph.all': '全部引用',
194
+ 'graph.head': '当前分支',
195
+ 'graph.empty': '还没有提交',
196
+ 'graph.loading': '正在读取提交历史…',
197
+ 'graph.truncated': '只显示最近 {count} 条',
198
+ 'graph.openDiff': '查看这次提交的改动',
199
+ 'graph.copy': '复制 hash',
200
+ 'graph.copied': '已复制',
201
+ 'graph.checkout': '切换到这个分支',
202
+ 'graph.tag': '标签',
203
+ 'graph.remote': '远端',
204
+ 'graph.files': '{count} 个文件',
205
+ 'graph.detail': '提交说明',
206
+ 'graph.author': '作者',
207
+ 'graph.date': '时间',
208
+ 'graph.hash': '提交',
209
+ }
210
+
211
+ const en = {
212
+ 'type.label': 'Git',
213
+ 'guide.title': 'Git',
214
+ 'guide.description': 'Changes · commit · graph',
215
+ 'diff.type.label': 'Change preview',
216
+ 'diff.title': 'Changes',
217
+ 'diff.title.path': '{path} · changes',
218
+ 'diff.mode.staged': 'staged',
219
+ 'diff.mode.worktree': 'unstaged',
220
+ 'diff.mode.untracked': 'new file',
221
+ 'diff.mode.commit': 'commit',
222
+ 'diff.openFile': 'Open file',
223
+ 'diff.refresh': 'Refresh',
224
+ 'diff.empty': 'Nothing to show in this range',
225
+ 'diff.binary': 'Binary file, no preview',
226
+ 'diff.lines': '{count} lines',
227
+ 'error.title': 'The action did not finish',
228
+ 'error.dismiss': 'Dismiss',
229
+ 'empty.noSession': 'This panel needs an open session',
230
+ 'empty.noRepo': 'The session working directory is not a Git work tree',
231
+ 'empty.clean': 'The work tree is clean',
232
+ 'empty.loading': 'Reading the repository…',
233
+ 'branch.detached': 'detached HEAD',
234
+ 'branch.ahead': '{count} ahead',
235
+ 'branch.behind': '{count} behind',
236
+ 'branch.switch': 'Switch branch',
237
+ 'branch.current': 'Current branch',
238
+ 'status.refresh': 'Refresh',
239
+ 'status.fetch': 'Fetch',
240
+ 'status.pull': 'Pull',
241
+ 'status.push': 'Push',
242
+ 'status.sync': 'Sync',
243
+ 'status.busy': 'Working…',
244
+ 'changes.title': 'Changes',
245
+ 'changes.staged': 'Staged Changes',
246
+ 'changes.worktree': 'Changes',
247
+ 'changes.untracked': 'Untracked',
248
+ 'changes.conflicts': 'Merge changes',
249
+ 'changes.stageAll': 'Stage all',
250
+ 'changes.unstageAll': 'Unstage all',
251
+ 'changes.discardAll': 'Discard all',
252
+ 'changes.previewAll': 'Preview all changes',
253
+ 'changes.collapse': 'Collapse',
254
+ 'changes.expand': 'Expand',
255
+ 'row.stage': 'Stage this file',
256
+ 'row.unstage': 'Unstage',
257
+ 'row.discard': 'Discard changes',
258
+ 'row.openDiff': 'Open the diff in a new tab',
259
+ 'row.openFile': 'Open the file in a new tab',
260
+ 'row.delete': 'Delete this untracked file',
261
+ 'change.M': 'Modified',
262
+ 'change.A': 'Added file (staged)',
263
+ 'change.U': 'New file (untracked)',
264
+ 'change.D': 'Deleted',
265
+ 'change.R': 'Renamed',
266
+ 'change.C': 'Copied',
267
+ 'change.T': 'Type changed',
268
+ 'change.conflict': 'Conflict',
269
+ 'confirm.discardOne': 'Discard the changes in {path}? This cannot be undone.',
270
+ 'confirm.discardAll': 'Discard every change? {count} files go back to HEAD and untracked files are deleted. This cannot be undone.',
271
+ 'confirm.discard': 'Discard',
272
+ 'confirm.cancel': 'Cancel',
273
+ 'discard.done': 'Discarded changes in {count} files',
274
+ 'commit.placeholder': 'Message ({mod}+↵ to commit on "{branch}")',
275
+ 'commit.draft': 'Draft a commit message from the changes',
276
+ 'commit.draft.one': 'Update {name}',
277
+ 'commit.draft.many': 'Update {name} and {count} more files',
278
+ 'commit.button': 'Commit',
279
+ 'commit.sync': 'Commit and push to the remote',
280
+ 'commit.continue': 'Continue',
281
+ 'commit.conflictsLeft': '{count} conflicts left to resolve',
282
+ 'conflict.badge': 'Conflict · both modified',
283
+ 'conflict.quote': 'Quote in chat',
284
+ 'conflict.quoteHeader': 'Git conflict in {path} (branch {branch}).\nBelow is one conflicted hunk; return the fully merged content and say which side to keep.',
285
+ 'conflict.quoted': 'Quoted into the chat input',
286
+ 'review.title': 'Agent review',
287
+ 'review.find': 'Find issues',
288
+ 'review.empty': 'Nothing to review',
289
+ 'review.quoted': 'Review request sent to the input',
290
+ 'review.copied': 'Review request copied (input unreachable)',
291
+ 'conflict.copied': 'Chat input unavailable — the hunk is on your clipboard',
292
+ 'commit.staged': 'Commit staged',
293
+ 'commit.all': 'Commit all changes',
294
+ 'commit.amend': 'Amend the last commit',
295
+ 'commit.noVerify': 'Commit without hooks',
296
+ 'commit.push': 'Commit, then push',
297
+ 'commit.hint': 'Nothing staged — this commits every change',
298
+ 'commit.clean': 'No changes in the working tree',
299
+ 'commit.subjectTooLong': 'Subject is {count} characters, 50 is the usual limit',
300
+ 'commit.empty': 'A commit message is required',
301
+ 'commit.done': 'Committed {hash}',
302
+ 'commit.donePushed': 'Committed and pushed {hash}',
303
+ 'graph.title': 'Graph',
304
+ 'graph.resize': 'Drag to resize the graph',
305
+ 'graph.all': 'All refs',
306
+ 'graph.head': 'Current branch',
307
+ 'graph.empty': 'No commits yet',
308
+ 'graph.loading': 'Reading history…',
309
+ 'graph.truncated': 'Showing the newest {count}',
310
+ 'graph.openDiff': 'See what this commit changed',
311
+ 'graph.copy': 'Copy hash',
312
+ 'graph.copied': 'Copied',
313
+ 'graph.checkout': 'Check out this branch',
314
+ 'graph.tag': 'tag',
315
+ 'graph.remote': 'remote',
316
+ 'graph.files': '{count} files',
317
+ 'graph.detail': 'Message',
318
+ 'graph.author': 'Author',
319
+ 'graph.date': 'Date',
320
+ 'graph.hash': 'Commit',
321
+ }
322
+
323
+ /* ------------------------------------------------------------------ */
324
+ /* Styles */
325
+ /* ------------------------------------------------------------------ */
326
+
327
+ const CSS = `
328
+ /* Apple-flavoured design tokens, scoped to the panel. */
329
+ .gp-root{
330
+ --gp-font:-apple-system,BlinkMacSystemFont,"SF Pro Text","Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
331
+ --gp-mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;
332
+ --gp-label:rgba(255,255,255,.92);
333
+ --gp-label-2:rgba(235,235,245,.62);
334
+ --gp-label-3:rgba(235,235,245,.38);
335
+ --gp-label-4:rgba(235,235,245,.20);
336
+ --gp-fill-2:rgba(120,120,128,.28);
337
+ --gp-fill-3:rgba(120,120,128,.20);
338
+ --gp-fill-4:rgba(120,120,128,.12);
339
+ --gp-separator:rgba(84,84,88,.62);
340
+ --gp-accent:#80a1c0;
341
+ --gp-accent-hover:#8db0cf;
342
+ --gp-accent-dim:#3a4552;
343
+ --gp-accent-ink:#101720;
344
+ --gp-red:#ff453a;
345
+ --gp-green:#30d158;
346
+ --gp-orange:#ff9f0a;
347
+ --gp-yellow:#ffd60a;
348
+ --gp-blue:#0a84ff;
349
+ --gp-purple:#bf5af2;
350
+ --gp-radius:8px;
351
+ display:flex;flex-direction:column;height:100%;min-height:0;position:relative;
352
+ font-family:var(--gp-font);font-size:12.5px;line-height:1.45;letter-spacing:-.006em;
353
+ color:var(--gp-label);background:transparent;
354
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
355
+ }
356
+ .gp-scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
357
+ .gp-review{flex:none;position:relative;display:flex;flex-direction:column;gap:6px;padding:8px 10px}
358
+ .gp-review-btn{width:100%;height:28px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:none;border-radius:7px;background:var(--gp-fill-3);color:var(--gp-label);font:inherit;font-size:12.5px;font-weight:560;cursor:pointer;transition:background .12s ease-out}
359
+ .gp-review-btn:hover:not(:disabled){background:var(--gp-fill-2)}
360
+ .gp-review-btn:disabled{opacity:.42;cursor:default}
361
+ .gp-review-head{font-size:10.5px;letter-spacing:.04em;text-transform:uppercase;color:var(--gp-label-3);cursor:pointer;user-select:none}
362
+ .gp-review-head:hover{color:var(--gp-label-2)}
363
+ .gp-changes{flex:1;min-height:80px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
364
+ .gp-resizer{flex:none;position:relative;height:8px;cursor:row-resize;background:transparent;user-select:none;touch-action:none}
365
+ .gp-resizer:hover,.gp-resizer:active{background:var(--gp-fill-3)}
366
+ .gp-graph-panel{flex:none;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
367
+ .gp-toolbar{display:flex;align-items:center;gap:6px;flex:none;padding:8px 10px;border-bottom:.5px solid var(--gp-separator)}
368
+ .gp-toolbar .gp-spacer{flex:1}
369
+ .gp-branch{display:inline-flex;align-items:center;gap:5px;min-width:0;max-width:100%;height:22px;padding:0 8px;border-radius:6px;background:var(--gp-fill-3);color:var(--gp-label);font-family:var(--gp-mono);font-size:11px;letter-spacing:0;border:none;cursor:pointer;transition:background .12s ease-out}
370
+ .gp-branch:hover{background:var(--gp-fill-2)}
371
+ .gp-branch span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
372
+ .gp-tag{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--gp-fill-3);color:var(--gp-label-2)}
373
+ .gp-btn{display:inline-flex;align-items:center;justify-content:center;gap:4px;height:24px;min-width:24px;padding:0 6px;border-radius:6px;border:none;background:transparent;color:var(--gp-label-2);font:inherit;font-size:11.5px;cursor:pointer;transition:background .12s ease-out,color .12s ease-out}
374
+ .gp-btn:hover:not(:disabled){background:var(--gp-fill-3);color:var(--gp-label)}
375
+ .gp-btn:active:not(:disabled){background:var(--gp-fill-2)}
376
+ .gp-btn:disabled{opacity:.38;cursor:default}
377
+ .gp-btn svg{width:13px;height:13px;flex:none}
378
+ .gp-btn-danger:hover:not(:disabled){background:rgba(255,69,58,.18);color:var(--gp-red)}
379
+ .gp-primary{height:28px;padding:0 12px;border-radius:7px;border:none;background:var(--gp-accent);color:var(--gp-accent-ink);font:inherit;font-size:12.5px;font-weight:590;letter-spacing:-.01em;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:background .12s ease-out,transform .06s ease-out}
380
+ .gp-primary:hover:not(:disabled){background:var(--gp-accent-hover)}
381
+ .gp-primary:active:not(:disabled){transform:scale(.985)}
382
+ .gp-primary:disabled{background:var(--gp-accent-dim);color:var(--gp-label-3);cursor:default}
383
+ .gp-compose{flex:none;display:flex;flex-direction:column;gap:8px;padding:10px}
384
+ .gp-input{box-sizing:border-box;width:100%;min-height:36px;max-height:132px;resize:none;field-sizing:content;padding:8px 10px;border-radius:var(--gp-radius);border:.5px solid rgba(255,255,255,.07);background:#2e2e2e;color:rgba(255,255,255,.94);font:inherit;font-size:12.5px;line-height:1.45;outline:none;transition:border-color .12s ease-out,box-shadow .12s ease-out}
385
+ .gp-input:focus{border-color:var(--gp-accent);box-shadow:0 0 0 3.5px rgba(128,161,192,.28)}
386
+ .gp-input::placeholder{color:var(--gp-label-3)}
387
+ .gp-compose .gp-input-wrap{position:relative;display:flex}
388
+ .gp-input-wrap .gp-input{padding-right:34px}
389
+ .gp-input-ai{position:absolute;top:5px;right:5px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;padding:0;border:none;border-radius:6px;background:transparent;color:var(--gp-label-3);cursor:pointer;transition:background .12s ease-out,color .12s ease-out}
390
+ .gp-input-ai:hover:not(:disabled){background:var(--gp-fill-3);color:var(--gp-label)}
391
+ .gp-input-ai:disabled{opacity:.35;cursor:default}
392
+ .gp-input-ai svg{width:14px;height:14px;flex:none}
393
+ .gp-actions{display:flex;align-items:center;gap:6px}
394
+ .gp-actions > .gp-primary{flex:1}
395
+ .gp-actions .gp-primary{width:100%;height:32px;justify-content:center;gap:6px;font-size:13px;border-radius:8px}
396
+ .gp-actions .gp-primary svg{width:14px;height:14px;flex:none}
397
+ .gp-actions .gp-primary-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
398
+ .gp-menu-wrap{position:relative}
399
+ .gp-menu{position:absolute;z-index:40;top:calc(100% + 6px);right:0;min-width:200px;padding:5px;border-radius:10px;border:.5px solid rgba(255,255,255,.1);background:rgba(40,40,44,.86);backdrop-filter:saturate(180%) blur(24px);-webkit-backdrop-filter:saturate(180%) blur(24px);box-shadow:0 12px 32px rgba(0,0,0,.48),0 0 0 .5px rgba(0,0,0,.5);display:flex;flex-direction:column;gap:1px}
400
+ .gp-menu button{display:flex;width:100%;align-items:center;gap:7px;height:28px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--gp-label);font:inherit;font-size:12.5px;text-align:left;cursor:pointer;transition:background .1s ease-out}
401
+ .gp-menu button:hover:not(:disabled){background:var(--gp-accent)}
402
+ .gp-menu button:hover:not(:disabled){color:var(--gp-accent-ink)}
403
+ .gp-menu button:disabled{opacity:.4;cursor:default}
404
+ .gp-section{flex:none}
405
+ .gp-section-head{display:flex;align-items:center;gap:6px;padding:0 8px 0 10px;min-height:30px;cursor:pointer;user-select:none;transition:background .1s ease-out}
406
+ .gp-section-head:hover{background:var(--gp-fill-4)}
407
+ .gp-section-title{font-weight:600;font-size:11px;letter-spacing:.055em;text-transform:uppercase;color:var(--gp-label-3)}
408
+ .gp-section-count{margin-left:2px;font-size:11px;color:var(--gp-label-3);font-variant-numeric:tabular-nums}
409
+ .gp-chevron{width:11px;height:11px;flex:none;color:var(--gp-label-3);transition:transform .18s cubic-bezier(.32,.72,0,1)}
410
+ .gp-chevron.open{transform:rotate(90deg)}
411
+ .gp-section-actions{margin-left:auto;display:flex;align-items:center;gap:2px}
412
+ .gp-group-head{display:flex;align-items:center;gap:6px;padding:2px 8px 2px 22px;min-height:22px;color:var(--gp-label-3);font-size:11px;font-weight:500}
413
+ .gp-group-head .gp-section-actions{gap:0}
414
+ .gp-row{display:flex;align-items:center;gap:8px;min-height:30px;padding:0 6px 0 22px;cursor:pointer;position:relative;transition:background .1s ease-out}
415
+ .gp-row:hover{background:var(--gp-fill-4)}
416
+ .gp-row.selected{background:rgba(128,161,192,.18)}
417
+ .gp-row .gp-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;letter-spacing:-.006em}
418
+ .gp-row .gp-name .gp-dir{color:var(--gp-label-3)}
419
+ /* One chip per change kind: M pencil, A plus, U dashed plus, D minus, R arrow,
420
+ C copy, T swap, CONFLICT alert. The chip carries the colour; the tooltip and
421
+ aria-label carry the word. */
422
+ .gp-change{flex:none;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:5.5px;background:var(--gp-fill-4);border:.5px solid transparent;color:var(--gp-label-2);transition:background .12s ease-out}
423
+ .gp-change svg{width:12px;height:12px;flex:none}
424
+ .gp-change.M{color:var(--gp-orange);background:rgba(255,159,10,.13);border-color:rgba(255,159,10,.3)}
425
+ .gp-change.A{color:var(--gp-green);background:rgba(48,209,88,.13);border-color:rgba(48,209,88,.3)}
426
+ .gp-change.U{color:var(--gp-green);background:rgba(48,209,88,.06);border:.5px dashed rgba(48,209,88,.55)}
427
+ .gp-change.D{color:var(--gp-red);background:rgba(255,69,58,.13);border-color:rgba(255,69,58,.3)}
428
+ .gp-change.R,.gp-change.C{color:var(--gp-blue);background:rgba(10,132,255,.14);border-color:rgba(10,132,255,.32)}
429
+ .gp-change.T{color:var(--gp-purple);background:rgba(191,90,242,.14);border-color:rgba(191,90,242,.32)}
430
+ .gp-change.CONFLICT{color:var(--gp-red);background:rgba(255,69,58,.2);border-color:rgba(255,69,58,.45)}
431
+ .gp-ftype{flex:none;display:inline-flex;align-items:center;justify-content:center;width:16px;color:var(--gp-label-3)}
432
+ .gp-ftype svg{width:14px;height:14px;flex:none}
433
+ .gp-ftype.code{color:#e0c07a}
434
+ .gp-ftype.style{color:#6fc7e8}
435
+ .gp-ftype.doc{color:#7fa6cf}
436
+ .gp-ftype.image{color:#c08ed6}
437
+ .gp-ftype.media{color:#e08ab0}
438
+ .gp-ftype.font{color:#d8c98a}
439
+ .gp-ftype.archive{color:#9aa3ad}
440
+ .gp-ftype.lock{color:#a9a9b3}
441
+ .gp-ftype.data{color:#8fcfa0}
442
+ .gp-ftype.shell{color:#8fd39a}
443
+ .gp-row-conflict .gp-change{color:var(--gp-red)}
444
+ .gp-counts{display:flex;gap:5px;flex:none;font-size:10.5px;font-variant-numeric:tabular-nums;color:var(--gp-label-3);font-family:var(--gp-mono)}
445
+ .gp-add{color:var(--gp-green)}
446
+ .gp-del{color:var(--gp-red)}
447
+ .gp-row-actions{display:none;align-items:center;gap:1px;flex:none}
448
+ .gp-row:hover .gp-row-actions{display:flex}
449
+ .gp-row:hover .gp-counts{display:none}
450
+ .gp-row:hover .gp-change{display:none}
451
+ .gp-graph{position:relative}
452
+ .gp-graph-row{display:flex;align-items:center;gap:8px;min-height:38px;padding:0 8px 0 0;cursor:pointer;position:relative;transition:background .1s ease-out}
453
+ .gp-graph-row:hover{background:var(--gp-fill-4)}
454
+ .gp-graph-row.selected{background:rgba(128,161,192,.18)}
455
+ .gp-graph-canvas{flex:none;display:block;overflow:visible}
456
+ .gp-graph-row:hover .gp-graph-canvas{filter:brightness(1.18)}
457
+ .gp-graph-row.selected .gp-graph-canvas{filter:brightness(1.3) saturate(1.1)}
458
+ .gp-graph-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
459
+ .gp-graph-subject{display:flex;align-items:center;gap:6px;min-width:0}
460
+ .gp-graph-subject .gp-subject-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px}
461
+ .gp-ref{flex:none;max-width:132px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--gp-fill-3);color:var(--gp-label-2);font-weight:500}
462
+ .gp-ref.head{background:rgba(10,132,255,.24);color:#9dc0ff}
463
+ .gp-ref.tag{background:rgba(255,159,10,.2);color:#ffc477}
464
+ .gp-ref.remote{background:rgba(191,90,242,.2);color:#d7a8ff}
465
+ .gp-graph-meta{display:flex;align-items:center;gap:7px;font-size:10.5px;color:var(--gp-label-3)}
466
+ .gp-graph-meta .gp-hash{font-family:var(--gp-mono)}
467
+ .gp-detail{padding:8px 10px 10px 22px;border-bottom:.5px solid var(--gp-separator);background:rgba(120,120,128,.08)}
468
+ .gp-detail-body{white-space:pre-wrap;word-break:break-word;font-size:11.5px;color:var(--gp-label-2);margin:5px 0 7px}
469
+ .gp-kv{display:flex;gap:7px;font-size:11px;color:var(--gp-label-3)}
470
+ .gp-kv b{font-weight:590;color:var(--gp-label-2)}
471
+ .gp-detail-actions{display:flex;gap:2px;margin-top:7px}
472
+ .gp-detail-files{margin-top:7px;display:flex;flex-direction:column;gap:2px;max-height:168px;overflow:auto}
473
+ .gp-detail-file{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--gp-label-2)}
474
+ .gp-detail-file .gp-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
475
+ .gp-note{padding:12px;color:var(--gp-label-3);font-size:11.5px;text-align:center}
476
+ .gp-error{margin:8px;padding:8px 10px;border-radius:10px;background:rgba(255,69,58,.12);border:.5px solid rgba(255,69,58,.28);color:var(--gp-label);font-size:11.5px;display:flex;gap:8px;align-items:center}
477
+ .gp-error span{flex:1;min-width:0;word-break:break-word;white-space:pre-wrap}
478
+ /* Confirmation is an in-panel overlay anchored to the bottom edge: it used to be
479
+ a sticky row at the end of the column, which landed outside the visible pane
480
+ whenever the graph held its height. */
481
+ .gp-confirm-scrim{position:absolute;inset:0;z-index:60;display:flex;align-items:flex-end;padding:8px;background:rgba(12,12,14,.32)}
482
+ .gp-confirm{flex:1;padding:9px 10px;border-radius:10px;background:rgba(44,44,48,.96);backdrop-filter:saturate(180%) blur(24px);-webkit-backdrop-filter:saturate(180%) blur(24px);border:.5px solid rgba(255,255,255,.12);box-shadow:0 12px 32px rgba(0,0,0,.5);font-size:11.5px;display:flex;gap:8px;align-items:center}
483
+ .gp-confirm span{flex:1;min-width:0;line-height:1.45}
484
+ .gp-confirm .gp-primary{height:24px;padding:0 10px;font-size:11.5px;flex:none}
485
+ .gp-diff{display:flex;flex-direction:column;height:100%;min-height:0;font-size:11.5px}
486
+ .gp-diff-head{display:flex;align-items:center;gap:7px;padding:0 10px;min-height:34px;flex:none;border-bottom:.5px solid var(--gp-separator)}
487
+ .gp-diff-head .gp-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--gp-mono);font-size:11.5px}
488
+ .gp-diff-body{flex:1;min-height:0;overflow:auto;font-family:var(--gp-mono);font-size:11.5px;line-height:18px;scrollbar-width:thin}
489
+ .gp-diff-line{display:flex;min-width:max-content;white-space:pre}
490
+ .gp-diff-line .gp-ln{flex:none;width:42px;padding-right:8px;text-align:right;color:var(--gp-label-4);user-select:none;font-variant-numeric:tabular-nums}
491
+ .gp-diff-line .gp-sign{flex:none;width:12px;text-align:center}
492
+ .gp-diff-line .gp-code{flex:1;padding-right:14px;white-space:pre}
493
+ .gp-diff-line.add{background:rgba(48,209,88,.13)}
494
+ .gp-diff-line.add .gp-sign{color:var(--gp-green)}
495
+ .gp-diff-line.del{background:rgba(255,69,58,.13)}
496
+ .gp-diff-line.del .gp-sign{color:var(--gp-red)}
497
+ .gp-diff-line.hunk{background:var(--gp-fill-4);color:var(--gp-label-3)}
498
+ .gp-diff-line.meta{color:var(--gp-label-3)}
499
+ .gp-diff-line .gp-prefix{color:var(--gp-label-3)}
500
+ .gp-spinner{display:inline-block;width:11px;height:11px;border:1.5px solid currentColor;border-right-color:transparent;border-radius:50%;animation:gp-spin .7s linear infinite}
501
+ @keyframes gp-spin{to{transform:rotate(360deg)}}
502
+
503
+ `
504
+
505
+ /* ------------------------------------------------------------------ */
506
+ /* Small utilities */
507
+ /* ------------------------------------------------------------------ */
508
+
509
+ /** Install the stylesheet once per page; the effect removes it on dispose. */
510
+ function installStyles() {
511
+ if (typeof document === 'undefined') return undefined
512
+ const existing = document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_ID)}]`)
513
+ if (existing !== null) return undefined
514
+ const tag = document.createElement('style')
515
+ tag.dataset.plugin = 'dsh-git-panel'
516
+ tag.dataset.pluginCss = STYLE_ID
517
+ tag.textContent = CSS
518
+ document.head.appendChild(tag)
519
+ return () => {
520
+ tag.remove()
521
+ }
522
+ }
523
+
524
+ /** Resolve the route below the page base so forwarded deployments keep working. */
525
+ function endpointUrl() {
526
+ if (typeof document === 'undefined') return ROUTE
527
+ try {
528
+ return new URL(ROUTE, document.baseURI).href
529
+ } catch {
530
+ return ROUTE
531
+ }
532
+ }
533
+
534
+ /** One failure from the Host route, carrying its stable code. */
535
+ class PanelFailure extends Error {
536
+ constructor(code, message, details) {
537
+ super(message)
538
+ this.name = 'PanelFailure'
539
+ this.code = code
540
+ this.details = details
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Call one Host action.
546
+ * @param {string} action - route action name.
547
+ * @param {object} payload - action fields (cwd plus action-specific ones).
548
+ * @param {AbortSignal} [signal] - request signal.
549
+ * @returns {Promise<object>} the route's value.
550
+ */
551
+ async function call(action, payload, signal) {
552
+ const response = await fetch(endpointUrl(), {
553
+ method: 'POST',
554
+ headers: { 'content-type': 'application/json' },
555
+ credentials: 'same-origin',
556
+ signal,
557
+ body: JSON.stringify({ action, ...payload }),
558
+ })
559
+ if (!response.ok) throw new PanelFailure('http', `HTTP ${response.status}`, { status: response.status })
560
+ const envelope = await response.json()
561
+ if (envelope === null || typeof envelope !== 'object') throw new PanelFailure('bad-payload', '响应格式不正确')
562
+ if (envelope.ok === true) return envelope.value
563
+ const error = envelope.error ?? {}
564
+ throw new PanelFailure(String(error.code ?? 'failed'), String(error.message ?? 'Git 操作失败'), error.details)
565
+ }
566
+
567
+ /** Fill `{name}` placeholders; unknown names stay literal. */
568
+ function fill(text, values) {
569
+ return String(text).replace(/\{(\w+)\}/gu, (whole, name) => {
570
+ const value = values === undefined ? undefined : values[name]
571
+ return value === undefined ? whole : String(value)
572
+ })
573
+ }
574
+
575
+ /** Split a path into its directory and its node name. */
576
+ function splitPath(path) {
577
+ const index = String(path).lastIndexOf('/')
578
+ if (index === -1) return { dir: '', name: String(path) }
579
+ return { dir: String(path).slice(0, index + 1), name: String(path).slice(index + 1) }
580
+ }
581
+
582
+ /**
583
+ * Navigation params for one row's diff tab.
584
+ *
585
+ * The worktree group mixes modified and untracked files, so the row's own
586
+ * porcelain code decides whether the Host should diff the index or
587
+ * synthesize an all-additions patch for a file git has never seen.
588
+ * @param {object} entry - status row.
589
+ * @param {string} group - section id (`staged | worktree | untracked | conflicts`).
590
+ * @param {string} [cwd] - repository working directory.
591
+ * @returns {object} params for the `git-diff` tab.
592
+ */
593
+ function diffRequestFor(entry, group, cwd) {
594
+ const path = String(entry?.path ?? '')
595
+ const base = typeof cwd === 'string' && cwd !== '' ? { cwd } : {}
596
+ if (group === 'staged') return { path, staged: true, ...base }
597
+ if (group === 'untracked' || entry?.code === '??') return { path, untracked: true, ...base }
598
+ return { path, ...base }
599
+ }
600
+
601
+ /**
602
+ * The `discard` payload for one row: untracked files are removed, tracked
603
+ * ones are restored. A staged-new file is handled by the Host, which sees
604
+ * it as tracked-without-HEAD.
605
+ * @param {object} entry - status row.
606
+ * @param {string} group - section id.
607
+ * @returns {object} request fields.
608
+ */
609
+ function discardRequestFor(entry, group) {
610
+ const path = String(entry?.path ?? '')
611
+ const untracked = group === 'untracked' || entry?.code === '??'
612
+ return untracked ? { paths: [], untracked: [path] } : { paths: [path] }
613
+ }
614
+
615
+ /** A short, human relative time for a unix-seconds or ISO timestamp. */
616
+ function relativeTime(value, now = Date.now()) {
617
+ let ms
618
+ if (typeof value === 'number') ms = value > 1e12 ? value : value * 1000
619
+ else ms = Date.parse(String(value))
620
+ if (!Number.isFinite(ms)) return ''
621
+ const seconds = Math.max(0, Math.round((now - ms) / 1000))
622
+ if (seconds < 60) return '刚刚'
623
+ const minutes = Math.round(seconds / 60)
624
+ if (minutes < 60) return `${minutes} 分钟前`
625
+ const hours = Math.round(minutes / 60)
626
+ if (hours < 24) return `${hours} 小时前`
627
+ const days = Math.round(hours / 24)
628
+ if (days < 30) return `${days} 天前`
629
+ const months = Math.round(days / 30)
630
+ if (months < 12) return `${months} 个月前`
631
+ return `${Math.round(months / 12)} 年前`
632
+ }
633
+
634
+ /** Absolute local time for a timestamp, for the commit detail. */
635
+ function absoluteTime(value) {
636
+ const ms = typeof value === 'number' ? (value > 1e12 ? value : value * 1000) : Date.parse(String(value))
637
+ if (!Number.isFinite(ms)) return String(value ?? '')
638
+ try {
639
+ return new Date(ms).toLocaleString()
640
+ } catch {
641
+ return String(value ?? '')
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Build the `dsh-resource://file/...` address of one repository file.
647
+ * An absolute path keeps working when the repository root differs from the
648
+ * Session's workspace root.
649
+ * @param {string} sessionId - Session opening the file.
650
+ * @param {string} root - repository top level.
651
+ * @param {string} path - repository-relative path.
652
+ * @returns {string} the address.
653
+ */
654
+ /**
655
+ * Pull the conflict block out of a patch; a patch without markers falls
656
+ * back to its first lines so the quote is never empty.
657
+ * @param {string} patch - unified diff text.
658
+ * @returns {string} the quoted lines.
659
+ */
660
+ /**
661
+ * Put one quote into the conversation input of a session, appending to what
662
+ * the user already typed. Falls back to the clipboard when the input cannot
663
+ * be reached from here.
664
+ * @param {string} sessionId - session on screen.
665
+ * @param {string} text - the quote.
666
+ * @returns {boolean} true when the input took it.
667
+ */
668
+ function insertIntoComposer(sessionId, text) {
669
+ try {
670
+ const conversation = pluginCtx?.get?.('conversation')
671
+ const sessions = pluginCtx?.get?.('sessions')
672
+ const binding = sessions?.binding?.(sessionId)
673
+ if (conversation?.input !== undefined && binding !== undefined) {
674
+ const shell = conversation.input.shellFor?.(binding)
675
+ const current = typeof shell?.snapshot?.draft === 'string' ? shell.snapshot.draft : ''
676
+ const prompt = current.trim() === '' ? text : `${current.trimEnd()}\n\n${text}`
677
+ if (conversation.input.requestDraftInitialization(binding, { prompt, whenDraftExists: 'replace' }) !== 'blocked') return true
678
+ }
679
+ } catch (failure) {
680
+ // the clipboard below is the fallback
681
+ }
682
+ try {
683
+ navigator.clipboard?.writeText?.(text)
684
+ } catch (failure) {
685
+ // nothing else to do
686
+ }
687
+ return false
688
+ }
689
+
690
+ function conflictSnippet(patch) {
691
+ const lines = String(patch ?? '').split('\n')
692
+ const start = lines.findIndex((line) => line.startsWith('<<<<<<<'))
693
+ let end = -1
694
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
695
+ if (lines[index].startsWith('>>>>>>>')) { end = index; break }
696
+ }
697
+ if (start === -1 || end === -1 || end < start) return lines.slice(0, 40).join('\n')
698
+ return lines.slice(start, end + 1).join('\n')
699
+ }
700
+
701
+ /**
702
+ * Build the review request the "Find Issues" button sends to the composer.
703
+ *
704
+ * The prompt asks the agent to read the diff itself rather than pasting it:
705
+ * a working tree can be megabytes, and the conversation input is only a draft.
706
+ * @param {object} input - repo facts.
707
+ * @returns {string} prompt text, empty when there is nothing to review.
708
+ */
709
+ function buildReviewPrompt(input) {
710
+ const cwd = String(input?.cwd ?? '')
711
+ const branch = String(input?.branch ?? 'HEAD')
712
+ const staged = Array.isArray(input?.staged) ? input.staged : []
713
+ const changes = Array.isArray(input?.changes) ? input.changes : []
714
+ const untracked = Array.isArray(input?.untracked) ? input.untracked : []
715
+ const limit = Number.isInteger(input?.limit) ? input.limit : 40
716
+ const all = [
717
+ ...staged.map((entry) => `- [已暂存] ${entry.path}`),
718
+ ...changes.map((entry) => `- [未暂存] ${entry.path}`),
719
+ ...untracked.map((entry) => `- [未跟踪] ${entry.path}`),
720
+ ]
721
+ if (all.length === 0) return ''
722
+ const shown = all.slice(0, limit)
723
+ const more = all.length > shown.length ? `\n- …另有 ${all.length - shown.length} 个文件,未列出` : ''
724
+ const head = [
725
+ '请审查当前工作区的本地改动,找出**真实存在的问题**,不要做风格偏好评审。',
726
+ '',
727
+ `仓库:${cwd}`,
728
+ `分支:${branch}(对比基线:该分支的 HEAD)`,
729
+ `改动:已暂存 ${staged.length} · 未暂存 ${changes.length} · 未跟踪 ${untracked.length}`,
730
+ '',
731
+ '改动文件:',
732
+ ...shown,
733
+ ].join('\n') + more
734
+ const rules = [
735
+ '',
736
+ '做法要求:',
737
+ `1. 先读真实内容:\`git -C ${cwd} diff\`、\`git -C ${cwd} diff --staged\`;未跟踪文件直接读文件。不要凭文件名猜。`,
738
+ '2. 只报能给出证据的问题:正确性 bug、边界条件、空值、资源泄漏、并发与竞态、错误处理缺失、安全(注入/路径穿越/凭据)、向后兼容破坏、明确的性能回退。',
739
+ '3. 每条按这个格式输出:`[级别] 文件:行 — 问题一句话`,下一行给「触发条件」与「建议修法」。级别只用 blocker / major / minor。',
740
+ '4. 不要修改文件、不要提交、不要跑破坏性命令;这一步只出结论。',
741
+ '5. 没问题就直说「未发现需要修的问题」,并列出实际看过的文件;不要为凑数编造。',
742
+ ].join('\n')
743
+ return `${head}\n${rules}`
744
+ }
745
+
746
+ function fileAddress(sessionId, root, path) {
747
+ const absolute = `${String(root).replace(/[\\/]+$/u, '')}/${path}`.replace(/\\/gu, '/')
748
+ const encodeSegment = (segment) => encodeURIComponent(segment).replace(/%3A/giu, ':')
749
+ const encoded = absolute.split('/').map(encodeSegment).join('/')
750
+ return `dsh-resource://file/session/${encodeSegment(String(sessionId))}/${encoded}`
751
+ }
752
+
753
+ /* ------------------------------------------------------------------ */
754
+ /* Patch parsing */
755
+ /* ------------------------------------------------------------------ */
756
+
757
+ /**
758
+ * Turn one unified diff into renderable rows, tracking both line numbers.
759
+ * @param {string} patch - unified diff text.
760
+ * @param {number} [limit] - row cap; the tail is dropped.
761
+ * @returns {{rows: object[], truncated: boolean, additions: number, deletions: number}} rows and totals.
762
+ */
763
+ function parsePatch(patch, limit = 6000) {
764
+ const rows = []
765
+ let oldNo = 0
766
+ let newNo = 0
767
+ let truncated = false
768
+ let additions = 0
769
+ let deletions = 0
770
+ for (const line of String(patch ?? '').split('\n')) {
771
+ if (rows.length >= limit) {
772
+ truncated = true
773
+ break
774
+ }
775
+ if (line.startsWith('@@')) {
776
+ const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/u.exec(line)
777
+ if (match !== null) {
778
+ oldNo = Number.parseInt(match[1], 10)
779
+ newNo = Number.parseInt(match[2], 10)
780
+ }
781
+ rows.push({ kind: 'hunk', text: line, oldNo: '', newNo: '' })
782
+ continue
783
+ }
784
+ if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
785
+ rows.push({ kind: 'meta', text: line, oldNo: '', newNo: '' })
786
+ continue
787
+ }
788
+ if (
789
+ line.startsWith('new file') ||
790
+ line.startsWith('deleted file') ||
791
+ line.startsWith('old mode') ||
792
+ line.startsWith('new mode') ||
793
+ line.startsWith('similarity') ||
794
+ line.startsWith('rename ') ||
795
+ line.startsWith('Binary files') ||
796
+ line.startsWith('\\')
797
+ ) {
798
+ rows.push({ kind: 'meta', text: line, oldNo: '', newNo: '' })
799
+ continue
800
+ }
801
+ if (line.startsWith('+')) {
802
+ additions += 1
803
+ rows.push({ kind: 'add', text: line.slice(1), oldNo: '', newNo: newNo })
804
+ newNo += 1
805
+ continue
806
+ }
807
+ if (line.startsWith('-')) {
808
+ deletions += 1
809
+ rows.push({ kind: 'del', text: line.slice(1), oldNo: oldNo, newNo: '' })
810
+ oldNo += 1
811
+ continue
812
+ }
813
+ if (line.startsWith(' ')) {
814
+ rows.push({ kind: 'context', text: line.slice(1), oldNo: oldNo, newNo: newNo })
815
+ oldNo += 1
816
+ newNo += 1
817
+ continue
818
+ }
819
+ if (line === '') continue
820
+ rows.push({ kind: 'context', text: line, oldNo: '', newNo: '' })
821
+ }
822
+ return { rows, truncated, additions, deletions }
823
+ }
824
+
825
+ /* ------------------------------------------------------------------ */
826
+ /* Graph lanes */
827
+ /* ------------------------------------------------------------------ */
828
+
829
+ /** Colour of one lane, cycling the palette. */
830
+ const laneColor = (index) => LANE_COLORS[((index % LANE_COLORS.length) + LANE_COLORS.length) % LANE_COLORS.length]
831
+
832
+ /**
833
+ * Assign lanes to commits, gitk-style.
834
+ *
835
+ * A lane holds the hash it expects next. A commit takes the lane already
836
+ * waiting for it (or opens one), leaves its first parent there, and puts any
837
+ * further parent into a free lane. The result is what the SVG needs: the
838
+ * lane of every row, and the edges crossing it.
839
+ * @param {object[]} commits - commits in git's order.
840
+ * @returns {{rows: object[], laneCount: number}} laid-out rows.
841
+ */
842
+ function layoutGraph(commits) {
843
+ const rows = []
844
+ let lanes = []
845
+ /**
846
+ * Palette colour of each lane's *lineage*, parallel to `lanes`.
847
+ *
848
+ * Colour used to be `laneColor(index)`, i.e. a function of the lane
849
+ * number. Since lanes compact when a branch ends, one branch changed
850
+ * colour mid-line (measured: 0.44–1.36% of edges), and two branches that
851
+ * reached the same index shared a colour. Keeping the colour with the
852
+ * lane slot instead makes it follow the branch and survive compaction.
853
+ */
854
+ let colors = []
855
+ let colorSeq = 0
856
+ let laneCount = 0
857
+ const takeColor = () => LANE_COLORS[colorSeq++ % LANE_COLORS.length]
858
+
859
+ for (const commit of commits) {
860
+ let index = lanes.indexOf(commit.hash)
861
+ // A commit that a lane already waits for is drawn with an incoming
862
+ // segment from the top of the row; one that opens a new lane is not,
863
+ // or every graph would start with a line running into nowhere.
864
+ const arrived = index !== -1
865
+ if (index === -1) {
866
+ index = lanes.length
867
+ lanes = lanes.concat([commit.hash])
868
+ colors = colors.concat([takeColor()])
869
+ }
870
+ const before = lanes
871
+ const beforeColors = colors
872
+ const next = before.slice()
873
+ const nextColors = beforeColors.slice()
874
+ const firstParent = commit.parents.length > 0 ? commit.parents[0] : null
875
+ // A first parent that another lane already waits for must not open a
876
+ // second lane for the same hash: duplicates gave one commit two lines
877
+ // in two colours, and `after.indexOf` then resolved to the wrong one.
878
+ next[index] = firstParent !== null && next.some((hash, lane) => lane !== index && hash === firstParent) ? null : firstParent
879
+ for (const parent of commit.parents.slice(1)) {
880
+ if (next.includes(parent)) continue
881
+ const free = next.indexOf(null)
882
+ if (free === -1) {
883
+ next.push(parent)
884
+ nextColors.push(takeColor())
885
+ } else {
886
+ next[free] = parent
887
+ nextColors[free] = takeColor()
888
+ }
889
+ }
890
+ // Nulls compact, and the colours compact with them.
891
+ const after = []
892
+ const afterColors = []
893
+ for (let lane = 0; lane < next.length; lane += 1) {
894
+ if (next[lane] === null) continue
895
+ after.push(next[lane])
896
+ afterColors.push(nextColors[lane])
897
+ }
898
+
899
+ const edges = []
900
+ // Lanes that merely pass through this row keep their line; when a
901
+ // previous row reordered them, the target is where they now sit.
902
+ for (let lane = 0; lane < before.length; lane += 1) {
903
+ if (lane === index) continue
904
+ const target = after.indexOf(before[lane])
905
+ if (target === -1) continue
906
+ edges.push({ from: lane, to: target, color: beforeColors[lane], commitEdge: false, hash: before[lane] })
907
+ }
908
+ for (const parent of commit.parents) {
909
+ const target = after.indexOf(parent)
910
+ if (target === -1) continue
911
+ // Take the colour of the lane the parent lands in, not the merge's
912
+ // own: a fan-out then splits into branch colours on this row instead
913
+ // of staying one colour until the next row.
914
+ edges.push({ from: index, to: target, color: afterColors[target] ?? beforeColors[index], commitEdge: true, hash: parent })
915
+ }
916
+
917
+ laneCount = Math.max(laneCount, before.length, after.length)
918
+ const current = Array.isArray(commit.refs) && commit.refs.some((ref) => ref.kind === 'head')
919
+ rows.push({ commit, lane: index, color: beforeColors[index], current, arrived, edges })
920
+ lanes = after
921
+ colors = afterColors
922
+ }
923
+ return { rows, laneCount }
924
+ }
925
+
926
+ /* ------------------------------------------------------------------ */
927
+ /* Icons */
928
+ /* ------------------------------------------------------------------ */
929
+
930
+ const iconProps = { viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', focusable: 'false' }
931
+ /** Same canvas, heavier line: the 11–14px glyphs would smear at 1.4. */
932
+ const glyphProps = { ...iconProps, strokeWidth: 1.75 }
933
+
934
+ const BranchIcon = (props) =>
935
+ h(
936
+ 'svg',
937
+ { ...iconProps, width: props?.size ?? 13, height: props?.size ?? 13 },
938
+ h('circle', { cx: 4.5, cy: 12, r: 1.7 }),
939
+ h('circle', { cx: 4.5, cy: 4, r: 1.7 }),
940
+ h('circle', { cx: 11.5, cy: 6.5, r: 1.7 }),
941
+ h('path', { d: 'M4.5 5.7v4.6' }),
942
+ h('path', { d: 'M11.5 8.2c0 1.7-1.4 2.2-3.4 2.5' }),
943
+ )
944
+
945
+ const ChevronIcon = (props) => h('svg', { ...iconProps, width: 12, height: 12, className: props?.className }, h('path', { d: 'M6 3.5 10.5 8 6 12.5' }))
946
+ const RefreshIcon = () => h('svg', iconProps, h('path', { d: 'M13.5 8a5.5 5.5 0 1 1-1.9-4.2' }), h('path', { d: 'M13.5 2v3.2h-3.2' }))
947
+ const PlusIcon = () => h('svg', iconProps, h('path', { d: 'M8 3v10M3 8h10' }))
948
+ const MinusIcon = () => h('svg', iconProps, h('path', { d: 'M3 8h10' }))
949
+ const UndoIcon = () => h('svg', iconProps, h('path', { d: 'M3 8a5 5 0 1 1 1.6 3.7' }), h('path', { d: 'M3 3.6V8h4.4' }))
950
+ const DiffIcon = () => h('svg', iconProps, h('path', { d: 'M4 2.5v6' }), h('path', { d: 'M2 5.5h4' }), h('path', { d: 'M10 10.5h4' }), h('path', { d: 'M2.5 13.5h11' }))
951
+ const FileIcon = () => h('svg', iconProps, h('path', { d: 'M9 1.8H4.4a1 1 0 0 0-1 1v10.4a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V5.3z' }), h('path', { d: 'M9 1.8v3.5h3.6' }))
952
+
953
+ /**
954
+ * File families, most specific first.
955
+ *
956
+ * A lockfile is checked before "config" (`package-lock.json` is a JSON
957
+ * file), an image before "code" (`.svg` is text), and a shell script
958
+ * before "code" too. Order is the whole contract here.
959
+ */
960
+ const FILE_FAMILIES = [
961
+ ['lock', /(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|npm-shrinkwrap\.json|Cargo\.lock|go\.sum|poetry\.lock|Pipfile\.lock|composer\.lock|Gemfile\.lock|bun\.lockb?)$/iu],
962
+ ['image', /\.(?:png|jpe?g|gif|webp|svg|ico|bmp|avif|tiff?|heic|psd)$/iu],
963
+ ['media', /\.(?:mp4|mov|mkv|avi|webm|mp3|wav|flac|ogg|m4a|aac|opus)$/iu],
964
+ ['font', /\.(?:woff2?|ttf|otf|eot)$/iu],
965
+ ['archive', /\.(?:zip|tar|gz|tgz|bz2|xz|7z|rar|zst|jar|war|exe|dll|so|dylib|bin|wasm|node|class|o|a|dmg|pkg|deb|rpm)$/iu],
966
+ ['style', /\.(?:css|scss|sass|less|styl|stylus)$/iu],
967
+ ['shell', /\.(?:sh|bash|zsh|fish|ps1|bat|cmd)$/iu],
968
+ ['data', /\.(?:json5?|jsonc|ya?ml|toml|ini|cfg|conf|env|properties|csv|tsv|xml|plist|lock)$/iu],
969
+ ['doc', /\.(?:md|markdown|mdx|txt|rst|adoc|org|tex)$/iu],
970
+ ['code', /\.(?:js|mjs|cjs|jsx|ts|tsx|vue|svelte|astro|py|rb|php|go|rs|java|kt|kts|scala|swift|c|h|cc|cpp|cxx|hpp|m|mm|cs|lua|pl|r|dart|ex|exs|erl|hs|clj|zig|nim|sql|graphql|gql|html?|xhtml|tpl|ejs|hbs|pug|slim)$/iu],
971
+ ]
972
+
973
+ /**
974
+ * Which family a path belongs to.
975
+ * @param {string} path - repository-relative path.
976
+ * @returns {string} family id, `plain` when nothing matches.
977
+ */
978
+ function fileFamilyOf(path) {
979
+ const name = String(path ?? '')
980
+ for (const [id, pattern] of FILE_FAMILIES) {
981
+ if (pattern.test(name)) return id
982
+ }
983
+ return 'plain'
984
+ }
985
+
986
+ /** One coloured glyph per file family; the change chip sits on the right. */
987
+ function FileTypeIcon({ path }) {
988
+ const family = fileFamilyOf(path)
989
+ const Glyph = FILE_FAMILY_ICONS[family] ?? FileIcon
990
+ return h('span', { className: `gp-ftype ${family}` }, h(Glyph, null))
991
+ }
992
+
993
+ /** Glyph per family; `plain` is the generic page. */
994
+ const FILE_FAMILY_ICONS = {
995
+ image: () => h(ImageIcon, null),
996
+ media: () => h(MediaIcon, null),
997
+ font: () => h(FontIcon, null),
998
+ archive: () => h(BoxIcon, null),
999
+ style: () => h(HashIcon, null),
1000
+ shell: () => h(TerminalIcon, null),
1001
+ data: () => h(BracesIcon, null),
1002
+ doc: () => h(DocIcon, null),
1003
+ code: () => h(CodeIcon, null),
1004
+ lock: () => h(LockIcon, null),
1005
+ plain: () => h(FileIcon, null),
1006
+ }
1007
+
1008
+ /**
1009
+ * One change kind per status entry.
1010
+ *
1011
+ * Porcelain v2 says "untracked" with `??` and "conflict" with an `u`
1012
+ * record inside the two-letter code; both carry the letter `U`, so the
1013
+ * code decides between them instead of the letter.
1014
+ * @param {object} entry - status row.
1015
+ * @param {string} [group] - section id; the conflicts list forces the alert.
1016
+ * @returns {string} `M A U D R C T CONFLICT`.
1017
+ */
1018
+ function changeKindOf(entry, group) {
1019
+ if (group === 'conflicts') return 'CONFLICT'
1020
+ const code = String(entry?.code ?? '')
1021
+ if (code === '??') return 'U'
1022
+ const letter = String(entry?.letter ?? '').toUpperCase()
1023
+ if (letter === 'U') return 'CONFLICT'
1024
+ if (letter === 'A' || letter === 'M' || letter === 'D' || letter === 'R' || letter === 'C' || letter === 'T') return letter
1025
+ // Anything else (a mode change on a file already listed as M, or a row
1026
+ // built from a commit's name-status) reads as a modification.
1027
+ return 'M'
1028
+ }
1029
+
1030
+ /**
1031
+ * The coloured chip that says *how* a file changed.
1032
+ * @param {object} props - `entry` and the translator.
1033
+ * @returns {object} the chip.
1034
+ */
1035
+ function ChangeBadge({ entry, t, group }) {
1036
+ const kind = changeKindOf(entry, group)
1037
+ const key = kind === 'CONFLICT' ? 'change.conflict' : `change.${kind}`
1038
+ const renamedFrom = kind === 'R' && typeof entry?.originalPath === 'string' && entry.originalPath !== '' ? entry.originalPath : ''
1039
+ const label = renamedFrom === '' ? t(key) : `${t(key)} · ${renamedFrom}`
1040
+ const Glyph = CHANGE_ICONS[kind] ?? ChangeModifyIcon
1041
+ return h('span', { className: `gp-change ${kind}`, title: label, role: 'img', 'aria-label': label }, h(Glyph, null))
1042
+ }
1043
+
1044
+ /** Glyph per change kind. */
1045
+ const CHANGE_ICONS = {
1046
+ M: () => h(ChangeModifyIcon, null),
1047
+ A: () => h(ChangeAddIcon, null),
1048
+ U: () => h(ChangeUntrackedIcon, null),
1049
+ D: () => h(ChangeDeleteIcon, null),
1050
+ R: () => h(ChangeRenameIcon, null),
1051
+ C: () => h(ChangeCopyIcon, null),
1052
+ T: () => h(ChangeTypeIcon, null),
1053
+ CONFLICT: () => h(ChangeConflictIcon, null),
1054
+ }
1055
+
1056
+ const TrashIcon = () => h('svg', iconProps, h('path', { d: 'M2.8 4.4h10.4' }), h('path', { d: 'M6.4 4.4V3.2a1 1 0 0 1 1-1h1.2a1 1 0 0 1 1 1v1.2' }), h('path', { d: 'M4.2 4.4l.6 8.2a1 1 0 0 0 1 .9h4.4a1 1 0 0 0 1-.9l.6-8.2' }))
1057
+ const CloudIcon = () => h('svg', iconProps, h('path', { d: 'M4.4 11.6a2.6 2.6 0 0 1-.3-5.2 3.6 3.6 0 0 1 6.9-.7 2.6 2.6 0 0 1 1 5.9z' }))
1058
+ const CaretIcon = () => h('svg', { ...iconProps, width: 12, height: 12 }, h('path', { d: 'M4 6.5 8 10.5l4-4' }))
1059
+ const CopyIcon = () => h('svg', iconProps, h('rect', { x: 5.5, y: 5.5, width: 8, height: 8, rx: 1.4 }), h('path', { d: 'M10.5 3.6V2.9a1 1 0 0 0-1-1H2.9a1 1 0 0 0-1 1v6.6a1 1 0 0 0 1 1h.7' }))
1060
+ const CheckIcon = () => h('svg', iconProps, h('path', { d: 'M3 8.4 6.3 11.7 13 4.6' }))
1061
+ const CodeIcon = () => h('svg', glyphProps, h('path', { d: 'M5.8 3.6 2.2 8l3.6 4.4' }), h('path', { d: 'M10.2 3.6 13.8 8l-3.6 4.4' }))
1062
+ const ImageIcon = () =>
1063
+ h('svg', glyphProps, h('rect', { x: 2.4, y: 3.2, width: 11.2, height: 9.6, rx: 2 }), h('circle', { cx: 6, cy: 6.7, r: 1 }), h('path', { d: 'M3.4 11.6 6.9 8.6l2.1 1.8 2-1.7 1.6 1.4' }))
1064
+ const DocIcon = () =>
1065
+ h(
1066
+ 'svg',
1067
+ glyphProps,
1068
+ h('path', { d: 'M9 1.8H4.4a1 1 0 0 0-1 1v10.4a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V5.3z' }),
1069
+ h('path', { d: 'M9 1.8v3.5h3.6' }),
1070
+ h('path', { d: 'M5.4 9.2h5.2M5.4 11.5h3.4' }),
1071
+ )
1072
+ const BracesIcon = () =>
1073
+ h(
1074
+ 'svg',
1075
+ glyphProps,
1076
+ h('path', { d: 'M6.2 2.6c-1 0-1.6.5-1.6 1.5v1.5c0 .9-.5 1.4-1.4 1.4.9 0 1.4.5 1.4 1.4v1.5c0 1 .6 1.5 1.6 1.5' }),
1077
+ h('path', { d: 'M9.8 2.6c1 0 1.6.5 1.6 1.5v1.5c0 .9.5 1.4 1.4 1.4-.9 0-1.4.5-1.4 1.4v1.5c0 1-.6 1.5-1.6 1.5' }),
1078
+ )
1079
+ const HashIcon = () => h('svg', glyphProps, h('path', { d: 'M6.4 2.8 5.2 13.2M10.8 2.8 9.6 13.2' }), h('path', { d: 'M3 6.2h10.4M2.6 10h10.4' }))
1080
+ const MediaIcon = () =>
1081
+ h('svg', glyphProps, h('rect', { x: 2.2, y: 4, width: 11.6, height: 8, rx: 1.8 }), h('path', { d: 'M6.8 6.4l3 1.6-3 1.6z' }))
1082
+ const FontIcon = () => h('svg', glyphProps, h('path', { d: 'M4.3 12.6 8 3.4l3.7 9.2' }), h('path', { d: 'M5.6 9.6h4.8' }))
1083
+ const BoxIcon = () => h('svg', glyphProps, h('path', { d: 'M2.6 5.5 8 2.9l5.4 2.6v5L8 13.1l-5.4-2.6z' }), h('path', { d: 'M2.6 5.5 8 8.1l5.4-2.6M8 8.1v5' }))
1084
+ const LockIcon = () => h('svg', glyphProps, h('rect', { x: 3.6, y: 7, width: 8.8, height: 6.4, rx: 1.4 }), h('path', { d: 'M5.8 7V5.4a2.2 2.2 0 0 1 4.4 0V7' }))
1085
+ const TerminalIcon = () => h('svg', glyphProps, h('rect', { x: 2.2, y: 3.4, width: 11.6, height: 9.2, rx: 1.8 }), h('path', { d: 'M4.6 6.4 6.6 8.2l-2 1.8M8.4 10.2h3' }))
1086
+
1087
+ /* Change-kind glyphs: the shape says *what happened*, the chip colour says
1088
+ which side of the index it happened on. */
1089
+ const ChangeModifyIcon = () =>
1090
+ h('svg', glyphProps, h('path', { d: 'M3.4 12.6l.5-2.4 6.4-6.4 1.9 1.9-6.4 6.4-2.4.5z' }), h('path', { d: 'M9.2 4.9l1.9 1.9' }))
1091
+ const ChangeAddIcon = () =>
1092
+ h('svg', glyphProps, h('path', { d: 'M9.2 2.4H4.6a1 1 0 0 0-1 1v9.2a1 1 0 0 0 1 1h6.8a1 1 0 0 0 1-1V5.8z' }), h('path', { d: 'M9.2 2.4v3.4h3.4M8 7.6v4M6 9.6h4' }))
1093
+ const ChangeUntrackedIcon = () =>
1094
+ h('svg', glyphProps, h('path', { d: 'M9.2 2.4H4.6a1 1 0 0 0-1 1v9.2a1 1 0 0 0 1 1h6.8a1 1 0 0 0 1-1V5.8z' }), h('path', { d: 'M8 7.6v4M6 9.6h4' }))
1095
+ const ChangeDeleteIcon = () => h('svg', glyphProps, h('path', { d: 'M3.4 8h9.2' }))
1096
+ const ChangeRenameIcon = () =>
1097
+ h('svg', glyphProps, h('path', { d: 'M2.8 8h8.6' }), h('path', { d: 'M8.4 5.2 11.2 8l-2.8 2.8' }), h('path', { d: 'M13.2 3.6v8.8' }))
1098
+ const ChangeCopyIcon = () =>
1099
+ h('svg', glyphProps, h('rect', { x: 2.4, y: 6, width: 6.2, height: 6.2, rx: 1.2 }), h('path', { d: 'M5.6 6V4.4a1 1 0 0 1 1-1h5.2a1 1 0 0 1 1 1v5.2a1 1 0 0 1-1 1H10' }))
1100
+ const ChangeTypeIcon = () =>
1101
+ h('svg', glyphProps, h('path', { d: 'M3.4 5.6h7.2' }), h('path', { d: 'M8.4 3.4 10.6 5.6 8.4 7.8' }), h('path', { d: 'M12.6 10.4H5.4' }), h('path', { d: 'M7.6 8.2 5.4 10.4 7.6 12.6' }))
1102
+ const ChangeConflictIcon = () =>
1103
+ h('svg', glyphProps, h('path', { d: 'M8 2.6 13.6 12.6H2.4z' }), h('path', { d: 'M8 6.2v2.6' }), h('circle', { cx: 8, cy: 10.9, r: 0.6, fill: 'currentColor', stroke: 'none' }))
1104
+ const ChatIcon = () =>
1105
+ h('svg', iconProps, h('path', { d: 'M13.4 8.4c0 2.6-2.4 4.7-5.4 4.7-.7 0-1.4-.1-2-.3L3 13.8l1.2-2.3A4.4 4.4 0 0 1 2.6 8.4C2.6 5.8 5 3.7 8 3.7s5.4 2.1 5.4 4.7Z' }))
1106
+ const SparkleIcon = () =>
1107
+ h(
1108
+ 'svg',
1109
+ iconProps,
1110
+ h('path', { d: 'M6.6 2.2 7.9 5.5 11.2 6.8 7.9 8.1 6.6 11.4 5.3 8.1 2 6.8 5.3 5.5Z' }),
1111
+ h('path', { d: 'M12.2 9.6l.6 1.4 1.4.6-1.4.6-.6 1.4-.6-1.4-1.4-.6 1.4-.6Z' }),
1112
+ )
1113
+
1114
+ /** The guide-page glyph: the Sidebar's own artwork size contract. */
1115
+ function GitGuideIcon(props) {
1116
+ const size = props?.size ?? 20
1117
+ return h(
1118
+ 'svg',
1119
+ { viewBox: '0 0 24 24', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
1120
+ h('circle', { cx: 6.5, cy: 18, r: 2.4 }),
1121
+ h('circle', { cx: 6.5, cy: 6, r: 2.4 }),
1122
+ h('circle', { cx: 17.5, cy: 9, r: 2.4 }),
1123
+ h('path', { d: 'M6.5 8.4v7.2' }),
1124
+ h('path', { d: 'M17.5 11.4c0 2.6-2.2 3.3-5.4 3.8' }),
1125
+ )
1126
+ }
1127
+
1128
+ /* ------------------------------------------------------------------ */
1129
+ /* Hooks */
1130
+ /* ------------------------------------------------------------------ */
1131
+
1132
+ /**
1133
+ * Read the Session's working directory with the slot's session selector.
1134
+ * The selector hook is always called: the slot supplies it for every
1135
+ * session-scoped body, so the call order never changes between renders.
1136
+ * @param {Function} useSessions - session list selector hook from the slot.
1137
+ * @param {string} sessionId - Session on screen.
1138
+ * @returns {string | undefined} absolute cwd.
1139
+ */
1140
+ function useSessionCwd(useSessions, sessionId) {
1141
+ const select = useSessions((sessions) => {
1142
+ const entry = sessions?.byId?.[sessionId]
1143
+ return entry?.cwd
1144
+ })
1145
+ return select
1146
+ }
1147
+
1148
+ /**
1149
+ * Poll one Host action while the panel is visible.
1150
+ * @param {string | undefined} cwd - repository directory.
1151
+ * @param {string} action - route action.
1152
+ * @param {object} params - extra fields.
1153
+ * @param {object} options - `enabled`, `poll`, `version`.
1154
+ * @returns {{value: object|undefined, error: Error|undefined, loading: boolean, refresh: Function}} state.
1155
+ */
1156
+ function useRoute(cwd, action, params, options = {}) {
1157
+ const { enabled = true, poll = 0, version = 0 } = options
1158
+ const [value, setValue] = useState(undefined)
1159
+ const [error, setError] = useState(undefined)
1160
+ const [loading, setLoading] = useState(false)
1161
+ const signature = JSON.stringify(params ?? {})
1162
+ const signatureRef = useRef(signature)
1163
+ signatureRef.current = signature
1164
+
1165
+ const load = useCallback(
1166
+ async (signal, silent) => {
1167
+ if (cwd === undefined || cwd === '' || enabled !== true) return undefined
1168
+ if (silent !== true) setLoading(true)
1169
+ try {
1170
+ const next = await call(action, { cwd, ...(params ?? {}) }, signal)
1171
+ setValue(next)
1172
+ setError(undefined)
1173
+ return next
1174
+ } catch (failure) {
1175
+ if (signal?.aborted === true) return undefined
1176
+ if (silent !== true) setError(failure)
1177
+ return undefined
1178
+ } finally {
1179
+ if (signal?.aborted !== true && silent !== true) setLoading(false)
1180
+ }
1181
+ },
1182
+ // The signature is the stable identity of `params`.
1183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1184
+ [cwd, action, enabled, signature, version],
1185
+ )
1186
+
1187
+ useEffect(() => {
1188
+ const controller = new AbortController()
1189
+ load(controller.signal, false)
1190
+ return () => controller.abort()
1191
+ }, [load])
1192
+
1193
+ useEffect(() => {
1194
+ if (poll <= 0 || enabled !== true) return undefined
1195
+ const timer = setInterval(() => {
1196
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return
1197
+ const controller = new AbortController()
1198
+ load(controller.signal, true)
1199
+ }, poll)
1200
+ const onFocus = () => {
1201
+ const controller = new AbortController()
1202
+ load(controller.signal, true)
1203
+ }
1204
+ window.addEventListener('focus', onFocus)
1205
+ return () => {
1206
+ clearInterval(timer)
1207
+ window.removeEventListener('focus', onFocus)
1208
+ }
1209
+ }, [load, poll, enabled])
1210
+
1211
+ const refresh = useCallback(
1212
+ (silent) => {
1213
+ const controller = new AbortController()
1214
+ return load(controller.signal, silent === true)
1215
+ },
1216
+ [load],
1217
+ )
1218
+
1219
+ return { value, error, loading, refresh }
1220
+ }
1221
+
1222
+ /* ------------------------------------------------------------------ */
1223
+ /* Shared little components */
1224
+ /* ------------------------------------------------------------------ */
1225
+
1226
+ /** A ghost icon button with an accessible label. */
1227
+ function IconButton({ label, onClick, disabled, danger, children }) {
1228
+ return h(
1229
+ 'button',
1230
+ {
1231
+ type: 'button',
1232
+ className: `gp-btn${danger === true ? ' gp-btn-danger' : ''}`,
1233
+ title: label,
1234
+ 'aria-label': label,
1235
+ disabled: disabled === true,
1236
+ onClick: (event) => {
1237
+ event.stopPropagation()
1238
+ onClick?.(event)
1239
+ },
1240
+ },
1241
+ children,
1242
+ )
1243
+ }
1244
+
1245
+ /** One collapsible section header. */
1246
+ function SectionHeader({ title, count, expanded, onToggle, actions, t }) {
1247
+ return h(
1248
+ 'div',
1249
+ { className: 'gp-section-head', onClick: onToggle, role: 'button', tabIndex: 0, onKeyDown: (event) => (event.key === 'Enter' || event.key === ' ' ? onToggle?.() : undefined) },
1250
+ h(ChevronIcon, { className: `gp-chevron${expanded ? ' open' : ''}` }),
1251
+ h('span', { className: 'gp-section-title' }, title),
1252
+ count === undefined ? null : h('span', { className: 'gp-section-count' }, String(count)),
1253
+ h('div', { className: 'gp-section-actions', onClick: (event) => event.stopPropagation() }, actions),
1254
+ )
1255
+ }
1256
+
1257
+ /**
1258
+ * A menu anchored to its own trigger button.
1259
+ * @param {object} props - `label` (trigger content), `items`, `disabled`,
1260
+ * `triggerClass`, `title` and `t`.
1261
+ * @returns {object} the trigger and, while open, its menu.
1262
+ */
1263
+ /** One group of rows (staged / unstaged / untracked / conflicts). */
1264
+ function ChangeRow({ entry, group, busy, t, onOpenDiff, onOpenFile, onStage, onUnstage, onDiscard, onQuote }) {
1265
+ const { dir, name } = splitPath(entry.path)
1266
+ const select = () => onOpenDiff(entry, group)
1267
+ const showCounts = entry.additions > 0 || entry.deletions > 0
1268
+ const conflicted = group === 'conflicts'
1269
+ return h(
1270
+ 'div',
1271
+ {
1272
+ className: `gp-row${conflicted ? ' gp-row-conflict' : ''}`,
1273
+ title: conflicted ? `${entry.path} · ${t('conflict.badge')}` : entry.path,
1274
+ role: 'button',
1275
+ tabIndex: 0,
1276
+ onClick: select,
1277
+ onKeyDown: (event) => (event.key === 'Enter' ? select() : undefined),
1278
+ },
1279
+ h(FileTypeIcon, { path: entry.path }),
1280
+ h('span', { className: 'gp-name' }, dir === '' ? null : h('span', { className: 'gp-dir' }, dir), name),
1281
+ showCounts
1282
+ ? h('span', { className: 'gp-counts' }, h('span', { className: 'gp-add' }, `+${entry.additions}`), h('span', { className: 'gp-del' }, `-${entry.deletions}`))
1283
+ : null,
1284
+ h(ChangeBadge, { entry, t, group }),
1285
+ h(
1286
+ 'span',
1287
+ { className: 'gp-row-actions' },
1288
+ conflicted
1289
+ ? h(IconButton, { label: t('conflict.quote'), disabled: busy, onClick: () => onQuote(entry) }, h(ChatIcon, null))
1290
+ : group === 'staged'
1291
+ ? h(IconButton, { label: t('row.unstage'), disabled: busy, onClick: () => onUnstage(entry) }, h(MinusIcon, null))
1292
+ : h(IconButton, { label: t('row.stage'), disabled: busy, onClick: () => onStage(entry) }, h(PlusIcon, null)),
1293
+ group === 'staged' || conflicted
1294
+ ? null
1295
+ : h(IconButton, { label: t('row.discard'), disabled: busy, danger: true, onClick: () => onDiscard(entry, group) }, h(UndoIcon, null)),
1296
+ h(IconButton, { label: t('row.openDiff'), onClick: () => onOpenDiff(entry, group) }, h(DiffIcon, null)),
1297
+ h(IconButton, { label: t('row.openFile'), onClick: () => onOpenFile(entry) }, h(FileIcon, null)),
1298
+ ),
1299
+ )
1300
+ }
1301
+
1302
+ /* ------------------------------------------------------------------ */
1303
+ /* Changes */
1304
+ /* ------------------------------------------------------------------ */
1305
+
1306
+ /** The commit box: message, primary action and its smaller options. */
1307
+ /**
1308
+ * The commit box: message field, the full-width Commit button and its menu.
1309
+ *
1310
+ * The placeholder names the branch the commit will land on, and the button
1311
+ * keeps its disabled look while there is nothing to commit.
1312
+ * @param {object} props - slot props.
1313
+ * @returns {object} the box.
1314
+ */
1315
+ function CommitBox({ t, status, busy, onCommit }) {
1316
+ const [message, setMessage] = useState('')
1317
+ const counts = status?.counts ?? {}
1318
+ const staged = counts.staged ?? 0
1319
+ const changed = staged + (counts.changes ?? 0) + (counts.untracked ?? 0) + (counts.conflicts ?? 0)
1320
+ const hasStaged = staged > 0
1321
+ /** No change in the work tree: the primary action stays disabled. */
1322
+ const idle = changed === 0
1323
+ const conflicts = (status?.conflicts ?? []).length
1324
+ const merging = status?.merging === true
1325
+ /** Unresolved conflicts block the commit, exactly like a merge editor. */
1326
+ const blocked = conflicts > 0
1327
+ const branch = status?.detached === true ? String(status?.oid ?? '').slice(0, 7) || 'HEAD' : status?.branch || 'HEAD'
1328
+ const submit = (overrides) => {
1329
+ const text = message
1330
+ if (text.trim() === '' || idle || blocked) return
1331
+ onCommit({ message: text, stageAll: hasStaged !== true, ...(overrides ?? {}) })
1332
+ .then((done) => {
1333
+ if (done === true) setMessage('')
1334
+ })
1335
+ .catch(() => {})
1336
+ }
1337
+
1338
+ /** Fill the field with a draft derived from the changed paths. */
1339
+ const draft = () => {
1340
+ const paths = [...(status?.staged ?? []), ...(status?.changes ?? []), ...(status?.untracked ?? [])].map((entry) => entry.path)
1341
+ if (paths.length === 0) return
1342
+ const name = String(paths[0]).split('/').pop() ?? paths[0]
1343
+ setMessage(paths.length === 1 ? fill(t('commit.draft.one'), { name }) : fill(t('commit.draft.many'), { name, count: paths.length - 1 }))
1344
+ }
1345
+
1346
+ const placeholder = fill(t('commit.placeholder'), { mod: MOD_LABEL, branch })
1347
+
1348
+ return h(
1349
+ 'div',
1350
+ { className: 'gp-compose' },
1351
+ h(
1352
+ 'div',
1353
+ { className: 'gp-input-wrap' },
1354
+ h('textarea', {
1355
+ className: 'gp-input',
1356
+ value: message,
1357
+ placeholder: placeholder,
1358
+ 'aria-label': placeholder,
1359
+ rows: 3,
1360
+ onChange: (event) => setMessage(event.target.value),
1361
+ onKeyDown: (event) => {
1362
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
1363
+ event.preventDefault()
1364
+ submit()
1365
+ }
1366
+ },
1367
+ }),
1368
+ h('button', { type: 'button', className: 'gp-input-ai', title: t('commit.draft'), disabled: busy || idle, onClick: draft }, h(SparkleIcon, null)),
1369
+ ),
1370
+ h(
1371
+ 'div',
1372
+ { className: 'gp-actions' },
1373
+ h(
1374
+ 'button',
1375
+ { type: 'button', className: 'gp-primary', title: t('commit.sync'), disabled: busy || idle || blocked, onClick: () => submit() },
1376
+ busy ? h('span', { className: 'gp-spinner' }) : h(CheckIcon, null),
1377
+ h('span', { className: 'gp-primary-label' }, merging ? t('commit.continue') : t('commit.button')),
1378
+ ),
1379
+ ),
1380
+ )
1381
+ }
1382
+
1383
+ /* ------------------------------------------------------------------ */
1384
+ /* Commit detail */
1385
+ /* ------------------------------------------------------------------ */
1386
+
1387
+ /** The panel under a graph row: full message, author, date and files. */
1388
+ function CommitDetail({ t, detail, loading, onOpenDiff, onCopy, onCheckout, copied }) {
1389
+ if (loading === true && detail === undefined) return h('div', { className: 'gp-detail gp-note' }, t('graph.loading'))
1390
+ const commit = detail?.commit
1391
+ if (commit === undefined) return null
1392
+ const files = Array.isArray(commit.files) ? commit.files : []
1393
+ return h(
1394
+ 'div',
1395
+ { className: 'gp-detail' },
1396
+ h('div', { className: 'gp-kv' }, h('b', null, t('graph.author')), h('span', null, `${commit.author} <${commit.email}>`)),
1397
+ h('div', { className: 'gp-kv' }, h('b', null, t('graph.date')), h('span', null, absoluteTime(commit.at))),
1398
+ h('div', { className: 'gp-kv' }, h('b', null, t('graph.hash')), h('span', { className: 'gp-hash' }, commit.short)),
1399
+ commit.body === '' ? null : h('div', { className: 'gp-detail-body' }, commit.body),
1400
+ h(
1401
+ 'div',
1402
+ { className: 'gp-detail-actions' },
1403
+ h(IconButton, { label: t('graph.openDiff'), onClick: () => onOpenDiff(commit) }, h(DiffIcon, null)),
1404
+ h(IconButton, { label: copied === true ? t('graph.copied') : t('graph.copy'), onClick: () => onCopy(commit) }, h(CopyIcon, null)),
1405
+ onCheckout === undefined
1406
+ ? null
1407
+ : h(IconButton, { label: t('graph.checkout'), onClick: () => onCheckout() }, h(BranchIcon, { size: 12 })),
1408
+ ),
1409
+ files.length === 0
1410
+ ? null
1411
+ : h(
1412
+ 'div',
1413
+ { className: 'gp-detail-files' },
1414
+ files.map((file) =>
1415
+ h(
1416
+ 'div',
1417
+ {
1418
+ className: 'gp-detail-file',
1419
+ key: file.path,
1420
+ title: file.path,
1421
+ },
1422
+ h(FileTypeIcon, { path: file.path }),
1423
+ h(ChangeBadge, { entry: { letter: file.status ?? 'M' }, t }),
1424
+ h('span', { className: 'gp-name' }, file.path),
1425
+ h('span', { className: 'gp-add' }, `+${file.additions}`),
1426
+ h('span', { className: 'gp-del' }, `-${file.deletions}`),
1427
+ ),
1428
+ ),
1429
+ ),
1430
+ )
1431
+ }
1432
+
1433
+ /* ------------------------------------------------------------------ */
1434
+ /* Graph section */
1435
+ /* ------------------------------------------------------------------ */
1436
+
1437
+ /**
1438
+ * Geometry of one graph row, as plain data.
1439
+ *
1440
+ * Everything the row's SVG needs, in the coordinate space the canvas uses:
1441
+ * one path per edge (straight when the lane holds, an S-curve with vertical
1442
+ * tangents when it moves — the shape the Cursor graph uses) and the commit's
1443
+ * own node. Kept separate from rendering so it can be tested and measured.
1444
+ * @param {object} row - one laid-out row from `layoutGraph`.
1445
+ * @param {number} laneCount - widest lane count of the whole graph.
1446
+ * @returns {{width: number, height: number, edges: object[], node: object}} geometry.
1447
+ */
1448
+ function graphShapes(row, laneCount, maxWidth = GRAPH_MAX_WIDTH) {
1449
+ const lanes = Math.max(laneCount, 1)
1450
+ // Compress the pitch down to a legibility floor, then let the pane scroll.
1451
+ const laneWidth = lanes <= 1
1452
+ ? LANE_WIDTH
1453
+ : Math.max(LANE_MIN_WIDTH, Math.min(LANE_WIDTH, (maxWidth - GRAPH_PAD * 2) / (lanes - 1)))
1454
+ const width = GRAPH_PAD * 2 + (lanes - 1) * laneWidth
1455
+ const x = (lane) => GRAPH_PAD + lane * laneWidth
1456
+ const center = ROW_HEIGHT / 2
1457
+ const edges = []
1458
+ for (const edge of row.edges) {
1459
+ // The commit's own edge leaves below its node; a lane that merely
1460
+ // passes through enters at the top of the row.
1461
+ const y0 = edge.from === row.lane ? center : 0
1462
+ const y1 = ROW_HEIGHT
1463
+ const x0 = x(edge.from)
1464
+ const x1 = x(edge.to)
1465
+ const bend = (y1 - y0) * CURVE_TENSION
1466
+ const d =
1467
+ x0 === x1
1468
+ ? `M${x0} ${y0}L${x0} ${y1}`
1469
+ : `M${x0} ${y0}C${x0} ${y0 + bend} ${x1} ${y1 - bend} ${x1} ${y1}`
1470
+ edges.push({ d, color: edge.color, width: 2, opacity: 1 })
1471
+ }
1472
+ if (row.arrived === true) {
1473
+ // The half-row above the node: without it a commit whose lane it owns
1474
+ // shows a 19px hole between rows (linear history was all holes).
1475
+ const cx = x(row.lane)
1476
+ edges.push({
1477
+ d: `M${cx} 0L${cx} ${center}`,
1478
+ color: row.color ?? laneColor(row.lane),
1479
+ width: 2,
1480
+ opacity: 1,
1481
+ })
1482
+ }
1483
+ const merge = row.commit.parents.length > 1
1484
+ // Compressed lanes must not keep full-size dots: at a 4px pitch a 4.8px
1485
+ // radius overlaps the neighbouring lane, so the dot scales with the pitch.
1486
+ const dot = Math.min(NODE_RADIUS, laneWidth * 0.4)
1487
+ return {
1488
+ width,
1489
+ height: ROW_HEIGHT,
1490
+ edges,
1491
+ node: {
1492
+ cx: x(row.lane),
1493
+ cy: center,
1494
+ radius: merge ? Math.min(dot + 1.2, laneWidth * 0.5) : dot,
1495
+ color: row.color ?? laneColor(row.lane),
1496
+ merge,
1497
+ current: row.current === true,
1498
+ },
1499
+ }
1500
+ }
1501
+
1502
+ /**
1503
+ * Fold one row's edges into drawable groups.
1504
+ *
1505
+ * A straight lane is one subpath either way, so grouping by
1506
+ * (colour, width, opacity) turns N pass-through lanes into at most one
1507
+ * `<path>` per palette colour. Movement within a group is identical.
1508
+ * @param {object[]} edges - `graphShapes` edges.
1509
+ * @returns {object[]} groups with a joined `d`.
1510
+ */
1511
+ function groupEdges(edges) {
1512
+ const groups = new Map()
1513
+ for (const edge of edges) {
1514
+ const key = `${edge.color}|${edge.width}|${edge.opacity}`
1515
+ const group = groups.get(key)
1516
+ if (group === undefined) groups.set(key, { color: edge.color, width: edge.width, opacity: edge.opacity, d: [edge.d] })
1517
+ else group.d.push(edge.d)
1518
+ }
1519
+ return [...groups.values()].map((group) => ({ ...group, d: group.d.join(' ') }))
1520
+ }
1521
+
1522
+ /** One row's lane artwork. */
1523
+ function GraphCanvas({ row, laneCount, maxWidth }) {
1524
+ const shapes = graphShapes(row, laneCount, maxWidth)
1525
+ const { node } = shapes
1526
+ // Lines are drawn once, at full opacity: an interrupter in the background
1527
+ // colour (Git Graph draws one) reads as a broken line at our row height.
1528
+ const groups = groupEdges(shapes.edges)
1529
+ const children = []
1530
+ for (let index = 0; index < groups.length; index += 1) {
1531
+ const group = groups[index]
1532
+ children.push(h('path', {
1533
+ key: `e${index}`,
1534
+ d: group.d,
1535
+ stroke: group.color,
1536
+ strokeWidth: group.width,
1537
+ strokeLinecap: 'round',
1538
+ fill: 'none',
1539
+ opacity: group.opacity,
1540
+ }))
1541
+ }
1542
+ // The checked-out commit is a hollow ring, matching Git Graph's `.current`.
1543
+ children.push(
1544
+ node.current === true
1545
+ ? h('circle', {
1546
+ key: 'node',
1547
+ cx: node.cx,
1548
+ cy: node.cy,
1549
+ r: node.radius,
1550
+ fill: 'var(--dsw-alias-bg-base,#1b1b1d)',
1551
+ stroke: node.color,
1552
+ strokeWidth: 2,
1553
+ })
1554
+ : h('circle', {
1555
+ key: 'node',
1556
+ cx: node.cx,
1557
+ cy: node.cy,
1558
+ r: node.radius,
1559
+ fill: node.color,
1560
+ stroke: 'var(--dsw-alias-bg-base,#1b1b1d)',
1561
+ strokeWidth: 1.2,
1562
+ strokeOpacity: 0.75,
1563
+ }),
1564
+ )
1565
+ if (node.merge === true) children.push(h('circle', { key: 'node-merge', cx: node.cx, cy: node.cy, r: 1.9, fill: 'var(--dsw-alias-bg-base,#1b1b1d)' }))
1566
+ return h(
1567
+ 'svg',
1568
+ {
1569
+ className: 'gp-graph-canvas',
1570
+ width: shapes.width,
1571
+ height: shapes.height,
1572
+ viewBox: `0 0 ${shapes.width} ${shapes.height}`,
1573
+ 'aria-hidden': 'true',
1574
+ },
1575
+ children,
1576
+ )
1577
+ }
1578
+
1579
+ /** The refs of one commit, as the small badges the graph shows. */
1580
+ function Refs({ refs, t }) {
1581
+ if (!Array.isArray(refs) || refs.length === 0) return null
1582
+ return refs.map((ref) =>
1583
+ h('span', { key: `${ref.kind}:${ref.name}`, className: `gp-ref ${ref.kind}`, title: ref.kind === 'tag' ? `${t('graph.tag')}: ${ref.name}` : ref.name }, ref.name),
1584
+ )
1585
+ }
1586
+
1587
+ /* ------------------------------------------------------------------ */
1588
+ /* The panel */
1589
+ /* ------------------------------------------------------------------ */
1590
+
1591
+ /**
1592
+ * The Git section body.
1593
+ *
1594
+ * The guard keeps hook order honest: this component holds no hooks, so when
1595
+ * a seat arrives without the session props the fallback costs no hook, and
1596
+ * the real body only ever mounts with every hook it needs.
1597
+ * @param {object} props - slot props: `sessionId`, `useSessions`, `useTabInfo`, `t`.
1598
+ * @returns {object} the panel.
1599
+ */
1600
+ function GitPanelBody(props) {
1601
+ const t = props.t ?? ((key) => key)
1602
+ if (typeof props.useSessions !== 'function' || typeof props.useTabInfo !== 'function') {
1603
+ return h('div', { className: 'gp-root' }, h('div', { className: 'gp-note' }, t('empty.noSession')))
1604
+ }
1605
+ return h(GitPanelView, props)
1606
+ }
1607
+
1608
+ /**
1609
+ * The panel proper: every hook runs on every render of this component.
1610
+ * @param {object} props - slot props.
1611
+ * @returns {object} the panel content.
1612
+ */
1613
+ function GitPanelView(props) {
1614
+ const t = props.t ?? ((key) => key)
1615
+ const sessionId = props.sessionId
1616
+ const useSessions = props.useSessions
1617
+ const tabInfo = props.useTabInfo()
1618
+ const tabActions = tabInfo?.tab?.actions
1619
+
1620
+ const sessionCwd = useSessionCwd(useSessions, sessionId)
1621
+ const sessionRepoCwd = typeof sessionCwd === 'string' && sessionCwd !== '' ? sessionCwd : undefined
1622
+ /** TEMP: 会话目录不是仓库时改用回退仓库,见 FALLBACK_REPO_CWD。 */
1623
+ const [usingFallback, setUsingFallback] = useState(false)
1624
+ const cwd = usingFallback && FALLBACK_REPO_CWD !== '' ? FALLBACK_REPO_CWD : sessionRepoCwd
1625
+
1626
+ const [statusVersion, setStatusVersion] = useState(0)
1627
+ const [graphVersion, setGraphVersion] = useState(0)
1628
+ const [busy, setBusy] = useState(false)
1629
+ const [error, setError] = useState(undefined)
1630
+ const [notice, setNotice] = useState(undefined)
1631
+ const [confirming, setConfirming] = useState(undefined)
1632
+ const [expanded, setExpanded] = useState({ changes: true, graph: true })
1633
+ const [graphAll, setGraphAll] = useState(false)
1634
+ /** Height of the graph pane, dragged by the separator below the changes. */
1635
+ const [graphHeight, setGraphHeight] = useState(() => {
1636
+ try {
1637
+ const raw = window.localStorage?.getItem('dsh-git-panel:graphHeight')
1638
+ const parsed = Number.parseInt(raw ?? '', 10)
1639
+ if (Number.isFinite(parsed) && parsed >= 140 && parsed <= 900) return parsed
1640
+ } catch {}
1641
+ return 320
1642
+ })
1643
+ const graphHeightRef = useRef(graphHeight)
1644
+ graphHeightRef.current = graphHeight
1645
+ const startGraphResize = useCallback((event) => {
1646
+ event.preventDefault()
1647
+ const startY = event.clientY
1648
+ const startHeight = graphHeightRef.current
1649
+ const move = (moveEvent) => {
1650
+ // Dragging all the way down must collapse, not stop at a useless 140px.
1651
+ const next = Math.min(900, Math.max(0, startHeight + (startY - moveEvent.clientY)))
1652
+ graphHeightRef.current = next
1653
+ setGraphHeight(next)
1654
+ }
1655
+ const up = () => {
1656
+ window.removeEventListener('pointermove', move)
1657
+ window.removeEventListener('pointerup', up)
1658
+ window.removeEventListener('pointercancel', up)
1659
+ const settled = graphHeightRef.current
1660
+ if (settled < 120) {
1661
+ // Snapped shut: the pane keeps the last usable height for re-opening.
1662
+ setExpanded((current) => ({ ...current, graph: false }))
1663
+ graphHeightRef.current = 320
1664
+ setGraphHeight(320)
1665
+ } else if (settled < 140) {
1666
+ graphHeightRef.current = 140
1667
+ setGraphHeight(140)
1668
+ }
1669
+ try {
1670
+ window.localStorage?.setItem('dsh-git-panel:graphHeight', String(graphHeightRef.current))
1671
+ } catch {}
1672
+ }
1673
+ window.addEventListener('pointermove', move)
1674
+ window.addEventListener('pointerup', up)
1675
+ window.addEventListener('pointercancel', up)
1676
+ }, [])
1677
+ const [selected, setSelected] = useState(undefined)
1678
+ const [detail, setDetail] = useState(undefined)
1679
+ const [detailLoading, setDetailLoading] = useState(false)
1680
+ const [copied, setCopied] = useState(false)
1681
+
1682
+ const status = useRoute(cwd, 'status', {}, { enabled: true, poll: POLL_MS, version: statusVersion })
1683
+ const value = status.value
1684
+
1685
+ // TEMP: 会话目录不是 Git 仓库(或还没有会话目录)时,退回到回退仓库。
1686
+ useEffect(() => {
1687
+ if (usingFallback || FALLBACK_REPO_CWD === '') return
1688
+ if (sessionRepoCwd === FALLBACK_REPO_CWD) return
1689
+ if (value !== undefined && value.repo !== true) setUsingFallback(true)
1690
+ }, [usingFallback, sessionRepoCwd, value])
1691
+ const repo = value?.repo === true ? value : undefined
1692
+
1693
+ const graph = useRoute(
1694
+ cwd,
1695
+ 'graph',
1696
+ { all: graphAll, limit: 60 },
1697
+ { enabled: expanded.graph && repo !== undefined, version: graphVersion },
1698
+ )
1699
+ const graphRows = useMemo(() => layoutGraph(Array.isArray(graph.value?.commits) ? graph.value.commits : []), [graph.value])
1700
+
1701
+ /** Run one Host action with the shared busy/error handling. */
1702
+ const run = useCallback(
1703
+ async (action, payload, options = {}) => {
1704
+ if (cwd === undefined) return { ok: false, value: undefined }
1705
+ setBusy(true)
1706
+ setError(undefined)
1707
+ try {
1708
+ const result = await call(action, { cwd, ...(payload ?? {}) })
1709
+ if (typeof options.notice === 'string' && options.notice !== '') setNotice(options.notice)
1710
+ setStatusVersion((value2) => value2 + 1)
1711
+ if (options.graph !== false) setGraphVersion((value2) => value2 + 1)
1712
+ options.onDone?.(result)
1713
+ return { ok: true, value: result }
1714
+ } catch (failure) {
1715
+ setError(failure)
1716
+ return { ok: false, value: undefined }
1717
+ } finally {
1718
+ setBusy(false)
1719
+ if (options.clearConfirm !== false) setConfirming(undefined)
1720
+ }
1721
+ },
1722
+ [cwd],
1723
+ )
1724
+
1725
+ useEffect(() => {
1726
+ if (notice === undefined) return undefined
1727
+ const timer = setTimeout(() => setNotice(undefined), 3200)
1728
+ return () => clearTimeout(timer)
1729
+ }, [notice])
1730
+
1731
+ /**
1732
+ * Open one file's diff in a new tab.
1733
+ *
1734
+ * The worktree group holds both modified and untracked files, so the
1735
+ * row's own porcelain code decides whether git can diff it: an
1736
+ * untracked path has no index entry, and `git diff` on it is empty —
1737
+ * the Host renders those as an all-additions patch instead.
1738
+ */
1739
+ const openDiff = useCallback(
1740
+ (entry, group) => {
1741
+ if (tabActions === undefined) return
1742
+ tabActions.openTab(DIFF_KIND, { params: diffRequestFor(entry, group, repo?.cwd) })
1743
+ },
1744
+ [tabActions, repo],
1745
+ )
1746
+
1747
+ /** Open one changed file itself — the same previewer a file link uses. */
1748
+ const openFile = useCallback(
1749
+ (entry) => {
1750
+ if (tabActions === undefined || repo === undefined) return
1751
+ try {
1752
+ tabActions.openResource(fileAddress(sessionId, repo.root, entry.path))
1753
+ } catch (failure) {
1754
+ setError(failure)
1755
+ }
1756
+ },
1757
+ [tabActions, repo, sessionId],
1758
+ )
1759
+
1760
+ /** Open every working-tree change as one preview page. */
1761
+ const openAllDiff = useCallback(() => {
1762
+ if (tabActions === undefined) return
1763
+ tabActions.openTab(DIFF_KIND, { params: { all: true, cwd: repo?.cwd } })
1764
+ }, [tabActions, repo])
1765
+
1766
+ /** Header actions per section: staging lives with the list it acts on. */
1767
+ function sectionHeaderActions(id) {
1768
+ if (repo === undefined) return []
1769
+ if (id === 'staged') {
1770
+ return [
1771
+ h(IconButton, { key: 'unstage', label: t('changes.unstageAll'), disabled: busy, onClick: () => run('unstage', { paths: (repo.staged ?? []).map((entry) => entry.path) }) }, h(MinusIcon, null)),
1772
+ ]
1773
+ }
1774
+ if (id === 'worktree') {
1775
+ return [
1776
+ h(IconButton, { key: 'stage', label: t('changes.stageAll'), disabled: busy || stageAllPaths(repo).length === 0, onClick: () => run('stage', { paths: stageAllPaths(repo) }) }, h(PlusIcon, null)),
1777
+ h(IconButton, { key: 'discard', label: t('changes.discardAll'), danger: true, disabled: busy, onClick: () => setConfirming({ kind: 'discardAll' }) }, h(UndoIcon, null)),
1778
+ h(IconButton, { key: 'preview', label: t('changes.previewAll'), onClick: openAllDiff }, h(DiffIcon, null)),
1779
+ ]
1780
+ }
1781
+ return []
1782
+ }
1783
+
1784
+ /** Send one conflicted file to the conversation input. */
1785
+ const quoteConflict = useCallback(
1786
+ async (entry) => {
1787
+ if (cwd === undefined) return
1788
+ const header = fill(t('conflict.quoteHeader'), { path: entry.path, branch: repo?.branch ?? 'HEAD' })
1789
+ let snippet = ''
1790
+ try {
1791
+ const patch = await call('diff', { cwd, path: entry.path }, undefined)
1792
+ snippet = conflictSnippet(patch?.patch)
1793
+ } catch (failure) {
1794
+ setError(failure)
1795
+ return
1796
+ }
1797
+ const text = `${header}\n\n\`\`\`diff\n${snippet}\n\`\`\`\n`
1798
+ setNotice(insertIntoComposer(sessionId, text) ? t('conflict.quoted') : t('conflict.copied'))
1799
+ },
1800
+ [cwd, repo, sessionId, t],
1801
+ )
1802
+
1803
+ /** Send the whole working tree to the conversation for review. */
1804
+ const findIssues = useCallback(() => {
1805
+ const text = buildReviewPrompt({
1806
+ cwd,
1807
+ branch: repo?.branch ?? 'HEAD',
1808
+ staged: repo?.staged ?? [],
1809
+ changes: repo?.changes ?? [],
1810
+ untracked: repo?.untracked ?? [],
1811
+ })
1812
+ if (text === '') {
1813
+ setNotice(t('review.empty'))
1814
+ return
1815
+ }
1816
+ setNotice(insertIntoComposer(sessionId, text) ? t('review.quoted') : t('review.copied'))
1817
+ }, [cwd, repo, sessionId, t])
1818
+
1819
+ /** Select one commit and read its description. */
1820
+ const selectCommit = useCallback(
1821
+ (commit) => {
1822
+ if (selected === commit.hash) {
1823
+ setSelected(undefined)
1824
+ setDetail(undefined)
1825
+ return
1826
+ }
1827
+ setSelected(commit.hash)
1828
+ setDetail(undefined)
1829
+ setDetailLoading(true)
1830
+ call('commitDetail', { cwd: cwd, hash: commit.hash })
1831
+ .then((next) => setDetail(next))
1832
+ .catch((failure) => setError(failure))
1833
+ .finally(() => setDetailLoading(false))
1834
+ },
1835
+ [cwd, selected],
1836
+ )
1837
+
1838
+ const onCommit = useCallback(
1839
+ async (payload) => {
1840
+ const outcome = await run('commit', payload, { clearConfirm: false })
1841
+ if (outcome.ok !== true) return false
1842
+ // 提交即同步:先 commit,再 push(失败会走到下面的 error)。
1843
+ const pushed = await run('network', { mode: 'push' }, { graph: false, clearConfirm: false })
1844
+ const hash = outcome.value?.hash
1845
+ setNotice(
1846
+ fill(pushed.ok === true ? t('commit.donePushed') : t('commit.done'), {
1847
+ hash: typeof hash === 'string' ? hash : '',
1848
+ }),
1849
+ )
1850
+ status.refresh?.(true)
1851
+ return true
1852
+ },
1853
+ [run, status, t],
1854
+ )
1855
+
1856
+ const sectionToggle = (name) => () => setExpanded((current) => ({ ...current, [name]: current[name] !== true }))
1857
+
1858
+ if (cwd === undefined) {
1859
+ return h('div', { className: 'gp-root' }, h('div', { className: 'gp-note' }, t('empty.noRepo')))
1860
+ }
1861
+
1862
+ if (value !== undefined && value.repo !== true) {
1863
+ return h('div', { className: 'gp-root' }, h('div', { className: 'gp-note' }, t('empty.noRepo')))
1864
+ }
1865
+
1866
+ /** Cursor/VS Code split: staged on its own, everything else in one list. */
1867
+ const worktreeEntries = [...(repo?.changes ?? []), ...(repo?.untracked ?? [])]
1868
+ const sections = [
1869
+ { id: 'conflicts', title: t('changes.conflicts'), entries: repo?.conflicts ?? [] },
1870
+ { id: 'staged', title: t('changes.staged'), entries: repo?.staged ?? [] },
1871
+ { id: 'worktree', title: t('changes.worktree'), entries: worktreeEntries },
1872
+ ].filter((section) => section.id === 'worktree' || section.entries.length > 0)
1873
+
1874
+ return h(
1875
+ 'div',
1876
+ { className: 'gp-root' },
1877
+ h(
1878
+ 'div',
1879
+ { className: 'gp-toolbar' },
1880
+ repo === undefined
1881
+ ? h('span', { className: 'gp-branch' }, h('span', { className: 'gp-spinner' }), h('span', null, t('empty.loading')))
1882
+ : h(
1883
+ 'div',
1884
+ { className: 'gp-menu-wrap' },
1885
+ h(BranchMenu, { t, repo, busy, onCheckout: (name) => run('checkout', { name }) }),
1886
+ ),
1887
+ repo !== undefined && repo.detached === true ? h('span', { className: 'gp-tag' }, t('branch.detached')) : null,
1888
+ repo !== undefined && repo.ahead > 0 ? h('span', { className: 'gp-tag' }, fill(t('branch.ahead'), { count: repo.ahead })) : null,
1889
+ repo !== undefined && repo.behind > 0 ? h('span', { className: 'gp-tag' }, fill(t('branch.behind'), { count: repo.behind })) : null,
1890
+ h('span', { className: 'gp-spacer' }),
1891
+ repo === undefined
1892
+ ? null
1893
+ : h(IconButton, { label: t('status.fetch'), disabled: busy, onClick: () => run('network', { mode: 'fetch' }, { graph: false }) }, h(CloudIcon, null)),
1894
+ repo === undefined ? null : h(IconButton, { label: t('status.sync'), disabled: busy, onClick: () => run('network', { mode: 'sync' }, { graph: false }) }, h(RefreshIcon, null)),
1895
+ h(IconButton, { label: t('status.refresh'), disabled: busy, onClick: () => status.refresh?.(true) }, h(RefreshIcon, null)),
1896
+ ),
1897
+ error === undefined
1898
+ ? null
1899
+ : h(
1900
+ 'div',
1901
+ { className: 'gp-error' },
1902
+ h('span', null, `${t('error.title')}: ${error.message}`),
1903
+ h(IconButton, { label: t('error.dismiss'), onClick: () => setError(undefined) }, h('span', { 'aria-hidden': 'true' }, '✕')),
1904
+ ),
1905
+ notice === undefined ? null : h('div', { className: 'gp-note' }, notice),
1906
+ h(CommitBox, {
1907
+ t,
1908
+ status: repo,
1909
+ busy,
1910
+ onCommit,
1911
+ }),
1912
+ h(
1913
+ 'div',
1914
+ { className: 'gp-changes' },
1915
+ ...sections.map((section) =>
1916
+ h(
1917
+ 'div',
1918
+ { className: 'gp-section', key: section.id },
1919
+ h(SectionHeader, {
1920
+ t,
1921
+ title: section.title,
1922
+ count: section.entries.length,
1923
+ expanded: expanded[section.id] !== false,
1924
+ onToggle: sectionToggle(section.id),
1925
+ actions: sectionHeaderActions(section.id),
1926
+ }),
1927
+ expanded[section.id] === false
1928
+ ? null
1929
+ : section.entries.length === 0
1930
+ ? section.id === 'worktree'
1931
+ ? h('div', { className: 'gp-note' }, t('empty.clean'))
1932
+ : null
1933
+ : section.entries.map((entry) =>
1934
+ h(ChangeRow, {
1935
+ key: `${section.id}:${entry.path}`,
1936
+ entry,
1937
+ group: section.id,
1938
+ busy,
1939
+ t,
1940
+ onOpenDiff: openDiff,
1941
+ onOpenFile: openFile,
1942
+ onStage: (target) => run('stage', { paths: [target.path] }),
1943
+ onUnstage: (target) => run('unstage', { paths: [target.path] }),
1944
+ onDiscard: (target, kind) =>
1945
+ setConfirming({ kind: 'discardOne', path: target.path, group: kind, request: discardRequestFor(target, kind) }),
1946
+ onQuote: quoteConflict,
1947
+ }),
1948
+ ),
1949
+ ),
1950
+ ),
1951
+ ),
1952
+ h('div', { className: 'gp-review' },
1953
+ h('div', {
1954
+ className: 'gp-review-head',
1955
+ role: 'button',
1956
+ tabIndex: 0,
1957
+ onClick: () => setExpanded((current) => ({ ...current, review: current.review === false })),
1958
+ onKeyDown: (event) => {
1959
+ if (event.key === 'Enter' || event.key === ' ') {
1960
+ event.preventDefault()
1961
+ setExpanded((current) => ({ ...current, review: current.review === false }))
1962
+ }
1963
+ },
1964
+ }, t('review.title')),
1965
+ expanded.review === false ? null : h('button', {
1966
+ type: 'button',
1967
+ className: 'gp-review-btn',
1968
+ disabled: busy || (repo?.staged?.length ?? 0) + (repo?.changes?.length ?? 0) + (repo?.untracked?.length ?? 0) === 0,
1969
+ onClick: findIssues,
1970
+ }, t('review.find')),
1971
+ ),
1972
+ expanded.graph === false
1973
+ ? null
1974
+ : h('div', {
1975
+ className: 'gp-resizer',
1976
+ role: 'separator',
1977
+ 'aria-orientation': 'horizontal',
1978
+ title: t('graph.resize'),
1979
+ onPointerDown: startGraphResize,
1980
+ }),
1981
+ h(
1982
+ 'div',
1983
+ {
1984
+ className: 'gp-graph-panel',
1985
+ style: expanded.graph === false ? null : { height: `${graphHeight}px` },
1986
+ },
1987
+ h(
1988
+ 'div',
1989
+ { className: 'gp-section' },
1990
+ h(SectionHeader, {
1991
+ t,
1992
+ title: t('graph.title'),
1993
+ count: graphRows.rows.length,
1994
+ expanded: expanded.graph,
1995
+ onToggle: sectionToggle('graph'),
1996
+ actions: [
1997
+ h(IconButton, { key: 'all', label: graphAll ? t('graph.all') : t('graph.head'), onClick: () => setGraphAll((value2) => !value2) }, h(BranchIcon, { size: 12 })),
1998
+ h(IconButton, { key: 'refresh', label: t('status.refresh'), onClick: () => setGraphVersion((value2) => value2 + 1) }, h(RefreshIcon, null)),
1999
+ ],
2000
+ }),
2001
+ expanded.graph !== true
2002
+ ? null
2003
+ : graph.loading === true && graphRows.rows.length === 0
2004
+ ? h('div', { className: 'gp-note' }, t('graph.loading'))
2005
+ : graphRows.rows.length === 0
2006
+ ? h('div', { className: 'gp-note' }, t('graph.empty'))
2007
+ : h(
2008
+ 'div',
2009
+ { className: 'gp-graph' },
2010
+ graphRows.rows.map((row) =>
2011
+ h(
2012
+ React.Fragment,
2013
+ { key: row.commit.hash },
2014
+ h(
2015
+ 'div',
2016
+ {
2017
+ className: `gp-graph-row${selected === row.commit.hash ? ' selected' : ''}`,
2018
+ onClick: () => selectCommit(row.commit),
2019
+ role: 'button',
2020
+ tabIndex: 0,
2021
+ onKeyDown: (event) => (event.key === 'Enter' ? selectCommit(row.commit) : undefined),
2022
+ },
2023
+ h(GraphCanvas, { row, laneCount: graphRows.laneCount }),
2024
+ h(
2025
+ 'div',
2026
+ { className: 'gp-graph-main' },
2027
+ h(
2028
+ 'div',
2029
+ { className: 'gp-graph-subject' },
2030
+ h(Refs, { refs: row.commit.refs, t }),
2031
+ h('span', { className: 'gp-subject-text', title: row.commit.subject }, row.commit.subject === '' ? '(无提交说明)' : row.commit.subject),
2032
+ ),
2033
+ h(
2034
+ 'div',
2035
+ { className: 'gp-graph-meta' },
2036
+ h('span', { className: 'gp-hash' }, row.commit.short),
2037
+ h('span', null, row.commit.author),
2038
+ h('span', null, relativeTime(row.commit.at)),
2039
+ ),
2040
+ ),
2041
+ ),
2042
+ selected === row.commit.hash
2043
+ ? h(CommitDetail, {
2044
+ t,
2045
+ detail,
2046
+ loading: detailLoading,
2047
+ copied,
2048
+ onOpenDiff: (commit) => tabActions?.openTab(DIFF_KIND, { params: { hash: commit.hash, cwd: repo?.cwd } }),
2049
+ onCopy: (commit) => {
2050
+ try {
2051
+ navigator.clipboard?.writeText?.(commit.hash)
2052
+ } catch {}
2053
+ setCopied(true)
2054
+ setTimeout(() => setCopied(false), 1600)
2055
+ },
2056
+ onCheckout:
2057
+ row.commit.refs.some((ref) => ref.kind === 'branch' || ref.kind === 'head')
2058
+ ? () => {
2059
+ const ref = row.commit.refs.find((entry) => entry.kind === 'branch')
2060
+ if (ref !== undefined) run('checkout', { name: ref.name })
2061
+ }
2062
+ : undefined,
2063
+ })
2064
+ : null,
2065
+ ),
2066
+ ),
2067
+ graph.value?.truncated === true ? h('div', { className: 'gp-note' }, fill(t('graph.truncated'), { count: graphRows.rows.length })) : null,
2068
+ ),
2069
+ ),
2070
+ ),
2071
+ confirming === undefined
2072
+ ? null
2073
+ : h(ConfirmBar, {
2074
+ t,
2075
+ confirming,
2076
+ repo,
2077
+ busy,
2078
+ onCancel: () => setConfirming(undefined),
2079
+ onConfirm: () => {
2080
+ /** One request per confirmation, so a stale row cannot widen it. */
2081
+ let request
2082
+ let count
2083
+ if (confirming.kind === 'discardOne') {
2084
+ request = confirming.request ?? discardRequestFor({ path: confirming.path, code: confirming.untracked === true ? '??' : '' }, confirming.group)
2085
+ count = (request.paths?.length ?? 0) + (request.untracked?.length ?? 0)
2086
+ } else {
2087
+ const paths = [...(repo?.staged ?? []), ...(repo?.changes ?? [])].map((entry) => entry.path)
2088
+ const untracked = (repo?.untracked ?? []).map((entry) => entry.path)
2089
+ request = { paths, untracked }
2090
+ count = paths.length + untracked.length
2091
+ }
2092
+ if (count === 0) {
2093
+ setConfirming(undefined)
2094
+ return
2095
+ }
2096
+ run('discard', request, { graph: false }).then((outcome) => {
2097
+ if (outcome.ok !== true) return
2098
+ setNotice(fill(t('discard.done'), { count }))
2099
+ status.refresh?.(true)
2100
+ })
2101
+ },
2102
+ }),
2103
+ )
2104
+ }
2105
+
2106
+ /** Every path a "stage all" click should add. */
2107
+ function stageAllPaths(repo) {
2108
+ const paths = [...(repo?.untracked ?? []), ...(repo?.changes ?? []), ...(repo?.conflicts ?? [])].map((entry) => entry.path)
2109
+ return paths
2110
+ }
2111
+
2112
+ /**
2113
+ * The in-panel confirmation overlay: discard never happens on one click.
2114
+ *
2115
+ * It is anchored to the bottom edge of the panel (`position:absolute` in a
2116
+ * positioned `.gp-root`) instead of being the last flex row of the column,
2117
+ * where a tall graph pushed it out of view.
2118
+ * @param {object} props - `t`, `confirming`, `repo`, `busy` and the handlers.
2119
+ * @returns {object} the overlay.
2120
+ */
2121
+ function ConfirmBar({ t, confirming, repo, busy, onCancel, onConfirm }) {
2122
+ const text =
2123
+ confirming.kind === 'discardOne'
2124
+ ? fill(t('confirm.discardOne'), { path: confirming.path })
2125
+ : fill(t('confirm.discardAll'), { count: (repo?.counts?.staged ?? 0) + (repo?.counts?.changes ?? 0) + (repo?.counts?.untracked ?? 0) })
2126
+ const label = t('confirm.discard')
2127
+ useEffect(() => {
2128
+ const onKey = (event) => {
2129
+ if (event.key === 'Escape') onCancel()
2130
+ }
2131
+ window.addEventListener('keydown', onKey)
2132
+ return () => window.removeEventListener('keydown', onKey)
2133
+ }, [onCancel])
2134
+ return h(
2135
+ 'div',
2136
+ {
2137
+ className: 'gp-confirm-scrim',
2138
+ onMouseDown: (event) => {
2139
+ if (event.target === event.currentTarget) onCancel()
2140
+ },
2141
+ },
2142
+ h(
2143
+ 'div',
2144
+ { className: 'gp-confirm', role: 'alertdialog', 'aria-modal': 'true' },
2145
+ h('span', null, text),
2146
+ h(
2147
+ 'button',
2148
+ { type: 'button', className: 'gp-primary', disabled: busy === true, onClick: onConfirm },
2149
+ busy === true ? h('span', { className: 'gp-spinner' }) : null,
2150
+ label,
2151
+ ),
2152
+ h(IconButton, { label: t('confirm.cancel'), onClick: onCancel }, h('span', { 'aria-hidden': 'true' }, '✕')),
2153
+ ),
2154
+ )
2155
+ }
2156
+
2157
+ /** The branch chip and its checkout menu. */
2158
+ function BranchMenu({ t, repo, busy, onCheckout }) {
2159
+ const [open, setOpen] = useState(false)
2160
+ const [branches, setBranches] = useState(undefined)
2161
+ const ref = useRef(null)
2162
+
2163
+ useEffect(() => {
2164
+ if (!open || branches !== undefined) return undefined
2165
+ call('branches', { cwd: repo.cwd })
2166
+ .then((next) => setBranches(next))
2167
+ .catch(() => setBranches({ local: [], remote: [], tags: [] }))
2168
+ return undefined
2169
+ }, [open, branches, repo.cwd])
2170
+
2171
+ useEffect(() => {
2172
+ if (!open) return undefined
2173
+ const onDown = (event) => {
2174
+ if (ref.current !== null && !ref.current.contains(event.target)) setOpen(false)
2175
+ }
2176
+ document.addEventListener('mousedown', onDown)
2177
+ return () => document.removeEventListener('mousedown', onDown)
2178
+ }, [open])
2179
+
2180
+ const label = repo.detached === true ? (repo.oid ?? t('branch.detached')) : (repo.branch ?? t('branch.detached'))
2181
+ return h(
2182
+ 'div',
2183
+ { className: 'gp-menu-wrap', ref },
2184
+ h(
2185
+ 'button',
2186
+ { type: 'button', className: 'gp-branch', title: `${t('branch.current')}: ${label}`, onClick: () => setOpen((value) => !value) },
2187
+ h(BranchIcon, { size: 12 }),
2188
+ h('span', null, label),
2189
+ ),
2190
+ open
2191
+ ? h(
2192
+ 'div',
2193
+ { className: 'gp-menu', role: 'menu' },
2194
+ (branches?.local ?? []).map((branch) =>
2195
+ h(
2196
+ 'button',
2197
+ {
2198
+ key: branch.name,
2199
+ type: 'button',
2200
+ role: 'menuitem',
2201
+ disabled: busy || branch.current === true,
2202
+ onClick: () => {
2203
+ setOpen(false)
2204
+ onCheckout(branch.name)
2205
+ },
2206
+ },
2207
+ h(BranchIcon, { size: 12 }),
2208
+ h('span', null, branch.name),
2209
+ branch.current === true ? h('span', { className: 'gp-tag' }, t('branch.current')) : null,
2210
+ ),
2211
+ ),
2212
+ (branches?.local ?? []).length === 0 ? h('div', { className: 'gp-note' }, t('graph.loading')) : null,
2213
+ )
2214
+ : null,
2215
+ )
2216
+ }
2217
+
2218
+ /* ------------------------------------------------------------------ */
2219
+ /* Diff page */
2220
+ /* ------------------------------------------------------------------ */
2221
+
2222
+ /**
2223
+ * The diff preview body: one file, every working-tree change, or one commit.
2224
+ * @param {object} props - slot props plus the tab's navigation params.
2225
+ * @returns {object} the preview.
2226
+ */
2227
+ function DiffBody(props) {
2228
+ const t = props.t ?? ((key) => key)
2229
+ if (typeof props.useSessions !== 'function' || typeof props.useTabInfo !== 'function') {
2230
+ return h('div', { className: 'gp-root' }, h('div', { className: 'gp-note' }, t('empty.noSession')))
2231
+ }
2232
+ return h(DiffView, props)
2233
+ }
2234
+
2235
+ /**
2236
+ * The diff page proper, with its hooks unconditional.
2237
+ * @param {object} props - slot props.
2238
+ * @returns {object} the preview.
2239
+ */
2240
+ function DiffView(props) {
2241
+ const t = props.t ?? ((key) => key)
2242
+ const sessionId = props.sessionId
2243
+ const tabInfo = props.useTabInfo()
2244
+ const params = tabInfo?.tab?.navigation?.params
2245
+ const revision = tabInfo?.tab?.navigation?.revision ?? 0
2246
+ const tabActions = tabInfo?.tab?.actions
2247
+ const sessionCwd = useSessionCwd(props.useSessions, sessionId)
2248
+ // TEMP: 面板打开改动页时带的仓库根优先,会话目录只作兜底。
2249
+ const paramCwd = typeof params?.cwd === 'string' && params.cwd !== '' ? params.cwd : undefined
2250
+ const cwd = paramCwd !== undefined ? paramCwd : typeof sessionCwd === 'string' && sessionCwd !== '' ? sessionCwd : undefined
2251
+
2252
+ const request = useMemo(() => {
2253
+ const source = params ?? {}
2254
+ const next = { all: source.all === true }
2255
+ if (typeof source.path === 'string') next.path = source.path
2256
+ if (source.staged === true) next.staged = true
2257
+ if (source.untracked === true) next.untracked = true
2258
+ if (typeof source.hash === 'string') next.hash = source.hash
2259
+ return next
2260
+ }, [params])
2261
+
2262
+ const [result, setResult] = useState(undefined)
2263
+ const [error, setError] = useState(undefined)
2264
+ const [loading, setLoading] = useState(false)
2265
+ const [version, setVersion] = useState(0)
2266
+ const signature = JSON.stringify(request)
2267
+
2268
+ useEffect(() => {
2269
+ if (cwd === undefined || cwd === '') return undefined
2270
+ const controller = new AbortController()
2271
+ setLoading(true)
2272
+ call('diff', { cwd, ...request }, controller.signal)
2273
+ .then((next) => {
2274
+ setResult(next)
2275
+ setError(undefined)
2276
+ })
2277
+ .catch((failure) => {
2278
+ if (controller.signal.aborted !== true) setError(failure)
2279
+ })
2280
+ .finally(() => {
2281
+ if (controller.signal.aborted !== true) setLoading(false)
2282
+ })
2283
+ return () => controller.abort()
2284
+ // `signature` is the stable identity of `request`; `revision` re-opens.
2285
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2286
+ }, [cwd, signature, version, revision])
2287
+
2288
+ const mode =
2289
+ request.hash !== undefined
2290
+ ? t('diff.mode.commit')
2291
+ : request.all === true
2292
+ ? undefined
2293
+ : request.staged === true
2294
+ ? t('diff.mode.staged')
2295
+ : request.untracked === true
2296
+ ? t('diff.mode.untracked')
2297
+ : t('diff.mode.worktree')
2298
+
2299
+ const patch = typeof result?.patch === 'string' ? result.patch : ''
2300
+ const parsed = useMemo(() => parsePatch(patch), [patch])
2301
+
2302
+ const openFile = () => {
2303
+ if (tabActions === undefined || typeof request.path !== 'string' || typeof result?.root !== 'string') return
2304
+ try {
2305
+ tabActions.openResource(fileAddress(sessionId, result.root, request.path))
2306
+ } catch (failure) {
2307
+ setError(failure)
2308
+ }
2309
+ }
2310
+
2311
+ return h(
2312
+ 'div',
2313
+ { className: 'gp-diff' },
2314
+ h(
2315
+ 'div',
2316
+ { className: 'gp-diff-head' },
2317
+ h('span', { className: 'gp-path', title: request.path ?? request.hash ?? t('diff.title') }, request.path ?? request.hash ?? t('diff.title')),
2318
+ mode === undefined ? null : h('span', { className: 'gp-tag' }, mode),
2319
+ parsed.additions > 0 ? h('span', { className: 'gp-add' }, `+${parsed.additions}`) : null,
2320
+ parsed.deletions > 0 ? h('span', { className: 'gp-del' }, `-${parsed.deletions}`) : null,
2321
+ typeof request.path === 'string' ? h(IconButton, { label: t('diff.openFile'), onClick: openFile }, h(FileIcon, null)) : null,
2322
+ h(IconButton, { label: t('diff.refresh'), disabled: loading, onClick: () => setVersion((value2) => value2 + 1) }, h(RefreshIcon, null)),
2323
+ ),
2324
+ error === undefined ? null : h('div', { className: 'gp-error' }, h('span', null, error.message)),
2325
+ h(
2326
+ 'div',
2327
+ { className: 'gp-diff-body' },
2328
+ loading === true && parsed.rows.length === 0
2329
+ ? h('div', { className: 'gp-note' }, t('empty.loading'))
2330
+ : result?.binary === true && parsed.rows.length === 0
2331
+ ? h('div', { className: 'gp-note' }, t('diff.binary'))
2332
+ : parsed.rows.length === 0
2333
+ ? h('div', { className: 'gp-note' }, t('diff.empty'))
2334
+ : parsed.rows.map((row, index) =>
2335
+ h(
2336
+ 'div',
2337
+ { key: index, className: `gp-diff-line ${row.kind}` },
2338
+ h('span', { className: 'gp-ln' }, row.oldNo === '' ? '' : String(row.oldNo)),
2339
+ h('span', { className: 'gp-ln' }, row.newNo === '' ? '' : String(row.newNo)),
2340
+ h('span', { className: 'gp-sign' }, row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ''),
2341
+ h('span', { className: 'gp-code' }, row.text),
2342
+ ),
2343
+ ),
2344
+ parsed.truncated === true ? h('div', { className: 'gp-note' }, fill(t('diff.lines'), { count: parsed.rows.length })) : null,
2345
+ ),
2346
+ )
2347
+ }
2348
+
2349
+ /** The panel chip's live title. */
2350
+ function GitTitle(props) {
2351
+ const t = props.t ?? ((key) => key)
2352
+ return h('span', null, t('type.label'))
2353
+ }
2354
+
2355
+ /** The diff chip's live title: the file being previewed. */
2356
+ function DiffTitle(props) {
2357
+ const t = props.t ?? ((key) => key)
2358
+ if (typeof props.useTabInfo !== 'function') return h('span', null, t('diff.title'))
2359
+ return h(DiffTitleView, props)
2360
+ }
2361
+
2362
+ /**
2363
+ * The chip title, read from the tab's own navigation parameters so two
2364
+ * diff tabs never share a label.
2365
+ * @param {object} props - slot props.
2366
+ * @returns {object} the title.
2367
+ */
2368
+ function DiffTitleView(props) {
2369
+ const t = props.t ?? ((key) => key)
2370
+ const params = props.useTabInfo()?.tab?.navigation?.params ?? {}
2371
+ if (typeof params.path === 'string' && params.path !== '') return h('span', null, fill(t('diff.title.path'), { path: params.path }))
2372
+ if (typeof params.hash === 'string' && params.hash !== '') return h('span', null, fill(t('diff.title.path'), { path: params.hash.slice(0, 7) }))
2373
+ return h('span', null, t('diff.title'))
2374
+ }
2375
+
2376
+ /* ------------------------------------------------------------------ */
2377
+ /* Registration */
2378
+ /* ------------------------------------------------------------------ */
2379
+
2380
+ /**
2381
+ * Client plugin body: register both tab types, their dictionaries and
2382
+ * their bodies.
2383
+ * @param {object} ctx - client root context.
2384
+ */
2385
+ function apply(ctx) {
2386
+ pluginCtx = ctx
2387
+ const t = ctx.locale.bind(NS)
2388
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'git-panel: dictionaries')
2389
+ ctx.effect(() => installStyles(), 'git-panel: styles')
2390
+
2391
+ ctx.effect(
2392
+ () =>
2393
+ ctx.sidebarRightTabs.register({
2394
+ id: PANEL_ID,
2395
+ kind: GIT_KIND,
2396
+ priority: 'extension',
2397
+ title: () => t('type.label'),
2398
+ guide: [
2399
+ {
2400
+ id: 'panel',
2401
+ order: 25,
2402
+ title: () => t('guide.title'),
2403
+ description: () => t('guide.description'),
2404
+ icon: GitGuideIcon,
2405
+ },
2406
+ ],
2407
+ }),
2408
+ 'git-panel: git type',
2409
+ )
2410
+
2411
+ ctx.effect(
2412
+ () =>
2413
+ ctx.sidebarRightTabs.register({
2414
+ id: DIFF_ID,
2415
+ kind: DIFF_KIND,
2416
+ multiple: true,
2417
+ priority: 'extension',
2418
+ title: () => t('diff.title'),
2419
+ }),
2420
+ 'git-panel: diff type',
2421
+ )
2422
+
2423
+ ctx.effect(
2424
+ () =>
2425
+ ctx.slots.inject('sidebar.right.pane.tab', () =>
2426
+ ctx.slots.register(
2427
+ {
2428
+ name: 'sidebar.right.pane.tab',
2429
+ key: PANEL_ID,
2430
+ locale: NS,
2431
+ inject: (sessionId) => ({ sessionId, t }),
2432
+ },
2433
+ GitPanelBody,
2434
+ ),
2435
+ ),
2436
+ 'git-panel: git body',
2437
+ )
2438
+
2439
+ ctx.effect(
2440
+ () =>
2441
+ ctx.slots.inject('sidebar.right.pane.tab', () =>
2442
+ ctx.slots.register(
2443
+ {
2444
+ name: 'sidebar.right.pane.tab',
2445
+ key: DIFF_ID,
2446
+ locale: NS,
2447
+ inject: (sessionId) => ({ sessionId, t }),
2448
+ },
2449
+ DiffBody,
2450
+ ),
2451
+ ),
2452
+ 'git-panel: diff body',
2453
+ )
2454
+
2455
+ ctx.effect(
2456
+ () =>
2457
+ ctx.slots.inject('sidebar.right.pane.tab.title', () =>
2458
+ ctx.slots.register(
2459
+ {
2460
+ name: 'sidebar.right.pane.tab.title',
2461
+ key: DIFF_ID,
2462
+ locale: NS,
2463
+ inject: (sessionId) => ({ sessionId, t }),
2464
+ },
2465
+ DiffTitle,
2466
+ ),
2467
+ ),
2468
+ 'git-panel: diff title',
2469
+ )
2470
+ }
2471
+
2472
+ return {
2473
+ name: 'git-panel',
2474
+ inject: ['slots', 'locale', 'sidebarRightTabs'],
2475
+ apply,
2476
+ // Exposed for the package's own tests; not part of any slot contract.
2477
+ __test: {
2478
+ buildReviewPrompt,
2479
+ ChangeBadge,
2480
+ changeKindOf,
2481
+ diffRequestFor,
2482
+ discardRequestFor,
2483
+ fileFamilyOf,
2484
+ FileTypeIcon,
2485
+ fill,
2486
+ graphShapes,
2487
+ groupEdges,
2488
+ layoutGraph,
2489
+ parsePatch,
2490
+ relativeTime,
2491
+ splitPath,
2492
+ fileAddress,
2493
+ GitPanelBody,
2494
+ DiffBody,
2495
+ },
2496
+ }
2497
+ },
2498
+ })