dsh-gitbar-lite 0.1.2 → 0.2.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/README.en.md CHANGED
@@ -23,6 +23,7 @@ The column is narrow (300px minimum, and a narrow viewport makes it full-screen)
23
23
  | Commit | Commits exactly what is ticked — because ticking wrote the index |
24
24
  | Commit and Push | Commits, then pushes. **A failed push never claims the commit failed**: the commit happened, and the panel says "committed `abc1234`, but the push failed" |
25
25
  | Context button | **Push** when local commits are unpushed, **Publish** when the branch has no upstream, **Fetch** otherwise |
26
+ | Automatic fetch | Every five minutes while the panel is visible and the repository has a remote (`autoFetchMs`, `0` to disable). Failures are silent — no error banner |
26
27
  | Discard | One file or all of them; the diff is archived *before* anything is restored, and the confirmation offers "delete for good" |
27
28
  | Trash | Its own tab. Every discard keeps a diff, recorded **against the commit it was taken from** (`abc1234 initial commit`) |
28
29
  | Diff preview | Double-click a file, or a trash entry, to open a diff tab in the column — **one tab, ever**, re-navigated in place |
@@ -137,12 +138,14 @@ The order is fixed and cannot be swapped:
137
138
  | `maxFileBytes` | `2097152` | Larger untracked files are listed but not read, archived, or diffed |
138
139
  | `trashLimit` | `200` | Trash entries kept; the oldest are dropped along with their diff files |
139
140
  | `scanDepth` | `3` | Directory depth the picker scans below the workspace root |
141
+ | `autoFetchMs` | `300000` | How often the panel fetches on its own; `0` disables it, leaving the manual button |
140
142
 
141
143
  Every field is volatile, so a change on the settings page takes effect without a restart.
142
144
 
143
145
  **Where state lives**: `<DSH_HOME>/dsh-gitbar-lite/` (falling back to `~/.dsh`) — `bindings.json` (workspace →
144
- repository), `prefs.json` (the two remembered choices), and `trash/index.json` plus `trash/<id>.diff`. Every write
145
- goes through a temp file and a rename, so a crash mid-write cannot truncate a file.
146
+ repository), `prefs.json` (the two remembered choices), `trash/index.json` plus `trash/<id>.diff`, and
147
+ `trash/<id>.content.json` (the raw contents a restore needs). Every write goes through a temp file and a rename, so a
148
+ crash mid-write cannot truncate a file.
146
149
 
147
150
  ## How it works
148
151
 
@@ -189,7 +192,7 @@ are in [`docs/CONTRACT.md`](./docs/CONTRACT.md).
189
192
 
190
193
  ```sh
191
194
  npm test # unit + real-git e2e + artifact self-check
192
- npm run test:e2e # the real-git suite alone (20 sections building their own fixtures, 241 assertions)
195
+ npm run test:e2e # the real-git suite alone (21 sections building their own fixtures, 263 assertions)
193
196
  npm run check # artifact self-check alone (catches boot-breaking mistakes without restarting DSH)
194
197
  npm run test:live # probe a running DSH (not part of npm test; needs the app running)
195
198
  ```
package/README.md CHANGED
@@ -23,6 +23,7 @@ DSH 的右栏本来就是一个常驻的标签区。Git 面板放这里,意味
23
23
  | Commit | 只提交勾上的(因为勾选写进了索引) |
24
24
  | Commit and Push | 提交后立刻推送;**推送失败不会说「提交失败」**——提交已经发生了,面板会明说「已提交 xxx,但推送失败」 |
25
25
  | 上下文按钮 | 有本地未推送提交时是 **Push**,没有上游时是 **Publish**,否则是 **Fetch** |
26
+ | 自动 Fetch | 面板可见且仓库有远端时,每 5 分钟自己 fetch 一次(`autoFetchMs` 可调,`0` 关掉)。失败是静默的,不弹错误 |
26
27
  | 舍弃更改 | 单个文件或「全部舍弃」;动手前先把这次改动的 diff 归档,确认框里可以勾「彻底删除」 |
27
28
  | 回收站 | 独立 tab。每次舍弃存一份 diff,并记下**是对哪个提交(`abc1234 初始提交`)取的 diff** |
28
29
  | diff 预览 | 双击文件,或点回收站里的一条,在右栏开一个 diff 标签页——**永远只占一个**,再点就复用 |
@@ -132,11 +133,13 @@ dsh plugin --profile <你的 profile> remove dsh-gitbar-lite
132
133
  | `maxFileBytes` | `2097152` | 比这大的未跟踪文件只列出来,不读内容、不归档、不出 diff |
133
134
  | `trashLimit` | `200` | 回收站保留多少条;超出丢弃最旧的连同它的 diff 文件 |
134
135
  | `scanDepth` | `3` | 「不是仓库时」扫描工作区找仓库的目录深度 |
136
+ | `autoFetchMs` | `300000` | 面板自己多久 fetch 一次;`0` = 关掉,只用手动按钮 |
135
137
 
136
138
  每个字段都是 volatile 的,所以在设置页改了立刻生效、不用重启。
137
139
 
138
140
  **状态存在哪**:`<DSH_HOME>/dsh-gitbar-lite/`(没有 `DSH_HOME` 就回落 `~/.dsh`)——
139
- `bindings.json`(工作区 → 仓库)、`prefs.json`(那两个勾选偏好)、`trash/index.json` + `trash/<id>.diff`。
141
+ `bindings.json`(工作区 → 仓库)、`prefs.json`(那两个勾选偏好)、`trash/index.json` + `trash/<id>.diff` +
142
+ `trash/<id>.content.json`(恢复用的原始内容)。
140
143
  都是先写临时文件再 rename,所以写到一半崩了也不会把文件截断。
141
144
 
142
145
  ## 工作原理
@@ -176,7 +179,7 @@ lib/client.js 浏览器半边:三个 tab 类型 + 面板/回收站/di
176
179
 
177
180
  ```sh
178
181
  npm test # 单测 + 真 git 的 e2e + 产物自检
179
- npm run test:e2e # 只跑真 git 的 e2e(20 个小节,各自造自己的夹具,241 项断言)
182
+ npm run test:e2e # 只跑真 git 的 e2e(21 个小节,各自造自己的夹具,263 项断言)
180
183
  npm run check # 只用产物自检(不启 DSH 也能发现会让 DSH 起不来的问题)
181
184
  npm run test:live # 探测一个正在运行的 DSH(不在 npm test 里,需要跑着应用)
182
185
  ```
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`, 241 assertions over 20 sections that build their own fixtures |
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
- const KIND_LETTER = {
390
- added: 'A',
391
- deleted: 'D',
392
- renamed: 'R',
393
- conflicted: '!',
394
- untracked: '?',
395
- modified: 'M',
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 single character git itself would print in `status --short`. */
399
- function statusLetter(file) {
400
- if (file === null || typeof file !== 'object') return 'M'
401
- if (file.conflicted === true) return '!'
402
- if (file.untracked === true) return '?'
403
- return KIND_LETTER[file.kind] ?? 'M'
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
- /** `IconArchiveOutlineRegular`. */
616
- const ArchiveGlyph = artwork(
617
- '0 0 16 16',
618
- [
619
- 'M13.5 2.5H2.5C1.94772 2.5 1.5 2.94772 1.5 3.5V4.5C1.5 5.05228 1.94772 5.5 2.5 5.5H13.5C14.0523 5.5 14.5 5.05228 14.5 4.5V3.5C14.5 2.94772 14.0523 2.5 13.5 2.5Z',
620
- 'M2.5 5.5V13.5C2.5 13.7652 2.60536 14.0196 2.79289 14.2071C2.98043 14.3946 3.23478 14.5 3.5 14.5H12.5C12.7652 14.5 13.0196 14.3946 13.2071 14.2071C13.3946 14.0196 13.5 13.7652 13.5 13.5V5.5',
621
- 'M6.5 9.5H9.5',
622
- ],
623
- [],
624
- )
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,23 @@ 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-letter{flex:none;width:13px;text-align:center;font-size:10.5px;font-weight:600;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
720
- '.gitbar-letter-m{color:var(--dsw-alias-state-warn-primary,#b45309)}',
721
- '.gitbar-letter-a{color:var(--dsw-alias-state-success-primary,#15803d)}',
722
- '.gitbar-letter-d{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
723
- '.gitbar-letter-r{color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
724
- '.gitbar-letter-u{color:var(--dsw-alias-label-tertiary,#6b7280)}',
725
- '.gitbar-letter-c{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
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}',
727
- '.gitbar-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
728
- '.gitbar-dir{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#6b7280)}',
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
+ // `direction` is inherited, so the rtl above would otherwise reach the
865
+ // separator (moving the slash to the wrong end) and the file name (right-
866
+ // aligning it). Both are spelled back to ltr explicitly.
867
+ '.gitbar-sep{flex:none;direction:ltr;color:var(--dsw-alias-label-tertiary,#6b7280)}',
868
+ '.gitbar-name{flex:0 1 auto;min-width:0;direction:ltr;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
729
869
  '.gitbar-stats{flex:none;display:flex;gap:3px;font-size:10.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
730
870
  '.gitbar-plus{color:var(--dsw-alias-state-success-primary,#15803d)}',
731
871
  '.gitbar-minus{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
@@ -787,6 +927,7 @@ window.__ModuleLoader__.load({
787
927
  '.gitbar-trashFiles{font-size:11px;color:var(--dsw-alias-label-tertiary,#6b7280);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
788
928
  '.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
929
  '.gitbar-toggle input{margin:0;accent-color:var(--dsw-alias-state-business-primary,#4d6bfe)}',
930
+ '.gitbar-trashWhy{display:block;font-size:10.5px;color:var(--dsw-alias-state-warn-primary,#92400e)}',
790
931
  '.gitbar-spacer{flex:1 1 auto}',
791
932
  '.gitbar-loading{padding:18px 12px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11.5px;display:flex;align-items:center;gap:6px}',
792
933
  '.gitbar-spin{display:inline-block;width:13px;height:13px;flex:none;animation:gitbar-spin .9s linear infinite}',
@@ -929,12 +1070,16 @@ window.__ModuleLoader__.load({
929
1070
  * 8. The panel
930
1071
  * ------------------------------------------------------------------ */
931
1072
 
932
- /** One changed file. */
1073
+ /** One changed file, laid out the way GitHub Desktop lays one out. */
933
1074
  function ChangeRow({ file, t, onToggle, onDiscard, onOpen }) {
934
- const letter = statusLetter(file)
935
- const letterClass =
936
- letter === 'M' ? 'm' : letter === 'A' ? 'a' : letter === 'D' ? 'd' : letter === 'R' ? 'r' : letter === '?' ? 'u' : 'c'
1075
+ const kind = statusKind(file)
1076
+ const Mark = statusGlyph(kind)
937
1077
  const parts = splitPath(file.path)
1078
+ // Desktop renders the directory *before* the name and lets the directory be
1079
+ // the part that truncates, so the name survives. The separator is its own
1080
+ // node because `direction:rtl` (how the directory gets a leading ellipsis)
1081
+ // would move a trailing slash to the other end of the text.
1082
+ const dir = parts.dir.endsWith('/') ? parts.dir.slice(0, -1) : parts.dir
938
1083
  const display = file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`
939
1084
 
940
1085
  return h(
@@ -943,6 +1088,7 @@ window.__ModuleLoader__.load({
943
1088
  className: 'gitbar-row',
944
1089
  'data-gitbar-row': file.path,
945
1090
  'data-gitbar-staged': file.staged === true ? 'true' : 'false',
1091
+ 'data-gitbar-kind': kind,
946
1092
  title: display,
947
1093
  onDoubleClick: () => onOpen(file.path),
948
1094
  },
@@ -964,10 +1110,10 @@ window.__ModuleLoader__.load({
964
1110
  },
965
1111
  file.staged === true && !file.partial ? h(CheckGlyph, { size: 11 }) : file.partial === true ? h('span', {}) : null,
966
1112
  ),
967
- h('span', { key: 'letter', className: `gitbar-letter gitbar-letter-${letterClass}` }, letter),
968
1113
  h('span', { key: 'path', className: 'gitbar-path' }, [
1114
+ dir === '' ? null : h('span', { key: 'dir', className: 'gitbar-dir' }, dir),
1115
+ dir === '' ? null : h('span', { key: 'sep', className: 'gitbar-sep' }, '/'),
969
1116
  h('span', { key: 'name', className: 'gitbar-name' }, parts.name),
970
- parts.dir === '' ? null : h('span', { key: 'dir', className: 'gitbar-dir' }, ` ${parts.dir}`),
971
1117
  ]),
972
1118
  h('span', { key: 'stats', className: 'gitbar-stats' }, [
973
1119
  (file.added ?? 0) > 0 ? h('span', { key: 'p', className: 'gitbar-plus' }, `+${file.added}`) : null,
@@ -989,6 +1135,19 @@ window.__ModuleLoader__.load({
989
1135
  },
990
1136
  h(TrashGlyph, { size: 14 }),
991
1137
  ),
1138
+ // Last in the row, as in Desktop: the checkbox and the path start the
1139
+ // row, the mark ends it.
1140
+ h(
1141
+ 'span',
1142
+ {
1143
+ key: 'mark',
1144
+ className: `gitbar-status gitbar-status-${kind}`,
1145
+ 'data-gitbar-status': kind,
1146
+ title: t(`status.${kind}`),
1147
+ 'aria-label': t(`status.${kind}`),
1148
+ },
1149
+ h(Mark, { size: 13 }),
1150
+ ),
992
1151
  ],
993
1152
  )
994
1153
  }
@@ -1178,6 +1337,50 @@ window.__ModuleLoader__.load({
1178
1337
  }
1179
1338
  }, [tab.visible, sessionId, load])
1180
1339
 
1340
+ // Fetch on a rhythm, unattended, while the panel is actually being looked
1341
+ // at. Three conditions gate it, and each one exists for a reason:
1342
+ //
1343
+ // * the tab must be visible — a hidden panel has no business touching the
1344
+ // network every few minutes, and the host may have several sessions;
1345
+ // * the repository must have a remote — otherwise `fetch` is a guaranteed
1346
+ // E_NO_REMOTE, and a timer that only ever fails is noise;
1347
+ // * nothing else may be in flight — a user mid-commit must not have an
1348
+ // extra git child appear under them.
1349
+ //
1350
+ // A failure is silent. The button stays there, the banner stays quiet, and
1351
+ // the next tick tries again: a fetch that failed once (offline, credential
1352
+ // prompt suppressed) usually succeeds later, and a panel that pops an error
1353
+ // every five minutes for a laptop that was closed is worse than one that
1354
+ // says nothing. Success refreshes the status, which is what moves
1355
+ // ahead/behind and the "last fetch" line.
1356
+ //
1357
+ // The conditions are read through a ref rather than listed as dependencies
1358
+ // on purpose: a dependency on `state` would restart the clock on every
1359
+ // refresh, and a panel the user keeps interacting with would then never
1360
+ // fetch at all.
1361
+ const latest = useRef({ state: null, busy: null })
1362
+ latest.current = { state, busy }
1363
+
1364
+ const autoFetchMs = typeof state?.autoFetchMs === 'number' && state.autoFetchMs > 0 ? state.autoFetchMs : 0
1365
+ useEffect(() => {
1366
+ if (autoFetchMs === 0) return undefined
1367
+ if (tab.visible === false) return undefined
1368
+
1369
+ const timer = setInterval(() => {
1370
+ const current = latest.current
1371
+ if (current.busy !== null) return
1372
+ if (!Array.isArray(current.state?.remotes) || current.state.remotes.length === 0) return
1373
+ void rpc(ACTIONS.FETCH, { sessionId }, tab.signal)
1374
+ .then(() => {
1375
+ if (alive.current) void load({ quiet: true })
1376
+ })
1377
+ .catch(() => {
1378
+ // Deliberately swallowed: see the note above.
1379
+ })
1380
+ }, autoFetchMs)
1381
+ return () => clearInterval(timer)
1382
+ }, [autoFetchMs, tab.visible, tab.signal, sessionId, load])
1383
+
1181
1384
  // The column's refresh shortcut, when it has one.
1182
1385
  useEffect(
1183
1386
  () => (typeof tab.actions?.bindCommands === 'function' ? tab.actions.bindCommands({ refresh: () => void load({ quiet: true }) }) : undefined),
@@ -1234,6 +1437,29 @@ window.__ModuleLoader__.load({
1234
1437
  [run],
1235
1438
  )
1236
1439
 
1440
+ // Everything changed is ticked by default, which is what GitHub Desktop
1441
+ // does and what the panel is asked to match. Our tick *is* the index, so
1442
+ // "ticked by default" has to mean staging every changed path once: the
1443
+ // first time the panel looks at a repository whose index is empty. A file
1444
+ // the user unticks leaves the index non-empty, so this cannot fight a
1445
+ // deliberate choice — and the ref guard means a failure does not loop.
1446
+ //
1447
+ // Conflicted paths are excluded on purpose: `git add` on a conflicted file
1448
+ // records a resolution, and nothing has been resolved at this point.
1449
+ const autoStaged = useRef('')
1450
+ useEffect(() => {
1451
+ if (state === null || phase !== 'ready' || busy !== null) return
1452
+ if (state.repoRoot === null || state.repoRoot === undefined) return
1453
+ const files = Array.isArray(state.files) ? state.files : []
1454
+ const auto = files.filter((file) => file.conflicted !== true).map((file) => file.path)
1455
+ if (auto.length === 0) return
1456
+ if ((state.counts?.staged ?? 0) > 0) return
1457
+ const key = `${state.repoRoot}\u0000${auto.join('\u0000')}`
1458
+ if (autoStaged.current === key) return
1459
+ autoStaged.current = key
1460
+ void run('stage', ACTIONS.STAGE, { paths: auto, staged: true })
1461
+ }, [state, phase, busy, run])
1462
+
1237
1463
  const openDiff = useCallback(
1238
1464
  (path) => {
1239
1465
  setNotice(null)
@@ -1398,16 +1624,21 @@ window.__ModuleLoader__.load({
1398
1624
  disabled: busy !== null,
1399
1625
  onClick: runContext,
1400
1626
  className: 'gitbar-contextBtn',
1401
- attrs: { 'data-gitbar-context': state.primary ?? 'fetch', style: { width: 'auto', padding: '0 8px', fontWeight: 500 } },
1627
+ attrs: { 'data-gitbar-context': state.primary ?? 'fetch' },
1402
1628
  },
1403
1629
  h('span', { style: { whiteSpace: 'nowrap' } }, t(contextLabelKey(state))),
1404
1630
  ),
1631
+ h(
1632
+ 'span',
1633
+ { key: 'last', className: 'gitbar-lastFetch', 'data-gitbar-lastfetch': state.lastFetchAt ?? '' },
1634
+ lastFetchLabel(state, Date.now(), t),
1635
+ ),
1405
1636
  h(
1406
1637
  IconButton,
1407
1638
  { key: 'refresh', label: t('action.refresh'), disabled: busy !== null, onClick: () => void load({ quiet: true }) },
1408
1639
  h(RefreshGlyph, {}),
1409
1640
  ),
1410
- h(IconButton, { key: 'trash', label: t('action.trash'), onClick: openTrash }, h(ArchiveGlyph, {})),
1641
+ h(IconButton, { key: 'trash', label: t('action.trash'), onClick: openTrash }, h(TrashGlyph, {})),
1411
1642
  h(
1412
1643
  IconButton,
1413
1644
  { key: 'rebind', label: t('action.rebind'), disabled: busy !== null, onClick: () => bind(null) },
@@ -1506,7 +1737,7 @@ window.__ModuleLoader__.load({
1506
1737
  {
1507
1738
  key: 'commit',
1508
1739
  type: 'button',
1509
- className: 'gitbar-btn gitbar-btn-primary',
1740
+ className: 'gitbar-btn',
1510
1741
  title: canCommit ? '' : commitHint,
1511
1742
  disabled: !canCommit,
1512
1743
  'data-gitbar-commit': '',
@@ -1519,7 +1750,7 @@ window.__ModuleLoader__.load({
1519
1750
  {
1520
1751
  key: 'push',
1521
1752
  type: 'button',
1522
- className: 'gitbar-btn',
1753
+ className: 'gitbar-btn gitbar-btn-primary',
1523
1754
  title: canCommit ? '' : commitHint,
1524
1755
  disabled: !canCommit,
1525
1756
  'data-gitbar-commitpush': '',
@@ -1611,6 +1842,25 @@ window.__ModuleLoader__.load({
1611
1842
  [sessionId, load, tab.signal],
1612
1843
  )
1613
1844
 
1845
+ // Restoring is offered only when the working tree still matches what the
1846
+ // discard left behind, which the host decides and reports per entry. The
1847
+ // check is re-run server side inside the action, so a row that renders as
1848
+ // enabled cannot fail and a row that renders as disabled cannot lie.
1849
+ const restoreEntry = useCallback(
1850
+ async (entry) => {
1851
+ setBusy(entry.id)
1852
+ try {
1853
+ await rpc(ACTIONS.TRASH_RESTORE, { sessionId, id: entry.id }, tab.signal)
1854
+ await load({ quiet: true })
1855
+ } catch (failure) {
1856
+ if (failure?.name !== 'AbortError') setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1857
+ } finally {
1858
+ setBusy(null)
1859
+ }
1860
+ },
1861
+ [sessionId, load, tab.signal],
1862
+ )
1863
+
1614
1864
  const clearAll = useCallback(async () => {
1615
1865
  setConfirmClear(false)
1616
1866
  setBusy('clear')
@@ -1733,8 +1983,28 @@ window.__ModuleLoader__.load({
1733
1983
  { key: 'files', className: 'gitbar-trashFiles', title: (entry.files ?? []).map((file) => file.path).join('\n') },
1734
1984
  (entry.files ?? []).map((file) => shortPath(file.path)).slice(0, 3).join(' · '),
1735
1985
  ),
1986
+ // Why restoring is off, when it is: the archive outlived the
1987
+ // state it was taken from.
1988
+ entry.restorable?.ok === true
1989
+ ? null
1990
+ : h('span', { key: 'why', className: 'gitbar-trashWhy' }, restoreReasonText(t, entry.restorable?.reason)),
1736
1991
  ]),
1737
1992
  ),
1993
+ h(
1994
+ 'span',
1995
+ { key: 'act', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', padding: '7px 4px 0 0' } },
1996
+ h(
1997
+ IconButton,
1998
+ {
1999
+ key: 'restore',
2000
+ label: t('action.restore'),
2001
+ disabled: busy !== null || entry.restorable?.ok !== true,
2002
+ onClick: () => void restoreEntry(entry),
2003
+ attrs: { 'data-gitbar-restore': entry.id },
2004
+ },
2005
+ h(RestoreGlyph, { size: 14 }),
2006
+ ),
2007
+ ),
1738
2008
  h(
1739
2009
  'span',
1740
2010
  { key: 'del', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', padding: '7px 6px 0 0' } },
@@ -1913,7 +2183,7 @@ window.__ModuleLoader__.load({
1913
2183
  function TrashTitle(props) {
1914
2184
  const t = props.t ?? fallbackTranslate()
1915
2185
  return h('span', { className: 'gitbar-tabTitle' }, [
1916
- h(ArchiveGlyph, { key: 'g', size: 14 }),
2186
+ h(TrashGlyph, { key: 'g', size: 14 }),
1917
2187
  h('span', { key: 'l', className: 'gitbar-tabTitleText' }, t('trash.title')),
1918
2188
  ])
1919
2189
  }
@@ -2129,9 +2399,12 @@ window.__ModuleLoader__.load({
2129
2399
  ZH,
2130
2400
  EN,
2131
2401
  diffRows,
2132
- statusLetter,
2402
+ statusKind,
2403
+ statusGlyph,
2133
2404
  splitPath,
2134
2405
  formatRelative,
2406
+ lastFetchLabel,
2407
+ restoreReasonText,
2135
2408
  stagedCount,
2136
2409
  hasConflict,
2137
2410
  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
  *
@@ -184,6 +206,13 @@ export function classifyGitFailure(error, fallbackCode = ERROR_CODES.GIT_FAILED)
184
206
  * @param {object} config resolved plugin config
185
207
  */
186
208
  export function createRepo(git, config) {
209
+ /**
210
+ * How often the panel should fetch on its own, in milliseconds, or 0 for
211
+ * "never". Resolved once: the value rides along with every status payload,
212
+ * because a client bundle cannot read the host's config.
213
+ */
214
+ const autoFetchMs = Number.isFinite(config.autoFetchMs) && config.autoFetchMs > 0 ? Math.floor(config.autoFetchMs) : 0
215
+
187
216
  /**
188
217
  * Identity, branch and working-tree status of one repository.
189
218
  *
@@ -191,14 +220,18 @@ export function createRepo(git, config) {
191
220
  * @param {AbortSignal} [signal]
192
221
  */
193
222
  async function readStatus(repoRoot, signal) {
194
- // The four probes are independent, so they share one round of latency.
223
+ // The probes are independent, so they share one round of latency.
195
224
  // `GIT_OPTIONAL_LOCKS=0` (set by the runner) is what makes the index reads
196
225
  // safe to overlap.
197
- const [statusResult, numstatResult, remotesResult, headResult] = await Promise.all([
226
+ const [statusResult, numstatResult, remotesResult, headResult, gitDirResult] = await Promise.all([
198
227
  git.run(['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { cwd: repoRoot, signal }),
199
228
  git.run(['diff', '--numstat', '-z', '--find-renames', 'HEAD'], { cwd: repoRoot, signal }),
200
229
  git.run(['remote'], { cwd: repoRoot, signal }),
201
230
  git.run(['log', '-1', '--pretty=%H%x00%h%x00%s'], { cwd: repoRoot, signal }),
231
+ // Where `.git` really is, so FETCH_HEAD can be found without assuming a
232
+ // plain directory: a worktree's git directory lives somewhere else, and a
233
+ // submodule's is inside the parent's.
234
+ git.run(['rev-parse', '--absolute-git-dir'], { cwd: repoRoot, signal }),
202
235
  ])
203
236
 
204
237
  if (statusResult.exitCode !== 0) {
@@ -263,6 +296,12 @@ export function createRepo(git, config) {
263
296
  ? PRIMARY_ACTIONS.PUBLISH
264
297
  : PRIMARY_ACTIONS.FETCH
265
298
 
299
+ // When the last fetch happened, asked of git rather than remembered by this
300
+ // plugin: `git fetch` (and `git pull`) rewrites FETCH_HEAD every time, so a
301
+ // fetch run in a terminal counts as well, and there is no state of ours to
302
+ // lose or to disagree with the repository.
303
+ const lastFetchAt = await fetchTimeOf(gitDirResult)
304
+
266
305
  return {
267
306
  repoRoot,
268
307
  repoName: basename(repoRoot),
@@ -272,6 +311,11 @@ export function createRepo(git, config) {
272
311
  files: enriched,
273
312
  counts,
274
313
  primary,
314
+ lastFetchAt,
315
+ // The panel cannot read the host's config (a client bundle has no access to
316
+ // it), so the rhythm it should fetch on travels with the status it is
317
+ // already reading. Zero means "never, on your own".
318
+ autoFetchMs,
275
319
  }
276
320
  }
277
321
 
@@ -587,6 +631,13 @@ export function createRepo(git, config) {
587
631
  }
588
632
  })
589
633
 
634
+ // What it would take to put this back. The content is read now, before
635
+ // anything is destroyed, because the archive is the only copy left: the
636
+ // diff alone cannot be replayed into a file. A path whose content cannot be
637
+ // carried (too large, or binary, or the diff was truncated) is recorded as
638
+ // unrestorable with a reason, so the UI can say why instead of failing.
639
+ const restorable = await buildRestorable(repoRoot, chosen, detailByPath, signal)
640
+
590
641
  entry = await archive(
591
642
  {
592
643
  repoRoot,
@@ -600,6 +651,7 @@ export function createRepo(git, config) {
600
651
  bytes: Buffer.byteLength(limited.text, 'utf8'),
601
652
  truncated: limited.truncated,
602
653
  droppedLines: limited.droppedLines,
654
+ restorable,
603
655
  },
604
656
  limited.text,
605
657
  )
@@ -725,5 +777,171 @@ export function createRepo(git, config) {
725
777
  return { scanned: visited, truncated: queue.length > 0, repos: found }
726
778
  }
727
779
 
728
- return { readStatus, statusOf, setStaged, commit, push, fetch, fileDiff, discard, scanRepos }
780
+ /**
781
+ * What each discarded path would need in order to be put back.
782
+ *
783
+ * The content is carried as base64 because it is bytes, not text: a file with
784
+ * CRLF endings, a latin-1 comment, or a NUL byte survives a round trip through
785
+ * a JSON string only by accident, and this is the last copy of it. The size cap
786
+ * is the same one the diff is capped by, so an archive that says "truncated"
787
+ * never promises a restore it cannot perform.
788
+ *
789
+ * @param {string} repoRoot
790
+ * @param {readonly object[]} chosen the status entries being discarded
791
+ * @param {Map<string, object>} detailByPath the already-read untracked details
792
+ * @param {AbortSignal} [signal]
793
+ */
794
+ async function buildRestorable(repoRoot, chosen, detailByPath, signal) {
795
+ const payload = []
796
+ for (const file of chosen) {
797
+ const detail = file.untracked === true ? detailByPath.get(file.path) : undefined
798
+ const absolute = absoluteIn(repoRoot, file.path)
799
+
800
+ let bytes
801
+ try {
802
+ bytes = await readFile(absolute)
803
+ } catch {
804
+ // The worktree copy is gone (a deletion). There is nothing to carry: the
805
+ // "before" content lives in HEAD, and restoring means taking it back from
806
+ // there, which is what `git restore` already did.
807
+ payload.push({ path: file.path, content: null, mode: null, restorable: false, reason: 'missing' })
808
+ continue
809
+ }
810
+
811
+ if (bytes.length > config.maxFileBytes) {
812
+ payload.push({ path: file.path, content: null, mode: null, restorable: false, reason: 'oversized' })
813
+ continue
814
+ }
815
+
816
+ const binary = file.untracked === true ? detail?.binary === true : looksBinary(bytes)
817
+ if (binary) {
818
+ // Binary content is carried too — it is bytes either way — but the panel
819
+ // will not pretend it can preview it.
820
+ payload.push({
821
+ path: file.path,
822
+ content: bytes.toString('base64'),
823
+ mode: file.untracked === true ? detail?.mode ?? '100644' : null,
824
+ restorable: true,
825
+ binary: true,
826
+ })
827
+ continue
828
+ }
829
+
830
+ payload.push({
831
+ path: file.path,
832
+ content: bytes.toString('base64'),
833
+ mode: file.untracked === true ? detail?.mode ?? '100644' : null,
834
+ restorable: true,
835
+ binary: false,
836
+ })
837
+ }
838
+ return payload
839
+ }
840
+
841
+ /**
842
+ * Whether one archived discard could be put back right now.
843
+ *
844
+ * The question the user asked is exactly this one: *if the version the archive
845
+ * was based on is still the current version, restoring is offered; otherwise it
846
+ * is not.* The comparison is one `git diff --quiet HEAD -- <paths>` per entry,
847
+ * which is also the first thing `restore` checks — so a button that says it is
848
+ * enabled cannot turn into a refusal.
849
+ *
850
+ * @param {string} repoRoot
851
+ * @param {object} entry the trash entry
852
+ * @param {readonly object[]} payload its `restorable` array
853
+ * @param {AbortSignal} [signal]
854
+ * @returns {Promise<{ ok: boolean, reason?: string }>}
855
+ */
856
+ async function canRestore(repoRoot, entry, payload, signal) {
857
+ if (!Array.isArray(payload) || payload.length === 0) return { ok: false, reason: 'no-content' }
858
+ if (payload.every((item) => item.restorable !== true)) return { ok: false, reason: 'no-content' }
859
+
860
+ // An entry recorded against a different repository cannot be restored into
861
+ // this one: the paths mean something else there.
862
+ if (typeof entry?.repoRoot === 'string' && entry.repoRoot !== '' && resolve(entry.repoRoot) !== resolve(repoRoot)) {
863
+ return { ok: false, reason: 'other-repo' }
864
+ }
865
+
866
+ const pathspec = payload.map((item) => literal(item.path))
867
+ const untouched = await git.run(['diff', '--quiet', 'HEAD', '--', ...pathspec], { cwd: repoRoot, signal })
868
+ if (untouched.exitCode === 0) return { ok: true }
869
+ if (untouched.exitCode === 1) return { ok: false, reason: 'changed' }
870
+ return { ok: false, reason: 'unknown' }
871
+ }
872
+
873
+ /**
874
+ * Put one archived discard back into the working tree.
875
+ *
876
+ * Refused, with nothing written, unless the working tree still matches what the
877
+ * discard produced: every file the archive touched must be identical to its
878
+ * HEAD version (or, for a file the discard deleted, still absent). Anything
879
+ * else means work happened after the archive was taken, and writing over it
880
+ * would be the one operation this plugin must never do.
881
+ *
882
+ * A partially matching entry is refused as a whole rather than restored file by
883
+ * file: an all-or-nothing discard restored half-way is a tree nobody can
884
+ * reason about.
885
+ *
886
+ * @param {string} repoRoot
887
+ * @param {object} entry the trash entry
888
+ * @param {readonly object[]} payload its `restorable` array
889
+ * @param {AbortSignal} [signal]
890
+ * @returns {Promise<{ restored: string[], skipped: string[] }>}
891
+ */
892
+ async function restore(repoRoot, entry, payload, signal) {
893
+ if (!Array.isArray(payload) || payload.length === 0) {
894
+ throw new RepoError(ERROR_CODES.TRASH_NOT_RESTORABLE, '这条记录没有可恢复的内容(舍弃时没留下副本)。')
895
+ }
896
+
897
+ const entries = Array.isArray(entry?.files) ? entry.files : []
898
+ if (entries.length !== payload.length) {
899
+ throw new RepoError(ERROR_CODES.TRASH_NOT_RESTORABLE, '这条记录的内容和文件列表不一致,无法安全恢复。')
900
+ }
901
+
902
+ // The one check that decides everything: does the working tree still look the
903
+ // way the discard left it? Reusing `canRestore` means the answer the button
904
+ // was rendered from is the answer that is enforced here — a disabled button
905
+ // cannot turn into a refusal, and an enabled one cannot surprise.
906
+ const eligibility = await canRestore(repoRoot, entry, payload, signal)
907
+ if (!eligibility.ok) {
908
+ const message =
909
+ eligibility.reason === 'changed'
910
+ ? '这些文件在舍弃之后又被改过了,恢复会盖掉后来的改动。要恢复请先提交或再舍弃一次。'
911
+ : eligibility.reason === 'other-repo'
912
+ ? '这条记录属于另一个仓库,不能恢复到当前仓库。'
913
+ : '这条记录里没有可以恢复的内容。'
914
+ throw new RepoError(ERROR_CODES.TRASH_NOT_RESTORABLE, message)
915
+ }
916
+
917
+ const restored = []
918
+ const skipped = []
919
+ for (const item of payload) {
920
+ if (item.restorable !== true || typeof item.content !== 'string' || item.content === '') {
921
+ skipped.push(item.path)
922
+ continue
923
+ }
924
+ const absolute = absoluteIn(repoRoot, item.path)
925
+ await mkdir(dirname(absolute), { recursive: true })
926
+ await writeFile(absolute, Buffer.from(item.content, 'base64'))
927
+
928
+ // The content is back on disk but the index is not: staging it restores the
929
+ // tick the user had, which is the state the panel was showing when they
930
+ // asked to discard. Untracked files are staged as well, because a discard
931
+ // that archived them removed them entirely.
932
+ const added = await git.run(['add', '--', literal(item.path)], { cwd: repoRoot, signal })
933
+ if (added.exitCode !== 0) {
934
+ throw classifyGitFailure({ ...added, message: `git add failed: ${added.stderr.trim()}` })
935
+ }
936
+ restored.push(item.path)
937
+ }
938
+
939
+ if (restored.length === 0) {
940
+ throw new RepoError(ERROR_CODES.TRASH_NOT_RESTORABLE, '这条记录里没有任何可以恢复的内容。')
941
+ }
942
+
943
+ return { restored, skipped }
944
+ }
945
+
946
+ return { readStatus, statusOf, setStaged, commit, push, fetch, fileDiff, discard, restore, canRestore, scanRepos }
729
947
  }
@@ -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
- return {
321
- workspacePath: workspace.cwd,
322
- repoRoot: binding.repoRoot ?? null,
323
- showAll,
324
- total: all.length,
325
- entries: filtered.map((entry) => ({
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/index.js CHANGED
@@ -67,6 +67,15 @@ export const Config = vol(
67
67
  trashLimit: z.number().default(200),
68
68
  /** Directory depth the repository picker scans below the workspace root. */
69
69
  scanDepth: z.number().default(3),
70
+ /**
71
+ * How often the panel fetches on its own, in milliseconds.
72
+ *
73
+ * A fetch only moves remote refs, never the working tree, so it is safe to
74
+ * do unattended — but it does hit the network and it does write
75
+ * `.git/FETCH_HEAD`, which is why it is opt-out rather than opt-in. Set to 0
76
+ * to disable it; the button still works.
77
+ */
78
+ autoFetchMs: z.number().default(300000),
70
79
  }),
71
80
  )
72
81
 
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.1.2",
3
+ "version": "0.2.1",
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",