@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.
- package/CHANGELOG.md +42 -0
- package/CHANGELOG_EN.md +42 -0
- package/lib/apply-blocks.js +159 -0
- package/lib/atomic-json.js +23 -5
- package/lib/blame.js +83 -0
- package/lib/client.js +34811 -11818
- package/lib/fs-remove.js +73 -0
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +404 -32
- package/lib/patch-model.js +223 -0
- package/lib/side-guard.js +55 -0
- package/lib/write-checked.js +164 -0
- package/package.json +7 -1
- package/src/apply-blocks.ts +215 -0
- package/src/atomic-json.ts +29 -5
- package/src/blame.ts +94 -0
- package/src/client/CodeEditor.tsx +317 -0
- package/src/client/FileBrowser.tsx +657 -0
- package/src/client/GitWorkbenchPanel.module.css +453 -7
- package/src/client/GitWorkbenchPanel.tsx +1465 -166
- package/src/client/ImageView.tsx +120 -0
- package/src/client/blame-gutter.ts +108 -0
- package/src/client/blame-view.ts +104 -0
- package/src/client/cm-diff.ts +108 -0
- package/src/client/cm-tokens.ts +79 -0
- package/src/client/diff-nav.ts +198 -0
- package/src/client/discard-flow.ts +82 -0
- package/src/client/file-icon.ts +190 -0
- package/src/client/file-rows.ts +184 -0
- package/src/client/files-place.ts +178 -0
- package/src/client/glyphs.tsx +86 -0
- package/src/client/highlight.ts +25 -0
- package/src/client/idle-value.ts +53 -0
- package/src/client/image-view.ts +106 -0
- package/src/client/indent.ts +74 -0
- package/src/client/index.ts +76 -9
- package/src/client/locales.ts +171 -4
- package/src/client/pane-size.ts +71 -0
- package/src/client/side-edit.ts +244 -0
- package/src/client/side-rows.ts +258 -0
- package/src/client/stable-list.ts +31 -0
- package/src/client/use-change-nav.ts +83 -0
- package/src/client/worktree-view.ts +11 -1
- package/src/fs-remove.ts +76 -0
- package/src/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +450 -32
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- package/src/write-checked.ts +223 -0
package/lib/fs-remove.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
import { rm } from 'node:fs/promises';
|
|
19
|
+
import { resolve, sep } from 'node:path';
|
|
20
|
+
import { isSafeRelativePath } from './discard-ops.js';
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a repo-relative path against the worktree root, refusing to leave it.
|
|
23
|
+
*
|
|
24
|
+
* The second lock rather than the only one: {@link isSafeRelativePath} already
|
|
25
|
+
* rejected traversal spellings when the plan was made. This re-checks the
|
|
26
|
+
* RESOLVED path, which is the form the filesystem acts on, so a path that
|
|
27
|
+
* survives the first check by being spelled unusually still has to land inside
|
|
28
|
+
* the root to be acted on.
|
|
29
|
+
*
|
|
30
|
+
* @param root - the worktree directory, absolute.
|
|
31
|
+
* @param relative - repo-relative path from a plan step.
|
|
32
|
+
* @returns the absolute path to act on.
|
|
33
|
+
* @throws if the path is not a safe relative path, resolves outside the root,
|
|
34
|
+
* or IS the root.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveInside(root, relative) {
|
|
37
|
+
if (!isSafeRelativePath(relative)) {
|
|
38
|
+
throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`);
|
|
39
|
+
}
|
|
40
|
+
const base = resolve(root);
|
|
41
|
+
const target = resolve(base, relative);
|
|
42
|
+
if (target === base)
|
|
43
|
+
throw new Error('refusing to delete the worktree root');
|
|
44
|
+
if (!target.startsWith(base + sep)) {
|
|
45
|
+
throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`);
|
|
46
|
+
}
|
|
47
|
+
return target;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Remove one entry from the worktree, having proven it is inside it.
|
|
51
|
+
*
|
|
52
|
+
* `recursive` is not a widening of the blast radius: `resolveInside` has
|
|
53
|
+
* already pinned the target to one path git named, and git names a DIRECTORY
|
|
54
|
+
* whenever it will not look inside one — an untracked nested repository is
|
|
55
|
+
* reported as `sub/`, with no per-file lines even under
|
|
56
|
+
* `--untracked-files=all`. Without `recursive` that row is the only one in the
|
|
57
|
+
* drawer whose roll-back fails, and it fails as `EISDIR`, which says nothing
|
|
58
|
+
* to the person who clicked it.
|
|
59
|
+
*
|
|
60
|
+
* `force` makes an absent entry a success: the reader asked for it to be gone,
|
|
61
|
+
* and it is.
|
|
62
|
+
*
|
|
63
|
+
* A symlinked directory inside the worktree could still point outward; that is
|
|
64
|
+
* a repository someone already has write access to, and resolving link targets
|
|
65
|
+
* per segment on every delete would cost a stat per segment for a case git
|
|
66
|
+
* itself does not defend against.
|
|
67
|
+
*
|
|
68
|
+
* @param root - the worktree directory, absolute.
|
|
69
|
+
* @param relative - repo-relative path from a plan step.
|
|
70
|
+
*/
|
|
71
|
+
export async function removePathInside(root, relative) {
|
|
72
|
+
await rm(resolveInside(root, relative), { recursive: true, force: true });
|
|
73
|
+
}
|
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
|
+
}
|