dsh-ide-git 0.4.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/src/client.js ADDED
@@ -0,0 +1,2612 @@
1
+ /**
2
+ * dsh-ide-git — client half (plain JavaScript, no build step).
3
+ *
4
+ * Registers ONE dsh-better-sidebar tab: an IDE-style Git tool window.
5
+ *
6
+ * Two surfaces, one registration (better-sidebar 0.19.x): the DSH NATIVE right
7
+ * sidebar (narrow, tall) and the plugin's bottom workbench (wide, flat). The
8
+ * panel never asks which one it is in — it measures its container and picks a
9
+ * chrome:
10
+ *
11
+ * columns wide and tall enough : repo/branch bar | tree | graph | changes
12
+ * stack narrow and tall : changes over graph, collapsible tree
13
+ * compact short or very narrow : one header row + a Changes/History switch
14
+ *
15
+ * Theming: no hard-coded surfaces. Every surface is a --dsw-alias-* token, so
16
+ * transparent themes and background plugins (dsh-any-background) show through,
17
+ * exactly like better-sidebar's own panels do.
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: 'dsh-ide-git',
21
+ factory: (require) => {
22
+ const React = require('react')
23
+ const E = React.createElement
24
+ const useState = React.useState
25
+ const useEffect = React.useEffect
26
+ const useMemo = React.useMemo
27
+ const useRef = React.useRef
28
+ const useCallback = React.useCallback
29
+ const useLayoutEffect = React.useLayoutEffect
30
+
31
+ const API_BASE = '/dsh-ide-git/api'
32
+ const TAB_ID = 'dsh-ide-git:panel'
33
+ const LANE_WIDTH = 14
34
+ const LANE_COLORS = ['#4d6bfe', '#e2a03f', '#3fb950', '#d2679b', '#59b0d6', '#b083f0', '#d2694a', '#8a9aa8']
35
+ const AUTO_REFRESH_MS = 12000
36
+ const REPO_KEY = 'dsh-ide-git.repo.v1'
37
+ const COMPACT_MAX_WIDTH = 400
38
+ /* Branch rows sit one step right of their SECTION header, and each namespace
39
+ folder adds another step. Without the base step the rows started at 8px —
40
+ left of the section title, which reads as "no indentation at all".
41
+ Folder headers use the same formula, so a folder's chevron lines up with the
42
+ icon of a branch on the same level. */
43
+ const BRANCH_INDENT_BASE = 34
44
+ const BRANCH_INDENT_STEP = 14
45
+
46
+ /* ============================== i18n ============================== */
47
+
48
+ const ZH = {
49
+ 'title': 'Git',
50
+ 'description': 'IDE 级 Git 面板:分支树 / 提交图谱 / 变更与提交',
51
+ 'repo.label': '仓库',
52
+ 'repo.workspace': '工作区',
53
+ 'repo.nested': '子仓库',
54
+ 'repo.none': '当前工作区不是 Git 仓库',
55
+ 'repo.pick': '选择要查看的仓库',
56
+ 'repo.empty': '这个工作区下没有找到 Git 仓库',
57
+ 'repo.switch': '切换仓库',
58
+ 'branch.switch': '切换分支',
59
+ 'seg.changes': '变更 {n}',
60
+ 'seg.history': '历史',
61
+ 'branches.local': '本地',
62
+ 'branches.remote': '远程',
63
+ 'branches.tags': '标签',
64
+ 'branches.head': 'HEAD(当前分支)',
65
+ 'branches.favorites': '收藏',
66
+ 'branches.pickHint': '单击选中,双击签出',
67
+ 'branches.worktree': '已在另一工作区检出',
68
+ 'action.stash': '贮藏',
69
+ 'action.favorite': '收藏/取消收藏当前分支',
70
+ 'action.newTagHere': '在当前提交新建标签...',
71
+ 'stash.push': '贮藏当前更改',
72
+ 'stash.apply': '应用最近贮藏',
73
+ 'stash.drop': '删除最近贮藏',
74
+ 'stash.count': '共 {n} 条贮藏',
75
+ 'note.noChanges': '没有可显示的变更',
76
+ 'note.noBranches': '没有其他分支',
77
+ 'branches.filter': '分支或标签',
78
+ 'changes.title': '变更',
79
+ 'changes.staged': '已暂存',
80
+ 'changes.unstaged': '更改',
81
+ 'changes.untracked': '未跟踪',
82
+ 'changes.conflicted': '冲突',
83
+ 'changes.empty': '没有未提交的变更',
84
+ 'changes.commitPlaceholder': '提交信息(Ctrl+Enter 提交)',
85
+ 'changes.commit': '提交',
86
+ 'changes.amend': '修补',
87
+ 'changes.stageAll': '全部暂存',
88
+ 'changes.unstageAll': '全部取消暂存',
89
+ 'changes.groupBy': '分组依据',
90
+ 'changes.groupFlat': '平铺',
91
+ 'changes.groupDir': '按目录',
92
+ 'changes.expandAll': '全部展开',
93
+ 'changes.foldAll': '全部折叠',
94
+ 'changes.showIgnored': '显示忽略的文件',
95
+ 'changes.rootDir': '(根目录)',
96
+ 'changes.commitAndPush': '提交并推送',
97
+ 'action.stage': '暂存',
98
+ 'action.unstage': '取消暂存',
99
+ 'action.discard': '丢弃更改',
100
+ 'action.showDiff': '显示差异',
101
+ 'action.copyPath': '复制路径',
102
+ 'action.checkout': '签出',
103
+ 'action.newBranchFrom': '从该分支新建分支...',
104
+ 'action.newBranchHere': '在此新建分支...',
105
+ 'action.rename': '重命名...',
106
+ 'action.delete': '删除',
107
+ 'action.mergeIntoCurrent': '合并到当前分支',
108
+ 'action.rebaseCurrentOnto': '将当前分支变基到此',
109
+ 'action.compare': '与当前分支比较',
110
+ 'action.update': '更新(Fetch)',
111
+ 'action.push': '推送',
112
+ 'action.newTag': '新建标签...',
113
+ 'action.cherryPick': '优选(Cherry-Pick)',
114
+ 'action.revert': '还原提交',
115
+ 'action.resetSoft': '重置到此(保留更改)',
116
+ 'action.resetHard': '重置到此(丢弃更改)',
117
+ 'action.copyHash': '复制修订号',
118
+ 'action.details': '提交详情',
119
+ 'history.empty': '这个仓库还没有提交',
120
+ 'history.loadMore': '加载更多',
121
+ 'history.filter': '文本或哈希',
122
+ 'detail.files': '变更文件',
123
+ 'detail.back': '返回历史',
124
+ 'detail.noFiles': '没有文件变更',
125
+ 'diff.empty': '没有可显示的文本差异',
126
+ 'diff.binary': '二进制文件',
127
+ 'diff.loading': '正在加载差异...',
128
+ 'toolbar.refresh': '刷新',
129
+ 'toolbar.newBranch': '新建分支',
130
+ 'toolbar.fetch': '抓取(Fetch)',
131
+ 'toolbar.pull': '拉取(Pull)',
132
+ 'toolbar.push': '推送(Push)',
133
+ 'toolbar.tree': '分支栏',
134
+ 'rail.more': '更多操作',
135
+ 'rail.settings': '动作条设置',
136
+ 'rail.settingsHint': '拖动或用箭头调整顺序,眼睛图标控制显示',
137
+ 'rail.reset': '恢复默认',
138
+ 'rail.up': '上移',
139
+ 'rail.down': '下移',
140
+ 'rail.show': '显示',
141
+ 'rail.hide': '隐藏',
142
+ 'action.unavailable': '当前状态不可用',
143
+ 'filter.branch': '分支',
144
+ 'filter.user': '用户',
145
+ 'filter.date': '日期',
146
+ 'filter.path': '路径',
147
+ 'filter.all': '全部',
148
+ 'filter.today': '今天',
149
+ 'filter.week': '最近 7 天',
150
+ 'filter.month': '最近 30 天',
151
+ 'filter.year': '今年',
152
+ 'filter.pathPlaceholder': '过滤路径(回车应用)',
153
+ 'filter.sortDesc': '新→旧',
154
+ 'filter.sortAsc': '旧→新',
155
+ 'filter.clear': '清除筛选',
156
+ 'filter.none': '没有匹配的提交',
157
+ 'toast.undo': '撤回',
158
+ 'toast.close': '关闭',
159
+ 'toast.branchDeleted': '已删除分支 {name}',
160
+ 'toast.stashDropped': '已删除贮藏 {ref}',
161
+ 'toast.discarded': '已丢弃 {path} 的更改',
162
+ 'toast.discardNoUndo': '已丢弃 {path} 的更改(文件过大或过多,未保留撤回)',
163
+ 'toast.restored': '已恢复',
164
+ 'undo.menu': '最近可撤回的操作',
165
+ 'undo.empty': '现在没有可撤回的操作',
166
+ 'undo.branch': '分支',
167
+ 'undo.stash': '贮藏',
168
+ 'undo.discard': '丢弃',
169
+ 'confirm.typeName': '输入 {name} 以确认',
170
+ 'confirm.protected': '{name} 是主分支。删除是不可逆的高风险操作,请输入分支名确认。删除后仍可从浮窗撤回。',
171
+ 'discard.untracked.confirm': '删除未跟踪文件 {path}?文件内容会被删除,删除后浮窗里可以撤回。',
172
+ 'stashDrop.confirm': '删除贮藏 {ref}?删除后浮窗里可以撤回。',
173
+ 'operation.merge': '合并进行中',
174
+ 'operation.rebase': '变基进行中',
175
+ 'operation.cherry-pick': '优选进行中',
176
+ 'operation.revert': '还原进行中',
177
+ 'operation.bisect': '二分定位进行中',
178
+ 'operation.hint': '该仓库还有一个多步操作没有结束,先完成或中止它再执行分支级操作',
179
+ 'confirm.title': '确认操作',
180
+ 'confirm.cancel': '取消',
181
+ 'confirm.ok': '确定',
182
+ 'prompt.newBranch': '新分支名(将创建并签出)',
183
+ 'prompt.renameBranch': '重命名分支',
184
+ 'prompt.newTag': '新标签名',
185
+ 'prompt.fromHead': '当前 HEAD',
186
+ 'error.dismiss': '关闭',
187
+ 'status.loading': '正在读取...',
188
+ 'status.busy': '处理中...',
189
+ 'counts.staged': '已暂存 {n}',
190
+ 'counts.ignored': '忽略 {n}',
191
+ 'counts.unstaged': '更改 {n}',
192
+ 'counts.untracked': '未跟踪 {n}',
193
+ 'counts.conflicted': '冲突 {n}',
194
+ 'push.confirm': '将 {branch} 推送到 {upstream}?',
195
+ 'delete.confirm': '删除分支 {name}?删除后浮窗里可以撤回(未合并的分支 git 会拒绝删除)。',
196
+ 'discard.confirm': '丢弃 {path} 的更改?工作区内容会被覆盖,丢弃后浮窗里可以撤回。',
197
+ 'resetHard.confirm': '硬重置到 {hash}?未提交的更改会丢失。',
198
+ 'resetSoft.confirm': '将 HEAD 重置到 {hash}(保留工作区更改)?',
199
+ 'checkoutCommit.confirm': '签出提交 {hash}?(分离头指针)',
200
+ }
201
+
202
+ const EN = {
203
+ 'title': 'Git',
204
+ 'description': 'IDE-grade Git panel: branch tree / commit graph / changes',
205
+ 'repo.label': 'Repository',
206
+ 'repo.workspace': 'workspace',
207
+ 'repo.nested': 'nested',
208
+ 'repo.none': 'This workspace is not a Git repository',
209
+ 'repo.pick': 'Pick a repository to inspect',
210
+ 'repo.empty': 'No Git repository found in this workspace',
211
+ 'repo.switch': 'Switch repository',
212
+ 'branch.switch': 'Switch branch',
213
+ 'seg.changes': 'Changes {n}',
214
+ 'seg.history': 'History',
215
+ 'branches.local': 'Local',
216
+ 'branches.remote': 'Remote',
217
+ 'branches.tags': 'Tags',
218
+ 'branches.head': 'HEAD (current branch)',
219
+ 'branches.favorites': 'Favorites',
220
+ 'branches.pickHint': 'Click to select, double-click to check out',
221
+ 'branches.worktree': 'Checked out in another working tree',
222
+ 'action.stash': 'Stash',
223
+ 'action.favorite': 'Favorite / unfavorite current branch',
224
+ 'action.newTagHere': 'New tag on current commit...',
225
+ 'stash.push': 'Stash current changes',
226
+ 'stash.apply': 'Apply latest stash',
227
+ 'stash.drop': 'Drop latest stash',
228
+ 'stash.count': '{n} stash entries',
229
+ 'note.noChanges': 'No pending changes',
230
+ 'note.noBranches': 'No other branches',
231
+ 'branches.filter': 'Branch or tag',
232
+ 'changes.title': 'Changes',
233
+ 'changes.staged': 'Staged',
234
+ 'changes.unstaged': 'Changes',
235
+ 'changes.untracked': 'Untracked',
236
+ 'changes.conflicted': 'Conflicts',
237
+ 'changes.empty': 'No pending changes',
238
+ 'changes.commitPlaceholder': 'Commit message (Ctrl+Enter)',
239
+ 'changes.commit': 'Commit',
240
+ 'changes.amend': 'Amend',
241
+ 'changes.stageAll': 'Stage all',
242
+ 'changes.unstageAll': 'Unstage all',
243
+ 'changes.groupBy': 'Group by',
244
+ 'changes.groupFlat': 'Flat list',
245
+ 'changes.groupDir': 'By directory',
246
+ 'changes.expandAll': 'Expand all',
247
+ 'changes.foldAll': 'Collapse all',
248
+ 'changes.showIgnored': 'Show ignored files',
249
+ 'changes.rootDir': '(root)',
250
+ 'changes.commitAndPush': 'Commit and push',
251
+ 'action.stage': 'Stage',
252
+ 'action.unstage': 'Unstage',
253
+ 'action.discard': 'Discard changes',
254
+ 'action.showDiff': 'Show diff',
255
+ 'action.copyPath': 'Copy path',
256
+ 'action.checkout': 'Checkout',
257
+ 'action.newBranchFrom': 'New branch from here...',
258
+ 'action.newBranchHere': 'New branch here...',
259
+ 'action.rename': 'Rename...',
260
+ 'action.delete': 'Delete',
261
+ 'action.mergeIntoCurrent': 'Merge into current branch',
262
+ 'action.rebaseCurrentOnto': 'Rebase current branch onto this',
263
+ 'action.compare': 'Compare with current branch',
264
+ 'action.update': 'Update (fetch)',
265
+ 'action.push': 'Push',
266
+ 'action.newTag': 'New tag...',
267
+ 'action.cherryPick': 'Cherry-pick',
268
+ 'action.revert': 'Revert commit',
269
+ 'action.resetSoft': 'Reset here (keep changes)',
270
+ 'action.resetHard': 'Reset here (discard changes)',
271
+ 'action.copyHash': 'Copy revision number',
272
+ 'action.details': 'Commit details',
273
+ 'history.empty': 'This repository has no commits yet',
274
+ 'history.loadMore': 'Load more',
275
+ 'history.filter': 'Text or hash',
276
+ 'detail.files': 'Changed files',
277
+ 'detail.back': 'Back to history',
278
+ 'detail.noFiles': 'No file changes',
279
+ 'diff.empty': 'No textual diff to show',
280
+ 'diff.binary': 'Binary file',
281
+ 'diff.loading': 'Loading diff...',
282
+ 'toolbar.refresh': 'Refresh',
283
+ 'toolbar.newBranch': 'New branch',
284
+ 'toolbar.fetch': 'Fetch',
285
+ 'toolbar.pull': 'Pull',
286
+ 'toolbar.push': 'Push',
287
+ 'toolbar.tree': 'Branch pane',
288
+ 'rail.more': 'More actions',
289
+ 'rail.settings': 'Action bar settings',
290
+ 'rail.settingsHint': 'Drag or use the arrows to reorder; the eye toggles visibility',
291
+ 'rail.reset': 'Restore defaults',
292
+ 'rail.up': 'Move up',
293
+ 'rail.down': 'Move down',
294
+ 'rail.show': 'Show',
295
+ 'rail.hide': 'Hide',
296
+ 'action.unavailable': 'Not available in the current state',
297
+ 'filter.branch': 'Branch',
298
+ 'filter.user': 'User',
299
+ 'filter.date': 'Date',
300
+ 'filter.path': 'Path',
301
+ 'filter.all': 'All',
302
+ 'filter.today': 'Today',
303
+ 'filter.week': 'Last 7 days',
304
+ 'filter.month': 'Last 30 days',
305
+ 'filter.year': 'This year',
306
+ 'filter.pathPlaceholder': 'Filter by path (Enter)',
307
+ 'filter.sortDesc': 'Newest first',
308
+ 'filter.sortAsc': 'Oldest first',
309
+ 'filter.clear': 'Clear filters',
310
+ 'filter.none': 'No matching commits',
311
+ 'toast.undo': 'Undo',
312
+ 'toast.close': 'Close',
313
+ 'toast.branchDeleted': 'Deleted branch {name}',
314
+ 'toast.stashDropped': 'Dropped stash {ref}',
315
+ 'toast.discarded': 'Discarded changes in {path}',
316
+ 'toast.discardNoUndo': 'Discarded changes in {path} (too large to keep an undo)',
317
+ 'toast.restored': 'Restored',
318
+ 'undo.menu': 'Recently deleted (undo)',
319
+ 'undo.empty': 'Nothing to undo right now',
320
+ 'undo.branch': 'Branch',
321
+ 'undo.stash': 'Stash',
322
+ 'undo.discard': 'Discard',
323
+ 'confirm.typeName': 'Type {name} to confirm',
324
+ 'confirm.protected': '{name} is a main branch. Deleting it is high risk, so type the branch name to confirm — a toast can still bring it back afterwards.',
325
+ 'discard.untracked.confirm': 'Delete the untracked file {path}? Its content is removed — a toast will offer to bring it back.',
326
+ 'stashDrop.confirm': 'Drop stash {ref}? A toast will offer to bring it back.',
327
+ 'operation.merge': 'Merge in progress',
328
+ 'operation.rebase': 'Rebase in progress',
329
+ 'operation.cherry-pick': 'Cherry-pick in progress',
330
+ 'operation.revert': 'Revert in progress',
331
+ 'operation.bisect': 'Bisect in progress',
332
+ 'operation.hint': 'This repository still has a multi-step operation open — finish or abort it before branch-level actions',
333
+ 'confirm.title': 'Confirm',
334
+ 'confirm.cancel': 'Cancel',
335
+ 'confirm.ok': 'OK',
336
+ 'prompt.newBranch': 'New branch name (created and checked out)',
337
+ 'prompt.renameBranch': 'Rename branch',
338
+ 'prompt.newTag': 'New tag name',
339
+ 'prompt.fromHead': 'current HEAD',
340
+ 'error.dismiss': 'Dismiss',
341
+ 'status.loading': 'Reading...',
342
+ 'status.busy': 'Working...',
343
+ 'counts.staged': 'Staged {n}',
344
+ 'counts.ignored': 'Ignored {n}',
345
+ 'counts.unstaged': 'Changes {n}',
346
+ 'counts.untracked': 'Untracked {n}',
347
+ 'counts.conflicted': 'Conflicts {n}',
348
+ 'push.confirm': 'Push {branch} to {upstream}?',
349
+ 'delete.confirm': 'Delete branch {name}? A toast will offer to bring it back (git still refuses unmerged branches).',
350
+ 'discard.confirm': 'Discard changes in {path}? The working-tree content is overwritten — a toast will offer to bring it back.',
351
+ 'resetHard.confirm': 'Hard reset to {hash}? Uncommitted changes are lost.',
352
+ 'resetSoft.confirm': 'Move HEAD to {hash} (working tree kept)?',
353
+ 'checkoutCommit.confirm': 'Check out commit {hash}? (detached HEAD)',
354
+ }
355
+
356
+ function dictionaryOf(ctx) {
357
+ let active = ''
358
+ try {
359
+ const locale = ctx.get('locale')
360
+ if (locale !== undefined && typeof locale.getSnapshot === 'function') {
361
+ const snapshot = locale.getSnapshot()
362
+ if (snapshot !== null && typeof snapshot === 'object' && typeof snapshot.active === 'string') active = snapshot.active
363
+ }
364
+ } catch (error) { void error }
365
+ if (active === '' && typeof navigator === 'object' && navigator !== null && typeof navigator.language === 'string') active = navigator.language
366
+ return active.toLowerCase().indexOf('zh') === 0 ? ZH : EN
367
+ }
368
+
369
+ function fill(template, values) {
370
+ return template.replace(/\{(\w+)\}/g, (match, key) => {
371
+ const value = values[key]
372
+ return value === undefined ? match : String(value)
373
+ })
374
+ }
375
+
376
+ /* ============================== api ============================== */
377
+
378
+ async function request(method, payload) {
379
+ const response = await fetch(API_BASE + '/' + method, {
380
+ method: 'POST',
381
+ headers: { 'content-type': 'application/json' },
382
+ body: JSON.stringify(payload === undefined ? {} : payload),
383
+ })
384
+ let body = null
385
+ try { body = await response.json() } catch (error) { void error }
386
+ if (body === null || typeof body !== 'object') throw new Error('HTTP ' + response.status)
387
+ if (body.ok !== true) {
388
+ const error = body.error === undefined || body.error === null ? {} : body.error
389
+ const failure = new Error(typeof error.message === 'string' && error.message !== '' ? error.message : 'HTTP ' + response.status)
390
+ failure.code = typeof error.code === 'string' ? error.code : 'unknown'
391
+ throw failure
392
+ }
393
+ return body.data
394
+ }
395
+
396
+ /* ============================== storage ============================== */
397
+
398
+ function readRememberedRepo(sessionId) {
399
+ try {
400
+ const raw = window.localStorage.getItem(REPO_KEY)
401
+ if (raw === null || raw === '') return undefined
402
+ const parsed = JSON.parse(raw)
403
+ if (parsed === null || typeof parsed !== 'object') return undefined
404
+ const value = parsed[sessionId]
405
+ return typeof value === 'string' && value !== '' ? value : undefined
406
+ } catch (error) { void error }
407
+ return undefined
408
+ }
409
+
410
+ function rememberRepo(sessionId, repoPath) {
411
+ try {
412
+ const raw = window.localStorage.getItem(REPO_KEY)
413
+ const parsed = raw === null || raw === '' ? {} : JSON.parse(raw)
414
+ const next = parsed !== null && typeof parsed === 'object' ? parsed : {}
415
+ next[sessionId] = repoPath
416
+ window.localStorage.setItem(REPO_KEY, JSON.stringify(next))
417
+ } catch (error) { void error }
418
+ }
419
+
420
+ const FAV_KEY = 'dsh-ide-git.favorites.v1'
421
+
422
+ function readFavorites(repoPath) {
423
+ try {
424
+ const raw = window.localStorage.getItem(FAV_KEY)
425
+ if (raw === null || raw === '') return []
426
+ const parsed = JSON.parse(raw)
427
+ if (parsed === null || typeof parsed !== 'object') return []
428
+ const list = parsed[repoPath]
429
+ return Array.isArray(list) ? list.filter((entry) => typeof entry === 'string') : []
430
+ } catch (error) { void error }
431
+ return []
432
+ }
433
+
434
+ function toggleFavorite(repoPath, branch) {
435
+ const current = readFavorites(repoPath)
436
+ const next = current.indexOf(branch) >= 0 ? current.filter((entry) => entry !== branch) : current.concat([branch])
437
+ try {
438
+ const raw = window.localStorage.getItem(FAV_KEY)
439
+ const parsed = raw === null || raw === '' ? {} : JSON.parse(raw)
440
+ const all = parsed !== null && typeof parsed === 'object' ? parsed : {}
441
+ all[repoPath] = next
442
+ window.localStorage.setItem(FAV_KEY, JSON.stringify(all))
443
+ } catch (error) { void error }
444
+ return next
445
+ }
446
+
447
+ const RAIL_KEY = 'dsh-ide-git.rail.v1'
448
+
449
+ /* The action rail is user-composed. RAIL_SPECS is the canonical set AND the
450
+ default order; a stored config only holds a permutation plus the ids the
451
+ user hid, and normalizeRail() drops unknown ids and appends missing ones —
452
+ so a version that adds an action never breaks a stored config. */
453
+ const RAIL_SPECS = [
454
+ { id: 'refresh', icon: 'refresh', key: 'toolbar.refresh', tone: 'accent' },
455
+ { id: 'newBranch', icon: 'plus', key: 'toolbar.newBranch', tone: 'success' },
456
+ { id: 'checkout', icon: 'checkout', key: 'action.checkout', tone: 'accent' },
457
+ { id: 'delete', icon: 'trash', key: 'action.delete', tone: 'danger' },
458
+ { id: 'compare', icon: 'compare', key: 'action.compare', tone: 'violet' },
459
+ { id: 'diff', icon: 'file', key: 'action.showDiff', tone: 'secondary' },
460
+ { id: 'stash', icon: 'stash', key: 'action.stash', tone: 'cyan' },
461
+ { id: 'tag', icon: 'tag', key: 'action.newTagHere', tone: 'warn' },
462
+ { id: 'favorite', icon: 'star', key: 'action.favorite', tone: 'warn' },
463
+ { id: 'fetch', icon: 'fetch', key: 'toolbar.fetch', tone: 'cyan' },
464
+ { id: 'pull', icon: 'pull', key: 'toolbar.pull', tone: 'accent' },
465
+ { id: 'push', icon: 'push', key: 'toolbar.push', tone: 'success' },
466
+ ]
467
+ const RAIL_IDS = RAIL_SPECS.map((spec) => spec.id)
468
+
469
+ function normalizeRail(raw) {
470
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
471
+ const order = []
472
+ const storedOrder = Array.isArray(source.order) ? source.order : []
473
+ for (const id of storedOrder) {
474
+ if (typeof id === 'string' && RAIL_IDS.indexOf(id) >= 0 && order.indexOf(id) < 0) order.push(id)
475
+ }
476
+ for (const id of RAIL_IDS) { if (order.indexOf(id) < 0) order.push(id) }
477
+ const hidden = []
478
+ const storedHidden = Array.isArray(source.hidden) ? source.hidden : []
479
+ for (const id of storedHidden) {
480
+ if (typeof id === 'string' && RAIL_IDS.indexOf(id) >= 0 && hidden.indexOf(id) < 0) hidden.push(id)
481
+ }
482
+ return { order, hidden }
483
+ }
484
+
485
+ function readRailConfig() {
486
+ try {
487
+ const raw = window.localStorage.getItem(RAIL_KEY)
488
+ if (raw === null || raw === '') return normalizeRail(null)
489
+ return normalizeRail(JSON.parse(raw))
490
+ } catch (error) { void error }
491
+ return normalizeRail(null)
492
+ }
493
+
494
+ function writeRailConfig(config) {
495
+ try { window.localStorage.setItem(RAIL_KEY, JSON.stringify(config)) } catch (error) { void error }
496
+ }
497
+
498
+ /* ============================== formatting ============================== */
499
+
500
+ function relativeTime(iso) {
501
+ if (typeof iso !== 'string' || iso === '') return ''
502
+ const then = Date.parse(iso)
503
+ if (!Number.isFinite(then)) return ''
504
+ const seconds = Math.max(0, Math.round((Date.now() - then) / 1000))
505
+ if (seconds < 60) return seconds + 's'
506
+ const minutes = Math.round(seconds / 60)
507
+ if (minutes < 60) return minutes + 'm'
508
+ const hours = Math.round(minutes / 60)
509
+ if (hours < 24) return hours + 'h'
510
+ const days = Math.round(hours / 24)
511
+ if (days < 30) return days + 'd'
512
+ const months = Math.round(days / 30)
513
+ if (months < 12) return months + 'mo'
514
+ return Math.round(months / 12) + 'y'
515
+ }
516
+
517
+ function shortDate(iso) {
518
+ if (typeof iso !== 'string' || iso === '') return ''
519
+ const at = new Date(iso)
520
+ if (Number.isNaN(at.getTime())) return ''
521
+ const pad = (value) => (value < 10 ? '0' + value : String(value))
522
+ return at.getFullYear() + '/' + pad(at.getMonth() + 1) + '/' + pad(at.getDate()) + ' ' + pad(at.getHours()) + ':' + pad(at.getMinutes())
523
+ }
524
+
525
+ function baseName(filePath) {
526
+ if (typeof filePath !== 'string') return ''
527
+ const parts = filePath.split('/')
528
+ return parts[parts.length - 1]
529
+ }
530
+
531
+ function dirName(filePath) {
532
+ if (typeof filePath !== 'string') return ''
533
+ const index = filePath.lastIndexOf('/')
534
+ return index < 0 ? '' : filePath.slice(0, index)
535
+ }
536
+
537
+ /* ============================== commit graph ============================== */
538
+
539
+ function buildRows(commits) {
540
+ const lanes = []
541
+ const rows = []
542
+ for (const commit of commits) {
543
+ const before = lanes.slice()
544
+ let lane = lanes.indexOf(commit.hash)
545
+ if (lane === -1) {
546
+ lane = lanes.indexOf(null)
547
+ if (lane === -1) { lanes.push(null); lane = lanes.length - 1 }
548
+ }
549
+ const parents = Array.isArray(commit.parents) ? commit.parents : []
550
+ const parentLanes = []
551
+ /* A commit whose parent already sits on ANOTHER lane merges into it:
552
+ its own lane ends here and the edge is drawn across. Until v0.4.1 the
553
+ first parent was parked on the commit's own lane unconditionally, so
554
+ a branch that merged back into a lane to its left kept a stub of its
555
+ own colour running on above the junction — a fork that never
556
+ happened. Only a parent that is not on the board yet inherits the
557
+ lane, which is what keeps an ordinary line continuous. */
558
+ if (parents.length > 0) {
559
+ const first = lanes.indexOf(parents[0])
560
+ if (first !== -1 && first !== lane) {
561
+ lanes[lane] = null
562
+ parentLanes.push(first)
563
+ } else {
564
+ lanes[lane] = parents[0]
565
+ }
566
+ } else {
567
+ lanes[lane] = null
568
+ }
569
+ for (let index = 1; index < parents.length; index += 1) {
570
+ let slot = lanes.indexOf(parents[index])
571
+ if (slot === -1) {
572
+ slot = lanes.indexOf(null)
573
+ if (slot === -1) { lanes.push(parents[index]); slot = lanes.length - 1 }
574
+ else lanes[slot] = parents[index]
575
+ }
576
+ parentLanes.push(slot)
577
+ }
578
+ rows.push({ commit, lane, parentLanes, before: before, after: lanes.slice(), width: 1 })
579
+ }
580
+ let laneCount = 1
581
+ for (const row of rows) laneCount = Math.max(laneCount, row.before.length, row.after.length)
582
+ for (const row of rows) row.width = laneCount
583
+ return rows
584
+ }
585
+
586
+ /* One SVG per row, but the geometry is chosen so that rows stack into ONE
587
+ continuous graph: a lane segment always covers the full row box (0..height)
588
+ whenever the lane exists above and below, and only rounds off to the row
589
+ centre where the lane genuinely starts (a branch tip) or ends (a root).
590
+ v0.1.7 started every non-own lane at 0 and every own lane at height/2, which
591
+ left a visible gap between two consecutive commits on the same lane. */
592
+ function GraphCell(props) {
593
+ const row = props.row
594
+ const height = props.height
595
+ const width = Math.max(1, row.width) * LANE_WIDTH + 8
596
+ const middle = height / 2
597
+ const children = []
598
+ const x = (lane) => 8 + lane * LANE_WIDTH
599
+ const color = (lane) => LANE_COLORS[lane % LANE_COLORS.length]
600
+ const depth = Math.max(row.before.length, row.after.length)
601
+ for (let lane = 0; lane < depth; lane += 1) {
602
+ const above = lane < row.before.length ? row.before[lane] : null
603
+ const below = lane < row.after.length ? row.after[lane] : null
604
+ const hasTop = above !== null && above !== undefined
605
+ const hasBottom = below !== null && below !== undefined
606
+ if (hasTop === false && hasBottom === false) continue
607
+ const y1 = hasTop ? 0 : middle
608
+ const y2 = hasBottom ? height : middle
609
+ if (y1 === y2) continue
610
+ children.push(E('line', {
611
+ key: 'lane-' + lane,
612
+ x1: x(lane), y1: y1, x2: x(lane), y2: y2,
613
+ stroke: color(lane), strokeWidth: 2, strokeLinecap: 'round',
614
+ }))
615
+ }
616
+ for (const parentLane of row.parentLanes) {
617
+ if (parentLane === row.lane) continue
618
+ children.push(E('path', {
619
+ key: 'edge-' + parentLane,
620
+ d: 'M ' + x(row.lane) + ' ' + middle + ' C ' + x(row.lane) + ' ' + height + ', ' + x(parentLane) + ' ' + middle + ', ' + x(parentLane) + ' ' + height,
621
+ fill: 'none',
622
+ stroke: color(parentLane),
623
+ strokeWidth: 2,
624
+ strokeLinecap: 'round',
625
+ }))
626
+ }
627
+ const isHead = Array.isArray(row.commit.refs) && row.commit.refs.some((ref) => String(ref).indexOf('HEAD') >= 0)
628
+ children.push(isHead
629
+ ? E('circle', { key: 'dot', cx: x(row.lane), cy: middle, r: 3.8, fill: 'none', stroke: color(row.lane), strokeWidth: 2 })
630
+ : E('circle', { key: 'dot', cx: x(row.lane), cy: middle, r: 3.6, fill: color(row.lane) }))
631
+ return E('svg', { className: 'dig-graph', width: width, height: height, viewBox: '0 0 ' + width + ' ' + height }, children)
632
+ }
633
+
634
+ /* ============================== diff ============================== */
635
+
636
+ function diffLines(patch) {
637
+ const text = typeof patch === 'string' ? patch : ''
638
+ if (text.trim() === '') return []
639
+ const raw = text.split('\n')
640
+ const out = []
641
+ let oldLine = 0
642
+ let newLine = 0
643
+ for (const line of raw) {
644
+ let kind = 'ctx'
645
+ if (line.indexOf('diff --git') === 0 || line.indexOf('index ') === 0 || line.indexOf('--- ') === 0 || line.indexOf('+++ ') === 0 || line.indexOf('new file') === 0 || line.indexOf('deleted file') === 0 || line.indexOf('similarity index') === 0 || line.indexOf('rename ') === 0) {
646
+ kind = 'meta'
647
+ } else if (line.indexOf('@@') === 0) {
648
+ kind = 'hunk'
649
+ const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
650
+ if (match !== null) { oldLine = Number.parseInt(match[1], 10); newLine = Number.parseInt(match[2], 10) }
651
+ } else if (line.indexOf('+') === 0) { kind = 'add'; newLine += 1 }
652
+ else if (line.indexOf('-') === 0) { kind = 'del'; oldLine += 1 }
653
+ else if (line.indexOf('\\') === 0) { kind = 'meta' }
654
+ else { oldLine += 1; newLine += 1 }
655
+ out.push({
656
+ kind, text: line,
657
+ oldLine: kind === 'add' || kind === 'meta' || kind === 'hunk' ? null : oldLine,
658
+ newLine: kind === 'del' || kind === 'meta' || kind === 'hunk' ? null : newLine,
659
+ })
660
+ }
661
+ return out
662
+ }
663
+
664
+ function DiffBody(props) {
665
+ const rows = useMemo(() => diffLines(props.patch), [props.patch])
666
+ if (props.loading === true) return E('div', { className: 'dig-empty' }, props.t('diff.loading'))
667
+ if (props.binary === true) return E('div', { className: 'dig-empty' }, props.t('diff.binary'))
668
+ if (rows.length === 0) return E('div', { className: 'dig-empty' }, props.t('diff.empty'))
669
+ return E('div', { className: 'dig-diff' }, rows.map((row, index) => E('div', { key: index, className: 'dig-diff-line dig-diff-' + row.kind },
670
+ E('span', { className: 'dig-diff-gutter' }, row.oldLine === null ? '' : String(row.oldLine)),
671
+ E('span', { className: 'dig-diff-gutter' }, row.newLine === null ? '' : String(row.newLine)),
672
+ E('span', { className: 'dig-diff-text' }, row.text === '' ? ' ' : row.text),
673
+ )))
674
+ }
675
+
676
+ /* ============================== ui kit ============================== */
677
+
678
+ const ICONS = {
679
+ branch: ['M4 3.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z', 'M12 3.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z', 'M4 6.5v3a3 3 0 0 0 3 3h2', 'M12 6.5v1a3 3 0 0 1-3 3'],
680
+ commit: ['M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z', 'M8 1.5v4', 'M8 10.5v4'],
681
+ refresh: ['M13 8a5 5 0 1 1-1.6-3.7', 'M13 3v3h-3'],
682
+ fetch: ['M8 2v8', 'M4.5 6.5 8 10l3.5-3.5', 'M3 13h10'],
683
+ // pull mirrors push: arrow INTO the local side (down) with the rail on top,
684
+ // where fetch keeps its arrow against the bottom rail. Both used to point up,
685
+ // so pull read as "push" in the rail (reported on v0.3.9).
686
+ pull: ['M8 3v8', 'M4.5 7.5 8 11l3.5-3.5', 'M3 2h10'],
687
+ push: ['M8 2v8', 'M4.5 5.5 8 2l3.5 3.5', 'M3 13h10'],
688
+ plus: ['M8 3v10', 'M3 8h10'],
689
+ minus: ['M3 8h10'],
690
+ undo: ['M5 6H2V3', 'M2.5 6.5a5.5 5.5 0 1 1 1.8 5'],
691
+ chevron: ['M5 6.5 8 9.5l3-3'],
692
+ close: ['M4 4l8 8', 'M12 4l-8 8'],
693
+ star: ['M8 2.2l1.8 3.8 4 .5-2.9 2.7.8 4-3.7-2.1-3.7 2.1.8-4L2.2 6.5l4-.5z'],
694
+ folder: ['M2 4.5h4l1.2 1.5H14v6.5H2z'],
695
+ file: ['M4 2h5l3 3v9H4z', 'M9 2v3h3'],
696
+ trash: ['M3 5h10', 'M6.5 5V3.5h3V5', 'M4.5 5l.6 8.5h5.8L11.5 5', 'M6.8 7v4', 'M9.2 7v4'],
697
+ checkout: ['M3 8h7', 'M7.5 5.5 10 8l-2.5 2.5', 'M13 3.5v9'],
698
+ compare: ['M4 4h7', 'M4 12h7', 'M6.5 1.5 4 4l2.5 2.5', 'M9.5 9.5 12 12l-2.5 2.5'],
699
+ stash: ['M2.5 3.5h11V6h-11z', 'M3.5 6v6.5h9V6', 'M6.5 8.5h3'],
700
+ tag: ['M2.5 7.5 7.5 2.5h6v6l-5 5z', 'M10.5 5.5h.01'],
701
+ settings: ['M8 6.4a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2Z', 'M8 1.8v1.5', 'M8 12.7v1.5', 'M1.8 8h1.5', 'M12.7 8h1.5', 'M3.6 3.6l1.1 1.1', 'M11.3 11.3l1.1 1.1', 'M12.4 3.6l-1.1 1.1', 'M4.7 11.3l-1.1 1.1'],
702
+ more: ['M3.6 8h0.01', 'M8 8h0.01', 'M12.4 8h0.01'],
703
+ eye: ['M1.5 8S4.1 3.8 8 3.8 14.5 8 14.5 8 11.9 12.2 8 12.2 1.5 8 1.5 8Z', 'M8 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z'],
704
+ eyeOff: ['M1.6 8S4.2 4 8 4c1 0 1.9.2 2.7.6', 'M13 5.6c.9 1.1 1.5 2.4 1.5 2.4S11.9 12 8 12c-.9 0-1.7-.2-2.4-.5', 'M2.6 2.6l10.8 10.8'],
705
+ up: ['M8 12.6V3.6', 'M4.6 7 8 3.6 11.4 7'],
706
+ down: ['M8 3.4v9', 'M4.6 9 8 12.4 11.4 9'],
707
+ grip: ['M5.6 4.6h4.8', 'M5.6 8h4.8', 'M5.6 11.4h4.8'],
708
+ filter: ['M2 3.6h12l-4.6 5.2v4.2l-2.8-1.4V8.8z'],
709
+ check: ['M3.2 8.4 6.4 11.6 12.8 4.6'],
710
+ expand: ['M4.5 6.2 8 9.7l3.5-3.5', 'M3 12.4h10'],
711
+ collapse: ['M4.5 9.8 8 6.3l3.5 3.5', 'M3 3.6h10'],
712
+ }
713
+
714
+ function Icon(props) {
715
+ const size = props.size === undefined ? 14 : props.size
716
+ const shape = ICONS[props.name] === undefined ? ICONS.commit : ICONS[props.name]
717
+ const weight = props.name === 'more' ? 2.6 : props.name === 'grip' ? 1.6 : 1.4
718
+ return E('svg', {
719
+ className: 'dig-icon', width: size, height: size, viewBox: '0 0 16 16',
720
+ fill: 'none', stroke: 'currentColor', strokeWidth: weight,
721
+ strokeLinecap: 'round', strokeLinejoin: 'round',
722
+ }, shape.map((d, index) => E('path', { key: index, d: d })))
723
+ }
724
+
725
+ /* Menus are laid out INSIDE the panel on purpose: dsh-better-sidebar's bottom
726
+ workbench declares contain: layout style, and layout containment makes that
727
+ element the containing block for position:fixed descendants. A viewport-
728
+ anchored menu inside a 300px-tall panel is therefore laid out against the
729
+ panel with viewport coordinates, lands outside it and looks exactly like
730
+ 'right click does nothing'. Anchors become panel-relative offsets clamped to
731
+ the panel box, and the menu scrolls when it is taller than the room left. */
732
+ function ContextMenu(props) {
733
+ const ref = useRef(null)
734
+ const closeRef = useRef(props.onClose)
735
+ const [position, setPosition] = useState({ left: props.anchor.x, top: props.anchor.y })
736
+ useEffect(() => { closeRef.current = props.onClose })
737
+ // Layout effect: the clamp below runs before the browser paints, so the menu
738
+ // never flashes at its raw (possibly out-of-panel) anchor position.
739
+ useLayoutEffect(() => {
740
+ const element = ref.current
741
+ if (element === null) return
742
+ const rect = element.getBoundingClientRect()
743
+ const width = props.boundsWidth === undefined ? 0 : props.boundsWidth
744
+ const height = props.boundsHeight === undefined ? 0 : props.boundsHeight
745
+ let left = props.anchor.x
746
+ let top = props.anchor.y
747
+ if (width > 0) left = Math.max(4, Math.min(left, width - rect.width - 4))
748
+ if (height > 0 && top + rect.height > height - 4) top = Math.max(4, top - rect.height)
749
+ setPosition({ left: left, top: top })
750
+ }, [props.anchor.x, props.anchor.y, props.boundsWidth, props.boundsHeight])
751
+ useEffect(() => {
752
+ const onDown = (event) => {
753
+ if (ref.current !== null && ref.current.contains(event.target)) return
754
+ closeRef.current()
755
+ }
756
+ const onKey = (event) => { if (event.key === 'Escape') closeRef.current() }
757
+ const timer = setTimeout(() => document.addEventListener('mousedown', onDown, true), 0)
758
+ document.addEventListener('keydown', onKey)
759
+ return () => {
760
+ clearTimeout(timer)
761
+ document.removeEventListener('mousedown', onDown, true)
762
+ document.removeEventListener('keydown', onKey)
763
+ }
764
+ }, [])
765
+ return E('div', { className: 'dig-menu', ref: ref, style: { left: position.left + 'px', top: position.top + 'px' } },
766
+ props.items.map((item, index) => (item === null
767
+ ? E('div', { key: 'sep-' + index, className: 'dig-menu-sep' })
768
+ : E('button', {
769
+ key: item.id + '-' + index,
770
+ type: 'button',
771
+ title: item.disabled === true && item.reason !== undefined ? item.reason : '',
772
+ className: 'dig-menu-item'
773
+ + (item.danger === true ? ' dig-menu-danger' : '')
774
+ + (item.disabled === true ? ' dig-menu-disabled' : '')
775
+ + (item.active === true ? ' dig-menu-item-active' : ''),
776
+ disabled: item.disabled === true,
777
+ onClick: (event) => { closeRef.current(); item.run(event) },
778
+ },
779
+ item.icon === undefined ? null : E('span', { className: 'dig-menu-icon dig-tone-' + (item.tone === undefined ? 'secondary' : item.tone) }, E(Icon, { name: item.icon, size: 13 })),
780
+ E('span', { className: 'dig-menu-label' }, item.label)))))
781
+ }
782
+
783
+ /* Lets the user choose which actions the rail shows and in which order. The
784
+ list is drag-sortable and arrow-sortable (the arrows also work with a
785
+ keyboard); visibility is a per-action toggle, not a delete, so a hidden
786
+ action keeps its position. */
787
+ function RailSettings(props) {
788
+ const t = props.t
789
+ const [dragId, setDragId] = useState(null)
790
+ return E('div', {
791
+ className: 'dig-overlay',
792
+ onMouseDown: (event) => { if (event.target === event.currentTarget) props.onClose() },
793
+ },
794
+ E('div', { className: 'dig-dialog dig-dialog-wide' },
795
+ E('div', { className: 'dig-dialog-title' }, t('rail.settings')),
796
+ E('div', { className: 'dig-dialog-text' }, t('rail.settingsHint')),
797
+ E('div', { className: 'dig-rail-list' },
798
+ props.items.map((item, index) => E('div', {
799
+ key: item.id,
800
+ className: 'dig-rail-item'
801
+ + (item.hidden === true ? ' dig-rail-item-off' : '')
802
+ + (dragId === item.id ? ' dig-rail-item-drag' : ''),
803
+ draggable: true,
804
+ onDragStart: (event) => {
805
+ setDragId(item.id)
806
+ if (event.dataTransfer !== undefined && event.dataTransfer !== null) {
807
+ event.dataTransfer.effectAllowed = 'move'
808
+ try { event.dataTransfer.setData('text/plain', item.id) } catch (error) { void error }
809
+ }
810
+ },
811
+ onDragEnd: () => setDragId(null),
812
+ onDragOver: (event) => { event.preventDefault() },
813
+ onDrop: (event) => { event.preventDefault(); props.onDrop(dragId, item.id); setDragId(null) },
814
+ },
815
+ E('span', { className: 'dig-rail-grip' }, E(Icon, { name: 'grip', size: 12 })),
816
+ E('span', { className: 'dig-rail-item-icon dig-tone-' + item.tone }, E(Icon, { name: item.icon, size: 13 })),
817
+ E('span', { className: 'dig-rail-item-label' }, item.label),
818
+ E('button', {
819
+ type: 'button', className: 'dig-icon-btn dig-icon-btn-small', title: t('rail.up'),
820
+ disabled: index === 0, onClick: () => props.onMove(item.id, -1),
821
+ }, E(Icon, { name: 'up', size: 12 })),
822
+ E('button', {
823
+ type: 'button', className: 'dig-icon-btn dig-icon-btn-small', title: t('rail.down'),
824
+ disabled: index === props.items.length - 1, onClick: () => props.onMove(item.id, 1),
825
+ }, E(Icon, { name: 'down', size: 12 })),
826
+ E('button', {
827
+ type: 'button',
828
+ className: 'dig-icon-btn dig-icon-btn-small' + (item.hidden === true ? '' : ' dig-icon-btn-active'),
829
+ title: item.hidden === true ? t('rail.show') : t('rail.hide'),
830
+ onClick: () => props.onToggle(item.id),
831
+ }, E(Icon, { name: item.hidden === true ? 'eyeOff' : 'eye', size: 13 }))))),
832
+ E('div', { className: 'dig-dialog-actions' },
833
+ E('button', { type: 'button', className: 'dig-btn', onClick: props.onReset }, t('rail.reset')),
834
+ E('button', { type: 'button', className: 'dig-btn dig-btn-primary', onClick: props.onClose }, t('confirm.ok')))))
835
+ }
836
+
837
+ /* Transient result chips, anchored inside the panel (see ContextMenu for why
838
+ nothing here is position:fixed). A toast can carry one action — currently
839
+ 'undo', which is how a real delete is still reversible. */
840
+ function ToastStack(props) {
841
+ if (props.toasts.length === 0) return null
842
+ return E('div', { className: 'dig-toasts' }, props.toasts.map((item) => E('div', {
843
+ key: item.id,
844
+ className: 'dig-toast' + (item.tone === undefined ? '' : ' dig-toast-' + item.tone),
845
+ },
846
+ E('span', { className: 'dig-toast-icon' }, E(Icon, { name: item.icon === undefined ? 'check' : item.icon, size: 13 })),
847
+ E('span', { className: 'dig-toast-text', title: item.text }, item.text),
848
+ item.actionLabel === undefined ? null : E('button', {
849
+ type: 'button', className: 'dig-toast-action', onClick: () => item.onAction(),
850
+ }, item.actionLabel),
851
+ E('button', {
852
+ type: 'button', className: 'dig-icon-btn dig-icon-btn-small',
853
+ title: props.t('toast.close'), onClick: () => item.onClose(),
854
+ }, E(Icon, { name: 'close', size: 11 })))))
855
+ }
856
+
857
+ function PromptDialog(props) {
858
+ const [value, setValue] = useState(props.initialValue === undefined ? '' : props.initialValue)
859
+ const ref = useRef(null)
860
+ useEffect(() => { if (ref.current !== null) ref.current.focus() }, [])
861
+ const submit = () => { if (value.trim() !== '') props.onSubmit(value.trim()) }
862
+ return E('div', { className: 'dig-overlay' },
863
+ E('div', { className: 'dig-dialog' },
864
+ E('div', { className: 'dig-dialog-title' }, props.title),
865
+ E('input', {
866
+ ref: ref, className: 'dig-input', value: value, spellCheck: false,
867
+ placeholder: props.placeholder === undefined ? '' : props.placeholder,
868
+ onChange: (event) => setValue(event.target.value),
869
+ onKeyDown: (event) => {
870
+ if (event.key === 'Enter') { event.preventDefault(); submit() }
871
+ if (event.key === 'Escape') { event.preventDefault(); props.onCancel() }
872
+ },
873
+ }),
874
+ E('div', { className: 'dig-dialog-actions' },
875
+ E('button', { type: 'button', className: 'dig-btn', onClick: props.onCancel }, props.cancelLabel),
876
+ E('button', { type: 'button', className: 'dig-btn dig-btn-primary', onClick: submit }, props.okLabel))))
877
+ }
878
+
879
+ /* A destructive confirm: focus starts on Cancel (never on the red button), Enter
880
+ only submits when the dialog asked for a typed confirmation, and passing
881
+ requireText turns the dialog into a type-the-name gate. */
882
+ function ConfirmDialog(props) {
883
+ const t = props.t
884
+ const [typed, setTyped] = useState('')
885
+ const cancelRef = useRef(null)
886
+ const needText = typeof props.requireText === 'string' && props.requireText !== ''
887
+ const matches = needText === false || typed.trim() === props.requireText
888
+ useEffect(() => { if (cancelRef.current !== null) cancelRef.current.focus() }, [])
889
+ return E('div', { className: 'dig-overlay' },
890
+ E('div', { className: 'dig-dialog' },
891
+ E('div', { className: 'dig-dialog-title' }, props.title),
892
+ E('div', { className: 'dig-dialog-text' }, props.text),
893
+ needText === false ? null : E('input', {
894
+ className: 'dig-input', value: typed, spellCheck: false,
895
+ placeholder: fill(t('confirm.typeName'), { name: props.requireText }),
896
+ onChange: (event) => setTyped(event.target.value),
897
+ onKeyDown: (event) => { if (event.key === 'Enter' && matches === true) props.onConfirm() },
898
+ }),
899
+ E('div', { className: 'dig-dialog-actions' },
900
+ E('button', { ref: cancelRef, type: 'button', className: 'dig-btn', onClick: props.onCancel }, props.cancelLabel),
901
+ E('button', { type: 'button', className: 'dig-btn dig-btn-danger', disabled: matches === false, onClick: props.onConfirm }, props.okLabel))))
902
+ }
903
+
904
+ function Segmented(props) {
905
+ return E('div', { className: 'dig-seg' },
906
+ props.items.map((item) => E('button', {
907
+ key: item.id,
908
+ type: 'button',
909
+ className: 'dig-seg-item',
910
+ 'data-active': props.value === item.id ? 'true' : 'false',
911
+ onClick: () => props.onChange(item.id),
912
+ }, item.label)))
913
+ }
914
+
915
+ function RepoSelect(props) {
916
+ const t = props.t
917
+ if (props.repos.length <= 1) {
918
+ const only = props.repos.length === 1 ? props.repos[0] : null
919
+ return E('span', { className: 'dig-repo-static', title: only === null ? '' : only.path },
920
+ E(Icon, { name: 'folder', size: 12 }),
921
+ E('span', { className: 'dig-repo-name' }, only === null ? t('repo.empty') : only.name))
922
+ }
923
+ return E('span', { className: 'dig-select-wrap', title: t('repo.switch') },
924
+ E(Icon, { name: 'folder', size: 12 }),
925
+ E('select', {
926
+ className: 'dig-select dig-select-repo',
927
+ value: props.value === null ? '' : props.value,
928
+ onChange: (event) => props.onChange(event.target.value),
929
+ }, props.repos.map((repo) => E('option', { key: repo.path, value: repo.path },
930
+ repo.name + (repo.branch === null || repo.branch === undefined ? '' : ' · ' + repo.branch)))))
931
+ }
932
+
933
+ function RepoPicker(props) {
934
+ const t = props.t
935
+ const repos = props.repos
936
+ return E('div', { className: 'dig-picker' },
937
+ E('div', { className: 'dig-picker-title' }, repos.length === 0 ? t('repo.empty') : t('repo.pick')),
938
+ E('div', { className: 'dig-picker-sub' }, props.cwd),
939
+ repos.map((repo) => E('button', {
940
+ key: repo.path,
941
+ type: 'button',
942
+ className: 'dig-picker-row',
943
+ onClick: () => props.onPick(repo.path),
944
+ },
945
+ E('span', { className: 'dig-picker-icon' }, E(Icon, { name: 'folder', size: 13 })),
946
+ E('span', { className: 'dig-picker-name' }, repo.name),
947
+ repo.branch === null || repo.branch === undefined ? null : E('span', { className: 'dig-badge' }, repo.branch),
948
+ E('span', { className: 'dig-picker-kind' }, repo.kind === 'workspace' ? t('repo.workspace') : t('repo.nested')),
949
+ E('span', { className: 'dig-picker-path' }, repo.path))))
950
+ }
951
+
952
+ /* ============================== branch tree ============================== */
953
+
954
+ function BranchRow(props) {
955
+ const t = props.t
956
+ const entry = props.entry
957
+ const isHead = entry.head === true
958
+ const kind = entry.tag === true ? 'tag' : entry.remote === true ? 'remote' : isHead ? 'head' : 'local'
959
+ const tone = kind === 'tag' ? 'warn' : kind === 'remote' ? 'violet' : kind === 'head' ? 'success' : 'accent'
960
+ const glyph = kind === 'tag' ? 'tag' : kind === 'remote' ? 'fetch' : kind === 'head' ? 'star' : 'branch'
961
+ const where = entry.upstream === null || entry.upstream === undefined ? entry.name : entry.name + ' → ' + entry.upstream
962
+ // A single click only SELECTS: checking a branch out is a real change of
963
+ // working tree, and a stray click in a long list must not do that (reported
964
+ // on v0.3.7). Double-click checks out, right-click has the whole menu.
965
+ return E('div', {
966
+ className: 'dig-row dig-row-' + kind + (props.picked === true ? ' dig-row-picked' : ''),
967
+ style: { paddingLeft: (BRANCH_INDENT_BASE + (props.depth === undefined ? 0 : props.depth) * BRANCH_INDENT_STEP) + 'px' },
968
+ title: where + ' · ' + t('branches.pickHint'),
969
+ onClick: () => props.onPick(entry),
970
+ onDoubleClick: () => props.onCheckout(entry),
971
+ onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, entry) },
972
+ },
973
+ E('span', { className: 'dig-row-icon dig-tone-' + tone }, E(Icon, { name: glyph, size: 12 })),
974
+ E('span', { className: 'dig-row-label' }, props.label === undefined ? entry.name : props.label),
975
+ // A bare 'W' told nobody anything (reported right after the section counts):
976
+ // it means this branch is checked out in ANOTHER working tree, which is why
977
+ // git refuses to check it out here. An icon plus a spelled-out tooltip says so.
978
+ entry.worktree === null || entry.worktree === undefined ? null : E('span', {
979
+ className: 'dig-badge dig-badge-muted dig-badge-worktree',
980
+ title: t('branches.worktree') + ': ' + entry.worktree,
981
+ }, E(Icon, { name: 'folder', size: 10 })),
982
+ entry.ahead > 0 ? E('span', { className: 'dig-badge' }, '↑' + entry.ahead) : null,
983
+ entry.behind > 0 ? E('span', { className: 'dig-badge' }, '↓' + entry.behind) : null)
984
+ }
985
+
986
+ function BranchTree(props) {
987
+ const t = props.t
988
+ const [filter, setFilter] = useState('')
989
+ const [collapsed, setCollapsed] = useState({ favorites: false, remote: false, tags: true })
990
+ const [picked, setPicked] = useState('')
991
+ const [foldedFolders, setFoldedFolders] = useState({})
992
+ const branches = props.branches
993
+ const needle = filter.trim().toLowerCase()
994
+ const match = (name) => needle === '' || String(name).toLowerCase().indexOf(needle) >= 0
995
+ const locals = (branches === null ? [] : branches.local).filter((entry) => match(entry.name))
996
+ const remotes = (branches === null ? [] : branches.remote).filter((entry) => match(entry.name))
997
+ const tags = (branches === null ? [] : branches.tags).filter((entry) => match(entry.name))
998
+ const favorites = Array.isArray(props.favorites) ? props.favorites : []
999
+ /**
1000
+ * Branch names nest on '/', the way an IDE shows a branch namespace: clicking a
1001
+ * row only SELECTS it (checking out rewrites the working tree), so the row
1002
+ * carries the selection and a double-click does the checkout.
1003
+ */
1004
+ const forestOf = (entries) => {
1005
+ const root = { folders: new Map(), leaves: [] }
1006
+ for (const entry of entries) {
1007
+ const parts = String(entry.name).split('/')
1008
+ let node = root
1009
+ for (let index = 0; index < parts.length - 1; index += 1) {
1010
+ const key = parts[index]
1011
+ if (node.folders.has(key) === false) node.folders.set(key, { folders: new Map(), leaves: [] })
1012
+ node = node.folders.get(key)
1013
+ }
1014
+ node.leaves.push({ entry: entry, label: parts[parts.length - 1] })
1015
+ }
1016
+ return root
1017
+ }
1018
+ const countLeaves = (node) => node.leaves.length + Array.from(node.folders.values()).reduce((sum, child) => sum + countLeaves(child), 0)
1019
+ const branchRow = (entry, label, depth) => E(BranchRow, {
1020
+ key: 'row:' + entry.name, entry: entry, label: label, depth: depth, t: t,
1021
+ picked: picked === entry.name,
1022
+ onPick: (target) => setPicked(target.name),
1023
+ onCheckout: props.onCheckout, onMenu: props.onBranchMenu,
1024
+ })
1025
+ const rowsOf = (node, prefix, depth) => {
1026
+ const out = []
1027
+ for (const name of Array.from(node.folders.keys()).sort()) {
1028
+ const id = prefix === '' ? name : prefix + '/' + name
1029
+ const open = foldedFolders[id] !== true
1030
+ const child = node.folders.get(name)
1031
+ out.push(E('div', { className: 'dig-branch-folder', key: 'dir:' + id },
1032
+ E('button', {
1033
+ type: 'button', className: 'dig-folder-head',
1034
+ style: { paddingLeft: (BRANCH_INDENT_BASE + depth * BRANCH_INDENT_STEP) + 'px' },
1035
+ onClick: () => setFoldedFolders((previous) => Object.assign({}, previous, { [id]: previous[id] !== true })),
1036
+ },
1037
+ E('span', { className: 'dig-chevron' + (open ? ' dig-chevron-open' : '') }, E(Icon, { name: 'chevron', size: 10 })),
1038
+ E('span', { className: 'dig-folder-name' }, name),
1039
+ E('span', { className: 'dig-count' }, String(countLeaves(child)))),
1040
+ open ? rowsOf(child, id, depth + 1) : null))
1041
+ }
1042
+ for (const leaf of node.leaves) out.push(branchRow(leaf.entry, leaf.label, depth))
1043
+ return out
1044
+ }
1045
+ const section = (key, title, entries, decorate, grouped) => E('div', { className: 'dig-section', key: key },
1046
+ E('button', {
1047
+ type: 'button', className: 'dig-section-head',
1048
+ onClick: () => setCollapsed((previous) => Object.assign({}, previous, { [key]: !previous[key] })),
1049
+ },
1050
+ E('span', { className: 'dig-chevron' + (collapsed[key] === true ? '' : ' dig-chevron-open') }, E(Icon, { name: 'chevron', size: 12 })),
1051
+ // No count here: a bare digit next to the section name read as another
1052
+ // column instead of a total (reported on v0.3.10).
1053
+ E('span', null, title)),
1054
+ collapsed[key] === true ? null : E('div', { className: 'dig-section-body' },
1055
+ grouped === true
1056
+ ? rowsOf(forestOf(entries.map((entry) => decorate(entry))), '', 0)
1057
+ : entries.map((entry) => branchRow(decorate(entry), undefined, 0))))
1058
+ return E('div', { className: 'dig-tree' },
1059
+ E('div', { className: 'dig-search' },
1060
+ E('input', {
1061
+ className: 'dig-input dig-input-compact', value: filter, spellCheck: false,
1062
+ placeholder: t('branches.filter'),
1063
+ onChange: (event) => setFilter(event.target.value),
1064
+ })),
1065
+ E('div', { className: 'dig-tree-scroll' },
1066
+ E('div', { className: 'dig-section-head dig-section-head-static' }, t('branches.head')),
1067
+ favorites.length === 0 ? null : section('favorites', t('branches.favorites'), locals.filter((entry) => favorites.indexOf(entry.name) >= 0), (entry) => entry),
1068
+ section('local', t('branches.local'), locals, (entry) => entry, true),
1069
+ section('remote', t('branches.remote'), remotes, (entry) => Object.assign({}, entry, { remote: true })),
1070
+ section('tags', t('branches.tags'), tags, (entry) => Object.assign({}, entry, { tag: true }))))
1071
+ }
1072
+
1073
+ /* ============================== changes ============================== */
1074
+
1075
+ function ChangeRow(props) {
1076
+ const t = props.t
1077
+ const item = props.item
1078
+ const raw = item.index === '?' ? '?' : (item.index + item.worktree).trim()
1079
+ // Ignored rows are informational: their status letter is their own, and the
1080
+ // stage/discard buttons below would silently do nothing (git needs -f).
1081
+ const readOnly = props.group === 'ignored'
1082
+ const status = readOnly ? 'I' : (raw === '' ? 'M' : raw.charAt(0))
1083
+ return E('div', {
1084
+ className: 'dig-row dig-row-file',
1085
+ title: item.origPath === undefined ? item.path : item.origPath + ' → ' + item.path,
1086
+ onClick: () => props.onDiff(item, props.group),
1087
+ onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, item, props.group) },
1088
+ },
1089
+ E('span', { className: 'dig-file-status dig-file-status-' + (status === '?' ? 'U' : status) }, status),
1090
+ // An ignored DIRECTORY comes back as "dist/" (ls-files --directory), whose
1091
+ // basename is empty — show the whole path instead of a blank label.
1092
+ E('span', { className: 'dig-row-label' }, item.path.slice(-1) === '/' ? item.path : baseName(item.path)),
1093
+ E('span', { className: 'dig-row-sub dig-row-dir' }, item.path.slice(-1) === '/' ? '' : dirName(item.path)),
1094
+ item.additions > 0 ? E('span', { className: 'dig-stat-add' }, '+' + item.additions) : null,
1095
+ item.deletions > 0 ? E('span', { className: 'dig-stat-del' }, '-' + item.deletions) : null,
1096
+ readOnly ? null : (props.group === 'staged'
1097
+ ? E('button', { type: 'button', className: 'dig-mini', title: t('action.unstage'), onClick: (event) => { event.stopPropagation(); props.onUnstage(item) } }, E(Icon, { name: 'minus', size: 12 }))
1098
+ : E('button', { type: 'button', className: 'dig-mini', title: t('action.stage'), onClick: (event) => { event.stopPropagation(); props.onStage(item) } }, E(Icon, { name: 'plus', size: 12 }))),
1099
+ readOnly ? null : E('button', { type: 'button', className: 'dig-mini', title: t('action.discard'), onClick: (event) => { event.stopPropagation(); props.onDiscard(item, props.group) } }, E(Icon, { name: 'undo', size: 12 })))
1100
+ }
1101
+
1102
+ function ChangesPanel(props) {
1103
+ const t = props.t
1104
+ const summary = props.summary
1105
+ const [message, setMessage] = useState('')
1106
+ const [amend, setAmend] = useState(false)
1107
+ const [collapsed, setCollapsed] = useState(false)
1108
+ // 'flat' keeps git's own grouping (staged / changes / untracked); 'dir'
1109
+ // clusters each group's files under their folder, the way "Group by:
1110
+ // Directory" does in an IDE. Folders are addressed by <group>|<dir>, so a
1111
+ // refresh never loses which ones the user folded away.
1112
+ const [groupBy, setGroupBy] = useState('flat')
1113
+ const [foldedDirs, setFoldedDirs] = useState({})
1114
+ const changes = summary === null ? null : summary.changes
1115
+ const conflicted = changes === null ? [] : changes.conflicted
1116
+ const staged = changes === null ? [] : changes.staged
1117
+ const unstaged = changes === null ? [] : changes.unstaged
1118
+ const untracked = changes === null ? [] : changes.untracked
1119
+ const ignored = changes === null || changes.ignored === undefined ? [] : changes.ignored
1120
+ const total = conflicted.length + staged.length + unstaged.length + untracked.length
1121
+ const submit = (push) => {
1122
+ if (message.trim() === '' || props.busy === true) return false
1123
+ props.onCommit(message, amend, push === true)
1124
+ setMessage('')
1125
+ setAmend(false)
1126
+ return true
1127
+ }
1128
+ const entryRow = (key, item) => E(ChangeRow, {
1129
+ key: key + ':' + item.path, item: item, group: key, t: t,
1130
+ onStage: props.onStage, onUnstage: props.onUnstage, onDiscard: props.onDiscard, onDiff: props.onDiff, onMenu: props.onChangeMenu,
1131
+ })
1132
+ const folderOf = (item) => {
1133
+ const dir = dirName(item.path)
1134
+ return dir === '' ? t('changes.rootDir') : dir
1135
+ }
1136
+ const rowsOf = (key, entries) => {
1137
+ if (groupBy !== 'dir') return entries.map((item) => entryRow(key, item))
1138
+ const folders = new Map()
1139
+ for (const item of entries) {
1140
+ const name = folderOf(item)
1141
+ if (folders.has(name) === false) folders.set(name, [])
1142
+ folders.get(name).push(item)
1143
+ }
1144
+ return Array.from(folders.keys()).sort().map((name) => {
1145
+ const id = key + '|' + name
1146
+ const open = foldedDirs[id] !== true
1147
+ const list = folders.get(name)
1148
+ return E('div', { className: 'dig-folder', key: id },
1149
+ E('button', {
1150
+ type: 'button', className: 'dig-folder-head',
1151
+ onClick: () => setFoldedDirs((previous) => Object.assign({}, previous, { [id]: previous[id] !== true })),
1152
+ },
1153
+ E('span', { className: 'dig-chevron' + (open ? ' dig-chevron-open' : '') }, E(Icon, { name: 'chevron', size: 10 })),
1154
+ E('span', { className: 'dig-folder-name' }, name),
1155
+ E('span', { className: 'dig-count' }, String(list.length))),
1156
+ open ? list.map((item) => entryRow(key, item)) : null)
1157
+ })
1158
+ }
1159
+ const foldAll = (value) => {
1160
+ const next = {}
1161
+ if (value === true) {
1162
+ const groups = [['conflicted', conflicted], ['staged', staged], ['unstaged', unstaged], ['untracked', untracked], ['ignored', ignored]]
1163
+ for (const pair of groups) for (const item of pair[1]) next[pair[0] + '|' + folderOf(item)] = true
1164
+ }
1165
+ setFoldedDirs(next)
1166
+ }
1167
+ const group = (key, title, entries) => entries.length === 0 ? null : E('div', { className: 'dig-group', key: key },
1168
+ E('div', { className: 'dig-group-head' },
1169
+ E('span', null, title),
1170
+ key === 'staged' ? E('button', { type: 'button', className: 'dig-link', onClick: () => props.onUnstageAll() }, t('changes.unstageAll')) : null,
1171
+ key === 'unstaged' || key === 'untracked' ? E('button', { type: 'button', className: 'dig-link', onClick: () => props.onStageAll(entries) }, t('changes.stageAll')) : null),
1172
+ rowsOf(key, entries))
1173
+ const composer = props.compact === true
1174
+ ? E('div', { className: 'dig-commit-box dig-commit-box-compact' },
1175
+ E('input', {
1176
+ className: 'dig-input dig-input-compact', value: message, spellCheck: false,
1177
+ placeholder: t('changes.commitPlaceholder'),
1178
+ onChange: (event) => setMessage(event.target.value),
1179
+ onKeyDown: (event) => {
1180
+ if (event.key === 'Enter') { event.preventDefault(); submit() }
1181
+ },
1182
+ }),
1183
+ E('button', {
1184
+ type: 'button', className: 'dig-btn dig-btn-primary dig-btn-small',
1185
+ disabled: message.trim() === '' || props.busy === true,
1186
+ onClick: () => { submit(false) },
1187
+ }, t('changes.commit')))
1188
+ : E('div', { className: 'dig-commit-box' },
1189
+ E('textarea', {
1190
+ className: 'dig-textarea', value: message, spellCheck: false,
1191
+ placeholder: t('changes.commitPlaceholder'),
1192
+ onChange: (event) => setMessage(event.target.value),
1193
+ onKeyDown: (event) => {
1194
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); submit() }
1195
+ },
1196
+ }),
1197
+ E('div', { className: 'dig-commit-actions' },
1198
+ E('label', { className: 'dig-check' },
1199
+ E('input', { type: 'checkbox', checked: amend, onChange: (event) => setAmend(event.target.checked) }),
1200
+ E('span', null, t('changes.amend'))),
1201
+ E('button', {
1202
+ type: 'button', className: 'dig-btn dig-btn-primary',
1203
+ disabled: message.trim() === '' || props.busy === true,
1204
+ onClick: () => { submit(false) },
1205
+ }, t('changes.commit')),
1206
+ E('button', {
1207
+ type: 'button', className: 'dig-btn',
1208
+ disabled: message.trim() === '' || props.busy === true,
1209
+ onClick: () => { submit(true) },
1210
+ }, t('changes.commitAndPush'))))
1211
+ // Folding hides the FILE LIST only. The commit box is the panel's primary
1212
+ // action and used to vanish together with the list, leaving an empty pane
1213
+ // with no way to commit anything (reported on v0.3.5).
1214
+ const head = props.hideHeader === true ? null : E('div', { className: 'dig-section-head dig-changes-head' },
1215
+ E('button', {
1216
+ type: 'button', className: 'dig-section-toggle',
1217
+ onClick: () => setCollapsed((value) => !value),
1218
+ },
1219
+ E('span', { className: 'dig-chevron' + (collapsed ? '' : ' dig-chevron-open') }, E(Icon, { name: 'chevron', size: 12 })),
1220
+ E('span', null, t('changes.title')),
1221
+ E('span', { className: 'dig-count' }, String(total))),
1222
+ E('span', { className: 'dig-topbar-spacer' }),
1223
+ E('button', {
1224
+ type: 'button',
1225
+ className: 'dig-icon-btn dig-icon-btn-small' + (groupBy === 'dir' ? ' dig-icon-btn-active' : ''),
1226
+ title: t('changes.groupBy') + ': ' + (groupBy === 'dir' ? t('changes.groupDir') : t('changes.groupFlat')),
1227
+ onClick: () => setGroupBy((value) => (value === 'dir' ? 'flat' : 'dir')),
1228
+ }, E(Icon, { name: 'folder', size: 13 })),
1229
+ E('button', {
1230
+ type: 'button', className: 'dig-icon-btn dig-icon-btn-small',
1231
+ title: t('changes.expandAll'), disabled: groupBy !== 'dir',
1232
+ onClick: () => foldAll(false),
1233
+ }, E(Icon, { name: 'expand', size: 13 })),
1234
+ E('button', {
1235
+ type: 'button', className: 'dig-icon-btn dig-icon-btn-small',
1236
+ title: t('changes.foldAll'), disabled: groupBy !== 'dir',
1237
+ onClick: () => foldAll(true),
1238
+ }, E(Icon, { name: 'collapse', size: 13 })),
1239
+ E('button', {
1240
+ type: 'button',
1241
+ className: 'dig-icon-btn dig-icon-btn-small' + (props.showIgnored === true ? ' dig-icon-btn-active' : ''),
1242
+ title: t('changes.showIgnored'),
1243
+ onClick: () => props.onToggleIgnored(),
1244
+ }, E(Icon, { name: props.showIgnored === true ? 'eye' : 'eyeOff', size: 13 })))
1245
+ return E('div', { className: 'dig-changes' },
1246
+ head,
1247
+ E('div', { className: 'dig-changes-body' },
1248
+ E('div', { className: 'dig-changes-list' },
1249
+ collapsed ? null : [
1250
+ total === 0 && ignored.length === 0 ? E('div', { className: 'dig-empty', key: 'empty' }, t('changes.empty')) : null,
1251
+ group('conflicted', fill(t('counts.conflicted'), { n: conflicted.length }), conflicted),
1252
+ group('staged', fill(t('counts.staged'), { n: staged.length }), staged),
1253
+ group('unstaged', fill(t('counts.unstaged'), { n: unstaged.length }), unstaged),
1254
+ group('untracked', fill(t('counts.untracked'), { n: untracked.length }), untracked),
1255
+ group('ignored', fill(t('counts.ignored'), { n: ignored.length }), ignored),
1256
+ ]),
1257
+ composer))
1258
+ }
1259
+
1260
+ /* ============================== history ============================== */
1261
+
1262
+ const DAY_MS = 86400000
1263
+
1264
+ /** Branches the panel treats as high risk: deleting one needs a typed confirm. */
1265
+ function isProtectedBranch(name) {
1266
+ return name === 'main' || name === 'master' || name === 'trunk'
1267
+ }
1268
+
1269
+ /* A ref decoration as git prints it in %D, split into a display name and the
1270
+ kind that drives its badge colour. */
1271
+ function refInfo(raw) {
1272
+ const value = String(raw)
1273
+ if (value.indexOf('HEAD -> ') === 0) return { name: value.slice(8), kind: 'head' }
1274
+ if (value === 'HEAD') return { name: 'HEAD', kind: 'head' }
1275
+ if (value.indexOf('tag: ') === 0) return { name: value.slice(5), kind: 'tag' }
1276
+ return { name: value, kind: value.indexOf('/') >= 0 ? 'remote' : 'local' }
1277
+ }
1278
+
1279
+ function sinceThreshold(key) {
1280
+ if (key === 'today') { const start = new Date(); start.setHours(0, 0, 0, 0); return start.getTime() }
1281
+ if (key === 'week') return Date.now() - 7 * DAY_MS
1282
+ if (key === 'month') return Date.now() - 30 * DAY_MS
1283
+ if (key === 'year') return new Date(new Date().getFullYear(), 0, 1).getTime()
1284
+ return 0
1285
+ }
1286
+
1287
+ function HistoryList(props) {
1288
+ const t = props.t
1289
+ const commits = props.commits
1290
+ const [text, setText] = useState('')
1291
+ const [author, setAuthor] = useState('')
1292
+ const [refName, setRefName] = useState('')
1293
+ const [since, setSince] = useState('')
1294
+ const [order, setOrder] = useState('desc')
1295
+ const [pathDraft, setPathDraft] = useState(props.pathFilter === undefined ? '' : props.pathFilter)
1296
+
1297
+ useEffect(() => { setPathDraft(props.pathFilter === undefined ? '' : props.pathFilter) }, [props.pathFilter])
1298
+
1299
+ const authors = useMemo(() => {
1300
+ const found = []
1301
+ for (const commit of commits) {
1302
+ if (typeof commit.author === 'string' && commit.author !== '' && found.indexOf(commit.author) < 0) found.push(commit.author)
1303
+ }
1304
+ return found.sort()
1305
+ }, [commits])
1306
+
1307
+ const refOptions = useMemo(() => {
1308
+ const found = []
1309
+ for (const commit of commits) {
1310
+ const list = Array.isArray(commit.refs) ? commit.refs : []
1311
+ for (const entry of list) {
1312
+ const info = refInfo(entry)
1313
+ if (info.kind !== 'head' && found.indexOf(info.name) < 0) found.push(info.name)
1314
+ }
1315
+ }
1316
+ return found.sort()
1317
+ }, [commits])
1318
+
1319
+ const filtered = useMemo(() => {
1320
+ const needle = text.trim().toLowerCase()
1321
+ const threshold = sinceThreshold(since)
1322
+ const kept = commits.filter((commit) => {
1323
+ if (needle !== '') {
1324
+ const hit = String(commit.subject).toLowerCase().indexOf(needle) >= 0 ||
1325
+ String(commit.hash).toLowerCase().indexOf(needle) === 0 ||
1326
+ String(commit.author).toLowerCase().indexOf(needle) >= 0
1327
+ if (hit === false) return false
1328
+ }
1329
+ if (author !== '' && commit.author !== author) return false
1330
+ if (refName !== '') {
1331
+ const list = (Array.isArray(commit.refs) ? commit.refs : []).map((entry) => refInfo(entry).name)
1332
+ if (list.indexOf(refName) < 0) return false
1333
+ }
1334
+ if (threshold > 0) {
1335
+ const at = Date.parse(commit.date)
1336
+ if (Number.isFinite(at) && at < threshold) return false
1337
+ }
1338
+ return true
1339
+ })
1340
+ return order === 'asc' ? kept.slice().reverse() : kept
1341
+ }, [commits, text, author, refName, since, order])
1342
+
1343
+ const rows = useMemo(() => buildRows(filtered), [filtered])
1344
+ const dirtyFilter = text !== '' || author !== '' || refName !== '' || since !== '' || (props.pathFilter !== undefined && props.pathFilter !== '')
1345
+ const applyPath = (value) => { if (props.onPathFilter !== undefined) props.onPathFilter(value) }
1346
+ const clearAll = () => {
1347
+ setText('')
1348
+ setAuthor('')
1349
+ setRefName('')
1350
+ setSince('')
1351
+ setPathDraft('')
1352
+ applyPath('')
1353
+ }
1354
+
1355
+ const filterBar = props.hideSearch === true ? null : E('div', { className: 'dig-filters' },
1356
+ E('input', {
1357
+ className: 'dig-input dig-input-compact dig-filter-text', value: text, spellCheck: false,
1358
+ placeholder: t('history.filter'),
1359
+ onChange: (event) => setText(event.target.value),
1360
+ }),
1361
+ E('select', {
1362
+ className: 'dig-filter-select', value: refName, title: t('filter.branch'),
1363
+ onChange: (event) => setRefName(event.target.value),
1364
+ }, [E('option', { key: '', value: '' }, t('filter.branch') + ': ' + t('filter.all'))].concat(
1365
+ refOptions.map((name) => E('option', { key: name, value: name }, name)))),
1366
+ E('select', {
1367
+ className: 'dig-filter-select', value: author, title: t('filter.user'),
1368
+ onChange: (event) => setAuthor(event.target.value),
1369
+ }, [E('option', { key: '', value: '' }, t('filter.user') + ': ' + t('filter.all'))].concat(
1370
+ authors.map((name) => E('option', { key: name, value: name }, name)))),
1371
+ E('select', {
1372
+ className: 'dig-filter-select', value: since, title: t('filter.date'),
1373
+ onChange: (event) => setSince(event.target.value),
1374
+ },
1375
+ E('option', { value: '' }, t('filter.date') + ': ' + t('filter.all')),
1376
+ E('option', { value: 'today' }, t('filter.today')),
1377
+ E('option', { value: 'week' }, t('filter.week')),
1378
+ E('option', { value: 'month' }, t('filter.month')),
1379
+ E('option', { value: 'year' }, t('filter.year'))),
1380
+ E('form', {
1381
+ className: 'dig-filter-path',
1382
+ onSubmit: (event) => { event.preventDefault(); applyPath(pathDraft.trim()) },
1383
+ }, E('input', {
1384
+ className: 'dig-input dig-input-compact', value: pathDraft, spellCheck: false,
1385
+ placeholder: t('filter.pathPlaceholder'),
1386
+ onChange: (event) => setPathDraft(event.target.value),
1387
+ })),
1388
+ E('button', {
1389
+ type: 'button', className: 'dig-icon-btn dig-icon-btn-small',
1390
+ title: order === 'desc' ? t('filter.sortDesc') : t('filter.sortAsc'),
1391
+ onClick: () => setOrder(order === 'desc' ? 'asc' : 'desc'),
1392
+ }, E(Icon, { name: order === 'desc' ? 'down' : 'up', size: 12 })),
1393
+ dirtyFilter ? E('button', {
1394
+ type: 'button', className: 'dig-icon-btn dig-icon-btn-small', title: t('filter.clear'),
1395
+ onClick: clearAll,
1396
+ }, E(Icon, { name: 'close', size: 12 })) : null)
1397
+
1398
+ return E('div', { className: 'dig-history' }, filterBar,
1399
+ E('div', { className: 'dig-history-scroll' },
1400
+ rows.length === 0 ? E('div', { className: 'dig-empty' }, dirtyFilter ? t('filter.none') : t('history.empty')) : null,
1401
+ rows.map((row) => {
1402
+ const commit = row.commit
1403
+ const decorations = (Array.isArray(commit.refs) ? commit.refs : []).map(refInfo)
1404
+ const shown = decorations.slice(0, 3)
1405
+ const hidden = decorations.length - shown.length
1406
+ return E('div', {
1407
+ key: commit.hash,
1408
+ className: 'dig-commit' + (props.selectedHash === commit.hash ? ' dig-commit-selected' : ''),
1409
+ onClick: () => props.onSelect(commit),
1410
+ onContextMenu: (event) => { event.preventDefault(); props.onMenu(event, commit) },
1411
+ },
1412
+ E('span', { className: 'dig-commit-date', title: shortDate(commit.date) }, relativeTime(commit.date)),
1413
+ E('span', { className: 'dig-commit-author', title: commit.author }, commit.author),
1414
+ E(GraphCell, { row: row, height: 26 }),
1415
+ shown.map((decoration, index) => E('span', {
1416
+ key: decoration.kind + ':' + decoration.name + ':' + index,
1417
+ className: 'dig-ref dig-ref-' + decoration.kind,
1418
+ title: decoration.name,
1419
+ }, decoration.name)),
1420
+ hidden > 0 ? E('span', { className: 'dig-ref dig-ref-more' }, '+' + hidden) : null,
1421
+ E('span', { className: 'dig-commit-subject', title: commit.subject }, commit.subject),
1422
+ E('span', { className: 'dig-commit-fill' }))
1423
+ }),
1424
+ props.hasMore === true
1425
+ ? E('button', { type: 'button', className: 'dig-load-more', disabled: props.busy === true, onClick: props.onLoadMore }, t('history.loadMore'))
1426
+ : null))
1427
+ }
1428
+
1429
+ function CommitDetail(props) {
1430
+ const t = props.t
1431
+ const detail = props.detail
1432
+ if (detail === null || detail === undefined) return E('div', { className: 'dig-empty' }, t('status.loading'))
1433
+ return E('div', { className: 'dig-detail' },
1434
+ E('div', { className: 'dig-detail-head' },
1435
+ E('button', { type: 'button', className: 'dig-btn dig-btn-small', onClick: props.onBack }, '← ' + t('detail.back')),
1436
+ E('span', { className: 'dig-mono' }, detail.shortHash),
1437
+ E('span', { className: 'dig-detail-author' }, detail.author),
1438
+ E('span', { className: 'dig-detail-date' }, shortDate(detail.date))),
1439
+ E('div', { className: 'dig-detail-subject' }, detail.subject),
1440
+ detail.body === undefined || detail.body.trim() === '' ? null : E('pre', { className: 'dig-detail-body' }, detail.body.trim()),
1441
+ E('div', { className: 'dig-detail-files-head' }, t('detail.files') + ' · ' + String(detail.files.length)),
1442
+ E('div', { className: 'dig-detail-files' },
1443
+ detail.files.length === 0 ? E('div', { className: 'dig-empty' }, t('detail.noFiles')) : null,
1444
+ detail.files.map((file) => E('div', {
1445
+ key: file.path,
1446
+ className: 'dig-row dig-row-file' + (props.selectedPath === file.path ? ' dig-row-selected' : ''),
1447
+ title: file.path,
1448
+ onClick: () => props.onSelectFile(file),
1449
+ },
1450
+ E('span', { className: 'dig-row-label' }, baseName(file.path)),
1451
+ E('span', { className: 'dig-row-sub dig-row-dir' }, dirName(file.path)),
1452
+ file.additions > 0 ? E('span', { className: 'dig-stat-add' }, '+' + file.additions) : null,
1453
+ file.deletions > 0 ? E('span', { className: 'dig-stat-del' }, '-' + file.deletions) : null))))
1454
+ }
1455
+
1456
+ /* ============================== panel ============================== */
1457
+
1458
+ function Panel(props) {
1459
+ const scope = props.scope
1460
+ const t = props.t
1461
+ const hostRef = useRef(null)
1462
+ const railHostRef = useRef(null)
1463
+ const [size, setSize] = useState({ width: 0, height: 0 })
1464
+ const [repoState, setRepoState] = useState(null)
1465
+ const [repoRoot, setRepoRoot] = useState(null)
1466
+ const [summary, setSummary] = useState(null)
1467
+ // Ignored files are opt-in: --ignored walks the ignore rules, and nobody
1468
+ // needs node_modules listed while they are writing a commit message.
1469
+ const [showIgnored, setShowIgnored] = useState(false)
1470
+ const [branches, setBranches] = useState(null)
1471
+ const [commits, setCommits] = useState([])
1472
+ const [hasMore, setHasMore] = useState(false)
1473
+ const [error, setError] = useState(null)
1474
+ const [note, setNote] = useState(null)
1475
+ const [busy, setBusy] = useState(false)
1476
+ const [view, setView] = useState('history')
1477
+ const [treeOpen, setTreeOpen] = useState(true)
1478
+ const [detail, setDetail] = useState(null)
1479
+ const [selectedHash, setSelectedHash] = useState(null)
1480
+ const [selectedPath, setSelectedPath] = useState(null)
1481
+ const [patch, setPatch] = useState('')
1482
+ const [patchLoading, setPatchLoading] = useState(false)
1483
+ const [menu, setMenu] = useState(null)
1484
+ const [dialog, setDialog] = useState(null)
1485
+ const [tick, setTick] = useState(0)
1486
+ const [favTick, setFavTick] = useState(0)
1487
+ const [pathFilter, setPathFilter] = useState('')
1488
+ const [railConfig, setRailConfig] = useState(readRailConfig)
1489
+ const [railSettings, setRailSettings] = useState(false)
1490
+ const [railBox, setRailBox] = useState({ width: 0, height: 0 })
1491
+ const [toasts, setToasts] = useState([])
1492
+
1493
+ const cwd = typeof scope.cwd === 'string' && scope.cwd !== '' ? scope.cwd : undefined
1494
+ const sessionId = typeof scope.sessionId === 'string' ? scope.sessionId : 'default'
1495
+ const base = useMemo(() => ({ cwd: cwd, repoRoot: repoRoot === null ? undefined : repoRoot }), [cwd, repoRoot])
1496
+
1497
+ useEffect(() => {
1498
+ const element = hostRef.current
1499
+ if (element === null) return undefined
1500
+ const measure = () => {
1501
+ const rect = element.getBoundingClientRect()
1502
+ setSize({ width: Math.round(rect.width), height: Math.round(rect.height) })
1503
+ }
1504
+ measure()
1505
+ if (typeof ResizeObserver === 'function') {
1506
+ const observer = new ResizeObserver(measure)
1507
+ observer.observe(element)
1508
+ return () => observer.disconnect()
1509
+ }
1510
+ window.addEventListener('resize', measure)
1511
+ return () => window.removeEventListener('resize', measure)
1512
+ }, [])
1513
+
1514
+ // Width decides: a wide container is a three-column workbench even when it is
1515
+ // short (the bottom panel is 1500x300 and still wants tree | graph | changes).
1516
+ // Only a genuinely tiny container (very narrow or very short) falls back to
1517
+ // the compact one-header chrome.
1518
+ // Width alone decides WHICH SURFACE this is: the native right sidebar is
1519
+ // narrow (< 400) whatever its height, the bottom workbench is wide
1520
+ // whatever its height. Height must never select the tall-narrow chrome on
1521
+ // its own — v0.1.3 already made that mistake with a height < 330 test (a
1522
+ // 1500x300 workbench came out single-column) and v0.3.5 still let
1523
+ // height < 200 flip a 1320x180 workbench into the right-sidebar chrome.
1524
+ const compact = size.width > 0 && size.width < COMPACT_MAX_WIDTH
1525
+ const columns = !compact && size.width >= 600 && size.width >= size.height * 1.15
1526
+
1527
+ // The action rail measures ITSELF, not the panel: how many buttons fit is a
1528
+ // function of the strip that holds them (a 1200x300 workbench and a 300x900
1529
+ // sidebar disagree about that). railBox keeps the last measured span; until
1530
+ // the first measurement lands, every action is rendered.
1531
+ useEffect(() => {
1532
+ const element = railHostRef.current
1533
+ if (element === null) { setRailBox({ width: 0, height: 0 }); return undefined }
1534
+ const measure = () => {
1535
+ const rect = element.getBoundingClientRect()
1536
+ setRailBox({ width: Math.round(rect.width), height: Math.round(rect.height) })
1537
+ }
1538
+ measure()
1539
+ if (typeof ResizeObserver === 'function') {
1540
+ const observer = new ResizeObserver(measure)
1541
+ observer.observe(element)
1542
+ return () => observer.disconnect()
1543
+ }
1544
+ window.addEventListener('resize', measure)
1545
+ return () => window.removeEventListener('resize', measure)
1546
+ }, [repoRoot, columns, compact])
1547
+
1548
+ const guard = useCallback(async (work) => {
1549
+ setBusy(true)
1550
+ setError(null)
1551
+ try {
1552
+ return await work()
1553
+ } catch (caught) {
1554
+ setError(caught instanceof Error ? caught.message : String(caught))
1555
+ return undefined
1556
+ } finally {
1557
+ setBusy(false)
1558
+ }
1559
+ }, [])
1560
+
1561
+ const favorites = useMemo(() => (repoRoot === null ? [] : readFavorites(repoRoot)), [repoRoot, favTick])
1562
+ const toggleFavoriteBranch = useCallback((name) => {
1563
+ if (repoRoot === null || name === '') return
1564
+ toggleFavorite(repoRoot, name)
1565
+ setFavTick((value) => value + 1)
1566
+ }, [repoRoot])
1567
+
1568
+ useEffect(() => {
1569
+ if (cwd === undefined) return undefined
1570
+ let cancelled = false
1571
+ void (async () => {
1572
+ try {
1573
+ const data = await request('repos', { cwd: cwd })
1574
+ if (cancelled) return
1575
+ setRepoState(data)
1576
+ const paths = data.repos.map((repo) => repo.path)
1577
+ const remembered = readRememberedRepo(sessionId)
1578
+ let pick = null
1579
+ if (remembered !== undefined && paths.indexOf(remembered) >= 0) pick = remembered
1580
+ else if (data.isRepo === true) pick = data.cwd
1581
+ else if (paths.length > 0) pick = paths[0]
1582
+ setRepoRoot(pick)
1583
+ } catch (caught) {
1584
+ if (!cancelled) setError(caught instanceof Error ? caught.message : String(caught))
1585
+ }
1586
+ })()
1587
+ return () => { cancelled = true }
1588
+ }, [cwd, sessionId, tick])
1589
+
1590
+ const loadSummary = useCallback(async () => {
1591
+ const data = await request('summary', Object.assign({}, base, { ignored: showIgnored }))
1592
+ setSummary(data)
1593
+ return data
1594
+ }, [base, showIgnored])
1595
+
1596
+ const loadBranches = useCallback(async () => {
1597
+ setBranches(await request('branches', base))
1598
+ }, [base])
1599
+
1600
+ const loadCommits = useCallback(async (skip, overridePath) => {
1601
+ const activePath = overridePath === undefined ? pathFilter : overridePath
1602
+ const payload = Object.assign({}, base, { skip: skip === undefined ? 0 : skip, limit: 120 })
1603
+ if (activePath !== undefined && activePath !== '') payload.path = activePath
1604
+ const data = await request('log', payload)
1605
+ setCommits((previous) => (skip === undefined || skip === 0 ? data.commits : previous.concat(data.commits)))
1606
+ setHasMore(data.hasMore)
1607
+ }, [base, pathFilter])
1608
+
1609
+ const refresh = useCallback(async () => {
1610
+ if (repoRoot === null) return
1611
+ await guard(async () => {
1612
+ await Promise.all([loadSummary(), loadBranches(), loadCommits(0)])
1613
+ })
1614
+ }, [guard, loadSummary, loadBranches, loadCommits, repoRoot])
1615
+
1616
+ useEffect(() => {
1617
+ if (repoRoot === null) return undefined
1618
+ void refresh()
1619
+ return undefined
1620
+ }, [repoRoot, refresh, tick])
1621
+
1622
+ useEffect(() => {
1623
+ if (props.visible !== true || repoRoot === null) return undefined
1624
+ const timer = setInterval(() => {
1625
+ void (async () => {
1626
+ try { await loadSummary() } catch (error) { void error }
1627
+ })()
1628
+ }, AUTO_REFRESH_MS)
1629
+ return () => clearInterval(timer)
1630
+ }, [props.visible, repoRoot, loadSummary])
1631
+
1632
+ const run = useCallback(async (method, payload) => {
1633
+ const result = await guard(() => request(method, Object.assign({}, base, payload)))
1634
+ if (result !== undefined) await refresh()
1635
+ return result
1636
+ }, [base, guard, refresh])
1637
+
1638
+ const pickRepo = useCallback((path) => {
1639
+ setRepoRoot(path)
1640
+ rememberRepo(sessionId, path)
1641
+ setSummary(null)
1642
+ setBranches(null)
1643
+ setCommits([])
1644
+ setDetail(null)
1645
+ setSelectedHash(null)
1646
+ setPatch('')
1647
+ setView('history')
1648
+ setPathFilter('')
1649
+ }, [sessionId])
1650
+
1651
+ const openDiff = useCallback(async (seed) => {
1652
+ setPatchLoading(true)
1653
+ setPatch('')
1654
+ try {
1655
+ const data = await request('diff', Object.assign({}, base, seed))
1656
+ setPatch(data.patch)
1657
+ } catch (caught) {
1658
+ setError(caught instanceof Error ? caught.message : String(caught))
1659
+ } finally {
1660
+ setPatchLoading(false)
1661
+ }
1662
+ }, [base])
1663
+
1664
+ const selectCommit = useCallback(async (commit) => {
1665
+ setSelectedHash(commit.hash)
1666
+ setSelectedPath(null)
1667
+ setPatch('')
1668
+ setView('detail')
1669
+ setDetail(null)
1670
+ try {
1671
+ setDetail(await request('commitDetail', Object.assign({}, base, { hash: commit.hash })))
1672
+ } catch (caught) {
1673
+ setError(caught instanceof Error ? caught.message : String(caught))
1674
+ }
1675
+ }, [base])
1676
+
1677
+ const checkout = useCallback(async (entry) => {
1678
+ if (entry.tag === true) { void run('checkout', { branch: entry.name }); return }
1679
+ if (entry.remote === true) { void run('checkout', { branch: entry.name.slice(entry.name.indexOf('/') + 1) }); return }
1680
+ void run('checkout', { branch: entry.name })
1681
+ }, [run])
1682
+
1683
+ const compareWith = useCallback(async (baseRef, headRef) => {
1684
+ const data = await run('compare', { base: baseRef, head: headRef })
1685
+ if (data === undefined) return
1686
+ setNote(baseRef + ' ... ' + headRef + ' · ' + String(data.files.length) + ' files / ' + String(data.commits.length) + ' commits')
1687
+ }, [run])
1688
+
1689
+ /* Menu anchors are stored as offsets inside the panel, not as viewport
1690
+ coordinates: the bottom workbench is a containing block for fixed
1691
+ descendants (contain: layout), so viewport coordinates would place the
1692
+ menu outside the panel. See ContextMenu. */
1693
+ // A merge / rebase / cherry-pick / revert / bisect that has not finished yet
1694
+ // makes branch-level actions dangerous: git refuses most of them anyway, so
1695
+ // the panel greys them out and says why instead of letting them fail.
1696
+ const operation = summary === null || summary.operation === undefined ? null : summary.operation
1697
+ const blocked = operation !== null
1698
+
1699
+ const openMenuAt = useCallback((event, items) => {
1700
+ const element = hostRef.current
1701
+ const rect = element === null ? null : element.getBoundingClientRect()
1702
+ setMenu({
1703
+ x: event.clientX - (rect === null ? 0 : rect.left),
1704
+ y: event.clientY - (rect === null ? 0 : rect.top),
1705
+ boundsWidth: rect === null ? 0 : Math.round(rect.width),
1706
+ boundsHeight: rect === null ? 0 : Math.round(rect.height),
1707
+ items: items,
1708
+ })
1709
+ }, [])
1710
+
1711
+ const branchMenu = useCallback((event, entry) => {
1712
+ const current = branches === null ? '' : branches.branch
1713
+ const isLocal = entry.remote !== true && entry.tag !== true
1714
+ const reason = t('action.unavailable')
1715
+ const items = [
1716
+ { id: 'checkout', icon: 'checkout', tone: 'accent', label: t('action.checkout'), disabled: entry.head === true || entry.tag === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void checkout(entry) } },
1717
+ isLocal ? { id: 'rebase', icon: 'compare', tone: 'violet', label: t('action.rebaseCurrentOnto'), disabled: entry.head === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void run('rebase', { onto: entry.name }) } } : null,
1718
+ isLocal ? { id: 'merge', icon: 'compare', tone: 'accent', label: t('action.mergeIntoCurrent'), disabled: entry.head === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void run('merge', { branch: entry.name }) } } : null,
1719
+ { id: 'compare', icon: 'filter', tone: 'violet', label: t('action.compare'), disabled: entry.head === true || current === '', reason: reason, run: () => { void compareWith(current, entry.name) } },
1720
+ null,
1721
+ { id: 'favorite', icon: 'star', tone: 'warn', label: t('action.favorite'), active: favorites.indexOf(entry.name) >= 0, run: () => toggleFavoriteBranch(entry.name) },
1722
+ { id: 'newBranch', icon: 'plus', tone: 'success', label: t('action.newBranchFrom'), run: () => setDialog({ kind: 'newBranch', from: entry.name }) },
1723
+ isLocal ? { id: 'rename', icon: 'file', tone: 'primary', label: t('action.rename'), run: () => setDialog({ kind: 'renameBranch', from: entry.name }) } : null,
1724
+ isLocal ? { id: 'delete', icon: 'trash', tone: 'danger', label: t('action.delete'), danger: true, disabled: entry.head === true, reason: reason, run: () => setDialog({ kind: 'deleteBranch', name: entry.name }) } : null,
1725
+ null,
1726
+ { id: 'update', icon: 'fetch', tone: 'cyan', label: t('action.update'), run: () => { void run('fetch', { prune: true }) } },
1727
+ { id: 'push', icon: 'push', tone: 'success', label: t('action.push'), disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'push' }) },
1728
+ ].filter((item) => item !== null)
1729
+ openMenuAt(event, items)
1730
+ }, [branches, checkout, run, t, compareWith, toggleFavoriteBranch, openMenuAt, favorites, blocked])
1731
+
1732
+ const commitMenu = useCallback((event, commit) => {
1733
+ const items = [
1734
+ { id: 'details', icon: 'file', tone: 'primary', label: t('action.details'), run: () => { void selectCommit(commit) } },
1735
+ { id: 'copy', icon: 'tag', tone: 'secondary', label: t('action.copyHash'), run: () => { copyText(commit.hash) } },
1736
+ null,
1737
+ { id: 'checkout', icon: 'checkout', tone: 'accent', label: t('action.checkout'), run: () => setDialog({ kind: 'checkoutCommit', hash: commit.hash }) },
1738
+ { id: 'branch', icon: 'plus', tone: 'success', label: t('action.newBranchHere'), run: () => setDialog({ kind: 'newBranch', from: commit.hash }) },
1739
+ { id: 'tag', icon: 'tag', tone: 'warn', label: t('action.newTag'), run: () => setDialog({ kind: 'newTag', hash: commit.hash }) },
1740
+ null,
1741
+ { id: 'cherry', icon: 'commit', tone: 'accent', label: t('action.cherryPick'), disabled: blocked, reason: t('operation.hint'), run: () => { void run('cherryPick', { hash: commit.hash }) } },
1742
+ { id: 'revert', icon: 'undo', tone: 'danger', label: t('action.revert'), disabled: blocked, reason: t('operation.hint'), run: () => { void run('revert', { hash: commit.hash }) } },
1743
+ null,
1744
+ { id: 'resetSoft', icon: 'undo', tone: 'warn', label: t('action.resetSoft'), disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'reset', hash: commit.hash, mode: 'mixed' }) },
1745
+ { id: 'resetHard', icon: 'trash', tone: 'danger', label: t('action.resetHard'), danger: true, disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'reset', hash: commit.hash, mode: 'hard' }) },
1746
+ ]
1747
+ openMenuAt(event, items)
1748
+ }, [run, selectCommit, t, openMenuAt, blocked])
1749
+
1750
+ const changeMenu = useCallback((event, item, group) => {
1751
+ const items = [
1752
+ { id: 'diff', icon: 'file', tone: 'primary', label: t('action.showDiff'), run: () => { void openDiff({ path: item.path, staged: group === 'staged' }) } },
1753
+ null,
1754
+ group === 'staged'
1755
+ ? { id: 'unstage', icon: 'minus', tone: 'warn', label: t('action.unstage'), run: () => { void run('unstage', { paths: [item.path] }) } }
1756
+ : { id: 'stage', icon: 'plus', tone: 'success', label: t('action.stage'), run: () => { void run('stage', { paths: [item.path] }) } },
1757
+ { id: 'discard', icon: 'undo', tone: 'danger', label: t('action.discard'), danger: true, disabled: group === 'untracked', reason: t('action.unavailable'), run: () => setDialog({ kind: 'discard', item: item, group: group }) },
1758
+ null,
1759
+ { id: 'copy', icon: 'tag', tone: 'secondary', label: t('action.copyPath'), run: () => { copyText(item.path) } },
1760
+ ]
1761
+ openMenuAt(event, items)
1762
+ }, [openDiff, run, t, openMenuAt])
1763
+
1764
+ const submitDialog = useCallback(async (state, value) => {
1765
+ setDialog(null)
1766
+ if (state.kind === 'newBranch') { await run('checkout', { branch: value, create: true, startPoint: state.from }); return }
1767
+ if (state.kind === 'renameBranch') { await run('branchRename', { from: state.from, to: value }); return }
1768
+ if (state.kind === 'newTag') { await run('tagCreate', { name: value, hash: state.hash }); return }
1769
+ }, [run])
1770
+
1771
+ /* ---------- toasts + undo ---------- */
1772
+
1773
+ const toastTimers = useRef([])
1774
+ const toastSeq = useRef(0)
1775
+
1776
+ useEffect(() => () => {
1777
+ for (const timer of toastTimers.current) clearTimeout(timer)
1778
+ toastTimers.current = []
1779
+ }, [])
1780
+
1781
+ const dropToast = useCallback((id) => {
1782
+ setToasts((list) => list.filter((item) => item.id !== id))
1783
+ }, [])
1784
+
1785
+ const pushToast = useCallback((toast) => {
1786
+ toastSeq.current += 1
1787
+ const id = 'toast-' + String(toastSeq.current)
1788
+ setToasts((list) => list.concat([Object.assign({ id: id, onClose: () => dropToast(id) }, toast)]))
1789
+ const ttl = toast.ttl === undefined ? (toast.actionLabel === undefined ? 9000 : 30000) : toast.ttl
1790
+ if (ttl > 0) toastTimers.current.push(setTimeout(() => dropToast(id), ttl))
1791
+ return id
1792
+ }, [dropToast])
1793
+
1794
+ // The delete really happened; the host kept an object id for a while, and this
1795
+ // asks it to recreate the branch / stash entry from that id.
1796
+ const undoAction = useCallback(async (id) => {
1797
+ const data = await guard(() => request('undoApply', Object.assign({}, base, { id: id })))
1798
+ if (data === undefined) return
1799
+ setToasts((list) => list.filter((item) => item.undoId !== id))
1800
+ await refresh()
1801
+ pushToast({ text: t('toast.restored') + ' · ' + data.label, icon: 'check', tone: 'ok', ttl: 6000 })
1802
+ }, [base, guard, refresh, pushToast, t])
1803
+
1804
+ const deleteBranch = useCallback(async (name) => {
1805
+ const data = await run('branchDelete', { name: name, confirm: true })
1806
+ if (data === undefined) return
1807
+ if (data.undo === undefined) return
1808
+ pushToast({
1809
+ text: fill(t('toast.branchDeleted'), { name: name }),
1810
+ icon: 'trash',
1811
+ tone: 'warn',
1812
+ undoId: data.undo.id,
1813
+ actionLabel: t('toast.undo'),
1814
+ onAction: () => { void undoAction(data.undo.id) },
1815
+ })
1816
+ }, [run, pushToast, undoAction, t])
1817
+
1818
+ const dropStash = useCallback(async (ref) => {
1819
+ const data = await run('stashDrop', { ref: ref, confirm: true })
1820
+ if (data === undefined) return
1821
+ if (data.undo === undefined) return
1822
+ pushToast({
1823
+ text: fill(t('toast.stashDropped'), { ref: ref }),
1824
+ icon: 'trash',
1825
+ tone: 'warn',
1826
+ undoId: data.undo.id,
1827
+ actionLabel: t('toast.undo'),
1828
+ onAction: () => { void undoAction(data.undo.id) },
1829
+ })
1830
+ }, [run, pushToast, undoAction, t])
1831
+
1832
+ // A discard really overwrote the working tree, so it hands back the same
1833
+ // kind of handle a delete does: the host snapshotted the bytes first.
1834
+ const discardChanges = useCallback(async (item, group) => {
1835
+ const data = await run('discard', { paths: [item.path], untracked: group === 'untracked', confirm: true })
1836
+ if (data === undefined) return
1837
+ if (data.undo === undefined) {
1838
+ // The host declined to snapshot (too big / not faithfully reproducible);
1839
+ // say so instead of letting the toast promise an undo that is not there.
1840
+ if (data.undoBlocked === true) {
1841
+ pushToast({ text: fill(t('toast.discardNoUndo'), { path: item.path }), icon: 'trash', tone: 'warn' })
1842
+ }
1843
+ return
1844
+ }
1845
+ pushToast({
1846
+ text: fill(t('toast.discarded'), { path: item.path }),
1847
+ icon: 'trash',
1848
+ tone: 'warn',
1849
+ undoId: data.undo.id,
1850
+ actionLabel: t('toast.undo'),
1851
+ onAction: () => { void undoAction(data.undo.id) },
1852
+ })
1853
+ }, [run, pushToast, undoAction, t])
1854
+
1855
+ // A toast is transient; this keeps the same handles reachable afterwards, so
1856
+ // undoing a delete never depends on catching a floating chip in time.
1857
+ const openUndoMenu = useCallback(async (event) => {
1858
+ const data = await guard(() => request('undoList', base))
1859
+ if (data === undefined) return
1860
+ const entries = Array.isArray(data.items) ? data.items.slice().reverse() : []
1861
+ if (entries.length === 0) {
1862
+ pushToast({ text: t('undo.empty'), icon: 'undo', ttl: 6000 })
1863
+ return
1864
+ }
1865
+ openMenuAt(event, entries.map((entry) => ({
1866
+ id: 'undo:' + entry.id,
1867
+ icon: 'undo',
1868
+ tone: 'warn',
1869
+ label: (entry.kind === 'branch-delete' ? t('undo.branch') : entry.kind === 'stash-drop' ? t('undo.stash') : entry.kind === 'discard' ? t('undo.discard') : entry.kind) + ' · ' + entry.label,
1870
+ run: () => { void undoAction(entry.id) },
1871
+ })))
1872
+ }, [base, guard, openMenuAt, pushToast, t, undoAction])
1873
+
1874
+ /* ---------- shared pieces ---------- */
1875
+
1876
+ const repoOptions = repoState === null ? [] : repoState.repos
1877
+ const dirty = summary === null ? 0 : summary.changes.staged.length + summary.changes.unstaged.length + summary.changes.untracked.length + summary.changes.conflicted.length
1878
+
1879
+ const topBar = E('div', { className: 'dig-topbar' },
1880
+ E(RepoSelect, { t: t, repos: repoOptions, value: repoRoot, onChange: pickRepo }),
1881
+ branches === null || branches.local.length === 0 ? null : E('span', { className: 'dig-select-wrap', title: t('branch.switch') },
1882
+ E(Icon, { name: 'branch', size: 12 }),
1883
+ E('select', {
1884
+ className: 'dig-select dig-select-branch',
1885
+ value: branches.branch,
1886
+ onChange: (event) => { void run('checkout', { branch: event.target.value }) },
1887
+ }, branches.local.map((entry) => E('option', { key: entry.name, value: entry.name }, entry.name)))),
1888
+ summary === null || summary.upstream === null ? null : E('span', { className: 'dig-track' },
1889
+ (summary.ahead > 0 ? '↑' + summary.ahead : '') + (summary.behind > 0 ? ' ↓' + summary.behind : '')),
1890
+ operation === null ? null : E('span', { className: 'dig-opchip', title: t('operation.hint') }, t('operation.' + operation)),
1891
+ E('button', {
1892
+ type: 'button', className: 'dig-icon-btn dig-icon-btn-small',
1893
+ title: t('undo.menu'),
1894
+ onClick: (event) => { void openUndoMenu(event) },
1895
+ }, E(Icon, { name: 'undo', size: 13 })),
1896
+ E('span', { className: 'dig-topbar-spacer' }),
1897
+ busy ? E('span', { className: 'dig-busy' }, t('status.busy')) : null)
1898
+
1899
+ const banner = error === null ? null : E('div', { className: 'dig-banner' },
1900
+ E('span', { className: 'dig-banner-text' }, error),
1901
+ E('button', { type: 'button', className: 'dig-link', onClick: () => setError(null) }, t('error.dismiss')))
1902
+
1903
+ const noteBanner = note === null ? null : E('div', { className: 'dig-note' },
1904
+ E('span', { className: 'dig-banner-text' }, note),
1905
+ E('button', { type: 'button', className: 'dig-link', onClick: () => setNote(null) }, t('error.dismiss')))
1906
+
1907
+ const changesPane = E(ChangesPanel, {
1908
+ t: t, summary: summary, busy: busy, compact: compact, hideHeader: compact,
1909
+ showIgnored: showIgnored,
1910
+ onToggleIgnored: () => setShowIgnored((value) => !value),
1911
+ // Commit-and-push carries the confirm flag with it: the button already
1912
+ // says exactly what it will publish, so the host guard is satisfied by
1913
+ // the click itself rather than by a second dialog.
1914
+ onCommit: (message, amend, push) => {
1915
+ void run('commit', { message: message, amend: amend, push: push === true, confirm: push === true })
1916
+ },
1917
+ onStage: (item) => { void run('stage', { paths: [item.path] }) },
1918
+ onUnstage: (item) => { void run('unstage', { paths: [item.path] }) },
1919
+ onStageAll: (entries) => { void run('stage', { paths: entries.map((entry) => entry.path) }) },
1920
+ onUnstageAll: () => { void run('unstage', { paths: (summary === null ? [] : summary.changes.staged).map((entry) => entry.path) }) },
1921
+ onDiscard: (item, group) => setDialog({ kind: 'discard', item: item, group: group }),
1922
+ onDiff: (item, group) => { void openDiff({ path: item.path, staged: group === 'staged' }) },
1923
+ onChangeMenu: changeMenu,
1924
+ })
1925
+
1926
+ const historyPane = view === 'detail'
1927
+ ? E(CommitDetail, {
1928
+ t: t, detail: detail, selectedPath: selectedPath,
1929
+ onBack: () => { setView('history'); setSelectedPath(null); setPatch('') },
1930
+ onSelectFile: (file) => {
1931
+ setSelectedPath(file.path)
1932
+ void openDiff({ hash: detail === null ? undefined : detail.hash, path: file.path })
1933
+ },
1934
+ })
1935
+ : E(HistoryList, {
1936
+ t: t, commits: commits, hasMore: hasMore, busy: busy, selectedHash: selectedHash,
1937
+ hideSearch: compact, pathFilter: pathFilter,
1938
+ onPathFilter: (value) => setPathFilter(value),
1939
+ onSelect: (commit) => { void selectCommit(commit) },
1940
+ onMenu: commitMenu,
1941
+ onLoadMore: () => { void guard(() => loadCommits(commits.length)) },
1942
+ })
1943
+
1944
+ const diffPane = patch === '' && patchLoading === false ? null : E('div', { className: 'dig-diff-pane' },
1945
+ E('div', { className: 'dig-diff-head' },
1946
+ E('span', { className: 'dig-mono dig-diff-path' }, selectedPath === null ? '' : selectedPath),
1947
+ E('button', { type: 'button', className: 'dig-icon-btn', onClick: () => { setPatch(''); setSelectedPath(null) } }, E(Icon, { name: 'close', size: 12 }))),
1948
+ E(DiffBody, { patch: patch, loading: patchLoading, t: t }))
1949
+
1950
+ /* ---------- rail (IDE-style left action strip) ---------- */
1951
+
1952
+ const otherBranches = (branches === null ? [] : branches.local).filter((entry) => entry.head !== true)
1953
+
1954
+ const pickBranchMenu = (event, kind) => {
1955
+ if (otherBranches.length === 0) { setNote(t('note.noBranches')); return }
1956
+ const current = branches === null ? '' : branches.branch
1957
+ const icon = kind === 'checkout' ? 'checkout' : kind === 'delete' ? 'trash' : 'compare'
1958
+ const tone = kind === 'delete' ? 'danger' : kind === 'compare' ? 'violet' : 'accent'
1959
+ openMenuAt(event, otherBranches.map((entry) => ({
1960
+ id: kind + ':' + entry.name,
1961
+ icon: icon,
1962
+ tone: tone,
1963
+ label: entry.name,
1964
+ run: () => {
1965
+ if (kind === 'checkout') { void run('checkout', { branch: entry.name }); return }
1966
+ if (kind === 'delete') { setDialog({ kind: 'deleteBranch', name: entry.name }); return }
1967
+ void compareWith(current, entry.name)
1968
+ },
1969
+ })))
1970
+ }
1971
+
1972
+ const showWorkingDiff = () => {
1973
+ const changes = summary === null ? null : summary.changes
1974
+ if (changes === null) return
1975
+ const order = [
1976
+ ['unstaged', changes.unstaged, false],
1977
+ ['staged', changes.staged, true],
1978
+ ['conflicted', changes.conflicted, false],
1979
+ ['untracked', changes.untracked, false],
1980
+ ]
1981
+ for (const entry of order) {
1982
+ if (entry[1].length > 0) { void openDiff({ path: entry[1][0].path, staged: entry[2] }); return }
1983
+ }
1984
+ setNote(t('note.noChanges'))
1985
+ }
1986
+
1987
+ const stashMenu = (event) => {
1988
+ const count = summary === null ? 0 : summary.stashCount
1989
+ openMenuAt(event, [
1990
+ { id: 'stash-push', icon: 'stash', tone: 'violet', label: t('stash.push'), disabled: dirty === 0, reason: t('action.unavailable'), run: () => { void run('stashPush', { includeUntracked: true }) } },
1991
+ { id: 'stash-apply', icon: 'checkout', tone: 'accent', label: t('stash.apply'), disabled: count === 0, reason: t('action.unavailable'), run: () => { void run('stashApply', {}) } },
1992
+ { id: 'stash-drop', icon: 'trash', tone: 'danger', label: t('stash.drop'), danger: true, disabled: count === 0, reason: t('action.unavailable'), run: () => setDialog({ kind: 'stashDrop', ref: 'stash@{0}' }) },
1993
+ null,
1994
+ { id: 'stash-count', icon: 'tag', tone: 'secondary', label: fill(t('stash.count'), { n: count }), disabled: true, run: () => {} },
1995
+ ])
1996
+ }
1997
+
1998
+ /* favorites/toggleFavoriteBranch live above branchMenu: a useCallback dep array is evaluated during render. */
1999
+ const headBranch = branches === null ? '' : branches.branch
2000
+ const remoteReady = branches !== null && Array.isArray(branches.remotes) && branches.remotes.length > 0
2001
+ const tracked = summary !== null && summary.upstream !== null
2002
+ const stashCount = summary === null ? 0 : summary.stashCount
2003
+ const reason = t('action.unavailable')
2004
+
2005
+ /* One descriptor per action, with the availability IDEA would use: delete /
2006
+ checkout / compare need another local branch, fetch and push need a remote,
2007
+ pull needs an upstream, diff needs pending changes, tag needs a commit.
2008
+ The rail renders a user-chosen subset in a user-chosen order. */
2009
+ const railActions = RAIL_SPECS.map((spec) => {
2010
+ const entry = { id: spec.id, icon: spec.icon, tone: spec.tone, label: t(spec.key), disabled: false, active: false, run: () => {} }
2011
+ if (spec.id === 'refresh') entry.run = () => setTick((value) => value + 1)
2012
+ else if (spec.id === 'newBranch') entry.run = () => setDialog({ kind: 'newBranch' })
2013
+ else if (spec.id === 'checkout') { entry.disabled = otherBranches.length === 0 || blocked; entry.run = (event) => pickBranchMenu(event, 'checkout') }
2014
+ else if (spec.id === 'delete') { entry.disabled = otherBranches.length === 0; entry.run = (event) => pickBranchMenu(event, 'delete') }
2015
+ else if (spec.id === 'compare') { entry.disabled = otherBranches.length === 0; entry.run = (event) => pickBranchMenu(event, 'compare') }
2016
+ else if (spec.id === 'diff') { entry.disabled = dirty === 0; entry.run = () => showWorkingDiff() }
2017
+ else if (spec.id === 'stash') { entry.disabled = dirty === 0 && stashCount === 0; entry.run = (event) => stashMenu(event) }
2018
+ else if (spec.id === 'tag') { entry.disabled = commits.length === 0; entry.run = () => setDialog({ kind: 'newTag' }) }
2019
+ else if (spec.id === 'favorite') { entry.disabled = headBranch === ''; entry.active = favorites.indexOf(headBranch) >= 0; entry.run = () => toggleFavoriteBranch(headBranch) }
2020
+ else if (spec.id === 'fetch') { entry.disabled = remoteReady !== true; entry.run = () => { void run('fetch', { prune: true }) } }
2021
+ else if (spec.id === 'pull') { entry.disabled = remoteReady !== true || tracked !== true || blocked; entry.run = () => { void run('pull', { mode: 'ff-only' }) } }
2022
+ else if (spec.id === 'push') { entry.disabled = remoteReady !== true || blocked; entry.run = () => setDialog({ kind: 'push' }) }
2023
+ if (busy === true && spec.id !== 'refresh') entry.disabled = true
2024
+ return entry
2025
+ })
2026
+
2027
+ const railOrdered = railConfig.order
2028
+ .map((id) => railActions.find((action) => action.id === id))
2029
+ .filter((action) => action !== undefined)
2030
+ const railVisible = railOrdered.filter((action) => railConfig.hidden.indexOf(action.id) < 0)
2031
+
2032
+ const applyRail = (next) => { setRailConfig(next); writeRailConfig(next) }
2033
+ const moveRailAction = (id, delta) => {
2034
+ const order = railConfig.order.slice()
2035
+ const at = order.indexOf(id)
2036
+ const to = at + delta
2037
+ if (at < 0 || to < 0 || to >= order.length) return
2038
+ order.splice(at, 1)
2039
+ order.splice(to, 0, id)
2040
+ applyRail({ order: order, hidden: railConfig.hidden })
2041
+ }
2042
+ const dropRailAction = (fromId, toId) => {
2043
+ if (fromId === null || fromId === toId) return
2044
+ const order = railConfig.order.slice()
2045
+ const at = order.indexOf(fromId)
2046
+ const to = order.indexOf(toId)
2047
+ if (at < 0 || to < 0) return
2048
+ order.splice(at, 1)
2049
+ order.splice(to, 0, fromId)
2050
+ applyRail({ order: order, hidden: railConfig.hidden })
2051
+ }
2052
+ const toggleRailAction = (id) => {
2053
+ const hidden = railConfig.hidden.indexOf(id) >= 0
2054
+ ? railConfig.hidden.filter((entry) => entry !== id)
2055
+ : railConfig.hidden.concat([id])
2056
+ applyRail({ order: railConfig.order, hidden: hidden })
2057
+ }
2058
+ const resetRailActions = () => applyRail({ order: RAIL_IDS.slice(), hidden: [] })
2059
+
2060
+ const RAIL_SLOT = 26
2061
+ const railCapacityOf = (vertical) => {
2062
+ const span = vertical ? railBox.height : railBox.width
2063
+ if (span <= 0) return railVisible.length + 1
2064
+ return Math.max(1, Math.floor((span - (vertical ? 10 : 12)) / RAIL_SLOT))
2065
+ }
2066
+
2067
+ /* Renders the rail for one orientation: as many buttons as fit, then a more
2068
+ menu that lists EVERY action, then the settings button pinned to the end. */
2069
+ const renderRail = (vertical) => {
2070
+ const capacity = railCapacityOf(vertical)
2071
+ // The settings button is never dropped and never pushed out: the more
2072
+ // button only appears when there is room for it AND for settings.
2073
+ const overflow = railVisible.length + 1 > capacity && capacity >= 3
2074
+ const slots = overflow
2075
+ ? Math.max(1, capacity - 2)
2076
+ : Math.min(railVisible.length, Math.max(0, capacity - 1))
2077
+ const children = railVisible.slice(0, slots).map((action) => E('button', {
2078
+ key: action.id,
2079
+ type: 'button',
2080
+ className: 'dig-rail-btn dig-tone-' + action.tone + (action.active === true ? ' dig-rail-btn-active' : ''),
2081
+ title: action.disabled === true ? action.label + ' · ' + reason : action.label,
2082
+ disabled: action.disabled === true,
2083
+ onClick: action.run,
2084
+ }, E(Icon, { name: action.icon, size: 15 })))
2085
+ if (overflow === true) {
2086
+ children.push(E('button', {
2087
+ key: 'more',
2088
+ type: 'button',
2089
+ className: 'dig-rail-btn dig-rail-more',
2090
+ title: t('rail.more'),
2091
+ onClick: (event) => openMenuAt(event, railActions.map((action) => ({
2092
+ id: 'rail:' + action.id,
2093
+ icon: action.icon,
2094
+ tone: action.tone,
2095
+ label: action.label,
2096
+ disabled: action.disabled,
2097
+ active: action.active,
2098
+ reason: reason,
2099
+ run: (inner) => action.run(inner === undefined ? event : inner),
2100
+ }))),
2101
+ }, E(Icon, { name: 'more', size: 16 })))
2102
+ }
2103
+ children.push(E('span', { key: 'gap', className: vertical ? 'dig-rail-gap' : 'dig-rail-gap-x' }))
2104
+ children.push(E('button', {
2105
+ key: 'settings',
2106
+ type: 'button',
2107
+ className: 'dig-rail-btn dig-rail-settings',
2108
+ title: t('rail.settings'),
2109
+ onClick: () => setRailSettings(true),
2110
+ }, E(Icon, { name: 'settings', size: 15 })))
2111
+ return E('div', { className: vertical ? 'dig-rail' : 'dig-rail-row', ref: railHostRef }, children)
2112
+ }
2113
+
2114
+ /* ---------- body per chrome ---------- */
2115
+
2116
+ let body
2117
+ if (repoRoot === null) {
2118
+ body = E('div', { className: 'dig-body' }, E(RepoPicker, {
2119
+ t: t, cwd: cwd === undefined ? '' : cwd, repos: repoOptions, onPick: pickRepo,
2120
+ }))
2121
+ } else if (compact) {
2122
+ body = E('div', { className: 'dig-body dig-body-compact' },
2123
+ E('div', { className: 'dig-compact-bar' },
2124
+ E(Segmented, {
2125
+ value: view === 'detail' ? 'history' : view,
2126
+ onChange: (next) => { setView(next); setPatch('') },
2127
+ items: [
2128
+ { id: 'changes', label: fill(t('seg.changes'), { n: dirty }) },
2129
+ { id: 'history', label: t('seg.history') },
2130
+ ],
2131
+ }),
2132
+ E('span', { className: 'dig-topbar-spacer' }),
2133
+ treeOpen ? E('button', { type: 'button', className: 'dig-icon-btn dig-icon-btn-active', title: t('toolbar.tree'), onClick: () => setTreeOpen(false) }, E(Icon, { name: 'branch' })) : null),
2134
+ treeOpen ? E('div', { className: 'dig-compact-tree' }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
2135
+ view === 'changes' ? changesPane : E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffPane))
2136
+ } else if (columns) {
2137
+ body = E('div', { className: 'dig-body dig-body-columns' },
2138
+ treeOpen ? E('div', { className: 'dig-pane dig-pane-tree' }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
2139
+ E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffPane),
2140
+ E('div', { className: 'dig-pane dig-pane-changes' }, changesPane))
2141
+ } else {
2142
+ body = E('div', { className: 'dig-body dig-body-stack' },
2143
+ treeOpen ? E('div', { className: 'dig-pane dig-pane-tree-stack' }, E(BranchTree, { t: t, branches: branches, favorites: favorites, onCheckout: checkout, onBranchMenu: branchMenu })) : null,
2144
+ E('div', { className: 'dig-pane dig-pane-changes-stack' }, changesPane),
2145
+ E('div', { className: 'dig-pane dig-pane-main' }, historyPane, diffPane))
2146
+ }
2147
+
2148
+ /* ---------- overlays ---------- */
2149
+
2150
+ const overlays = []
2151
+ if (menu !== null) {
2152
+ overlays.push(E(ContextMenu, {
2153
+ key: 'menu',
2154
+ anchor: { x: menu.x, y: menu.y },
2155
+ boundsWidth: menu.boundsWidth,
2156
+ boundsHeight: menu.boundsHeight,
2157
+ items: menu.items,
2158
+ onClose: () => setMenu(null),
2159
+ }))
2160
+ }
2161
+ if (railSettings === true) {
2162
+ overlays.push(E(RailSettings, {
2163
+ key: 'rail-settings',
2164
+ t: t,
2165
+ items: railOrdered.map((action) => ({
2166
+ id: action.id,
2167
+ icon: action.icon,
2168
+ tone: action.tone,
2169
+ label: action.label,
2170
+ hidden: railConfig.hidden.indexOf(action.id) >= 0,
2171
+ })),
2172
+ onMove: moveRailAction,
2173
+ onDrop: dropRailAction,
2174
+ onToggle: toggleRailAction,
2175
+ onReset: resetRailActions,
2176
+ onClose: () => setRailSettings(false),
2177
+ }))
2178
+ }
2179
+ if (dialog !== null && (dialog.kind === 'newBranch' || dialog.kind === 'renameBranch' || dialog.kind === 'newTag')) {
2180
+ const titles = { newBranch: t('prompt.newBranch'), renameBranch: t('prompt.renameBranch'), newTag: t('prompt.newTag') }
2181
+ overlays.push(E(PromptDialog, {
2182
+ key: 'prompt',
2183
+ title: titles[dialog.kind],
2184
+ placeholder: dialog.from === undefined ? t('prompt.fromHead') : dialog.from,
2185
+ initialValue: dialog.kind === 'renameBranch' ? dialog.from : '',
2186
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
2187
+ onCancel: () => setDialog(null),
2188
+ onSubmit: (value) => { void submitDialog(dialog, value) },
2189
+ }))
2190
+ }
2191
+ if (dialog !== null && dialog.kind === 'deleteBranch') {
2192
+ const guarded = isProtectedBranch(dialog.name)
2193
+ overlays.push(E(ConfirmDialog, {
2194
+ key: 'delete',
2195
+ t: t,
2196
+ title: t('confirm.title'),
2197
+ text: fill(guarded ? t('confirm.protected') : t('delete.confirm'), { name: dialog.name }),
2198
+ requireText: guarded ? dialog.name : undefined,
2199
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
2200
+ onCancel: () => setDialog(null),
2201
+ onConfirm: () => { const name = dialog.name; setDialog(null); void deleteBranch(name) },
2202
+ }))
2203
+ }
2204
+ if (dialog !== null && dialog.kind === 'stashDrop') {
2205
+ overlays.push(E(ConfirmDialog, {
2206
+ key: 'stashDrop',
2207
+ t: t,
2208
+ title: t('confirm.title'),
2209
+ text: fill(t('stashDrop.confirm'), { ref: dialog.ref }),
2210
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
2211
+ onCancel: () => setDialog(null),
2212
+ onConfirm: () => { const ref = dialog.ref; setDialog(null); void dropStash(ref) },
2213
+ }))
2214
+ }
2215
+ if (dialog !== null && dialog.kind === 'discard') {
2216
+ overlays.push(E(ConfirmDialog, {
2217
+ key: 'discard',
2218
+ t: t,
2219
+ title: t('confirm.title'),
2220
+ text: dialog.group === 'untracked'
2221
+ ? fill(t('discard.untracked.confirm'), { path: dialog.item.path })
2222
+ : fill(t('discard.confirm'), { path: dialog.item.path }),
2223
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
2224
+ onCancel: () => setDialog(null),
2225
+ onConfirm: () => {
2226
+ const item = dialog.item
2227
+ const group = dialog.group
2228
+ setDialog(null)
2229
+ void discardChanges(item, group)
2230
+ },
2231
+ }))
2232
+ }
2233
+ if (dialog !== null && dialog.kind === 'reset') {
2234
+ overlays.push(E(ConfirmDialog, {
2235
+ key: 'reset', t: t,
2236
+ title: t('confirm.title'),
2237
+ text: dialog.mode === 'hard' ? fill(t('resetHard.confirm'), { hash: dialog.hash.slice(0, 8) }) : fill(t('resetSoft.confirm'), { hash: dialog.hash.slice(0, 8) }),
2238
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
2239
+ onCancel: () => setDialog(null),
2240
+ onConfirm: () => { setDialog(null); void run('reset', { hash: dialog.hash, mode: dialog.mode, confirm: true }) },
2241
+ }))
2242
+ }
2243
+ if (dialog !== null && dialog.kind === 'checkoutCommit') {
2244
+ overlays.push(E(ConfirmDialog, {
2245
+ key: 'checkoutCommit', t: t,
2246
+ title: t('confirm.title'), text: fill(t('checkoutCommit.confirm'), { hash: dialog.hash.slice(0, 8) }),
2247
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
2248
+ onCancel: () => setDialog(null),
2249
+ onConfirm: () => { setDialog(null); void run('checkout', { branch: dialog.hash }) },
2250
+ }))
2251
+ }
2252
+ if (dialog !== null && dialog.kind === 'push') {
2253
+ const branch = summary === null ? '' : summary.branch
2254
+ const target = summary === null || summary.upstream === null ? 'origin' : summary.upstream
2255
+ overlays.push(E(ConfirmDialog, {
2256
+ key: 'push', t: t,
2257
+ title: t('confirm.title'), text: fill(t('push.confirm'), { branch: branch, upstream: target }),
2258
+ okLabel: t('confirm.ok'), cancelLabel: t('confirm.cancel'),
2259
+ onCancel: () => setDialog(null),
2260
+ onConfirm: () => {
2261
+ setDialog(null)
2262
+ void run('push', { confirm: true, setUpstream: summary !== null && summary.upstream === null })
2263
+ },
2264
+ }))
2265
+ }
2266
+
2267
+ // Wide chromes (the bottom workbench) get the vertical IDE rail on the left;
2268
+ // narrow ones (the native right sidebar) get the same actions as one
2269
+ // horizontal row under the repo/branch bar, and every action lives in
2270
+ // exactly one place.
2271
+ return E('div', { className: 'dig-root', ref: hostRef }, topBar,
2272
+ columns === true || repoRoot === null ? null : renderRail(false),
2273
+ banner, noteBanner,
2274
+ E('div', { className: 'dig-shell' },
2275
+ columns === true && repoRoot !== null ? renderRail(true) : null,
2276
+ body),
2277
+ overlays,
2278
+ E(ToastStack, { t: t, toasts: toasts }))
2279
+ }
2280
+
2281
+ function copyText(text) {
2282
+ try {
2283
+ void navigator.clipboard.writeText(text)
2284
+ } catch (error) { void error }
2285
+ }
2286
+
2287
+ /* ============================== styles ============================== */
2288
+ /* Tokens only (--dsw-alias-*): transparent themes and background plugins
2289
+ (dsh-any-background) keep showing through, like the host's own panels. */
2290
+
2291
+ const CSS = [
2292
+ '.dig-root{position:relative;display:flex;flex-direction:column;height:100%;min-height:0;background:transparent;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,system-ui,sans-serif);font-size:13px;line-height:1.5;font-weight:600;overflow:hidden}',
2293
+ '.dig-topbar{display:flex;align-items:center;gap:6px;padding:4px 6px;flex:none;min-width:0;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1));overflow:hidden}',
2294
+ '.dig-topbar-spacer{flex:1;min-width:4px}',
2295
+ '.dig-busy{color:var(--dsw-alias-label-tertiary);flex:none;white-space:nowrap}',
2296
+ '.dig-track{color:var(--dsw-alias-label-secondary);flex:none;font-variant-numeric:tabular-nums;white-space:nowrap}',
2297
+ '.dig-select-wrap{display:inline-flex;align-items:center;gap:4px;min-width:0;color:var(--dsw-alias-label-secondary)}',
2298
+ '.dig-select{appearance:none;-webkit-appearance:none;background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;color:var(--dsw-alias-label-primary);font:inherit;font-weight:500;padding:1px 6px;height:22px;max-width:190px;min-width:0;cursor:pointer;text-overflow:ellipsis}',
2299
+ '.dig-select:hover{background:var(--dsw-alias-interactive-bg-hover)}',
2300
+ '.dig-select:focus{outline:1px solid var(--dsw-alias-brand-primary);outline-offset:-1px}',
2301
+ '.dig-select-repo{max-width:200px}',
2302
+ '.dig-select-branch{max-width:150px}',
2303
+ '.dig-repo-static{display:inline-flex;align-items:center;gap:5px;min-width:0;color:var(--dsw-alias-label-secondary)}',
2304
+ '.dig-repo-name{color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px}',
2305
+ '.dig-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;flex:none}',
2306
+ '.dig-icon-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2307
+ '.dig-icon-btn:disabled{opacity:.4;cursor:default}',
2308
+ '.dig-icon-btn-active{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary)}',
2309
+ '.dig-banner{display:flex;align-items:center;gap:8px;padding:4px 8px;flex:none;color:var(--dsw-alias-state-error-primary)}',
2310
+ '.dig-note{display:flex;align-items:center;gap:8px;padding:4px 8px;flex:none;color:var(--dsw-alias-label-secondary)}',
2311
+ '.dig-banner-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}',
2312
+ '.dig-shell{flex:1;min-height:0;display:flex;overflow:hidden}',
2313
+ '.dig-rail{width:32px;flex:none;display:flex;flex-direction:column;align-items:center;gap:2px;padding:4px 0;overflow:hidden;border-right:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2314
+ '.dig-rail-gap{flex:1;min-height:2px}',
2315
+ '.dig-rail-gap-x{flex:1;min-width:2px}',
2316
+ '.dig-rail-row{display:flex;align-items:center;gap:2px;padding:3px 6px;flex:none;overflow:hidden;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2317
+ '.dig-rail-btn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;flex:none}',
2318
+ '.dig-rail-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2319
+ '.dig-rail-btn:disabled{opacity:.28;cursor:default}',
2320
+ '.dig-rail-btn-active{background:color-mix(in srgb, var(--dsw-alias-brand-primary) 20%, transparent)}',
2321
+ '.dig-rail-more,.dig-rail-settings{color:var(--dsw-alias-label-tertiary)}',
2322
+ '.dig-rail-more:hover,.dig-rail-settings:hover{color:var(--dsw-alias-label-primary)}',
2323
+ '.dig-tone-primary{color:var(--dsw-alias-label-primary)}',
2324
+ '.dig-tone-secondary{color:var(--dsw-alias-label-secondary)}',
2325
+ '.dig-tone-accent{color:var(--dsw-alias-brand-primary)}',
2326
+ '.dig-tone-success{color:var(--dsw-alias-state-success-primary)}',
2327
+ '.dig-tone-warn{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
2328
+ '.dig-tone-danger{color:var(--dsw-alias-state-error-primary)}',
2329
+ '.dig-tone-violet{color:#b083f0}',
2330
+ '.dig-tone-cyan{color:#59b0d6}',
2331
+ '.dig-icon-btn-small{width:20px;height:20px;border-radius:5px}',
2332
+ '.dig-toasts{position:absolute;right:8px;bottom:8px;display:flex;flex-direction:column;gap:6px;z-index:80;max-width:min(340px,92%)}',
2333
+ '.dig-toast{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));border:1px solid var(--dsw-alias-border-l2);box-shadow:0 8px 22px rgba(0,0,0,.32)}',
2334
+ '.dig-toast-icon{display:inline-flex;flex:none;color:var(--dsw-alias-label-secondary)}',
2335
+ '.dig-toast-warn .dig-toast-icon{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
2336
+ '.dig-toast-ok .dig-toast-icon{color:var(--dsw-alias-state-success-primary)}',
2337
+ '.dig-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}',
2338
+ '.dig-toast-action{border:none;background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-weight:600;cursor:pointer;padding:0 2px;flex:none}',
2339
+ '.dig-toast-action:hover{text-decoration:underline}',
2340
+ '.dig-opchip{flex:none;padding:0 6px;border-radius:999px;background:color-mix(in srgb, var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary)) 20%, transparent);color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary));font-weight:500;white-space:nowrap}',
2341
+ '.dig-rail-list{display:flex;flex-direction:column;gap:2px;max-height:min(50vh,300px);overflow:auto;padding:2px;border:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1));border-radius:8px}',
2342
+ '.dig-rail-item{display:flex;align-items:center;gap:6px;padding:2px 4px;border-radius:6px}',
2343
+ '.dig-rail-item:hover{background:var(--dsw-alias-interactive-bg-hover)}',
2344
+ '.dig-rail-item-off{opacity:.5}',
2345
+ '.dig-rail-item-drag{outline:1px solid var(--dsw-alias-brand-primary)}',
2346
+ '.dig-rail-grip{display:inline-flex;color:var(--dsw-alias-label-dimmed,var(--dsw-alias-label-tertiary));cursor:grab;flex:none}',
2347
+ '.dig-rail-item-icon{display:inline-flex;flex:none}',
2348
+ '.dig-rail-item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}',
2349
+ '.dig-body{flex:1;min-height:0;display:flex;overflow:hidden}',
2350
+ '.dig-body-columns{flex-direction:row}',
2351
+ '.dig-body-stack{flex-direction:column}',
2352
+ '.dig-body-compact{flex-direction:column}',
2353
+ '.dig-pane{display:flex;flex-direction:column;min-height:0;min-width:0}',
2354
+ '.dig-pane-tree{width:200px;flex:none;border-right:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2355
+ '.dig-pane-tree-stack{max-height:36%;flex:none;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2356
+ '.dig-pane-main{flex:1;min-width:0}',
2357
+ '.dig-pane-changes{width:290px;flex:none;border-left:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2358
+ '.dig-pane-changes-stack{max-height:46%;flex:none;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2359
+ '.dig-compact-bar{display:flex;align-items:center;gap:6px;padding:4px 6px;flex:none}',
2360
+ '.dig-compact-tree{max-height:42%;flex:none;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2361
+ '.dig-seg{display:inline-flex;gap:2px;padding:2px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover);flex:none}',
2362
+ '.dig-seg-item{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-weight:500;padding:2px 10px;border-radius:6px;cursor:pointer;white-space:nowrap}',
2363
+ '.dig-seg-item:hover{color:var(--dsw-alias-label-primary)}',
2364
+ '.dig-seg-item[data-active="true"]{background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}',
2365
+ '.dig-search{padding:4px 6px;flex:none}',
2366
+ '.dig-input{width:100%;box-sizing:border-box;padding:4px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;outline:none}',
2367
+ '.dig-input::placeholder{color:var(--dsw-alias-label-tertiary)}',
2368
+ '.dig-input:focus{border-color:var(--dsw-alias-brand-primary)}',
2369
+ '.dig-input-compact{padding:3px 6px}',
2370
+ '.dig-picker{display:flex;flex-direction:column;gap:2px;padding:12px;overflow:auto;flex:1;min-height:0}',
2371
+ '.dig-picker-title{color:var(--dsw-alias-label-primary);font-weight:600;margin-bottom:2px}',
2372
+ '.dig-picker-sub{color:var(--dsw-alias-label-tertiary);margin-bottom:8px;word-break:break-all}',
2373
+ '.dig-picker-row{display:flex;align-items:center;gap:8px;width:100%;text-align:left;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;padding:6px 8px;border-radius:8px;cursor:pointer}',
2374
+ '.dig-picker-row:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-brand-primary)}',
2375
+ '.dig-picker-icon{display:inline-flex;color:var(--dsw-alias-label-secondary);flex:none}',
2376
+ '.dig-picker-name{flex:none;font-weight:600}',
2377
+ '.dig-picker-kind{color:var(--dsw-alias-label-tertiary);flex:none}',
2378
+ '.dig-picker-path{color:var(--dsw-alias-label-tertiary);margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:50%}',
2379
+ '.dig-tree{display:flex;flex-direction:column;min-height:0;flex:1}',
2380
+ '.dig-tree-scroll{flex:1;overflow:auto;padding-bottom:6px}',
2381
+ '.dig-section{display:flex;flex-direction:column}',
2382
+ '.dig-section-head{display:flex;align-items:center;gap:4px;width:100%;padding:3px 8px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-weight:600;cursor:pointer;text-align:left}',
2383
+ '.dig-section-head-static{cursor:default;padding-left:20px}',
2384
+ '.dig-section-head:hover{color:var(--dsw-alias-label-primary)}',
2385
+ // The header became a DIV (it now holds buttons), and a div is content-box
2386
+ // while a button is border-box: without this, width:100% plus its own padding
2387
+ // overflowed the pane by 16px and clipped the last toolbar icon (reported on
2388
+ // v0.3.8).
2389
+ '.dig-changes-head{cursor:default;gap:2px;box-sizing:border-box}',
2390
+ '.dig-section-toggle{display:flex;align-items:center;gap:4px;flex:1;min-width:0;padding:0;border:none;background:transparent;color:inherit;font:inherit;font-weight:600;cursor:pointer;text-align:left}',
2391
+ '.dig-folder{display:flex;flex-direction:column}',
2392
+ '.dig-branch-folder{display:flex;flex-direction:column}',
2393
+ '.dig-row-picked{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary)}',
2394
+ '.dig-folder-head{display:flex;align-items:center;gap:6px;width:100%;padding:1px 8px 1px 4px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;text-align:left}',
2395
+ '.dig-folder-head:hover{color:var(--dsw-alias-label-primary)}',
2396
+ '.dig-folder-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
2397
+ '.dig-count{margin-left:auto;opacity:.6;font-weight:400}',
2398
+ '.dig-chevron{display:inline-flex;transform:rotate(-90deg);transition:transform .12s ease}',
2399
+ '.dig-chevron-open{transform:rotate(0deg)}',
2400
+ '.dig-row{display:flex;align-items:center;gap:5px;padding:2px 8px;cursor:pointer;white-space:nowrap;overflow:hidden}',
2401
+ '.dig-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
2402
+ '.dig-row-head{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
2403
+ '.dig-row-selected{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))}',
2404
+ '.dig-row-remote .dig-row-label{color:var(--dsw-alias-label-secondary);font-weight:500}',
2405
+ '.dig-row-tag .dig-row-label{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary));font-weight:500}',
2406
+ '.dig-row-head .dig-row-label{font-weight:600}',
2407
+ '.dig-row-icon{display:inline-flex;flex:none;opacity:.9}',
2408
+ '.dig-row-label{overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;font-weight:600}',
2409
+ '.dig-row-sub{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis}',
2410
+ '.dig-row-dir{margin-left:auto;opacity:.75}',
2411
+ '.dig-badge{font-size:10px;padding:0 5px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);flex:none}',
2412
+ '.dig-badge-muted{opacity:.8}',
2413
+ '.dig-badge-worktree{padding:0 3px;display:inline-flex;align-items:center;height:14px}',
2414
+ '.dig-history{display:flex;flex-direction:column;min-height:0;flex:1}',
2415
+ '.dig-history-scroll{flex:1;overflow:auto}',
2416
+ '.dig-commit{display:flex;align-items:center;gap:6px;padding:1px 8px 1px 0;cursor:pointer;height:26px;overflow:hidden}',
2417
+ '.dig-commit:hover{background:var(--dsw-alias-interactive-bg-hover)}',
2418
+ '.dig-commit-selected{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))}',
2419
+ '.dig-graph{flex:none}',
2420
+ '.dig-commit-date{color:var(--dsw-alias-label-tertiary);flex:none;width:54px;font-variant-numeric:tabular-nums;font-weight:500}',
2421
+ '.dig-commit-author{color:var(--dsw-alias-label-secondary);flex:none;width:96px;overflow:hidden;text-overflow:ellipsis;font-weight:500}',
2422
+ '.dig-commit-subject{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}',
2423
+ '.dig-commit-fill{flex:1 1 0;min-width:0}',
2424
+ '.dig-ref{font-size:10px;padding:0 5px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);flex:none;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}',
2425
+ '.dig-ref-head{color:var(--dsw-alias-state-success-primary)}',
2426
+ '.dig-ref-local{color:var(--dsw-alias-brand-primary)}',
2427
+ '.dig-ref-remote{color:#b083f0}',
2428
+ '.dig-ref-tag{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
2429
+ '.dig-ref-more{color:var(--dsw-alias-label-tertiary)}',
2430
+ '.dig-filters{display:flex;align-items:center;gap:4px;padding:4px 6px;flex:none;min-width:0;overflow-x:auto;overflow-y:hidden;border-bottom:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2431
+ '.dig-filter-text{flex:1 1 90px;min-width:80px;width:auto}',
2432
+ '.dig-filter-select{flex:none;max-width:118px;appearance:none;-webkit-appearance:none;background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;color:var(--dsw-alias-label-secondary);font:inherit;font-weight:500;height:22px;padding:0 4px;cursor:pointer;text-overflow:ellipsis}',
2433
+ '.dig-filter-select:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2434
+ '.dig-filter-path{flex:0 1 112px;min-width:70px;display:flex}',
2435
+ '.dig-load-more{margin:6px auto;display:block;padding:3px 12px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer}',
2436
+ '.dig-load-more:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2437
+ '.dig-empty{padding:14px;text-align:center;color:var(--dsw-alias-label-tertiary);font-weight:500}',
2438
+ '.dig-changes{display:flex;flex-direction:column;min-height:0;flex:1}',
2439
+ '.dig-changes-body{display:flex;flex-direction:column;min-height:0;flex:1}',
2440
+ '.dig-changes-list{flex:1;overflow:auto;min-height:0}',
2441
+ '.dig-group{display:flex;flex-direction:column}',
2442
+ '.dig-group-head{display:flex;align-items:center;gap:8px;padding:2px 8px;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:600}',
2443
+ '.dig-link{margin-left:auto;border:none;background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;cursor:pointer;padding:0;flex:none}',
2444
+ '.dig-link:hover{text-decoration:underline}',
2445
+ '.dig-row-file{gap:6px}',
2446
+ '.dig-file-status{flex:none;width:14px;text-align:center;font-weight:700;font-size:10px;color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-secondary))}',
2447
+ '.dig-file-status-A{color:var(--dsw-alias-state-success-primary)}',
2448
+ '.dig-file-status-D{color:var(--dsw-alias-state-error-primary)}',
2449
+ '.dig-file-status-R{color:var(--dsw-alias-brand-primary)}',
2450
+ '.dig-file-status-U{color:var(--dsw-alias-label-tertiary)}',
2451
+ '.dig-file-status-I{color:var(--dsw-alias-label-tertiary)}',
2452
+ '.dig-stat-add{color:var(--dsw-alias-state-success-primary);flex:none;font-variant-numeric:tabular-nums}',
2453
+ '.dig-stat-del{color:var(--dsw-alias-state-error-primary);flex:none;font-variant-numeric:tabular-nums}',
2454
+ '.dig-mini{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;opacity:0;flex:none;padding:0}',
2455
+ '.dig-row:hover .dig-mini{opacity:1}',
2456
+ '.dig-mini:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2457
+ '.dig-commit-box{flex:none;padding:6px;display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2458
+ '.dig-commit-box-compact{flex-direction:row;align-items:center;gap:6px;padding:4px 6px}',
2459
+ '.dig-textarea{width:100%;box-sizing:border-box;min-height:50px;resize:vertical;padding:5px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;outline:none}',
2460
+ '.dig-textarea::placeholder{color:var(--dsw-alias-label-tertiary)}',
2461
+ '.dig-textarea:focus{border-color:var(--dsw-alias-brand-primary)}',
2462
+ '.dig-commit-actions{display:flex;align-items:center;gap:8px}',
2463
+ '.dig-check{display:inline-flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);cursor:pointer}',
2464
+ '.dig-btn{padding:3px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-weight:500;cursor:pointer;flex:none}',
2465
+ '.dig-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
2466
+ '.dig-btn:disabled{opacity:.45;cursor:default}',
2467
+ '.dig-btn-small{padding:2px 8px;font-size:11px}',
2468
+ '.dig-btn-primary{margin-left:auto;background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary));border-color:transparent;color:var(--dsw-alias-label-primary-inverted,#fff)}',
2469
+ '.dig-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-brand-primary))}',
2470
+ '.dig-btn-danger{background:var(--dsw-alias-state-error-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted,#fff)}',
2471
+ '.dig-detail{display:flex;flex-direction:column;min-height:0;flex:1;overflow:auto;padding:6px 8px;gap:6px}',
2472
+ '.dig-detail-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
2473
+ '.dig-detail-author,.dig-detail-date{color:var(--dsw-alias-label-tertiary)}',
2474
+ '.dig-detail-subject{font-weight:600;white-space:pre-wrap}',
2475
+ '.dig-detail-body{margin:0;white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font:inherit}',
2476
+ '.dig-detail-files-head{font-weight:600;margin-top:4px}',
2477
+ '.dig-detail-files{display:flex;flex-direction:column}',
2478
+ '.dig-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
2479
+ '.dig-diff-pane{flex:none;max-height:55%;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2480
+ '.dig-diff-head{display:flex;align-items:center;gap:8px;padding:3px 8px;color:var(--dsw-alias-label-tertiary);flex:none;min-width:0}',
2481
+ '.dig-diff-path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}',
2482
+ '.dig-diff{flex:1;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5;font-weight:500;min-height:0}',
2483
+ '.dig-diff-line{display:flex;gap:8px;padding:0 6px;white-space:pre}',
2484
+ '.dig-diff-gutter{width:32px;flex:none;text-align:right;color:var(--dsw-alias-label-dimmed,var(--dsw-alias-label-tertiary));user-select:none}',
2485
+ '.dig-diff-text{white-space:pre-wrap;word-break:break-word;flex:1;min-width:0}',
2486
+ '.dig-diff-add{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent)}',
2487
+ '.dig-diff-del{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent)}',
2488
+ '.dig-diff-hunk{color:var(--dsw-alias-brand-primary);background:color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent)}',
2489
+ '.dig-diff-meta{color:var(--dsw-alias-label-tertiary)}',
2490
+ '.dig-overlay{position:absolute;inset:0;padding:8px;box-sizing:border-box;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.42));display:flex;align-items:center;justify-content:center;z-index:60}',
2491
+ '.dig-dialog{min-width:240px;max-width:min(420px,94%);max-height:100%;overflow:auto;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px;box-shadow:0 12px 32px rgba(0,0,0,.35)}',
2492
+ '.dig-dialog-wide{min-width:min(360px,92%)}',
2493
+ '.dig-dialog-title{font-weight:600}',
2494
+ '.dig-dialog-text{color:var(--dsw-alias-label-secondary);white-space:pre-wrap}',
2495
+ '.dig-dialog-actions{display:flex;justify-content:flex-end;gap:8px}',
2496
+ '.dig-dialog-actions .dig-btn-primary{margin-left:0}',
2497
+ '.dig-menu{position:absolute;z-index:70;min-width:200px;max-width:calc(100% - 8px);max-height:calc(100% - 8px);overflow:auto;overscroll-behavior:contain;padding:4px;border-radius:8px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));border:1px solid var(--dsw-alias-border-l2);box-shadow:0 10px 28px rgba(0,0,0,.35);display:flex;flex-direction:column}',
2498
+ '.dig-menu-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-weight:500;text-align:left;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden}',
2499
+ '.dig-menu-icon{display:inline-flex;flex:none}',
2500
+ '.dig-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}',
2501
+ '.dig-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
2502
+ '.dig-menu-item-active{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
2503
+ '.dig-menu-danger,.dig-menu-danger .dig-menu-label{color:var(--dsw-alias-state-error-primary)}',
2504
+ '.dig-menu-disabled{opacity:.38;cursor:default}',
2505
+ '.dig-menu-sep{height:1px;margin:3px 6px;background:var(--dsw-alias-hairline,var(--dsw-alias-border-l1))}',
2506
+ ].join('\n')
2507
+
2508
+ /* ============================== plugin ============================== */
2509
+
2510
+ /* The native right-sidebar seats need three names: the registration id (a
2511
+ package name is the natural value, and it is also the seat key the body
2512
+ registers under), the tab kind (unique across kinds — the shipped `git`
2513
+ kind is not ours to take), and the guide capsule that opens the tab. */
2514
+ const NATIVE_ID = 'dsh-ide-git'
2515
+ const NATIVE_KIND = 'ide-git'
2516
+ const NATIVE_WORKSPACE_ITEMS = (state) => state.items
2517
+
2518
+ /* The native seat hands a tab the standard props; the panel wants
2519
+ `{ scope, t }`. A session's working directory is the path of the
2520
+ workspace that accounts for it — the same value the better-sidebar seat
2521
+ delivers as `scope.cwd`. */
2522
+ function NativePanel(props) {
2523
+ const workspaces = typeof props.useWorkspaces === 'function'
2524
+ ? props.useWorkspaces(NATIVE_WORKSPACE_ITEMS)
2525
+ : null
2526
+ const sessionId = typeof props.sessionId === 'string' ? props.sessionId : 'default'
2527
+ let cwd
2528
+ if (Array.isArray(workspaces)) {
2529
+ for (const item of workspaces) {
2530
+ if (item === null || typeof item !== 'object') continue
2531
+ if (!Array.isArray(item.sessionIds) || item.sessionIds.indexOf(sessionId) < 0) continue
2532
+ if (typeof item.path === 'string' && item.path !== '') cwd = item.path
2533
+ break
2534
+ }
2535
+ }
2536
+ const scope = useMemo(() => ({ cwd: cwd, sessionId: sessionId }), [cwd, sessionId])
2537
+ return E(Panel, { scope: scope, t: props.t, visible: true })
2538
+ }
2539
+
2540
+ function apply(ctx) {
2541
+ const style = document.createElement('style')
2542
+ style.setAttribute('data-dsh-ide-git', '')
2543
+ style.textContent = CSS
2544
+ document.head.appendChild(style)
2545
+ ctx.effect(() => () => { style.remove() }, 'dsh-ide-git: panel styles')
2546
+
2547
+ const dict = dictionaryOf(ctx)
2548
+ const t = (key) => (Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : key)
2549
+
2550
+ /* Two doors, one panel. dsh-better-sidebar also carries the bottom
2551
+ workbench, so wherever it is loaded it stays the host; the native
2552
+ right-sidebar seats are the fallback for a host running this plugin on
2553
+ its own — registering both would draw the same panel twice in one
2554
+ column, so a late-arriving better-sidebar takes the native one down. */
2555
+ let disposeNative = null
2556
+ let hostedByBetterSidebar = false
2557
+
2558
+ const hostNatively = () => {
2559
+ if (disposeNative !== null) return
2560
+ const tabs = ctx.get('sidebarRightTabs')
2561
+ const slots = ctx.get('slots')
2562
+ if (tabs === undefined || typeof tabs.register !== 'function') return
2563
+ if (slots === undefined || typeof slots.inject !== 'function') return
2564
+ const offType = tabs.register({
2565
+ id: NATIVE_ID,
2566
+ kind: NATIVE_KIND,
2567
+ title: () => t('title'),
2568
+ guide: [{
2569
+ id: 'git',
2570
+ order: 21,
2571
+ title: () => t('title'),
2572
+ description: () => t('description'),
2573
+ icon: (iconProps) => E(Icon, {
2574
+ name: 'commit',
2575
+ size: iconProps === undefined || iconProps.size === undefined ? 16 : iconProps.size,
2576
+ }),
2577
+ }],
2578
+ })
2579
+ const offBody = slots.inject('sidebar.right.pane.tab', () => slots.register(
2580
+ { name: 'sidebar.right.pane.tab', key: NATIVE_ID },
2581
+ (tabProps) => E(NativePanel, Object.assign({}, tabProps, { t: t })),
2582
+ ))
2583
+ disposeNative = () => {
2584
+ try { offBody() } catch (error) { void error }
2585
+ try { offType() } catch (error) { void error }
2586
+ }
2587
+ }
2588
+
2589
+ ctx.inject(['betterSidebar'], (tabCtx) => {
2590
+ const betterSidebar = tabCtx.get('betterSidebar')
2591
+ if (betterSidebar === undefined || typeof betterSidebar.registerTab !== 'function') return
2592
+ hostedByBetterSidebar = true
2593
+ if (disposeNative !== null) { disposeNative(); disposeNative = null }
2594
+ tabCtx.effect(() => betterSidebar.registerTab({
2595
+ id: TAB_ID,
2596
+ title: () => t('title'),
2597
+ description: () => t('description'),
2598
+ icon: (size) => E(Icon, { name: 'commit', size: size === undefined ? 16 : size }),
2599
+ order: 21,
2600
+ single: true,
2601
+ component: (tabProps) => E(Panel, Object.assign({}, tabProps, { t: t })),
2602
+ }), 'dsh-ide-git: Git tab')
2603
+ })
2604
+
2605
+ /* ctx.inject runs its callback straight away when the service is already
2606
+ there, so only a host without better-sidebar reaches the native seats. */
2607
+ if (!hostedByBetterSidebar) hostNatively()
2608
+ }
2609
+
2610
+ return { name: 'dsh-ide-git', inject: [], apply }
2611
+ },
2612
+ })