@young1lin/dsh-ui-gitworkbench 0.1.5 → 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 (48) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CHANGELOG_EN.md +33 -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 +24360 -1436
  7. package/lib/git-ops.js +25 -0
  8. package/lib/image-sniff.js +197 -0
  9. package/lib/index.js +401 -7
  10. package/lib/patch-model.js +223 -0
  11. package/lib/side-guard.js +55 -0
  12. package/lib/write-checked.js +164 -0
  13. package/package.json +7 -1
  14. package/src/apply-blocks.ts +215 -0
  15. package/src/atomic-json.ts +29 -5
  16. package/src/blame.ts +94 -0
  17. package/src/client/CodeEditor.tsx +317 -0
  18. package/src/client/FileBrowser.tsx +657 -0
  19. package/src/client/GitWorkbenchPanel.module.css +453 -7
  20. package/src/client/GitWorkbenchPanel.tsx +1436 -143
  21. package/src/client/ImageView.tsx +120 -0
  22. package/src/client/blame-gutter.ts +108 -0
  23. package/src/client/blame-view.ts +104 -0
  24. package/src/client/cm-diff.ts +108 -0
  25. package/src/client/cm-tokens.ts +79 -0
  26. package/src/client/diff-nav.ts +198 -0
  27. package/src/client/file-icon.ts +190 -0
  28. package/src/client/file-rows.ts +184 -0
  29. package/src/client/files-place.ts +178 -0
  30. package/src/client/glyphs.tsx +86 -0
  31. package/src/client/highlight.ts +25 -0
  32. package/src/client/idle-value.ts +53 -0
  33. package/src/client/image-view.ts +106 -0
  34. package/src/client/indent.ts +74 -0
  35. package/src/client/index.ts +59 -0
  36. package/src/client/locales.ts +171 -4
  37. package/src/client/pane-size.ts +71 -0
  38. package/src/client/side-edit.ts +244 -0
  39. package/src/client/side-rows.ts +258 -0
  40. package/src/client/stable-list.ts +31 -0
  41. package/src/client/use-change-nav.ts +83 -0
  42. package/src/client/worktree-view.ts +11 -1
  43. package/src/git-ops.ts +36 -1
  44. package/src/image-sniff.ts +204 -0
  45. package/src/index.ts +447 -7
  46. package/src/patch-model.ts +267 -0
  47. package/src/side-guard.ts +58 -0
  48. 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
  /**
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).
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Is this file actually an image, and which kind?
3
+ *
4
+ * The extension is a hint, never the answer: `.png` is a filename, not a
5
+ * format, and a repository full of generated assets has mislabelled files in
6
+ * it. So the bytes decide — every format below is identified by the signature
7
+ * its own specification mandates in the first few bytes.
8
+ *
9
+ * The obvious alternative is the `file-type` package, which is the mature
10
+ * packaging of exactly this idea. It is not used here for three reasons: it
11
+ * is four transitive dependencies in a package that is published to npm, it
12
+ * identifies two hundred types where this needs eight, and — the deciding one
13
+ * — it could not make the render any safer than it already is. Sniffing is
14
+ * the FIRST of two gates; the second is the browser's own image decoder, and
15
+ * that one is authoritative in a way no table can be. If the trade ever turns,
16
+ * `sniffImage` is the only function to replace.
17
+ *
18
+ * Membership in the table is decided by one rule: browsers render it in an
19
+ * `<img>` element. That is why TIFF and HEIC are absent — recognising them
20
+ * would only let the view promise a picture it cannot draw, and "binary file"
21
+ * is the more honest answer for a format the reader's browser will refuse.
22
+ *
23
+ * SVG is the one member with no magic number, because it is XML rather than a
24
+ * container. It is admitted anyway: rendering happens through `<img>`, which
25
+ * the HTML specification defines as a non-scripted context — script elements,
26
+ * event handlers and external references inside the document do not run and do
27
+ * not load. That property, not a sanitiser, is what makes it safe.
28
+ *
29
+ * Pure: no node, no fs, no git. `tests/image-sniff.test.ts` loads it directly.
30
+ *
31
+ * @module @young1lin/dsh-ui-gitworkbench/image-sniff
32
+ */
33
+
34
+ /** A file the sniffer recognised. */
35
+ export interface SniffedImage {
36
+ /** MIME type, as the `<img>` blob should be labelled. */
37
+ readonly mime: string
38
+ /** Short human label for the caption: 'PNG', 'WebP', … */
39
+ readonly kind: string
40
+ }
41
+
42
+ /**
43
+ * Largest image handed to the browser, in bytes.
44
+ *
45
+ * This is a WIRE budget, not a rendering one. The bytes cross the RPC channel
46
+ * base64-encoded, which costs a third again on top, so a cap of four megabytes
47
+ * is a payload of five and a third — already the largest single message the
48
+ * drawer sends. Screenshots and icons, which is what repositories actually
49
+ * hold, sit two orders of magnitude below it.
50
+ */
51
+ export const IMAGE_BYTE_CAP = 4_000_000
52
+
53
+ /** How many leading bytes any signature below needs. */
54
+ const SNIFF_BYTES = 64
55
+
56
+ /** How much of a text file is read looking for an SVG root element.
57
+ *
58
+ * Larger than any magic number needs because the prologue in front of that
59
+ * root is unbounded in principle: an XML declaration, a DOCTYPE with an
60
+ * internal subset of declarations, and any number of comments all come
61
+ * first, and real files use all three. */
62
+ const SVG_PROLOGUE_BYTES = 4096
63
+
64
+ /** Do `bytes` begin with these byte values at `at`? */
65
+ function at(bytes: Uint8Array, offset: number, signature: readonly number[]): boolean {
66
+ if (bytes.length < offset + signature.length) return false
67
+ for (let i = 0; i < signature.length; i += 1) {
68
+ if (bytes[offset + i] !== signature[i]) return false
69
+ }
70
+ return true
71
+ }
72
+
73
+ /** The bytes of an ASCII marker, so the tables read as the specs write them. */
74
+ function ascii(text: string): readonly number[] {
75
+ return [...text].map(ch => ch.charCodeAt(0))
76
+ }
77
+
78
+ /** Little-endian uint32 at `offset`, or -1 when the buffer is too short. */
79
+ function u32le(bytes: Uint8Array, offset: number): number {
80
+ if (bytes.length < offset + 4) return -1
81
+ return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0
82
+ }
83
+
84
+ /** DIB header sizes BMP has ever defined. A `BM` prefix alone is two bytes of
85
+ * evidence, which any text beginning "BM" would satisfy; the header size is
86
+ * what makes the match a bitmap. */
87
+ const BMP_HEADERS: readonly number[] = [12, 16, 40, 52, 56, 64, 108, 124]
88
+
89
+ /** ISO base-media brands that carry a still image a browser will draw. */
90
+ const AVIF_BRANDS: readonly string[] = ['avif', 'avis']
91
+
92
+ /**
93
+ * Identify one file from its leading bytes.
94
+ *
95
+ * @param bytes - the file's content, or at least its first {@link SNIFF_BYTES}.
96
+ * @returns what it is, or null for anything not in the table.
97
+ */
98
+ export function sniffImage(bytes: Uint8Array): SniffedImage | null {
99
+ if (bytes.length === 0) return null
100
+
101
+ // PNG: the eight-byte signature from the specification, chosen there
102
+ // precisely so that no other format collides with it.
103
+ if (at(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
104
+ return { mime: 'image/png', kind: 'PNG' }
105
+ }
106
+ // JPEG: SOI marker, then the first marker of the next segment.
107
+ if (at(bytes, 0, [0xff, 0xd8, 0xff])) {
108
+ return { mime: 'image/jpeg', kind: 'JPEG' }
109
+ }
110
+ if (at(bytes, 0, ascii('GIF87a')) || at(bytes, 0, ascii('GIF89a'))) {
111
+ return { mime: 'image/gif', kind: 'GIF' }
112
+ }
113
+ // WebP is a RIFF container; the four bytes after the length field are what
114
+ // separate it from a WAV or an AVI.
115
+ if (at(bytes, 0, ascii('RIFF')) && at(bytes, 8, ascii('WEBP'))) {
116
+ return { mime: 'image/webp', kind: 'WebP' }
117
+ }
118
+ // ISO base media: `ftyp` box, then the brand. Also matches HEIC and MP4,
119
+ // which is why only the still-image AVIF brands are admitted.
120
+ if (at(bytes, 4, ascii('ftyp')) && AVIF_BRANDS.some(brand => at(bytes, 8, ascii(brand)))) {
121
+ return { mime: 'image/avif', kind: 'AVIF' }
122
+ }
123
+ if (at(bytes, 0, ascii('BM')) && BMP_HEADERS.includes(u32le(bytes, 14))) {
124
+ return { mime: 'image/bmp', kind: 'BMP' }
125
+ }
126
+ // ICO: reserved zero, type 1 (icon) or 2 (cursor), then a non-zero count of
127
+ // images. The count is the check that a run of zero bytes cannot pass.
128
+ if (at(bytes, 0, [0x00, 0x00, 0x01, 0x00]) && (bytes[4] | (bytes[5] << 8)) > 0) {
129
+ return { mime: 'image/x-icon', kind: 'ICO' }
130
+ }
131
+ if (looksLikeSvg(bytes)) {
132
+ return { mime: 'image/svg+xml', kind: 'SVG' }
133
+ }
134
+ return null
135
+ }
136
+
137
+ /**
138
+ * Does this text begin an SVG document?
139
+ *
140
+ * Structural rather than a substring search: the prologue XML allows before a
141
+ * root element is skipped, and then the root element itself must be `svg`. A
142
+ * file that merely CONTAINS `<svg` somewhere — an HTML page with an inline
143
+ * icon, a TypeScript file with a template literal — is not one.
144
+ */
145
+ function looksLikeSvg(bytes: Uint8Array): boolean {
146
+ const head = bytes.subarray(0, SVG_PROLOGUE_BYTES)
147
+ // A NUL rules out text before any parsing: the same test the diff side uses
148
+ // to call a file binary.
149
+ if (head.includes(0)) return false
150
+ let text: string
151
+ try {
152
+ text = new TextDecoder('utf-8', { fatal: true }).decode(head)
153
+ } catch {
154
+ // A truncated multi-byte character at the cut is not a decode failure of
155
+ // the FILE, so retry lenient; a genuinely non-UTF-8 file yields U+FFFD,
156
+ // which no prologue below accepts.
157
+ text = new TextDecoder('utf-8').decode(head)
158
+ }
159
+ // Strip a byte-order mark, which is legal before an XML declaration.
160
+ let rest = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
161
+ for (;;) {
162
+ rest = rest.replace(/^\s+/, '')
163
+ if (rest.startsWith('<?')) {
164
+ const end = rest.indexOf('?>')
165
+ // Prologue cut off by the sniff window: undecidable, so not an image.
166
+ if (end === -1) return false
167
+ rest = rest.slice(end + 2)
168
+ continue
169
+ }
170
+ if (rest.startsWith('<!--')) {
171
+ const end = rest.indexOf('-->')
172
+ if (end === -1) return false
173
+ rest = rest.slice(end + 3)
174
+ continue
175
+ }
176
+ if (rest.startsWith('<!')) {
177
+ // A DOCTYPE may carry an internal subset in brackets, and the
178
+ // declarations inside it end with '>' characters of their own —
179
+ // scanning to the first one lands in the middle of the subset and the
180
+ // root element is never reached. Real files do this: matplotlib ships
181
+ // an SVG whose DOCTYPE declares an ATTLIST, and it was the one file in
182
+ // a hundred and twenty-five thousand that this missed.
183
+ const bracket = rest.indexOf('[')
184
+ const end = rest.indexOf('>')
185
+ if (end === -1) return false
186
+ if (bracket === -1 || bracket > end) {
187
+ rest = rest.slice(end + 1)
188
+ continue
189
+ }
190
+ const closed = rest.indexOf(']', bracket)
191
+ if (closed === -1) return false
192
+ const after = rest.indexOf('>', closed)
193
+ if (after === -1) return false
194
+ rest = rest.slice(after + 1)
195
+ continue
196
+ }
197
+ break
198
+ }
199
+ // The root element, and only `svg`: the character after the name must end
200
+ // it, so `<svgfoo>` is not a match.
201
+ if (!rest.startsWith('<svg')) return false
202
+ const after = rest.charAt(4)
203
+ return after === '' || after === '>' || after === '/' || /\s/.test(after)
204
+ }