@xiaoming_z/dsh-git 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +998 -2
- package/client.js +3133 -0
- package/cordis.patch.yml +29 -0
- package/docs/composer-branch-chip.png +0 -0
- package/index.js +1320 -0
- package/package.json +44 -4
- package/test/browser/README.md +39 -0
- package/test/browser/drive.mjs +122 -0
- package/test/browser/harness.js +271 -0
- package/test/browser/index.html +41 -0
- package/test/browser/setup.mjs +119 -0
- package/test/client.test.mjs +1258 -0
- package/test/host.test.mjs +817 -0
package/client.js
ADDED
|
@@ -0,0 +1,3133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@xiaoming_z/dsh-git` — browser half of the DSH git panel.
|
|
3
|
+
*
|
|
4
|
+
* Hand-authored bundle: this is the exact artifact shape the DSH client module
|
|
5
|
+
* system expects (a classic script that registers a factory with
|
|
6
|
+
* `window.__ModuleLoader__.load`). Writing it by hand is what lets the plugin
|
|
7
|
+
* live outside the DSH monorepo — the canonical build emits this file from
|
|
8
|
+
* TypeScript, but nothing about the format requires that toolchain.
|
|
9
|
+
*
|
|
10
|
+
* The registered id MUST equal the package name: the module system looks the
|
|
11
|
+
* factory up by the Loader row id, which is the row's package specifier. A
|
|
12
|
+
* mismatch means the bundle loads but never binds to its row.
|
|
13
|
+
*
|
|
14
|
+
* Only `react` is required, and the shell seeds React as a shared module, so
|
|
15
|
+
* the package needs no `dsh.client.external` entry. Everything else is plain
|
|
16
|
+
* DOM with inline styles.
|
|
17
|
+
*
|
|
18
|
+
* Layout follows the first-party view contract (`dsh-client-ui-trajectory`):
|
|
19
|
+
* `width/height: 100%`, `box-sizing: border-box`, `overflow: hidden`, and a
|
|
20
|
+
* `min-width: 0` on every flex child that contains truncating text. Without
|
|
21
|
+
* those the commit subjects' `nowrap` forces the log column wider than the
|
|
22
|
+
* container, the panel overflows horizontally, and the file column gets pushed
|
|
23
|
+
* off the left edge.
|
|
24
|
+
*/
|
|
25
|
+
window.__ModuleLoader__.load({
|
|
26
|
+
id: '@xiaoming_z/dsh-git',
|
|
27
|
+
factory: (require) => {
|
|
28
|
+
var module = { exports: {} }
|
|
29
|
+
var exports = module.exports
|
|
30
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
31
|
+
|
|
32
|
+
const React = require('react')
|
|
33
|
+
const h = React.createElement
|
|
34
|
+
|
|
35
|
+
/** Services this plugin needs before its body runs. */
|
|
36
|
+
const inject = ['slots', 'locale', 'sessions']
|
|
37
|
+
|
|
38
|
+
/** Locale namespace this plugin registers under. */
|
|
39
|
+
const NS = 'git'
|
|
40
|
+
|
|
41
|
+
/** Route prefix owned by the Host half of `@xiaoming_z/dsh-git`. */
|
|
42
|
+
const API = '/git-api'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Header every write must carry. The route table is outside the `/api`
|
|
46
|
+
* browser-trust fence, so this is what stops a cross-origin page from
|
|
47
|
+
* driving git through loopback: a custom header cannot be set
|
|
48
|
+
* cross-origin without a preflight, and this server answers none.
|
|
49
|
+
*/
|
|
50
|
+
const WRITE_HEADER = 'x-dsh-git'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every string the panel renders.
|
|
54
|
+
*
|
|
55
|
+
* The shell owns the language choice (Settings → General, persisted to
|
|
56
|
+
* `$DSH_HOME/settings.yaml`, defaulting to the browser's requested
|
|
57
|
+
* languages), so this is a dictionary rather than a config of our own:
|
|
58
|
+
* the panel just translates through whatever locale is active.
|
|
59
|
+
*/
|
|
60
|
+
const DICTS = {
|
|
61
|
+
zh: {
|
|
62
|
+
'view.git': 'Git',
|
|
63
|
+
'view.review': '审查',
|
|
64
|
+
'view.worktree': '工作区',
|
|
65
|
+
'view.history': '历史',
|
|
66
|
+
'tab.changes': '{count} 项变更',
|
|
67
|
+
'header.refresh': '刷新',
|
|
68
|
+
'header.retry': '重试',
|
|
69
|
+
'header.path.follow': '跟随会话工作区:{path}',
|
|
70
|
+
'header.path.placeholder': '仓库路径(留空则用宿主默认)',
|
|
71
|
+
'review.noMarker': '还没有审查点。标记之后,这里只显示之后发生的变化——你不用再从头翻历史。',
|
|
72
|
+
'review.mark': '标记为已审查',
|
|
73
|
+
'review.marked': '已标记为已审查',
|
|
74
|
+
'review.markHint': '把当前 HEAD 记为审查点',
|
|
75
|
+
'review.clean': '与审查点相比没有变化',
|
|
76
|
+
'review.summary': '自 {when} 以来:{commits} 个提交、{files} 个文件、+{added} −{deleted}',
|
|
77
|
+
'review.worktreeNote': '(含未提交改动)',
|
|
78
|
+
'review.rank': '按需要留意的程度排序:冲突 / 删除 / 二进制 / 大改动 优先',
|
|
79
|
+
'review.pickFile': '选择文件查看自审查点以来的改动',
|
|
80
|
+
'review.markerGone': '审查点已失效(可能被 rebase 删掉了),请重新标记',
|
|
81
|
+
'handoff.files': '改动的文件',
|
|
82
|
+
'handoff.commits': '最近的提交',
|
|
83
|
+
'handoff.copy': '复制引用',
|
|
84
|
+
'handoff.copyTitle': '复制一段可直接粘进对话的说明',
|
|
85
|
+
'handoff.copied': '已复制,粘到输入框即可',
|
|
86
|
+
'handoff.failed': '复制失败',
|
|
87
|
+
'review.why.conflict': '冲突',
|
|
88
|
+
'review.why.deleted': '删除',
|
|
89
|
+
'review.why.binary': '二进制',
|
|
90
|
+
'review.why.large': '大改动',
|
|
91
|
+
'empty.loading': '加载中…',
|
|
92
|
+
'empty.clean': '工作区干净',
|
|
93
|
+
'empty.noCommits': '没有提交',
|
|
94
|
+
'empty.pickFile': '选择左侧文件查看差异',
|
|
95
|
+
'empty.pickCommit': '选择中间一栏的提交查看改动',
|
|
96
|
+
'empty.noCommitDiff': '该提交没有可显示的差异',
|
|
97
|
+
'empty.pickOne': '选择一项查看差异',
|
|
98
|
+
'group.conflicted': '冲突',
|
|
99
|
+
'group.staged': '已暂存',
|
|
100
|
+
'group.unstaged': '未暂存',
|
|
101
|
+
'group.untracked': '未跟踪',
|
|
102
|
+
'group.header': '{label} · {count}',
|
|
103
|
+
'file.stage': '暂存',
|
|
104
|
+
'file.unstage': '取消',
|
|
105
|
+
'hunk.stage': '暂存此块',
|
|
106
|
+
'hunk.unstage': '取消此块',
|
|
107
|
+
'hunk.header': '改动块 {index}/{total}',
|
|
108
|
+
'commit.button': '提交 {count} 项',
|
|
109
|
+
'commit.placeholderReady': '提交信息(Enter 提交)',
|
|
110
|
+
'commit.placeholderEmpty': '先暂存要提交的改动…',
|
|
111
|
+
'commit.done': '已提交 {hash}',
|
|
112
|
+
'diff.failed': '无法读取差异:{message}',
|
|
113
|
+
'branch.title': '分支',
|
|
114
|
+
'branch.head': 'HEAD',
|
|
115
|
+
'branch.local': '本地 · {count}',
|
|
116
|
+
'branch.remote': '远程 · {count}',
|
|
117
|
+
'branch.new': '新建',
|
|
118
|
+
'branch.newTitle': '从 {base} 新建分支',
|
|
119
|
+
'branch.checkout': '检出',
|
|
120
|
+
'branch.checkoutRemoteTitle': '检出为本地分支 {name}',
|
|
121
|
+
'branch.current': '当前',
|
|
122
|
+
'branch.newPlaceholder': '新分支名(基于 {base})',
|
|
123
|
+
'branch.filtered': '分支 {ref} · {count} 提交',
|
|
124
|
+
'branch.checkedOut': '已检出 {ref}',
|
|
125
|
+
'branch.created': '已新建并检出 {name}',
|
|
126
|
+
'pull.button': '拉取',
|
|
127
|
+
'pull.ff-only': '快进',
|
|
128
|
+
'pull.merge': '合并',
|
|
129
|
+
'pull.rebase': '变基',
|
|
130
|
+
'pull.ff-only.title': '拉取(仅快进):分叉时失败,不改历史也不造合并提交',
|
|
131
|
+
'pull.merge.title': '拉取并合并:分叉时生成一个合并提交',
|
|
132
|
+
'pull.rebase.title': '拉取并变基:把本地提交重放到远端之上,会重写它们',
|
|
133
|
+
'pull.updated': '已更新 {before} → {after}',
|
|
134
|
+
'pull.uptodate': '已是最新',
|
|
135
|
+
'push.button': '推送',
|
|
136
|
+
'push.publish': '发布',
|
|
137
|
+
'push.title': '推送当前分支到它的上游',
|
|
138
|
+
'push.publishTitle': '把 {branch} 推送到远端并设置上游',
|
|
139
|
+
'push.done': '已推送 {before} → {after}',
|
|
140
|
+
'push.uptodate': '远端已是最新',
|
|
141
|
+
'push.published': '已发布 {branch}',
|
|
142
|
+
'splitter.title': '拖动调整宽度',
|
|
143
|
+
'stamp.justNow': '刚刚',
|
|
144
|
+
'stamp.minutes': '{count} 分钟前',
|
|
145
|
+
'stamp.hours': '{count} 小时前',
|
|
146
|
+
'stamp.days': '{count} 天前',
|
|
147
|
+
'status.M': '修改',
|
|
148
|
+
'status.A': '新增',
|
|
149
|
+
'status.D': '删除',
|
|
150
|
+
'status.R': '重命名',
|
|
151
|
+
'status.C': '复制',
|
|
152
|
+
'status.T': '类型变更',
|
|
153
|
+
'status.U': '冲突',
|
|
154
|
+
'status.?': '未跟踪',
|
|
155
|
+
'error.unreachable': 'git 服务不可达({route}):{message}\n确认 @xiaoming_z/dsh-git 这一行已在 profile 中启用。',
|
|
156
|
+
'error.notJson': '{route} 返回了非 JSON 响应(HTTP {status})。',
|
|
157
|
+
'error.badStatus': '宿主半返回的 status 缺少字段 {fields};客户端与宿主版本可能不匹配。',
|
|
158
|
+
},
|
|
159
|
+
en: {
|
|
160
|
+
'view.git': 'Git',
|
|
161
|
+
'view.review': 'Review',
|
|
162
|
+
'view.worktree': 'Changes',
|
|
163
|
+
'view.history': 'History',
|
|
164
|
+
'tab.changes': '{count} changed',
|
|
165
|
+
'header.refresh': 'Refresh',
|
|
166
|
+
'header.retry': 'Retry',
|
|
167
|
+
'header.path.follow': 'Following session workspace: {path}',
|
|
168
|
+
'header.path.placeholder': 'Repository path (empty follows the session)',
|
|
169
|
+
'review.noMarker': 'No review marker yet. Once you set one, this view shows only what changed after it — no re-reading history.',
|
|
170
|
+
'review.mark': 'Mark as reviewed',
|
|
171
|
+
'review.marked': 'Marked as reviewed',
|
|
172
|
+
'review.markHint': 'Record the current HEAD as the review marker',
|
|
173
|
+
'review.clean': 'Nothing changed since the review marker',
|
|
174
|
+
'review.summary': 'Since {when}: {commits} commits, {files} files, +{added} −{deleted}',
|
|
175
|
+
'review.worktreeNote': ' (includes uncommitted changes)',
|
|
176
|
+
'review.rank': 'Ordered by how much attention it deserves: conflicts, deletions, binaries and large changes first',
|
|
177
|
+
'review.pickFile': 'Pick a file to see its change since the marker',
|
|
178
|
+
'review.markerGone': 'The marker no longer resolves (a rebase may have removed it); mark again',
|
|
179
|
+
'handoff.files': 'Changed files',
|
|
180
|
+
'handoff.commits': 'Recent commits',
|
|
181
|
+
'handoff.copy': 'Copy ref',
|
|
182
|
+
'handoff.copyTitle': 'Copy a description you can paste into the conversation',
|
|
183
|
+
'handoff.copied': 'Copied — paste it into the composer',
|
|
184
|
+
'handoff.failed': 'Copy failed',
|
|
185
|
+
'review.why.conflict': 'conflict',
|
|
186
|
+
'review.why.deleted': 'deleted',
|
|
187
|
+
'review.why.binary': 'binary',
|
|
188
|
+
'review.why.large': 'large',
|
|
189
|
+
'empty.loading': 'Loading…',
|
|
190
|
+
'empty.clean': 'Working tree clean',
|
|
191
|
+
'empty.noCommits': 'No commits',
|
|
192
|
+
'empty.pickFile': 'Pick a file to see its diff',
|
|
193
|
+
'empty.pickCommit': 'Pick a commit to see its changes',
|
|
194
|
+
'empty.noCommitDiff': 'This commit has no diff to show',
|
|
195
|
+
'empty.pickOne': 'Pick something to see its diff',
|
|
196
|
+
'group.conflicted': 'Conflicted',
|
|
197
|
+
'group.staged': 'Staged',
|
|
198
|
+
'group.unstaged': 'Unstaged',
|
|
199
|
+
'group.untracked': 'Untracked',
|
|
200
|
+
'group.header': '{label} · {count}',
|
|
201
|
+
'file.stage': 'Stage',
|
|
202
|
+
'file.unstage': 'Unstage',
|
|
203
|
+
'hunk.stage': 'Stage hunk',
|
|
204
|
+
'hunk.unstage': 'Unstage hunk',
|
|
205
|
+
'hunk.header': 'Hunk {index}/{total}',
|
|
206
|
+
'commit.button': 'Commit {count}',
|
|
207
|
+
'commit.placeholderReady': 'Commit message (Enter to commit)',
|
|
208
|
+
'commit.placeholderEmpty': 'Stage something to commit…',
|
|
209
|
+
'commit.done': 'Committed {hash}',
|
|
210
|
+
'diff.failed': 'Cannot read the diff: {message}',
|
|
211
|
+
'branch.title': 'Branches',
|
|
212
|
+
'branch.head': 'HEAD',
|
|
213
|
+
'branch.local': 'Local · {count}',
|
|
214
|
+
'branch.remote': 'Remote · {count}',
|
|
215
|
+
'branch.new': 'New',
|
|
216
|
+
'branch.newTitle': 'New branch from {base}',
|
|
217
|
+
'branch.checkout': 'Checkout',
|
|
218
|
+
'branch.checkoutRemoteTitle': 'Check out as local branch {name}',
|
|
219
|
+
'branch.current': 'current',
|
|
220
|
+
'branch.newPlaceholder': 'New branch name (from {base})',
|
|
221
|
+
'branch.filtered': '{ref} · {count} commits',
|
|
222
|
+
'branch.checkedOut': 'Checked out {ref}',
|
|
223
|
+
'branch.created': 'Created and checked out {name}',
|
|
224
|
+
'pull.button': 'Pull',
|
|
225
|
+
'pull.ff-only': 'Fast-forward',
|
|
226
|
+
'pull.merge': 'Merge',
|
|
227
|
+
'pull.rebase': 'Rebase',
|
|
228
|
+
'pull.ff-only.title': 'Pull, fast-forward only: fails when diverged, so it never rewrites history or invents a merge commit',
|
|
229
|
+
'pull.merge.title': 'Pull and merge: creates a merge commit when the branch diverged',
|
|
230
|
+
'pull.rebase.title': 'Pull and rebase: replays your local commits on top of the remote, rewriting them',
|
|
231
|
+
'pull.updated': 'Updated {before} → {after}',
|
|
232
|
+
'pull.uptodate': 'Already up to date',
|
|
233
|
+
'push.button': 'Push',
|
|
234
|
+
'push.publish': 'Publish',
|
|
235
|
+
'push.title': 'Push the current branch to its upstream',
|
|
236
|
+
'push.publishTitle': 'Push {branch} to the remote and set its upstream',
|
|
237
|
+
'push.done': 'Pushed {before} → {after}',
|
|
238
|
+
'push.uptodate': 'Remote already up to date',
|
|
239
|
+
'push.published': 'Published {branch}',
|
|
240
|
+
'splitter.title': 'Drag to resize',
|
|
241
|
+
'stamp.justNow': 'just now',
|
|
242
|
+
'stamp.minutes': '{count}m ago',
|
|
243
|
+
'stamp.hours': '{count}h ago',
|
|
244
|
+
'stamp.days': '{count}d ago',
|
|
245
|
+
'status.M': 'modified',
|
|
246
|
+
'status.A': 'added',
|
|
247
|
+
'status.D': 'deleted',
|
|
248
|
+
'status.R': 'renamed',
|
|
249
|
+
'status.C': 'copied',
|
|
250
|
+
'status.T': 'type changed',
|
|
251
|
+
'status.U': 'conflicted',
|
|
252
|
+
'status.?': 'untracked',
|
|
253
|
+
'error.unreachable': 'git service unreachable ({route}): {message}\nCheck that @xiaoming_z/dsh-git is enabled in the profile.',
|
|
254
|
+
'error.notJson': '{route} returned a non-JSON response (HTTP {status}).',
|
|
255
|
+
'error.badStatus': 'The host returned a status payload without {fields}; the client and host halves are probably mismatched.',
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Substitute `{name}` placeholders.
|
|
261
|
+
* @param template - the dictionary entry.
|
|
262
|
+
* @param params - values by placeholder name.
|
|
263
|
+
* @returns the filled string.
|
|
264
|
+
*/
|
|
265
|
+
function format(template, params) {
|
|
266
|
+
if (params === undefined || params === null) return template
|
|
267
|
+
return template.replace(/\{(\w+)\}/g, (match, name) =>
|
|
268
|
+
name in params ? String(params[name]) : match,
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Translate without a shell-injected seat.
|
|
274
|
+
*
|
|
275
|
+
* The shell injects a bound `t` through the slot descriptor; this is the
|
|
276
|
+
* fallback for a bare render (a test frame, or a slot that failed to
|
|
277
|
+
* inject). It uses English so a missing seat degrades to plain English
|
|
278
|
+
* rather than raw dictionary keys.
|
|
279
|
+
* @param key - the dictionary key.
|
|
280
|
+
* @param params - placeholder values.
|
|
281
|
+
* @returns the translated string.
|
|
282
|
+
*/
|
|
283
|
+
function fallbackTranslate(key, params) {
|
|
284
|
+
return format(DICTS.en[key] ?? key, params)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Lane colours, cycled.
|
|
289
|
+
*
|
|
290
|
+
* Theme tokens rather than a fixed palette: hues chosen to read on a dark
|
|
291
|
+
* background are close to invisible on a light one, and the shell
|
|
292
|
+
* switches these with the theme. The first four are the clearly distinct
|
|
293
|
+
* ones; the rest only matter on very wide graphs, which the lane
|
|
294
|
+
* algorithm keeps rare.
|
|
295
|
+
*/
|
|
296
|
+
const LANE_COLORS = [
|
|
297
|
+
'var(--dsw-alias-state-business-primary)',
|
|
298
|
+
'var(--dsw-alias-state-success-primary)',
|
|
299
|
+
'var(--dsw-alias-state-warn-primary)',
|
|
300
|
+
'var(--dsw-alias-state-error-primary)',
|
|
301
|
+
'var(--dsw-alias-label-primary)',
|
|
302
|
+
'var(--dsw-alias-state-success-secondary)',
|
|
303
|
+
'var(--dsw-alias-state-warn-secondary)',
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
/** Status letter → colour + label. */
|
|
307
|
+
// Colours are theme TOKENS, never literals: the palette that reads well on
|
|
308
|
+
// a dark background is close to invisible on a light one, and the shell
|
|
309
|
+
// already switches these tokens with the theme.
|
|
310
|
+
const STATUS = {
|
|
311
|
+
M: ['var(--dsw-alias-state-warn-primary)', 'status.M'],
|
|
312
|
+
A: ['var(--dsw-alias-state-success-primary)', 'status.A'],
|
|
313
|
+
D: ['var(--dsw-alias-state-error-primary)', 'status.D'],
|
|
314
|
+
R: ['var(--dsw-alias-state-business-primary)', 'status.R'],
|
|
315
|
+
C: ['var(--dsw-alias-state-business-primary)', 'status.C'],
|
|
316
|
+
T: ['var(--dsw-alias-state-warn-primary)', 'status.T'],
|
|
317
|
+
U: ['var(--dsw-alias-state-error-primary)', 'status.U'],
|
|
318
|
+
'?': ['var(--dsw-alias-label-tertiary)', 'status.?'],
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The pull strategies the host accepts, and what each one does.
|
|
323
|
+
*
|
|
324
|
+
* `ff-only` is the default because it is the only one that can neither
|
|
325
|
+
* rewrite local commits nor invent a merge commit; the other two are
|
|
326
|
+
* deliberate choices, so the tooltip says what they cost.
|
|
327
|
+
*/
|
|
328
|
+
const PULL_STRATEGIES = ['ff-only', 'merge', 'rebase']
|
|
329
|
+
|
|
330
|
+
/** Drag bounds per resizable column, in px. */
|
|
331
|
+
const COLUMN_LIMITS = {
|
|
332
|
+
branches: [140, 380],
|
|
333
|
+
commits: [220, 720],
|
|
334
|
+
files: [180, 720],
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Default widths, and the fallback for anything the store rejects. */
|
|
338
|
+
const COLUMN_DEFAULTS = { branches: 190, commits: 380, files: 380 }
|
|
339
|
+
|
|
340
|
+
const STORAGE_KEY = 'dsh-git:column-widths:v1'
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Coerce a width into its column's bounds.
|
|
344
|
+
* @param column - one of {@link COLUMN_LIMITS}' keys.
|
|
345
|
+
* @param value - the candidate width.
|
|
346
|
+
* @returns a whole number of pixels, or null when the input is not a number.
|
|
347
|
+
*/
|
|
348
|
+
function clampWidth(column, value) {
|
|
349
|
+
const [min, max] = COLUMN_LIMITS[column] ?? [80, 1200]
|
|
350
|
+
const numeric = Number(value)
|
|
351
|
+
if (!Number.isFinite(numeric)) return null
|
|
352
|
+
return Math.min(Math.max(Math.round(numeric), min), max)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The nearest ancestor that actually scrolls.
|
|
357
|
+
*
|
|
358
|
+
* The composer seat is a plain in-flow sibling of this view (`flex:none`),
|
|
359
|
+
* not an overlay, so the scroll container's content is "this panel plus
|
|
360
|
+
* whatever else shares the flow". Sizing the panel to the *window* made
|
|
361
|
+
* that content exactly one composer taller than the scrollport, which is
|
|
362
|
+
* why the composer could be scrolled into view.
|
|
363
|
+
* @param element - the panel's outer box.
|
|
364
|
+
* @returns the scrolling ancestor, or null.
|
|
365
|
+
*/
|
|
366
|
+
function nearestScroller(element) {
|
|
367
|
+
// Identified by `overflow-y`, never by "is it currently overflowing":
|
|
368
|
+
// a scroller whose content happens to fit — the short worktree, which
|
|
369
|
+
// is the case that needs growing — would not look like one, and the
|
|
370
|
+
// caller would fall back to the window estimate that caused the bug.
|
|
371
|
+
if (typeof window.getComputedStyle !== 'function') return null
|
|
372
|
+
let node = element.parentElement
|
|
373
|
+
while (node !== null && node !== undefined) {
|
|
374
|
+
if (/(auto|scroll)/.test(window.getComputedStyle(node).overflowY) && node.clientHeight > 0) return node
|
|
375
|
+
node = node.parentElement
|
|
376
|
+
}
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The height the panel needs so the scroll container's content fits
|
|
382
|
+
* exactly, leaving no slack to scroll.
|
|
383
|
+
*
|
|
384
|
+
* Subtracting the panel's own height from `scrollHeight` yields everything
|
|
385
|
+
* else in the flow (headers above, the composer below); removing that from
|
|
386
|
+
* the scrollport height is the panel's share. The answer does not depend on
|
|
387
|
+
* the height the panel happens to have when measured, so one pass is exact
|
|
388
|
+
* and a `resize` re-measure self-corrects.
|
|
389
|
+
* @param element - the panel's outer box.
|
|
390
|
+
* @param fallback - value to use when no scroller can be found.
|
|
391
|
+
* @returns the target height in px, or null when it is not trustworthy.
|
|
392
|
+
*/
|
|
393
|
+
function fillHeightFor(element, fallback) {
|
|
394
|
+
const rect = element.getBoundingClientRect()
|
|
395
|
+
const scroller = nearestScroller(element)
|
|
396
|
+
const next =
|
|
397
|
+
scroller === null
|
|
398
|
+
? fallback
|
|
399
|
+
: scroller.clientHeight - (scroller.scrollHeight - rect.height)
|
|
400
|
+
return next > 200 ? Math.round(next) : null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The width a drag produces: the column's width at grab time plus the
|
|
405
|
+
* pointer's horizontal travel, clamped to the column's bounds.
|
|
406
|
+
* @param column - the column being dragged.
|
|
407
|
+
* @param startWidth - its width when the drag began.
|
|
408
|
+
* @param startX - pointer x when the drag began.
|
|
409
|
+
* @param currentX - pointer x now.
|
|
410
|
+
* @returns the clamped width.
|
|
411
|
+
*/
|
|
412
|
+
function nextWidth(column, startWidth, startX, currentX) {
|
|
413
|
+
return clampWidth(column, startWidth + (currentX - startX))
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The browser's localStorage, or null where it is unavailable.
|
|
418
|
+
* @returns the store, or null.
|
|
419
|
+
*/
|
|
420
|
+
function defaultStorage() {
|
|
421
|
+
try {
|
|
422
|
+
return typeof localStorage === 'undefined' ? null : localStorage
|
|
423
|
+
} catch {
|
|
424
|
+
// Access itself throws in some sandboxed frames.
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Read the persisted widths, dropping every value that fails validation.
|
|
431
|
+
*
|
|
432
|
+
* Storage is not a trusted input: it may hold a value from an older
|
|
433
|
+
* layout, a hand-edited string, or nothing at all. Anything invalid
|
|
434
|
+
* falls back to the default rather than reaching the style attribute.
|
|
435
|
+
* @param storage - a Storage-like object, or null.
|
|
436
|
+
* @returns the surviving widths.
|
|
437
|
+
*/
|
|
438
|
+
function readWidths(storage) {
|
|
439
|
+
const widths = { ...COLUMN_DEFAULTS }
|
|
440
|
+
if (storage === null || storage === undefined) return widths
|
|
441
|
+
let parsed
|
|
442
|
+
try {
|
|
443
|
+
const raw = storage.getItem(STORAGE_KEY)
|
|
444
|
+
if (raw === null || raw === undefined) return widths
|
|
445
|
+
parsed = JSON.parse(raw)
|
|
446
|
+
} catch {
|
|
447
|
+
return widths
|
|
448
|
+
}
|
|
449
|
+
if (parsed === null || typeof parsed !== 'object') return widths
|
|
450
|
+
for (const column of Object.keys(COLUMN_LIMITS)) {
|
|
451
|
+
const width = clampWidth(column, parsed[column])
|
|
452
|
+
if (width !== null) widths[column] = width
|
|
453
|
+
}
|
|
454
|
+
return widths
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Persist the widths. A storage failure is ignored: losing a panel
|
|
459
|
+
* preference must never break the panel.
|
|
460
|
+
* @param storage - a Storage-like object, or null.
|
|
461
|
+
* @param widths - the widths to store.
|
|
462
|
+
*/
|
|
463
|
+
function writeWidths(storage, widths) {
|
|
464
|
+
if (storage === null || storage === undefined) return
|
|
465
|
+
try {
|
|
466
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(widths))
|
|
467
|
+
} catch {
|
|
468
|
+
/* private mode, quota, disabled storage */
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Prefix that marks a reference string as ours. The shell treats it as opaque. */
|
|
473
|
+
const REF_PREFIX = 'dsh-git:'
|
|
474
|
+
|
|
475
|
+
const FILE_MODES = ['worktree', 'staged', 'untracked']
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Encode a reference to one changed file.
|
|
479
|
+
*
|
|
480
|
+
* The metadata is baked into the reference rather than looked up when the
|
|
481
|
+
* message is sent. The source's `codec.serialize` receives no session, so
|
|
482
|
+
* it cannot resolve a repository root — and a serialize that has to fetch
|
|
483
|
+
* is a serialize that can fail, which blocks the send entirely. Burning
|
|
484
|
+
* the numbers in also makes the model see exactly what the panel showed
|
|
485
|
+
* at pick time. The path is last, so colons inside it are harmless.
|
|
486
|
+
* @param entry - a status file entry.
|
|
487
|
+
* @param mode - which diff the reference means.
|
|
488
|
+
* @returns the opaque reference string.
|
|
489
|
+
*/
|
|
490
|
+
function encodeFileRef(entry, mode) {
|
|
491
|
+
// Porcelain status entries carry `index`/`worktree` ('.' meaning
|
|
492
|
+
// unchanged on that side) while review entries carry a single
|
|
493
|
+
// `status`; take whichever one actually says something, or a
|
|
494
|
+
// worktree reference would go out with no status at all.
|
|
495
|
+
const status =
|
|
496
|
+
[entry.status, entry.index, entry.worktree].find(
|
|
497
|
+
(letter) => typeof letter === 'string' && letter !== '' && letter !== '.',
|
|
498
|
+
) ?? ''
|
|
499
|
+
const fields = [
|
|
500
|
+
REF_PREFIX + 'file',
|
|
501
|
+
mode,
|
|
502
|
+
status,
|
|
503
|
+
Number.isFinite(entry.added) ? entry.added : '',
|
|
504
|
+
Number.isFinite(entry.deleted) ? entry.deleted : '',
|
|
505
|
+
entry.binary === true ? '1' : '0',
|
|
506
|
+
]
|
|
507
|
+
return `${fields.join(':')}:${entry.path}`
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Encode a reference to one commit. The subject is last for the same
|
|
512
|
+
* reason as the path above.
|
|
513
|
+
* @param commit - `{hash, files, subject}`.
|
|
514
|
+
* @returns the opaque reference string.
|
|
515
|
+
*/
|
|
516
|
+
function encodeCommitRef(commit) {
|
|
517
|
+
return `${REF_PREFIX}commit:${commit.hash}:${Number.isFinite(commit.files) ? commit.files : ''}:${commit.subject ?? ''}`
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Decode one of our references.
|
|
522
|
+
*
|
|
523
|
+
* Returns null for anything malformed rather than throwing: the string
|
|
524
|
+
* round-trips through clipboard and persistence, so it is untrusted.
|
|
525
|
+
* @param ref - the reference string.
|
|
526
|
+
* @returns the decoded reference, or null.
|
|
527
|
+
*/
|
|
528
|
+
function decodeRef(ref) {
|
|
529
|
+
if (typeof ref !== 'string' || !ref.startsWith(REF_PREFIX)) return null
|
|
530
|
+
const kind = ref.slice(REF_PREFIX.length, ref.indexOf(':', REF_PREFIX.length))
|
|
531
|
+
if (kind === 'commit') {
|
|
532
|
+
const rest = ref.slice(REF_PREFIX.length + 'commit:'.length)
|
|
533
|
+
const hash = rest.slice(0, rest.indexOf(':'))
|
|
534
|
+
if (!/^[0-9a-f]{4,40}$/.test(hash)) return null
|
|
535
|
+
const tail = rest.slice(hash.length + 1)
|
|
536
|
+
const files = tail.slice(0, tail.indexOf(':'))
|
|
537
|
+
const subject = tail.slice(files.length + 1)
|
|
538
|
+
return {
|
|
539
|
+
kind: 'commit',
|
|
540
|
+
hash,
|
|
541
|
+
files: files === '' ? null : Number(files),
|
|
542
|
+
subject,
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (kind === 'file') {
|
|
546
|
+
const rest = ref.slice(REF_PREFIX.length + 'file:'.length)
|
|
547
|
+
// mode, status, added, deleted, binary, then the path (which may
|
|
548
|
+
// itself contain colons, so it is whatever is left).
|
|
549
|
+
const parts = []
|
|
550
|
+
let cursor = 0
|
|
551
|
+
for (let index = 0; index < 5; index += 1) {
|
|
552
|
+
const at = rest.indexOf(':', cursor)
|
|
553
|
+
if (at === -1) return null
|
|
554
|
+
parts.push(rest.slice(cursor, at))
|
|
555
|
+
cursor = at + 1
|
|
556
|
+
}
|
|
557
|
+
const path = rest.slice(cursor)
|
|
558
|
+
if (!FILE_MODES.includes(parts[0]) || path === '') return null
|
|
559
|
+
return {
|
|
560
|
+
kind: 'file',
|
|
561
|
+
mode: parts[0],
|
|
562
|
+
status: parts[1] === '' ? null : parts[1],
|
|
563
|
+
added: parts[2] === '' ? null : Number(parts[2]),
|
|
564
|
+
deleted: parts[3] === '' ? null : Number(parts[3]),
|
|
565
|
+
binary: parts[4] === '1',
|
|
566
|
+
path,
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return null
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The command that shows a reference's content.
|
|
574
|
+
* @param decoded - a decoded reference.
|
|
575
|
+
* @returns a shell command.
|
|
576
|
+
*/
|
|
577
|
+
function refCommand(decoded) {
|
|
578
|
+
if (decoded.kind === 'commit') return `git show ${decoded.hash.slice(0, 7)}`
|
|
579
|
+
if (decoded.mode === 'staged') return `git diff --cached -- ${decoded.path}`
|
|
580
|
+
if (decoded.mode === 'untracked') return `git diff --no-index /dev/null -- ${decoded.path}`
|
|
581
|
+
return `git diff -- ${decoded.path}`
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The model-facing text for a file reference.
|
|
586
|
+
*
|
|
587
|
+
* Metadata only, by design: the path, what happened to it, how much, and
|
|
588
|
+
* the exact command that shows it. Inlining the diff would spend context
|
|
589
|
+
* on something the model can fetch itself, and one review message can
|
|
590
|
+
* legitimately carry several of these.
|
|
591
|
+
* @param decoded - a decoded file reference.
|
|
592
|
+
* @returns the serialized text.
|
|
593
|
+
*/
|
|
594
|
+
function describeFileRef(decoded) {
|
|
595
|
+
const parts = [`path="${decoded.path}"`]
|
|
596
|
+
if (decoded.status !== null) parts.push(`status="${decoded.status}"`)
|
|
597
|
+
if (decoded.binary) parts.push('binary="true"')
|
|
598
|
+
if (decoded.added !== null) parts.push(`added="${decoded.added}"`)
|
|
599
|
+
if (decoded.deleted !== null) parts.push(`deleted="${decoded.deleted}"`)
|
|
600
|
+
if (decoded.mode === 'staged') parts.push('staged="true"')
|
|
601
|
+
if (decoded.mode === 'untracked') parts.push('untracked="true"')
|
|
602
|
+
return `<git-change ${parts.join(' ')}>Read the file, or run \`${refCommand(decoded)}\`, to see the change.</git-change>`
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The model-facing text for a commit reference.
|
|
607
|
+
* @param decoded - a decoded commit reference.
|
|
608
|
+
* @returns the serialized text.
|
|
609
|
+
*/
|
|
610
|
+
function describeCommitRef(decoded) {
|
|
611
|
+
const parts = [`hash="${decoded.hash.slice(0, 10)}"`]
|
|
612
|
+
// A quote inside an attribute would break the tag, so the subject is
|
|
613
|
+
// flattened rather than escaped.
|
|
614
|
+
if (decoded.subject !== '') parts.push(`subject="${decoded.subject.replace(/"/g, "'")}"`)
|
|
615
|
+
if (decoded.files !== null) parts.push(`files="${decoded.files}"`)
|
|
616
|
+
return `<git-commit ${parts.join(' ')}>Run \`${refCommand(decoded)}\` to see it.</git-commit>`
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The text a person can paste into the composer themselves.
|
|
621
|
+
*
|
|
622
|
+
* This is the affordance that needs no unverified API: the panel only
|
|
623
|
+
* writes to the clipboard, and the user decides whether to send.
|
|
624
|
+
* @param ref - a reference string.
|
|
625
|
+
* @returns the text to copy.
|
|
626
|
+
*/
|
|
627
|
+
function refText(ref) {
|
|
628
|
+
const decoded = decodeRef(ref)
|
|
629
|
+
if (decoded === null) return ref
|
|
630
|
+
return decoded.kind === 'commit' ? describeCommitRef(decoded) : describeFileRef(decoded)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Per-repository review markers, keyed by canonical repository root. */
|
|
634
|
+
const MARKER_KEY = 'dsh-git:review-markers:v1'
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Read the review markers.
|
|
638
|
+
*
|
|
639
|
+
* Storage is untrusted input, exactly like the column widths: anything
|
|
640
|
+
* that fails validation is dropped rather than reaching a request.
|
|
641
|
+
* @param storage - a Storage-like object, or null.
|
|
642
|
+
* @returns markers keyed by repository root.
|
|
643
|
+
*/
|
|
644
|
+
function readMarkers(storage) {
|
|
645
|
+
const markers = {}
|
|
646
|
+
if (storage === null || storage === undefined) return markers
|
|
647
|
+
let parsed
|
|
648
|
+
try {
|
|
649
|
+
const raw = storage.getItem(MARKER_KEY)
|
|
650
|
+
if (raw === null || raw === undefined) return markers
|
|
651
|
+
parsed = JSON.parse(raw)
|
|
652
|
+
} catch {
|
|
653
|
+
return markers
|
|
654
|
+
}
|
|
655
|
+
if (parsed === null || typeof parsed !== 'object') return markers
|
|
656
|
+
for (const [root, entry] of Object.entries(parsed)) {
|
|
657
|
+
if (typeof root !== 'string' || root === '') continue
|
|
658
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
659
|
+
if (typeof entry.hash !== 'string' || entry.hash === '') continue
|
|
660
|
+
markers[root] = { hash: entry.hash, at: Number.isFinite(entry.at) ? entry.at : null }
|
|
661
|
+
}
|
|
662
|
+
return markers
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Persist the review markers. A storage failure is ignored.
|
|
667
|
+
* @param storage - a Storage-like object, or null.
|
|
668
|
+
* @param markers - markers keyed by repository root.
|
|
669
|
+
*/
|
|
670
|
+
function writeMarkers(storage, markers) {
|
|
671
|
+
if (storage === null || storage === undefined) return
|
|
672
|
+
try {
|
|
673
|
+
storage.setItem(MARKER_KEY, JSON.stringify(markers))
|
|
674
|
+
} catch {
|
|
675
|
+
/* private mode, quota, disabled storage */
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Changed lines at which a diff is worth flagging as large. */
|
|
680
|
+
const LARGE_CHANGE_LINES = 200
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Which attention tier a changed file falls in, and why.
|
|
684
|
+
*
|
|
685
|
+
* The reviewer's scarce resource is attention, so a review list is
|
|
686
|
+
* ordered by what most deserves a look rather than by path: a conflict
|
|
687
|
+
* or a deletion can break things outright, a binary cannot be skimmed at
|
|
688
|
+
* all, and a large diff is where mistakes hide.
|
|
689
|
+
* @param file - a review file entry.
|
|
690
|
+
* @returns `{tier, reason}`, where `reason` is a dictionary key or null.
|
|
691
|
+
*/
|
|
692
|
+
function reviewTier(file) {
|
|
693
|
+
if (file.status === 'U') return { tier: 0, reason: 'review.why.conflict' }
|
|
694
|
+
if (file.status === 'D') return { tier: 1, reason: 'review.why.deleted' }
|
|
695
|
+
if (file.binary === true) return { tier: 2, reason: 'review.why.binary' }
|
|
696
|
+
if ((file.added ?? 0) + (file.deleted ?? 0) >= LARGE_CHANGE_LINES) {
|
|
697
|
+
return { tier: 3, reason: 'review.why.large' }
|
|
698
|
+
}
|
|
699
|
+
return { tier: 4, reason: null }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Rank review files by how much attention they deserve.
|
|
704
|
+
* @param files - review file entries.
|
|
705
|
+
* @returns the entries with their tier, most attention-worthy first.
|
|
706
|
+
*/
|
|
707
|
+
function rankReviewFiles(files) {
|
|
708
|
+
return files
|
|
709
|
+
.map((file) => {
|
|
710
|
+
const { tier, reason } = reviewTier(file)
|
|
711
|
+
return { file, tier, reason, churn: (file.added ?? 0) + (file.deleted ?? 0) }
|
|
712
|
+
})
|
|
713
|
+
.sort(
|
|
714
|
+
(left, right) =>
|
|
715
|
+
left.tier - right.tier ||
|
|
716
|
+
right.churn - left.churn ||
|
|
717
|
+
left.file.path.localeCompare(right.file.path),
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* A draggable divider between two columns.
|
|
723
|
+
*
|
|
724
|
+
* A wide invisible hit strip over a hairline: a 1px target would be
|
|
725
|
+
* almost impossible to grab, which is why the first-party layout uses
|
|
726
|
+
* the same trick.
|
|
727
|
+
* @param props - `{ column, active, onStart }`.
|
|
728
|
+
* @returns the splitter element.
|
|
729
|
+
*/
|
|
730
|
+
function Splitter(props) {
|
|
731
|
+
const { column, active, onStart, t } = props
|
|
732
|
+
const [hover, setHover] = React.useState(false)
|
|
733
|
+
const lit = hover || active
|
|
734
|
+
return h('div', {
|
|
735
|
+
style: {
|
|
736
|
+
...S.splitter,
|
|
737
|
+
background: lit ? 'var(--dsw-alias-state-business-primary)' : 'transparent',
|
|
738
|
+
},
|
|
739
|
+
title: t('splitter.title'),
|
|
740
|
+
onPointerEnter: () => setHover(true),
|
|
741
|
+
onPointerLeave: () => setHover(false),
|
|
742
|
+
onPointerDown: (event) => {
|
|
743
|
+
// Without this the drag selects the surrounding text.
|
|
744
|
+
event.preventDefault()
|
|
745
|
+
event.stopPropagation()
|
|
746
|
+
onStart(column, event.clientX)
|
|
747
|
+
},
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const S = {
|
|
752
|
+
root: {
|
|
753
|
+
display: 'flex',
|
|
754
|
+
flexDirection: 'column',
|
|
755
|
+
width: '100%',
|
|
756
|
+
minWidth: 0,
|
|
757
|
+
minHeight: 0,
|
|
758
|
+
boxSizing: 'border-box',
|
|
759
|
+
// The shell sizes this view to its content while a session is
|
|
760
|
+
// active (`.viewArea { flex: 1 0 auto; min-height: auto }`), so
|
|
761
|
+
// `height: 100%` resolves to `auto` and the panel would grow with
|
|
762
|
+
// the commit list until the whole page scrolled. The cap below is
|
|
763
|
+
// what pins it to the window; the panes inside then scroll.
|
|
764
|
+
// Two shell overlays sit above this view and both are dealt with
|
|
765
|
+
// by this one z-index:
|
|
766
|
+
//
|
|
767
|
+
// * the session column's invisible 40px resize strip
|
|
768
|
+
// (`position:absolute; z-index:8; top:0; bottom:0` in the
|
|
769
|
+
// gutter) spans the full width, so it lands ON TOP of the panel
|
|
770
|
+
// and swallows the pointer wherever it crosses;
|
|
771
|
+
// * the composer floats at z-index:7 over the bottom of the view.
|
|
772
|
+
//
|
|
773
|
+
// The git panel wants the full height and has its own commit box,
|
|
774
|
+
// so it deliberately occludes both while this view is selected.
|
|
775
|
+
// Switching back to the conversation view unmounts the panel and both reappear.
|
|
776
|
+
position: 'relative',
|
|
777
|
+
zIndex: 9,
|
|
778
|
+
// A definite height, not a cap. A `max-height` only bounds the
|
|
779
|
+
// panel: with a short worktree the content-sized view area shrank
|
|
780
|
+
// the panel back and the composer reappeared underneath, while the
|
|
781
|
+
// long history list happened to reach the cap and hide it. The
|
|
782
|
+
// measured height below replaces this fallback at runtime; it is
|
|
783
|
+
// what the render test sees.
|
|
784
|
+
height: 'calc(100vh - 96px)',
|
|
785
|
+
// When the shell *does* hand down a definite height, that is the
|
|
786
|
+
// real bound and the viewport estimate must not exceed it.
|
|
787
|
+
maxHeight: '100%',
|
|
788
|
+
// The shell box exists only to win the layering fight, so it stays
|
|
789
|
+
// out of hit-testing; the inner panel is the real surface.
|
|
790
|
+
pointerEvents: 'none',
|
|
791
|
+
// No background here on purpose: the shell box paints nothing.
|
|
792
|
+
},
|
|
793
|
+
panel: {
|
|
794
|
+
display: 'flex',
|
|
795
|
+
flexDirection: 'column',
|
|
796
|
+
flex: '1 1 auto',
|
|
797
|
+
minWidth: 0,
|
|
798
|
+
minHeight: 0,
|
|
799
|
+
overflow: 'hidden',
|
|
800
|
+
pointerEvents: 'auto',
|
|
801
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
802
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
803
|
+
fontSize: 12,
|
|
804
|
+
lineHeight: 1.5,
|
|
805
|
+
},
|
|
806
|
+
header: {
|
|
807
|
+
display: 'flex',
|
|
808
|
+
alignItems: 'center',
|
|
809
|
+
gap: 8,
|
|
810
|
+
padding: '6px 10px',
|
|
811
|
+
borderBottom: '1px solid var(--dsw-alias-border-l2)',
|
|
812
|
+
flexWrap: 'wrap',
|
|
813
|
+
minWidth: 0,
|
|
814
|
+
flex: '0 0 auto',
|
|
815
|
+
},
|
|
816
|
+
branch: { fontWeight: 600, display: 'flex', alignItems: 'center', gap: 4, flex: '0 0 auto' },
|
|
817
|
+
pill: {
|
|
818
|
+
fontSize: 10,
|
|
819
|
+
padding: '1px 6px',
|
|
820
|
+
borderRadius: 999,
|
|
821
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
822
|
+
opacity: 0.85,
|
|
823
|
+
},
|
|
824
|
+
input: {
|
|
825
|
+
flex: '1 1 200px',
|
|
826
|
+
minWidth: 120,
|
|
827
|
+
background: 'transparent',
|
|
828
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
829
|
+
borderRadius: 4,
|
|
830
|
+
color: 'inherit',
|
|
831
|
+
font: 'inherit',
|
|
832
|
+
padding: '3px 6px',
|
|
833
|
+
boxSizing: 'border-box',
|
|
834
|
+
},
|
|
835
|
+
button: {
|
|
836
|
+
background: 'transparent',
|
|
837
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
838
|
+
borderRadius: 4,
|
|
839
|
+
color: 'inherit',
|
|
840
|
+
font: 'inherit',
|
|
841
|
+
padding: '3px 10px',
|
|
842
|
+
cursor: 'pointer',
|
|
843
|
+
flex: '0 0 auto',
|
|
844
|
+
},
|
|
845
|
+
primary: {
|
|
846
|
+
background: 'var(--dsw-alias-button-primary-fill)',
|
|
847
|
+
border: '1px solid transparent',
|
|
848
|
+
borderRadius: 4,
|
|
849
|
+
color: 'var(--dsw-alias-label-primary-inverted)',
|
|
850
|
+
font: 'inherit',
|
|
851
|
+
padding: '3px 12px',
|
|
852
|
+
cursor: 'pointer',
|
|
853
|
+
flex: '0 0 auto',
|
|
854
|
+
},
|
|
855
|
+
tab: {
|
|
856
|
+
background: 'transparent',
|
|
857
|
+
border: 'none',
|
|
858
|
+
borderBottom: '2px solid transparent',
|
|
859
|
+
color: 'inherit',
|
|
860
|
+
font: 'inherit',
|
|
861
|
+
padding: '4px 8px',
|
|
862
|
+
cursor: 'pointer',
|
|
863
|
+
opacity: 0.6,
|
|
864
|
+
},
|
|
865
|
+
tabActive: { opacity: 1, borderBottomColor: 'var(--dsw-alias-button-primary-fill)' },
|
|
866
|
+
body: { display: 'flex', flex: '1 1 auto', minWidth: 0, minHeight: 0 },
|
|
867
|
+
splitter: {
|
|
868
|
+
flex: '0 0 5px',
|
|
869
|
+
alignSelf: 'stretch',
|
|
870
|
+
cursor: 'col-resize',
|
|
871
|
+
// A 5px grab strip with no resting fill; it lights up on hover so
|
|
872
|
+
// the affordance is discoverable without adding a permanent bar.
|
|
873
|
+
opacity: 0.55,
|
|
874
|
+
touchAction: 'none',
|
|
875
|
+
position: 'relative',
|
|
876
|
+
zIndex: 2,
|
|
877
|
+
},
|
|
878
|
+
branches: {
|
|
879
|
+
flex: '0 0 190px',
|
|
880
|
+
minWidth: 0,
|
|
881
|
+
overflow: 'auto',
|
|
882
|
+
borderRight: '1px solid var(--dsw-alias-border-l2)',
|
|
883
|
+
paddingBottom: 12,
|
|
884
|
+
},
|
|
885
|
+
branchRow: {
|
|
886
|
+
display: 'flex',
|
|
887
|
+
alignItems: 'center',
|
|
888
|
+
gap: 4,
|
|
889
|
+
padding: '1px 10px',
|
|
890
|
+
cursor: 'pointer',
|
|
891
|
+
minWidth: 0,
|
|
892
|
+
whiteSpace: 'nowrap',
|
|
893
|
+
},
|
|
894
|
+
branchSelected: { background: 'var(--dsw-alias-interactive-bg-active)' },
|
|
895
|
+
branchIndent: { paddingLeft: 22 },
|
|
896
|
+
branchMark: { flex: '0 0 auto', color: 'var(--dsw-alias-state-business-primary)' },
|
|
897
|
+
branchAction: { textTransform: 'none', letterSpacing: 0, opacity: 0.85 },
|
|
898
|
+
select: {
|
|
899
|
+
flex: '0 1 auto',
|
|
900
|
+
minWidth: 0,
|
|
901
|
+
background: 'transparent',
|
|
902
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
903
|
+
borderRadius: 4,
|
|
904
|
+
color: 'inherit',
|
|
905
|
+
font: 'inherit',
|
|
906
|
+
fontSize: 10,
|
|
907
|
+
padding: '0 2px',
|
|
908
|
+
textTransform: 'none',
|
|
909
|
+
letterSpacing: 0,
|
|
910
|
+
cursor: 'pointer',
|
|
911
|
+
},
|
|
912
|
+
branchInput: {
|
|
913
|
+
flex: '1 1 auto',
|
|
914
|
+
minWidth: 0,
|
|
915
|
+
background: 'transparent',
|
|
916
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
917
|
+
borderRadius: 4,
|
|
918
|
+
color: 'inherit',
|
|
919
|
+
font: 'inherit',
|
|
920
|
+
padding: '1px 5px',
|
|
921
|
+
boxSizing: 'border-box',
|
|
922
|
+
},
|
|
923
|
+
groupRow: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
|
|
924
|
+
commits: {
|
|
925
|
+
flex: '1 1 34%',
|
|
926
|
+
minWidth: 200,
|
|
927
|
+
overflow: 'auto',
|
|
928
|
+
borderRight: '1px solid var(--dsw-alias-border-l2)',
|
|
929
|
+
},
|
|
930
|
+
details: { flex: '1 1 30%', minWidth: 220, minHeight: 0, display: 'flex', flexDirection: 'column' },
|
|
931
|
+
left: {
|
|
932
|
+
flex: '0 0 380px',
|
|
933
|
+
minWidth: 0,
|
|
934
|
+
overflow: 'auto',
|
|
935
|
+
borderRight: '1px solid var(--dsw-alias-border-l2)',
|
|
936
|
+
display: 'flex',
|
|
937
|
+
flexDirection: 'column',
|
|
938
|
+
},
|
|
939
|
+
right: { flex: '1 1 auto', minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' },
|
|
940
|
+
group: {
|
|
941
|
+
padding: '5px 10px 2px',
|
|
942
|
+
opacity: 0.6,
|
|
943
|
+
fontSize: 10,
|
|
944
|
+
textTransform: 'uppercase',
|
|
945
|
+
letterSpacing: 0.5,
|
|
946
|
+
flex: '0 0 auto',
|
|
947
|
+
},
|
|
948
|
+
row: {
|
|
949
|
+
display: 'flex',
|
|
950
|
+
gap: 6,
|
|
951
|
+
alignItems: 'center',
|
|
952
|
+
padding: '2px 10px',
|
|
953
|
+
cursor: 'pointer',
|
|
954
|
+
minWidth: 0,
|
|
955
|
+
},
|
|
956
|
+
rowSelected: { background: 'var(--dsw-alias-interactive-bg-active)' },
|
|
957
|
+
stat: { fontFamily: 'ui-monospace, monospace', flex: '0 0 auto', width: 20, textAlign: 'center' },
|
|
958
|
+
path: { flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
959
|
+
// A file row leads with the NAME and follows with its directory, dimmer
|
|
960
|
+
// — the way IDEA's commit tree does it, and for the same reason: a
|
|
961
|
+
// project has many files in one directory and few files with one
|
|
962
|
+
// name, so the name is what a reader scans for and the directory is
|
|
963
|
+
// context.
|
|
964
|
+
//
|
|
965
|
+
// The directory carries a much larger shrink factor so that a narrow
|
|
966
|
+
// row eats the path before it eats the name.
|
|
967
|
+
fileName: {
|
|
968
|
+
flex: '0 1 auto',
|
|
969
|
+
minWidth: 0,
|
|
970
|
+
overflow: 'hidden',
|
|
971
|
+
textOverflow: 'ellipsis',
|
|
972
|
+
whiteSpace: 'nowrap',
|
|
973
|
+
fontWeight: 500,
|
|
974
|
+
},
|
|
975
|
+
fileDir: {
|
|
976
|
+
flexGrow: 1,
|
|
977
|
+
flexShrink: 20,
|
|
978
|
+
flexBasis: 'auto',
|
|
979
|
+
minWidth: 0,
|
|
980
|
+
overflow: 'hidden',
|
|
981
|
+
textOverflow: 'ellipsis',
|
|
982
|
+
whiteSpace: 'nowrap',
|
|
983
|
+
opacity: 0.6,
|
|
984
|
+
},
|
|
985
|
+
iconButton: {
|
|
986
|
+
background: 'transparent',
|
|
987
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
988
|
+
borderRadius: 3,
|
|
989
|
+
color: 'inherit',
|
|
990
|
+
font: 'inherit',
|
|
991
|
+
fontSize: 10,
|
|
992
|
+
padding: '0 5px',
|
|
993
|
+
cursor: 'pointer',
|
|
994
|
+
flex: '0 0 auto',
|
|
995
|
+
opacity: 0.75,
|
|
996
|
+
},
|
|
997
|
+
commit: { display: 'flex', gap: 6, padding: '2px 10px', alignItems: 'center', cursor: 'pointer', minWidth: 0 },
|
|
998
|
+
hash: { fontFamily: 'ui-monospace, monospace', opacity: 0.65, flex: '0 0 auto' },
|
|
999
|
+
subject: { flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
1000
|
+
meta: { opacity: 0.55, flex: '0 0 auto', fontSize: 11 },
|
|
1001
|
+
diffWrap: { flex: '1 1 auto', minWidth: 0, minHeight: 0, overflow: 'auto' },
|
|
1002
|
+
diffLine: { fontFamily: 'ui-monospace, monospace', padding: '0 10px', whiteSpace: 'pre' },
|
|
1003
|
+
hunkBar: {
|
|
1004
|
+
display: 'flex',
|
|
1005
|
+
alignItems: 'center',
|
|
1006
|
+
gap: 8,
|
|
1007
|
+
padding: '3px 10px',
|
|
1008
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
1009
|
+
position: 'sticky',
|
|
1010
|
+
top: 0,
|
|
1011
|
+
},
|
|
1012
|
+
commitFiles: {
|
|
1013
|
+
flex: '0 0 auto',
|
|
1014
|
+
maxHeight: '38%',
|
|
1015
|
+
overflow: 'auto',
|
|
1016
|
+
borderBottom: '1px solid var(--dsw-alias-border-l2)',
|
|
1017
|
+
},
|
|
1018
|
+
commitHead: {
|
|
1019
|
+
padding: '8px 10px',
|
|
1020
|
+
borderBottom: '1px solid var(--dsw-alias-border-l2)',
|
|
1021
|
+
flex: '0 0 auto',
|
|
1022
|
+
},
|
|
1023
|
+
composer: {
|
|
1024
|
+
display: 'flex',
|
|
1025
|
+
gap: 6,
|
|
1026
|
+
padding: '6px 10px',
|
|
1027
|
+
borderBottom: '1px solid var(--dsw-alias-border-l2)',
|
|
1028
|
+
flex: '0 0 auto',
|
|
1029
|
+
alignItems: 'center',
|
|
1030
|
+
},
|
|
1031
|
+
message: {
|
|
1032
|
+
flex: '1 1 auto',
|
|
1033
|
+
minWidth: 0,
|
|
1034
|
+
background: 'transparent',
|
|
1035
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
1036
|
+
borderRadius: 4,
|
|
1037
|
+
color: 'inherit',
|
|
1038
|
+
font: 'inherit',
|
|
1039
|
+
padding: '3px 6px',
|
|
1040
|
+
boxSizing: 'border-box',
|
|
1041
|
+
},
|
|
1042
|
+
empty: { padding: 16, opacity: 0.6 },
|
|
1043
|
+
error: {
|
|
1044
|
+
margin: 10,
|
|
1045
|
+
padding: '8px 10px',
|
|
1046
|
+
border: '1px solid var(--dsw-alias-state-error-primary)',
|
|
1047
|
+
borderRadius: 4,
|
|
1048
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
1049
|
+
whiteSpace: 'pre-wrap',
|
|
1050
|
+
overflow: 'auto',
|
|
1051
|
+
flex: '0 0 auto',
|
|
1052
|
+
},
|
|
1053
|
+
notice: {
|
|
1054
|
+
padding: '6px 10px',
|
|
1055
|
+
color: 'var(--dsw-alias-state-success-primary)',
|
|
1056
|
+
flex: '0 0 auto',
|
|
1057
|
+
},
|
|
1058
|
+
// The composer's branch chip. Its metrics match the shell's own
|
|
1059
|
+
// composer controls (13px type, 20px line, the secondary label
|
|
1060
|
+
// colour), so it reads as part of that row rather than as a badge
|
|
1061
|
+
// dropped into it.
|
|
1062
|
+
branchChip: {
|
|
1063
|
+
display: 'inline-flex',
|
|
1064
|
+
alignItems: 'center',
|
|
1065
|
+
gap: 5,
|
|
1066
|
+
minWidth: 0,
|
|
1067
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
1068
|
+
fontSize: 13,
|
|
1069
|
+
fontWeight: 500,
|
|
1070
|
+
lineHeight: '20px',
|
|
1071
|
+
// Display only: there is nothing to press, so it must not look
|
|
1072
|
+
// pressable.
|
|
1073
|
+
cursor: 'default',
|
|
1074
|
+
userSelect: 'none',
|
|
1075
|
+
},
|
|
1076
|
+
branchChipIcon: { display: 'inline-flex', flex: '0 0 auto', opacity: 0.85 },
|
|
1077
|
+
branchChipName: {
|
|
1078
|
+
minWidth: 0,
|
|
1079
|
+
// Capped so a long branch name cannot push the composer row
|
|
1080
|
+
// into a second line; the bubble carries the rest.
|
|
1081
|
+
maxWidth: 220,
|
|
1082
|
+
overflow: 'hidden',
|
|
1083
|
+
textOverflow: 'ellipsis',
|
|
1084
|
+
whiteSpace: 'nowrap',
|
|
1085
|
+
},
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* How long the pointer must rest on a cut-off label before its bubble
|
|
1090
|
+
* appears.
|
|
1091
|
+
*
|
|
1092
|
+
* A bubble that pops up the instant the pointer crosses a label is
|
|
1093
|
+
* noise — the pointer is usually on its way somewhere else. The pause is
|
|
1094
|
+
* what separates "passing by" from "I want to read this".
|
|
1095
|
+
*/
|
|
1096
|
+
const BUBBLE_DELAY_MS = 300
|
|
1097
|
+
|
|
1098
|
+
/** The gap between the label and its bubble. */
|
|
1099
|
+
const BUBBLE_GAP = 8
|
|
1100
|
+
|
|
1101
|
+
/** The smallest distance a bubble keeps from the window's edges. */
|
|
1102
|
+
const BUBBLE_MARGIN = 8
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* How often the composer's branch chip re-reads the branch.
|
|
1106
|
+
*
|
|
1107
|
+
* The branch changes under the panel's feet: the agent checks out a
|
|
1108
|
+
* branch in the terminal, or a shell script switches one. A chip that
|
|
1109
|
+
* only read the branch when it mounted would lie for the rest of the
|
|
1110
|
+
* session. The host caches the read and drops it when `.git` changes, so
|
|
1111
|
+
* this poll is a cache hit until something actually moves.
|
|
1112
|
+
*/
|
|
1113
|
+
const BRANCH_POLL_MS = 5000
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The bubble's own look.
|
|
1117
|
+
*
|
|
1118
|
+
* These are the shell's tooltip tokens and metrics, not a new palette.
|
|
1119
|
+
* The shell already draws every tooltip this way — `position: fixed`,
|
|
1120
|
+
* `pointer-events: none`, the tooltip background token, 13px type — so a
|
|
1121
|
+
* bubble built from the same tokens is indistinguishable from the
|
|
1122
|
+
* tooltips around it, in both themes. A colour picked for one theme is
|
|
1123
|
+
* close to invisible in the other, and the tooltip tokens are switched
|
|
1124
|
+
* with the theme.
|
|
1125
|
+
*/
|
|
1126
|
+
const BUBBLE_STYLE = {
|
|
1127
|
+
position: 'fixed',
|
|
1128
|
+
// The shell's own tooltips use this layer; the panel itself sits at
|
|
1129
|
+
// 9, and the bubble lives outside the panel's stacking context
|
|
1130
|
+
// anyway (it is a child of the document body).
|
|
1131
|
+
zIndex: '1100',
|
|
1132
|
+
// The bubble must never steal the pointer: it covers the label the
|
|
1133
|
+
// reader is pointing at.
|
|
1134
|
+
pointerEvents: 'none',
|
|
1135
|
+
boxSizing: 'border-box',
|
|
1136
|
+
width: 'max-content',
|
|
1137
|
+
maxWidth: 'min(50vw, 480px)',
|
|
1138
|
+
maxHeight: 'calc(100vh - 24px)',
|
|
1139
|
+
overflow: 'auto',
|
|
1140
|
+
padding: '3px 7px',
|
|
1141
|
+
borderRadius: 'var(--dsw-radius-sm)',
|
|
1142
|
+
background: 'var(--dsw-alias-tooltip-bg)',
|
|
1143
|
+
color: 'var(--dsw-static-neutral-bluish-00)',
|
|
1144
|
+
fontSize: '13px',
|
|
1145
|
+
lineHeight: '20px',
|
|
1146
|
+
// `pre-line` so a multi-line bubble (a path, a rename, a hash and an
|
|
1147
|
+
// author) keeps its line breaks, while a long single line still wraps.
|
|
1148
|
+
whiteSpace: 'pre-line',
|
|
1149
|
+
overflowWrap: 'break-word',
|
|
1150
|
+
wordBreak: 'break-word',
|
|
1151
|
+
boxShadow: 'var(--dsw-shadow-lv2)',
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** The bubble element, created on the first hover and reused after that. */
|
|
1155
|
+
let bubbleElement = null
|
|
1156
|
+
|
|
1157
|
+
/** The pending bubble, when one is scheduled but not shown yet. */
|
|
1158
|
+
let bubbleTimer = null
|
|
1159
|
+
|
|
1160
|
+
/** The label a pending bubble belongs to. */
|
|
1161
|
+
let bubbleAnchor = null
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* The bubble element, or null when there is no document to put it in.
|
|
1165
|
+
*
|
|
1166
|
+
* The bubble lives in `document.body` rather than inside the panel: every
|
|
1167
|
+
* column of the panel scrolls inside an `overflow: hidden` box, so a
|
|
1168
|
+
* bubble rendered in the tree would be clipped by exactly the panel it
|
|
1169
|
+
* belongs to. `position: fixed` then puts it in viewport coordinates,
|
|
1170
|
+
* which is what the placement arithmetic works in.
|
|
1171
|
+
* @returns the element, or null.
|
|
1172
|
+
*/
|
|
1173
|
+
function ensureBubbleElement() {
|
|
1174
|
+
if (bubbleElement !== null) return bubbleElement
|
|
1175
|
+
if (typeof document === 'undefined' || document === null) return null
|
|
1176
|
+
if (typeof document.createElement !== 'function') return null
|
|
1177
|
+
if (!document.body) return null
|
|
1178
|
+
const element = document.createElement('div')
|
|
1179
|
+
element.setAttribute('data-dsh-git-bubble', '')
|
|
1180
|
+
Object.assign(element.style, BUBBLE_STYLE)
|
|
1181
|
+
element.style.display = 'none'
|
|
1182
|
+
document.body.appendChild(element)
|
|
1183
|
+
bubbleElement = element
|
|
1184
|
+
// A scroll or a resize moves the label out from under the bubble,
|
|
1185
|
+
// which would leave it pointing at nothing; it is dismissed instead.
|
|
1186
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
1187
|
+
window.addEventListener('scroll', () => hideBubble(), true)
|
|
1188
|
+
window.addEventListener('resize', () => hideBubble())
|
|
1189
|
+
}
|
|
1190
|
+
return element
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Whether a label is actually cut off.
|
|
1195
|
+
*
|
|
1196
|
+
* The bubble exists to show what the reader cannot see, so it must not
|
|
1197
|
+
* appear for a label that fits — that would be a bubble repeating text
|
|
1198
|
+
* that is already on the screen. `scrollWidth` is the width the text
|
|
1199
|
+
* wants and `clientWidth` the width it was given; the extra pixel
|
|
1200
|
+
* absorbs the rounding the two are reported with.
|
|
1201
|
+
* @param element - the label.
|
|
1202
|
+
* @returns true when the text overflows its box.
|
|
1203
|
+
*/
|
|
1204
|
+
function isTruncated(element) {
|
|
1205
|
+
if (element === null || element === undefined) return false
|
|
1206
|
+
const scrollWidth = element.scrollWidth
|
|
1207
|
+
const clientWidth = element.clientWidth
|
|
1208
|
+
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false
|
|
1209
|
+
return scrollWidth > clientWidth + 1
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Where the bubble goes.
|
|
1214
|
+
*
|
|
1215
|
+
* Below the label when there is room for it, above it otherwise, and
|
|
1216
|
+
* never past the edge of the window: a bubble the reader cannot see is
|
|
1217
|
+
* worse than no bubble at all.
|
|
1218
|
+
* @param anchor - the label's box, in viewport coordinates.
|
|
1219
|
+
* @param size - the bubble's own size.
|
|
1220
|
+
* @param viewport - the window's size.
|
|
1221
|
+
* @returns `{left, top}` in viewport pixels.
|
|
1222
|
+
*/
|
|
1223
|
+
function bubblePosition(anchor, size, viewport) {
|
|
1224
|
+
const width = Math.min(size.width, Math.max(0, viewport.width - BUBBLE_MARGIN * 2))
|
|
1225
|
+
const height = size.height
|
|
1226
|
+
// A bubble wider than its label is lined up with the label's start
|
|
1227
|
+
// edge. Centring it would push half of it off the window and leave it
|
|
1228
|
+
// pointing at nothing.
|
|
1229
|
+
let left =
|
|
1230
|
+
width >= anchor.width
|
|
1231
|
+
? anchor.left
|
|
1232
|
+
: anchor.left + anchor.width / 2 - width / 2
|
|
1233
|
+
left = Math.max(BUBBLE_MARGIN, Math.min(left, viewport.width - width - BUBBLE_MARGIN))
|
|
1234
|
+
const below = anchor.bottom + BUBBLE_GAP
|
|
1235
|
+
const above = anchor.top - BUBBLE_GAP - height
|
|
1236
|
+
const fitsBelow = below + height <= viewport.height - BUBBLE_MARGIN
|
|
1237
|
+
let top = fitsBelow || above < BUBBLE_MARGIN ? below : above
|
|
1238
|
+
if (top + height > viewport.height - BUBBLE_MARGIN) {
|
|
1239
|
+
top = Math.max(BUBBLE_MARGIN, viewport.height - height - BUBBLE_MARGIN)
|
|
1240
|
+
}
|
|
1241
|
+
return { left, top }
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* The window's size, in the same units as the label's box.
|
|
1246
|
+
* @returns `{width, height}`.
|
|
1247
|
+
*/
|
|
1248
|
+
function viewportSize() {
|
|
1249
|
+
return {
|
|
1250
|
+
width: typeof window === 'undefined' ? 0 : window.innerWidth,
|
|
1251
|
+
height: typeof window === 'undefined' ? 0 : window.innerHeight,
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Move the bubble next to a label.
|
|
1257
|
+
* @param anchor - the label element.
|
|
1258
|
+
*/
|
|
1259
|
+
function positionBubble(anchor) {
|
|
1260
|
+
const element = bubbleElement
|
|
1261
|
+
if (element === null) return
|
|
1262
|
+
const place = bubblePosition(
|
|
1263
|
+
anchor.getBoundingClientRect(),
|
|
1264
|
+
{ width: element.offsetWidth, height: element.offsetHeight },
|
|
1265
|
+
viewportSize(),
|
|
1266
|
+
)
|
|
1267
|
+
element.style.left = `${Math.round(place.left)}px`
|
|
1268
|
+
element.style.top = `${Math.round(place.top)}px`
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Fill the bubble with its text.
|
|
1273
|
+
*
|
|
1274
|
+
* A path has no spaces, so the browser would break it wherever the line
|
|
1275
|
+
* happens to end — often in the middle of a file name. A `<wbr>` after
|
|
1276
|
+
* every slash gives it somewhere better to break: at a directory
|
|
1277
|
+
* boundary. `<wbr>` carries no text of its own, so what the bubble shows
|
|
1278
|
+
* and what a reader copies are both the path.
|
|
1279
|
+
* @param element - the bubble element.
|
|
1280
|
+
* @param text - the text to show.
|
|
1281
|
+
*/
|
|
1282
|
+
function setBubbleText(element, text) {
|
|
1283
|
+
if (typeof document.createTextNode !== 'function') {
|
|
1284
|
+
element.textContent = text
|
|
1285
|
+
return
|
|
1286
|
+
}
|
|
1287
|
+
element.textContent = ''
|
|
1288
|
+
const parts = text.split('/')
|
|
1289
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
1290
|
+
element.appendChild(document.createTextNode(parts[index]))
|
|
1291
|
+
if (index < parts.length - 1) {
|
|
1292
|
+
element.appendChild(document.createTextNode('/'))
|
|
1293
|
+
element.appendChild(document.createElement('wbr'))
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Put the bubble on screen for one label.
|
|
1300
|
+
* @param anchor - the label element.
|
|
1301
|
+
* @param text - the text to show.
|
|
1302
|
+
*/
|
|
1303
|
+
function showBubble(anchor, text) {
|
|
1304
|
+
const element = ensureBubbleElement()
|
|
1305
|
+
if (element === null) return
|
|
1306
|
+
if (typeof text !== 'string' || text === '') return
|
|
1307
|
+
setBubbleText(element, text)
|
|
1308
|
+
element.style.display = 'block'
|
|
1309
|
+
positionBubble(anchor)
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Take the bubble off the screen.
|
|
1314
|
+
*/
|
|
1315
|
+
function hideBubble() {
|
|
1316
|
+
if (bubbleElement !== null) bubbleElement.style.display = 'none'
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Drop the pending bubble and hide the visible one.
|
|
1321
|
+
*/
|
|
1322
|
+
function cancelBubble() {
|
|
1323
|
+
if (bubbleTimer !== null) {
|
|
1324
|
+
clearTimeout(bubbleTimer)
|
|
1325
|
+
bubbleTimer = null
|
|
1326
|
+
}
|
|
1327
|
+
bubbleAnchor = null
|
|
1328
|
+
hideBubble()
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Show a label's bubble, once the pointer has rested on it.
|
|
1333
|
+
* @param anchor - the label element.
|
|
1334
|
+
* @param text - the text to show.
|
|
1335
|
+
* @param always - show it even when the label is not cut off.
|
|
1336
|
+
*/
|
|
1337
|
+
function scheduleBubble(anchor, text, always = false) {
|
|
1338
|
+
cancelBubble()
|
|
1339
|
+
if (typeof setTimeout !== 'function') return
|
|
1340
|
+
bubbleAnchor = anchor
|
|
1341
|
+
bubbleTimer = setTimeout(() => {
|
|
1342
|
+
bubbleTimer = null
|
|
1343
|
+
if (bubbleAnchor !== anchor) return
|
|
1344
|
+
// The check happens now, not on hover: the label may have been
|
|
1345
|
+
// resized in between (a column drag), and a bubble for text that
|
|
1346
|
+
// now fits is noise.
|
|
1347
|
+
if (!always && !isTruncated(anchor)) return
|
|
1348
|
+
showBubble(anchor, text)
|
|
1349
|
+
}, BUBBLE_DELAY_MS)
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* The props that make one label reveal its full text on hover.
|
|
1354
|
+
*
|
|
1355
|
+
* The bubble replaces the native `title` on these labels: both answer the
|
|
1356
|
+
* same hover, and the native one paints over the bubble a second later.
|
|
1357
|
+
* The native tooltip also cannot be styled, cannot show a rename's two
|
|
1358
|
+
* paths at once, and takes seconds to appear.
|
|
1359
|
+
*
|
|
1360
|
+
* The bubble earns its place in one of two ways. Either the label is cut
|
|
1361
|
+
* off, so the bubble shows what the reader cannot see; or the bubble
|
|
1362
|
+
* carries something the row does not show at all — a branch's upstream,
|
|
1363
|
+
* a commit's author — so the bubble is the only place that information
|
|
1364
|
+
* appears. The second case is `extra`, and it shows even when the label
|
|
1365
|
+
* fits.
|
|
1366
|
+
* @param text - the text to reveal.
|
|
1367
|
+
* @param options - `{ extra }`: the text carries something the row does not show.
|
|
1368
|
+
* @returns props to spread onto the label.
|
|
1369
|
+
*/
|
|
1370
|
+
function reveal(text, options = {}) {
|
|
1371
|
+
const always = options.extra === true
|
|
1372
|
+
return {
|
|
1373
|
+
onPointerEnter: (event) => {
|
|
1374
|
+
const anchor = event.currentTarget
|
|
1375
|
+
if (!always && !isTruncated(anchor)) return
|
|
1376
|
+
scheduleBubble(anchor, text, always)
|
|
1377
|
+
},
|
|
1378
|
+
onPointerLeave: () => cancelBubble(),
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* The branch icon.
|
|
1384
|
+
*
|
|
1385
|
+
* Inline SVG rather than an icon from the shell: the icon package is
|
|
1386
|
+
* bundled INTO the first-party clients and is not a module a plugin can
|
|
1387
|
+
* require, so an inline path is the only way to get one without making
|
|
1388
|
+
* the whole panel depend on a package that may not be there. `currentColor`
|
|
1389
|
+
* means it follows the chip's colour, and therefore the theme.
|
|
1390
|
+
* @returns the icon element.
|
|
1391
|
+
*/
|
|
1392
|
+
function BranchIcon() {
|
|
1393
|
+
return h(
|
|
1394
|
+
'svg',
|
|
1395
|
+
{
|
|
1396
|
+
width: 14,
|
|
1397
|
+
height: 14,
|
|
1398
|
+
viewBox: '0 0 24 24',
|
|
1399
|
+
fill: 'none',
|
|
1400
|
+
stroke: 'currentColor',
|
|
1401
|
+
strokeWidth: 1.8,
|
|
1402
|
+
strokeLinecap: 'round',
|
|
1403
|
+
strokeLinejoin: 'round',
|
|
1404
|
+
'aria-hidden': 'true',
|
|
1405
|
+
},
|
|
1406
|
+
h('line', { x1: 6, y1: 3, x2: 6, y2: 15 }),
|
|
1407
|
+
h('circle', { cx: 18, cy: 6, r: 3 }),
|
|
1408
|
+
h('circle', { cx: 6, cy: 18, r: 3 }),
|
|
1409
|
+
h('path', { d: 'M18 9a9 9 0 0 1-9 9' }),
|
|
1410
|
+
)
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* The composer's branch chip: which branch the session's workspace is on.
|
|
1415
|
+
*
|
|
1416
|
+
* Display only. Switching branches is the panel's job — a second place to
|
|
1417
|
+
* do it would be a second way to be wrong, and the composer is where
|
|
1418
|
+
* people write messages, not where they change repositories.
|
|
1419
|
+
*
|
|
1420
|
+
* A directory that is not a repository has no branch, so it has no chip
|
|
1421
|
+
* either. The failure is silence, not an error in the composer: the
|
|
1422
|
+
* composer is not the place to complain about a repository.
|
|
1423
|
+
*
|
|
1424
|
+
* The branch is read from `/branches`, which lists refs and never scans
|
|
1425
|
+
* the working tree — the chip must not cost a `git status` on every
|
|
1426
|
+
* repository the user opens.
|
|
1427
|
+
* @param props - `{ root }`, the session's working directory.
|
|
1428
|
+
* @returns the chip, or nothing.
|
|
1429
|
+
*/
|
|
1430
|
+
function BranchChip(props) {
|
|
1431
|
+
const { root } = props
|
|
1432
|
+
const [branch, setBranch] = React.useState(null)
|
|
1433
|
+
|
|
1434
|
+
React.useEffect(() => {
|
|
1435
|
+
if (typeof root !== 'string' || root === '') {
|
|
1436
|
+
setBranch(null)
|
|
1437
|
+
return undefined
|
|
1438
|
+
}
|
|
1439
|
+
let cancelled = false
|
|
1440
|
+
const read = () => {
|
|
1441
|
+
api('/branches', { root }, fallbackTranslate)
|
|
1442
|
+
.then((data) => {
|
|
1443
|
+
if (cancelled) return
|
|
1444
|
+
// A detached HEAD has no branch name — the host
|
|
1445
|
+
// leaves `head` empty for it — so there is no chip.
|
|
1446
|
+
const head = data?.head
|
|
1447
|
+
setBranch(typeof head === 'string' && head !== '' ? head : null)
|
|
1448
|
+
})
|
|
1449
|
+
.catch(() => {
|
|
1450
|
+
// Not a repository, or the git service is not
|
|
1451
|
+
// reachable. Both mean "no branch to show".
|
|
1452
|
+
if (!cancelled) setBranch(null)
|
|
1453
|
+
})
|
|
1454
|
+
}
|
|
1455
|
+
read()
|
|
1456
|
+
const timer = setInterval(read, BRANCH_POLL_MS)
|
|
1457
|
+
// The user usually comes back to the window right after the
|
|
1458
|
+
// agent switched branches somewhere else.
|
|
1459
|
+
const onFocus = () => read()
|
|
1460
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
1461
|
+
window.addEventListener('focus', onFocus)
|
|
1462
|
+
}
|
|
1463
|
+
return () => {
|
|
1464
|
+
cancelled = true
|
|
1465
|
+
clearInterval(timer)
|
|
1466
|
+
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
|
|
1467
|
+
window.removeEventListener('focus', onFocus)
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}, [root])
|
|
1471
|
+
|
|
1472
|
+
if (branch === null) return null
|
|
1473
|
+
return h(
|
|
1474
|
+
'span',
|
|
1475
|
+
{ style: S.branchChip, 'data-dsh-git-branch': branch },
|
|
1476
|
+
h('span', { style: S.branchChipIcon }, h(BranchIcon)),
|
|
1477
|
+
h('span', { style: S.branchChipName, ...reveal(branch) }, branch),
|
|
1478
|
+
)
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Fetch one read endpoint.
|
|
1483
|
+
* @param route - path under the prefix.
|
|
1484
|
+
* @param params - query parameters; empty values are dropped.
|
|
1485
|
+
* @returns the unwrapped `data` payload.
|
|
1486
|
+
*/
|
|
1487
|
+
async function api(route, params, t = fallbackTranslate) {
|
|
1488
|
+
const search = new URLSearchParams()
|
|
1489
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
1490
|
+
if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
|
|
1491
|
+
}
|
|
1492
|
+
const query = search.toString()
|
|
1493
|
+
let response
|
|
1494
|
+
try {
|
|
1495
|
+
response = await fetch(`${API}${route}${query === '' ? '' : `?${query}`}`)
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
throw new Error(
|
|
1498
|
+
t('error.unreachable', { route: `${API}${route}`, message: error.message }),
|
|
1499
|
+
)
|
|
1500
|
+
}
|
|
1501
|
+
let payload
|
|
1502
|
+
try {
|
|
1503
|
+
payload = await response.json()
|
|
1504
|
+
} catch {
|
|
1505
|
+
throw new Error(t('error.notJson', { route: `${API}${route}`, status: response.status }))
|
|
1506
|
+
}
|
|
1507
|
+
if (payload.ok !== true) throw new Error(payload.error ?? `HTTP ${response.status}`)
|
|
1508
|
+
return payload.data
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Perform one write. Always carries {@link WRITE_HEADER}.
|
|
1513
|
+
* @param route - path under the prefix.
|
|
1514
|
+
* @param body - JSON body.
|
|
1515
|
+
* @returns the unwrapped `data` payload.
|
|
1516
|
+
*/
|
|
1517
|
+
async function write(route, body) {
|
|
1518
|
+
const response = await fetch(`${API}${route}`, {
|
|
1519
|
+
method: 'POST',
|
|
1520
|
+
headers: { 'content-type': 'application/json', [WRITE_HEADER]: '1' },
|
|
1521
|
+
body: JSON.stringify(body),
|
|
1522
|
+
})
|
|
1523
|
+
const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` }))
|
|
1524
|
+
if (payload.ok !== true) throw new Error(payload.error ?? `HTTP ${response.status}`)
|
|
1525
|
+
return payload.data
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Assign a lane to every commit, newest-first.
|
|
1530
|
+
*
|
|
1531
|
+
* A lane holds the hash of the commit it is waiting for. Three rules keep
|
|
1532
|
+
* the graph from widening without bound, and the previous version broke
|
|
1533
|
+
* all three — on a merge-heavy history it grew a new column for almost
|
|
1534
|
+
* every commit until the row text was squeezed to nothing:
|
|
1535
|
+
*
|
|
1536
|
+
* 1. A freed lane is REUSED (first free slot) instead of appending, so a
|
|
1537
|
+
* short-lived branch does not permanently widen the graph. The old
|
|
1538
|
+
* version only trimmed trailing empties, so interior holes were never
|
|
1539
|
+
* reclaimed.
|
|
1540
|
+
* 2. A parent that is not in the loaded window is not awaited at all.
|
|
1541
|
+
* Otherwise every merge whose second parent fell outside the page
|
|
1542
|
+
* held a lane open forever.
|
|
1543
|
+
* 3. When several lanes await the same commit (a merge target), all but
|
|
1544
|
+
* one are cleared as the row is consumed, so the graph converges.
|
|
1545
|
+
*
|
|
1546
|
+
* Lane indices stay stable between consecutive rows, so the caller can
|
|
1547
|
+
* draw straight vertical runs without diagonal connectors.
|
|
1548
|
+
* @param commits - commits newest-first, each with `hash` and `parents`.
|
|
1549
|
+
* @returns one row per commit with its lane and the lane state around it.
|
|
1550
|
+
*/
|
|
1551
|
+
function computeLanes(commits) {
|
|
1552
|
+
const visible = new Set(commits.map((commit) => commit.hash))
|
|
1553
|
+
let lanes = []
|
|
1554
|
+
const rows = []
|
|
1555
|
+
|
|
1556
|
+
/** Take a lane: an emptied one if possible, else a new column. */
|
|
1557
|
+
const acquire = (hash) => {
|
|
1558
|
+
const free = lanes.indexOf(null)
|
|
1559
|
+
if (free !== -1) {
|
|
1560
|
+
lanes[free] = hash
|
|
1561
|
+
return free
|
|
1562
|
+
}
|
|
1563
|
+
lanes.push(hash)
|
|
1564
|
+
return lanes.length - 1
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
for (const commit of commits) {
|
|
1568
|
+
const lanesBefore = lanes.slice()
|
|
1569
|
+
let lane = lanes.indexOf(commit.hash)
|
|
1570
|
+
if (lane === -1) lane = acquire(commit.hash)
|
|
1571
|
+
|
|
1572
|
+
// A merge target can be awaited by more than one lane; this row
|
|
1573
|
+
// consumes them all.
|
|
1574
|
+
for (let i = lanes.length - 1; i >= 0; i -= 1) {
|
|
1575
|
+
if (i !== lane && lanes[i] === commit.hash) lanes[i] = null
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// The first parent carries this lane on. A parent past the end of
|
|
1579
|
+
// the loaded window will never arrive, so the lane is released
|
|
1580
|
+
// rather than left waiting.
|
|
1581
|
+
const first = commit.parents[0]
|
|
1582
|
+
lanes[lane] = first !== undefined && visible.has(first) ? first : null
|
|
1583
|
+
|
|
1584
|
+
for (const parent of commit.parents.slice(1)) {
|
|
1585
|
+
if (!visible.has(parent) || lanes.includes(parent)) continue
|
|
1586
|
+
acquire(parent)
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Two lanes can now await the same parent if this commit's first
|
|
1590
|
+
// parent was already being awaited elsewhere — the branch has
|
|
1591
|
+
// merged, so the duplicate column collapses.
|
|
1592
|
+
const awaited = lanes[lane]
|
|
1593
|
+
if (awaited !== null) {
|
|
1594
|
+
for (let i = lanes.length - 1; i > lane; i -= 1) {
|
|
1595
|
+
if (lanes[i] === awaited) lanes.splice(i, 1)
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop()
|
|
1600
|
+
rows.push({ commit, lane, lanesBefore, lanesAfter: lanes.slice() })
|
|
1601
|
+
}
|
|
1602
|
+
return rows
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* The lane gutter for one commit row.
|
|
1607
|
+
* @param props - `{ row }`.
|
|
1608
|
+
* @returns the gutter element.
|
|
1609
|
+
*/
|
|
1610
|
+
function LaneGraph(props) {
|
|
1611
|
+
const { row } = props
|
|
1612
|
+
const width = Math.max(row.lanesBefore.length, row.lanesAfter.length, row.lane + 1)
|
|
1613
|
+
const cells = []
|
|
1614
|
+
for (let i = 0; i < width; i += 1) {
|
|
1615
|
+
const color = LANE_COLORS[i % LANE_COLORS.length]
|
|
1616
|
+
const isCommit = i === row.lane
|
|
1617
|
+
const live = row.lanesBefore[i] != null || row.lanesAfter[i] != null
|
|
1618
|
+
cells.push(
|
|
1619
|
+
h(
|
|
1620
|
+
'div',
|
|
1621
|
+
{ key: i, style: { position: 'relative', width: 13, height: 18, flex: '0 0 auto' } },
|
|
1622
|
+
live && !isCommit
|
|
1623
|
+
? h('div', {
|
|
1624
|
+
style: { position: 'absolute', left: 6, top: 0, bottom: 0, width: 2, background: color, opacity: 0.45 },
|
|
1625
|
+
})
|
|
1626
|
+
: null,
|
|
1627
|
+
isCommit
|
|
1628
|
+
? h('div', {
|
|
1629
|
+
style: {
|
|
1630
|
+
position: 'absolute',
|
|
1631
|
+
left: 3,
|
|
1632
|
+
top: 5,
|
|
1633
|
+
width: 8,
|
|
1634
|
+
height: 8,
|
|
1635
|
+
borderRadius: '50%',
|
|
1636
|
+
background: color,
|
|
1637
|
+
boxShadow: `0 0 0 2px color-mix(in srgb, ${color} 20%, transparent)`,
|
|
1638
|
+
},
|
|
1639
|
+
})
|
|
1640
|
+
: null,
|
|
1641
|
+
),
|
|
1642
|
+
)
|
|
1643
|
+
}
|
|
1644
|
+
return h('div', { style: { display: 'flex', flex: '0 0 auto' } }, cells)
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* The directory the given session is working in.
|
|
1649
|
+
*
|
|
1650
|
+
* This is the whole of the session ↔ repository binding: the slot hands
|
|
1651
|
+
* the active session id to the contribution's `inject`, and the session
|
|
1652
|
+
* summary carries the `cwd` it was started with. Resolved defensively —
|
|
1653
|
+
* an older shell without the summary shape must degrade to a manually
|
|
1654
|
+
* entered path, not throw in the slot.
|
|
1655
|
+
* @param ctx - client root context.
|
|
1656
|
+
* @param sessionId - the slot's active session id.
|
|
1657
|
+
* @returns the session's working directory, or null.
|
|
1658
|
+
*/
|
|
1659
|
+
function sessionCwd(ctx, sessionId) {
|
|
1660
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null
|
|
1661
|
+
try {
|
|
1662
|
+
const snapshot = ctx.sessions?.list?.getSnapshot?.()
|
|
1663
|
+
const summary = snapshot?.byId?.[sessionId]
|
|
1664
|
+
if (typeof summary?.cwd === 'string' && summary.cwd !== '') return summary.cwd
|
|
1665
|
+
} catch {
|
|
1666
|
+
/* fall through to the manual path */
|
|
1667
|
+
}
|
|
1668
|
+
return null
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Candidates for the `@` menu: the changed files of this session's
|
|
1673
|
+
* repository, then its recent commits.
|
|
1674
|
+
*
|
|
1675
|
+
* Failure is silence, not an error: a menu that cannot load simply offers
|
|
1676
|
+
* nothing from this source, and the other `@` sources still work.
|
|
1677
|
+
* @param ctx - client root context.
|
|
1678
|
+
* @param session - the session projection the menu is open for.
|
|
1679
|
+
* @param req - the live query.
|
|
1680
|
+
* @param t - translate function.
|
|
1681
|
+
* @returns menu candidates, most relevant first.
|
|
1682
|
+
*/
|
|
1683
|
+
async function gitCandidates(ctx, session, req, t) {
|
|
1684
|
+
const root = sessionCwd(ctx, session?.sessionId)
|
|
1685
|
+
if (root === null) return []
|
|
1686
|
+
const query = String(req?.query ?? '').toLowerCase()
|
|
1687
|
+
let status
|
|
1688
|
+
let log
|
|
1689
|
+
try {
|
|
1690
|
+
;[status, log] = await Promise.all([
|
|
1691
|
+
api('/status', { root }, fallbackTranslate),
|
|
1692
|
+
api('/log', { root, limit: 30 }, fallbackTranslate),
|
|
1693
|
+
])
|
|
1694
|
+
} catch {
|
|
1695
|
+
return []
|
|
1696
|
+
}
|
|
1697
|
+
const matches = (text) => query === '' || String(text).toLowerCase().includes(query)
|
|
1698
|
+
const files = []
|
|
1699
|
+
for (const [label, group, mode] of [
|
|
1700
|
+
['group.conflicted', status.conflicted, 'worktree'],
|
|
1701
|
+
['group.staged', status.staged, 'staged'],
|
|
1702
|
+
['group.unstaged', status.unstaged, 'worktree'],
|
|
1703
|
+
['group.untracked', status.untracked, 'untracked'],
|
|
1704
|
+
]) {
|
|
1705
|
+
for (const entry of group ?? []) {
|
|
1706
|
+
if (!matches(entry.path)) continue
|
|
1707
|
+
files.push({
|
|
1708
|
+
name: entry.path,
|
|
1709
|
+
description: t(label),
|
|
1710
|
+
section: t('handoff.files'),
|
|
1711
|
+
value: encodeFileRef(entry, mode),
|
|
1712
|
+
})
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
const commits = (log.commits ?? [])
|
|
1716
|
+
.filter((commit) => matches(commit.subject) || matches(commit.hash))
|
|
1717
|
+
.map((commit) => ({
|
|
1718
|
+
name: `${commit.hash.slice(0, 7)} ${commit.subject}`,
|
|
1719
|
+
description: commit.author,
|
|
1720
|
+
section: t('handoff.commits'),
|
|
1721
|
+
value: encodeCommitRef({ hash: commit.hash, subject: commit.subject, files: null }),
|
|
1722
|
+
}))
|
|
1723
|
+
return [...files.slice(0, 30), ...commits]
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Split a unified diff into its file header and its hunks. Mirrors
|
|
1728
|
+
* `splitHunks` in the host half; the hunk indices sent back must line up
|
|
1729
|
+
* with the ones the host derives from the same text.
|
|
1730
|
+
* @param text - a unified diff.
|
|
1731
|
+
* @returns the header lines and one string per `@@` hunk.
|
|
1732
|
+
*/
|
|
1733
|
+
function splitHunks(text) {
|
|
1734
|
+
const header = []
|
|
1735
|
+
const hunks = []
|
|
1736
|
+
let current = null
|
|
1737
|
+
for (const line of text.split('\n')) {
|
|
1738
|
+
if (line.startsWith('@@')) {
|
|
1739
|
+
if (current !== null) hunks.push(current.join('\n'))
|
|
1740
|
+
current = [line]
|
|
1741
|
+
continue
|
|
1742
|
+
}
|
|
1743
|
+
if (current === null) header.push(line)
|
|
1744
|
+
else current.push(line)
|
|
1745
|
+
}
|
|
1746
|
+
if (current !== null) hunks.push(current.join('\n'))
|
|
1747
|
+
return { header, hunks: hunks.map((hunk) => hunk.replace(/\n+$/, '')) }
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Colourised diff lines.
|
|
1752
|
+
* @param props - `{ text }`.
|
|
1753
|
+
* @returns the lines.
|
|
1754
|
+
*/
|
|
1755
|
+
function DiffLines(props) {
|
|
1756
|
+
return h(
|
|
1757
|
+
'div',
|
|
1758
|
+
null,
|
|
1759
|
+
props.text.split('\n').map((line, index) => {
|
|
1760
|
+
const isMeta =
|
|
1761
|
+
line.startsWith('+++') ||
|
|
1762
|
+
line.startsWith('---') ||
|
|
1763
|
+
line.startsWith('diff ') ||
|
|
1764
|
+
line.startsWith('index ') ||
|
|
1765
|
+
line.startsWith('similarity ') ||
|
|
1766
|
+
line.startsWith('rename ') ||
|
|
1767
|
+
line.startsWith('new file ') ||
|
|
1768
|
+
line.startsWith('deleted file ')
|
|
1769
|
+
const isHunk = line.startsWith('@@')
|
|
1770
|
+
const isAdd = line.startsWith('+') && !isMeta
|
|
1771
|
+
const isDel = line.startsWith('-') && !isMeta
|
|
1772
|
+
return h(
|
|
1773
|
+
'div',
|
|
1774
|
+
{
|
|
1775
|
+
key: index,
|
|
1776
|
+
style: {
|
|
1777
|
+
...S.diffLine,
|
|
1778
|
+
color: isMeta
|
|
1779
|
+
? 'var(--dsw-alias-label-tertiary)'
|
|
1780
|
+
: isHunk
|
|
1781
|
+
? 'var(--dsw-alias-state-business-primary)'
|
|
1782
|
+
: isAdd
|
|
1783
|
+
? 'var(--dsw-alias-state-success-primary)'
|
|
1784
|
+
: isDel
|
|
1785
|
+
? 'var(--dsw-alias-state-error-primary)'
|
|
1786
|
+
: 'inherit',
|
|
1787
|
+
// A tint of the same token rather than a fixed rgba, so the
|
|
1788
|
+
// line background tracks the theme too. `color-mix` is the
|
|
1789
|
+
// pattern the shell's own stylesheets use.
|
|
1790
|
+
background: isAdd
|
|
1791
|
+
? 'color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent)'
|
|
1792
|
+
: isDel
|
|
1793
|
+
? 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)'
|
|
1794
|
+
: 'transparent',
|
|
1795
|
+
},
|
|
1796
|
+
},
|
|
1797
|
+
line === '' ? ' ' : line,
|
|
1798
|
+
)
|
|
1799
|
+
}),
|
|
1800
|
+
)
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* A diff, split so each hunk carries its own action.
|
|
1805
|
+
* @param props - `{ text, empty, actions }`, where `actions` is
|
|
1806
|
+
* `{label, title, run(indices)}` or null for a read-only diff.
|
|
1807
|
+
* @returns the pane.
|
|
1808
|
+
*/
|
|
1809
|
+
function DiffPane(props) {
|
|
1810
|
+
const { text, empty, actions, t } = props
|
|
1811
|
+
if (!text) return h('div', { style: S.empty }, empty ?? t('empty.pickOne'))
|
|
1812
|
+
const { header, hunks } = splitHunks(text)
|
|
1813
|
+
if (hunks.length === 0) return h(DiffLines, { text })
|
|
1814
|
+
return h(
|
|
1815
|
+
'div',
|
|
1816
|
+
null,
|
|
1817
|
+
h(DiffLines, { text: header.join('\n') }),
|
|
1818
|
+
hunks.map((hunk, index) =>
|
|
1819
|
+
h(
|
|
1820
|
+
'div',
|
|
1821
|
+
{ key: index },
|
|
1822
|
+
actions === null
|
|
1823
|
+
? null
|
|
1824
|
+
: h(
|
|
1825
|
+
'div',
|
|
1826
|
+
{ style: S.hunkBar },
|
|
1827
|
+
h(
|
|
1828
|
+
'button',
|
|
1829
|
+
{ style: S.iconButton, title: actions.title, onClick: () => actions.run([index]) },
|
|
1830
|
+
actions.label,
|
|
1831
|
+
),
|
|
1832
|
+
h('span', { style: S.meta }, t('hunk.header', { index: index + 1, total: hunks.length })),
|
|
1833
|
+
),
|
|
1834
|
+
h(DiffLines, { text: hunk }),
|
|
1835
|
+
),
|
|
1836
|
+
),
|
|
1837
|
+
)
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* Split a repository-relative path into its file name and its directory.
|
|
1842
|
+
*
|
|
1843
|
+
* The panel shows the name first and the directory after it, dimmer, so
|
|
1844
|
+
* that a row reads `AnalyseManager.java src/main/java/…/analyse` rather
|
|
1845
|
+
* than one long path where the name is buried at the end. A file at the
|
|
1846
|
+
* repository root has no directory, and shows none.
|
|
1847
|
+
* @param path - a repository-relative path.
|
|
1848
|
+
* @returns `{name, dir}`; `dir` is `''` for a file at the root.
|
|
1849
|
+
*/
|
|
1850
|
+
function splitNameDir(path) {
|
|
1851
|
+
const text = typeof path === 'string' ? path : ''
|
|
1852
|
+
const slash = text.lastIndexOf('/')
|
|
1853
|
+
return slash === -1
|
|
1854
|
+
? { name: text, dir: '' }
|
|
1855
|
+
: { name: text.slice(slash + 1), dir: text.slice(0, slash) }
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Relative time, coarse on purpose; the exact timestamp is in the title.
|
|
1860
|
+
* @param ms - epoch milliseconds.
|
|
1861
|
+
* @returns a short human string.
|
|
1862
|
+
*/
|
|
1863
|
+
function relativeTime(ms, t) {
|
|
1864
|
+
const seconds = Math.max(0, (Date.now() - ms) / 1000)
|
|
1865
|
+
if (seconds < 60) return t('stamp.justNow')
|
|
1866
|
+
if (seconds < 3600) return t('stamp.minutes', { count: Math.floor(seconds / 60) })
|
|
1867
|
+
if (seconds < 86400) return t('stamp.hours', { count: Math.floor(seconds / 3600) })
|
|
1868
|
+
if (seconds < 2592000) return t('stamp.days', { count: Math.floor(seconds / 86400) })
|
|
1869
|
+
return new Date(ms).toISOString().slice(0, 10)
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* One changed file row, with the action that moves it between the index
|
|
1874
|
+
* and the worktree.
|
|
1875
|
+
* @param props - `{ file, selected, onOpen, onAction, action }`.
|
|
1876
|
+
* @returns the row element.
|
|
1877
|
+
*/
|
|
1878
|
+
function FileRow(props) {
|
|
1879
|
+
// `onAction`, when present, is the descriptor `{label, title, run}` for
|
|
1880
|
+
// this row's index/worktree toggle. It used to be read from a second
|
|
1881
|
+
// destructured name that no caller ever passed, so every row with an
|
|
1882
|
+
// action threw on `undefined.title` during render.
|
|
1883
|
+
const { file, selected, onOpen, onAction, onCopy, t } = props
|
|
1884
|
+
// Two shapes arrive here: working-tree entries carry `index`/`worktree`
|
|
1885
|
+
// (where '.' means "unchanged on that side"), commit entries carry a
|
|
1886
|
+
// single `status`. Pick the side that actually changed, or every
|
|
1887
|
+
// unstaged file would render as a bare ".".
|
|
1888
|
+
const letter =
|
|
1889
|
+
file.status ??
|
|
1890
|
+
[file.index, file.worktree].find((side) => side !== undefined && side !== '.' && side !== ' ') ??
|
|
1891
|
+
file.index ??
|
|
1892
|
+
'?'
|
|
1893
|
+
const [color, labelKey] = STATUS[letter] ?? ['var(--dsw-alias-label-tertiary)', '']
|
|
1894
|
+
// Name first, directory after it and dimmer — see `splitNameDir`.
|
|
1895
|
+
const { name: fileName, dir: fileDir } = splitNameDir(file.path)
|
|
1896
|
+
// The bubble shows the whole path, which is what the row splits into
|
|
1897
|
+
// two pieces; a rename adds the path it came from.
|
|
1898
|
+
const fullPath = file.origPath == null ? file.path : `${file.origPath} → ${file.path}`
|
|
1899
|
+
return h(
|
|
1900
|
+
'div',
|
|
1901
|
+
{
|
|
1902
|
+
style: { ...S.row, ...(selected ? S.rowSelected : null) },
|
|
1903
|
+
onClick: onOpen,
|
|
1904
|
+
},
|
|
1905
|
+
h('span', { style: { ...S.stat, color }, title: labelKey === '' ? '' : t(labelKey) }, letter),
|
|
1906
|
+
h('span', { style: S.fileName, ...reveal(fullPath) }, fileName),
|
|
1907
|
+
fileDir === '' ? null : h('span', { style: S.fileDir, ...reveal(fullPath) }, fileDir),
|
|
1908
|
+
file.origPath == null ? null : h('span', { style: S.meta }, `← ${file.origPath}`),
|
|
1909
|
+
onCopy === undefined
|
|
1910
|
+
? null
|
|
1911
|
+
: h(
|
|
1912
|
+
'button',
|
|
1913
|
+
{
|
|
1914
|
+
style: { ...S.iconButton, ...S.branchAction },
|
|
1915
|
+
title: t('handoff.copyTitle'),
|
|
1916
|
+
onClick: (event) => {
|
|
1917
|
+
event.stopPropagation()
|
|
1918
|
+
onCopy()
|
|
1919
|
+
},
|
|
1920
|
+
},
|
|
1921
|
+
t('handoff.copy'),
|
|
1922
|
+
),
|
|
1923
|
+
onAction === undefined
|
|
1924
|
+
? null
|
|
1925
|
+
: h(
|
|
1926
|
+
'button',
|
|
1927
|
+
{
|
|
1928
|
+
style: S.iconButton,
|
|
1929
|
+
title: onAction.title,
|
|
1930
|
+
onClick: (event) => {
|
|
1931
|
+
// Without this the row's own click (open the diff)
|
|
1932
|
+
// would also fire.
|
|
1933
|
+
event.stopPropagation()
|
|
1934
|
+
onAction.run()
|
|
1935
|
+
},
|
|
1936
|
+
},
|
|
1937
|
+
onAction.label,
|
|
1938
|
+
),
|
|
1939
|
+
)
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* The git panel.
|
|
1944
|
+
* @returns the panel element.
|
|
1945
|
+
*/
|
|
1946
|
+
function GitView(props) {
|
|
1947
|
+
// The repository the current session works in. An explicitly typed
|
|
1948
|
+
// path overrides it; otherwise the panel follows the session.
|
|
1949
|
+
const sessionRoot = typeof props?.sessionRoot === 'string' ? props.sessionRoot : ''
|
|
1950
|
+
// The shell injects a bound `t` (see the slot descriptor in `apply`);
|
|
1951
|
+
// the fallback keeps a bare render working.
|
|
1952
|
+
const t = typeof props?.t === 'function' ? props.t : fallbackTranslate
|
|
1953
|
+
const [rootInput, setRootInput] = React.useState('')
|
|
1954
|
+
const [appliedRoot, setAppliedRoot] = React.useState('')
|
|
1955
|
+
const [mode, setMode] = React.useState('review')
|
|
1956
|
+
// Declared after `mode` on purpose: hook order is positional, and the
|
|
1957
|
+
// render test seeds the mode slot by index.
|
|
1958
|
+
const [branches, setBranches] = React.useState({ local: [], remote: [], head: null })
|
|
1959
|
+
const [ref, setRef] = React.useState('')
|
|
1960
|
+
const [status, setStatus] = React.useState(null)
|
|
1961
|
+
const [commits, setCommits] = React.useState([])
|
|
1962
|
+
const [file, setFile] = React.useState(null)
|
|
1963
|
+
const [fileDiff, setFileDiff] = React.useState('')
|
|
1964
|
+
const [commit, setCommit] = React.useState(null)
|
|
1965
|
+
const [commitDetail, setCommitDetail] = React.useState(null)
|
|
1966
|
+
const [commitFile, setCommitFile] = React.useState(null)
|
|
1967
|
+
const [commitDiff, setCommitDiff] = React.useState('')
|
|
1968
|
+
const [message, setMessage] = React.useState('')
|
|
1969
|
+
const [error, setError] = React.useState(null)
|
|
1970
|
+
const [notice, setNotice] = React.useState(null)
|
|
1971
|
+
const [busy, setBusy] = React.useState(false)
|
|
1972
|
+
// Declared last so the render test's seeded indices stay valid.
|
|
1973
|
+
const [widths, setWidths] = React.useState(() => readWidths(defaultStorage()))
|
|
1974
|
+
const [drag, setDrag] = React.useState(null)
|
|
1975
|
+
const [fillHeight, setFillHeight] = React.useState(null)
|
|
1976
|
+
// Hook order is positional; this one is last on purpose so the render
|
|
1977
|
+
// test's seeded indices (mode at 2, file/fileDiff at 7/8) stay valid.
|
|
1978
|
+
const rootRef = React.useRef(null)
|
|
1979
|
+
// `null` means the new-branch input is closed.
|
|
1980
|
+
const [newBranch, setNewBranch] = React.useState(null)
|
|
1981
|
+
// Bumped to force a reload when the ref filter did not change (creating a
|
|
1982
|
+
// branch from HEAD leaves `ref` at ''), so the reload can never be a
|
|
1983
|
+
// second, stale-ref fetch racing the effect's own.
|
|
1984
|
+
const [reloadToken, setReloadToken] = React.useState(0)
|
|
1985
|
+
const [pullStrategy, setPullStrategy] = React.useState('ff-only')
|
|
1986
|
+
const [markers, setMarkers] = React.useState(() => readMarkers(defaultStorage()))
|
|
1987
|
+
const [review, setReview] = React.useState(null)
|
|
1988
|
+
|
|
1989
|
+
// Empty `appliedRoot` means "follow the session"; a typed path pins it.
|
|
1990
|
+
const effectiveRoot = appliedRoot !== '' ? appliedRoot : sessionRoot
|
|
1991
|
+
|
|
1992
|
+
const fail = React.useCallback((failure) => {
|
|
1993
|
+
setError(failure instanceof Error ? failure.message : String(failure))
|
|
1994
|
+
}, [])
|
|
1995
|
+
|
|
1996
|
+
const load = React.useCallback(async () => {
|
|
1997
|
+
setBusy(true)
|
|
1998
|
+
try {
|
|
1999
|
+
const [nextStatus, nextLog, nextBranches] = await Promise.all([
|
|
2000
|
+
api('/status', { root: effectiveRoot }, t),
|
|
2001
|
+
api('/log', { root: effectiveRoot, limit: 200, ref }, t),
|
|
2002
|
+
api('/branches', { root: effectiveRoot }, t),
|
|
2003
|
+
])
|
|
2004
|
+
// Every array is coerced, and its absence is reported. An
|
|
2005
|
+
// undefined array here would throw while rendering the file
|
|
2006
|
+
// groups — *after* the error branch has already been passed —
|
|
2007
|
+
// and React would unmount the whole panel, leaving a blank
|
|
2008
|
+
// view with no message anywhere.
|
|
2009
|
+
const missing = ['staged', 'unstaged', 'untracked', 'conflicted'].filter(
|
|
2010
|
+
(key) => !Array.isArray(nextStatus[key]),
|
|
2011
|
+
)
|
|
2012
|
+
if (missing.length > 0) {
|
|
2013
|
+
throw new Error(t('error.badStatus', { fields: missing.join(', ') }))
|
|
2014
|
+
}
|
|
2015
|
+
setStatus({
|
|
2016
|
+
...nextStatus,
|
|
2017
|
+
staged: nextStatus.staged,
|
|
2018
|
+
unstaged: nextStatus.unstaged,
|
|
2019
|
+
untracked: nextStatus.untracked,
|
|
2020
|
+
conflicted: nextStatus.conflicted,
|
|
2021
|
+
})
|
|
2022
|
+
setCommits(Array.isArray(nextLog.commits) ? nextLog.commits : [])
|
|
2023
|
+
setBranches({
|
|
2024
|
+
local: Array.isArray(nextBranches.local) ? nextBranches.local : [],
|
|
2025
|
+
remote: Array.isArray(nextBranches.remote) ? nextBranches.remote : [],
|
|
2026
|
+
head: nextBranches.head ?? null,
|
|
2027
|
+
})
|
|
2028
|
+
setError(null)
|
|
2029
|
+
setRootInput((current) => (current === '' ? nextStatus.root : current))
|
|
2030
|
+
} catch (failure) {
|
|
2031
|
+
fail(failure)
|
|
2032
|
+
setStatus(null)
|
|
2033
|
+
setCommits([])
|
|
2034
|
+
} finally {
|
|
2035
|
+
setBusy(false)
|
|
2036
|
+
}
|
|
2037
|
+
}, [effectiveRoot, fail, ref, reloadToken])
|
|
2038
|
+
|
|
2039
|
+
React.useEffect(() => {
|
|
2040
|
+
void load()
|
|
2041
|
+
}, [load])
|
|
2042
|
+
|
|
2043
|
+
// The resize listener lives on window, not on the splitter, so the
|
|
2044
|
+
// pointer can leave the 5px strip without dropping the drag.
|
|
2045
|
+
React.useEffect(() => {
|
|
2046
|
+
if (drag === null) return undefined
|
|
2047
|
+
const onMove = (event) => {
|
|
2048
|
+
const next = nextWidth(drag.column, drag.startWidth, drag.startX, event.clientX)
|
|
2049
|
+
if (next !== null) setWidths((current) => ({ ...current, [drag.column]: next }))
|
|
2050
|
+
}
|
|
2051
|
+
const onEnd = () => setDrag(null)
|
|
2052
|
+
window.addEventListener('pointermove', onMove)
|
|
2053
|
+
window.addEventListener('pointerup', onEnd)
|
|
2054
|
+
window.addEventListener('pointercancel', onEnd)
|
|
2055
|
+
return () => {
|
|
2056
|
+
window.removeEventListener('pointermove', onMove)
|
|
2057
|
+
window.removeEventListener('pointerup', onEnd)
|
|
2058
|
+
window.removeEventListener('pointercancel', onEnd)
|
|
2059
|
+
}
|
|
2060
|
+
}, [drag])
|
|
2061
|
+
|
|
2062
|
+
// Fill from this view's own top edge to the bottom of the window, so
|
|
2063
|
+
// the panel occludes the composer regardless of how short its content
|
|
2064
|
+
// is. Measuring beats a hard-coded header height: the session header
|
|
2065
|
+
// above this view is not a fixed size.
|
|
2066
|
+
React.useLayoutEffect(() => {
|
|
2067
|
+
const element = rootRef.current
|
|
2068
|
+
if (element === null || typeof window === 'undefined' || typeof window.addEventListener !== 'function') {
|
|
2069
|
+
return undefined
|
|
2070
|
+
}
|
|
2071
|
+
const measure = () => {
|
|
2072
|
+
const fallback = window.innerHeight - element.getBoundingClientRect().top
|
|
2073
|
+
// A nonsense measurement (hidden view, zero-height frame) is
|
|
2074
|
+
// ignored rather than collapsed into the style.
|
|
2075
|
+
setFillHeight(fillHeightFor(element, fallback))
|
|
2076
|
+
}
|
|
2077
|
+
measure()
|
|
2078
|
+
window.addEventListener('resize', measure)
|
|
2079
|
+
return () => window.removeEventListener('resize', measure)
|
|
2080
|
+
}, [])
|
|
2081
|
+
|
|
2082
|
+
// The marker is keyed by the CANONICAL root the host echoed back, not by
|
|
2083
|
+
// whatever path was typed: the same repository must keep one marker.
|
|
2084
|
+
const reviewMarker = status?.root === undefined ? null : (markers[status.root] ?? null)
|
|
2085
|
+
|
|
2086
|
+
React.useEffect(() => {
|
|
2087
|
+
const root = status?.root
|
|
2088
|
+
if (mode !== 'review' || root === undefined || reviewMarker === null) {
|
|
2089
|
+
setReview(null)
|
|
2090
|
+
return undefined
|
|
2091
|
+
}
|
|
2092
|
+
let cancelled = false
|
|
2093
|
+
setReview(null)
|
|
2094
|
+
api('/review', { root, since: reviewMarker.hash }, t)
|
|
2095
|
+
.then((data) => {
|
|
2096
|
+
if (!cancelled) setReview(data)
|
|
2097
|
+
})
|
|
2098
|
+
.catch((failure) => {
|
|
2099
|
+
if (!cancelled) fail(failure)
|
|
2100
|
+
})
|
|
2101
|
+
return () => {
|
|
2102
|
+
cancelled = true
|
|
2103
|
+
}
|
|
2104
|
+
}, [mode, status?.root, reviewMarker?.hash, reloadToken, fail, t])
|
|
2105
|
+
|
|
2106
|
+
// A different session means a different repository, so a pinned path is
|
|
2107
|
+
// dropped and the panel follows the new session.
|
|
2108
|
+
React.useEffect(() => {
|
|
2109
|
+
setAppliedRoot('')
|
|
2110
|
+
setRootInput('')
|
|
2111
|
+
}, [sessionRoot])
|
|
2112
|
+
|
|
2113
|
+
// Persisted once per completed drag, not on every pointermove.
|
|
2114
|
+
React.useEffect(() => {
|
|
2115
|
+
if (drag === null) writeWidths(defaultStorage(), widths)
|
|
2116
|
+
}, [drag, widths])
|
|
2117
|
+
|
|
2118
|
+
/**
|
|
2119
|
+
* Wrap the panel's content.
|
|
2120
|
+
*
|
|
2121
|
+
* The outer box is the shell-facing one: it carries the z-index that
|
|
2122
|
+
* beats the session column's resize strip, keeps the pointer disabled
|
|
2123
|
+
* so the band reserved for the composer stays click-through, and
|
|
2124
|
+
* paints nothing. The inner box is the visible, interactive panel.
|
|
2125
|
+
* @param children - the panel's children.
|
|
2126
|
+
* @returns the framed subtree.
|
|
2127
|
+
*/
|
|
2128
|
+
const frame = (children) =>
|
|
2129
|
+
h(
|
|
2130
|
+
'div',
|
|
2131
|
+
{ ref: rootRef, style: fillHeight === null ? S.root : { ...S.root, height: `${fillHeight}px` } },
|
|
2132
|
+
h('div', { style: S.panel }, children),
|
|
2133
|
+
)
|
|
2134
|
+
|
|
2135
|
+
const beginDrag = React.useCallback(
|
|
2136
|
+
(column, startX) => setDrag({ column, startX, startWidth: widths[column] }),
|
|
2137
|
+
[widths],
|
|
2138
|
+
)
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* Switch to a branch, optionally creating it first.
|
|
2142
|
+
* @param target - the ref to check out, or the start point.
|
|
2143
|
+
* @param create - when given, create this branch at `target`.
|
|
2144
|
+
*/
|
|
2145
|
+
const checkoutBranch = (target, create) =>
|
|
2146
|
+
void mutate(
|
|
2147
|
+
async () => {
|
|
2148
|
+
await write('/checkout', { root: effectiveRoot, ref: target, create })
|
|
2149
|
+
// The branch filter described the branch we just left.
|
|
2150
|
+
setRef('')
|
|
2151
|
+
setReloadToken((count) => count + 1)
|
|
2152
|
+
setNewBranch(null)
|
|
2153
|
+
setNotice(
|
|
2154
|
+
create === undefined
|
|
2155
|
+
? t('branch.checkedOut', { ref: target })
|
|
2156
|
+
: t('branch.created', { name: create }),
|
|
2157
|
+
)
|
|
2158
|
+
},
|
|
2159
|
+
{ reload: false },
|
|
2160
|
+
)
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* Fetch and integrate the current branch's upstream.
|
|
2164
|
+
*
|
|
2165
|
+
* `ff-only` cannot rewrite history or invent a merge commit, so it is
|
|
2166
|
+
* what the default Pull button does; a diverged branch fails with
|
|
2167
|
+
* git's own message and the user picks Merge deliberately.
|
|
2168
|
+
* @param strategy - `ff-only` | `merge` | `rebase`.
|
|
2169
|
+
*/
|
|
2170
|
+
const pull = (strategy) =>
|
|
2171
|
+
void mutate(
|
|
2172
|
+
async () => {
|
|
2173
|
+
const result = await write('/pull', { root: effectiveRoot, strategy })
|
|
2174
|
+
setReloadToken((count) => count + 1)
|
|
2175
|
+
setNotice(
|
|
2176
|
+
result.changed
|
|
2177
|
+
? t('pull.updated', { before: result.before, after: result.after })
|
|
2178
|
+
: t('pull.uptodate'),
|
|
2179
|
+
)
|
|
2180
|
+
},
|
|
2181
|
+
{ reload: false },
|
|
2182
|
+
)
|
|
2183
|
+
|
|
2184
|
+
/**
|
|
2185
|
+
* Record the current HEAD as the review marker.
|
|
2186
|
+
*
|
|
2187
|
+
* The hash comes from the porcelain status the panel already holds, so
|
|
2188
|
+
* marking costs no extra request. Note what the marker does and does
|
|
2189
|
+
* not cover: it tracks COMMITS, so uncommitted work keeps appearing
|
|
2190
|
+
* until it is committed — which is the honest behaviour, since it has
|
|
2191
|
+
* not been recorded anywhere yet.
|
|
2192
|
+
*/
|
|
2193
|
+
const markReviewed = () => {
|
|
2194
|
+
const root = status?.root
|
|
2195
|
+
const hash = status?.branch?.oid
|
|
2196
|
+
if (typeof root !== 'string' || typeof hash !== 'string' || hash === '') return
|
|
2197
|
+
const next = { ...markers, [root]: { hash, at: Date.now() } }
|
|
2198
|
+
setMarkers(next)
|
|
2199
|
+
writeMarkers(defaultStorage(), next)
|
|
2200
|
+
setReloadToken((count) => count + 1)
|
|
2201
|
+
setNotice(t('review.marked'))
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/**
|
|
2205
|
+
* Put a reference's paste-ready text on the clipboard.
|
|
2206
|
+
*
|
|
2207
|
+
* The panel never submits anything itself: it hands the user a line
|
|
2208
|
+
* they can read, edit and decide to send. (Programmatic insertion
|
|
2209
|
+
* would need a per-session input facade that no first-party package
|
|
2210
|
+
* reaches, so it is deliberately not attempted here.)
|
|
2211
|
+
* @param ref - a reference string.
|
|
2212
|
+
*/
|
|
2213
|
+
const copyRef = async (ref) => {
|
|
2214
|
+
try {
|
|
2215
|
+
await navigator.clipboard.writeText(refText(ref))
|
|
2216
|
+
setNotice(t('handoff.copied'))
|
|
2217
|
+
} catch {
|
|
2218
|
+
setNotice(t('handoff.failed'))
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Show one path's change since the marker.
|
|
2224
|
+
* @param entry - a review file entry.
|
|
2225
|
+
*/
|
|
2226
|
+
const openReviewFile = async (entry) => {
|
|
2227
|
+
setFile({ path: entry.path, review: true })
|
|
2228
|
+
setFileDiff('')
|
|
2229
|
+
try {
|
|
2230
|
+
const data = await api(
|
|
2231
|
+
'/diff',
|
|
2232
|
+
{ root: effectiveRoot, file: entry.path, since: reviewMarker?.hash },
|
|
2233
|
+
t,
|
|
2234
|
+
)
|
|
2235
|
+
setFileDiff(data.text)
|
|
2236
|
+
} catch (failure) {
|
|
2237
|
+
setFileDiff(t('diff.failed', { message: failure.message }))
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
/**
|
|
2242
|
+
* Push the current branch, publishing it first when it has no upstream.
|
|
2243
|
+
* @param setUpstream - publish the branch as part of the push.
|
|
2244
|
+
*/
|
|
2245
|
+
const push = (setUpstream) =>
|
|
2246
|
+
void mutate(
|
|
2247
|
+
async () => {
|
|
2248
|
+
const result = await write('/push', { root: effectiveRoot, setUpstream })
|
|
2249
|
+
setReloadToken((count) => count + 1)
|
|
2250
|
+
setNotice(
|
|
2251
|
+
result.published
|
|
2252
|
+
? t('push.published', { branch: result.branch })
|
|
2253
|
+
: result.changed
|
|
2254
|
+
? t('push.done', { before: result.before, after: result.after })
|
|
2255
|
+
: t('push.uptodate'),
|
|
2256
|
+
)
|
|
2257
|
+
},
|
|
2258
|
+
{ reload: false },
|
|
2259
|
+
)
|
|
2260
|
+
|
|
2261
|
+
/**
|
|
2262
|
+
* Check out a remote-tracking branch: reuse the local branch of the
|
|
2263
|
+
* same name when one exists, since `checkout -b` would refuse.
|
|
2264
|
+
* @param entry - a `branches.remote` entry.
|
|
2265
|
+
*/
|
|
2266
|
+
const checkoutRemote = (entry) => {
|
|
2267
|
+
const existing = branches.local.find((branch) => branch.name === entry.branch)
|
|
2268
|
+
if (existing === undefined) checkoutBranch(entry.name, entry.branch)
|
|
2269
|
+
else checkoutBranch(existing.name)
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** Create the branch named in the inline input. */
|
|
2273
|
+
const createBranch = () => {
|
|
2274
|
+
const name = typeof newBranch === 'string' ? newBranch.trim() : ''
|
|
2275
|
+
if (name === '') return
|
|
2276
|
+
checkoutBranch(ref === '' ? 'HEAD' : ref, name)
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/**
|
|
2280
|
+
* Pin the typed path, or re-read when it is unchanged. A changed root
|
|
2281
|
+
* reloads through the `load` effect rather than here, so the fetch
|
|
2282
|
+
* uses the new root instead of the one this render closed over.
|
|
2283
|
+
*/
|
|
2284
|
+
const applyRoot = () => {
|
|
2285
|
+
const next = rootInput.trim()
|
|
2286
|
+
if (next !== appliedRoot) setAppliedRoot(next)
|
|
2287
|
+
else void load()
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const openFile = React.useCallback(
|
|
2291
|
+
async (target, staged, untracked) => {
|
|
2292
|
+
setFile({ path: target.path, staged, untracked })
|
|
2293
|
+
setFileDiff('')
|
|
2294
|
+
try {
|
|
2295
|
+
const data = await api(
|
|
2296
|
+
'/diff',
|
|
2297
|
+
{
|
|
2298
|
+
root: effectiveRoot,
|
|
2299
|
+
file: target.path,
|
|
2300
|
+
staged: staged ? 1 : undefined,
|
|
2301
|
+
untracked: untracked ? 1 : undefined,
|
|
2302
|
+
},
|
|
2303
|
+
t,
|
|
2304
|
+
)
|
|
2305
|
+
setFileDiff(data.text)
|
|
2306
|
+
} catch (failure) {
|
|
2307
|
+
setFileDiff(t('diff.failed', { message: failure.message }))
|
|
2308
|
+
}
|
|
2309
|
+
},
|
|
2310
|
+
[effectiveRoot],
|
|
2311
|
+
)
|
|
2312
|
+
|
|
2313
|
+
const openCommit = React.useCallback(
|
|
2314
|
+
async (hash) => {
|
|
2315
|
+
setCommit(hash)
|
|
2316
|
+
setCommitDetail(null)
|
|
2317
|
+
setCommitFile(null)
|
|
2318
|
+
setCommitDiff('')
|
|
2319
|
+
try {
|
|
2320
|
+
const detail = await api('/commit', { root: effectiveRoot, hash }, t)
|
|
2321
|
+
// Same reasoning as the status arrays: a missing `files`
|
|
2322
|
+
// would throw during render, past the error branch.
|
|
2323
|
+
const files = Array.isArray(detail.files) ? detail.files : []
|
|
2324
|
+
setCommitDetail({ ...detail, files })
|
|
2325
|
+
const first = files[0]
|
|
2326
|
+
if (first !== undefined) {
|
|
2327
|
+
setCommitFile(first.path)
|
|
2328
|
+
setCommitDiff((await api('/show', { root: effectiveRoot, hash, file: first.path }, t)).text)
|
|
2329
|
+
}
|
|
2330
|
+
} catch (failure) {
|
|
2331
|
+
fail(failure)
|
|
2332
|
+
}
|
|
2333
|
+
},
|
|
2334
|
+
[effectiveRoot, fail],
|
|
2335
|
+
)
|
|
2336
|
+
|
|
2337
|
+
const openCommitFile = React.useCallback(
|
|
2338
|
+
async (path) => {
|
|
2339
|
+
if (commit === null) return
|
|
2340
|
+
setCommitFile(path)
|
|
2341
|
+
setCommitDiff('')
|
|
2342
|
+
try {
|
|
2343
|
+
setCommitDiff((await api('/show', { root: effectiveRoot, hash: commit, file: path }, t)).text)
|
|
2344
|
+
} catch (failure) {
|
|
2345
|
+
fail(failure)
|
|
2346
|
+
}
|
|
2347
|
+
},
|
|
2348
|
+
[effectiveRoot, commit, fail],
|
|
2349
|
+
)
|
|
2350
|
+
|
|
2351
|
+
/**
|
|
2352
|
+
* Run a write, then refresh. The host invalidates its own cache on a
|
|
2353
|
+
* write, so this read is guaranteed to see the new state.
|
|
2354
|
+
* @param run - the write to perform.
|
|
2355
|
+
* @param keep - optional selection to re-open afterwards.
|
|
2356
|
+
*/
|
|
2357
|
+
const mutate = React.useCallback(
|
|
2358
|
+
async (run, options = {}) => {
|
|
2359
|
+
const { reload = true, keep } = options
|
|
2360
|
+
setBusy(true)
|
|
2361
|
+
setNotice(null)
|
|
2362
|
+
try {
|
|
2363
|
+
await run()
|
|
2364
|
+
// A caller that changed the ref filter reloads through the
|
|
2365
|
+
// `load` effect instead: fetching here would use the ref this
|
|
2366
|
+
// render closed over and could land after the correct one.
|
|
2367
|
+
if (reload) await load()
|
|
2368
|
+
if (keep !== undefined) await keep()
|
|
2369
|
+
setError(null)
|
|
2370
|
+
} catch (failure) {
|
|
2371
|
+
fail(failure)
|
|
2372
|
+
} finally {
|
|
2373
|
+
setBusy(false)
|
|
2374
|
+
}
|
|
2375
|
+
},
|
|
2376
|
+
[load, fail],
|
|
2377
|
+
)
|
|
2378
|
+
|
|
2379
|
+
const rows = React.useMemo(() => computeLanes(commits), [commits])
|
|
2380
|
+
|
|
2381
|
+
// The bubble lives in the document body, so it outlives the panel
|
|
2382
|
+
// unless the panel dismisses it on the way out. This hook is last on
|
|
2383
|
+
// purpose: the hook order is positional, and the render test seeds
|
|
2384
|
+
// the earlier slots by index.
|
|
2385
|
+
React.useEffect(() => () => cancelBubble(), [])
|
|
2386
|
+
|
|
2387
|
+
const groups =
|
|
2388
|
+
status === null
|
|
2389
|
+
? []
|
|
2390
|
+
: [
|
|
2391
|
+
['group.conflicted', status.conflicted, null],
|
|
2392
|
+
['group.staged', status.staged, 'unstage'],
|
|
2393
|
+
['group.unstaged', status.unstaged, 'stage'],
|
|
2394
|
+
['group.untracked', status.untracked, 'stage'],
|
|
2395
|
+
].filter(([, files]) => files.length > 0)
|
|
2396
|
+
|
|
2397
|
+
const header = h(
|
|
2398
|
+
'div',
|
|
2399
|
+
{ style: S.header },
|
|
2400
|
+
h(
|
|
2401
|
+
'span',
|
|
2402
|
+
{ style: S.branch, title: status?.root ?? '' },
|
|
2403
|
+
status?.branch?.head ?? (status?.branch?.detached === true ? '(detached)' : '—'),
|
|
2404
|
+
status?.branch?.ahead > 0 ? h('span', { style: S.pill }, `↑${status.branch.ahead}`) : null,
|
|
2405
|
+
status?.branch?.behind > 0 ? h('span', { style: S.pill }, `↓${status.branch.behind}`) : null,
|
|
2406
|
+
),
|
|
2407
|
+
h('input', {
|
|
2408
|
+
style: S.input,
|
|
2409
|
+
value: rootInput,
|
|
2410
|
+
// An empty box is not "unset": it means the panel is following
|
|
2411
|
+
// the session, and the placeholder says which directory that is.
|
|
2412
|
+
placeholder:
|
|
2413
|
+
effectiveRoot === ''
|
|
2414
|
+
? t('header.path.placeholder')
|
|
2415
|
+
: t('header.path.follow', { path: effectiveRoot }),
|
|
2416
|
+
spellCheck: false,
|
|
2417
|
+
onChange: (event) => setRootInput(event.target.value),
|
|
2418
|
+
onKeyDown: (event) => {
|
|
2419
|
+
if (event.key === 'Enter') applyRoot()
|
|
2420
|
+
},
|
|
2421
|
+
}),
|
|
2422
|
+
h('button', { style: S.button, onClick: () => applyRoot() }, busy ? '…' : t('header.refresh')),
|
|
2423
|
+
)
|
|
2424
|
+
|
|
2425
|
+
const tabs = h(
|
|
2426
|
+
'div',
|
|
2427
|
+
{ style: { display: 'flex', gap: 4, padding: '0 6px', flex: '0 0 auto', borderBottom: '1px solid var(--dsw-alias-border-l2)' } },
|
|
2428
|
+
[
|
|
2429
|
+
['review', 'view.review'],
|
|
2430
|
+
['worktree', 'view.worktree'],
|
|
2431
|
+
['history', 'view.history'],
|
|
2432
|
+
].map(([key, labelKey]) =>
|
|
2433
|
+
h(
|
|
2434
|
+
'button',
|
|
2435
|
+
{
|
|
2436
|
+
key,
|
|
2437
|
+
style: { ...S.tab, ...(mode === key ? S.tabActive : null) },
|
|
2438
|
+
onClick: () => setMode(key),
|
|
2439
|
+
},
|
|
2440
|
+
t(labelKey),
|
|
2441
|
+
),
|
|
2442
|
+
),
|
|
2443
|
+
h('span', { style: { ...S.meta, marginLeft: 'auto', alignSelf: 'center' } },
|
|
2444
|
+
status === null
|
|
2445
|
+
? ''
|
|
2446
|
+
: t('tab.changes', {
|
|
2447
|
+
count:
|
|
2448
|
+
status.staged.length +
|
|
2449
|
+
status.unstaged.length +
|
|
2450
|
+
status.untracked.length +
|
|
2451
|
+
status.conflicted.length,
|
|
2452
|
+
})),
|
|
2453
|
+
)
|
|
2454
|
+
|
|
2455
|
+
if (error !== null) {
|
|
2456
|
+
return frame([
|
|
2457
|
+
header,
|
|
2458
|
+
h('div', { style: S.error }, error),
|
|
2459
|
+
h(
|
|
2460
|
+
'div',
|
|
2461
|
+
{ style: { padding: '0 10px' } },
|
|
2462
|
+
h('button', { style: S.button, onClick: () => void load() }, t('header.retry')),
|
|
2463
|
+
),
|
|
2464
|
+
])
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// ── review pane: what changed since you last looked ───────────────────
|
|
2468
|
+
const reviewRanked = review === null ? [] : rankReviewFiles(review.files)
|
|
2469
|
+
|
|
2470
|
+
const reviewSummary =
|
|
2471
|
+
review === null || reviewMarker === null
|
|
2472
|
+
? null
|
|
2473
|
+
: h(
|
|
2474
|
+
'div',
|
|
2475
|
+
{ style: { ...S.group, ...S.groupRow } },
|
|
2476
|
+
h(
|
|
2477
|
+
'span',
|
|
2478
|
+
{ style: { flex: '1 1 auto', minWidth: 0 } },
|
|
2479
|
+
t('review.summary', {
|
|
2480
|
+
when:
|
|
2481
|
+
reviewMarker.at === null
|
|
2482
|
+
? reviewMarker.hash.slice(0, 7)
|
|
2483
|
+
: relativeTime(reviewMarker.at, t),
|
|
2484
|
+
commits: review.commits.length,
|
|
2485
|
+
files: review.totals.files,
|
|
2486
|
+
added: review.totals.added,
|
|
2487
|
+
deleted: review.totals.deleted,
|
|
2488
|
+
}),
|
|
2489
|
+
t('review.worktreeNote'),
|
|
2490
|
+
),
|
|
2491
|
+
h(
|
|
2492
|
+
'button',
|
|
2493
|
+
{
|
|
2494
|
+
style: { ...S.iconButton, ...S.branchAction },
|
|
2495
|
+
title: t('review.markHint'),
|
|
2496
|
+
disabled: busy,
|
|
2497
|
+
onClick: () => markReviewed(),
|
|
2498
|
+
},
|
|
2499
|
+
t('review.mark'),
|
|
2500
|
+
),
|
|
2501
|
+
)
|
|
2502
|
+
|
|
2503
|
+
const reviewPane =
|
|
2504
|
+
reviewMarker === null
|
|
2505
|
+
? h(
|
|
2506
|
+
'div',
|
|
2507
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 12, padding: 16, alignItems: 'flex-start' } },
|
|
2508
|
+
h('div', { style: { opacity: 0.7, maxWidth: 520 } }, t('review.noMarker')),
|
|
2509
|
+
h(
|
|
2510
|
+
'button',
|
|
2511
|
+
{
|
|
2512
|
+
style: S.primary,
|
|
2513
|
+
disabled: busy || status === null,
|
|
2514
|
+
onClick: () => markReviewed(),
|
|
2515
|
+
},
|
|
2516
|
+
t('review.mark'),
|
|
2517
|
+
),
|
|
2518
|
+
)
|
|
2519
|
+
: h(
|
|
2520
|
+
'div',
|
|
2521
|
+
{ style: { display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: 0 } },
|
|
2522
|
+
reviewSummary,
|
|
2523
|
+
h('div', { style: { ...S.group, opacity: 0.45, fontSize: 10 } }, t('review.rank')),
|
|
2524
|
+
h(
|
|
2525
|
+
'div',
|
|
2526
|
+
{ style: S.body },
|
|
2527
|
+
h(
|
|
2528
|
+
'div',
|
|
2529
|
+
{ style: { ...S.left, flex: `0 0 ${widths.files}px` } },
|
|
2530
|
+
review === null
|
|
2531
|
+
? h('div', { style: S.empty }, t('empty.loading'))
|
|
2532
|
+
: reviewRanked.length === 0
|
|
2533
|
+
? h('div', { style: S.empty }, t('review.clean'))
|
|
2534
|
+
: reviewRanked.map(({ file: entry, reason }) => {
|
|
2535
|
+
// Name first, directory after it — see `splitNameDir`.
|
|
2536
|
+
const { name: entryName, dir: entryDir } = splitNameDir(entry.path)
|
|
2537
|
+
// The bubble shows the whole path; a rename adds the path it came from.
|
|
2538
|
+
const fullPath =
|
|
2539
|
+
entry.origPath == null
|
|
2540
|
+
? entry.path
|
|
2541
|
+
: `${entry.origPath} → ${entry.path}`
|
|
2542
|
+
return h(
|
|
2543
|
+
'div',
|
|
2544
|
+
{
|
|
2545
|
+
key: entry.path,
|
|
2546
|
+
style: {
|
|
2547
|
+
...S.row,
|
|
2548
|
+
...(file?.path === entry.path && file?.review === true
|
|
2549
|
+
? S.rowSelected
|
|
2550
|
+
: null),
|
|
2551
|
+
},
|
|
2552
|
+
onClick: () => void openReviewFile(entry),
|
|
2553
|
+
},
|
|
2554
|
+
h(
|
|
2555
|
+
'span',
|
|
2556
|
+
{
|
|
2557
|
+
style: {
|
|
2558
|
+
...S.stat,
|
|
2559
|
+
color: (STATUS[entry.status ?? 'M'] ??
|
|
2560
|
+
['var(--dsw-alias-label-tertiary)'])[0],
|
|
2561
|
+
},
|
|
2562
|
+
},
|
|
2563
|
+
entry.status ?? '?',
|
|
2564
|
+
),
|
|
2565
|
+
h('span', { style: S.fileName, ...reveal(fullPath) }, entryName),
|
|
2566
|
+
entryDir === '' ? null : h('span', { style: S.fileDir, ...reveal(fullPath) }, entryDir),
|
|
2567
|
+
reason === null
|
|
2568
|
+
? null
|
|
2569
|
+
: h('span', { style: { ...S.meta, flex: '0 0 auto' } }, t(reason)),
|
|
2570
|
+
h(
|
|
2571
|
+
'span',
|
|
2572
|
+
{ style: { ...S.meta, flex: '0 0 auto' } },
|
|
2573
|
+
entry.binary ? '' : `+${entry.added ?? 0} −${entry.deleted ?? 0}`,
|
|
2574
|
+
),
|
|
2575
|
+
h(
|
|
2576
|
+
'button',
|
|
2577
|
+
{
|
|
2578
|
+
style: { ...S.iconButton, ...S.branchAction },
|
|
2579
|
+
title: t('handoff.copyTitle'),
|
|
2580
|
+
onClick: (event) => {
|
|
2581
|
+
event.stopPropagation()
|
|
2582
|
+
void copyRef(
|
|
2583
|
+
encodeFileRef(
|
|
2584
|
+
{ ...entry, status: entry.status ?? '' },
|
|
2585
|
+
'worktree',
|
|
2586
|
+
),
|
|
2587
|
+
)
|
|
2588
|
+
},
|
|
2589
|
+
},
|
|
2590
|
+
t('handoff.copy'),
|
|
2591
|
+
),
|
|
2592
|
+
)
|
|
2593
|
+
}),
|
|
2594
|
+
),
|
|
2595
|
+
h(
|
|
2596
|
+
'div',
|
|
2597
|
+
{ style: S.right },
|
|
2598
|
+
h(
|
|
2599
|
+
'div',
|
|
2600
|
+
{ style: S.diffWrap },
|
|
2601
|
+
h(DiffPane, { text: fileDiff, empty: t('review.pickFile'), actions: null, t }),
|
|
2602
|
+
),
|
|
2603
|
+
),
|
|
2604
|
+
),
|
|
2605
|
+
)
|
|
2606
|
+
|
|
2607
|
+
// ── worktree pane ─────────────────────────────────────────────────────
|
|
2608
|
+
const worktreeLeft = h(
|
|
2609
|
+
'div',
|
|
2610
|
+
{ style: { ...S.left, flex: `0 0 ${widths.files}px` } },
|
|
2611
|
+
status === null
|
|
2612
|
+
? h('div', { style: S.empty }, t('empty.loading'))
|
|
2613
|
+
: groups.length === 0
|
|
2614
|
+
? h('div', { style: S.empty }, t('empty.clean'))
|
|
2615
|
+
: groups.map(([labelKey, files, action]) =>
|
|
2616
|
+
h(
|
|
2617
|
+
'div',
|
|
2618
|
+
{ key: labelKey },
|
|
2619
|
+
h(
|
|
2620
|
+
'div',
|
|
2621
|
+
{ style: S.group },
|
|
2622
|
+
t('group.header', { label: t(labelKey), count: files.length }),
|
|
2623
|
+
),
|
|
2624
|
+
files.map((entry) =>
|
|
2625
|
+
h(FileRow, {
|
|
2626
|
+
key: `${labelKey}:${entry.path}`,
|
|
2627
|
+
file: entry,
|
|
2628
|
+
selected: file?.path === entry.path,
|
|
2629
|
+
t,
|
|
2630
|
+
onOpen: () =>
|
|
2631
|
+
void openFile(entry, labelKey === 'group.staged', labelKey === 'group.untracked'),
|
|
2632
|
+
onCopy: () => copyRef(encodeFileRef(entry, labelKey === 'group.staged' ? 'staged' : labelKey === 'group.untracked' ? 'untracked' : 'worktree')),
|
|
2633
|
+
onAction:
|
|
2634
|
+
action === null
|
|
2635
|
+
? undefined
|
|
2636
|
+
: action === 'stage'
|
|
2637
|
+
? {
|
|
2638
|
+
label: t('file.stage'),
|
|
2639
|
+
title: 'git add',
|
|
2640
|
+
run: () => void mutate(() => write('/stage', { root: effectiveRoot, files: [entry.path] })),
|
|
2641
|
+
}
|
|
2642
|
+
: {
|
|
2643
|
+
label: t('file.unstage'),
|
|
2644
|
+
title: 'git reset HEAD',
|
|
2645
|
+
run: () => void mutate(() => write('/unstage', { root: effectiveRoot, files: [entry.path] })),
|
|
2646
|
+
},
|
|
2647
|
+
}),
|
|
2648
|
+
),
|
|
2649
|
+
),
|
|
2650
|
+
),
|
|
2651
|
+
)
|
|
2652
|
+
|
|
2653
|
+
// Hunk actions only make sense for a file that is already tracked:
|
|
2654
|
+
// `git apply --cached` cannot stage part of an untracked file, which
|
|
2655
|
+
// has no index entry to patch.
|
|
2656
|
+
const hunkActions =
|
|
2657
|
+
file === null || file.untracked === true || file.review === true || typeof file.path !== 'string'
|
|
2658
|
+
? null
|
|
2659
|
+
: file.staged === true
|
|
2660
|
+
? {
|
|
2661
|
+
label: t('hunk.unstage'),
|
|
2662
|
+
title: 'git apply --cached --reverse',
|
|
2663
|
+
run: (indices) =>
|
|
2664
|
+
void mutate(
|
|
2665
|
+
() => write('/hunks', { root: effectiveRoot, file: file.path, hunks: indices, staged: true }),
|
|
2666
|
+
{ keep: () => openFile(file, file.staged, file.untracked) },
|
|
2667
|
+
),
|
|
2668
|
+
}
|
|
2669
|
+
: {
|
|
2670
|
+
label: t('hunk.stage'),
|
|
2671
|
+
title: 'git apply --cached',
|
|
2672
|
+
run: (indices) =>
|
|
2673
|
+
void mutate(
|
|
2674
|
+
() => write('/hunks', { root: effectiveRoot, file: file.path, hunks: indices }),
|
|
2675
|
+
{ keep: () => openFile(file, file.staged, file.untracked) },
|
|
2676
|
+
),
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const worktreeRight = h(
|
|
2680
|
+
'div',
|
|
2681
|
+
{ style: S.right },
|
|
2682
|
+
h(
|
|
2683
|
+
'div',
|
|
2684
|
+
{ style: S.diffWrap },
|
|
2685
|
+
h(DiffPane, { text: fileDiff, empty: t('empty.pickFile'), actions: hunkActions, t }),
|
|
2686
|
+
),
|
|
2687
|
+
)
|
|
2688
|
+
|
|
2689
|
+
const commitBox = h(
|
|
2690
|
+
'div',
|
|
2691
|
+
{ style: S.composer },
|
|
2692
|
+
h('input', {
|
|
2693
|
+
style: S.message,
|
|
2694
|
+
value: message,
|
|
2695
|
+
placeholder:
|
|
2696
|
+
status === null || status.staged.length === 0
|
|
2697
|
+
? t('commit.placeholderEmpty')
|
|
2698
|
+
: t('commit.placeholderReady'),
|
|
2699
|
+
spellCheck: false,
|
|
2700
|
+
onChange: (event) => setMessage(event.target.value),
|
|
2701
|
+
onKeyDown: (event) => {
|
|
2702
|
+
if (event.key !== 'Enter' || message.trim() === '') return
|
|
2703
|
+
void mutate(
|
|
2704
|
+
async () => {
|
|
2705
|
+
const result = await write('/commit', { root: effectiveRoot, message: message.trim() })
|
|
2706
|
+
setMessage('')
|
|
2707
|
+
setNotice(t('commit.done', { hash: result.hash }))
|
|
2708
|
+
},
|
|
2709
|
+
)
|
|
2710
|
+
},
|
|
2711
|
+
}),
|
|
2712
|
+
h(
|
|
2713
|
+
'button',
|
|
2714
|
+
{
|
|
2715
|
+
style: { ...S.primary, opacity: status === null || status.staged.length === 0 || message.trim() === '' ? 0.5 : 1 },
|
|
2716
|
+
disabled: status === null || status.staged.length === 0 || message.trim() === '',
|
|
2717
|
+
onClick: () =>
|
|
2718
|
+
void mutate(async () => {
|
|
2719
|
+
const result = await write('/commit', { root: effectiveRoot, message: message.trim() })
|
|
2720
|
+
setMessage('')
|
|
2721
|
+
setNotice(t('commit.done', { hash: result.hash }))
|
|
2722
|
+
}),
|
|
2723
|
+
},
|
|
2724
|
+
t('commit.button', { count: status?.staged?.length ?? 0 }),
|
|
2725
|
+
),
|
|
2726
|
+
)
|
|
2727
|
+
|
|
2728
|
+
// ── history pane: branches | commits | changes ────────────────────────
|
|
2729
|
+
const remotes = new Map()
|
|
2730
|
+
for (const entry of branches.remote) {
|
|
2731
|
+
if (!remotes.has(entry.remote)) remotes.set(entry.remote, [])
|
|
2732
|
+
remotes.get(entry.remote).push(entry)
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// A branch with no upstream cannot be pushed until it is published,
|
|
2736
|
+
// and the panel already knows which case it is in.
|
|
2737
|
+
const currentBranch = branches.local.find((branch) => branch.current)
|
|
2738
|
+
const needsPublish = currentBranch !== undefined && currentBranch.upstream === null
|
|
2739
|
+
|
|
2740
|
+
/** Clicking a ref re-walks the log from it; clicking it again returns to HEAD. */
|
|
2741
|
+
const toggleRef = (value) => setRef(ref === value ? '' : value)
|
|
2742
|
+
|
|
2743
|
+
const branchColumn = h(
|
|
2744
|
+
'div',
|
|
2745
|
+
{ style: { ...S.branches, flex: `0 0 ${widths.branches}px` } },
|
|
2746
|
+
h(
|
|
2747
|
+
'div',
|
|
2748
|
+
{ style: { ...S.group, ...S.groupRow } },
|
|
2749
|
+
t('branch.title'),
|
|
2750
|
+
h(
|
|
2751
|
+
'select',
|
|
2752
|
+
{
|
|
2753
|
+
style: S.select,
|
|
2754
|
+
value: pullStrategy,
|
|
2755
|
+
disabled: busy,
|
|
2756
|
+
title: t(`pull.${pullStrategy}.title`),
|
|
2757
|
+
onChange: (event) => setPullStrategy(event.target.value),
|
|
2758
|
+
},
|
|
2759
|
+
PULL_STRATEGIES.map((value) => h('option', { key: value, value }, t(`pull.${value}`))),
|
|
2760
|
+
),
|
|
2761
|
+
h(
|
|
2762
|
+
'button',
|
|
2763
|
+
{
|
|
2764
|
+
style: { ...S.iconButton, ...S.branchAction },
|
|
2765
|
+
title: t(`pull.${pullStrategy}.title`),
|
|
2766
|
+
disabled: busy,
|
|
2767
|
+
onClick: () => pull(pullStrategy),
|
|
2768
|
+
},
|
|
2769
|
+
t('pull.button'),
|
|
2770
|
+
),
|
|
2771
|
+
h(
|
|
2772
|
+
'button',
|
|
2773
|
+
{
|
|
2774
|
+
style: { ...S.iconButton, ...S.branchAction },
|
|
2775
|
+
title: needsPublish
|
|
2776
|
+
? t('push.publishTitle', { branch: currentBranch?.name ?? '' })
|
|
2777
|
+
: t('push.title'),
|
|
2778
|
+
disabled: busy,
|
|
2779
|
+
onClick: () => push(needsPublish),
|
|
2780
|
+
},
|
|
2781
|
+
needsPublish ? t('push.publish') : t('push.button'),
|
|
2782
|
+
),
|
|
2783
|
+
),
|
|
2784
|
+
h('div', { style: S.group }, t('branch.head')),
|
|
2785
|
+
h(
|
|
2786
|
+
'div',
|
|
2787
|
+
{ style: { ...S.branchRow, ...(ref === '' ? S.branchSelected : null) }, onClick: () => setRef('') },
|
|
2788
|
+
h('span', { style: S.branchMark }, '●'),
|
|
2789
|
+
h('span', { style: S.path, ...reveal(branches.head ?? '') }, branches.head ?? '—'),
|
|
2790
|
+
),
|
|
2791
|
+
h(
|
|
2792
|
+
'div',
|
|
2793
|
+
{ style: { ...S.group, ...S.groupRow } },
|
|
2794
|
+
t('branch.local', { count: branches.local.length }),
|
|
2795
|
+
h(
|
|
2796
|
+
'button',
|
|
2797
|
+
{
|
|
2798
|
+
style: { ...S.iconButton, ...S.branchAction },
|
|
2799
|
+
title: t('branch.newTitle', { base: ref === '' ? 'HEAD' : ref }),
|
|
2800
|
+
onClick: () => setNewBranch(newBranch === null ? '' : null),
|
|
2801
|
+
},
|
|
2802
|
+
t('branch.new'),
|
|
2803
|
+
),
|
|
2804
|
+
),
|
|
2805
|
+
newBranch === null
|
|
2806
|
+
? null
|
|
2807
|
+
: h(
|
|
2808
|
+
'div',
|
|
2809
|
+
{ style: S.branchRow },
|
|
2810
|
+
h('input', {
|
|
2811
|
+
style: S.branchInput,
|
|
2812
|
+
value: newBranch,
|
|
2813
|
+
autoFocus: true,
|
|
2814
|
+
placeholder: t('branch.newPlaceholder', { base: ref === '' ? 'HEAD' : ref }),
|
|
2815
|
+
spellCheck: false,
|
|
2816
|
+
onChange: (event) => setNewBranch(event.target.value),
|
|
2817
|
+
onKeyDown: (event) => {
|
|
2818
|
+
if (event.key === 'Enter') createBranch()
|
|
2819
|
+
if (event.key === 'Escape') setNewBranch(null)
|
|
2820
|
+
},
|
|
2821
|
+
}),
|
|
2822
|
+
),
|
|
2823
|
+
branches.local.map((entry) =>
|
|
2824
|
+
h(
|
|
2825
|
+
'div',
|
|
2826
|
+
{
|
|
2827
|
+
key: entry.name,
|
|
2828
|
+
style: { ...S.branchRow, ...(ref === entry.name ? S.branchSelected : null) },
|
|
2829
|
+
onClick: () => toggleRef(entry.name),
|
|
2830
|
+
},
|
|
2831
|
+
h('span', { style: { ...S.branchMark, opacity: entry.current ? 1 : 0 } }, '★'),
|
|
2832
|
+
h('span', {
|
|
2833
|
+
style: S.path,
|
|
2834
|
+
// The upstream is on no other part of the row, so the
|
|
2835
|
+
// bubble is worth showing even when the name fits.
|
|
2836
|
+
...reveal(
|
|
2837
|
+
entry.upstream === null ? entry.name : `${entry.name}\n→ ${entry.upstream}`,
|
|
2838
|
+
{ extra: entry.upstream !== null },
|
|
2839
|
+
),
|
|
2840
|
+
}, entry.name),
|
|
2841
|
+
entry.current
|
|
2842
|
+
? h('span', { style: S.meta }, t('branch.current'))
|
|
2843
|
+
: h(
|
|
2844
|
+
'button',
|
|
2845
|
+
{
|
|
2846
|
+
style: { ...S.iconButton, ...S.branchAction },
|
|
2847
|
+
title: `git checkout ${entry.name}`,
|
|
2848
|
+
onClick: (event) => {
|
|
2849
|
+
event.stopPropagation()
|
|
2850
|
+
checkoutBranch(entry.name)
|
|
2851
|
+
},
|
|
2852
|
+
},
|
|
2853
|
+
t('branch.checkout'),
|
|
2854
|
+
),
|
|
2855
|
+
),
|
|
2856
|
+
),
|
|
2857
|
+
h('div', { style: S.group }, t('branch.remote', { count: branches.remote.length })),
|
|
2858
|
+
[...remotes.entries()].map(([remoteName, entries]) =>
|
|
2859
|
+
h(
|
|
2860
|
+
'div',
|
|
2861
|
+
{ key: remoteName },
|
|
2862
|
+
h('div', { style: { ...S.group, ...S.branchIndent, textTransform: 'none' } }, remoteName),
|
|
2863
|
+
entries.map((entry) =>
|
|
2864
|
+
h(
|
|
2865
|
+
'div',
|
|
2866
|
+
{
|
|
2867
|
+
key: entry.name,
|
|
2868
|
+
style: {
|
|
2869
|
+
...S.branchRow,
|
|
2870
|
+
...S.branchIndent,
|
|
2871
|
+
...(ref === entry.name ? S.branchSelected : null),
|
|
2872
|
+
},
|
|
2873
|
+
onClick: () => toggleRef(entry.name),
|
|
2874
|
+
},
|
|
2875
|
+
h('span', { style: S.path, ...reveal(entry.name) }, entry.branch),
|
|
2876
|
+
h(
|
|
2877
|
+
'button',
|
|
2878
|
+
{
|
|
2879
|
+
style: { ...S.iconButton, ...S.branchAction },
|
|
2880
|
+
title: t('branch.checkoutRemoteTitle', { name: entry.branch }),
|
|
2881
|
+
onClick: (event) => {
|
|
2882
|
+
event.stopPropagation()
|
|
2883
|
+
checkoutRemote(entry)
|
|
2884
|
+
},
|
|
2885
|
+
},
|
|
2886
|
+
t('branch.checkout'),
|
|
2887
|
+
),
|
|
2888
|
+
),
|
|
2889
|
+
),
|
|
2890
|
+
),
|
|
2891
|
+
),
|
|
2892
|
+
)
|
|
2893
|
+
|
|
2894
|
+
const commitColumn = h(
|
|
2895
|
+
'div',
|
|
2896
|
+
{ style: { ...S.commits, flex: `0 0 ${widths.commits}px` } },
|
|
2897
|
+
ref === '' ? null : h('div', { style: S.group }, t('branch.filtered', { ref, count: rows.length })),
|
|
2898
|
+
rows.length === 0 ? h('div', { style: S.empty }, t('empty.noCommits')) : null,
|
|
2899
|
+
rows.map((row) =>
|
|
2900
|
+
h(
|
|
2901
|
+
'div',
|
|
2902
|
+
{
|
|
2903
|
+
key: row.commit.hash,
|
|
2904
|
+
style: { ...S.commit, ...(commit === row.commit.hash ? S.rowSelected : null) },
|
|
2905
|
+
onClick: () => void openCommit(row.commit.hash),
|
|
2906
|
+
},
|
|
2907
|
+
h(LaneGraph, { row }),
|
|
2908
|
+
// The hash is cut to seven characters, and the author is not
|
|
2909
|
+
// on the row at all — so the bubble carries what the row
|
|
2910
|
+
// does not, and shows even though nothing is cut off.
|
|
2911
|
+
h('span', {
|
|
2912
|
+
style: S.hash,
|
|
2913
|
+
...reveal(`${row.commit.hash}\n${row.commit.author}`, { extra: true }),
|
|
2914
|
+
}, row.commit.hash.slice(0, 7)),
|
|
2915
|
+
h('span', { style: S.subject, ...reveal(row.commit.subject) }, row.commit.subject),
|
|
2916
|
+
h('span', { style: S.meta }, relativeTime(row.commit.at, t)),
|
|
2917
|
+
),
|
|
2918
|
+
),
|
|
2919
|
+
)
|
|
2920
|
+
|
|
2921
|
+
const detailColumn = h(
|
|
2922
|
+
'div',
|
|
2923
|
+
{ style: S.details },
|
|
2924
|
+
commitDetail === null
|
|
2925
|
+
? h('div', { style: S.empty }, commit === null ? t('empty.pickCommit') : t('empty.loading'))
|
|
2926
|
+
: h(
|
|
2927
|
+
'div',
|
|
2928
|
+
{ style: S.commitFiles },
|
|
2929
|
+
h(
|
|
2930
|
+
'div',
|
|
2931
|
+
{ style: S.commitHead },
|
|
2932
|
+
h('div', { style: { fontWeight: 600 } }, commitDetail.commit.subject),
|
|
2933
|
+
h(
|
|
2934
|
+
'div',
|
|
2935
|
+
{ style: S.meta },
|
|
2936
|
+
`${commitDetail.commit.hash.slice(0, 10)} · ${commitDetail.commit.author} · ${relativeTime(commitDetail.commit.at, t)} · ${commitDetail.files.length}`,
|
|
2937
|
+
),
|
|
2938
|
+
),
|
|
2939
|
+
commitDetail.files.map((entry) =>
|
|
2940
|
+
h(FileRow, {
|
|
2941
|
+
key: entry.path,
|
|
2942
|
+
file: entry,
|
|
2943
|
+
selected: commitFile === entry.path,
|
|
2944
|
+
t,
|
|
2945
|
+
onOpen: () => void openCommitFile(entry.path),
|
|
2946
|
+
}),
|
|
2947
|
+
),
|
|
2948
|
+
),
|
|
2949
|
+
h(
|
|
2950
|
+
'div',
|
|
2951
|
+
{ style: S.diffWrap },
|
|
2952
|
+
h(DiffPane, {
|
|
2953
|
+
text: commitDiff,
|
|
2954
|
+
empty: commit === null ? '' : t('empty.noCommitDiff'),
|
|
2955
|
+
actions: null,
|
|
2956
|
+
t,
|
|
2957
|
+
}),
|
|
2958
|
+
),
|
|
2959
|
+
)
|
|
2960
|
+
|
|
2961
|
+
return frame([
|
|
2962
|
+
header,
|
|
2963
|
+
tabs,
|
|
2964
|
+
notice === null ? null : h('div', { style: S.notice }, notice),
|
|
2965
|
+
// The commit box sits above the panes. It was moved here because the
|
|
2966
|
+
// floating composer used to own the bottom of this view; the
|
|
2967
|
+
// composer is now occluded (see S.root), so the bottom is free and
|
|
2968
|
+
// this is a plain layout choice — IDEA puts it at the bottom, which
|
|
2969
|
+
// is a one-line move if that reads better.
|
|
2970
|
+
mode === 'worktree' ? commitBox : null,
|
|
2971
|
+
mode === 'worktree'
|
|
2972
|
+
? h(
|
|
2973
|
+
'div',
|
|
2974
|
+
{ style: S.body },
|
|
2975
|
+
worktreeLeft,
|
|
2976
|
+
h(Splitter, { column: 'files', active: drag?.column === 'files', onStart: beginDrag, t }),
|
|
2977
|
+
worktreeRight,
|
|
2978
|
+
)
|
|
2979
|
+
: mode === 'history'
|
|
2980
|
+
? h(
|
|
2981
|
+
'div',
|
|
2982
|
+
{ style: S.body },
|
|
2983
|
+
branchColumn,
|
|
2984
|
+
h(Splitter, { column: 'branches', active: drag?.column === 'branches', onStart: beginDrag, t }),
|
|
2985
|
+
commitColumn,
|
|
2986
|
+
h(Splitter, { column: 'commits', active: drag?.column === 'commits', onStart: beginDrag, t }),
|
|
2987
|
+
detailColumn,
|
|
2988
|
+
)
|
|
2989
|
+
: reviewPane,
|
|
2990
|
+
])
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
/**
|
|
2994
|
+
* Client plugin body: register the dictionary and the view.
|
|
2995
|
+
* @param ctx - client root context.
|
|
2996
|
+
*/
|
|
2997
|
+
function apply(ctx) {
|
|
2998
|
+
ctx.effect(() => ctx.locale.register(NS, DICTS), 'ui-git: dictionaries')
|
|
2999
|
+
// Bound once: the function reads the active locale at call time, so a
|
|
3000
|
+
// locale switch only needs a re-render (the slot renderer re-renders
|
|
3001
|
+
// every outlet), not a re-registration.
|
|
3002
|
+
const t = ctx.locale.bind(NS)
|
|
3003
|
+
// `slots.inject` makes the contribution declaration-aware: it lands
|
|
3004
|
+
// whether or not `conversation` (the slot's owner) activated first.
|
|
3005
|
+
// The `@` source. This is the sanctioned way a plugin puts content in
|
|
3006
|
+
// front of the model: the source's `codec.serialize` decides what the
|
|
3007
|
+
// model actually receives when the message is sent.
|
|
3008
|
+
//
|
|
3009
|
+
// A SOFT dependency on purpose. `ctx.get` here is undefined until the
|
|
3010
|
+
// provider activates — reading it eagerly threw and took the whole
|
|
3011
|
+
// plugin down with it — while listing it in the exported `inject` would
|
|
3012
|
+
// be the opposite failure: a deployment without that package would
|
|
3013
|
+
// never mount this panel at all. `ctx.inject` waits for the service
|
|
3014
|
+
// without gating the plugin, and the review view is the part that must
|
|
3015
|
+
// never depend on an optional extra.
|
|
3016
|
+
ctx.inject(['inputTriggers'], (scoped) => {
|
|
3017
|
+
scoped.effect(
|
|
3018
|
+
() =>
|
|
3019
|
+
scoped.inputTriggers.registerSource({
|
|
3020
|
+
trigger: '@',
|
|
3021
|
+
name: 'git',
|
|
3022
|
+
order: 20,
|
|
3023
|
+
showGroupTitle: true,
|
|
3024
|
+
candidates: (session, req) => gitCandidates(ctx, session, req, t),
|
|
3025
|
+
onPick: (pick) => {
|
|
3026
|
+
const value = pick?.candidate?.value
|
|
3027
|
+
const decoded = decodeRef(value)
|
|
3028
|
+
if (decoded === null) return undefined
|
|
3029
|
+
return {
|
|
3030
|
+
insert: {
|
|
3031
|
+
source: 'git',
|
|
3032
|
+
ref: value,
|
|
3033
|
+
label: pick.candidate.name,
|
|
3034
|
+
appearance: decoded.kind === 'file' ? 'file' : undefined,
|
|
3035
|
+
clipboardText: refText(value),
|
|
3036
|
+
},
|
|
3037
|
+
}
|
|
3038
|
+
},
|
|
3039
|
+
codec: {
|
|
3040
|
+
clipboardText: (ref) => refText(ref),
|
|
3041
|
+
// Pure: the metadata is already in the reference, so this
|
|
3042
|
+
// cannot fail on a network hiccup and block the send.
|
|
3043
|
+
serialize: (ref) => Promise.resolve(refText(ref)),
|
|
3044
|
+
},
|
|
3045
|
+
}),
|
|
3046
|
+
'ui-git: @ source',
|
|
3047
|
+
)
|
|
3048
|
+
})
|
|
3049
|
+
|
|
3050
|
+
ctx.slots.inject('conversation.view', () =>
|
|
3051
|
+
ctx.slots.register(
|
|
3052
|
+
{
|
|
3053
|
+
name: 'conversation.view',
|
|
3054
|
+
id: 'git',
|
|
3055
|
+
order: 30,
|
|
3056
|
+
locale: NS,
|
|
3057
|
+
label: () => 'Git',
|
|
3058
|
+
// The slot reports the active session id; its working
|
|
3059
|
+
// directory is the repository this panel opens on.
|
|
3060
|
+
inject: (sessionId) => ({ sessionRoot: sessionCwd(ctx, sessionId), t }),
|
|
3061
|
+
},
|
|
3062
|
+
GitView,
|
|
3063
|
+
),
|
|
3064
|
+
)
|
|
3065
|
+
|
|
3066
|
+
// The branch chip in the composer's accessory row.
|
|
3067
|
+
//
|
|
3068
|
+
// `conversation.input.left` is the shell's list slot for the
|
|
3069
|
+
// controls on the left of the composer's bottom row. It is empty in
|
|
3070
|
+
// the first-party shell, which is what makes it the right home for
|
|
3071
|
+
// a chip that says where you are: it sits next to the permission
|
|
3072
|
+
// selector and the model picker, not inside the message.
|
|
3073
|
+
//
|
|
3074
|
+
// Registered AFTER the view on purpose: the registration order is
|
|
3075
|
+
// the order the shell renders, and the panel is the plugin's main
|
|
3076
|
+
// event.
|
|
3077
|
+
ctx.slots.inject('conversation.input.left', () =>
|
|
3078
|
+
ctx.slots.register(
|
|
3079
|
+
{
|
|
3080
|
+
name: 'conversation.input.left',
|
|
3081
|
+
id: 'git-branch',
|
|
3082
|
+
order: 10,
|
|
3083
|
+
locale: NS,
|
|
3084
|
+
// The session's working directory is the repository the
|
|
3085
|
+
// chip reports on — the same one the panel opens on.
|
|
3086
|
+
inject: (sessionId) => ({ root: sessionCwd(ctx, sessionId) ?? '' }),
|
|
3087
|
+
},
|
|
3088
|
+
BranchChip,
|
|
3089
|
+
),
|
|
3090
|
+
)
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
exports.apply = apply
|
|
3094
|
+
exports.inject = inject
|
|
3095
|
+
exports.GitView = GitView
|
|
3096
|
+
// Exported so the lane assignment, the hunk split and the column-width
|
|
3097
|
+
// validation can be unit-tested without a DOM. The hunk split must stay
|
|
3098
|
+
// identical to the host's.
|
|
3099
|
+
exports.computeLanes = computeLanes
|
|
3100
|
+
exports.splitHunks = splitHunks
|
|
3101
|
+
exports.DICTS = DICTS
|
|
3102
|
+
exports.sessionCwd = sessionCwd
|
|
3103
|
+
exports.rankReviewFiles = rankReviewFiles
|
|
3104
|
+
exports.encodeFileRef = encodeFileRef
|
|
3105
|
+
exports.encodeCommitRef = encodeCommitRef
|
|
3106
|
+
exports.decodeRef = decodeRef
|
|
3107
|
+
exports.refText = refText
|
|
3108
|
+
exports.describeFileRef = describeFileRef
|
|
3109
|
+
exports.describeCommitRef = describeCommitRef
|
|
3110
|
+
exports.gitCandidates = gitCandidates
|
|
3111
|
+
exports.readMarkers = readMarkers
|
|
3112
|
+
exports.MARKER_KEY = MARKER_KEY
|
|
3113
|
+
exports.fillHeightFor = fillHeightFor
|
|
3114
|
+
exports.clampWidth = clampWidth
|
|
3115
|
+
exports.nextWidth = nextWidth
|
|
3116
|
+
exports.readWidths = readWidths
|
|
3117
|
+
exports.COLUMN_LIMITS = COLUMN_LIMITS
|
|
3118
|
+
exports.STORAGE_KEY = STORAGE_KEY
|
|
3119
|
+
// Exported so the bubble's placement arithmetic and its truncation gate
|
|
3120
|
+
// can be unit-tested without a DOM.
|
|
3121
|
+
exports.bubblePosition = bubblePosition
|
|
3122
|
+
exports.isTruncated = isTruncated
|
|
3123
|
+
exports.reveal = reveal
|
|
3124
|
+
exports.BUBBLE_STYLE = BUBBLE_STYLE
|
|
3125
|
+
exports.BUBBLE_DELAY_MS = BUBBLE_DELAY_MS
|
|
3126
|
+
// Exported so the composer's branch chip can be rendered on its own and
|
|
3127
|
+
// checked against a mocked host.
|
|
3128
|
+
exports.BranchChip = BranchChip
|
|
3129
|
+
exports.BranchIcon = BranchIcon
|
|
3130
|
+
exports.BRANCH_POLL_MS = BRANCH_POLL_MS
|
|
3131
|
+
return module.exports
|
|
3132
|
+
},
|
|
3133
|
+
})
|