@young1lin/dsh-ui-gitworkbench 0.1.5 → 0.1.7

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 (49) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CHANGELOG_EN.md +43 -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 +34960 -11826
  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 +491 -12
  20. package/src/client/GitWorkbenchPanel.tsx +1655 -190
  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/history-layout.ts +52 -0
  33. package/src/client/idle-value.ts +53 -0
  34. package/src/client/image-view.ts +106 -0
  35. package/src/client/indent.ts +74 -0
  36. package/src/client/index.ts +59 -0
  37. package/src/client/locales.ts +179 -4
  38. package/src/client/pane-size.ts +71 -0
  39. package/src/client/side-edit.ts +244 -0
  40. package/src/client/side-rows.ts +258 -0
  41. package/src/client/stable-list.ts +31 -0
  42. package/src/client/use-change-nav.ts +83 -0
  43. package/src/client/worktree-view.ts +11 -1
  44. package/src/git-ops.ts +36 -1
  45. package/src/image-sniff.ts +204 -0
  46. package/src/index.ts +447 -7
  47. package/src/patch-model.ts +267 -0
  48. package/src/side-guard.ts +58 -0
  49. package/src/write-checked.ts +223 -0
package/lib/git-ops.js CHANGED
@@ -399,6 +399,31 @@ export function countBufferLines(bytes) {
399
399
  export function isBinaryPrefix(bytes, windowBytes) {
400
400
  return bytes.subarray(0, windowBytes).includes(0);
401
401
  }
402
+ /**
403
+ * Whether a buffer is valid UTF-8, and so survives a decode/encode round trip.
404
+ *
405
+ * The editable pane hands the browser a decoded string and writes back what
406
+ * comes home encoded as UTF-8. For a file in any other encoding — GBK, Shift
407
+ * JIS, Latin-1 — that trip is LOSSY: every byte the decoder cannot read
408
+ * becomes U+FFFD, and writing the result replaces every non-ASCII byte in the
409
+ * file, including the lines nobody edited. Such a file carries no NUL byte,
410
+ * so the binary sniff above waves it through; only decoding it says so.
411
+ *
412
+ * `fatal` makes the decoder throw on the first invalid sequence rather than
413
+ * substituting, which is the whole question asked in one call and without
414
+ * allocating the string twice to compare it.
415
+ *
416
+ * @param bytes - the file's contents.
417
+ */
418
+ export function decodesAsUtf8(bytes) {
419
+ try {
420
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes);
421
+ return true;
422
+ }
423
+ catch {
424
+ return false;
425
+ }
426
+ }
402
427
  /**
403
428
  * Clip a diff to a character cap, and SAY so when the clip happened — a
404
429
  * silently shortened diff reads as a complete one (TESTS.md H1).
@@ -0,0 +1,197 @@
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
+ * Largest image handed to the browser, in bytes.
35
+ *
36
+ * This is a WIRE budget, not a rendering one. The bytes cross the RPC channel
37
+ * base64-encoded, which costs a third again on top, so a cap of four megabytes
38
+ * is a payload of five and a third — already the largest single message the
39
+ * drawer sends. Screenshots and icons, which is what repositories actually
40
+ * hold, sit two orders of magnitude below it.
41
+ */
42
+ export const IMAGE_BYTE_CAP = 4_000_000;
43
+ /** How many leading bytes any signature below needs. */
44
+ const SNIFF_BYTES = 64;
45
+ /** How much of a text file is read looking for an SVG root element.
46
+ *
47
+ * Larger than any magic number needs because the prologue in front of that
48
+ * root is unbounded in principle: an XML declaration, a DOCTYPE with an
49
+ * internal subset of declarations, and any number of comments all come
50
+ * first, and real files use all three. */
51
+ const SVG_PROLOGUE_BYTES = 4096;
52
+ /** Do `bytes` begin with these byte values at `at`? */
53
+ function at(bytes, offset, signature) {
54
+ if (bytes.length < offset + signature.length)
55
+ return false;
56
+ for (let i = 0; i < signature.length; i += 1) {
57
+ if (bytes[offset + i] !== signature[i])
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+ /** The bytes of an ASCII marker, so the tables read as the specs write them. */
63
+ function ascii(text) {
64
+ return [...text].map(ch => ch.charCodeAt(0));
65
+ }
66
+ /** Little-endian uint32 at `offset`, or -1 when the buffer is too short. */
67
+ function u32le(bytes, offset) {
68
+ if (bytes.length < offset + 4)
69
+ return -1;
70
+ return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0;
71
+ }
72
+ /** DIB header sizes BMP has ever defined. A `BM` prefix alone is two bytes of
73
+ * evidence, which any text beginning "BM" would satisfy; the header size is
74
+ * what makes the match a bitmap. */
75
+ const BMP_HEADERS = [12, 16, 40, 52, 56, 64, 108, 124];
76
+ /** ISO base-media brands that carry a still image a browser will draw. */
77
+ const AVIF_BRANDS = ['avif', 'avis'];
78
+ /**
79
+ * Identify one file from its leading bytes.
80
+ *
81
+ * @param bytes - the file's content, or at least its first {@link SNIFF_BYTES}.
82
+ * @returns what it is, or null for anything not in the table.
83
+ */
84
+ export function sniffImage(bytes) {
85
+ if (bytes.length === 0)
86
+ return null;
87
+ // PNG: the eight-byte signature from the specification, chosen there
88
+ // precisely so that no other format collides with it.
89
+ if (at(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
90
+ return { mime: 'image/png', kind: 'PNG' };
91
+ }
92
+ // JPEG: SOI marker, then the first marker of the next segment.
93
+ if (at(bytes, 0, [0xff, 0xd8, 0xff])) {
94
+ return { mime: 'image/jpeg', kind: 'JPEG' };
95
+ }
96
+ if (at(bytes, 0, ascii('GIF87a')) || at(bytes, 0, ascii('GIF89a'))) {
97
+ return { mime: 'image/gif', kind: 'GIF' };
98
+ }
99
+ // WebP is a RIFF container; the four bytes after the length field are what
100
+ // separate it from a WAV or an AVI.
101
+ if (at(bytes, 0, ascii('RIFF')) && at(bytes, 8, ascii('WEBP'))) {
102
+ return { mime: 'image/webp', kind: 'WebP' };
103
+ }
104
+ // ISO base media: `ftyp` box, then the brand. Also matches HEIC and MP4,
105
+ // which is why only the still-image AVIF brands are admitted.
106
+ if (at(bytes, 4, ascii('ftyp')) && AVIF_BRANDS.some(brand => at(bytes, 8, ascii(brand)))) {
107
+ return { mime: 'image/avif', kind: 'AVIF' };
108
+ }
109
+ if (at(bytes, 0, ascii('BM')) && BMP_HEADERS.includes(u32le(bytes, 14))) {
110
+ return { mime: 'image/bmp', kind: 'BMP' };
111
+ }
112
+ // ICO: reserved zero, type 1 (icon) or 2 (cursor), then a non-zero count of
113
+ // images. The count is the check that a run of zero bytes cannot pass.
114
+ if (at(bytes, 0, [0x00, 0x00, 0x01, 0x00]) && (bytes[4] | (bytes[5] << 8)) > 0) {
115
+ return { mime: 'image/x-icon', kind: 'ICO' };
116
+ }
117
+ if (looksLikeSvg(bytes)) {
118
+ return { mime: 'image/svg+xml', kind: 'SVG' };
119
+ }
120
+ return null;
121
+ }
122
+ /**
123
+ * Does this text begin an SVG document?
124
+ *
125
+ * Structural rather than a substring search: the prologue XML allows before a
126
+ * root element is skipped, and then the root element itself must be `svg`. A
127
+ * file that merely CONTAINS `<svg` somewhere — an HTML page with an inline
128
+ * icon, a TypeScript file with a template literal — is not one.
129
+ */
130
+ function looksLikeSvg(bytes) {
131
+ const head = bytes.subarray(0, SVG_PROLOGUE_BYTES);
132
+ // A NUL rules out text before any parsing: the same test the diff side uses
133
+ // to call a file binary.
134
+ if (head.includes(0))
135
+ return false;
136
+ let text;
137
+ try {
138
+ text = new TextDecoder('utf-8', { fatal: true }).decode(head);
139
+ }
140
+ catch {
141
+ // A truncated multi-byte character at the cut is not a decode failure of
142
+ // the FILE, so retry lenient; a genuinely non-UTF-8 file yields U+FFFD,
143
+ // which no prologue below accepts.
144
+ text = new TextDecoder('utf-8').decode(head);
145
+ }
146
+ // Strip a byte-order mark, which is legal before an XML declaration.
147
+ let rest = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
148
+ for (;;) {
149
+ rest = rest.replace(/^\s+/, '');
150
+ if (rest.startsWith('<?')) {
151
+ const end = rest.indexOf('?>');
152
+ // Prologue cut off by the sniff window: undecidable, so not an image.
153
+ if (end === -1)
154
+ return false;
155
+ rest = rest.slice(end + 2);
156
+ continue;
157
+ }
158
+ if (rest.startsWith('<!--')) {
159
+ const end = rest.indexOf('-->');
160
+ if (end === -1)
161
+ return false;
162
+ rest = rest.slice(end + 3);
163
+ continue;
164
+ }
165
+ if (rest.startsWith('<!')) {
166
+ // A DOCTYPE may carry an internal subset in brackets, and the
167
+ // declarations inside it end with '>' characters of their own —
168
+ // scanning to the first one lands in the middle of the subset and the
169
+ // root element is never reached. Real files do this: matplotlib ships
170
+ // an SVG whose DOCTYPE declares an ATTLIST, and it was the one file in
171
+ // a hundred and twenty-five thousand that this missed.
172
+ const bracket = rest.indexOf('[');
173
+ const end = rest.indexOf('>');
174
+ if (end === -1)
175
+ return false;
176
+ if (bracket === -1 || bracket > end) {
177
+ rest = rest.slice(end + 1);
178
+ continue;
179
+ }
180
+ const closed = rest.indexOf(']', bracket);
181
+ if (closed === -1)
182
+ return false;
183
+ const after = rest.indexOf('>', closed);
184
+ if (after === -1)
185
+ return false;
186
+ rest = rest.slice(after + 1);
187
+ continue;
188
+ }
189
+ break;
190
+ }
191
+ // The root element, and only `svg`: the character after the name must end
192
+ // it, so `<svgfoo>` is not a match.
193
+ if (!rest.startsWith('<svg'))
194
+ return false;
195
+ const after = rest.charAt(4);
196
+ return after === '' || after === '>' || after === '/' || /\s/.test(after);
197
+ }