@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
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The side-by-side diff's row model: a parsed full-context diff turned into
3
+ * rows that pair the two columns.
4
+ *
5
+ * A unified diff already IS an alignment — `context` lines hit both columns, a
6
+ * `del` the left one, an `add` the right one — so no diff algorithm is needed
7
+ * here or bundled: `git diff -U1000000` emits one hunk covering the whole file
8
+ * and this module only has to read that alignment into rows.
9
+ *
10
+ * The pairing rule is positional, the way IDEA renders it: inside a run of
11
+ * consecutive changed lines the 1st deletion pairs with the 1st addition, the
12
+ * 2nd with the 2nd, … into `change` rows, and whatever has no partner becomes a
13
+ * one-sided `del` or `add` row. A one-line edit is then ONE row with both
14
+ * sides, not a delete row above an add row.
15
+ *
16
+ * A block — a maximal run of changed rows uninterrupted by a `same` row — is
17
+ * the unit the stage/discard/unstage buttons of the editable view act on.
18
+ * `blockLines` names a block back in the hunk's own coordinates: the line
19
+ * indices it returns are exactly the selection `patch-model`'s `emitPatch`
20
+ * needs to reproduce that block alone, which is the contract that keeps those
21
+ * buttons correct.
22
+ *
23
+ * `` markers belong to the line before them and
24
+ * never form a row of their own. They are carried, not dropped: a row's
25
+ * `leftIndex`/`rightIndex` stay true indices into `hunk.lines`, so the marker
26
+ * describing a row's line is reachable as `index + 1` when a later task wants
27
+ * to render it.
28
+ *
29
+ * Pure: no React, no CSS, no git. `tests/side-rows.test.ts` loads it directly.
30
+ */
31
+
32
+ import type { FilePatch } from '../patch-model.ts'
33
+
34
+ /** One column's half of a row: that side's real line number and text. */
35
+ export interface SideCell {
36
+ readonly line: number
37
+ readonly text: string
38
+ }
39
+
40
+ export interface SideRow {
41
+ /** `same` both columns equal; `change` a paired del/add; `del`/`add` one-sided. */
42
+ readonly kind: 'same' | 'change' | 'del' | 'add'
43
+ readonly left: SideCell | null
44
+ readonly right: SideCell | null
45
+ /** Index into the hunk's `lines` for the LEFT cell, -1 when absent. */
46
+ readonly leftIndex: number
47
+ /** Index into the hunk's `lines` for the RIGHT cell, -1 when absent. */
48
+ readonly rightIndex: number
49
+ /** Which change block this row belongs to; -1 for unchanged rows. */
50
+ readonly block: number
51
+ }
52
+
53
+ /** One collected side of a run of changed lines, with its hunk-line index. */
54
+ interface RunSide {
55
+ readonly index: number
56
+ readonly text: string
57
+ }
58
+
59
+ /**
60
+ * Align a full-context diff's single hunk into two-column rows.
61
+ *
62
+ * @param file - a parsed diff. Full context means one hunk; anything else is
63
+ * the caller's bug, and mis-aligning it silently would be worse
64
+ * than refusing.
65
+ * @returns the rows, in file order: `same` rows carry both line numbers,
66
+ * changed rows carry their block id.
67
+ * @throws when the patch does not have exactly one hunk.
68
+ */
69
+ export function alignRows(file: FilePatch): readonly SideRow[] {
70
+ if (file.hunks.length !== 1) {
71
+ throw new Error(`alignRows expects the one hunk of a full-context diff, got ${file.hunks.length}`)
72
+ }
73
+ const hunk = file.hunks[0]!
74
+ const rows: SideRow[] = []
75
+ let oldL = hunk.oldStart
76
+ let newL = hunk.newStart
77
+ let block = -1
78
+
79
+ let i = 0
80
+ while (i < hunk.lines.length) {
81
+ const line = hunk.lines[i]!
82
+ if (line.kind === 'nonewline') {
83
+ // Describes the line before it, which a row already carries; never a row.
84
+ i += 1
85
+ continue
86
+ }
87
+ if (line.kind === 'context') {
88
+ rows.push({
89
+ kind: 'same',
90
+ left: { line: oldL, text: line.text },
91
+ right: { line: newL, text: line.text },
92
+ leftIndex: i,
93
+ rightIndex: i,
94
+ block: -1,
95
+ })
96
+ oldL += 1
97
+ newL += 1
98
+ i += 1
99
+ continue
100
+ }
101
+
102
+ // A run of del/add lines — one block. `\ No newline` markers inside the
103
+ // run are transparent: git can put one between the deletions and the
104
+ // additions, and that is still ONE change, not two blocks with no context
105
+ // line between them.
106
+ const dels: RunSide[] = []
107
+ const adds: RunSide[] = []
108
+ let j = i
109
+ while (j < hunk.lines.length) {
110
+ const run = hunk.lines[j]!
111
+ if (run.kind === 'del') dels.push({ index: j, text: run.text })
112
+ else if (run.kind === 'add') adds.push({ index: j, text: run.text })
113
+ else if (run.kind !== 'nonewline') break
114
+ j += 1
115
+ }
116
+ block += 1
117
+
118
+ const pairs = Math.min(dels.length, adds.length)
119
+ for (let p = 0; p < pairs; p += 1) {
120
+ rows.push({
121
+ kind: 'change',
122
+ left: { line: oldL, text: dels[p]!.text },
123
+ right: { line: newL, text: adds[p]!.text },
124
+ leftIndex: dels[p]!.index,
125
+ rightIndex: adds[p]!.index,
126
+ block,
127
+ })
128
+ oldL += 1
129
+ newL += 1
130
+ }
131
+ for (let p = pairs; p < dels.length; p += 1) {
132
+ rows.push({
133
+ kind: 'del',
134
+ left: { line: oldL, text: dels[p]!.text },
135
+ right: null,
136
+ leftIndex: dels[p]!.index,
137
+ rightIndex: -1,
138
+ block,
139
+ })
140
+ oldL += 1
141
+ }
142
+ for (let p = pairs; p < adds.length; p += 1) {
143
+ rows.push({
144
+ kind: 'add',
145
+ left: null,
146
+ right: { line: newL, text: adds[p]!.text },
147
+ leftIndex: -1,
148
+ rightIndex: adds[p]!.index,
149
+ block,
150
+ })
151
+ newL += 1
152
+ }
153
+ i = j
154
+ }
155
+ return rows
156
+ }
157
+
158
+ /**
159
+ * Every hunk line index a block covers: each of its rows' `leftIndex` and
160
+ * `rightIndex` where present, ascending, without duplicates.
161
+ *
162
+ * @param rows - rows from {@link alignRows}.
163
+ * @param block - a block id the rows carry.
164
+ * @returns the indices, exactly the selection `emitPatch` takes to reproduce
165
+ * this block alone (its own changes plus the file's context).
166
+ */
167
+ export function blockLines(rows: readonly SideRow[], block: number): readonly number[] {
168
+ const indices = new Set<number>()
169
+ for (const row of rows) {
170
+ if (row.block !== block) continue
171
+ if (row.leftIndex !== -1) indices.add(row.leftIndex)
172
+ if (row.rightIndex !== -1) indices.add(row.rightIndex)
173
+ }
174
+ return [...indices].sort((a, b) => a - b)
175
+ }
176
+
177
+ /**
178
+ * How many change blocks the rows hold.
179
+ *
180
+ * @param rows - rows from {@link alignRows}.
181
+ * @returns the block count, 0 for an unchanged file.
182
+ */
183
+ export function blockCount(rows: readonly SideRow[]): number {
184
+ let max = -1
185
+ for (const row of rows) if (row.block > max) max = row.block
186
+ return max + 1
187
+ }
188
+
189
+ /**
190
+ * Whether a block is the file's ENTIRE content: the one block of a diff with
191
+ * no context row at all.
192
+ *
193
+ * An untracked file's synthesized new-file diff is exactly this shape — every
194
+ * line an addition, nothing unchanged — and it is the case whose roll-back
195
+ * DELETES the file rather than rewriting it, so the confirmation's wording
196
+ * asks for it by name. A tracked file whose every line changed has the same
197
+ * shape and only rewrites, which is why the caller combines this with the
198
+ * file row's status instead of reading deletion into the shape alone.
199
+ *
200
+ * @param rows - rows from {@link alignRows}.
201
+ * @param block - a block id the rows carry.
202
+ * @returns true when the block covers every row and no row is unchanged.
203
+ */
204
+ export function blockIsWholeFile(rows: readonly SideRow[], block: number): boolean {
205
+ if (rows.length === 0 || block !== 0 || blockCount(rows) !== 1) return false
206
+ return rows.every(row => row.kind !== 'same')
207
+ }
208
+
209
+ /** What the pane's body area renders for the payload on screen. */
210
+ export type SideBody =
211
+ | { readonly kind: 'editor' }
212
+ | { readonly kind: 'empty' }
213
+ | { readonly kind: 'rows' }
214
+
215
+ /**
216
+ * Decide the pane's BODY area — never the whole pane.
217
+ *
218
+ * The tab row above it always renders: an empty layer diff (a fully staged
219
+ * file's unstaged side, a file with nothing staged) is one click from the
220
+ * other layer — and, on the unstaged side, one Edit button from the editor,
221
+ * because the working tree is the edit target even when every change in it is
222
+ * already staged. So "no rows" is a state of the body, not an early return
223
+ * for the pane, and an ARMED editor over an emptied diff still edits: the
224
+ * file's unstaged delta can vanish (the agent staged everything) under a
225
+ * buffer the reader is mid-edit in.
226
+ *
227
+ * @param rows - rows from {@link alignRows}; empty when the layer diff is.
228
+ * @param editable - whether the editor is armed (unstaged layer only).
229
+ * @returns which of the three body treatments the pane renders.
230
+ */
231
+ export function sideBodyState(rows: readonly SideRow[], editable: boolean): SideBody {
232
+ if (editable) return { kind: 'editor' }
233
+ if (rows.length === 0) return { kind: 'empty' }
234
+ return { kind: 'rows' }
235
+ }
236
+
237
+ /**
238
+ * A block's line tallies: how many of its rows carry a right cell (added) and
239
+ * how many a left one (deleted). A paired edit counts one on each side.
240
+ *
241
+ * The roll-back confirmation states the consequence in these terms — "these N
242
+ * added and M deleted lines are reverted" — so the count must come from the
243
+ * same row model the buttons act on, not from the file row's whole-file stats.
244
+ *
245
+ * @param rows - rows from {@link alignRows}.
246
+ * @param block - a block id the rows carry.
247
+ * @returns the tallies; zeros for an id no row carries.
248
+ */
249
+ export function blockTally(rows: readonly SideRow[], block: number): { readonly added: number; readonly deleted: number } {
250
+ let added = 0
251
+ let deleted = 0
252
+ for (const row of rows) {
253
+ if (row.block !== block) continue
254
+ if (row.left !== null) deleted += 1
255
+ if (row.right !== null) added += 1
256
+ }
257
+ return { added, deleted }
258
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Is this list the same list as last time?
3
+ *
4
+ * React memoisation keys on IDENTITY, and the drawer polls: `git status` comes
5
+ * back every 3–15 seconds and every derived array is a new object even when
6
+ * the repository has not moved. In the Files tab that identity change is the
7
+ * head of a chain — the untracked paths feed the merged path list, which feeds
8
+ * the directory tree, which feeds the rows — so one fresh array per poll
9
+ * rebuilds a 20,000-path tree that is byte-for-byte what it already was.
10
+ *
11
+ * Comparing the contents costs one pass over the list; rebuilding costs a sort
12
+ * and a tree walk. The comparison wins by two orders of magnitude, and it wins
13
+ * by more the larger the repository is, which is exactly where it matters.
14
+ *
15
+ * @module @young1lin/dsh-ui-gitworkbench/client/stable-list
16
+ */
17
+
18
+ /**
19
+ * Element-wise equality, in order.
20
+ *
21
+ * Identity first: the common case is that nothing upstream changed at all, and
22
+ * then there is nothing to compare.
23
+ */
24
+ export function sameList(a: readonly string[], b: readonly string[]): boolean {
25
+ if (a === b) return true
26
+ if (a.length !== b.length) return false
27
+ for (let i = 0; i < a.length; i += 1) {
28
+ if (a[i] !== b[i]) return false
29
+ }
30
+ return true
31
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Walking a scroller change by change — the wiring half of `diff-nav.ts`.
3
+ *
4
+ * The decisions (which block is next, where to stop, how to survive the end of
5
+ * the file) are pure and live next door, where vitest can reach them. What is
6
+ * left is the part that cannot be pure: reading the DOM for where the blocks
7
+ * actually are, and moving the scroller. Both panes that offer the walk — the
8
+ * side-by-side view in Changes and the unified view in History and Compare —
9
+ * need exactly this, and a second copy of it is a second place for the
10
+ * scroll-position bookkeeping to be got subtly wrong.
11
+ *
12
+ * The scroller is passed IN rather than created here: in Changes the pane owns
13
+ * it, in History the drawer does. The blocks are found by attribute, so any
14
+ * renderer that marks its changed rows with `data-block` can be walked.
15
+ *
16
+ * @module @young1lin/dsh-ui-gitworkbench/client/use-change-nav
17
+ */
18
+
19
+ import { useRef, type MutableRefObject } from 'react'
20
+
21
+ import { anchorFrom, scrollTopFor, stepToBlock, type BlockTop, type NavMemory } from './diff-nav.ts'
22
+
23
+ /** What a pane needs to offer the walk. */
24
+ export interface ChangeNav {
25
+ /** Move to the next (1) or previous (-1) change, wrapping at both ends. */
26
+ readonly goToChange: (direction: 1 | -1) => void
27
+ }
28
+
29
+ /**
30
+ * @param scrollRef - the element that scrolls, and the one whose subtree
31
+ * carries the `data-block` marks.
32
+ */
33
+ export function useChangeNav(scrollRef: MutableRefObject<HTMLDivElement | null>): ChangeNav {
34
+ /** What the last press landed on. A ref, not state: it exists to make the
35
+ * NEXT press correct, and nothing on screen reads it. */
36
+ const navMemory = useRef<NavMemory | null>(null)
37
+
38
+ /**
39
+ * Every change block's position inside the scrolled content.
40
+ *
41
+ * Measured off the DOM rather than derived from a row model: a model knows
42
+ * which rows changed, not how many pixels down the page they sit, and the
43
+ * rows are never the only thing in the scroller — padding, a sticky bar and,
44
+ * in the armed side-by-side pane, a CodeMirror view of a different height
45
+ * all move the answer. One layout read per PRESS is cheap; nothing here runs
46
+ * on scroll.
47
+ *
48
+ * `offsetTop` is deliberately not used: it is relative to whichever ancestor
49
+ * happens to be positioned, which no rule in the stylesheet guarantees.
50
+ * Measuring both boxes and subtracting is independent of that.
51
+ */
52
+ const blockTops = (): readonly BlockTop[] => {
53
+ const scroller = scrollRef.current
54
+ if (scroller === null) return []
55
+ // First element carrying each id, in document order. A side-by-side row
56
+ // marks both of its columns, so the first hit for a block is its first row.
57
+ const first = new Map<number, HTMLElement>()
58
+ for (const cell of scroller.querySelectorAll<HTMLElement>('[data-block]')) {
59
+ const block = Number(cell.dataset.block)
60
+ if (!Number.isInteger(block) || block < 0 || first.has(block)) continue
61
+ first.set(block, cell)
62
+ }
63
+ const base = scroller.getBoundingClientRect().top - scroller.scrollTop
64
+ const tops: BlockTop[] = []
65
+ for (const [block, cell] of first) tops.push({ block, top: cell.getBoundingClientRect().top - base })
66
+ return tops
67
+ }
68
+
69
+ const goToChange = (direction: 1 | -1): void => {
70
+ const scroller = scrollRef.current
71
+ if (scroller === null) return
72
+ const tops = blockTops()
73
+ const target = stepToBlock(tops, anchorFrom(tops, scroller.scrollTop, navMemory.current), direction)
74
+ if (target === null) return
75
+ scroller.scrollTop = scrollTopFor(target.top)
76
+ // Read back rather than storing what was asked for: the browser clamps at
77
+ // the end of the content, and the clamped value is what the next press
78
+ // compares against to tell "still here" from "the reader scrolled".
79
+ navMemory.current = { block: target.block, scrollTop: scroller.scrollTop }
80
+ }
81
+
82
+ return { goToChange }
83
+ }
@@ -12,6 +12,16 @@ function normalize(path: string): string {
12
12
  return path.replace(/\\/g, '/').replace(/\/+$/, '')
13
13
  }
14
14
 
15
+ /**
16
+ * A path in the one form everything compares by — the same normalisation
17
+ * {@link samePath} uses, exposed so state can be KEYED by a worktree rather
18
+ * than only tested against one. An absent path keys as '', which is a real
19
+ * key: it names the session's own repository, the source the drawer starts on.
20
+ */
21
+ export function pathKey(path: string | null | undefined): string {
22
+ return path === null || path === undefined ? '' : normalize(path)
23
+ }
24
+
15
25
  /**
16
26
  * Whether two paths name the same place.
17
27
  *
@@ -23,7 +33,7 @@ function normalize(path: string): string {
23
33
  */
24
34
  export function samePath(a: string | null | undefined, b: string | null | undefined): boolean {
25
35
  if (a === null || a === undefined || b === null || b === undefined) return false
26
- return normalize(a) === normalize(b)
36
+ return pathKey(a) === pathKey(b)
27
37
  }
28
38
 
29
39
  /**
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The one filesystem delete in this plugin, and the checks it carries.
3
+ *
4
+ * `discard-ops.ts` plans a delete when git has no copy of a file to restore
5
+ * from — untracked, or added-but-never-committed. git will not carry that out:
6
+ * `git clean` refuses paths it cannot index, which on Windows includes every
7
+ * reserved device name (`nul`, `con`, `aux`, `com1`, and the same names with
8
+ * any extension). So the removal goes through the filesystem, where git's own
9
+ * refusal to leave the repository does not apply — hence the checks here
10
+ * rather than a bare `rm`.
11
+ *
12
+ * Lives outside `index.ts` so vitest can load it: the class there needs the
13
+ * dsh runtime, and the property worth testing is "what does this delete, and
14
+ * what does it refuse" — a question about paths and the disk, not about RPC.
15
+ *
16
+ * @module @young1lin/dsh-ui-gitworkbench/fs-remove
17
+ */
18
+
19
+ import { rm } from 'node:fs/promises'
20
+ import { resolve, sep } from 'node:path'
21
+
22
+ import { isSafeRelativePath } from './discard-ops.js'
23
+
24
+ /**
25
+ * Resolve a repo-relative path against the worktree root, refusing to leave it.
26
+ *
27
+ * The second lock rather than the only one: {@link isSafeRelativePath} already
28
+ * rejected traversal spellings when the plan was made. This re-checks the
29
+ * RESOLVED path, which is the form the filesystem acts on, so a path that
30
+ * survives the first check by being spelled unusually still has to land inside
31
+ * the root to be acted on.
32
+ *
33
+ * @param root - the worktree directory, absolute.
34
+ * @param relative - repo-relative path from a plan step.
35
+ * @returns the absolute path to act on.
36
+ * @throws if the path is not a safe relative path, resolves outside the root,
37
+ * or IS the root.
38
+ */
39
+ export function resolveInside(root: string, relative: string): string {
40
+ if (!isSafeRelativePath(relative)) {
41
+ throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`)
42
+ }
43
+ const base = resolve(root)
44
+ const target = resolve(base, relative)
45
+ if (target === base) throw new Error('refusing to delete the worktree root')
46
+ if (!target.startsWith(base + sep)) {
47
+ throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`)
48
+ }
49
+ return target
50
+ }
51
+
52
+ /**
53
+ * Remove one entry from the worktree, having proven it is inside it.
54
+ *
55
+ * `recursive` is not a widening of the blast radius: `resolveInside` has
56
+ * already pinned the target to one path git named, and git names a DIRECTORY
57
+ * whenever it will not look inside one — an untracked nested repository is
58
+ * reported as `sub/`, with no per-file lines even under
59
+ * `--untracked-files=all`. Without `recursive` that row is the only one in the
60
+ * drawer whose roll-back fails, and it fails as `EISDIR`, which says nothing
61
+ * to the person who clicked it.
62
+ *
63
+ * `force` makes an absent entry a success: the reader asked for it to be gone,
64
+ * and it is.
65
+ *
66
+ * A symlinked directory inside the worktree could still point outward; that is
67
+ * a repository someone already has write access to, and resolving link targets
68
+ * per segment on every delete would cost a stat per segment for a case git
69
+ * itself does not defend against.
70
+ *
71
+ * @param root - the worktree directory, absolute.
72
+ * @param relative - repo-relative path from a plan step.
73
+ */
74
+ export async function removePathInside(root: string, relative: string): Promise<void> {
75
+ await rm(resolveInside(root, relative), { recursive: true, force: true })
76
+ }
package/src/git-ops.ts CHANGED
@@ -210,7 +210,15 @@ export function stageStateOf(xy: string): StageState {
210
210
  return { staged: index !== ' ', unstaged: worktree !== ' ' }
211
211
  }
212
212
 
213
- /** Why an operation failed, in terms the drawer can explain to a person. */
213
+ /**
214
+ * Why an operation failed, in terms the drawer can explain to a person.
215
+ *
216
+ * The last two are caller-derived, which is why {@link classifyFailure} never
217
+ * returns them: `stale` is a sha mismatch the caller compared before running
218
+ * git at all, and `invalid` an argument combination the caller refused before
219
+ * anything spawned. They ride the same union so a `GitOpResult` needs no
220
+ * parallel classification for the operations that produce them.
221
+ */
214
222
  export type OpFailure =
215
223
  | 'auth'
216
224
  | 'network'
@@ -219,6 +227,8 @@ export type OpFailure =
219
227
  | 'conflict'
220
228
  | 'nothing-to-commit'
221
229
  | 'dirty'
230
+ | 'stale'
231
+ | 'invalid'
222
232
  | 'unknown'
223
233
 
224
234
  /**
@@ -489,6 +499,31 @@ export function isBinaryPrefix(bytes: Buffer, windowBytes: number): boolean {
489
499
  return bytes.subarray(0, windowBytes).includes(0)
490
500
  }
491
501
 
502
+ /**
503
+ * Whether a buffer is valid UTF-8, and so survives a decode/encode round trip.
504
+ *
505
+ * The editable pane hands the browser a decoded string and writes back what
506
+ * comes home encoded as UTF-8. For a file in any other encoding — GBK, Shift
507
+ * JIS, Latin-1 — that trip is LOSSY: every byte the decoder cannot read
508
+ * becomes U+FFFD, and writing the result replaces every non-ASCII byte in the
509
+ * file, including the lines nobody edited. Such a file carries no NUL byte,
510
+ * so the binary sniff above waves it through; only decoding it says so.
511
+ *
512
+ * `fatal` makes the decoder throw on the first invalid sequence rather than
513
+ * substituting, which is the whole question asked in one call and without
514
+ * allocating the string twice to compare it.
515
+ *
516
+ * @param bytes - the file's contents.
517
+ */
518
+ export function decodesAsUtf8(bytes: Buffer): boolean {
519
+ try {
520
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes)
521
+ return true
522
+ } catch {
523
+ return false
524
+ }
525
+ }
526
+
492
527
  /**
493
528
  * Clip a diff to a character cap, and SAY so when the clip happened — a
494
529
  * silently shortened diff reads as a complete one (TESTS.md H1).