@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,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
+ }