@young1lin/dsh-ui-gitworkbench 0.1.4 → 0.1.6

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CHANGELOG_EN.md +42 -0
  3. package/lib/apply-blocks.js +159 -0
  4. package/lib/atomic-json.js +23 -5
  5. package/lib/blame.js +83 -0
  6. package/lib/client.js +34811 -11818
  7. package/lib/fs-remove.js +73 -0
  8. package/lib/git-ops.js +25 -0
  9. package/lib/image-sniff.js +197 -0
  10. package/lib/index.js +404 -32
  11. package/lib/patch-model.js +223 -0
  12. package/lib/side-guard.js +55 -0
  13. package/lib/write-checked.js +164 -0
  14. package/package.json +7 -1
  15. package/src/apply-blocks.ts +215 -0
  16. package/src/atomic-json.ts +29 -5
  17. package/src/blame.ts +94 -0
  18. package/src/client/CodeEditor.tsx +317 -0
  19. package/src/client/FileBrowser.tsx +657 -0
  20. package/src/client/GitWorkbenchPanel.module.css +453 -7
  21. package/src/client/GitWorkbenchPanel.tsx +1465 -166
  22. package/src/client/ImageView.tsx +120 -0
  23. package/src/client/blame-gutter.ts +108 -0
  24. package/src/client/blame-view.ts +104 -0
  25. package/src/client/cm-diff.ts +108 -0
  26. package/src/client/cm-tokens.ts +79 -0
  27. package/src/client/diff-nav.ts +198 -0
  28. package/src/client/discard-flow.ts +82 -0
  29. package/src/client/file-icon.ts +190 -0
  30. package/src/client/file-rows.ts +184 -0
  31. package/src/client/files-place.ts +178 -0
  32. package/src/client/glyphs.tsx +86 -0
  33. package/src/client/highlight.ts +25 -0
  34. package/src/client/idle-value.ts +53 -0
  35. package/src/client/image-view.ts +106 -0
  36. package/src/client/indent.ts +74 -0
  37. package/src/client/index.ts +76 -9
  38. package/src/client/locales.ts +171 -4
  39. package/src/client/pane-size.ts +71 -0
  40. package/src/client/side-edit.ts +244 -0
  41. package/src/client/side-rows.ts +258 -0
  42. package/src/client/stable-list.ts +31 -0
  43. package/src/client/use-change-nav.ts +83 -0
  44. package/src/client/worktree-view.ts +11 -1
  45. package/src/fs-remove.ts +76 -0
  46. package/src/git-ops.ts +36 -1
  47. package/src/image-sniff.ts +204 -0
  48. package/src/index.ts +450 -32
  49. package/src/patch-model.ts +267 -0
  50. package/src/side-guard.ts +58 -0
  51. package/src/write-checked.ts +223 -0
@@ -22,10 +22,18 @@ export type WorkbenchKey =
22
22
  | 'aheadTitle' | 'behindTitle' | 'files'
23
23
  | 'filterFiles' | 'filterFilesPlaceholder' | 'filterFilesClear' | 'filesFiltered' | 'filterNoMatch'
24
24
  | 'drawerLabel' | 'totalsDim' | 'refresh' | 'close'
25
- | 'tabsLabel' | 'tabChanges' | 'tabHistory' | 'tabCompare'
25
+ | 'tabsLabel' | 'tabChanges' | 'tabHistory' | 'tabCompare' | 'tabFiles'
26
+ // the Files tab: browse the repository, read a file, blame it, edit it
27
+ | 'fileSearchPlaceholder' | 'filesTruncated' | 'filesEmpty' | 'filesNoMatch' | 'filesPick'
28
+ | 'filesUnsavedAsk' | 'filesDiscardOpen' | 'filesMore' | 'filesVanished' | 'fileReadOnlyCrlf' | 'fileReadOnlyEncoding'
29
+ | 'blameWhileEditing' | 'blameLine' | 'blamePick' | 'blameInHistory' | 'paintTooLarge'
30
+ | 'imageBroken' | 'imageFit' | 'imageActual' | 'imageTooLarge' | 'imageSource' | 'imagePreview'
31
+ | 'prevChange' | 'nextChange' | 'prevChangeHint' | 'nextChangeHint' | 'changeCount'
26
32
  | 'sourceLabel' | 'workingTree'
27
33
  | 'loadingCommit' | 'renamedFrom' | 'binaryFile' | 'loadingDiff' | 'noTextDiff'
28
34
  | 'noCommits' | 'historyLabel' | 'historyEnd' | 'loading' | 'maximize' | 'restore'
35
+ // side-by-side diff pane: the two layer tabs, and the size-guard notice
36
+ | 'tabUnstaged' | 'tabStaged' | 'diffTooLarge'
29
37
  | 'commitAuthor' | 'commitCommitter' | 'commitDate' | 'historyFilterPlaceholder' | 'historyNoMatch'
30
38
  | 'filterClearAll' | 'filterBy' | 'filterUsers' | 'filterUserSearch' | 'filterAuthorsMore'
31
39
  | 'filterDate' | 'filterToday' | 'filterLast7' | 'filterLast30' | 'filterAfter' | 'filterBefore'
@@ -38,7 +46,7 @@ export type WorkbenchKey =
38
46
  | 'scopeProject' | 'scopeGlobal' | 'scopeGlobalHint' | 'scopeNoRepo'
39
47
  | 'bgNone' | 'bgChoose' | 'bgClear' | 'bgBlur' | 'bgVeil' | 'bgWorking' | 'bgFailed' | 'bgTooBig'
40
48
  | 'cssPlaceholder' | 'cssImport' | 'cssApply' | 'cssUnapplied' | 'styleFailed'
41
- | 'resizeLabel' | 'resizeCommits' | 'resizeTree'
49
+ | 'resizeLabel' | 'resizeCommits' | 'resizeTree' | 'resizeSides'
42
50
  | 'expandAll' | 'collapseAll' | 'noBranch'
43
51
  | 'copyCommit' | 'copiedCommit'
44
52
  // write operations
@@ -48,11 +56,22 @@ export type WorkbenchKey =
48
56
  | 'stage' | 'unstage' | 'stageAll' | 'unstageAll' | 'stagedCount'
49
57
  | 'commit' | 'amend' | 'commitPlaceholder' | 'commitNeedMessage' | 'commitLead'
50
58
  | 'op.ok.stage' | 'op.ok.unstage' | 'op.ok.commit' | 'op.ok.fetch' | 'op.ok.pull' | 'op.ok.push'
51
- | 'op.ok.discardFile'
59
+ | 'op.ok.discardFile' | 'op.ok.applyBlocks'
52
60
  | 'discardAction' | 'discardTitle' | 'discardConfirm' | 'discardCancel'
53
61
  | 'discardBodyRestore' | 'discardBodyDelete' | 'discardBodyUnrename'
62
+ // side-by-side block actions: the three buttons and the roll-back confirmation's wording
63
+ | 'blockStage' | 'blockDiscard' | 'blockUnstage' | 'blockDiscardBody' | 'blockDiscardBodyDelete'
64
+ // side-by-side editing: arm the editor, save, revert, the stale/conflict
65
+ // banner, the CRLF refusal notice, and the unsaved-changes prompt that
66
+ // guards every gesture dropping the buffer (tab, file, close)
67
+ | 'editFile' | 'fileSave' | 'fileRevert' | 'editingNotice' | 'crlfNotice' | 'encodingNotice'
68
+ // blame gutter on the working-tree column
69
+ | 'blameToggle' | 'blameHint' | 'blameUncommitted' | 'blameFailed' | 'blameTruncated'
70
+ | 'saveFailed' | 'saveUnavailable' | 'saveRetry'
71
+ | 'staleTitle' | 'staleBody' | 'staleReload' | 'staleOverwrite'
72
+ | 'unsavedTitle' | 'unsavedBody' | 'unsavedLeave' | 'unsavedStay'
54
73
  | 'op.fail.auth' | 'op.fail.network' | 'op.fail.no-upstream' | 'op.fail.diverged' | 'op.fail.conflict'
55
- | 'op.fail.nothing-to-commit' | 'op.fail.dirty' | 'op.fail.unknown'
74
+ | 'op.fail.nothing-to-commit' | 'op.fail.dirty' | 'op.fail.stale' | 'op.fail.invalid' | 'op.fail.unknown'
56
75
 
57
76
  export const zh: Record<WorkbenchKey, string> = {
58
77
  aheadTitle: '领先上游 {count} 个提交',
@@ -66,6 +85,25 @@ export const zh: Record<WorkbenchKey, string> = {
66
85
  tabChanges: '变更',
67
86
  tabHistory: '历史',
68
87
  tabCompare: '对比',
88
+ tabFiles: '文件',
89
+ fileSearchPlaceholder: '搜索文件…',
90
+ filesTruncated: '文件太多,列表已截断;用搜索找剩下的。',
91
+ filesEmpty: '这个仓库还没有文件',
92
+ filesNoMatch: '没有匹配的文件',
93
+ filesMore: '还有 {count} 个,用上面的搜索找',
94
+ filesPick: '从左边选一个文件打开',
95
+ filesVanished: '{path} 已不在仓库里(被删除、重命名,或者切到了没有它的分支)。',
96
+ filesUnsavedAsk: '有未保存的修改,打开其他文件会丢弃它们。',
97
+ filesDiscardOpen: '丢弃并打开',
98
+ // Read-only rather than withheld: in this view READING the file is the
99
+ // point, and only saving it back would rewrite bytes nobody touched.
100
+ fileReadOnlyCrlf: '这个文件的行尾是 CRLF,只能查看不能编辑(编辑器会把行尾统一成 LF,保存时整份文件都会被改写)。',
101
+ fileReadOnlyEncoding: '这个文件不是 UTF-8 编码,只能查看不能编辑:页面上的文字是一次有损解码,保存回去会改写每一个非 ASCII 字节。',
102
+ blameLine: '第 {line} 行',
103
+ paintTooLarge: '文件较长,已关闭语法上色(整文件上色每次要一秒以上,每次停手都卡一下比纯文本更难用)。编辑、保存、追溯不受影响。',
104
+ blameInHistory: '他在本文件的提交',
105
+ blamePick: '点左边的名字,看那一行是哪个提交改的',
106
+ blameWhileEditing: '正在编辑,追溯已暂时收起:改过的行号已经对不上它背后的提交。保存或放弃后会重新出现。',
69
107
  historyEnd: '已到最早的提交',
70
108
  loading: '加载中…',
71
109
  maximize: '最大化',
@@ -112,13 +150,30 @@ export const zh: Record<WorkbenchKey, string> = {
112
150
  resizeLabel: '拖动调整抽屉宽度',
113
151
  resizeCommits: '拖动调整提交列表宽度',
114
152
  resizeTree: '拖动调整文件树宽度',
153
+ resizeSides: '拖动调整左右两栏宽度',
115
154
  sourceLabel: '统计来源切换',
116
155
  workingTree: '工作区',
117
156
  loadingCommit: '加载提交…',
118
157
  renamedFrom: '重命名自',
119
158
  binaryFile: '二进制文件 —— 不显示文本 diff',
159
+ imageBroken: '浏览器无法解码这个图片文件(文件头对得上,但内容可能已损坏或被截断)。',
160
+ imageFit: '适应窗口',
161
+ imageActual: '实际大小',
162
+ imageTooLarge: '图片有 {size},超过 {cap} 的预览上限。',
163
+ imageSource: '看源码',
164
+ imagePreview: '看图',
165
+ prevChange: '上一处变更',
166
+ nextChange: '下一处变更',
167
+ prevChangeHint: '上一处变更(Shift+F7)',
168
+ nextChangeHint: '下一处变更(F7)',
169
+ changeCount: '变更 {n} 处',
120
170
  loadingDiff: '加载 diff…',
121
171
  noTextDiff: '无文本差异',
172
+ // The side-by-side pane's layer tabs: unstaged is index→worktree, staged is
173
+ // HEAD→index — the two halves of what the old combined view showed at once.
174
+ tabUnstaged: '未暂存',
175
+ tabStaged: '已暂存',
176
+ diffTooLarge: '文件过大(超过 20000 行或 2 MB),已改用普通 diff 视图',
122
177
  noCommits: '无提交历史',
123
178
  historyLabel: '提交历史',
124
179
  commitAuthor: '作者',
@@ -193,6 +248,7 @@ export const zh: Record<WorkbenchKey, string> = {
193
248
  'op.ok.pull': '拉取完成',
194
249
  'op.ok.push': '推送成功',
195
250
  'op.ok.discardFile': '已撤回',
251
+ 'op.ok.applyBlocks': '已应用',
196
252
  // The row action, and the dialog it opens. IDEA calls this Rollback and
197
253
  // means "take the file back to its committed state" — not "undo my last
198
254
  // edit", which is the editor's job and a different promise.
@@ -205,6 +261,43 @@ export const zh: Record<WorkbenchKey, string> = {
205
261
  discardBodyRestore: '{path} 将还原成上次提交时的样子。这里的 {added} 行新增、{deleted} 行删除无法找回。',
206
262
  discardBodyDelete: '{path} 从未被 git 记录过,删除后无法找回。',
207
263
  discardBodyUnrename: '撤销重命名:{path} 改回 {previousPath},改名期间的内容改动一并丢弃。',
264
+ // One BLOCK, not the whole file: the side pane's roll-back states exactly
265
+ // which lines leave and that the working-tree file is rewritten to do it.
266
+ blockStage: '暂存这块',
267
+ blockDiscard: '撤回这块',
268
+ blockUnstage: '取消暂存这块',
269
+ blockDiscardBody: '{path} 的这一块改动({added} 行新增、{deleted} 行删除)将被撤回,工作区文件随之改写,无法找回。',
270
+ // The untracked case: the file's whole content is the one block, so rolling
271
+ // the block back reverse-applies the new-file patch and DELETES the file —
272
+ // the same consequence class the file-level delete wording exists to name.
273
+ blockDiscardBodyDelete: '{path} 从未被 git 记录过,这一块就是整个文件,撤回后文件将被删除,无法找回。',
274
+ // The editable right column: editing arms explicitly (never per keystroke —
275
+ // an autosave racing a concurrent agent write is the data-loss case), and
276
+ // every save carries the sha the editor opened with.
277
+ editFile: '编辑',
278
+ fileSave: '保存(Ctrl/Cmd+S)',
279
+ fileRevert: '放弃修改',
280
+ editingNotice: '正在编辑且未保存:保存前区块操作不可用;切走前(换页签、选其他文件、关闭抽屉)会先确认。',
281
+ // Why the edit affordance is withheld on a CRLF file: the editor control
282
+ // itself rewrites \r\n to \n, so any save would change every line ending.
283
+ blameToggle: 'Blame',
284
+ blameHint: '显示每一行最后是被谁、在哪个提交里改的',
285
+ blameUncommitted: '尚未提交',
286
+ blameFailed: '这个文件没有可追溯的历史(可能是未跟踪的新文件)。',
287
+ blameTruncated: '文件过长,追溯信息只显示了前面一部分。',
288
+ crlfNotice: '这个文件的行尾是 CRLF,暂不支持在线编辑(编辑框会把行尾统一成 LF,保存时整份文件都会被改写);查看和按块暂存/撤回不受影响。',
289
+ encodingNotice: '这个文件不是 UTF-8 编码(可能是 GBK、Shift JIS 之类),暂不支持在线编辑:页面上看到的文字是一次有损解码,保存回去会把文件里每一个非 ASCII 字节都改写掉,包括你没动过的行。查看和按块暂存/撤回不受影响。',
290
+ saveFailed: '保存失败',
291
+ saveUnavailable: '当前宿主还不支持保存(需要重启 dsh web 加载新版宿主端)。',
292
+ saveRetry: '重试保存',
293
+ staleTitle: '这个文件在你编辑期间被修改了',
294
+ staleBody: '磁盘上的内容已不是编辑器打开时的那份(保存时会被拒绝,文件保持原样)。可以放弃你的修改重新加载,也可以用编辑器里的内容覆盖磁盘上的新版本。',
295
+ staleReload: '重新加载(丢弃修改)',
296
+ staleOverwrite: '用编辑器内容覆盖',
297
+ unsavedTitle: '有未保存的修改',
298
+ unsavedBody: '{path} 有未保存的修改,切换后将丢弃这些修改。',
299
+ unsavedLeave: '仍要切换',
300
+ unsavedStay: '继续编辑',
208
301
  'op.fail.auth': '认证失败。凭据提示已被禁用,请先在终端里配置好凭据再重试。',
209
302
  'op.fail.network': '网络不可达:主机名解析失败或连接不上。检查网络与远程地址后重试。',
210
303
  'op.fail.no-upstream': '当前分支没有上游分支。',
@@ -212,6 +305,8 @@ export const zh: Record<WorkbenchKey, string> = {
212
305
  'op.fail.conflict': '出现冲突,工作区已被改动。请在编辑器里解决冲突后继续。',
213
306
  'op.fail.nothing-to-commit': '暂存区是空的,没有可提交的内容。',
214
307
  'op.fail.dirty': '本地改动会被覆盖。先提交或暂存它们。',
308
+ 'op.fail.stale': 'diff 已过期。',
309
+ 'op.fail.invalid': '无效的操作。',
215
310
  'op.fail.unknown': '操作失败。',
216
311
  }
217
312
 
@@ -227,6 +322,23 @@ export const en: Record<WorkbenchKey, string> = {
227
322
  tabChanges: 'Changes',
228
323
  tabHistory: 'History',
229
324
  tabCompare: 'Compare',
325
+ tabFiles: 'Files',
326
+ fileSearchPlaceholder: 'Search files…',
327
+ filesTruncated: 'Too many files to list; use the search for the rest.',
328
+ filesEmpty: 'This repository has no files yet',
329
+ filesNoMatch: 'No matching files',
330
+ filesMore: 'and {count} more — use the search above',
331
+ filesPick: 'Pick a file on the left to open it',
332
+ filesVanished: '{path} is not in the repository any more — deleted, renamed, or on a branch that no longer has it.',
333
+ filesUnsavedAsk: 'You have unsaved edits; opening another file discards them.',
334
+ filesDiscardOpen: 'Discard and open',
335
+ fileReadOnlyCrlf: 'This file uses CRLF line endings, so it opens read-only (the editor normalises them to LF, and a save would rewrite every line).',
336
+ fileReadOnlyEncoding: 'This file is not UTF-8, so it opens read-only: the text shown is a lossy decode, and saving it back would rewrite every non-ASCII byte.',
337
+ blameLine: 'Line {line}',
338
+ paintTooLarge: 'This file is long, so syntax colouring is off (a full repaint costs over a second, and a freeze on every pause is worse than plain text). Editing, saving and blame are unaffected.',
339
+ blameInHistory: 'Their commits on this file',
340
+ blamePick: 'Click a name to see which commit changed that line',
341
+ blameWhileEditing: 'Blame is hidden while you type: edited line numbers no longer match the commits behind them. It returns once you save or revert.',
230
342
  historyEnd: 'Start of history',
231
343
  loading: 'Loading…',
232
344
  maximize: 'Maximize',
@@ -273,13 +385,28 @@ export const en: Record<WorkbenchKey, string> = {
273
385
  resizeLabel: 'Drag to resize the drawer',
274
386
  resizeCommits: 'Drag to resize the commit list',
275
387
  resizeTree: 'Drag to resize the file tree',
388
+ resizeSides: 'Drag to resize the two columns',
276
389
  sourceLabel: 'Switch stats source',
277
390
  workingTree: 'Working tree',
278
391
  loadingCommit: 'Loading commit…',
279
392
  renamedFrom: 'Renamed from',
280
393
  binaryFile: 'Binary file — no text diff',
394
+ imageBroken: 'The browser could not decode this image (the header checks out, so the file is probably damaged or truncated).',
395
+ imageFit: 'Fit',
396
+ imageActual: 'Actual size',
397
+ imageTooLarge: 'This image is {size}, past the {cap} preview limit.',
398
+ imageSource: 'Source',
399
+ imagePreview: 'Preview',
400
+ prevChange: 'Previous change',
401
+ nextChange: 'Next change',
402
+ prevChangeHint: 'Previous change (Shift+F7)',
403
+ nextChangeHint: 'Next change (F7)',
404
+ changeCount: 'changes: {n}',
281
405
  loadingDiff: 'Loading diff…',
282
406
  noTextDiff: 'No text changes',
407
+ tabUnstaged: 'Unstaged',
408
+ tabStaged: 'Staged',
409
+ diffTooLarge: 'File too large (over 20,000 lines or 2 MB) — showing the plain diff view',
283
410
  noCommits: 'No commit history',
284
411
  historyLabel: 'Commit history',
285
412
  commitAuthor: 'Author',
@@ -346,6 +473,7 @@ export const en: Record<WorkbenchKey, string> = {
346
473
  'op.ok.pull': 'Pulled',
347
474
  'op.ok.push': 'Pushed',
348
475
  'op.ok.discardFile': 'Rolled back',
476
+ 'op.ok.applyBlocks': 'Applied',
349
477
  discardAction: 'Roll back changes',
350
478
  discardTitle: 'Roll back changes?',
351
479
  discardConfirm: 'Roll back',
@@ -353,6 +481,43 @@ export const en: Record<WorkbenchKey, string> = {
353
481
  discardBodyRestore: '{path} goes back to its committed content. The {added} added and {deleted} deleted lines here cannot be recovered.',
354
482
  discardBodyDelete: '{path} was never recorded by git. Deleting it cannot be undone.',
355
483
  discardBodyUnrename: 'Undo the rename: {path} goes back to {previousPath}, and content changed along the way is lost.',
484
+ // One BLOCK, not the whole file: the side pane's roll-back states exactly
485
+ // which lines leave and that the working-tree file is rewritten to do it.
486
+ blockStage: 'Stage block',
487
+ blockDiscard: 'Roll back block',
488
+ blockUnstage: 'Unstage block',
489
+ blockDiscardBody: 'This block of {path} ({added} added, {deleted} deleted lines) is rolled back and the working-tree file rewritten to do it. This cannot be undone.',
490
+ // The untracked case: the file's whole content is the one block, so rolling
491
+ // the block back reverse-applies the new-file patch and DELETES the file —
492
+ // the same consequence class the file-level delete wording exists to name.
493
+ blockDiscardBodyDelete: '{path} was never recorded by git, and this block is its whole content — rolling it back deletes the file. This cannot be undone.',
494
+ // The editable right column: editing arms explicitly (never per keystroke —
495
+ // an autosave racing a concurrent agent write is the data-loss case), and
496
+ // every save carries the sha the editor opened with.
497
+ editFile: 'Edit',
498
+ fileSave: 'Save (Ctrl/Cmd+S)',
499
+ fileRevert: 'Discard edits',
500
+ editingNotice: 'Editing with unsaved changes: block actions wait until the file is saved, and leaving first — another tab, another file, closing the drawer — asks.',
501
+ // Why the edit affordance is withheld on a CRLF file: the editor control
502
+ // itself rewrites \r\n to \n, so any save would change every line ending.
503
+ blameToggle: 'Blame',
504
+ blameHint: 'Show which commit last changed each line',
505
+ blameUncommitted: 'Not committed yet',
506
+ blameFailed: 'This file has no history to blame (it may be a new, untracked file).',
507
+ blameTruncated: 'The file is long, so blame is shown for the first part only.',
508
+ crlfNotice: 'This file has CRLF line endings, which the editor does not support yet (the edit box would turn every ending into LF, so a save rewrites the whole file); viewing and block staging/rolling back still work.',
509
+ encodingNotice: 'This file is not UTF-8 (GBK, Shift JIS or similar), so the editor is unavailable: the text shown is a lossy decode of it, and saving that back would rewrite every non-ASCII byte in the file, including lines you never touched. Viewing and block staging/rolling back still work.',
510
+ saveFailed: 'Save failed',
511
+ saveUnavailable: 'This host does not support saving yet — restart dsh web to load the new host half.',
512
+ saveRetry: 'Retry save',
513
+ staleTitle: 'This file changed while you were editing it',
514
+ staleBody: 'What is on disk is no longer what the editor opened (a save is refused and nothing written). Reload and lose your edits, or overwrite the newer file with what the editor holds.',
515
+ staleReload: 'Reload (discard my edits)',
516
+ staleOverwrite: 'Overwrite with my version',
517
+ unsavedTitle: 'Unsaved edits',
518
+ unsavedBody: '{path} has unsaved edits; switching now discards them.',
519
+ unsavedLeave: 'Switch anyway',
520
+ unsavedStay: 'Keep editing',
356
521
  'op.fail.auth': 'Authentication failed. Credential prompts are disabled here — set your credentials up in a terminal first.',
357
522
  'op.fail.network': 'The network was unreachable — the host could not be resolved or the connection failed. Check connectivity and the remote URL, then retry.',
358
523
  'op.fail.no-upstream': 'This branch has no upstream.',
@@ -360,5 +525,7 @@ export const en: Record<WorkbenchKey, string> = {
360
525
  'op.fail.conflict': 'Conflicts. The working tree has been changed; resolve them in the editor before continuing.',
361
526
  'op.fail.nothing-to-commit': 'Nothing staged to commit.',
362
527
  'op.fail.dirty': 'Local changes would be overwritten. Commit or stash them first.',
528
+ 'op.fail.stale': 'The diff is stale.',
529
+ 'op.fail.invalid': 'Invalid operation.',
363
530
  'op.fail.unknown': 'The operation failed.',
364
531
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * How wide a dragged pane is allowed to be.
3
+ *
4
+ * A pane may grow only into space the others do not need, so the ceiling is
5
+ * the drawer minus its neighbour minus the floor under the diff. The part that
6
+ * broke is the word NEIGHBOUR: the History tab stacks the commit list across
7
+ * the top, where it is a sibling of the tree-and-diff row rather than a column
8
+ * beside the tree — and counting a full-drawer-wide list as the tree's
9
+ * neighbour makes the ceiling smaller than the floor. The tree then sits at its
10
+ * minimum whatever the pointer does, which reads as a divider that will not
11
+ * move.
12
+ *
13
+ * So the question is asked of the geometry instead of the tab: two boxes are
14
+ * neighbours when one actually ends where the other begins. That stays true if
15
+ * another tab is stacked later, and it cannot be got wrong by a rename.
16
+ *
17
+ * Pure: no React, no DOM — the caller measures, this decides.
18
+ *
19
+ * @module @young1lin/dsh-ui-gitworkbench/client/pane-size
20
+ */
21
+
22
+ /** As much of a `DOMRect` as any of this needs. */
23
+ export interface PaneBox {
24
+ readonly left: number
25
+ readonly right: number
26
+ readonly width: number
27
+ }
28
+
29
+ /** Sub-pixel tolerance: two boxes a third of a pixel apart are touching. */
30
+ const EPSILON_PX = 1
31
+
32
+ /**
33
+ * The width `other` contributes to a drag on `pane` — zero unless the two are
34
+ * side by side.
35
+ *
36
+ * @param other - the pane that might be in the way, or null when it is not on
37
+ * screen at all.
38
+ * @param pane - the pane being dragged.
39
+ */
40
+ export function neighbourWidth(other: PaneBox | null, pane: PaneBox | null): number {
41
+ if (other === null || pane === null) return 0
42
+ // Either order: whichever is on the left, they are neighbours only if one's
43
+ // trailing edge meets the other's leading edge. A box stacked above spans
44
+ // the same columns as the one below, so neither test holds.
45
+ const beside = other.right <= pane.left + EPSILON_PX || pane.right <= other.left + EPSILON_PX
46
+ return beside ? other.width : 0
47
+ }
48
+
49
+ /**
50
+ * Clamp a dragged width between the pane's floor and the room actually left.
51
+ *
52
+ * The outer `Math.max` keeps the range from inverting in a drawer too narrow to
53
+ * hold both floors — the pane then simply takes its minimum, rather than the
54
+ * clamp returning a number below it.
55
+ *
56
+ * @param next - the width the pointer implies.
57
+ * @param min - this pane's floor.
58
+ * @param drawer - the drawer's inner width.
59
+ * @param other - the neighbour's width, from {@link neighbourWidth}.
60
+ * @param minDiff - the floor under the diff, which is never dragged directly.
61
+ */
62
+ export function clampPane(
63
+ next: number,
64
+ min: number,
65
+ drawer: number,
66
+ other: number,
67
+ minDiff: number,
68
+ ): number {
69
+ const max = Math.max(min, drawer - other - minDiff)
70
+ return Math.min(Math.max(next, min), max)
71
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The editable right column's decision core: when a fetched layer payload may
3
+ * replace the editor buffer and when it must not, what a save's answer does to
4
+ * the buffer's basis, what "dirty" means, and what any gesture that would drop
5
+ * the buffer must ask first.
6
+ *
7
+ * The host's sha check is the hard wall between the editor and a concurrent
8
+ * agent write; these rules are everything the UI does around that wall. The
9
+ * one with teeth is {@link applySides}: a refresh that lands over a DIRTY
10
+ * buffer keeps the buffer and only notes whether the file moved underneath —
11
+ * the tree refreshes, the editor does not, which is what makes polling safe to
12
+ * leave running while somebody is typing.
13
+ *
14
+ * Pure: no React, no CSS, no git. `tests/side-edit.test.ts` loads it directly.
15
+ */
16
+
17
+ /**
18
+ * `gitWorkbench/writeChecked`'s answer, mirrored from the host's `WriteResult`
19
+ * (the client re-declares host shapes rather than importing the host module,
20
+ * which pulls node and the RPC decorators).
21
+ */
22
+ export interface WriteResult {
23
+ readonly ok: boolean
24
+ /** Present only on failure; `stale` is the sha refusal. */
25
+ readonly failure?: string
26
+ /** A sentence for the user; names the file on the stale path. */
27
+ readonly error?: string
28
+ /** The file's blob sha after a successful write, for the next save. */
29
+ readonly sha?: string
30
+ }
31
+
32
+ /** What a fetched layer payload contributes to the editor's basis. */
33
+ export interface EditSides {
34
+ readonly targetText: string
35
+ readonly targetSha: string
36
+ /** Host's word that the working-tree file is not valid UTF-8. Optional so a
37
+ * payload from a host half older than this client reads as "fine" rather
38
+ * than as a refusal it cannot explain. */
39
+ readonly lossyEncoding?: boolean
40
+ }
41
+
42
+ /** The editable right column's whole state, minus transient banners. */
43
+ export interface EditState {
44
+ /** Whether the right column is the editor at all (unstaged tab only). */
45
+ readonly armed: boolean
46
+ /** The editor's text — the only thing the user changes directly. */
47
+ readonly buffer: string
48
+ /** Blob sha the buffer is based on: `writeChecked`'s `expectedSha`. */
49
+ readonly baseSha: string
50
+ /** The text `baseSha` names; dirty is buffer against THIS, not the disk. */
51
+ readonly baseText: string
52
+ /** The file changed underneath (fresh targetSha !== baseSha) while dirty. */
53
+ readonly conflict: boolean
54
+ }
55
+
56
+ /** Before the first arm: nothing editable, no basis, nothing conflicting. */
57
+ export const DISARMED: EditState = { armed: false, buffer: '', baseSha: '', baseText: '', conflict: false }
58
+
59
+ /**
60
+ * Whether the buffer holds edits no save has landed yet.
61
+ *
62
+ * Dirty is measured against `baseText` — the text of the last save or load —
63
+ * not against what a poll says the disk holds: the poll's view is a snapshot
64
+ * old by up to one interval, and "the agent changed the file" is a different
65
+ * question from "you have unsaved edits".
66
+ */
67
+ export function isDirty(edit: EditState): boolean {
68
+ return edit.armed && edit.buffer !== edit.baseText
69
+ }
70
+
71
+ /**
72
+ * Whether the editor may hold this payload's text: it must carry no carriage
73
+ * return.
74
+ *
75
+ * The HTML textarea value API normalises `\r\n` to `\n` the moment text
76
+ * enters it, so an armed CRLF file (an `core.autocrlf=true` checkout, or
77
+ * `eol=crlf`) would save ANY later edit with every line ending rewritten —
78
+ * a whole-file diff the reader never asked for. The first cut declines the
79
+ * edit rather than translate endings on the way out: the file stays viewable
80
+ * and the block actions keep working (they act on git's diff, not on the
81
+ * buffer); only the editor is withheld, with a notice saying why.
82
+ */
83
+ export type ArmRefusal = 'encoding' | 'crlf'
84
+
85
+ /**
86
+ * Why the editor is withheld for this payload, or null when it may arm.
87
+ *
88
+ * Two reasons, and the pane says WHICH: a reader shown one sentence for two
89
+ * different causes cannot tell whether converting line endings would help.
90
+ *
91
+ * - `encoding` — the file is not UTF-8 (GBK, Shift JIS, Latin-1). The text
92
+ * on screen is a lossy decode of it, so saving it back would replace every
93
+ * non-ASCII byte in the file, including lines nobody touched. The host
94
+ * refuses such a save too; this is what keeps the reader from reaching it.
95
+ * - `crlf` — the text carries carriage returns. The textarea normalises
96
+ * `{CR}{LF}` to `{LF}` the moment the text lands in it, so the next save
97
+ * would rewrite every line ending in the file.
98
+ *
99
+ * Encoding is reported first: it is the more fundamental of the two, and its
100
+ * sentence is the one that tells the reader nothing here will help.
101
+ *
102
+ * Either way only the EDITOR is withheld — the diff still renders and the
103
+ * block actions still work, because those act on git's own diff rather than
104
+ * on the buffer.
105
+ */
106
+ export function armRefusal(sides: EditSides): ArmRefusal | null {
107
+ if (sides.lossyEncoding === true) return 'encoding'
108
+ if (sides.targetText.includes('\r')) return 'crlf'
109
+ return null
110
+ }
111
+
112
+ export function editableSides(sides: EditSides): boolean {
113
+ return armRefusal(sides) === null
114
+ }
115
+
116
+ /** The adopt core: buffer and basis become the payload, conflict cleared. */
117
+ function adopt(armed: boolean, sides: EditSides): EditState {
118
+ return { armed, buffer: sides.targetText, baseSha: sides.targetSha, baseText: sides.targetText, conflict: false }
119
+ }
120
+
121
+ /**
122
+ * Arm the editor from the payload currently on screen: the buffer starts as
123
+ * the working-tree text, and the first save is checked against its sha.
124
+ * A payload {@link editableSides} refuses is handed back unchanged — the
125
+ * caller shows the notice instead of an editor.
126
+ */
127
+ export function armEdit(edit: EditState, sides: EditSides): EditState {
128
+ return editableSides(sides) ? adopt(true, sides) : edit
129
+ }
130
+
131
+ /**
132
+ * A fetched payload lands over the editor.
133
+ *
134
+ * Clean (or disarmed): adopt — the editor follows the file, so the drawer
135
+ * keeps up with the agent exactly as it did before editing existed, and an
136
+ * armed editor whose file turned CRLF underneath disarms with the payload
137
+ * adopted (clean costs no edits, and an armed editor must never hold text
138
+ * the textarea would normalise). Dirty: keep the buffer whatever the payload
139
+ * says (a poll must never overwrite unsaved edits) and record whether the
140
+ * file itself moved, which is what the reload-and-lose-edits banner keys on.
141
+ * An index-only change (same targetSha) leaves the flag down: the diff moved,
142
+ * the edited file did not.
143
+ */
144
+ export function applySides(edit: EditState, sides: EditSides): EditState {
145
+ if (!isDirty(edit)) {
146
+ return adopt(edit.armed && editableSides(sides), sides)
147
+ }
148
+ return { ...edit, conflict: sides.targetSha !== edit.baseSha }
149
+ }
150
+
151
+ /**
152
+ * The banner's reload action, on the user's explicit say-so: drop the edits
153
+ * onto the fresh text with a clean, current basis — and disarmed when the
154
+ * fresh text carries CRLF, for the same reason arming refuses it.
155
+ */
156
+ export function reloadSides(edit: EditState, sides: EditSides): EditState {
157
+ return adopt(editableSides(sides), sides)
158
+ }
159
+
160
+ /**
161
+ * Another file or layer selected: the edit session belonged to what was on
162
+ * screen, so the editor disarms and adopts the new payload from scratch.
163
+ */
164
+ export function resetSides(edit: EditState, sides: EditSides): EditState {
165
+ return adopt(false, sides)
166
+ }
167
+
168
+ /** The drawer-level unsaved-edits guard's state. */
169
+ export interface LeaveGuard {
170
+ /** The pane's last dirty report — the pane is this flag's only writer. */
171
+ readonly dirty: boolean
172
+ /** Whether the unsaved-edits dialog is holding a deferred gesture. */
173
+ readonly askOpen: boolean
174
+ }
175
+
176
+ /** No edits, no open ask: the guard at rest. */
177
+ export const LEAVE_GUARD_CLEAR: LeaveGuard = { dirty: false, askOpen: false }
178
+
179
+ /** What a gesture that would drop the editor's surface should do. */
180
+ export type LeaveGate = { readonly kind: 'run' } | { readonly kind: 'ask' } | { readonly kind: 'wait' }
181
+
182
+ /**
183
+ * Decide a leave gesture — a layer tab, a file selection, the drawer's close,
184
+ * a main-tab switch — against unsaved edits.
185
+ *
186
+ * `same` names a gesture whose target is what the pane already shows: the
187
+ * already-active tab, the tree row of the file already rendered. Such a
188
+ * gesture changes nothing the pane renders and discards nothing, so it runs
189
+ * without asking — a dialog over it would promise "switching discards your
190
+ * edits" and then discard nothing, and a Leave answer on it used to disarm
191
+ * the guard entirely. For a real target: `run` acts now, `ask` opens the
192
+ * unsaved-edits dialog (Stay keeps editing, Leave drops), `wait` does
193
+ * nothing because an ask is already open — a second gesture waits on the
194
+ * first's answer rather than stacking a dialog on it.
195
+ */
196
+ export function gateLeave(guard: LeaveGuard, same: boolean): LeaveGate {
197
+ if (same) return { kind: 'run' }
198
+ if (!guard.dirty) return { kind: 'run' }
199
+ return guard.askOpen ? { kind: 'wait' } : { kind: 'ask' }
200
+ }
201
+
202
+ /** An ask opened: the gesture is held for the dialog's answer. */
203
+ export function leaveAsked(guard: LeaveGuard): LeaveGuard {
204
+ return { ...guard, askOpen: true }
205
+ }
206
+
207
+ /**
208
+ * The dialog answered — Leave or Stay, both close the ask — and the dirty
209
+ * flag is deliberately NOT touched.
210
+ *
211
+ * The pane is the flag's only writer: a genuine navigation routes through
212
+ * `resetSides`, whose clean transition is what reports false, while a no-op
213
+ * gesture leaves the pane armed and dirty and the guard correctly still
214
+ * armed for the next one. Clearing the flag here — on the guess that "Leave
215
+ * means the buffer went away" — is exactly what let a Leave confirmed on a
216
+ * no-op gesture disarm the guard for every gesture after it.
217
+ */
218
+ export function leaveAnswered(guard: LeaveGuard): LeaveGuard {
219
+ return { ...guard, askOpen: false }
220
+ }
221
+
222
+ /** The pane reports its dirty flag — the one transition that writes it. */
223
+ export function paneDirtyReport(guard: LeaveGuard, dirty: boolean): LeaveGuard {
224
+ return { ...guard, dirty }
225
+ }
226
+
227
+ /**
228
+ * A successful `writeChecked`: the basis becomes the saved text and the sha
229
+ * the host read back. The buffer is NOT touched — typing that landed while
230
+ * the write was in flight stays dirty against the new basis, so it stays
231
+ * visible as unsaved rather than being silently declared written.
232
+ */
233
+ export function applySaveOk(edit: EditState, savedText: string, sha: string): EditState {
234
+ return { ...edit, baseSha: sha, baseText: savedText, conflict: false }
235
+ }
236
+
237
+ /**
238
+ * A refused save (`failure: 'stale'`): the buffer stands, the banner goes up.
239
+ * The reload/overwrite the banner offers reads the fresh sha off the next
240
+ * fetch, not off this refusal.
241
+ */
242
+ export function markConflict(edit: EditState): EditState {
243
+ return { ...edit, conflict: true }
244
+ }