dsh-gitbar-lite 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/CONTRACT.md +1 -1
- package/lib/client.js +269 -43
- package/lib/host/repo.js +212 -5
- package/lib/host/routes.js +44 -8
- package/lib/host/store.js +35 -3
- package/lib/shared.js +13 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -189,7 +189,7 @@ are in [`docs/CONTRACT.md`](./docs/CONTRACT.md).
|
|
|
189
189
|
|
|
190
190
|
```sh
|
|
191
191
|
npm test # unit + real-git e2e + artifact self-check
|
|
192
|
-
npm run test:e2e # the real-git suite alone (
|
|
192
|
+
npm run test:e2e # the real-git suite alone (21 sections building their own fixtures, 263 assertions)
|
|
193
193
|
npm run check # artifact self-check alone (catches boot-breaking mistakes without restarting DSH)
|
|
194
194
|
npm run test:live # probe a running DSH (not part of npm test; needs the app running)
|
|
195
195
|
```
|
package/README.md
CHANGED
|
@@ -176,7 +176,7 @@ lib/client.js 浏览器半边:三个 tab 类型 + 面板/回收站/di
|
|
|
176
176
|
|
|
177
177
|
```sh
|
|
178
178
|
npm test # 单测 + 真 git 的 e2e + 产物自检
|
|
179
|
-
npm run test:e2e # 只跑真 git 的 e2e(
|
|
179
|
+
npm run test:e2e # 只跑真 git 的 e2e(21 个小节,各自造自己的夹具,263 项断言)
|
|
180
180
|
npm run check # 只用产物自检(不启 DSH 也能发现会让 DSH 起不来的问题)
|
|
181
181
|
npm run test:live # 探测一个正在运行的 DSH(不在 npm test 里,需要跑着应用)
|
|
182
182
|
```
|
package/docs/CONTRACT.md
CHANGED
|
@@ -326,7 +326,7 @@ this package on the next upgrade.
|
|
|
326
326
|
| state files, the binding key, corruption tolerance, write serialization | `test/store.test.mjs` |
|
|
327
327
|
| the route carrier, single registration under a hostile service order, error classification | `test/index.test.mjs` |
|
|
328
328
|
| every registration identity, the wire literals, the dictionaries, and the hook-free components' rendered trees | `test/client.test.mjs` |
|
|
329
|
-
| the whole host stack against **real git**, mutation by mutation | `test/e2e.mjs`,
|
|
329
|
+
| the whole host stack against **real git**, mutation by mutation | `test/e2e.mjs`, 263 assertions over 21 sections that build their own fixtures |
|
|
330
330
|
| the manifest, the patch layer, the tarball's contents, the two copies of the wire contract, the dictionaries, the workflow | `test/check.mjs` |
|
|
331
331
|
| that the client bundle would not abort the Host boot | `scripts/check-client-bundle.mjs` |
|
|
332
332
|
|
package/lib/client.js
CHANGED
|
@@ -74,6 +74,7 @@ window.__ModuleLoader__.load({
|
|
|
74
74
|
TRASH_DIFF: 'trashDiff',
|
|
75
75
|
TRASH_DELETE: 'trashDelete',
|
|
76
76
|
TRASH_CLEAR: 'trashClear',
|
|
77
|
+
TRASH_RESTORE: 'trashRestore',
|
|
77
78
|
PREFS: 'prefs',
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -146,8 +147,17 @@ window.__ModuleLoader__.load({
|
|
|
146
147
|
'action.cancel': '取消',
|
|
147
148
|
'action.confirm': '舍弃',
|
|
148
149
|
'action.delete': '删除这条记录',
|
|
150
|
+
'action.restore': '恢复这条更改',
|
|
149
151
|
'action.clear': '清空回收站',
|
|
150
152
|
'action.busy': '处理中…',
|
|
153
|
+
'fetch.last': '上次 Fetch:{when}',
|
|
154
|
+
'fetch.never': '还没 Fetch 过',
|
|
155
|
+
|
|
156
|
+
'status.added': '新增',
|
|
157
|
+
'status.modified': '修改',
|
|
158
|
+
'status.deleted': '删除',
|
|
159
|
+
'status.renamed': '重命名',
|
|
160
|
+
'status.conflicted': '冲突',
|
|
151
161
|
|
|
152
162
|
'changes.title': '更改',
|
|
153
163
|
'changes.empty': '工作区干净,没有要提交的更改。',
|
|
@@ -193,6 +203,10 @@ window.__ModuleLoader__.load({
|
|
|
193
203
|
'trash.clearConfirm': '清空 {n} 条回收站记录?',
|
|
194
204
|
'trash.clearHint': '这只是删掉归档的 diff,不会影响已经还原的工作区。',
|
|
195
205
|
'trash.truncated': '(这条记录在归档时被截断了)',
|
|
206
|
+
'trash.restoreChanged': '舍弃之后这些文件又改过了,恢复会盖掉后来的改动。',
|
|
207
|
+
'trash.restoreNoContent': '这条舍弃没留下文件内容,只留了 diff,无法恢复。',
|
|
208
|
+
'trash.restoreOtherRepo': '这条记录属于另一个仓库。',
|
|
209
|
+
'trash.restoreNoRepo': '当前工作区没有绑定这个仓库。',
|
|
196
210
|
|
|
197
211
|
'diff.title': 'Diff',
|
|
198
212
|
'diff.loading': '正在生成 diff…',
|
|
@@ -220,6 +234,7 @@ window.__ModuleLoader__.load({
|
|
|
220
234
|
'err.E_AUTH': '推送认证失败:本机 git 没有可用凭据。先在终端里成功 push 一次。',
|
|
221
235
|
'err.E_PUSH_REJECTED': '推送被拒绝:远端有你本地没有的提交,先 Fetch 再推。',
|
|
222
236
|
'err.E_TRASH_NOT_FOUND': '这条回收站记录已经不在了。',
|
|
237
|
+
'err.E_TRASH_NOT_RESTORABLE': '这条记录现在不能恢复。',
|
|
223
238
|
'err.E_INTERNAL': '插件内部出错了。',
|
|
224
239
|
'err.E_TRANSPORT': '和 DSH 宿主通信失败。',
|
|
225
240
|
'err.E_BAD_RESPONSE': '宿主返回了无法解析的响应。',
|
|
@@ -235,6 +250,7 @@ window.__ModuleLoader__.load({
|
|
|
235
250
|
'toast.unbound': '已解除绑定',
|
|
236
251
|
'toast.trashCleared': '回收站已清空({n} 条)',
|
|
237
252
|
'toast.trashDeleted': '已删除那条记录',
|
|
253
|
+
'toast.trashRestored': '已恢复 {n} 个文件的更改',
|
|
238
254
|
'toast.pushFailed': '已提交 {commit},但推送失败',
|
|
239
255
|
}
|
|
240
256
|
|
|
@@ -262,8 +278,17 @@ window.__ModuleLoader__.load({
|
|
|
262
278
|
'action.cancel': 'Cancel',
|
|
263
279
|
'action.confirm': 'Discard',
|
|
264
280
|
'action.delete': 'Delete this entry',
|
|
281
|
+
'action.restore': 'Restore this change',
|
|
265
282
|
'action.clear': 'Empty the trash',
|
|
266
283
|
'action.busy': 'Working…',
|
|
284
|
+
'fetch.last': 'Last fetch: {when}',
|
|
285
|
+
'fetch.never': 'Never fetched',
|
|
286
|
+
|
|
287
|
+
'status.added': 'Added',
|
|
288
|
+
'status.modified': 'Modified',
|
|
289
|
+
'status.deleted': 'Deleted',
|
|
290
|
+
'status.renamed': 'Renamed',
|
|
291
|
+
'status.conflicted': 'Conflicted',
|
|
267
292
|
|
|
268
293
|
'changes.title': 'Changes',
|
|
269
294
|
'changes.empty': 'The working tree is clean — nothing to commit.',
|
|
@@ -309,6 +334,10 @@ window.__ModuleLoader__.load({
|
|
|
309
334
|
'trash.clearConfirm': 'Empty {n} trash entries?',
|
|
310
335
|
'trash.clearHint': 'This only deletes the archived diffs; the restored working tree is untouched.',
|
|
311
336
|
'trash.truncated': '(this entry was truncated when archived)',
|
|
337
|
+
'trash.restoreChanged': 'These files changed after the discard; restoring would overwrite newer work.',
|
|
338
|
+
'trash.restoreNoContent': 'This discard kept only its diff, no file contents, so it cannot be put back.',
|
|
339
|
+
'trash.restoreOtherRepo': 'This entry belongs to a different repository.',
|
|
340
|
+
'trash.restoreNoRepo': 'This workspace is not bound to that repository.',
|
|
312
341
|
|
|
313
342
|
'diff.title': 'Diff',
|
|
314
343
|
'diff.loading': 'Building the diff…',
|
|
@@ -336,6 +365,7 @@ window.__ModuleLoader__.load({
|
|
|
336
365
|
'err.E_AUTH': 'Push authentication failed: the local git has no usable credential. Push once from a terminal first.',
|
|
337
366
|
'err.E_PUSH_REJECTED': 'The push was rejected: the remote has commits you do not. Fetch, then push.',
|
|
338
367
|
'err.E_TRASH_NOT_FOUND': 'That trash entry is gone.',
|
|
368
|
+
'err.E_TRASH_NOT_RESTORABLE': 'This entry cannot be restored right now.',
|
|
339
369
|
'err.E_INTERNAL': 'The plugin hit an internal error.',
|
|
340
370
|
'err.E_TRANSPORT': 'Could not reach the DSH host.',
|
|
341
371
|
'err.E_BAD_RESPONSE': 'The host returned a response that could not be read.',
|
|
@@ -351,6 +381,7 @@ window.__ModuleLoader__.load({
|
|
|
351
381
|
'toast.unbound': 'Binding cleared',
|
|
352
382
|
'toast.trashCleared': 'Trash emptied ({n} entries)',
|
|
353
383
|
'toast.trashDeleted': 'Entry deleted',
|
|
384
|
+
'toast.trashRestored': 'Restored the changes to {n} files',
|
|
354
385
|
'toast.pushFailed': 'Committed {commit}, but the push failed',
|
|
355
386
|
}
|
|
356
387
|
|
|
@@ -386,21 +417,31 @@ window.__ModuleLoader__.load({
|
|
|
386
417
|
* 3. Small pure helpers (covered by the unit tests)
|
|
387
418
|
* ------------------------------------------------------------------ */
|
|
388
419
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
420
|
+
/**
|
|
421
|
+
* Which mark the row shows for a change.
|
|
422
|
+
*
|
|
423
|
+
* Five kinds, not the seven git reports. GitHub Desktop maps a new file and
|
|
424
|
+
* an untracked one to the same `diff-added` mark (`iconForStatus` in
|
|
425
|
+
* `app/src/ui/octicons/status.ts`), and this panel follows it: "tracked or
|
|
426
|
+
* not" is a distinction git cares about when *staging*, not one worth two
|
|
427
|
+
* different marks in a list of what changed.
|
|
428
|
+
*/
|
|
429
|
+
function statusKind(file) {
|
|
430
|
+
if (file === null || typeof file !== 'object') return 'modified'
|
|
431
|
+
if (file.conflicted === true) return 'conflicted'
|
|
432
|
+
if (file.kind === 'deleted') return 'deleted'
|
|
433
|
+
if (file.kind === 'renamed') return 'renamed'
|
|
434
|
+
if (file.kind === 'added' || file.kind === 'untracked' || file.untracked === true) return 'added'
|
|
435
|
+
return 'modified'
|
|
396
436
|
}
|
|
397
437
|
|
|
398
|
-
/** The
|
|
399
|
-
function
|
|
400
|
-
if (
|
|
401
|
-
if (
|
|
402
|
-
if (
|
|
403
|
-
|
|
438
|
+
/** The component that draws one of those marks. */
|
|
439
|
+
function statusGlyph(kind) {
|
|
440
|
+
if (kind === 'added') return StatusAddedGlyph
|
|
441
|
+
if (kind === 'deleted') return StatusDeletedGlyph
|
|
442
|
+
if (kind === 'renamed') return StatusRenamedGlyph
|
|
443
|
+
if (kind === 'conflicted') return StatusConflictGlyph
|
|
444
|
+
return StatusModifiedGlyph
|
|
404
445
|
}
|
|
405
446
|
|
|
406
447
|
/** Split a repository path into the directory part and the file name. */
|
|
@@ -502,6 +543,39 @@ window.__ModuleLoader__.load({
|
|
|
502
543
|
}
|
|
503
544
|
}
|
|
504
545
|
|
|
546
|
+
/**
|
|
547
|
+
* "Last fetch: 3 min ago", or a plain "never" when there is no fetch to
|
|
548
|
+
* report.
|
|
549
|
+
*
|
|
550
|
+
* The timestamp is git's own — the mtime of `.git/FETCH_HEAD` — not something
|
|
551
|
+
* this plugin writes down, so a fetch run in a terminal counts too and the
|
|
552
|
+
* answer survives a restart.
|
|
553
|
+
*
|
|
554
|
+
* @param {object | null} state the status payload
|
|
555
|
+
* @param {number} now epoch milliseconds
|
|
556
|
+
* @param {(key: string, params?: object) => string} t
|
|
557
|
+
*/
|
|
558
|
+
function lastFetchLabel(state, now, t) {
|
|
559
|
+
const at = state === null || typeof state !== 'object' ? null : state.lastFetchAt
|
|
560
|
+
const when = formatRelative(at, now, t)
|
|
561
|
+
return when === '' ? t('fetch.never') : t('fetch.last', { when })
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Why restoring is unavailable for one entry, in one sentence.
|
|
566
|
+
*
|
|
567
|
+
* The reasons come from the host (`canRestore`), which is the only side that
|
|
568
|
+
* can compare the archive against the current tree. Anything unrecognised
|
|
569
|
+
* falls back to the honest general statement rather than a blank line.
|
|
570
|
+
*/
|
|
571
|
+
function restoreReasonText(t, reason) {
|
|
572
|
+
if (reason === 'changed') return t('trash.restoreChanged')
|
|
573
|
+
if (reason === 'no-content') return t('trash.restoreNoContent')
|
|
574
|
+
if (reason === 'other-repo') return t('trash.restoreOtherRepo')
|
|
575
|
+
if (reason === 'no-repo') return t('trash.restoreNoRepo')
|
|
576
|
+
return t('trash.restoreNoContent')
|
|
577
|
+
}
|
|
578
|
+
|
|
505
579
|
/** How many of a status payload's files are ticked and commit-ready. */
|
|
506
580
|
function stagedCount(state) {
|
|
507
581
|
if (state === null || !Array.isArray(state.files)) return 0
|
|
@@ -586,6 +660,63 @@ window.__ModuleLoader__.load({
|
|
|
586
660
|
return Ink
|
|
587
661
|
}
|
|
588
662
|
|
|
663
|
+
/**
|
|
664
|
+
* The same box, for artwork that is filled rather than stroked.
|
|
665
|
+
*
|
|
666
|
+
* The status marks are GitHub Desktop's own `diff-*` octicons
|
|
667
|
+
* (`app/src/ui/octicons/status.ts` `iconForStatus`), and each is a single
|
|
668
|
+
* filled path whose sub-paths draw both the frame and the mark inside it.
|
|
669
|
+
* Stroking one would draw the outline of an outline, so this variant puts
|
|
670
|
+
* `fill="currentColor"` on the path and leaves the box `fill="none"`, which
|
|
671
|
+
* is the filled twin of the rule above.
|
|
672
|
+
*
|
|
673
|
+
* These are deliberately *not* DSH's artwork: DSH has no changed-file status
|
|
674
|
+
* marks to copy, and the panel is meant to read like GitHub Desktop. The
|
|
675
|
+
* paths below are the octicon sources verbatim.
|
|
676
|
+
*/
|
|
677
|
+
function filledArtwork(viewBox, paths) {
|
|
678
|
+
const Ink = ({ size = 16, className }) =>
|
|
679
|
+
h(
|
|
680
|
+
'svg',
|
|
681
|
+
{
|
|
682
|
+
width: size,
|
|
683
|
+
height: size,
|
|
684
|
+
className,
|
|
685
|
+
viewBox,
|
|
686
|
+
fill: 'none',
|
|
687
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
688
|
+
'aria-hidden': 'true',
|
|
689
|
+
},
|
|
690
|
+
paths.map((d, index) => h('path', { key: `f${index}`, d, fill: 'currentColor' })),
|
|
691
|
+
)
|
|
692
|
+
return Ink
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** `diff-added` — a new file, and an untracked one, exactly as Desktop draws them. */
|
|
696
|
+
const StatusAddedGlyph = filledArtwork('0 0 16 16', [
|
|
697
|
+
'M2.75 1h10.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1Zm10.5 1.5H2.75a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM8 4a.75.75 0 0 1 .75.75v2.5h2.5a.75.75 0 0 1 0 1.5h-2.5v2.5a.75.75 0 0 1-1.5 0v-2.5h-2.5a.75.75 0 0 1 0-1.5h2.5v-2.5A.75.75 0 0 1 8 4Z',
|
|
698
|
+
])
|
|
699
|
+
|
|
700
|
+
/** `diff-modified`. */
|
|
701
|
+
const StatusModifiedGlyph = filledArtwork('0 0 16 16', [
|
|
702
|
+
'M13.25 1c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1ZM2.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z',
|
|
703
|
+
])
|
|
704
|
+
|
|
705
|
+
/** `diff-removed`. */
|
|
706
|
+
const StatusDeletedGlyph = filledArtwork('0 0 16 16', [
|
|
707
|
+
'M13.25 1c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1ZM2.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Zm8.5 6.25h-6.5a.75.75 0 0 1 0-1.5h6.5a.75.75 0 0 1 0 1.5Z',
|
|
708
|
+
])
|
|
709
|
+
|
|
710
|
+
/** `diff-renamed`. */
|
|
711
|
+
const StatusRenamedGlyph = filledArtwork('0 0 16 16', [
|
|
712
|
+
'M13.25 1c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1ZM2.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Zm9.03 6.03-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H4.75a.75.75 0 0 1 0-1.5h4.69L7.47 5.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l3.25 3.25a.75.75 0 0 1 0 1.06Z',
|
|
713
|
+
])
|
|
714
|
+
|
|
715
|
+
/** `alert`. */
|
|
716
|
+
const StatusConflictGlyph = filledArtwork('0 0 16 16', [
|
|
717
|
+
'M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
|
|
718
|
+
])
|
|
719
|
+
|
|
589
720
|
/** `IconBranchOutlineRegular` — the panel's own mark. */
|
|
590
721
|
const GitGlyph = artwork(
|
|
591
722
|
'0 0 16 16',
|
|
@@ -612,16 +743,18 @@ window.__ModuleLoader__.load({
|
|
|
612
743
|
[],
|
|
613
744
|
)
|
|
614
745
|
|
|
615
|
-
/**
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
746
|
+
/**
|
|
747
|
+
* The restore mark: an arc with a head, drawn to the host's own icon
|
|
748
|
+
* specification (16 box, `stroke` on each path, `Regular` weight).
|
|
749
|
+
*
|
|
750
|
+
* DSH ships no undo/restore glyph to copy — `IconTrashOutlineRegular` is as
|
|
751
|
+
* close as its icon set gets, and that is what the *discard* button already
|
|
752
|
+
* uses, so reusing it for the opposite verb would be actively confusing.
|
|
753
|
+
*/
|
|
754
|
+
const RestoreGlyph = artwork('0 0 16 16', [
|
|
755
|
+
'M2.9 7.4A5.1 5.1 0 1 1 7.1 12.3',
|
|
756
|
+
'M2.3 3.6V7.4H6.1',
|
|
757
|
+
], [])
|
|
625
758
|
|
|
626
759
|
/** `IconRefreshOutlineRegular`. */
|
|
627
760
|
const RefreshGlyph = artwork(
|
|
@@ -716,16 +849,20 @@ window.__ModuleLoader__.load({
|
|
|
716
849
|
'.gitbar-tick-partial{border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
|
|
717
850
|
'.gitbar-tick svg{width:11px;height:11px;display:block}',
|
|
718
851
|
'.gitbar-tick-partial span{display:block;width:7px;height:1.5px;background:var(--dsw-alias-state-business-primary,#4d6bfe)}',
|
|
719
|
-
'.gitbar-
|
|
720
|
-
'.gitbar-
|
|
721
|
-
'.gitbar-
|
|
722
|
-
'.gitbar-
|
|
723
|
-
'.gitbar-
|
|
724
|
-
'.gitbar-
|
|
725
|
-
'.gitbar-
|
|
852
|
+
'.gitbar-contextBtn{width:auto;height:24px;padding:0 10px;font-size:12px;font-weight:500;border-color:transparent;background:var(--dsw-alias-state-business-primary,#4d6bfe);color:var(--dsw-static-neutral-bluish-00,#fff)}',
|
|
853
|
+
'.gitbar-contextBtn:hover:not(:disabled){background:var(--dsw-alias-state-business-primary,#4d6bfe);filter:brightness(1.08)}',
|
|
854
|
+
'.gitbar-lastFetch{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary,#6b7280);white-space:nowrap}',
|
|
855
|
+
'.gitbar-status{flex:none;display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px}',
|
|
856
|
+
'.gitbar-status svg{display:block}',
|
|
857
|
+
'.gitbar-status-added{color:var(--dsw-alias-state-success-primary,#15803d)}',
|
|
858
|
+
'.gitbar-status-modified{color:var(--dsw-alias-state-warn-primary,#b45309)}',
|
|
859
|
+
'.gitbar-status-deleted{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
|
|
860
|
+
'.gitbar-status-renamed{color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
|
|
861
|
+
'.gitbar-status-conflicted{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
|
|
726
862
|
'.gitbar-path{flex:1 1 auto;min-width:0;display:flex;align-items:baseline;gap:0;overflow:hidden}',
|
|
863
|
+
'.gitbar-dir{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;color:var(--dsw-alias-label-tertiary,#6b7280)}',
|
|
864
|
+
'.gitbar-sep{flex:none;color:var(--dsw-alias-label-tertiary,#6b7280)}',
|
|
727
865
|
'.gitbar-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
728
|
-
'.gitbar-dir{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#6b7280)}',
|
|
729
866
|
'.gitbar-stats{flex:none;display:flex;gap:3px;font-size:10.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
|
|
730
867
|
'.gitbar-plus{color:var(--dsw-alias-state-success-primary,#15803d)}',
|
|
731
868
|
'.gitbar-minus{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
|
|
@@ -787,6 +924,7 @@ window.__ModuleLoader__.load({
|
|
|
787
924
|
'.gitbar-trashFiles{font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
788
925
|
'.gitbar-toggle{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;user-select:none;white-space:nowrap}',
|
|
789
926
|
'.gitbar-toggle input{margin:0;accent-color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
|
|
927
|
+
'.gitbar-trashWhy{display:block;font-size:10.5px;color:var(--dsw-alias-state-warn-primary,#92400e)}',
|
|
790
928
|
'.gitbar-spacer{flex:1 1 auto}',
|
|
791
929
|
'.gitbar-loading{padding:18px 12px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11.5px;display:flex;align-items:center;gap:6px}',
|
|
792
930
|
'.gitbar-spin{display:inline-block;width:13px;height:13px;flex:none;animation:gitbar-spin .9s linear infinite}',
|
|
@@ -929,12 +1067,16 @@ window.__ModuleLoader__.load({
|
|
|
929
1067
|
* 8. The panel
|
|
930
1068
|
* ------------------------------------------------------------------ */
|
|
931
1069
|
|
|
932
|
-
/** One changed file. */
|
|
1070
|
+
/** One changed file, laid out the way GitHub Desktop lays one out. */
|
|
933
1071
|
function ChangeRow({ file, t, onToggle, onDiscard, onOpen }) {
|
|
934
|
-
const
|
|
935
|
-
const
|
|
936
|
-
letter === 'M' ? 'm' : letter === 'A' ? 'a' : letter === 'D' ? 'd' : letter === 'R' ? 'r' : letter === '?' ? 'u' : 'c'
|
|
1072
|
+
const kind = statusKind(file)
|
|
1073
|
+
const Mark = statusGlyph(kind)
|
|
937
1074
|
const parts = splitPath(file.path)
|
|
1075
|
+
// Desktop renders the directory *before* the name and lets the directory be
|
|
1076
|
+
// the part that truncates, so the name survives. The separator is its own
|
|
1077
|
+
// node because `direction:rtl` (how the directory gets a leading ellipsis)
|
|
1078
|
+
// would move a trailing slash to the other end of the text.
|
|
1079
|
+
const dir = parts.dir.endsWith('/') ? parts.dir.slice(0, -1) : parts.dir
|
|
938
1080
|
const display = file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`
|
|
939
1081
|
|
|
940
1082
|
return h(
|
|
@@ -943,6 +1085,7 @@ window.__ModuleLoader__.load({
|
|
|
943
1085
|
className: 'gitbar-row',
|
|
944
1086
|
'data-gitbar-row': file.path,
|
|
945
1087
|
'data-gitbar-staged': file.staged === true ? 'true' : 'false',
|
|
1088
|
+
'data-gitbar-kind': kind,
|
|
946
1089
|
title: display,
|
|
947
1090
|
onDoubleClick: () => onOpen(file.path),
|
|
948
1091
|
},
|
|
@@ -964,10 +1107,10 @@ window.__ModuleLoader__.load({
|
|
|
964
1107
|
},
|
|
965
1108
|
file.staged === true && !file.partial ? h(CheckGlyph, { size: 11 }) : file.partial === true ? h('span', {}) : null,
|
|
966
1109
|
),
|
|
967
|
-
h('span', { key: 'letter', className: `gitbar-letter gitbar-letter-${letterClass}` }, letter),
|
|
968
1110
|
h('span', { key: 'path', className: 'gitbar-path' }, [
|
|
1111
|
+
dir === '' ? null : h('span', { key: 'dir', className: 'gitbar-dir' }, dir),
|
|
1112
|
+
dir === '' ? null : h('span', { key: 'sep', className: 'gitbar-sep' }, '/'),
|
|
969
1113
|
h('span', { key: 'name', className: 'gitbar-name' }, parts.name),
|
|
970
|
-
parts.dir === '' ? null : h('span', { key: 'dir', className: 'gitbar-dir' }, ` ${parts.dir}`),
|
|
971
1114
|
]),
|
|
972
1115
|
h('span', { key: 'stats', className: 'gitbar-stats' }, [
|
|
973
1116
|
(file.added ?? 0) > 0 ? h('span', { key: 'p', className: 'gitbar-plus' }, `+${file.added}`) : null,
|
|
@@ -989,6 +1132,19 @@ window.__ModuleLoader__.load({
|
|
|
989
1132
|
},
|
|
990
1133
|
h(TrashGlyph, { size: 14 }),
|
|
991
1134
|
),
|
|
1135
|
+
// Last in the row, as in Desktop: the checkbox and the path start the
|
|
1136
|
+
// row, the mark ends it.
|
|
1137
|
+
h(
|
|
1138
|
+
'span',
|
|
1139
|
+
{
|
|
1140
|
+
key: 'mark',
|
|
1141
|
+
className: `gitbar-status gitbar-status-${kind}`,
|
|
1142
|
+
'data-gitbar-status': kind,
|
|
1143
|
+
title: t(`status.${kind}`),
|
|
1144
|
+
'aria-label': t(`status.${kind}`),
|
|
1145
|
+
},
|
|
1146
|
+
h(Mark, { size: 13 }),
|
|
1147
|
+
),
|
|
992
1148
|
],
|
|
993
1149
|
)
|
|
994
1150
|
}
|
|
@@ -1234,6 +1390,29 @@ window.__ModuleLoader__.load({
|
|
|
1234
1390
|
[run],
|
|
1235
1391
|
)
|
|
1236
1392
|
|
|
1393
|
+
// Everything changed is ticked by default, which is what GitHub Desktop
|
|
1394
|
+
// does and what the panel is asked to match. Our tick *is* the index, so
|
|
1395
|
+
// "ticked by default" has to mean staging every changed path once: the
|
|
1396
|
+
// first time the panel looks at a repository whose index is empty. A file
|
|
1397
|
+
// the user unticks leaves the index non-empty, so this cannot fight a
|
|
1398
|
+
// deliberate choice — and the ref guard means a failure does not loop.
|
|
1399
|
+
//
|
|
1400
|
+
// Conflicted paths are excluded on purpose: `git add` on a conflicted file
|
|
1401
|
+
// records a resolution, and nothing has been resolved at this point.
|
|
1402
|
+
const autoStaged = useRef('')
|
|
1403
|
+
useEffect(() => {
|
|
1404
|
+
if (state === null || phase !== 'ready' || busy !== null) return
|
|
1405
|
+
if (state.repoRoot === null || state.repoRoot === undefined) return
|
|
1406
|
+
const files = Array.isArray(state.files) ? state.files : []
|
|
1407
|
+
const auto = files.filter((file) => file.conflicted !== true).map((file) => file.path)
|
|
1408
|
+
if (auto.length === 0) return
|
|
1409
|
+
if ((state.counts?.staged ?? 0) > 0) return
|
|
1410
|
+
const key = `${state.repoRoot}\u0000${auto.join('\u0000')}`
|
|
1411
|
+
if (autoStaged.current === key) return
|
|
1412
|
+
autoStaged.current = key
|
|
1413
|
+
void run('stage', ACTIONS.STAGE, { paths: auto, staged: true })
|
|
1414
|
+
}, [state, phase, busy, run])
|
|
1415
|
+
|
|
1237
1416
|
const openDiff = useCallback(
|
|
1238
1417
|
(path) => {
|
|
1239
1418
|
setNotice(null)
|
|
@@ -1398,16 +1577,21 @@ window.__ModuleLoader__.load({
|
|
|
1398
1577
|
disabled: busy !== null,
|
|
1399
1578
|
onClick: runContext,
|
|
1400
1579
|
className: 'gitbar-contextBtn',
|
|
1401
|
-
attrs: { 'data-gitbar-context': state.primary ?? 'fetch'
|
|
1580
|
+
attrs: { 'data-gitbar-context': state.primary ?? 'fetch' },
|
|
1402
1581
|
},
|
|
1403
1582
|
h('span', { style: { whiteSpace: 'nowrap' } }, t(contextLabelKey(state))),
|
|
1404
1583
|
),
|
|
1584
|
+
h(
|
|
1585
|
+
'span',
|
|
1586
|
+
{ key: 'last', className: 'gitbar-lastFetch', 'data-gitbar-lastfetch': state.lastFetchAt ?? '' },
|
|
1587
|
+
lastFetchLabel(state, Date.now(), t),
|
|
1588
|
+
),
|
|
1405
1589
|
h(
|
|
1406
1590
|
IconButton,
|
|
1407
1591
|
{ key: 'refresh', label: t('action.refresh'), disabled: busy !== null, onClick: () => void load({ quiet: true }) },
|
|
1408
1592
|
h(RefreshGlyph, {}),
|
|
1409
1593
|
),
|
|
1410
|
-
h(IconButton, { key: 'trash', label: t('action.trash'), onClick: openTrash }, h(
|
|
1594
|
+
h(IconButton, { key: 'trash', label: t('action.trash'), onClick: openTrash }, h(TrashGlyph, {})),
|
|
1411
1595
|
h(
|
|
1412
1596
|
IconButton,
|
|
1413
1597
|
{ key: 'rebind', label: t('action.rebind'), disabled: busy !== null, onClick: () => bind(null) },
|
|
@@ -1506,7 +1690,7 @@ window.__ModuleLoader__.load({
|
|
|
1506
1690
|
{
|
|
1507
1691
|
key: 'commit',
|
|
1508
1692
|
type: 'button',
|
|
1509
|
-
className: 'gitbar-btn
|
|
1693
|
+
className: 'gitbar-btn',
|
|
1510
1694
|
title: canCommit ? '' : commitHint,
|
|
1511
1695
|
disabled: !canCommit,
|
|
1512
1696
|
'data-gitbar-commit': '',
|
|
@@ -1519,7 +1703,7 @@ window.__ModuleLoader__.load({
|
|
|
1519
1703
|
{
|
|
1520
1704
|
key: 'push',
|
|
1521
1705
|
type: 'button',
|
|
1522
|
-
className: 'gitbar-btn',
|
|
1706
|
+
className: 'gitbar-btn gitbar-btn-primary',
|
|
1523
1707
|
title: canCommit ? '' : commitHint,
|
|
1524
1708
|
disabled: !canCommit,
|
|
1525
1709
|
'data-gitbar-commitpush': '',
|
|
@@ -1611,6 +1795,25 @@ window.__ModuleLoader__.load({
|
|
|
1611
1795
|
[sessionId, load, tab.signal],
|
|
1612
1796
|
)
|
|
1613
1797
|
|
|
1798
|
+
// Restoring is offered only when the working tree still matches what the
|
|
1799
|
+
// discard left behind, which the host decides and reports per entry. The
|
|
1800
|
+
// check is re-run server side inside the action, so a row that renders as
|
|
1801
|
+
// enabled cannot fail and a row that renders as disabled cannot lie.
|
|
1802
|
+
const restoreEntry = useCallback(
|
|
1803
|
+
async (entry) => {
|
|
1804
|
+
setBusy(entry.id)
|
|
1805
|
+
try {
|
|
1806
|
+
await rpc(ACTIONS.TRASH_RESTORE, { sessionId, id: entry.id }, tab.signal)
|
|
1807
|
+
await load({ quiet: true })
|
|
1808
|
+
} catch (failure) {
|
|
1809
|
+
if (failure?.name !== 'AbortError') setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
|
|
1810
|
+
} finally {
|
|
1811
|
+
setBusy(null)
|
|
1812
|
+
}
|
|
1813
|
+
},
|
|
1814
|
+
[sessionId, load, tab.signal],
|
|
1815
|
+
)
|
|
1816
|
+
|
|
1614
1817
|
const clearAll = useCallback(async () => {
|
|
1615
1818
|
setConfirmClear(false)
|
|
1616
1819
|
setBusy('clear')
|
|
@@ -1733,8 +1936,28 @@ window.__ModuleLoader__.load({
|
|
|
1733
1936
|
{ key: 'files', className: 'gitbar-trashFiles', title: (entry.files ?? []).map((file) => file.path).join('\n') },
|
|
1734
1937
|
(entry.files ?? []).map((file) => shortPath(file.path)).slice(0, 3).join(' · '),
|
|
1735
1938
|
),
|
|
1939
|
+
// Why restoring is off, when it is: the archive outlived the
|
|
1940
|
+
// state it was taken from.
|
|
1941
|
+
entry.restorable?.ok === true
|
|
1942
|
+
? null
|
|
1943
|
+
: h('span', { key: 'why', className: 'gitbar-trashWhy' }, restoreReasonText(t, entry.restorable?.reason)),
|
|
1736
1944
|
]),
|
|
1737
1945
|
),
|
|
1946
|
+
h(
|
|
1947
|
+
'span',
|
|
1948
|
+
{ key: 'act', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', padding: '7px 4px 0 0' } },
|
|
1949
|
+
h(
|
|
1950
|
+
IconButton,
|
|
1951
|
+
{
|
|
1952
|
+
key: 'restore',
|
|
1953
|
+
label: t('action.restore'),
|
|
1954
|
+
disabled: busy !== null || entry.restorable?.ok !== true,
|
|
1955
|
+
onClick: () => void restoreEntry(entry),
|
|
1956
|
+
attrs: { 'data-gitbar-restore': entry.id },
|
|
1957
|
+
},
|
|
1958
|
+
h(RestoreGlyph, { size: 14 }),
|
|
1959
|
+
),
|
|
1960
|
+
),
|
|
1738
1961
|
h(
|
|
1739
1962
|
'span',
|
|
1740
1963
|
{ key: 'del', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', padding: '7px 6px 0 0' } },
|
|
@@ -1913,7 +2136,7 @@ window.__ModuleLoader__.load({
|
|
|
1913
2136
|
function TrashTitle(props) {
|
|
1914
2137
|
const t = props.t ?? fallbackTranslate()
|
|
1915
2138
|
return h('span', { className: 'gitbar-tabTitle' }, [
|
|
1916
|
-
h(
|
|
2139
|
+
h(TrashGlyph, { key: 'g', size: 14 }),
|
|
1917
2140
|
h('span', { key: 'l', className: 'gitbar-tabTitleText' }, t('trash.title')),
|
|
1918
2141
|
])
|
|
1919
2142
|
}
|
|
@@ -2129,9 +2352,12 @@ window.__ModuleLoader__.load({
|
|
|
2129
2352
|
ZH,
|
|
2130
2353
|
EN,
|
|
2131
2354
|
diffRows,
|
|
2132
|
-
|
|
2355
|
+
statusKind,
|
|
2356
|
+
statusGlyph,
|
|
2133
2357
|
splitPath,
|
|
2134
2358
|
formatRelative,
|
|
2359
|
+
lastFetchLabel,
|
|
2360
|
+
restoreReasonText,
|
|
2135
2361
|
stagedCount,
|
|
2136
2362
|
hasConflict,
|
|
2137
2363
|
contextLabelKey,
|
package/lib/host/repo.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
* @module dsh-gitbar-lite/host/repo
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { readFile, readdir, rm, stat } from 'node:fs/promises'
|
|
24
|
-
import { basename, join, resolve, sep } from 'node:path'
|
|
23
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
24
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
25
25
|
|
|
26
26
|
import { DISCARD_SCOPE, ERROR_CODES, FILE_STATUS, PRIMARY_ACTIONS } from '../shared.js'
|
|
27
27
|
import {
|
|
@@ -177,6 +177,28 @@ export function classifyGitFailure(error, fallbackCode = ERROR_CODES.GIT_FAILED)
|
|
|
177
177
|
return new RepoError(fallbackCode, String(error?.message ?? error))
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* The mtime of `FETCH_HEAD`, as an ISO string, or `null` when there is none.
|
|
182
|
+
*
|
|
183
|
+
* Every `git fetch` and `git pull` writes that file, which makes its mtime the
|
|
184
|
+
* honest answer to "when did this repository last talk to a remote" — including
|
|
185
|
+
* for a checkout this plugin has never seen, and including a fetch run in a
|
|
186
|
+
* terminal. A missing file means nobody has fetched yet, which is not an error.
|
|
187
|
+
*
|
|
188
|
+
* @param {{ exitCode: number | null, stdout: string }} gitDirResult the outcome of `rev-parse --absolute-git-dir`
|
|
189
|
+
* @returns {Promise<string | null>}
|
|
190
|
+
*/
|
|
191
|
+
async function fetchTimeOf(gitDirResult) {
|
|
192
|
+
if (gitDirResult.exitCode !== 0) return null
|
|
193
|
+
const dir = gitDirResult.stdout.trim()
|
|
194
|
+
if (dir === '') return null
|
|
195
|
+
try {
|
|
196
|
+
return (await stat(join(dir, 'FETCH_HEAD'))).mtime.toISOString()
|
|
197
|
+
} catch {
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
180
202
|
/**
|
|
181
203
|
* Build the repository facade for one plugin instance.
|
|
182
204
|
*
|
|
@@ -191,14 +213,18 @@ export function createRepo(git, config) {
|
|
|
191
213
|
* @param {AbortSignal} [signal]
|
|
192
214
|
*/
|
|
193
215
|
async function readStatus(repoRoot, signal) {
|
|
194
|
-
// The
|
|
216
|
+
// The probes are independent, so they share one round of latency.
|
|
195
217
|
// `GIT_OPTIONAL_LOCKS=0` (set by the runner) is what makes the index reads
|
|
196
218
|
// safe to overlap.
|
|
197
|
-
const [statusResult, numstatResult, remotesResult, headResult] = await Promise.all([
|
|
219
|
+
const [statusResult, numstatResult, remotesResult, headResult, gitDirResult] = await Promise.all([
|
|
198
220
|
git.run(['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { cwd: repoRoot, signal }),
|
|
199
221
|
git.run(['diff', '--numstat', '-z', '--find-renames', 'HEAD'], { cwd: repoRoot, signal }),
|
|
200
222
|
git.run(['remote'], { cwd: repoRoot, signal }),
|
|
201
223
|
git.run(['log', '-1', '--pretty=%H%x00%h%x00%s'], { cwd: repoRoot, signal }),
|
|
224
|
+
// Where `.git` really is, so FETCH_HEAD can be found without assuming a
|
|
225
|
+
// plain directory: a worktree's git directory lives somewhere else, and a
|
|
226
|
+
// submodule's is inside the parent's.
|
|
227
|
+
git.run(['rev-parse', '--absolute-git-dir'], { cwd: repoRoot, signal }),
|
|
202
228
|
])
|
|
203
229
|
|
|
204
230
|
if (statusResult.exitCode !== 0) {
|
|
@@ -263,6 +289,12 @@ export function createRepo(git, config) {
|
|
|
263
289
|
? PRIMARY_ACTIONS.PUBLISH
|
|
264
290
|
: PRIMARY_ACTIONS.FETCH
|
|
265
291
|
|
|
292
|
+
// When the last fetch happened, asked of git rather than remembered by this
|
|
293
|
+
// plugin: `git fetch` (and `git pull`) rewrites FETCH_HEAD every time, so a
|
|
294
|
+
// fetch run in a terminal counts as well, and there is no state of ours to
|
|
295
|
+
// lose or to disagree with the repository.
|
|
296
|
+
const lastFetchAt = await fetchTimeOf(gitDirResult)
|
|
297
|
+
|
|
266
298
|
return {
|
|
267
299
|
repoRoot,
|
|
268
300
|
repoName: basename(repoRoot),
|
|
@@ -272,6 +304,7 @@ export function createRepo(git, config) {
|
|
|
272
304
|
files: enriched,
|
|
273
305
|
counts,
|
|
274
306
|
primary,
|
|
307
|
+
lastFetchAt,
|
|
275
308
|
}
|
|
276
309
|
}
|
|
277
310
|
|
|
@@ -587,6 +620,13 @@ export function createRepo(git, config) {
|
|
|
587
620
|
}
|
|
588
621
|
})
|
|
589
622
|
|
|
623
|
+
// What it would take to put this back. The content is read now, before
|
|
624
|
+
// anything is destroyed, because the archive is the only copy left: the
|
|
625
|
+
// diff alone cannot be replayed into a file. A path whose content cannot be
|
|
626
|
+
// carried (too large, or binary, or the diff was truncated) is recorded as
|
|
627
|
+
// unrestorable with a reason, so the UI can say why instead of failing.
|
|
628
|
+
const restorable = await buildRestorable(repoRoot, chosen, detailByPath, signal)
|
|
629
|
+
|
|
590
630
|
entry = await archive(
|
|
591
631
|
{
|
|
592
632
|
repoRoot,
|
|
@@ -600,6 +640,7 @@ export function createRepo(git, config) {
|
|
|
600
640
|
bytes: Buffer.byteLength(limited.text, 'utf8'),
|
|
601
641
|
truncated: limited.truncated,
|
|
602
642
|
droppedLines: limited.droppedLines,
|
|
643
|
+
restorable,
|
|
603
644
|
},
|
|
604
645
|
limited.text,
|
|
605
646
|
)
|
|
@@ -725,5 +766,171 @@ export function createRepo(git, config) {
|
|
|
725
766
|
return { scanned: visited, truncated: queue.length > 0, repos: found }
|
|
726
767
|
}
|
|
727
768
|
|
|
728
|
-
|
|
769
|
+
/**
|
|
770
|
+
* What each discarded path would need in order to be put back.
|
|
771
|
+
*
|
|
772
|
+
* The content is carried as base64 because it is bytes, not text: a file with
|
|
773
|
+
* CRLF endings, a latin-1 comment, or a NUL byte survives a round trip through
|
|
774
|
+
* a JSON string only by accident, and this is the last copy of it. The size cap
|
|
775
|
+
* is the same one the diff is capped by, so an archive that says "truncated"
|
|
776
|
+
* never promises a restore it cannot perform.
|
|
777
|
+
*
|
|
778
|
+
* @param {string} repoRoot
|
|
779
|
+
* @param {readonly object[]} chosen the status entries being discarded
|
|
780
|
+
* @param {Map<string, object>} detailByPath the already-read untracked details
|
|
781
|
+
* @param {AbortSignal} [signal]
|
|
782
|
+
*/
|
|
783
|
+
async function buildRestorable(repoRoot, chosen, detailByPath, signal) {
|
|
784
|
+
const payload = []
|
|
785
|
+
for (const file of chosen) {
|
|
786
|
+
const detail = file.untracked === true ? detailByPath.get(file.path) : undefined
|
|
787
|
+
const absolute = absoluteIn(repoRoot, file.path)
|
|
788
|
+
|
|
789
|
+
let bytes
|
|
790
|
+
try {
|
|
791
|
+
bytes = await readFile(absolute)
|
|
792
|
+
} catch {
|
|
793
|
+
// The worktree copy is gone (a deletion). There is nothing to carry: the
|
|
794
|
+
// "before" content lives in HEAD, and restoring means taking it back from
|
|
795
|
+
// there, which is what `git restore` already did.
|
|
796
|
+
payload.push({ path: file.path, content: null, mode: null, restorable: false, reason: 'missing' })
|
|
797
|
+
continue
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (bytes.length > config.maxFileBytes) {
|
|
801
|
+
payload.push({ path: file.path, content: null, mode: null, restorable: false, reason: 'oversized' })
|
|
802
|
+
continue
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const binary = file.untracked === true ? detail?.binary === true : looksBinary(bytes)
|
|
806
|
+
if (binary) {
|
|
807
|
+
// Binary content is carried too — it is bytes either way — but the panel
|
|
808
|
+
// will not pretend it can preview it.
|
|
809
|
+
payload.push({
|
|
810
|
+
path: file.path,
|
|
811
|
+
content: bytes.toString('base64'),
|
|
812
|
+
mode: file.untracked === true ? detail?.mode ?? '100644' : null,
|
|
813
|
+
restorable: true,
|
|
814
|
+
binary: true,
|
|
815
|
+
})
|
|
816
|
+
continue
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
payload.push({
|
|
820
|
+
path: file.path,
|
|
821
|
+
content: bytes.toString('base64'),
|
|
822
|
+
mode: file.untracked === true ? detail?.mode ?? '100644' : null,
|
|
823
|
+
restorable: true,
|
|
824
|
+
binary: false,
|
|
825
|
+
})
|
|
826
|
+
}
|
|
827
|
+
return payload
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Whether one archived discard could be put back right now.
|
|
832
|
+
*
|
|
833
|
+
* The question the user asked is exactly this one: *if the version the archive
|
|
834
|
+
* was based on is still the current version, restoring is offered; otherwise it
|
|
835
|
+
* is not.* The comparison is one `git diff --quiet HEAD -- <paths>` per entry,
|
|
836
|
+
* which is also the first thing `restore` checks — so a button that says it is
|
|
837
|
+
* enabled cannot turn into a refusal.
|
|
838
|
+
*
|
|
839
|
+
* @param {string} repoRoot
|
|
840
|
+
* @param {object} entry the trash entry
|
|
841
|
+
* @param {readonly object[]} payload its `restorable` array
|
|
842
|
+
* @param {AbortSignal} [signal]
|
|
843
|
+
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
|
844
|
+
*/
|
|
845
|
+
async function canRestore(repoRoot, entry, payload, signal) {
|
|
846
|
+
if (!Array.isArray(payload) || payload.length === 0) return { ok: false, reason: 'no-content' }
|
|
847
|
+
if (payload.every((item) => item.restorable !== true)) return { ok: false, reason: 'no-content' }
|
|
848
|
+
|
|
849
|
+
// An entry recorded against a different repository cannot be restored into
|
|
850
|
+
// this one: the paths mean something else there.
|
|
851
|
+
if (typeof entry?.repoRoot === 'string' && entry.repoRoot !== '' && resolve(entry.repoRoot) !== resolve(repoRoot)) {
|
|
852
|
+
return { ok: false, reason: 'other-repo' }
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const pathspec = payload.map((item) => literal(item.path))
|
|
856
|
+
const untouched = await git.run(['diff', '--quiet', 'HEAD', '--', ...pathspec], { cwd: repoRoot, signal })
|
|
857
|
+
if (untouched.exitCode === 0) return { ok: true }
|
|
858
|
+
if (untouched.exitCode === 1) return { ok: false, reason: 'changed' }
|
|
859
|
+
return { ok: false, reason: 'unknown' }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Put one archived discard back into the working tree.
|
|
864
|
+
*
|
|
865
|
+
* Refused, with nothing written, unless the working tree still matches what the
|
|
866
|
+
* discard produced: every file the archive touched must be identical to its
|
|
867
|
+
* HEAD version (or, for a file the discard deleted, still absent). Anything
|
|
868
|
+
* else means work happened after the archive was taken, and writing over it
|
|
869
|
+
* would be the one operation this plugin must never do.
|
|
870
|
+
*
|
|
871
|
+
* A partially matching entry is refused as a whole rather than restored file by
|
|
872
|
+
* file: an all-or-nothing discard restored half-way is a tree nobody can
|
|
873
|
+
* reason about.
|
|
874
|
+
*
|
|
875
|
+
* @param {string} repoRoot
|
|
876
|
+
* @param {object} entry the trash entry
|
|
877
|
+
* @param {readonly object[]} payload its `restorable` array
|
|
878
|
+
* @param {AbortSignal} [signal]
|
|
879
|
+
* @returns {Promise<{ restored: string[], skipped: string[] }>}
|
|
880
|
+
*/
|
|
881
|
+
async function restore(repoRoot, entry, payload, signal) {
|
|
882
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
883
|
+
throw new RepoError(ERROR_CODES.TRASH_NOT_RESTORABLE, '这条记录没有可恢复的内容(舍弃时没留下副本)。')
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const entries = Array.isArray(entry?.files) ? entry.files : []
|
|
887
|
+
if (entries.length !== payload.length) {
|
|
888
|
+
throw new RepoError(ERROR_CODES.TRASH_NOT_RESTORABLE, '这条记录的内容和文件列表不一致,无法安全恢复。')
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// The one check that decides everything: does the working tree still look the
|
|
892
|
+
// way the discard left it? Reusing `canRestore` means the answer the button
|
|
893
|
+
// was rendered from is the answer that is enforced here — a disabled button
|
|
894
|
+
// cannot turn into a refusal, and an enabled one cannot surprise.
|
|
895
|
+
const eligibility = await canRestore(repoRoot, entry, payload, signal)
|
|
896
|
+
if (!eligibility.ok) {
|
|
897
|
+
const message =
|
|
898
|
+
eligibility.reason === 'changed'
|
|
899
|
+
? '这些文件在舍弃之后又被改过了,恢复会盖掉后来的改动。要恢复请先提交或再舍弃一次。'
|
|
900
|
+
: eligibility.reason === 'other-repo'
|
|
901
|
+
? '这条记录属于另一个仓库,不能恢复到当前仓库。'
|
|
902
|
+
: '这条记录里没有可以恢复的内容。'
|
|
903
|
+
throw new RepoError(ERROR_CODES.TRASH_NOT_RESTORABLE, message)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const restored = []
|
|
907
|
+
const skipped = []
|
|
908
|
+
for (const item of payload) {
|
|
909
|
+
if (item.restorable !== true || typeof item.content !== 'string' || item.content === '') {
|
|
910
|
+
skipped.push(item.path)
|
|
911
|
+
continue
|
|
912
|
+
}
|
|
913
|
+
const absolute = absoluteIn(repoRoot, item.path)
|
|
914
|
+
await mkdir(dirname(absolute), { recursive: true })
|
|
915
|
+
await writeFile(absolute, Buffer.from(item.content, 'base64'))
|
|
916
|
+
|
|
917
|
+
// The content is back on disk but the index is not: staging it restores the
|
|
918
|
+
// tick the user had, which is the state the panel was showing when they
|
|
919
|
+
// asked to discard. Untracked files are staged as well, because a discard
|
|
920
|
+
// that archived them removed them entirely.
|
|
921
|
+
const added = await git.run(['add', '--', literal(item.path)], { cwd: repoRoot, signal })
|
|
922
|
+
if (added.exitCode !== 0) {
|
|
923
|
+
throw classifyGitFailure({ ...added, message: `git add failed: ${added.stderr.trim()}` })
|
|
924
|
+
}
|
|
925
|
+
restored.push(item.path)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (restored.length === 0) {
|
|
929
|
+
throw new RepoError(ERROR_CODES.TRASH_NOT_RESTORABLE, '这条记录里没有任何可以恢复的内容。')
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return { restored, skipped }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return { readStatus, statusOf, setStaged, commit, push, fetch, fileDiff, discard, restore, canRestore, scanRepos }
|
|
729
936
|
}
|
package/lib/host/routes.js
CHANGED
|
@@ -287,7 +287,7 @@ export function createHandlers({ ctx, git, repo, store }) {
|
|
|
287
287
|
// The entry id is minted here rather than in `repo.discard` so the git
|
|
288
288
|
// layer stays free of any notion of where archives are stored.
|
|
289
289
|
archive: (entry, diffText) =>
|
|
290
|
-
store.appendTrash({ ...entry, id: trashId(), at: new Date().toISOString() }, diffText),
|
|
290
|
+
store.appendTrash({ ...entry, id: trashId(), at: new Date().toISOString() }, diffText, entry.restorable),
|
|
291
291
|
})
|
|
292
292
|
|
|
293
293
|
// Remember the choice: the confirmation box opens on whatever the user
|
|
@@ -317,12 +317,14 @@ export function createHandlers({ ctx, git, repo, store }) {
|
|
|
317
317
|
? all
|
|
318
318
|
: all.filter((entry) => typeof entry.repoRoot === 'string' && workspaceKey(entry.repoRoot) === workspaceKey(binding.repoRoot))
|
|
319
319
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
320
|
+
const entries = []
|
|
321
|
+
for (const entry of filtered) {
|
|
322
|
+
const restorable = await store.readTrashRestore(entry)
|
|
323
|
+
// Which repository this entry belongs to: the bound one when we are not
|
|
324
|
+
// showing every repository, and the entry's own recorded root otherwise.
|
|
325
|
+
// An entry whose root no longer resolves simply cannot be restored.
|
|
326
|
+
const root = showAll ? entry.repoRoot : binding.repoRoot
|
|
327
|
+
entries.push({
|
|
326
328
|
id: entry.id,
|
|
327
329
|
at: entry.at,
|
|
328
330
|
repoRoot: entry.repoRoot,
|
|
@@ -345,7 +347,19 @@ export function createHandlers({ ctx, git, repo, store }) {
|
|
|
345
347
|
bytes: entry.bytes ?? 0,
|
|
346
348
|
truncated: entry.truncated === true,
|
|
347
349
|
droppedLines: entry.droppedLines ?? 0,
|
|
348
|
-
|
|
350
|
+
restorable:
|
|
351
|
+
typeof root === 'string' && root !== ''
|
|
352
|
+
? await repo.canRestore(root, entry, restorable, request.signal)
|
|
353
|
+
: { ok: false, reason: 'no-repo' },
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
workspacePath: workspace.cwd,
|
|
359
|
+
repoRoot: binding.repoRoot ?? null,
|
|
360
|
+
showAll,
|
|
361
|
+
total: all.length,
|
|
362
|
+
entries,
|
|
349
363
|
}
|
|
350
364
|
},
|
|
351
365
|
|
|
@@ -362,6 +376,28 @@ export function createHandlers({ ctx, git, repo, store }) {
|
|
|
362
376
|
}
|
|
363
377
|
},
|
|
364
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Put one archived discard back.
|
|
381
|
+
*
|
|
382
|
+
* The refusal lives in the repository layer, because that is where the
|
|
383
|
+
* comparison against the current working tree is made — the route only turns
|
|
384
|
+
* it into the payload the panel disables the button with.
|
|
385
|
+
*/
|
|
386
|
+
async [ACTIONS.TRASH_RESTORE](payload, request) {
|
|
387
|
+
const workspace = await requireWorkspace(ctx, stringField(payload, 'sessionId'))
|
|
388
|
+
const entry = await store.readTrashEntry(stringField(payload, 'id'))
|
|
389
|
+
if (entry === undefined) throw new RepoError(ERROR_CODES.TRASH_NOT_FOUND, '这条回收站记录已经不存在了。')
|
|
390
|
+
if (typeof entry.repoRoot !== 'string' || entry.repoRoot === '') {
|
|
391
|
+
throw new RepoError(ERROR_CODES.NO_REPO, '这条记录没有记下是哪个仓库的,无法恢复。')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const content = await store.readTrashRestore(entry)
|
|
395
|
+
const restored = await repo.restore(entry.repoRoot, entry, content, request.signal)
|
|
396
|
+
|
|
397
|
+
const state = await repo.readStatus(entry.repoRoot, request.signal)
|
|
398
|
+
return { restored, repoRoot: entry.repoRoot, workspacePath: workspace.cwd, ...state }
|
|
399
|
+
},
|
|
400
|
+
|
|
365
401
|
async [ACTIONS.TRASH_DELETE](payload) {
|
|
366
402
|
const id = stringField(payload, 'id')
|
|
367
403
|
const removed = await store.removeTrash(id)
|
package/lib/host/store.js
CHANGED
|
@@ -271,27 +271,59 @@ export function createStore(ctx, config) {
|
|
|
271
271
|
*
|
|
272
272
|
* @param {object} entry
|
|
273
273
|
* @param {string} diffText
|
|
274
|
+
* @param {object[]} [restorable] the per-path content needed to put it back
|
|
274
275
|
*/
|
|
275
|
-
async appendTrash(entry, diffText) {
|
|
276
|
+
async appendTrash(entry, diffText, restorable) {
|
|
276
277
|
const blobName = `${entry.id}.diff`
|
|
277
278
|
await mkdir(trashDir(), { recursive: true })
|
|
278
279
|
await writeFile(join(trashDir(), blobName), diffText, 'utf8')
|
|
279
280
|
|
|
281
|
+
// The restore payload lives beside the diff, not inside it: the diff is the
|
|
282
|
+
// human-readable record, and this is machine state with a different shape.
|
|
283
|
+
// Older archives simply have no such file, which reads as "not restorable"
|
|
284
|
+
// rather than as a broken entry.
|
|
285
|
+
const contentName = `${entry.id}.content.json`
|
|
286
|
+
const carriesContent = Array.isArray(restorable) && restorable.length > 0
|
|
287
|
+
if (carriesContent) {
|
|
288
|
+
await writeFile(join(trashDir(), contentName), JSON.stringify({ version: STATE_VERSION, files: restorable }), 'utf8')
|
|
289
|
+
}
|
|
290
|
+
|
|
280
291
|
let dropped = []
|
|
281
292
|
await mutateJson(trashIndexPath(), { version: STATE_VERSION, entries: [] }, (document) => {
|
|
282
293
|
const existing = Array.isArray(document?.entries) ? document.entries : []
|
|
283
294
|
const limit = Number.isFinite(config.trashLimit) && config.trashLimit > 0 ? config.trashLimit : 200
|
|
284
|
-
const combined = [{ ...entry, blob: blobName }, ...existing]
|
|
295
|
+
const combined = [{ ...entry, blob: blobName, ...(carriesContent ? { content: contentName } : {}) }, ...existing]
|
|
285
296
|
dropped = combined.slice(limit)
|
|
286
297
|
return { version: STATE_VERSION, entries: combined.slice(0, limit) }
|
|
287
298
|
})
|
|
288
299
|
|
|
289
300
|
for (const stale of dropped) {
|
|
290
301
|
if (typeof stale?.blob === 'string') await this.removeTrashBlob(stale.blob)
|
|
302
|
+
if (typeof stale?.content === 'string') await this.removeTrashBlob(stale.content)
|
|
291
303
|
}
|
|
292
304
|
return entry
|
|
293
305
|
},
|
|
294
306
|
|
|
307
|
+
/**
|
|
308
|
+
* The per-path content one entry would need to be put back, or `[]` when the
|
|
309
|
+
* entry carries none (an archive written before this existed, or one whose
|
|
310
|
+
* content was deliberately not kept).
|
|
311
|
+
*
|
|
312
|
+
* @param {object} entry
|
|
313
|
+
* @returns {Promise<object[]>}
|
|
314
|
+
*/
|
|
315
|
+
async readTrashRestore(entry) {
|
|
316
|
+
const name = typeof entry?.content === 'string' && entry.content !== '' ? entry.content : `${entry?.id}.content.json`
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(await readFile(join(trashDir(), name), 'utf8'))
|
|
319
|
+
const files = Array.isArray(parsed?.files) ? parsed.files : []
|
|
320
|
+
return files.filter((file) => file !== null && typeof file === 'object' && typeof file.path === 'string')
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error?.code !== 'ENOENT') warn(`dsh-gitbar-lite: reading trash content failed: ${String(error?.message ?? error)}`)
|
|
323
|
+
return []
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
|
|
295
327
|
/** One entry's metadata, or `undefined`. */
|
|
296
328
|
async readTrashEntry(id) {
|
|
297
329
|
const entries = await this.readTrash()
|
|
@@ -349,7 +381,7 @@ export function createStore(ctx, config) {
|
|
|
349
381
|
await writeJson(trashIndexPath(), { version: STATE_VERSION, entries: [] })
|
|
350
382
|
try {
|
|
351
383
|
for (const name of await readdir(trashDir())) {
|
|
352
|
-
if (name.endsWith('.diff')) await this.removeTrashBlob(name)
|
|
384
|
+
if (name.endsWith('.diff') || name.endsWith('.content.json')) await this.removeTrashBlob(name)
|
|
353
385
|
}
|
|
354
386
|
} catch (error) {
|
|
355
387
|
if (error?.code !== 'ENOENT') warn(`dsh-gitbar-lite: sweeping trash failed: ${String(error?.message ?? error)}`)
|
package/lib/shared.js
CHANGED
|
@@ -46,6 +46,14 @@ export const ACTIONS = {
|
|
|
46
46
|
TRASH_DELETE: 'trashDelete',
|
|
47
47
|
/** Drop every archived entry. */
|
|
48
48
|
TRASH_CLEAR: 'trashClear',
|
|
49
|
+
/**
|
|
50
|
+
* Put one archived entry's changes back into the working tree.
|
|
51
|
+
*
|
|
52
|
+
* Refused with `E_TRASH_NOT_RESTORABLE` when the files it touched have moved on
|
|
53
|
+
* since the discard, because writing back then would silently destroy work
|
|
54
|
+
* that happened after the archive was taken.
|
|
55
|
+
*/
|
|
56
|
+
TRASH_RESTORE: 'trashRestore',
|
|
49
57
|
/** Read or write the small persisted UI preferences. */
|
|
50
58
|
PREFS: 'prefs',
|
|
51
59
|
}
|
|
@@ -70,6 +78,11 @@ export const ERROR_CODES = {
|
|
|
70
78
|
AUTH: 'E_AUTH',
|
|
71
79
|
PUSH_REJECTED: 'E_PUSH_REJECTED',
|
|
72
80
|
TRASH_NOT_FOUND: 'E_TRASH_NOT_FOUND',
|
|
81
|
+
/**
|
|
82
|
+
* The archive is real but the working tree has moved on since it was taken, so
|
|
83
|
+
* putting it back would overwrite something newer.
|
|
84
|
+
*/
|
|
85
|
+
TRASH_NOT_RESTORABLE: 'E_TRASH_NOT_RESTORABLE',
|
|
73
86
|
INTERNAL: 'E_INTERNAL',
|
|
74
87
|
}
|
|
75
88
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gitbar-lite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A GitHub Desktop-style Git panel for DSH: changed files, tick-to-stage, Summary + Description, Commit and Commit and Push, discard-to-trash, and diff preview, in the built-in right sidebar.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|