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/LICENSE +21 -0
- package/README.md +154 -0
- package/README_EN.md +123 -0
- package/cordis.patch.yml +22 -0
- package/docs/screenshots/.gitkeep +0 -0
- package/docs/screenshots/1-bottom-workbench.png +0 -0
- package/docs/screenshots/2-commit-menu.png +0 -0
- package/docs/screenshots/3-branch-undo.png +0 -0
- package/docs/screenshots/4-rail-settings.png +0 -0
- package/docs/screenshots/5-commit-detail.png +0 -0
- package/docs/screenshots/6-right-sidebar.png +0 -0
- package/dsh.plugin.json +13 -0
- package/package.json +75 -0
- package/src/client.js +2612 -0
- package/src/index.js +1249 -0
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
|
+
})
|