@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/LICENSE +21 -0
- package/README.md +78 -2
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2498 -0
- package/lib/index.js +1271 -0
- package/package.json +57 -3
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
|
+
})
|