@xiaoming_z/dsh-git 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ })