dsh-gitbar-lite 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,2154 @@
1
+ /**
2
+ * dsh-gitbar-lite — browser half.
3
+ *
4
+ * A hand-written lazy-CJS bundle: `window.__ModuleLoader__.load({ id, factory })`
5
+ * with a `factory` that requires React and nothing else. No build step, no JSX,
6
+ * no TypeScript. `@deepseek-ai/dsh-client-ui-primitives` is deliberately NOT
7
+ * required: a throwing import blanks the whole slot entry, the seed module table
8
+ * is a runtime contract this plugin would rather not widen, and the official
9
+ * authoring checklist asks a client bundle to import no Harness Client package.
10
+ * The handful of controls it would have supplied are drawn here against the same
11
+ * design tokens, and the icons below are the host's own artwork copied
12
+ * path-for-path so the column does not look foreign.
13
+ *
14
+ * Three tab types, all in the built-in right sidebar:
15
+ *
16
+ * `gitbar` the panel — changed files, tick-to-stage, Summary +
17
+ * Description, Commit / Commit and Push, one context button
18
+ * that is Push or Fetch depending on the branch, discard with
19
+ * a confirmation box, and a repository picker for a workspace
20
+ * that is not a repository.
21
+ * `gitbar-trash` the plugin's own trash: the diffs discarded changes left
22
+ * behind, with the base commit each one was taken against.
23
+ * `gitbar-diff` one diff, in a single reused tab. A `dsh-resource://`
24
+ * address (rather than a page kind) is what makes it a
25
+ * singleton: the host reveals an already-open resource instead
26
+ * of stacking a second copy, and a fresh `params` payload
27
+ * re-navigates the tab that is already there.
28
+ *
29
+ * The panel and the trash tab are page kinds, opened by kind; only the panel
30
+ * carries a guide card, because it is the one the user needs to find.
31
+ *
32
+ * @module dsh-gitbar-lite/client
33
+ */
34
+
35
+ window.__ModuleLoader__.load({
36
+ id: 'dsh-gitbar-lite',
37
+ factory: (require) => {
38
+ // The loader passes `require` but no `module`, so every bundle declares its
39
+ // own CommonJS-ish pair, exactly as the shipped bundles do.
40
+ const module = { exports: {} }
41
+ const exports = module.exports
42
+
43
+ const React = require('react')
44
+ const h = React.createElement
45
+ const useState = React.useState
46
+ const useEffect = React.useEffect
47
+ const useCallback = React.useCallback
48
+ const useMemo = React.useMemo
49
+ const useRef = React.useRef
50
+
51
+ /* ------------------------------------------------------------------ *
52
+ * 1. Wire contract — mirror of lib/shared.js
53
+ * ------------------------------------------------------------------ */
54
+
55
+ /** Package name; also the module-loader id and the locale namespace. */
56
+ const PLUGIN_ID = 'dsh-gitbar-lite'
57
+
58
+ /** The one exact host route this half talks to. */
59
+ const RPC_ROUTE = '/api/dsh-gitbar-lite/rpc'
60
+
61
+ /** Every action name the host dispatches on (mirror of `ACTIONS`). */
62
+ const ACTIONS = {
63
+ STATUS: 'status',
64
+ BIND: 'bind',
65
+ REPOS: 'repos',
66
+ STAGE: 'stage',
67
+ COMMIT: 'commit',
68
+ COMMIT_PUSH: 'commitAndPush',
69
+ PUSH: 'push',
70
+ FETCH: 'fetch',
71
+ DISCARD: 'discard',
72
+ FILE_DIFF: 'fileDiff',
73
+ TRASH_LIST: 'trashList',
74
+ TRASH_DIFF: 'trashDiff',
75
+ TRASH_DELETE: 'trashDelete',
76
+ TRASH_CLEAR: 'trashClear',
77
+ PREFS: 'prefs',
78
+ }
79
+
80
+ /** Which label the single context button wears (mirror of `PRIMARY_ACTIONS`). */
81
+ const PRIMARY = { PUSH: 'push', PUBLISH: 'publish', FETCH: 'fetch' }
82
+
83
+ /** Diff-scope badge on a trash entry (mirror of `DISCARD_SCOPE`). */
84
+ const SCOPE = { FILE: 'file', ALL: 'all' }
85
+
86
+ /* ------------------------------------------------------------------ *
87
+ * 2. Locale namespace, tab identities, and the dictionary
88
+ * ------------------------------------------------------------------ */
89
+
90
+ const NS = PLUGIN_ID
91
+
92
+ /** The panel's tab type. `id` is also the slot key its body registers under. */
93
+ const GIT_ID = `${PLUGIN_ID}/git`
94
+ const GIT_KIND = 'gitbar'
95
+
96
+ /** The trash tab type. */
97
+ const TRASH_ID = `${PLUGIN_ID}/trash`
98
+ const TRASH_KIND = 'gitbar-trash'
99
+
100
+ /**
101
+ * The diff tab type.
102
+ *
103
+ * `dsh-resource://` with a fixed address, not a page kind: re-opening the
104
+ * same address reveals the tab that is already open (the host looks the
105
+ * content id up across the whole layout before planning a new tab), and the
106
+ * `params` payload re-navigates it. That is exactly "one diff tab, ever".
107
+ */
108
+ const DIFF_ID = `${PLUGIN_ID}/diff`
109
+ const DIFF_KIND = 'gitbar-diff'
110
+ const DIFF_ADDRESS = 'dsh-resource://dsh-gitbar-lite/diff/current'
111
+ const DIFF_PATTERN = 'dsh-resource://dsh-gitbar-lite/diff/**'
112
+
113
+ /** Where the guide card sits: after the shipped files(10) and terminal(20). */
114
+ const GUIDE_ORDER = 30
115
+
116
+ /**
117
+ * Copy.
118
+ *
119
+ * This inline pair is the fallback for a composition without the locale
120
+ * service (the browser harness is one), and it is also what gets registered
121
+ * with the service when there is one. `test/check.mjs` compares both
122
+ * dictionaries against `locale/zh.json` and `locale/en.json` so a key added
123
+ * here cannot silently miss the shipped files.
124
+ */
125
+ const ZH = {
126
+ 'meta.title': 'Git 栏(轻量)',
127
+ 'meta.description': 'GitHub Desktop 风格的 Git 面板:变更文件、勾选提交、舍弃进回收站、Commit and Push、diff 预览。',
128
+
129
+ 'panel.title': 'Git',
130
+ 'guide.title': 'Git 更改',
131
+ 'guide.description': '查看改动、勾选要提交的文件、写 Summary 并提交或推送;双击文件看 diff。',
132
+
133
+ 'state.loading': '正在读取仓库…',
134
+ 'state.retry': '重试',
135
+ 'state.noRepo': '没有输出',
136
+
137
+ 'action.fetch': 'Fetch',
138
+ 'action.push': 'Push',
139
+ 'action.publish': 'Publish',
140
+ 'action.refresh': '刷新',
141
+ 'action.trash': '打开回收站',
142
+ 'action.rebind': '换绑仓库',
143
+ 'action.unbind': '解除绑定',
144
+ 'action.discard': '舍弃这个文件的更改',
145
+ 'action.discardAll': '全部舍弃',
146
+ 'action.cancel': '取消',
147
+ 'action.confirm': '舍弃',
148
+ 'action.delete': '删除这条记录',
149
+ 'action.clear': '清空回收站',
150
+ 'action.busy': '处理中…',
151
+
152
+ 'changes.title': '更改',
153
+ 'changes.empty': '工作区干净,没有要提交的更改。',
154
+ 'changes.summary': '{files} 个文件 · 已勾选 {staged}',
155
+
156
+ 'commit.summaryPlaceholder': 'Summary(必填)',
157
+ 'commit.descriptionPlaceholder': 'Description(可选)',
158
+ 'commit.expand': '展开 Description',
159
+ 'commit.collapse': '收起 Description',
160
+ 'commit.commit': 'Commit',
161
+ 'commit.commitAndPush': 'Commit and Push',
162
+ 'commit.needsSummary': '先写 Summary',
163
+ 'commit.needsStaged': '先勾选要提交的文件',
164
+
165
+ 'discard.one': '舍弃「{path}」的更改?',
166
+ 'discard.all': '舍弃全部 {files} 个文件的更改?',
167
+ 'discard.more': '还有 {n} 个…',
168
+ 'discard.hard': '彻底删除(不存入回收站)',
169
+ 'discard.keep': '这次的 diff 会存进插件的回收站,之后还能回看是改了什么。',
170
+ 'discard.hardHint': '这次不会留下任何记录,舍弃之后就找不回来了。',
171
+
172
+ 'bind.title': '这个工作区不是 Git 仓库',
173
+ 'bind.hint': '选择一个要绑定的仓库;绑定会记住,下次打开这个工作区还是它。',
174
+ 'bind.pathPlaceholder': '也可以直接填一个仓库目录的绝对路径',
175
+ 'bind.bind': '绑定',
176
+ 'bind.bindPath': '绑定这个路径',
177
+ 'bind.rescan': '重新扫描',
178
+ 'bind.scanning': '正在扫描…',
179
+ 'bind.noCandidates': '工作区里(3 层以内)没找到 Git 仓库,直接填路径吧。',
180
+ 'bind.truncated': '目录太多,扫描提前停止了。',
181
+ 'bind.current': '已绑定:{path}',
182
+ 'bind.notARepo': '这个目录不是 Git 仓库的根目录。',
183
+
184
+ 'trash.title': '回收站',
185
+ 'trash.empty': '回收站是空的。被舍弃的更改会连同当时的 diff 一起存在这里。',
186
+ 'trash.count': '{n} 条记录',
187
+ 'trash.scope.file': '单个文件',
188
+ 'trash.scope.all': '全部更改',
189
+ 'trash.base': '基于 {commit}',
190
+ 'trash.detail': '{files} 个文件 · +{added} −{deleted}',
191
+ 'trash.showAll': '显示全部仓库',
192
+ 'trash.onlyThis': '只看这个仓库',
193
+ 'trash.clearConfirm': '清空 {n} 条回收站记录?',
194
+ 'trash.clearHint': '这只是删掉归档的 diff,不会影响已经还原的工作区。',
195
+ 'trash.truncated': '(这条记录在归档时被截断了)',
196
+
197
+ 'diff.title': 'Diff',
198
+ 'diff.loading': '正在生成 diff…',
199
+ 'diff.empty': '没有可显示的 diff。',
200
+ 'diff.binary': '二进制文件,不显示内容。',
201
+ 'diff.truncated': 'diff 太长,已截断。',
202
+ 'diff.trashEntry': '回收站记录',
203
+
204
+ 'time.now': '刚刚',
205
+ 'time.minutes': '{n} 分钟前',
206
+ 'time.hours': '{n} 小时前',
207
+ 'time.days': '{n} 天前',
208
+
209
+ 'err.E_BAD_REQUEST': '请求不合法。',
210
+ 'err.E_UNKNOWN_ACTION': '宿主不认识这个操作(插件版本可能不一致)。',
211
+ 'err.E_NO_WORKSPACE': '拿不到这个会话的工作区目录。',
212
+ 'err.E_NO_REPO': '还没有绑定 Git 仓库。',
213
+ 'err.E_NO_GIT': '本机找不到 git 可执行文件。装一个 Git,或在插件设置里指定 gitPath。',
214
+ 'err.E_EMPTY_SUMMARY': 'Summary 不能为空。',
215
+ 'err.E_NOTHING_STAGED': '没有勾选任何改动。',
216
+ 'err.E_CONFLICT': '仓库里有未解决的冲突,先解决它们。',
217
+ 'err.E_GIT_FAILED': 'git 命令失败了。',
218
+ 'err.E_NO_REMOTE': '这个仓库没有配置远端(remote)。',
219
+ 'err.E_NO_UPSTREAM': '这个分支还没有上游。',
220
+ 'err.E_AUTH': '推送认证失败:本机 git 没有可用凭据。先在终端里成功 push 一次。',
221
+ 'err.E_PUSH_REJECTED': '推送被拒绝:远端有你本地没有的提交,先 Fetch 再推。',
222
+ 'err.E_TRASH_NOT_FOUND': '这条回收站记录已经不在了。',
223
+ 'err.E_INTERNAL': '插件内部出错了。',
224
+ 'err.E_TRANSPORT': '和 DSH 宿主通信失败。',
225
+ 'err.E_BAD_RESPONSE': '宿主返回了无法解析的响应。',
226
+ 'err.E_UNKNOWN': '操作失败。',
227
+
228
+ 'toast.committed': '已提交 {commit}',
229
+ 'toast.pushed': '已推送',
230
+ 'toast.published': '已发布到这个远端',
231
+ 'toast.fetched': '已 Fetch',
232
+ 'toast.discarded': '已舍弃 {n} 个文件的更改',
233
+ 'toast.trashed': '已舍弃 {n} 个文件,diff 存进了回收站',
234
+ 'toast.bound': '已绑定这个仓库',
235
+ 'toast.unbound': '已解除绑定',
236
+ 'toast.trashCleared': '回收站已清空({n} 条)',
237
+ 'toast.trashDeleted': '已删除那条记录',
238
+ 'toast.pushFailed': '已提交 {commit},但推送失败',
239
+ }
240
+
241
+ const EN = {
242
+ 'meta.title': 'Git Bar (lite)',
243
+ 'meta.description': 'A GitHub Desktop-style Git panel: changed files, tick-to-stage, commit, commit and push, discard into a trash, and diff preview.',
244
+
245
+ 'panel.title': 'Git',
246
+ 'guide.title': 'Git changes',
247
+ 'guide.description': 'Review changes, tick what to commit, write a Summary and commit or push; double-click a file for its diff.',
248
+
249
+ 'state.loading': 'Reading the repository…',
250
+ 'state.retry': 'Retry',
251
+ 'state.noRepo': 'Nothing to show',
252
+
253
+ 'action.fetch': 'Fetch',
254
+ 'action.push': 'Push',
255
+ 'action.publish': 'Publish',
256
+ 'action.refresh': 'Refresh',
257
+ 'action.trash': 'Open the trash',
258
+ 'action.rebind': 'Change repository',
259
+ 'action.unbind': 'Unbind',
260
+ 'action.discard': 'Discard this file’s changes',
261
+ 'action.discardAll': 'Discard all',
262
+ 'action.cancel': 'Cancel',
263
+ 'action.confirm': 'Discard',
264
+ 'action.delete': 'Delete this entry',
265
+ 'action.clear': 'Empty the trash',
266
+ 'action.busy': 'Working…',
267
+
268
+ 'changes.title': 'Changes',
269
+ 'changes.empty': 'The working tree is clean — nothing to commit.',
270
+ 'changes.summary': '{files} files · {staged} ticked',
271
+
272
+ 'commit.summaryPlaceholder': 'Summary (required)',
273
+ 'commit.descriptionPlaceholder': 'Description (optional)',
274
+ 'commit.expand': 'Show the Description field',
275
+ 'commit.collapse': 'Hide the Description field',
276
+ 'commit.commit': 'Commit',
277
+ 'commit.commitAndPush': 'Commit and Push',
278
+ 'commit.needsSummary': 'Write a Summary first',
279
+ 'commit.needsStaged': 'Tick a file first',
280
+
281
+ 'discard.one': 'Discard the changes to “{path}”?',
282
+ 'discard.all': 'Discard the changes to all {files} files?',
283
+ 'discard.more': 'and {n} more…',
284
+ 'discard.hard': 'Delete for good (keep no copy)',
285
+ 'discard.keep': 'This diff is stored in the plugin’s trash, so you can still see what was discarded.',
286
+ 'discard.hardHint': 'Nothing is kept. Once discarded, this change cannot be recovered.',
287
+
288
+ 'bind.title': 'This workspace is not a Git repository',
289
+ 'bind.hint': 'Pick a repository to bind. The binding is remembered, so this workspace opens on it next time too.',
290
+ 'bind.pathPlaceholder': 'Or type the absolute path of a repository',
291
+ 'bind.bind': 'Bind',
292
+ 'bind.bindPath': 'Bind this path',
293
+ 'bind.rescan': 'Scan again',
294
+ 'bind.scanning': 'Scanning…',
295
+ 'bind.noCandidates': 'No Git repository found within three levels; type a path instead.',
296
+ 'bind.truncated': 'There were too many directories, so the scan stopped early.',
297
+ 'bind.current': 'Bound to {path}',
298
+ 'bind.notARepo': 'That directory is not the root of a Git repository.',
299
+
300
+ 'trash.title': 'Trash',
301
+ 'trash.empty': 'The trash is empty. Discarded changes are archived here with the diff they had at the time.',
302
+ 'trash.count': '{n} entries',
303
+ 'trash.scope.file': 'one file',
304
+ 'trash.scope.all': 'all changes',
305
+ 'trash.base': 'against {commit}',
306
+ 'trash.detail': '{files} files · +{added} −{deleted}',
307
+ 'trash.showAll': 'Show every repository',
308
+ 'trash.onlyThis': 'Only this repository',
309
+ 'trash.clearConfirm': 'Empty {n} trash entries?',
310
+ 'trash.clearHint': 'This only deletes the archived diffs; the restored working tree is untouched.',
311
+ 'trash.truncated': '(this entry was truncated when archived)',
312
+
313
+ 'diff.title': 'Diff',
314
+ 'diff.loading': 'Building the diff…',
315
+ 'diff.empty': 'There is no diff to show.',
316
+ 'diff.binary': 'Binary file — contents not shown.',
317
+ 'diff.truncated': 'The diff was too long and has been truncated.',
318
+ 'diff.trashEntry': 'Trash entry',
319
+
320
+ 'time.now': 'just now',
321
+ 'time.minutes': '{n} min ago',
322
+ 'time.hours': '{n} h ago',
323
+ 'time.days': '{n} d ago',
324
+
325
+ 'err.E_BAD_REQUEST': 'The request was not valid.',
326
+ 'err.E_UNKNOWN_ACTION': 'The host does not know that action (plugin versions may not match).',
327
+ 'err.E_NO_WORKSPACE': 'This session has no workspace directory to look in.',
328
+ 'err.E_NO_REPO': 'No Git repository is bound yet.',
329
+ 'err.E_NO_GIT': 'No git executable was found. Install Git, or set gitPath in the plugin settings.',
330
+ 'err.E_EMPTY_SUMMARY': 'The Summary cannot be empty.',
331
+ 'err.E_NOTHING_STAGED': 'Nothing is ticked.',
332
+ 'err.E_CONFLICT': 'The repository has unresolved conflicts; resolve them first.',
333
+ 'err.E_GIT_FAILED': 'A git command failed.',
334
+ 'err.E_NO_REMOTE': 'This repository has no remote configured.',
335
+ 'err.E_NO_UPSTREAM': 'This branch has no upstream yet.',
336
+ 'err.E_AUTH': 'Push authentication failed: the local git has no usable credential. Push once from a terminal first.',
337
+ 'err.E_PUSH_REJECTED': 'The push was rejected: the remote has commits you do not. Fetch, then push.',
338
+ 'err.E_TRASH_NOT_FOUND': 'That trash entry is gone.',
339
+ 'err.E_INTERNAL': 'The plugin hit an internal error.',
340
+ 'err.E_TRANSPORT': 'Could not reach the DSH host.',
341
+ 'err.E_BAD_RESPONSE': 'The host returned a response that could not be read.',
342
+ 'err.E_UNKNOWN': 'The operation failed.',
343
+
344
+ 'toast.committed': 'Committed {commit}',
345
+ 'toast.pushed': 'Pushed',
346
+ 'toast.published': 'Published this branch to the remote',
347
+ 'toast.fetched': 'Fetched',
348
+ 'toast.discarded': 'Discarded the changes to {n} files',
349
+ 'toast.trashed': 'Discarded {n} files; the diff went to the trash',
350
+ 'toast.bound': 'Bound to that repository',
351
+ 'toast.unbound': 'Binding cleared',
352
+ 'toast.trashCleared': 'Trash emptied ({n} entries)',
353
+ 'toast.trashDeleted': 'Entry deleted',
354
+ 'toast.pushFailed': 'Committed {commit}, but the push failed',
355
+ }
356
+
357
+ /** Interpolate `{name}` placeholders; the one syntax the locale service has. */
358
+ function interpolate(template, params) {
359
+ if (params === undefined) return template
360
+ return String(template).replace(/\{(\w+)\}/g, (match, name) =>
361
+ Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match,
362
+ )
363
+ }
364
+
365
+ /** `true` when the browser is set to Chinese, for the no-locale-service case. */
366
+ function prefersChinese() {
367
+ const tag = typeof navigator === 'undefined' ? '' : String(navigator.language ?? '')
368
+ return tag.toLowerCase().startsWith('zh')
369
+ }
370
+
371
+ /** The translate function used when the locale service is absent. */
372
+ function fallbackTranslate() {
373
+ const table = prefersChinese() ? ZH : EN
374
+ return (key, params) => interpolate(Object.prototype.hasOwnProperty.call(table, key) ? table[key] : key, params)
375
+ }
376
+
377
+ /** The host's own message for a code, or its text, or the raw code. */
378
+ function errorText(t, code, fallback) {
379
+ const key = `err.${code}`
380
+ const localised = t(key)
381
+ if (localised !== key) return localised
382
+ return typeof fallback === 'string' && fallback !== '' ? fallback : code
383
+ }
384
+
385
+ /* ------------------------------------------------------------------ *
386
+ * 3. Small pure helpers (covered by the unit tests)
387
+ * ------------------------------------------------------------------ */
388
+
389
+ const KIND_LETTER = {
390
+ added: 'A',
391
+ deleted: 'D',
392
+ renamed: 'R',
393
+ conflicted: '!',
394
+ untracked: '?',
395
+ modified: 'M',
396
+ }
397
+
398
+ /** The single character git itself would print in `status --short`. */
399
+ function statusLetter(file) {
400
+ if (file === null || typeof file !== 'object') return 'M'
401
+ if (file.conflicted === true) return '!'
402
+ if (file.untracked === true) return '?'
403
+ return KIND_LETTER[file.kind] ?? 'M'
404
+ }
405
+
406
+ /** Split a repository path into the directory part and the file name. */
407
+ function splitPath(path) {
408
+ const text = String(path ?? '')
409
+ const cut = text.lastIndexOf('/')
410
+ return cut < 0 ? { dir: '', name: text } : { dir: text.slice(0, cut + 1), name: text.slice(cut + 1) }
411
+ }
412
+
413
+ /**
414
+ * Classify the lines of a unified diff for rendering.
415
+ *
416
+ * Line numbers are tracked from each `@@` header so the gutter can show the
417
+ * old and new line for every row, which is most of what makes a diff
418
+ * readable. Everything before the first hunk is `meta` (the `diff --git`,
419
+ * `index`, `---`/`+++` block), and the header of each file starts a `file`
420
+ * group so a multi-file blob still reads as separate files.
421
+ *
422
+ * @returns {{ kind: string, text: string, oldLine?: number, newLine?: number, path?: string }[]}
423
+ */
424
+ function diffRows(text) {
425
+ const source = typeof text === 'string' ? text : ''
426
+ if (source === '') return []
427
+ const lines = source.split('\n')
428
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
429
+
430
+ const rows = []
431
+ let oldLine = 0
432
+ let newLine = 0
433
+ let inHunk = false
434
+
435
+ for (const line of lines) {
436
+ if (line.startsWith('diff --git ')) {
437
+ inHunk = false
438
+ const body = line.slice('diff --git '.length)
439
+ const halves = body.split(' ')
440
+ const right = halves.length >= 2 ? halves[halves.length - 1] : body
441
+ rows.push({ kind: 'file', text: line, path: right.startsWith('b/') ? right.slice(2) : right })
442
+ continue
443
+ }
444
+ if (line.startsWith('@@')) {
445
+ const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
446
+ if (match !== null) {
447
+ oldLine = Number(match[1])
448
+ newLine = Number(match[2])
449
+ }
450
+ inHunk = true
451
+ rows.push({ kind: 'hunk', text: line })
452
+ continue
453
+ }
454
+ if (!inHunk) {
455
+ rows.push({ kind: 'meta', text: line })
456
+ continue
457
+ }
458
+ if (line.startsWith('+')) {
459
+ rows.push({ kind: 'add', text: line.slice(1), newLine })
460
+ newLine += 1
461
+ continue
462
+ }
463
+ if (line.startsWith('-')) {
464
+ rows.push({ kind: 'del', text: line.slice(1), oldLine })
465
+ oldLine += 1
466
+ continue
467
+ }
468
+ if (line.startsWith('\\')) {
469
+ rows.push({ kind: 'note', text: line })
470
+ continue
471
+ }
472
+ const content = line.startsWith(' ') ? line.slice(1) : line
473
+ rows.push({ kind: 'ctx', text: content, oldLine, newLine })
474
+ oldLine += 1
475
+ newLine += 1
476
+ }
477
+ return rows
478
+ }
479
+
480
+ /**
481
+ * A coarse relative time, localized by the copy table.
482
+ *
483
+ * @param {string} at ISO timestamp
484
+ * @param {number} now epoch milliseconds
485
+ * @param {(key: string, params?: object) => string} t
486
+ */
487
+ function formatRelative(at, now, t) {
488
+ const when = Date.parse(String(at ?? ''))
489
+ if (!Number.isFinite(when)) return ''
490
+ const seconds = Math.max(0, Math.round((Number(now) - when) / 1000))
491
+ if (seconds < 90) return t('time.now')
492
+ const minutes = Math.round(seconds / 60)
493
+ if (minutes < 60) return t('time.minutes', { n: minutes })
494
+ const hours = Math.round(minutes / 60)
495
+ if (hours < 24) return t('time.hours', { n: hours })
496
+ const days = Math.round(hours / 24)
497
+ if (days <= 30) return t('time.days', { n: days })
498
+ try {
499
+ return new Date(when).toLocaleDateString()
500
+ } catch {
501
+ return String(at)
502
+ }
503
+ }
504
+
505
+ /** How many of a status payload's files are ticked and commit-ready. */
506
+ function stagedCount(state) {
507
+ if (state === null || !Array.isArray(state.files)) return 0
508
+ return state.files.filter((file) => file.staged === true && file.conflicted !== true).length
509
+ }
510
+
511
+ /** Whether a status payload has anything that cannot be committed as-is. */
512
+ function hasConflict(state) {
513
+ return state !== null && (state.counts?.conflicted ?? 0) > 0
514
+ }
515
+
516
+ /** The label the single context button wears, from the branch state. */
517
+ function contextLabelKey(state) {
518
+ if (state === null) return 'action.fetch'
519
+ if (state.primary === PRIMARY.PUSH) return 'action.push'
520
+ if (state.primary === PRIMARY.PUBLISH) return 'action.publish'
521
+ return 'action.fetch'
522
+ }
523
+
524
+ /** The action the single context button performs. */
525
+ function contextAction(state) {
526
+ return state !== null && state.primary === PRIMARY.PUSH ? ACTIONS.PUSH : ACTIONS.FETCH
527
+ }
528
+
529
+ /**
530
+ * Whether the commit buttons may be pressed, and why not when they may not.
531
+ *
532
+ * Extracted from the component so the rule can be asserted without a DOM: a
533
+ * commit needs a Summary and at least one ticked, non-conflicted file, and
534
+ * nothing else may be in flight. The host enforces the same rules again —
535
+ * this is only what makes the button honest.
536
+ *
537
+ * @returns {{ canCommit: boolean, reasonKey: string | null }}
538
+ */
539
+ function commitReadiness(state, summary, busy) {
540
+ const ready = stagedCount(state)
541
+ if (hasConflict(state)) return { canCommit: false, reasonKey: 'err.E_CONFLICT' }
542
+ if (String(summary ?? '').trim() === '') return { canCommit: false, reasonKey: 'commit.needsSummary' }
543
+ if (ready === 0) return { canCommit: false, reasonKey: 'commit.needsStaged' }
544
+ if (busy !== null && busy !== undefined) return { canCommit: false, reasonKey: null }
545
+ return { canCommit: true, reasonKey: null }
546
+ }
547
+
548
+ /* ------------------------------------------------------------------ *
549
+ * 4. Icons — the host's own artwork, copied path for path
550
+ * ------------------------------------------------------------------ */
551
+
552
+ /**
553
+ * Build an icon on the host's 16×16 / currentColor grid.
554
+ *
555
+ * The host puts `stroke-width` on the `<svg>` and `stroke="currentColor"` on
556
+ * each path; `getComputedStyle(svg).stroke` is `none`, so the path is where
557
+ * the colour actually lives. `Regular` weight is 1.
558
+ */
559
+ function artwork(viewBox, paths, circles) {
560
+ const Ink = ({ size = 16, className, strokeWidth }) =>
561
+ h(
562
+ 'svg',
563
+ {
564
+ width: size,
565
+ height: size,
566
+ className,
567
+ viewBox,
568
+ fill: 'none',
569
+ xmlns: 'http://www.w3.org/2000/svg',
570
+ 'aria-hidden': 'true',
571
+ strokeWidth: strokeWidth === undefined ? 1 : strokeWidth,
572
+ },
573
+ [
574
+ ...paths.map((d, index) => h('path', { key: `p${index}`, d, stroke: 'currentColor' })),
575
+ ...circles.map((circle, index) =>
576
+ h('circle', {
577
+ key: `c${index}`,
578
+ cx: circle.cx,
579
+ cy: circle.cy,
580
+ r: circle.r,
581
+ stroke: 'currentColor',
582
+ }),
583
+ ),
584
+ ],
585
+ )
586
+ return Ink
587
+ }
588
+
589
+ /** `IconBranchOutlineRegular` — the panel's own mark. */
590
+ const GitGlyph = artwork(
591
+ '0 0 16 16',
592
+ [
593
+ 'M1.01503 8.0001L5.6964 8.0001C6.41913 8.0001 6.78049 8.0001 7.12115 7.91951C7.4232 7.84804 7.71233 7.73014 7.97821 7.57C8.27809 7.38939 8.5364 7.13669 9.05303 6.63129L11.3281 4.40564',
594
+ 'M1.01221 7.9999L5.6964 7.9999C6.41913 7.9999 6.78049 7.9999 7.12115 8.08049C7.4232 8.15196 7.71233 8.26986 7.97821 8.43C8.27809 8.61061 8.5364 8.86331 9.05303 9.36871L11.3281 11.5944',
595
+ ],
596
+ [
597
+ { cx: 12.4502, cy: 3.3079, r: 1.56962 },
598
+ { cx: 12.4502, cy: 12.6921, r: 1.56962 },
599
+ ],
600
+ )
601
+
602
+ /** `IconTrashOutlineRegular`. */
603
+ const TrashGlyph = artwork(
604
+ '0 0 16 16',
605
+ [
606
+ 'M1.28149 3.88831H14.7187',
607
+ 'M5.41602 3.88833V2.47962C5.41602 2.29282 5.52492 2.11366 5.71876 1.98157C5.9126 1.84948 6.17551 1.77527 6.44964 1.77527H9.55053C9.82466 1.77527 10.0876 1.84948 10.2814 1.98157C10.4753 2.11366 10.5842 2.29282 10.5842 2.47962V3.88833',
608
+ 'M2.57349 3.88831L3.19366 13.2943C3.21937 13.5502 3.33952 13.7872 3.53065 13.9593C3.72178 14.1313 3.97016 14.2259 4.22729 14.2246H11.7728C12.0299 14.2259 12.2783 14.1313 12.4694 13.9593C12.6605 13.7872 12.7807 13.5502 12.8064 13.2943L13.4266 3.88831',
609
+ 'M6.44946 6.98926V11.1238',
610
+ 'M9.55054 6.98926V11.1238',
611
+ ],
612
+ [],
613
+ )
614
+
615
+ /** `IconArchiveOutlineRegular`. */
616
+ const ArchiveGlyph = artwork(
617
+ '0 0 16 16',
618
+ [
619
+ 'M13.5 2.5H2.5C1.94772 2.5 1.5 2.94772 1.5 3.5V4.5C1.5 5.05228 1.94772 5.5 2.5 5.5H13.5C14.0523 5.5 14.5 5.05228 14.5 4.5V3.5C14.5 2.94772 14.0523 2.5 13.5 2.5Z',
620
+ 'M2.5 5.5V13.5C2.5 13.7652 2.60536 14.0196 2.79289 14.2071C2.98043 14.3946 3.23478 14.5 3.5 14.5H12.5C12.7652 14.5 13.0196 14.3946 13.2071 14.2071C13.3946 14.0196 13.5 13.7652 13.5 13.5V5.5',
621
+ 'M6.5 9.5H9.5',
622
+ ],
623
+ [],
624
+ )
625
+
626
+ /** `IconRefreshOutlineRegular`. */
627
+ const RefreshGlyph = artwork(
628
+ '0 0 16 16',
629
+ [
630
+ 'M14.5001 8C14.5 9.28552 14.1188 10.5422 13.4045 11.611C12.6903 12.6799 11.6752 13.5129 10.4875 14.0049C9.29982 14.4968 7.99295 14.6255 6.73212 14.3747C5.4713 14.124 4.31314 13.505 3.4041 12.596C2.49514 11.687 1.87614 10.5288 1.62537 9.26798C1.37459 8.00716 1.50331 6.70028 1.99525 5.51261C2.48719 4.32494 3.32025 3.30981 4.3891 2.59557C5.45795 1.88134 6.71458 1.50008 8.0001 1.5C9.9001 1.5 11.7001 2.3 13.0001 3.6L14.5001 5.1',
631
+ 'M14.4999 1.5V5.1H10.8999',
632
+ ],
633
+ [],
634
+ )
635
+
636
+ /** `IconCloseOutlineRegular`. */
637
+ const CloseGlyph = artwork('0 0 16 16', ['M2.5 2.5L13.5 13.5', 'M13.5 2.5L2.5 13.5'], [])
638
+
639
+ /** `IconCheckOutlineRegular`. */
640
+ const CheckGlyph = artwork(
641
+ '0 0 16 16',
642
+ ['M2.25 8.5L5.49732 11.7473C5.90519 12.1552 6.57263 12.1344 6.95426 11.7018L13.75 4'],
643
+ [],
644
+ )
645
+
646
+ /** `IconWarningTriangleOutlineRegular`. */
647
+ const WarningGlyph = artwork(
648
+ '0 0 16 16',
649
+ ['M6.87 2.6a1.33 1.33 0 0 1 2.26 0l5.34 9.33A1.33 1.33 0 0 1 13.33 14H2.67a1.33 1.33 0 0 1-1.14-2.07Z', 'M8 6v3m0 2.33h.01'],
650
+ [],
651
+ )
652
+
653
+ /** `IconChevronRightOutlineRegular`. */
654
+ const ChevronRightGlyph = artwork(
655
+ '0 0 16 16',
656
+ ['M6 12L9.29289 8.70711C9.68342 8.31658 9.68342 7.68342 9.29289 7.29289L6 4'],
657
+ [],
658
+ )
659
+
660
+ /**
661
+ * The guide card's mark.
662
+ *
663
+ * The card renders its icon at 22–26px. A 16px grid scaled up would thicken
664
+ * the 1px stroke with it, so the stroke is divided by the size ratio to keep
665
+ * the optical weight the same as a real 16px icon.
666
+ */
667
+ const GuideGlyph = ({ size = 26, className }) =>
668
+ h(GitGlyph, { size, className, strokeWidth: 16 / size })
669
+
670
+ /* ------------------------------------------------------------------ *
671
+ * 5. Styles
672
+ * ------------------------------------------------------------------ */
673
+
674
+ const STYLE_ATTR = 'data-gitbar-style'
675
+
676
+ /**
677
+ * Tokens only, each with a light fallback, plus the panel's own dimensions.
678
+ *
679
+ * The root fills the host's `.tabBody` (`display:flex; height:100%;
680
+ * min-height:0; overflow:hidden`) — the host gives a tab body no padding and
681
+ * no scrollbar, so every scroll area here is declared explicitly.
682
+ */
683
+ const CSS = [
684
+ '.gitbar-root{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;box-sizing:border-box;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-primary,#111827);background:var(--dsw-alias-bg-base,transparent)}',
685
+ '.gitbar-head{flex:none;display:flex;align-items:center;gap:6px;padding:8px 10px 6px;border-bottom:1px solid var(--dsw-alias-border-l4,rgba(127,127,127,.18));min-width:0}',
686
+ '.gitbar-ident{display:flex;align-items:baseline;gap:6px;min-width:0;flex:1 1 auto}',
687
+ '.gitbar-repo{font-weight:600;color:var(--dsw-alias-label-primary,#111827);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}',
688
+ '.gitbar-branch{flex:none;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:45%}',
689
+ '.gitbar-ab{flex:none;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px}',
690
+ '.gitbar-tools{flex:none;display:flex;align-items:center;gap:2px}',
691
+ '.gitbar-iconBtn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-secondary,#4b5563);cursor:pointer;flex:none}',
692
+ '.gitbar-iconBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,#111827)}',
693
+ '.gitbar-iconBtn:disabled{opacity:.4;cursor:default}',
694
+ '.gitbar-iconBtn:focus-visible,.gitbar-btn:focus-visible,.gitbar-input:focus-visible,.gitbar-tick:focus-visible,.gitbar-row:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:1px}',
695
+ '.gitbar-iconBtn svg{width:15px;height:15px;display:block}',
696
+ '.gitbar-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:26px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-primary,#111827);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}',
697
+ '.gitbar-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
698
+ '.gitbar-btn:disabled{opacity:.45;cursor:default}',
699
+ '.gitbar-btn-primary{border-color:transparent;background:var(--dsw-alias-state-business-primary,#4d6bfe);color:var(--dsw-static-neutral-bluish-00,#fff);font-weight:500}',
700
+ '.gitbar-btn-primary:hover:not(:disabled){filter:brightness(1.08)}',
701
+ '.gitbar-btn-danger{border-color:var(--dsw-alias-state-error-primary,#dc2626);color:var(--dsw-alias-state-error-primary,#dc2626)}',
702
+ '.gitbar-btn-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger,rgba(220,38,38,.12))}',
703
+ '.gitbar-banner{flex:none;display:flex;align-items:flex-start;gap:6px;margin:6px 10px 0;padding:6px 8px;border-radius:var(--dsw-radius-sm,6px);font-size:11.5px;word-break:break-word}',
704
+ '.gitbar-banner-error{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(220,38,38,.1));color:var(--dsw-alias-state-error-primary,#b91c1c)}',
705
+ '.gitbar-banner-warn{background:var(--dsw-alias-interactive-bg-hover,rgba(180,120,20,.12));color:var(--dsw-alias-state-warn-primary,#92400e)}',
706
+ '.gitbar-banner-ok{background:var(--dsw-alias-interactive-bg-hover,rgba(22,163,74,.12));color:var(--dsw-alias-state-success-primary,#15803d)}',
707
+ '.gitbar-banner svg{width:14px;height:14px;flex:none;margin-top:1px;display:block}',
708
+ '.gitbar-sub{flex:none;display:flex;align-items:center;gap:6px;padding:6px 10px 4px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;min-width:0}',
709
+ '.gitbar-subTitle{flex:none;font-weight:500;color:var(--dsw-alias-label-secondary,#4b5563)}',
710
+ '.gitbar-subStats{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}',
711
+ '.gitbar-list{flex:1 1 auto;min-height:40px;overflow:auto;padding:2px 6px 6px}',
712
+ '.gitbar-row{display:flex;align-items:center;gap:4px;padding:3px 4px;border-radius:var(--dsw-radius-sm,6px);cursor:default;min-width:0}',
713
+ '.gitbar-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}',
714
+ '.gitbar-tick{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;padding:0;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.45));border-radius:3px;background:transparent;color:var(--dsw-static-neutral-bluish-00,#fff);cursor:pointer}',
715
+ '.gitbar-tick-on{border-color:transparent;background:var(--dsw-alias-state-business-primary,#4d6bfe)}',
716
+ '.gitbar-tick-partial{border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
717
+ '.gitbar-tick svg{width:11px;height:11px;display:block}',
718
+ '.gitbar-tick-partial span{display:block;width:7px;height:1.5px;background:var(--dsw-alias-state-business-primary,#4d6bfe)}',
719
+ '.gitbar-letter{flex:none;width:13px;text-align:center;font-size:10.5px;font-weight:600;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
720
+ '.gitbar-letter-m{color:var(--dsw-alias-state-warn-primary,#b45309)}',
721
+ '.gitbar-letter-a{color:var(--dsw-alias-state-success-primary,#15803d)}',
722
+ '.gitbar-letter-d{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
723
+ '.gitbar-letter-r{color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
724
+ '.gitbar-letter-u{color:var(--dsw-alias-label-tertiary,#6b7280)}',
725
+ '.gitbar-letter-c{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
726
+ '.gitbar-path{flex:1 1 auto;min-width:0;display:flex;align-items:baseline;gap:0;overflow:hidden}',
727
+ '.gitbar-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
728
+ '.gitbar-dir{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#6b7280)}',
729
+ '.gitbar-stats{flex:none;display:flex;gap:3px;font-size:10.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
730
+ '.gitbar-plus{color:var(--dsw-alias-state-success-primary,#15803d)}',
731
+ '.gitbar-minus{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
732
+ '.gitbar-rowBtn{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:var(--dsw-radius-xs,4px);background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;opacity:0}',
733
+ '.gitbar-row:hover .gitbar-rowBtn,.gitbar-rowBtn:focus-visible{opacity:1}',
734
+ '.gitbar-rowBtn:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(220,38,38,.14));color:var(--dsw-alias-state-error-primary,#b91c1c)}',
735
+ '.gitbar-rowBtn svg{width:14px;height:14px;display:block}',
736
+ '.gitbar-empty{padding:14px 12px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11.5px;text-align:center}',
737
+ '.gitbar-commit{flex:none;display:flex;flex-direction:column;gap:5px;padding:8px 10px 10px;border-top:1px solid var(--dsw-alias-border-l4,rgba(127,127,127,.18))}',
738
+ '.gitbar-summaryRow{display:flex;align-items:center;gap:4px}',
739
+ '.gitbar-input{box-sizing:border-box;flex:1 1 auto;min-width:0;height:28px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));border-radius:var(--dsw-radius-sm,6px);background:transparent;color:inherit;font:inherit;font-size:12px}',
740
+ '.gitbar-input::placeholder{color:var(--dsw-alias-label-tertiary,#6b7280)}',
741
+ 'textarea.gitbar-input{height:auto;min-height:56px;padding:6px 8px;resize:vertical;line-height:1.5}',
742
+ '.gitbar-disclose{box-sizing:border-box;flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:var(--dsw-radius-xs,4px);background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer}',
743
+ '.gitbar-disclose:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,#111827)}',
744
+ '.gitbar-disclose svg{width:13px;height:13px;display:block;transition:transform 120ms var(--ds-ease-in-out,ease)}',
745
+ '.gitbar-disclose-open svg{transform:rotate(90deg)}',
746
+ '.gitbar-actions{display:flex;gap:6px}',
747
+ '.gitbar-actions .gitbar-btn{flex:1 1 0;min-width:0}',
748
+ '.gitbar-overlay{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:12px;background:var(--dsw-alias-bg-mask-1,rgba(15,23,42,.45))}',
749
+ '.gitbar-dialog{box-sizing:border-box;width:100%;max-width:360px;max-height:100%;overflow:auto;display:flex;flex-direction:column;gap:10px;padding:14px;border-radius:var(--dsw-radius-lg,10px);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111827);box-shadow:0 12px 32px rgba(0,0,0,.28)}',
750
+ '.gitbar-dialogTitle{font-size:13px;font-weight:600;word-break:break-word}',
751
+ '.gitbar-dialogList{margin:0;padding:0 0 0 16px;max-height:140px;overflow:auto;font-size:11.5px;color:var(--dsw-alias-label-secondary,#4b5563)}',
752
+ '.gitbar-dialogList li{word-break:break-all}',
753
+ '.gitbar-dialogMore{font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280)}',
754
+ '.gitbar-checkRow{display:flex;align-items:flex-start;gap:7px;cursor:pointer;user-select:none}',
755
+ '.gitbar-checkRow input{margin:2px 0 0;accent-color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
756
+ '.gitbar-checkText{display:flex;flex-direction:column;gap:2px;min-width:0}',
757
+ '.gitbar-checkHint{font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280)}',
758
+ '.gitbar-dialogActions{display:flex;justify-content:flex-end;gap:6px}',
759
+ '.gitbar-picker{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px;padding:12px 10px}',
760
+ '.gitbar-pickerTitle{font-size:12.5px;font-weight:600}',
761
+ '.gitbar-pickerHint{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#6b7280)}',
762
+ '.gitbar-candidate{display:flex;align-items:center;gap:6px;width:100%;padding:6px 8px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));border-radius:var(--dsw-radius-sm,6px);background:transparent;color:inherit;font:inherit;font-size:11.5px;text-align:left;cursor:pointer}',
763
+ '.gitbar-candidate:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
764
+ '.gitbar-candidatePath{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}',
765
+ '.gitbar-tabTitle{display:inline-flex;align-items:center;gap:5px;min-width:0;max-width:100%}',
766
+ '.gitbar-tabTitle svg{width:14px;height:14px;flex:none;display:block}',
767
+ '.gitbar-tabTitleText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
768
+ '.gitbar-diff{flex:1 1 auto;min-height:0;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;padding:0 0 12px}',
769
+ '.gitbar-diffRow{display:flex;min-width:0}',
770
+ '.gitbar-gutter{flex:none;display:flex;width:66px;color:var(--dsw-alias-label-tertiary,#6b7280);text-align:right;user-select:none}',
771
+ '.gitbar-gutter span{flex:1 1 0;padding:0 5px;min-width:0}',
772
+ '.gitbar-code{flex:1 1 auto;min-width:0;padding:0 8px 0 6px;white-space:pre-wrap;word-break:break-all}',
773
+ '.gitbar-r-file{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14));color:var(--dsw-alias-label-primary,#111827);font-weight:600}',
774
+ '.gitbar-r-hunk{background:var(--dsw-alias-interactive-bg-hover,rgba(77,107,254,.12));color:var(--dsw-alias-state-business-primary,#1d4ed8)}',
775
+ '.gitbar-r-meta{color:var(--dsw-alias-label-tertiary,#6b7280)}',
776
+ '.gitbar-r-add{background:var(--dsw-alias-interactive-bg-hover,rgba(22,163,74,.13));color:var(--dsw-alias-state-success-primary,#14532d)}',
777
+ '.gitbar-r-del{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(220,38,38,.13));color:var(--dsw-alias-state-error-primary,#7f1d1d)}',
778
+ '.gitbar-r-note{color:var(--dsw-alias-label-tertiary,#6b7280);font-style:italic}',
779
+ '.gitbar-r-ctx{color:var(--dsw-alias-label-secondary,#374151)}',
780
+ '.gitbar-trashRow{display:flex;align-items:flex-start;gap:6px;width:100%;padding:7px 8px;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;min-width:0}',
781
+ '.gitbar-trashRow:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}',
782
+ '.gitbar-trashBody{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px}',
783
+ '.gitbar-trashHead{display:flex;align-items:baseline;gap:6px;min-width:0}',
784
+ '.gitbar-trashWhen{flex:none;color:var(--dsw-alias-label-primary,#111827);font-weight:500}',
785
+ '.gitbar-badge{flex:none;padding:0 5px;border-radius:999px;font-size:10px;line-height:15px;background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));color:var(--dsw-alias-label-secondary,#4b5563)}',
786
+ '.gitbar-trashMeta{font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
787
+ '.gitbar-trashFiles{font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
788
+ '.gitbar-toggle{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;user-select:none;white-space:nowrap}',
789
+ '.gitbar-toggle input{margin:0;accent-color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
790
+ '.gitbar-spacer{flex:1 1 auto}',
791
+ '.gitbar-loading{padding:18px 12px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11.5px;display:flex;align-items:center;gap:6px}',
792
+ '.gitbar-spin{display:inline-block;width:13px;height:13px;flex:none;animation:gitbar-spin .9s linear infinite}',
793
+ '.gitbar-spin svg{width:13px;height:13px;display:block}',
794
+ '@keyframes gitbar-spin{to{transform:rotate(360deg)}}',
795
+ ].join('\n')
796
+
797
+ /** Inject the stylesheet once, through the usual `data-plugin` marker. */
798
+ function ensureStyle() {
799
+ if (typeof document === 'undefined') return
800
+ if (document.querySelector(`style[${STYLE_ATTR}]`) !== null) return
801
+ const tag = document.createElement('style')
802
+ tag.setAttribute(STYLE_ATTR, '')
803
+ tag.textContent = CSS
804
+ document.head.appendChild(tag)
805
+ }
806
+
807
+ /* ------------------------------------------------------------------ *
808
+ * 6. RPC
809
+ * ------------------------------------------------------------------ */
810
+
811
+ /** An action failure carrying the host's code, so the UI can localize it. */
812
+ function RpcError(code, message) {
813
+ const error = new Error(message)
814
+ error.name = 'RpcError'
815
+ error.code = code
816
+ return error
817
+ }
818
+
819
+ /**
820
+ * One host call.
821
+ *
822
+ * The body is read before the status is judged: a failure still arrives as
823
+ * JSON with `{ ok:false, error }`, and a non-2xx status must not be the only
824
+ * signal.
825
+ */
826
+ async function rpc(action, payload, signal) {
827
+ const body = Object.assign({ action }, payload === undefined ? {} : payload)
828
+ let response
829
+ try {
830
+ response = await fetch(RPC_ROUTE, {
831
+ method: 'POST',
832
+ credentials: 'same-origin',
833
+ cache: 'no-store',
834
+ headers: { 'content-type': 'application/json' },
835
+ body: JSON.stringify(body),
836
+ ...(signal === undefined ? {} : { signal }),
837
+ })
838
+ } catch (error) {
839
+ if (error !== null && error !== undefined && error.name === 'AbortError') throw error
840
+ throw RpcError('E_TRANSPORT', String((error && error.message) || error))
841
+ }
842
+
843
+ let data = null
844
+ try {
845
+ data = await response.json()
846
+ } catch {
847
+ throw RpcError('E_BAD_RESPONSE', `HTTP ${response.status}`)
848
+ }
849
+ if (data === null || typeof data !== 'object') throw RpcError('E_BAD_RESPONSE', `HTTP ${response.status}`)
850
+ if (data.ok !== true) {
851
+ const failure = data.error
852
+ throw RpcError(
853
+ failure !== null && typeof failure?.code === 'string' ? failure.code : 'E_UNKNOWN',
854
+ failure !== null && typeof failure?.message === 'string' ? failure.message : `HTTP ${response.status}`,
855
+ )
856
+ }
857
+ return data
858
+ }
859
+
860
+ /* ------------------------------------------------------------------ *
861
+ * 7. Shared pieces
862
+ * ------------------------------------------------------------------ */
863
+
864
+ /** A `<button>` with the panel's icon-button chrome. */
865
+ function IconButton({ label, onClick, disabled, className, children, attrs }) {
866
+ return h(
867
+ 'button',
868
+ {
869
+ type: 'button',
870
+ className: `gitbar-iconBtn${className === undefined ? '' : ` ${className}`}`,
871
+ title: label,
872
+ 'aria-label': label,
873
+ disabled: disabled === true,
874
+ onClick,
875
+ ...(attrs ?? {}),
876
+ },
877
+ children,
878
+ )
879
+ }
880
+
881
+ /** The loading indicator: the host's arc plus a plain rotation. */
882
+ function LoadingGlyph() {
883
+ // `IconLoadingOutlineRegular` — the arc the host itself spins.
884
+ const Arc = artwork(
885
+ '0 0 16 16',
886
+ [
887
+ 'M12.596 12.596C11.687 13.5049 10.5288 14.1239 9.26798 14.3747C8.00716 14.6255 6.70028 14.4968 5.51261 14.0048C4.32494 13.5129 3.30981 12.6798 2.59557 11.611C1.88134 10.5421 1.50008 9.2855 1.5 7.99998C1.50008 6.71446 1.88134 5.45783 2.59557 4.38898C3.30981 3.32013 4.32494 2.48707 5.51261 1.99513C6.70028 1.50319 8.00716 1.37447 9.26798 1.62524C10.5288 1.87602 11.687 2.49502 12.596 3.40398',
888
+ ],
889
+ [],
890
+ )
891
+ return h('span', { className: 'gitbar-spin', 'aria-hidden': 'true' }, h(Arc, {}))
892
+ }
893
+
894
+ function Spinner({ label }) {
895
+ return h('div', { className: 'gitbar-loading' }, [h(LoadingGlyph, { key: 's' }), h('span', { key: 't' }, label)])
896
+ }
897
+
898
+ /** A one-line notice above the list. */
899
+ function Banner({ tone, text, onClose, closeLabel }) {
900
+ const Glyph = tone === 'error' ? WarningGlyph : tone === 'warn' ? WarningGlyph : CheckGlyph
901
+ return h('div', { className: `gitbar-banner gitbar-banner-${tone}`, role: 'status' }, [
902
+ h(Glyph, { key: 'g' }),
903
+ h('span', { key: 't', style: { flex: '1 1 auto', minWidth: 0 } }, text),
904
+ onClose === undefined
905
+ ? null
906
+ : h(
907
+ 'button',
908
+ {
909
+ key: 'x',
910
+ type: 'button',
911
+ className: 'gitbar-iconBtn',
912
+ style: { width: 18, height: 18 },
913
+ title: closeLabel,
914
+ 'aria-label': closeLabel,
915
+ onClick: onClose,
916
+ },
917
+ h(CloseGlyph, { size: 12 }),
918
+ ),
919
+ ])
920
+ }
921
+
922
+ /** The username-and-repo tail of a path, for a narrow column. */
923
+ function shortPath(path) {
924
+ const parts = String(path ?? '').split(/[\\/]/).filter((part) => part !== '')
925
+ return parts.length <= 2 ? parts.join('/') : `…/${parts.slice(-2).join('/')}`
926
+ }
927
+
928
+ /* ------------------------------------------------------------------ *
929
+ * 8. The panel
930
+ * ------------------------------------------------------------------ */
931
+
932
+ /** One changed file. */
933
+ function ChangeRow({ file, t, onToggle, onDiscard, onOpen }) {
934
+ const letter = statusLetter(file)
935
+ const letterClass =
936
+ letter === 'M' ? 'm' : letter === 'A' ? 'a' : letter === 'D' ? 'd' : letter === 'R' ? 'r' : letter === '?' ? 'u' : 'c'
937
+ const parts = splitPath(file.path)
938
+ const display = file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`
939
+
940
+ return h(
941
+ 'div',
942
+ {
943
+ className: 'gitbar-row',
944
+ 'data-gitbar-row': file.path,
945
+ 'data-gitbar-staged': file.staged === true ? 'true' : 'false',
946
+ title: display,
947
+ onDoubleClick: () => onOpen(file.path),
948
+ },
949
+ [
950
+ h(
951
+ 'button',
952
+ {
953
+ key: 'tick',
954
+ type: 'button',
955
+ className: `gitbar-tick${file.staged === true && !file.partial ? ' gitbar-tick-on' : file.partial === true ? ' gitbar-tick-partial' : ''}`,
956
+ 'data-gitbar-tick': file.path,
957
+ 'aria-checked': file.staged === true ? 'true' : 'false',
958
+ role: 'checkbox',
959
+ 'aria-label': file.path,
960
+ onClick: (event) => {
961
+ event.stopPropagation()
962
+ onToggle(file)
963
+ },
964
+ },
965
+ file.staged === true && !file.partial ? h(CheckGlyph, { size: 11 }) : file.partial === true ? h('span', {}) : null,
966
+ ),
967
+ h('span', { key: 'letter', className: `gitbar-letter gitbar-letter-${letterClass}` }, letter),
968
+ h('span', { key: 'path', className: 'gitbar-path' }, [
969
+ h('span', { key: 'name', className: 'gitbar-name' }, parts.name),
970
+ parts.dir === '' ? null : h('span', { key: 'dir', className: 'gitbar-dir' }, ` ${parts.dir}`),
971
+ ]),
972
+ h('span', { key: 'stats', className: 'gitbar-stats' }, [
973
+ (file.added ?? 0) > 0 ? h('span', { key: 'p', className: 'gitbar-plus' }, `+${file.added}`) : null,
974
+ (file.deleted ?? 0) > 0 ? h('span', { key: 'm', className: 'gitbar-minus' }, `−${file.deleted}`) : null,
975
+ ]),
976
+ h(
977
+ 'button',
978
+ {
979
+ key: 'discard',
980
+ type: 'button',
981
+ className: 'gitbar-rowBtn',
982
+ 'data-gitbar-discard': file.path,
983
+ title: t('action.discard'),
984
+ 'aria-label': t('action.discard'),
985
+ onClick: (event) => {
986
+ event.stopPropagation()
987
+ onDiscard(file.path)
988
+ },
989
+ },
990
+ h(TrashGlyph, { size: 14 }),
991
+ ),
992
+ ],
993
+ )
994
+ }
995
+
996
+ /** The discard confirmation, drawn inside the panel rather than portalled. */
997
+ function DiscardDialog({ pending, t, onCancel, onHardDeleteChange, onConfirm }) {
998
+ if (pending === null) return null
999
+ const files = pending.files
1000
+ const shown = files.slice(0, 8)
1001
+ const title =
1002
+ pending.paths === null
1003
+ ? t('discard.all', { files: files.length })
1004
+ : t('discard.one', { path: pending.paths[0] })
1005
+
1006
+ return h('div', { className: 'gitbar-overlay', 'data-gitbar-dialog': 'discard' }, [
1007
+ h('div', { className: 'gitbar-dialog', role: 'dialog', 'aria-modal': 'true' }, [
1008
+ h('div', { key: 'title', className: 'gitbar-dialogTitle' }, title),
1009
+ h(
1010
+ 'ul',
1011
+ { key: 'list', className: 'gitbar-dialogList' },
1012
+ shown.map((file) => h('li', { key: file }, file)),
1013
+ ),
1014
+ files.length > shown.length
1015
+ ? h('div', { key: 'more', className: 'gitbar-dialogMore' }, t('discard.more', { n: files.length - shown.length }))
1016
+ : null,
1017
+ h('label', { key: 'hard', className: 'gitbar-checkRow' }, [
1018
+ h('input', {
1019
+ type: 'checkbox',
1020
+ checked: pending.hardDelete,
1021
+ 'data-gitbar-hard': '',
1022
+ onChange: (event) => onHardDeleteChange(event.target.checked === true),
1023
+ }),
1024
+ h('span', { className: 'gitbar-checkText' }, [
1025
+ h('span', { key: 'l' }, t('discard.hard')),
1026
+ h('span', { key: 'h', className: 'gitbar-checkHint' }, pending.hardDelete ? t('discard.hardHint') : t('discard.keep')),
1027
+ ]),
1028
+ ]),
1029
+ h('div', { key: 'actions', className: 'gitbar-dialogActions' }, [
1030
+ h(
1031
+ 'button',
1032
+ { key: 'cancel', type: 'button', className: 'gitbar-btn', onClick: onCancel },
1033
+ t('action.cancel'),
1034
+ ),
1035
+ h(
1036
+ 'button',
1037
+ {
1038
+ key: 'go',
1039
+ type: 'button',
1040
+ className: 'gitbar-btn gitbar-btn-danger',
1041
+ 'data-gitbar-confirm': '',
1042
+ onClick: onConfirm,
1043
+ },
1044
+ t('action.confirm'),
1045
+ ),
1046
+ ]),
1047
+ ]),
1048
+ ])
1049
+ }
1050
+
1051
+ /** The repository picker for a workspace that is not a repository. */
1052
+ function RepositoryPicker({ candidates, truncated, scanning, pathInput, t, onPick, onPath, onPathChange, onRescan, onUnbind, bound }) {
1053
+ return h('div', { className: 'gitbar-picker', 'data-gitbar-picker': '' }, [
1054
+ h('div', { key: 'title', className: 'gitbar-pickerTitle' }, t('bind.title')),
1055
+ h('div', { key: 'hint', className: 'gitbar-pickerHint' }, t('bind.hint')),
1056
+ bound === null
1057
+ ? null
1058
+ : h('div', { key: 'bound', className: 'gitbar-pickerHint' }, [
1059
+ t('bind.current', { path: bound }),
1060
+ ' ',
1061
+ h(
1062
+ 'button',
1063
+ { key: 'unbind', type: 'button', className: 'gitbar-btn', style: { marginLeft: 6 }, onClick: onUnbind },
1064
+ t('action.unbind'),
1065
+ ),
1066
+ ]),
1067
+ scanning ? h(Spinner, { key: 'scan', label: t('bind.scanning') }) : null,
1068
+ !scanning && candidates.length === 0
1069
+ ? h('div', { key: 'none', className: 'gitbar-pickerHint' }, t('bind.noCandidates'))
1070
+ : null,
1071
+ ...candidates.map((candidate) =>
1072
+ h(
1073
+ 'button',
1074
+ {
1075
+ key: candidate.path,
1076
+ type: 'button',
1077
+ className: 'gitbar-candidate',
1078
+ 'data-gitbar-candidate': candidate.path,
1079
+ onClick: () => onPick(candidate.path),
1080
+ },
1081
+ [
1082
+ h(GitGlyph, { key: 'g', size: 14 }),
1083
+ h('span', { key: 'p', className: 'gitbar-candidatePath', title: candidate.path }, candidate.path),
1084
+ ],
1085
+ ),
1086
+ ),
1087
+ truncated ? h('div', { key: 'trunc', className: 'gitbar-pickerHint' }, t('bind.truncated')) : null,
1088
+ h('div', { key: 'manual', className: 'gitbar-summaryRow' }, [
1089
+ h('input', {
1090
+ key: 'input',
1091
+ className: 'gitbar-input',
1092
+ placeholder: t('bind.pathPlaceholder'),
1093
+ value: pathInput,
1094
+ 'data-gitbar-pathinput': '',
1095
+ onChange: (event) => onPathChange(event.target.value),
1096
+ onKeyDown: (event) => {
1097
+ if (event.key === 'Enter') onPath()
1098
+ },
1099
+ }),
1100
+ h('button', { key: 'go', type: 'button', className: 'gitbar-btn', onClick: onPath }, t('bind.bindPath')),
1101
+ ]),
1102
+ h(
1103
+ 'button',
1104
+ { key: 'rescan', type: 'button', className: 'gitbar-btn', onClick: onRescan, disabled: scanning },
1105
+ t('bind.rescan'),
1106
+ ),
1107
+ ])
1108
+ }
1109
+
1110
+ /**
1111
+ * The Git panel body.
1112
+ *
1113
+ * Ticks are not local state: a file's tick *is* whether all of its change is
1114
+ * in the repository index, so ticking calls the host and adopts the status
1115
+ * that comes back. That is what keeps this panel and `git status` in a
1116
+ * terminal from ever disagreeing.
1117
+ */
1118
+ function GitBody(props) {
1119
+ const t = props.t ?? fallbackTranslate()
1120
+ const ctx = props.ctx
1121
+ // `useTabInfo` is a hook the host binds into this seat's props (the shell
1122
+ // declares `inject: { hooks: { tabInfo } }` on the slot itself), so it has
1123
+ // to be called unconditionally at the top of the body.
1124
+ const tab = props.useTabInfo().tab
1125
+ const sessionId = props.sessionId ?? tab.sessionId
1126
+
1127
+ const [state, setState] = useState(null)
1128
+ const [phase, setPhase] = useState('loading')
1129
+ const [error, setError] = useState(null)
1130
+ const [notice, setNotice] = useState(null)
1131
+ const [busy, setBusy] = useState(null)
1132
+ const [summary, setSummary] = useState('')
1133
+ const [description, setDescription] = useState('')
1134
+ const [descriptionOpen, setDescriptionOpen] = useState(false)
1135
+ const [pending, setPending] = useState(null)
1136
+ const [pathInput, setPathInput] = useState('')
1137
+ const [scanning, setScanning] = useState(false)
1138
+
1139
+ const alive = useRef(true)
1140
+ useEffect(() => {
1141
+ alive.current = true
1142
+ return () => {
1143
+ alive.current = false
1144
+ }
1145
+ }, [])
1146
+
1147
+ /** Fetch the whole panel state. Never throws. */
1148
+ const load = useCallback(
1149
+ async (options = {}) => {
1150
+ if (options.quiet !== true) setPhase((current) => (current === 'ready' ? current : 'loading'))
1151
+ try {
1152
+ const data = await rpc(ACTIONS.STATUS, { sessionId }, tab.signal)
1153
+ if (!alive.current) return
1154
+ setState(data)
1155
+ setError(null)
1156
+ setPhase('ready')
1157
+ } catch (failure) {
1158
+ if (!alive.current || failure?.name === 'AbortError') return
1159
+ setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1160
+ setPhase('error')
1161
+ }
1162
+ },
1163
+ [sessionId, tab.signal],
1164
+ )
1165
+
1166
+ // Load on mount, and again whenever this tab comes back to the front —
1167
+ // the panel can be looking at a working tree the agent just changed.
1168
+ const wasVisible = useRef(false)
1169
+ const loadedOnce = useRef(false)
1170
+ useEffect(() => {
1171
+ const visible = tab.visible !== false
1172
+ if (!loadedOnce.current || visible) {
1173
+ loadedOnce.current = true
1174
+ wasVisible.current = visible
1175
+ // Quiet when there is already something to show, so returning to the
1176
+ // tab does not flash the loading state over a list the user recognises.
1177
+ void load({ quiet: state !== null })
1178
+ }
1179
+ }, [tab.visible, sessionId, load])
1180
+
1181
+ // The column's refresh shortcut, when it has one.
1182
+ useEffect(
1183
+ () => (typeof tab.actions?.bindCommands === 'function' ? tab.actions.bindCommands({ refresh: () => void load({ quiet: true }) }) : undefined),
1184
+ [tab.actions, load],
1185
+ )
1186
+
1187
+ /** Say something briefly. */
1188
+ const flash = useCallback((tone, text) => {
1189
+ setNotice({ tone, text, at: Date.now() })
1190
+ }, [])
1191
+
1192
+ useEffect(() => {
1193
+ if (notice === null) return undefined
1194
+ const timer = setTimeout(() => {
1195
+ if (alive.current) setNotice(null)
1196
+ }, 6000)
1197
+ return () => clearTimeout(timer)
1198
+ }, [notice])
1199
+
1200
+ /** Run one mutating action, adopting the status it returns. */
1201
+ const run = useCallback(
1202
+ async (kind, action, payload, onDone) => {
1203
+ setBusy(kind)
1204
+ try {
1205
+ const data = await rpc(action, { sessionId, ...(payload ?? {}) }, tab.signal)
1206
+ if (!alive.current) return null
1207
+ setError(null)
1208
+ // Most actions answer with a fresh status; binding answers with only
1209
+ // the new repository, so anything that is not a status is followed by
1210
+ // a re-read rather than being mistaken for one.
1211
+ if (Array.isArray(data.files) && data.branch !== undefined) setState(data)
1212
+ else await load({ quiet: true })
1213
+ if (onDone !== undefined) onDone(data)
1214
+ return data
1215
+ } catch (failure) {
1216
+ if (!alive.current) return null
1217
+ if (failure?.name === 'AbortError') return null
1218
+ setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1219
+ // Even a failed action can have changed the repository (a commit that
1220
+ // did land before its push was rejected), so re-read.
1221
+ void load({ quiet: true })
1222
+ return null
1223
+ } finally {
1224
+ if (alive.current) setBusy(null)
1225
+ }
1226
+ },
1227
+ [sessionId, load, tab.signal],
1228
+ )
1229
+
1230
+ const toggle = useCallback(
1231
+ (file) => {
1232
+ void run('stage', ACTIONS.STAGE, { paths: [file.path], staged: file.staged !== true })
1233
+ },
1234
+ [run],
1235
+ )
1236
+
1237
+ const openDiff = useCallback(
1238
+ (path) => {
1239
+ setNotice(null)
1240
+ ctx.sidebarRight.openResourceIn(sessionId, DIFF_ADDRESS, {
1241
+ params: { source: 'worktree', path, revision: Date.now() },
1242
+ })
1243
+ },
1244
+ [ctx, sessionId],
1245
+ )
1246
+
1247
+ const askDiscard = useCallback(
1248
+ (paths) => {
1249
+ if (state === null) return
1250
+ const targets = paths === null ? state.files.map((file) => file.path) : paths
1251
+ setPending({
1252
+ paths: paths === null ? null : paths,
1253
+ files: targets,
1254
+ hardDelete: state.prefs?.trashHardDelete === true,
1255
+ })
1256
+ },
1257
+ [state],
1258
+ )
1259
+
1260
+ const confirmDiscard = useCallback(() => {
1261
+ if (pending === null) return
1262
+ const target = pending
1263
+ setPending(null)
1264
+ void run(
1265
+ 'discard',
1266
+ ACTIONS.DISCARD,
1267
+ { paths: target.paths, hardDelete: target.hardDelete },
1268
+ (data) => {
1269
+ const count = (data.discard?.restored?.length ?? 0) + (data.discard?.deleted?.length ?? 0)
1270
+ flash('ok', target.hardDelete ? t('toast.discarded', { n: count }) : t('toast.trashed', { n: count }))
1271
+ },
1272
+ )
1273
+ }, [pending, run, flash, t])
1274
+
1275
+ const commit = useCallback(
1276
+ (andPush) => {
1277
+ const action = andPush ? ACTIONS.COMMIT_PUSH : ACTIONS.COMMIT
1278
+ void run('commit', action, { summary, description }, (data) => {
1279
+ setSummary('')
1280
+ setDescription('')
1281
+ setDescriptionOpen(false)
1282
+ const short = data.commit?.short ?? ''
1283
+ if (andPush && data.push?.ok === false) {
1284
+ flash('warn', `${t('toast.pushFailed', { commit: short })}:${errorText(t, data.push.error?.code, data.push.error?.message)}`)
1285
+ return
1286
+ }
1287
+ flash('ok', andPush ? `${t('toast.committed', { commit: short })} · ${t('toast.pushed')}` : t('toast.committed', { commit: short }))
1288
+ })
1289
+ },
1290
+ [run, summary, description, flash, t],
1291
+ )
1292
+
1293
+ const runContext = useCallback(() => {
1294
+ const action = contextAction(state)
1295
+ void run('context', action, undefined, (data) => {
1296
+ if (action === ACTIONS.PUSH) {
1297
+ flash('ok', data.outcome?.published === true ? t('toast.published') : t('toast.pushed'))
1298
+ } else {
1299
+ flash('ok', t('toast.fetched'))
1300
+ }
1301
+ })
1302
+ }, [run, state, flash, t])
1303
+
1304
+ const openTrash = useCallback(() => {
1305
+ ctx.sidebarRight.openTabIn(sessionId, TRASH_KIND)
1306
+ }, [ctx, sessionId])
1307
+
1308
+ const bind = useCallback(
1309
+ (repoRoot) => {
1310
+ void run('bind', ACTIONS.BIND, { repoRoot }, () => {
1311
+ flash('ok', repoRoot === null ? t('toast.unbound') : t('toast.bound'))
1312
+ })
1313
+ },
1314
+ [run, flash, t],
1315
+ )
1316
+
1317
+ const rescan = useCallback(async () => {
1318
+ setScanning(true)
1319
+ try {
1320
+ const data = await rpc(ACTIONS.REPOS, { sessionId }, tab.signal)
1321
+ if (alive.current) setState((current) => ({ ...(current ?? {}), ...data }))
1322
+ } catch (failure) {
1323
+ if (alive.current && failure?.name !== 'AbortError') {
1324
+ setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1325
+ }
1326
+ } finally {
1327
+ if (alive.current) setScanning(false)
1328
+ }
1329
+ }, [sessionId, tab.signal])
1330
+
1331
+ /* ---------------------------------------------------------------- render */
1332
+
1333
+ if (state === null && phase !== 'error') {
1334
+ return h('div', { className: 'gitbar-root', 'data-gitbar': 'loading' }, h(Spinner, { label: t('state.loading') }))
1335
+ }
1336
+
1337
+ if (state === null && phase === 'error') {
1338
+ return h('div', { className: 'gitbar-root', 'data-gitbar': 'error' }, [
1339
+ h(Banner, {
1340
+ key: 'b',
1341
+ tone: 'error',
1342
+ text: errorText(t, error?.code, error?.message),
1343
+ }),
1344
+ h(
1345
+ 'div',
1346
+ { key: 'a', className: 'gitbar-picker' },
1347
+ h('button', { type: 'button', className: 'gitbar-btn', onClick: () => void load() }, t('state.retry')),
1348
+ ),
1349
+ ])
1350
+ }
1351
+
1352
+ // Unbound: the workspace is not a repository, so ask which one it is.
1353
+ if (state.repoRoot === null || state.repoRoot === undefined) {
1354
+ return h('div', { className: 'gitbar-root', 'data-gitbar': 'picker' }, [
1355
+ error === null ? null : h(Banner, { key: 'err', tone: 'error', text: errorText(t, error.code, error.message) }),
1356
+ notice === null ? null : h(Banner, { key: 'notice', tone: notice.tone, text: notice.text }),
1357
+ h(RepositoryPicker, {
1358
+ key: 'picker',
1359
+ candidates: state.candidates ?? [],
1360
+ truncated: state.scanTruncated === true,
1361
+ scanning,
1362
+ pathInput,
1363
+ t,
1364
+ bound: null,
1365
+ onPick: (path) => bind(path),
1366
+ onPath: () => bind(pathInput),
1367
+ onPathChange: setPathInput,
1368
+ onRescan: () => void rescan(),
1369
+ onUnbind: () => bind(null),
1370
+ }),
1371
+ ])
1372
+ }
1373
+
1374
+ const files = state.files ?? []
1375
+ const readiness = commitReadiness(state, summary, busy)
1376
+ const canCommit = readiness.canCommit
1377
+ const commitHint = readiness.reasonKey === null ? '' : t(readiness.reasonKey)
1378
+
1379
+ return h('div', { className: 'gitbar-root', 'data-gitbar': 'panel', style: { position: 'relative' } }, [
1380
+ h('div', { key: 'head', className: 'gitbar-head' }, [
1381
+ h('div', { key: 'ident', className: 'gitbar-ident' }, [
1382
+ h('span', { key: 'repo', className: 'gitbar-repo', title: state.repoRoot }, state.repoName ?? state.repoRoot),
1383
+ h(
1384
+ 'span',
1385
+ { key: 'branch', className: 'gitbar-branch', title: state.branch?.head ?? '' },
1386
+ state.branch?.detached === true ? `(${state.branch?.head ?? ''})` : state.branch?.head ?? '',
1387
+ ),
1388
+ state.branch !== undefined && (state.branch.ahead > 0 || state.branch.behind > 0)
1389
+ ? h('span', { key: 'ab', className: 'gitbar-ab' }, `↑${state.branch.ahead} ↓${state.branch.behind}`)
1390
+ : null,
1391
+ ]),
1392
+ h('div', { key: 'tools', className: 'gitbar-tools' }, [
1393
+ h(
1394
+ IconButton,
1395
+ {
1396
+ key: 'ctx',
1397
+ label: t(contextLabelKey(state)),
1398
+ disabled: busy !== null,
1399
+ onClick: runContext,
1400
+ className: 'gitbar-contextBtn',
1401
+ attrs: { 'data-gitbar-context': state.primary ?? 'fetch', style: { width: 'auto', padding: '0 8px', fontWeight: 500 } },
1402
+ },
1403
+ h('span', { style: { whiteSpace: 'nowrap' } }, t(contextLabelKey(state))),
1404
+ ),
1405
+ h(
1406
+ IconButton,
1407
+ { key: 'refresh', label: t('action.refresh'), disabled: busy !== null, onClick: () => void load({ quiet: true }) },
1408
+ h(RefreshGlyph, {}),
1409
+ ),
1410
+ h(IconButton, { key: 'trash', label: t('action.trash'), onClick: openTrash }, h(ArchiveGlyph, {})),
1411
+ h(
1412
+ IconButton,
1413
+ { key: 'rebind', label: t('action.rebind'), disabled: busy !== null, onClick: () => bind(null) },
1414
+ h(GitGlyph, {}),
1415
+ ),
1416
+ ]),
1417
+ ]),
1418
+
1419
+ error === null ? null : h(Banner, { key: 'err', tone: 'error', text: errorText(t, error.code, error.message), onClose: () => setError(null), closeLabel: t('action.cancel') }),
1420
+ notice === null ? null : h(Banner, { key: 'notice', tone: notice.tone, text: notice.text }),
1421
+
1422
+ h('div', { key: 'sub', className: 'gitbar-sub' }, [
1423
+ h('span', { key: 'title', className: 'gitbar-subTitle' }, t('changes.title')),
1424
+ h(
1425
+ 'span',
1426
+ { key: 'stats', className: 'gitbar-subStats' },
1427
+ t('changes.summary', { files: files.length, staged: stagedCount(state) }),
1428
+ ),
1429
+ hasConflict(state)
1430
+ ? h('span', { key: 'warn', className: 'gitbar-ab', title: t('err.E_CONFLICT') }, '⚠')
1431
+ : null,
1432
+ h(
1433
+ 'button',
1434
+ {
1435
+ key: 'all',
1436
+ type: 'button',
1437
+ className: 'gitbar-btn',
1438
+ 'data-gitbar-discardall': '',
1439
+ disabled: files.length === 0 || busy !== null,
1440
+ onClick: () => askDiscard(null),
1441
+ },
1442
+ t('action.discardAll'),
1443
+ ),
1444
+ ]),
1445
+
1446
+ h(
1447
+ 'div',
1448
+ { key: 'list', className: 'gitbar-list', 'data-gitbar-list': '' },
1449
+ files.length === 0
1450
+ ? h('div', { className: 'gitbar-empty' }, t('changes.empty'))
1451
+ : files.map((file) =>
1452
+ h(ChangeRow, {
1453
+ key: file.path,
1454
+ file,
1455
+ t,
1456
+ onToggle: toggle,
1457
+ onDiscard: (path) => askDiscard([path]),
1458
+ onOpen: openDiff,
1459
+ }),
1460
+ ),
1461
+ ),
1462
+
1463
+ h('div', { key: 'commit', className: 'gitbar-commit' }, [
1464
+ h('div', { key: 'summary', className: 'gitbar-summaryRow' }, [
1465
+ h('input', {
1466
+ key: 'input',
1467
+ className: 'gitbar-input',
1468
+ placeholder: t('commit.summaryPlaceholder'),
1469
+ value: summary,
1470
+ 'data-gitbar-summary': '',
1471
+ disabled: busy !== null,
1472
+ onChange: (event) => setSummary(event.target.value),
1473
+ onKeyDown: (event) => {
1474
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && canCommit) commit(false)
1475
+ },
1476
+ }),
1477
+ h(
1478
+ 'button',
1479
+ {
1480
+ key: 'disclose',
1481
+ type: 'button',
1482
+ className: `gitbar-disclose${descriptionOpen ? ' gitbar-disclose-open' : ''}`,
1483
+ title: descriptionOpen ? t('commit.collapse') : t('commit.expand'),
1484
+ 'aria-label': descriptionOpen ? t('commit.collapse') : t('commit.expand'),
1485
+ 'aria-expanded': descriptionOpen ? 'true' : 'false',
1486
+ 'data-gitbar-describe': '',
1487
+ onClick: () => setDescriptionOpen((open) => !open),
1488
+ },
1489
+ h(ChevronRightGlyph, { size: 13 }),
1490
+ ),
1491
+ ]),
1492
+ descriptionOpen
1493
+ ? h('textarea', {
1494
+ key: 'description',
1495
+ className: 'gitbar-input',
1496
+ placeholder: t('commit.descriptionPlaceholder'),
1497
+ value: description,
1498
+ 'data-gitbar-description': '',
1499
+ disabled: busy !== null,
1500
+ onChange: (event) => setDescription(event.target.value),
1501
+ })
1502
+ : null,
1503
+ h('div', { key: 'actions', className: 'gitbar-actions' }, [
1504
+ h(
1505
+ 'button',
1506
+ {
1507
+ key: 'commit',
1508
+ type: 'button',
1509
+ className: 'gitbar-btn gitbar-btn-primary',
1510
+ title: canCommit ? '' : commitHint,
1511
+ disabled: !canCommit,
1512
+ 'data-gitbar-commit': '',
1513
+ onClick: () => commit(false),
1514
+ },
1515
+ t('commit.commit'),
1516
+ ),
1517
+ h(
1518
+ 'button',
1519
+ {
1520
+ key: 'push',
1521
+ type: 'button',
1522
+ className: 'gitbar-btn',
1523
+ title: canCommit ? '' : commitHint,
1524
+ disabled: !canCommit,
1525
+ 'data-gitbar-commitpush': '',
1526
+ onClick: () => commit(true),
1527
+ },
1528
+ t('commit.commitAndPush'),
1529
+ ),
1530
+ ]),
1531
+ ]),
1532
+
1533
+ h(DiscardDialog, {
1534
+ key: 'dialog',
1535
+ pending,
1536
+ t,
1537
+ onCancel: () => setPending(null),
1538
+ onHardDeleteChange: (hardDelete) => setPending((current) => (current === null ? null : { ...current, hardDelete })),
1539
+ onConfirm: confirmDiscard,
1540
+ }),
1541
+ ])
1542
+ }
1543
+
1544
+ /* ------------------------------------------------------------------ *
1545
+ * 9. The trash tab
1546
+ * ------------------------------------------------------------------ */
1547
+
1548
+ function TrashBody(props) {
1549
+ const t = props.t ?? fallbackTranslate()
1550
+ const ctx = props.ctx
1551
+ const tab = props.useTabInfo().tab
1552
+ const sessionId = props.sessionId ?? tab.sessionId
1553
+
1554
+ const [state, setState] = useState(null)
1555
+ const [phase, setPhase] = useState('loading')
1556
+ const [error, setError] = useState(null)
1557
+ const [busy, setBusy] = useState(null)
1558
+ const [confirmClear, setConfirmClear] = useState(false)
1559
+ const [showAll, setShowAll] = useState(null)
1560
+
1561
+ const load = useCallback(
1562
+ async (options = {}) => {
1563
+ if (options.quiet !== true) setPhase('loading')
1564
+ try {
1565
+ const data = await rpc(ACTIONS.TRASH_LIST, { sessionId, ...(options.all === undefined ? {} : { all: options.all }) }, tab.signal)
1566
+ setState(data)
1567
+ setShowAll(data.showAll === true)
1568
+ setError(null)
1569
+ setPhase('ready')
1570
+ } catch (failure) {
1571
+ if (failure?.name === 'AbortError') return
1572
+ setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1573
+ setPhase('error')
1574
+ }
1575
+ },
1576
+ [sessionId, tab.signal],
1577
+ )
1578
+
1579
+ const wasVisible = useRef(false)
1580
+ useEffect(() => {
1581
+ const visible = tab.visible !== false
1582
+ if (!wasVisible.current || visible) {
1583
+ wasVisible.current = true
1584
+ void load({ quiet: state !== null })
1585
+ }
1586
+ }, [tab.visible, sessionId])
1587
+
1588
+ useEffect(() => tab.actions.bindCommands({ refresh: () => void load({ quiet: true }) }), [tab.actions, load])
1589
+
1590
+ const openEntry = useCallback(
1591
+ (entry) => {
1592
+ ctx.sidebarRight.openResourceIn(sessionId, DIFF_ADDRESS, {
1593
+ params: { source: 'trash', id: entry.id, revision: Date.now() },
1594
+ })
1595
+ },
1596
+ [ctx, sessionId],
1597
+ )
1598
+
1599
+ const removeEntry = useCallback(
1600
+ async (entry) => {
1601
+ setBusy(entry.id)
1602
+ try {
1603
+ await rpc(ACTIONS.TRASH_DELETE, { sessionId, id: entry.id }, tab.signal)
1604
+ await load({ quiet: true })
1605
+ } catch (failure) {
1606
+ if (failure?.name !== 'AbortError') setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1607
+ } finally {
1608
+ setBusy(null)
1609
+ }
1610
+ },
1611
+ [sessionId, load, tab.signal],
1612
+ )
1613
+
1614
+ const clearAll = useCallback(async () => {
1615
+ setConfirmClear(false)
1616
+ setBusy('clear')
1617
+ try {
1618
+ await rpc(ACTIONS.TRASH_CLEAR, { sessionId, all: showAll === true }, tab.signal)
1619
+ await load({ quiet: true })
1620
+ } catch (failure) {
1621
+ if (failure?.name !== 'AbortError') setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1622
+ } finally {
1623
+ setBusy(null)
1624
+ }
1625
+ }, [sessionId, showAll, load, tab.signal])
1626
+
1627
+ const toggleShowAll = useCallback(
1628
+ async (next) => {
1629
+ setShowAll(next)
1630
+ try {
1631
+ await rpc(ACTIONS.PREFS, { sessionId, trashShowAllRepos: next }, tab.signal)
1632
+ } catch {
1633
+ // A preference that failed to persist is not worth an error banner.
1634
+ }
1635
+ await load({ quiet: true, all: next })
1636
+ },
1637
+ [sessionId, load, tab.signal],
1638
+ )
1639
+
1640
+ if (state === null && phase !== 'error') {
1641
+ return h('div', { className: 'gitbar-root', 'data-gitbar-trash': 'loading' }, h(Spinner, { label: t('state.loading') }))
1642
+ }
1643
+ if (state === null) {
1644
+ return h('div', { className: 'gitbar-root', 'data-gitbar-trash': 'error' }, [
1645
+ h(Banner, { key: 'b', tone: 'error', text: errorText(t, error?.code, error?.message) }),
1646
+ h('div', { key: 'a', className: 'gitbar-picker' }, h('button', { type: 'button', className: 'gitbar-btn', onClick: () => void load() }, t('state.retry'))),
1647
+ ])
1648
+ }
1649
+
1650
+ const entries = state.entries ?? []
1651
+ const now = Date.now()
1652
+
1653
+ return h('div', { className: 'gitbar-root', 'data-gitbar-trash': 'ready', style: { position: 'relative' } }, [
1654
+ h('div', { key: 'head', className: 'gitbar-head' }, [
1655
+ h('div', { key: 'ident', className: 'gitbar-ident' }, [
1656
+ h('span', { key: 'title', className: 'gitbar-repo' }, t('trash.title')),
1657
+ h('span', { key: 'count', className: 'gitbar-branch' }, t('trash.count', { n: entries.length })),
1658
+ ]),
1659
+ h('div', { key: 'tools', className: 'gitbar-tools' }, [
1660
+ h(IconButton, { key: 'refresh', label: t('action.refresh'), disabled: busy !== null, onClick: () => void load({ quiet: true }) }, h(RefreshGlyph, {})),
1661
+ ]),
1662
+ ]),
1663
+
1664
+ error === null ? null : h(Banner, { key: 'err', tone: 'error', text: errorText(t, error.code, error.message), onClose: () => setError(null), closeLabel: t('action.cancel') }),
1665
+
1666
+ h('div', { key: 'sub', className: 'gitbar-sub' }, [
1667
+ h('label', { key: 'toggle', className: 'gitbar-toggle' }, [
1668
+ h('input', {
1669
+ type: 'checkbox',
1670
+ checked: showAll === true,
1671
+ 'data-gitbar-showall': '',
1672
+ onChange: (event) => void toggleShowAll(event.target.checked),
1673
+ }),
1674
+ h('span', { key: 'l' }, showAll === true ? t('trash.showAll') : t('trash.onlyThis')),
1675
+ ]),
1676
+ h('span', { key: 'spacer', className: 'gitbar-spacer' }),
1677
+ h(
1678
+ 'button',
1679
+ {
1680
+ key: 'clear',
1681
+ type: 'button',
1682
+ className: 'gitbar-btn gitbar-btn-danger',
1683
+ disabled: entries.length === 0 || busy !== null,
1684
+ 'data-gitbar-clear': '',
1685
+ onClick: () => setConfirmClear(true),
1686
+ },
1687
+ t('action.clear'),
1688
+ ),
1689
+ ]),
1690
+
1691
+ h(
1692
+ 'div',
1693
+ { key: 'list', className: 'gitbar-list', 'data-gitbar-trashlist': '' },
1694
+ entries.length === 0
1695
+ ? h('div', { className: 'gitbar-empty' }, t('trash.empty'))
1696
+ : entries.map((entry) => {
1697
+ const added = (entry.files ?? []).reduce((sum, file) => sum + (file.added ?? 0), 0)
1698
+ const deleted = (entry.files ?? []).reduce((sum, file) => sum + (file.deleted ?? 0), 0)
1699
+ const base = entry.base === null || entry.base === undefined ? '' : `#${entry.base.short ?? ''}`
1700
+ return h('div', { key: entry.id, className: 'gitbar-row', style: { alignItems: 'stretch', padding: 0 } }, [
1701
+ h(
1702
+ 'button',
1703
+ {
1704
+ key: 'open',
1705
+ type: 'button',
1706
+ className: 'gitbar-trashRow',
1707
+ 'data-gitbar-entry': entry.id,
1708
+ title: t('diff.trashEntry'),
1709
+ onClick: () => openEntry(entry),
1710
+ },
1711
+ h('span', { className: 'gitbar-trashBody' }, [
1712
+ h('span', { key: 'head', className: 'gitbar-trashHead' }, [
1713
+ h('span', { key: 'when', className: 'gitbar-trashWhen' }, formatRelative(entry.at, now, t)),
1714
+ h(
1715
+ 'span',
1716
+ { key: 'scope', className: 'gitbar-badge' },
1717
+ entry.scope === 'all' ? t('trash.scope.all') : t('trash.scope.file'),
1718
+ ),
1719
+ entry.truncated === true ? h('span', { key: 'trunc', className: 'gitbar-badge' }, '…') : null,
1720
+ ]),
1721
+ h(
1722
+ 'span',
1723
+ { key: 'detail', className: 'gitbar-trashMeta' },
1724
+ t('trash.detail', { files: (entry.files ?? []).length, added, deleted }),
1725
+ ),
1726
+ h(
1727
+ 'span',
1728
+ { key: 'base', className: 'gitbar-trashFiles' },
1729
+ [base === '' ? '' : t('trash.base', { commit: base }), entry.base?.subject === undefined ? '' : ` · ${entry.base.subject}`].join(''),
1730
+ ),
1731
+ h(
1732
+ 'span',
1733
+ { key: 'files', className: 'gitbar-trashFiles', title: (entry.files ?? []).map((file) => file.path).join('\n') },
1734
+ (entry.files ?? []).map((file) => shortPath(file.path)).slice(0, 3).join(' · '),
1735
+ ),
1736
+ ]),
1737
+ ),
1738
+ h(
1739
+ 'span',
1740
+ { key: 'del', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', padding: '7px 6px 0 0' } },
1741
+ h(
1742
+ IconButton,
1743
+ {
1744
+ label: t('action.delete'),
1745
+ disabled: busy !== null,
1746
+ onClick: () => void removeEntry(entry),
1747
+ attrs: { 'data-gitbar-trashtrash': entry.id },
1748
+ },
1749
+ h(TrashGlyph, { size: 14 }),
1750
+ ),
1751
+ ),
1752
+ ])
1753
+ }),
1754
+ ),
1755
+
1756
+ confirmClear
1757
+ ? h('div', { className: 'gitbar-overlay', 'data-gitbar-dialog': 'clear' }, [
1758
+ h('div', { className: 'gitbar-dialog', role: 'dialog', 'aria-modal': 'true' }, [
1759
+ h('div', { key: 'title', className: 'gitbar-dialogTitle' }, t('trash.clearConfirm', { n: entries.length })),
1760
+ h('div', { key: 'hint', className: 'gitbar-checkHint' }, t('trash.clearHint')),
1761
+ h('div', { key: 'actions', className: 'gitbar-dialogActions' }, [
1762
+ h('button', { key: 'cancel', type: 'button', className: 'gitbar-btn', onClick: () => setConfirmClear(false) }, t('action.cancel')),
1763
+ h(
1764
+ 'button',
1765
+ { key: 'go', type: 'button', className: 'gitbar-btn gitbar-btn-danger', 'data-gitbar-clearcancel': '', onClick: () => void clearAll() },
1766
+ t('action.clear'),
1767
+ ),
1768
+ ]),
1769
+ ]),
1770
+ ])
1771
+ : null,
1772
+ ])
1773
+ }
1774
+
1775
+ /* ------------------------------------------------------------------ *
1776
+ * 10. The diff tab
1777
+ * ------------------------------------------------------------------ */
1778
+
1779
+ /** Whatever the diff tab was last asked to show. */
1780
+ function diffTarget(params) {
1781
+ if (params === null || typeof params !== 'object') return null
1782
+ if (params.source === 'trash' && typeof params.id === 'string' && params.id !== '') {
1783
+ return { source: 'trash', id: params.id }
1784
+ }
1785
+ if (typeof params.path === 'string' && params.path !== '') {
1786
+ return { source: 'worktree', path: params.path }
1787
+ }
1788
+ return null
1789
+ }
1790
+
1791
+ function DiffBody(props) {
1792
+ const t = props.t ?? fallbackTranslate()
1793
+ const tab = props.useTabInfo().tab
1794
+ const sessionId = props.sessionId ?? tab.sessionId
1795
+ const navigation = tab.navigation ?? {}
1796
+ const target = diffTarget(navigation.params)
1797
+ const revision = navigation.revision ?? 0
1798
+
1799
+ const [state, setState] = useState(null)
1800
+ const [phase, setPhase] = useState('loading')
1801
+ const [error, setError] = useState(null)
1802
+
1803
+ const load = useCallback(async () => {
1804
+ if (target === null) {
1805
+ setState({ text: '', added: 0, deleted: 0, binary: false, truncated: false })
1806
+ setPhase('ready')
1807
+ return
1808
+ }
1809
+ setPhase('loading')
1810
+ try {
1811
+ if (target.source === 'trash') {
1812
+ const data = await rpc(ACTIONS.TRASH_DIFF, { sessionId, id: target.id }, tab.signal)
1813
+ setState({ text: data.text, added: 0, deleted: 0, binary: false, truncated: data.entry?.truncated === true })
1814
+ } else {
1815
+ const data = await rpc(ACTIONS.FILE_DIFF, { sessionId, path: target.path }, tab.signal)
1816
+ setState(data.diff)
1817
+ }
1818
+ setError(null)
1819
+ setPhase('ready')
1820
+ } catch (failure) {
1821
+ if (failure?.name === 'AbortError') return
1822
+ setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1823
+ setPhase('error')
1824
+ }
1825
+ }, [sessionId, tab.signal, target?.source, target?.id, target?.path, revision])
1826
+
1827
+ useEffect(() => {
1828
+ void load()
1829
+ }, [load])
1830
+
1831
+ useEffect(() => tab.actions.bindCommands({ refresh: () => void load() }), [tab.actions, load])
1832
+
1833
+ const rows = useMemo(() => (state === null ? [] : diffRows(state.text)), [state])
1834
+
1835
+ if (phase === 'loading' && state === null) {
1836
+ return h('div', { className: 'gitbar-root', 'data-gitbar-diff': 'loading' }, h(Spinner, { label: t('diff.loading') }))
1837
+ }
1838
+ if (phase === 'error') {
1839
+ return h('div', { className: 'gitbar-root', 'data-gitbar-diff': 'error' }, [
1840
+ h(Banner, { key: 'b', tone: 'error', text: errorText(t, error?.code, error?.message) }),
1841
+ h('div', { key: 'a', className: 'gitbar-picker' }, h('button', { type: 'button', className: 'gitbar-btn', onClick: () => void load() }, t('state.retry'))),
1842
+ ])
1843
+ }
1844
+
1845
+ const empty = state === null || state.text === ''
1846
+
1847
+ return h('div', { className: 'gitbar-root', 'data-gitbar-diff': 'ready' }, [
1848
+ h('div', { key: 'head', className: 'gitbar-head' }, [
1849
+ h('div', { key: 'ident', className: 'gitbar-ident' }, [
1850
+ h(
1851
+ 'span',
1852
+ { key: 'name', className: 'gitbar-repo', title: target?.path ?? '' },
1853
+ target === null ? t('diff.title') : target.source === 'trash' ? t('diff.trashEntry') : splitPath(target.path).name,
1854
+ ),
1855
+ target === null || target.source === 'trash' ? null : h('span', { key: 'dir', className: 'gitbar-branch' }, splitPath(target.path).dir),
1856
+ ]),
1857
+ h('div', { key: 'tools', className: 'gitbar-tools' }, [
1858
+ h(
1859
+ 'span',
1860
+ { key: 'stats', className: 'gitbar-stats', style: { marginRight: 4 } },
1861
+ [
1862
+ (state?.added ?? 0) > 0 ? h('span', { key: 'p', className: 'gitbar-plus' }, `+${state.added}`) : null,
1863
+ (state?.deleted ?? 0) > 0 ? h('span', { key: 'm', className: 'gitbar-minus' }, `−${state.deleted}`) : null,
1864
+ ],
1865
+ ),
1866
+ h(IconButton, { key: 'refresh', label: t('action.refresh'), onClick: () => void load() }, h(RefreshGlyph, {})),
1867
+ ]),
1868
+ ]),
1869
+
1870
+ state?.truncated === true ? h(Banner, { key: 'trunc', tone: 'warn', text: t('diff.truncated') }) : null,
1871
+ state?.binary === true ? h(Banner, { key: 'bin', tone: 'warn', text: t('diff.binary') }) : null,
1872
+
1873
+ empty
1874
+ ? h('div', { key: 'empty', className: 'gitbar-empty' }, t('diff.empty'))
1875
+ : h(
1876
+ 'div',
1877
+ { key: 'body', className: 'gitbar-diff', 'data-gitbar-diffbody': '' },
1878
+ rows.map((row, index) => {
1879
+ const key = `${index}`
1880
+ if (row.kind === 'file' || row.kind === 'hunk' || row.kind === 'meta' || row.kind === 'note') {
1881
+ return h('div', { key, className: `gitbar-diffRow gitbar-r-${row.kind}` }, [
1882
+ h('span', { key: 'g', className: 'gitbar-gutter' }, [h('span', { key: 'a' }, ''), h('span', { key: 'b' }, '')]),
1883
+ h('span', { key: 'c', className: 'gitbar-code' }, row.text),
1884
+ ])
1885
+ }
1886
+ const mark = row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '
1887
+ return h('div', { key, className: `gitbar-diffRow gitbar-r-${row.kind}` }, [
1888
+ h('span', { key: 'g', className: 'gitbar-gutter' }, [
1889
+ h('span', { key: 'a' }, row.kind === 'add' ? '' : String(row.oldLine ?? '')),
1890
+ h('span', { key: 'b' }, row.kind === 'del' ? '' : String(row.newLine ?? '')),
1891
+ ]),
1892
+ h('span', { key: 'c', className: 'gitbar-code' }, `${mark}${row.text}`),
1893
+ ])
1894
+ }),
1895
+ ),
1896
+ ])
1897
+ }
1898
+
1899
+ /* ------------------------------------------------------------------ *
1900
+ * 11. Tab titles
1901
+ * ------------------------------------------------------------------ */
1902
+
1903
+ /** The panel's chip: the branch mark plus the panel name. */
1904
+ function GitTitle(props) {
1905
+ const t = props.t ?? fallbackTranslate()
1906
+ return h('span', { className: 'gitbar-tabTitle' }, [
1907
+ h(GitGlyph, { key: 'g', size: 14 }),
1908
+ h('span', { key: 'l', className: 'gitbar-tabTitleText' }, t('panel.title')),
1909
+ ])
1910
+ }
1911
+
1912
+ /** The trash chip. */
1913
+ function TrashTitle(props) {
1914
+ const t = props.t ?? fallbackTranslate()
1915
+ return h('span', { className: 'gitbar-tabTitle' }, [
1916
+ h(ArchiveGlyph, { key: 'g', size: 14 }),
1917
+ h('span', { key: 'l', className: 'gitbar-tabTitleText' }, t('trash.title')),
1918
+ ])
1919
+ }
1920
+
1921
+ /**
1922
+ * The diff chip.
1923
+ *
1924
+ * Registered as a seat rather than left to the definition's `title(address)`
1925
+ * on purpose: a captured title never changes, and this tab is re-navigated
1926
+ * in place — so it has to read the live `navigation.params` to name the file
1927
+ * currently on screen.
1928
+ */
1929
+ function DiffTitle(props) {
1930
+ const t = props.t ?? fallbackTranslate()
1931
+ const tab = props.useTabInfo().tab
1932
+ const target = diffTarget(tab.navigation?.params)
1933
+ const label =
1934
+ target === null
1935
+ ? t('diff.title')
1936
+ : target.source === 'trash'
1937
+ ? t('diff.trashEntry')
1938
+ : splitPath(target.path).name
1939
+ return h('span', { className: 'gitbar-tabTitle' }, [
1940
+ h(ChevronRightGlyph, { key: 'g', size: 14 }),
1941
+ h('span', { key: 'l', className: 'gitbar-tabTitleText' }, label),
1942
+ ])
1943
+ }
1944
+
1945
+ /* ------------------------------------------------------------------ *
1946
+ * 12. Install
1947
+ * ------------------------------------------------------------------ */
1948
+
1949
+ /**
1950
+ * @param {object} ctx client context
1951
+ */
1952
+ function apply(ctx) {
1953
+ ensureStyle()
1954
+
1955
+ // The dictionaries go to the locale service when there is one; the inline
1956
+ // table is the fallback for a composition without it.
1957
+ const locale = ctx.locale
1958
+ if (locale !== undefined && typeof locale.register === 'function') {
1959
+ ctx.effect(() => locale.register(NS, { zh: ZH, en: EN }), 'dsh-gitbar-lite: copy')
1960
+ }
1961
+ const t = locale !== undefined && typeof locale.bind === 'function' ? locale.bind(NS) : fallbackTranslate()
1962
+
1963
+ /**
1964
+ * Inject the context plus a bound translator into every body, and the
1965
+ * session id when the seat has one.
1966
+ *
1967
+ * `ctx` is handed over explicitly because a slot's standard props carry no
1968
+ * context — the same reason the shipped settings row does it.
1969
+ */
1970
+ const injectFor = () => ({ ctx, t })
1971
+
1972
+ /* --- the panel ----------------------------------------------------- */
1973
+
1974
+ ctx.effect(
1975
+ () =>
1976
+ ctx.sidebarRightTabs.register({
1977
+ id: GIT_ID,
1978
+ kind: GIT_KIND,
1979
+ title: () => t('panel.title'),
1980
+ // The panel keeps its ticks and its half-written Summary while the
1981
+ // user peeks at a diff in the next tab along.
1982
+ keepMounted: true,
1983
+ guide: [
1984
+ {
1985
+ id: 'changes',
1986
+ order: GUIDE_ORDER,
1987
+ title: () => t('guide.title'),
1988
+ description: () => t('guide.description'),
1989
+ icon: GuideGlyph,
1990
+ },
1991
+ ],
1992
+ }),
1993
+ 'dsh-gitbar-lite: panel type',
1994
+ )
1995
+
1996
+ ctx.effect(
1997
+ () =>
1998
+ ctx.slots.inject('sidebar.right.pane.tab', () =>
1999
+ ctx.slots.register(
2000
+ {
2001
+ name: 'sidebar.right.pane.tab',
2002
+ key: GIT_ID,
2003
+ locale: NS,
2004
+ inject: (sessionId) => injectFor(sessionId),
2005
+ },
2006
+ GitBody,
2007
+ ),
2008
+ ),
2009
+ 'dsh-gitbar-lite: panel body',
2010
+ )
2011
+
2012
+ ctx.effect(
2013
+ () =>
2014
+ ctx.slots.inject('sidebar.right.pane.tab.title', () =>
2015
+ ctx.slots.register(
2016
+ { name: 'sidebar.right.pane.tab.title', key: GIT_ID, locale: NS, inject: () => ({ t }) },
2017
+ GitTitle,
2018
+ ),
2019
+ ),
2020
+ 'dsh-gitbar-lite: panel title',
2021
+ )
2022
+
2023
+ /* --- the trash ----------------------------------------------------- */
2024
+
2025
+ ctx.effect(
2026
+ () =>
2027
+ ctx.sidebarRightTabs.register({
2028
+ id: TRASH_ID,
2029
+ kind: TRASH_KIND,
2030
+ title: () => t('trash.title'),
2031
+ }),
2032
+ 'dsh-gitbar-lite: trash type',
2033
+ )
2034
+
2035
+ ctx.effect(
2036
+ () =>
2037
+ ctx.slots.inject('sidebar.right.pane.tab', () =>
2038
+ ctx.slots.register(
2039
+ {
2040
+ name: 'sidebar.right.pane.tab',
2041
+ key: TRASH_ID,
2042
+ locale: NS,
2043
+ inject: (sessionId) => injectFor(sessionId),
2044
+ },
2045
+ TrashBody,
2046
+ ),
2047
+ ),
2048
+ 'dsh-gitbar-lite: trash body',
2049
+ )
2050
+
2051
+ ctx.effect(
2052
+ () =>
2053
+ ctx.slots.inject('sidebar.right.pane.tab.title', () =>
2054
+ ctx.slots.register(
2055
+ { name: 'sidebar.right.pane.tab.title', key: TRASH_ID, locale: NS, inject: () => ({ t }) },
2056
+ TrashTitle,
2057
+ ),
2058
+ ),
2059
+ 'dsh-gitbar-lite: trash title',
2060
+ )
2061
+
2062
+ /* --- the diff tab, a singleton by address -------------------------- */
2063
+
2064
+ ctx.effect(
2065
+ () =>
2066
+ ctx.sidebarRightTabs.register({
2067
+ id: DIFF_ID,
2068
+ kind: DIFF_KIND,
2069
+ patterns: [DIFF_PATTERN],
2070
+ canOpen: (address) => address === DIFF_ADDRESS || address.startsWith(`${DIFF_ADDRESS}/`),
2071
+ // Never seen: the live chip comes from the title seat below.
2072
+ title: () => t('diff.title'),
2073
+ }),
2074
+ 'dsh-gitbar-lite: diff type',
2075
+ )
2076
+
2077
+ ctx.effect(
2078
+ () =>
2079
+ ctx.slots.inject('sidebar.right.pane.tab', () =>
2080
+ ctx.slots.register(
2081
+ {
2082
+ name: 'sidebar.right.pane.tab',
2083
+ key: DIFF_ID,
2084
+ locale: NS,
2085
+ inject: (sessionId) => injectFor(sessionId),
2086
+ },
2087
+ DiffBody,
2088
+ ),
2089
+ ),
2090
+ 'dsh-gitbar-lite: diff body',
2091
+ )
2092
+
2093
+ ctx.effect(
2094
+ () =>
2095
+ ctx.slots.inject('sidebar.right.pane.tab.title', () =>
2096
+ ctx.slots.register(
2097
+ { name: 'sidebar.right.pane.tab.title', key: DIFF_ID, locale: NS, inject: () => ({ t }) },
2098
+ DiffTitle,
2099
+ ),
2100
+ ),
2101
+ 'dsh-gitbar-lite: diff title',
2102
+ )
2103
+ }
2104
+
2105
+ exports.name = PLUGIN_ID
2106
+ // Every one of these is a shipped service of the Web app; without any of
2107
+ // them there is no right column to put a tab in.
2108
+ exports.inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs']
2109
+ exports.apply = apply
2110
+
2111
+ /**
2112
+ * Internals the test suite drives directly.
2113
+ *
2114
+ * The pure helpers are exported rather than duplicated in the tests, so a
2115
+ * change to the diff renderer cannot pass a stale expectation.
2116
+ */
2117
+ exports.internals = {
2118
+ RPC_ROUTE,
2119
+ ACTIONS,
2120
+ NS,
2121
+ GIT_ID,
2122
+ GIT_KIND,
2123
+ TRASH_ID,
2124
+ TRASH_KIND,
2125
+ DIFF_ID,
2126
+ DIFF_KIND,
2127
+ DIFF_ADDRESS,
2128
+ DIFF_PATTERN,
2129
+ ZH,
2130
+ EN,
2131
+ diffRows,
2132
+ statusLetter,
2133
+ splitPath,
2134
+ formatRelative,
2135
+ stagedCount,
2136
+ hasConflict,
2137
+ contextLabelKey,
2138
+ contextAction,
2139
+ diffTarget,
2140
+ commitReadiness,
2141
+ interpolate,
2142
+ errorText,
2143
+ // The pieces with no hooks in them, so the suite can assert what they
2144
+ // render without a DOM: a tick's state, the confirmation's default, and
2145
+ // the picker's candidates are all pure functions of their props.
2146
+ ChangeRow,
2147
+ DiscardDialog,
2148
+ RepositoryPicker,
2149
+ Banner,
2150
+ }
2151
+
2152
+ return module.exports
2153
+ },
2154
+ })