@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.
- package/CHANGELOG.md +43 -0
- package/CHANGELOG_EN.md +43 -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 +34960 -11826
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +401 -7
- 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 +491 -12
- package/src/client/GitWorkbenchPanel.tsx +1655 -190
- 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/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/history-layout.ts +52 -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 +59 -0
- package/src/client/locales.ts +179 -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/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +447 -7
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- package/src/write-checked.ts +223 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A unified diff, parsed so that a SUBSET of it can be emitted back as a patch
|
|
3
|
+
* `git apply` will accept.
|
|
4
|
+
*
|
|
5
|
+
* `diff-model.ts` already parses diffs, but for rendering: `parseRows` throws
|
|
6
|
+
* the file headers away and keeps only line numbers from the hunk header, which
|
|
7
|
+
* is everything a reader needs and nothing a patch needs. Re-emitting is the
|
|
8
|
+
* whole point here, so this model keeps what that one drops.
|
|
9
|
+
*
|
|
10
|
+
* The rules for a partial selection are git's own, from `add -p`, and each one
|
|
11
|
+
* is a statement about the file the patch will be applied to:
|
|
12
|
+
*
|
|
13
|
+
* - a selected `+` line is emitted as `+` — it is being added;
|
|
14
|
+
* - an UNSELECTED `+` line is dropped entirely — it is not part of this
|
|
15
|
+
* patch, and the target does not have it;
|
|
16
|
+
* - a selected `-` line is emitted as `-` — it is being removed;
|
|
17
|
+
* - an UNSELECTED `-` line becomes CONTEXT — the target still has that line,
|
|
18
|
+
* and claiming otherwise makes the patch fail to apply.
|
|
19
|
+
*
|
|
20
|
+
* Those are the rules for a FORWARD apply, where the target holds the patch's
|
|
21
|
+
* pre-image. A patch meant for `git apply --REVERSE` meets the target holding
|
|
22
|
+
* the POST-image, so the unselected treatments mirror: an unselected `+` line
|
|
23
|
+
* becomes context (the target has it) and an unselected `-` line is dropped
|
|
24
|
+
* (the target does not). Confirmed against git itself: reverse-applying a
|
|
25
|
+
* forward-style subset to a working tree that also holds another block's
|
|
26
|
+
* unselected addition fails as "patch does not apply", because the dropped
|
|
27
|
+
* line is missing from the post-image git is trying to match.
|
|
28
|
+
*
|
|
29
|
+
* Both counts are then recomputed from what was actually emitted. That
|
|
30
|
+
* arithmetic is the reason this module exists as a tested unit: a wrong count
|
|
31
|
+
* makes `git apply` fail as "corrupt patch", which tells the reader their diff
|
|
32
|
+
* is broken, when the truth would have been "the file changed under you" —
|
|
33
|
+
* the message the context check is there to produce.
|
|
34
|
+
*
|
|
35
|
+
* Pure: no React, no CSS, no git. `tests/patch-model.test.ts` loads it directly.
|
|
36
|
+
*
|
|
37
|
+
* @module @young1lin/dsh-ui-gitworkbench/patch-model
|
|
38
|
+
*/
|
|
39
|
+
/** Take the whole diff. */
|
|
40
|
+
export const selectAll = () => true;
|
|
41
|
+
/** Take none of it — {@link emitPatch} then returns ''. */
|
|
42
|
+
export const selectNone = () => false;
|
|
43
|
+
const HUNK_HEAD = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
44
|
+
/**
|
|
45
|
+
* Parse one file's unified diff.
|
|
46
|
+
*
|
|
47
|
+
* @param text - a single file's diff, as `git diff -- <path>` prints it.
|
|
48
|
+
* @returns the parsed patch, or null when the text carries no hunk at all —
|
|
49
|
+
* an empty diff, or a binary one, which says `Binary files … differ`
|
|
50
|
+
* and cannot be applied line by line.
|
|
51
|
+
*/
|
|
52
|
+
export function parsePatch(text) {
|
|
53
|
+
if (text.length === 0)
|
|
54
|
+
return null;
|
|
55
|
+
const lines = text.split('\n');
|
|
56
|
+
const trailingNewline = lines.length > 0 && lines[lines.length - 1] === '';
|
|
57
|
+
if (trailingNewline)
|
|
58
|
+
lines.pop();
|
|
59
|
+
const header = [];
|
|
60
|
+
const hunks = [];
|
|
61
|
+
let current = null;
|
|
62
|
+
const close = () => {
|
|
63
|
+
if (current === null)
|
|
64
|
+
return;
|
|
65
|
+
const [, oldStart, oldCount, newStart, newCount, heading] = current.head;
|
|
66
|
+
hunks.push({
|
|
67
|
+
oldStart: Number.parseInt(oldStart, 10),
|
|
68
|
+
// The unified format allows the count to be omitted when it is 1.
|
|
69
|
+
oldCount: oldCount === undefined ? 1 : Number.parseInt(oldCount, 10),
|
|
70
|
+
newStart: Number.parseInt(newStart, 10),
|
|
71
|
+
newCount: newCount === undefined ? 1 : Number.parseInt(newCount, 10),
|
|
72
|
+
heading: heading ?? '',
|
|
73
|
+
oldCountOmitted: oldCount === undefined,
|
|
74
|
+
newCountOmitted: newCount === undefined,
|
|
75
|
+
lines: current.lines,
|
|
76
|
+
});
|
|
77
|
+
current = null;
|
|
78
|
+
};
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const head = HUNK_HEAD.exec(line);
|
|
81
|
+
if (head !== null) {
|
|
82
|
+
close();
|
|
83
|
+
current = { head, lines: [] };
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (current === null) {
|
|
87
|
+
header.push(line);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (line.startsWith('\\'))
|
|
91
|
+
current.lines.push({ kind: 'nonewline', text: '' });
|
|
92
|
+
else if (line.startsWith('+'))
|
|
93
|
+
current.lines.push({ kind: 'add', text: line.slice(1) });
|
|
94
|
+
else if (line.startsWith('-'))
|
|
95
|
+
current.lines.push({ kind: 'del', text: line.slice(1) });
|
|
96
|
+
// A context line is ' ' plus content, but git emits a BARE empty line for
|
|
97
|
+
// an empty one; slicing that would be harmless and reading it as anything
|
|
98
|
+
// else would drop the line.
|
|
99
|
+
else
|
|
100
|
+
current.lines.push({ kind: 'context', text: line.startsWith(' ') ? line.slice(1) : line });
|
|
101
|
+
}
|
|
102
|
+
close();
|
|
103
|
+
if (hunks.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
return { header, hunks, trailingNewline };
|
|
106
|
+
}
|
|
107
|
+
/** Spell a count the way the source spelled it, when that is unambiguous. */
|
|
108
|
+
function count(value, omitted) {
|
|
109
|
+
return value === 1 && omitted ? '' : `,${value}`;
|
|
110
|
+
}
|
|
111
|
+
/** Apply the selection rules to one hunk's lines. */
|
|
112
|
+
function emitHunkLines(hunk, hunkIndex, isSelected, reverseApply) {
|
|
113
|
+
const out = [];
|
|
114
|
+
let oldCount = 0;
|
|
115
|
+
let newCount = 0;
|
|
116
|
+
let changed = false;
|
|
117
|
+
// Whether the line the next `\ No newline` marker would describe survived.
|
|
118
|
+
let lastKept = false;
|
|
119
|
+
hunk.lines.forEach((line, lineIndex) => {
|
|
120
|
+
if (line.kind === 'nonewline') {
|
|
121
|
+
// The marker describes the line before it. Emitting it after a line that
|
|
122
|
+
// is no longer in the patch makes git read it as describing a different
|
|
123
|
+
// line entirely.
|
|
124
|
+
if (lastKept)
|
|
125
|
+
out.push('\');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (line.kind === 'context') {
|
|
129
|
+
out.push(` ${line.text}`);
|
|
130
|
+
oldCount += 1;
|
|
131
|
+
newCount += 1;
|
|
132
|
+
lastKept = true;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const selected = isSelected(hunkIndex, lineIndex);
|
|
136
|
+
if (line.kind === 'add') {
|
|
137
|
+
// Forward apply: the target does not have an unselected addition, so it
|
|
138
|
+
// is not part of this patch. Reverse apply: the target DOES have it — it
|
|
139
|
+
// holds the post-image — so it has to be context or the patch will not
|
|
140
|
+
// match.
|
|
141
|
+
if (!selected && !reverseApply) {
|
|
142
|
+
lastKept = false;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!selected) {
|
|
146
|
+
out.push(` ${line.text}`);
|
|
147
|
+
oldCount += 1;
|
|
148
|
+
newCount += 1;
|
|
149
|
+
lastKept = true;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
out.push(`+${line.text}`);
|
|
153
|
+
newCount += 1;
|
|
154
|
+
changed = true;
|
|
155
|
+
lastKept = true;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// A deletion that is not part of this patch still EXISTS in a forward
|
|
159
|
+
// apply's target (the pre-image), so it is presented as context. A reverse
|
|
160
|
+
// apply's target never had it, so it is dropped instead.
|
|
161
|
+
if (!selected) {
|
|
162
|
+
if (reverseApply) {
|
|
163
|
+
lastKept = false;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
out.push(` ${line.text}`);
|
|
167
|
+
oldCount += 1;
|
|
168
|
+
newCount += 1;
|
|
169
|
+
lastKept = true;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
out.push(`-${line.text}`);
|
|
173
|
+
oldCount += 1;
|
|
174
|
+
changed = true;
|
|
175
|
+
lastKept = true;
|
|
176
|
+
});
|
|
177
|
+
return { lines: out, oldCount, newCount, changed };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Emit the selected part of a patch as text `git apply` will accept.
|
|
181
|
+
*
|
|
182
|
+
* A hunk with nothing selected is dropped whole: a hunk of pure context is
|
|
183
|
+
* valid but pointless, and a patch of nothing but such hunks is a no-op git
|
|
184
|
+
* would still report as applied.
|
|
185
|
+
*
|
|
186
|
+
* The new-side start of each hunk is recomputed by how much the hunks BEFORE
|
|
187
|
+
* it (in this patch) actually shift the file. Taking one of two added lines
|
|
188
|
+
* moves everything after it by one, and a header that still claims the
|
|
189
|
+
* original offset describes a file that will not exist.
|
|
190
|
+
*
|
|
191
|
+
* @param file - a parsed patch.
|
|
192
|
+
* @param isSelected - which changed lines to take.
|
|
193
|
+
* @param reverseApply - emit for `git apply --reverse`, whose target holds the
|
|
194
|
+
* patch's POST-image rather than its pre-image: unselected additions
|
|
195
|
+
* become context and unselected deletions are dropped (the mirror of
|
|
196
|
+
* the forward rules). Selected lines keep their sign either way.
|
|
197
|
+
* @returns the patch text, or '' when the selection is empty. The caller must
|
|
198
|
+
* treat '' as "nothing to do" and make no git call: a patch with no
|
|
199
|
+
* hunks is an error to `git apply`, not a no-op.
|
|
200
|
+
*/
|
|
201
|
+
export function emitPatch(file, isSelected, reverseApply = false) {
|
|
202
|
+
const body = [];
|
|
203
|
+
// Set from the first hunk that survives, so a patch starting at hunk 2 keeps
|
|
204
|
+
// that hunk's own offset rather than inheriting one from hunks left out.
|
|
205
|
+
let delta = null;
|
|
206
|
+
file.hunks.forEach((hunk, hunkIndex) => {
|
|
207
|
+
const emitted = emitHunkLines(hunk, hunkIndex, isSelected, reverseApply);
|
|
208
|
+
if (!emitted.changed)
|
|
209
|
+
return;
|
|
210
|
+
if (delta === null)
|
|
211
|
+
delta = hunk.newStart - hunk.oldStart;
|
|
212
|
+
const newStart = hunk.oldStart + delta;
|
|
213
|
+
const old = count(emitted.oldCount, hunk.oldCountOmitted);
|
|
214
|
+
const fresh = count(emitted.newCount, hunk.newCountOmitted);
|
|
215
|
+
body.push(`@@ -${hunk.oldStart}${old} +${newStart}${fresh} @@${hunk.heading}`);
|
|
216
|
+
body.push(...emitted.lines);
|
|
217
|
+
delta += emitted.newCount - emitted.oldCount;
|
|
218
|
+
});
|
|
219
|
+
if (body.length === 0)
|
|
220
|
+
return '';
|
|
221
|
+
const text = [...file.header, ...body].join('\n');
|
|
222
|
+
return file.trailingNewline ? `${text}\n` : text;
|
|
223
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side-by-side diff pane's size guard, as a decision rather than two
|
|
3
|
+
* scattered comparisons.
|
|
4
|
+
*
|
|
5
|
+
* The guard exists for the wire, not the reader: a full-context patch carries
|
|
6
|
+
* the WHOLE file, so past these caps "a 2 MB patch per click is not a
|
|
7
|
+
* reasonable wire payload and the side-by-side DOM would be enormous" (the
|
|
8
|
+
* design spec's own words for why the pane declines).
|
|
9
|
+
*
|
|
10
|
+
* Two measurements, because they bound different things:
|
|
11
|
+
*
|
|
12
|
+
* - the TARGET (right-hand) file's byte size and line count — checked
|
|
13
|
+
* BEFORE the diff is produced, so declining a pathological file never
|
|
14
|
+
* means reading it whole first;
|
|
15
|
+
* - the produced diff text itself — checked AFTER, because the target-side
|
|
16
|
+
* check has two blind spots the review caught: a file DELETED from the
|
|
17
|
+
* working tree has no target to measure while its diff is the entire old
|
|
18
|
+
* file as del lines, and a huge LEFT side behind a small target (a 5 MB
|
|
19
|
+
* index blob trimmed to 50 lines) passes the target guard while the patch
|
|
20
|
+
* still carries the 5 MB old side. Only the diff text sees both.
|
|
21
|
+
*
|
|
22
|
+
* Pure: no React, no CSS, no git, no node. `tests/side-guard.test.ts` loads
|
|
23
|
+
* it directly, defeat cases included.
|
|
24
|
+
*
|
|
25
|
+
* @module @young1lin/dsh-ui-gitworkbench/side-guard
|
|
26
|
+
*/
|
|
27
|
+
/** Target side past this many bytes is declined. */
|
|
28
|
+
export const SIDE_BYTE_CAP = 2_000_000;
|
|
29
|
+
/** Target side past this many lines is declined. */
|
|
30
|
+
export const SIDE_LINE_CAP = 20_000;
|
|
31
|
+
/** Diff text past this many characters is declined — the guard's own 2 MB
|
|
32
|
+
* budget applied to the payload itself, `clipDiff`'s character convention. */
|
|
33
|
+
export const SIDE_DIFF_CHAR_CAP = 2_000_000;
|
|
34
|
+
/**
|
|
35
|
+
* The before-the-diff half: does the layer's right-hand file already exceed
|
|
36
|
+
* the guard?
|
|
37
|
+
*
|
|
38
|
+
* @param byteLength - the target's size in bytes.
|
|
39
|
+
* @param lineCount - the target's line count.
|
|
40
|
+
* @returns true when the pane should decline without producing a diff.
|
|
41
|
+
*/
|
|
42
|
+
export function targetTooLarge(byteLength, lineCount) {
|
|
43
|
+
return byteLength > SIDE_BYTE_CAP || lineCount > SIDE_LINE_CAP;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The after-the-diff half: does the patch text itself exceed the guard? This
|
|
47
|
+
* is what bounds the wire payload when the target measurement cannot — a
|
|
48
|
+
* deleted working-tree file, or a huge left side behind a small target.
|
|
49
|
+
*
|
|
50
|
+
* @param diff - the layer's full-context diff, exactly as it would be returned.
|
|
51
|
+
* @returns true when the pane should decline instead of shipping it.
|
|
52
|
+
*/
|
|
53
|
+
export function diffTooLarge(diff) {
|
|
54
|
+
return diff.length > SIDE_DIFF_CHAR_CAP;
|
|
55
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `writeChecked` sequence: save the editor's buffer over the working-tree
|
|
3
|
+
* file it was opened from, refusing when the file moved underneath the editor.
|
|
4
|
+
*
|
|
5
|
+
* The sha check is the whole point of this RPC. The drawer shares its
|
|
6
|
+
* worktrees with an agent that writes the same files, and the poll only says
|
|
7
|
+
* how fast the drawer *notices* — so the one hard rule is that a write lands
|
|
8
|
+
* only when the file's blob sha is still the one the editor opened with. A
|
|
9
|
+
* client that lies about that sha gains nothing: the sha is re-derived from
|
|
10
|
+
* what git says at the moment of the write, the same never-trust-the-client
|
|
11
|
+
* shape as `discardFile`'s `expectedEffect`.
|
|
12
|
+
*
|
|
13
|
+
* `expectedSha === ''` means "the file did not exist when I opened it" (a
|
|
14
|
+
* buffer opened on a file deleted meanwhile), and is accepted only while the
|
|
15
|
+
* file is still absent. The not-exist/hash-failure distinction is made
|
|
16
|
+
* explicitly, with a filesystem stat: a spawn error must surface as a failed
|
|
17
|
+
* save, never masquerade as "exists with a different sha" — that would look
|
|
18
|
+
* like the stale case while silently skipping the guard's real work.
|
|
19
|
+
*
|
|
20
|
+
* The sequence's every branch lives here rather than in `index.ts`, because
|
|
21
|
+
* `index.ts` extends the RPC service class and imports its dsh peers as values
|
|
22
|
+
* — vitest cannot load it. Everything git- or filesystem-shaped is injected
|
|
23
|
+
* (`WriteCheckedIo`), which is also what lets the git-backed tests drive this
|
|
24
|
+
* exact code with a real git in a temp repo.
|
|
25
|
+
*
|
|
26
|
+
* There is deliberately no `writeFile(path, content)` without the sha check
|
|
27
|
+
* anywhere in this plugin, and none may be added: an unchecked write is a
|
|
28
|
+
* clobber-a-concurrent-agent primitive.
|
|
29
|
+
*
|
|
30
|
+
* @module @young1lin/dsh-ui-gitworkbench/write-checked
|
|
31
|
+
*/
|
|
32
|
+
import { randomBytes } from 'node:crypto';
|
|
33
|
+
import { renameWithRetry } from './atomic-json.js';
|
|
34
|
+
import { resolveInside } from './fs-remove.js';
|
|
35
|
+
import { decodesAsUtf8, isSafePathArg } from './git-ops.js';
|
|
36
|
+
/**
|
|
37
|
+
* Run one checked write end to end. Never throws: every failure, including an
|
|
38
|
+
* IO failure, is a result the RPC can carry back as a sentence.
|
|
39
|
+
*
|
|
40
|
+
* The order of the steps is the contract: the path lock, then the sha as git
|
|
41
|
+
* reads it RIGHT NOW, then the refusal before anything is staged, then the
|
|
42
|
+
* atomic write, and only then the sha the next save will be checked against.
|
|
43
|
+
*/
|
|
44
|
+
export async function runWriteChecked(io, cwd, path, text, expectedSha) {
|
|
45
|
+
try {
|
|
46
|
+
return await writeChecked(io, cwd, path, text, expectedSha);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
// Nothing may throw across the RPC boundary: a failed helper is a failed
|
|
50
|
+
// save with a message, not a broken call.
|
|
51
|
+
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function writeChecked(io, cwd, path, text, expectedSha) {
|
|
55
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
56
|
+
return { ok: false, failure: 'invalid', error: `unsafe path argument: ${JSON.stringify(path)}` };
|
|
57
|
+
}
|
|
58
|
+
if (typeof text !== 'string' || typeof expectedSha !== 'string') {
|
|
59
|
+
return { ok: false, failure: 'invalid', error: 'text and expectedSha must be strings' };
|
|
60
|
+
}
|
|
61
|
+
// The path lock every filesystem write in this plugin passes — the same
|
|
62
|
+
// `resolveInside`, not a second one. It throws on traversal spellings and on
|
|
63
|
+
// anything resolving outside the worktree.
|
|
64
|
+
let target;
|
|
65
|
+
try {
|
|
66
|
+
target = resolveInside(cwd, path);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return { ok: false, failure: 'invalid', error: error instanceof Error ? error.message : String(error) };
|
|
70
|
+
}
|
|
71
|
+
// Encoding before anything else, because it is a permanent property of the
|
|
72
|
+
// file rather than a race. The buffer reached the browser as a UTF-8 decode
|
|
73
|
+
// of these bytes; for a file in any other encoding that decode was LOSSY,
|
|
74
|
+
// and writing the result back replaces every non-ASCII byte in the file —
|
|
75
|
+
// including the lines nobody edited. The client withholds the editor for
|
|
76
|
+
// such a file, and this refuses the write regardless, because a client's
|
|
77
|
+
// word is not what this RPC trusts.
|
|
78
|
+
let current;
|
|
79
|
+
const present = await io.exists(target);
|
|
80
|
+
if (present) {
|
|
81
|
+
let bytes;
|
|
82
|
+
try {
|
|
83
|
+
bytes = await io.readBytes(target);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false, failure: 'unknown',
|
|
88
|
+
error: `could not read ${path} to check its encoding: ${error instanceof Error ? error.message : String(error)}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!decodesAsUtf8(bytes)) {
|
|
92
|
+
return { ok: false, failure: 'invalid', error: notUtf8Message(path) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// The sha as git reads it now. Absent is '', by stat rather than by reading
|
|
96
|
+
// a spawn failure into it; a file that IS there but will not hash is a
|
|
97
|
+
// failed save, never a stale one.
|
|
98
|
+
if (!present) {
|
|
99
|
+
current = '';
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const hashed = await io.git(cwd, ['hash-object', '--', path]);
|
|
103
|
+
if (hashed.exitCode !== 0) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false, failure: 'unknown',
|
|
106
|
+
error: `git hash-object failed (exit ${hashed.exitCode}) for ${path}${hashed.stderr.length > 0 ? `: ${hashed.stderr}` : ''}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
current = hashed.stdout.trim();
|
|
110
|
+
}
|
|
111
|
+
if (current !== expectedSha) {
|
|
112
|
+
return { ok: false, failure: 'stale', error: staleMessage(path, expectedSha, current) };
|
|
113
|
+
}
|
|
114
|
+
// Atomic write: stage the bytes beside the target (same directory, so the
|
|
115
|
+
// rename stays inside one filesystem), then rename over it with the shared
|
|
116
|
+
// Windows-EPERM retry. The buffer is written as bytes exactly as received —
|
|
117
|
+
// no newline translation anywhere in this path.
|
|
118
|
+
const tmp = `${target}.gwtmp-${randomBytes(6).toString('hex')}`;
|
|
119
|
+
try {
|
|
120
|
+
await io.writeBytes(tmp, Buffer.from(text, 'utf8'));
|
|
121
|
+
await renameWithRetry(io.rename, io.delay, tmp, target);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
// The write did not land; the temp must not linger in the tree as a
|
|
125
|
+
// phantom untracked file. Cleanup must never mask the result it follows.
|
|
126
|
+
await io.remove(tmp).catch(() => { });
|
|
127
|
+
return {
|
|
128
|
+
ok: false, failure: 'unknown',
|
|
129
|
+
error: `could not write ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// The sha the NEXT save is checked against, read back from the file that is
|
|
133
|
+
// now on disk.
|
|
134
|
+
const after = await io.git(cwd, ['hash-object', '--', path]);
|
|
135
|
+
if (after.exitCode !== 0) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false, failure: 'unknown',
|
|
138
|
+
error: `${path} was written but its new sha could not be read (exit ${after.exitCode})${after.stderr.length > 0 ? `: ${after.stderr}` : ''}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, sha: after.stdout.trim() };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Why an encoding refusal happened, in the reader's terms. It names the path
|
|
145
|
+
* for the same reason the stale message does — the banner sits under a file
|
|
146
|
+
* tab the reader may already have switched away from.
|
|
147
|
+
*/
|
|
148
|
+
function notUtf8Message(path) {
|
|
149
|
+
return `${path} is not UTF-8, so editing it here would rewrite every non-ASCII byte; nothing was written`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Why the save was refused, in the reader's terms: which way the file moved.
|
|
153
|
+
* Every spelling names the path, because the banner this lands in sits under a
|
|
154
|
+
* file tab the reader may already have switched away from.
|
|
155
|
+
*/
|
|
156
|
+
function staleMessage(path, expectedSha, current) {
|
|
157
|
+
if (expectedSha === '') {
|
|
158
|
+
return `${path} was created while you were editing it; nothing was written`;
|
|
159
|
+
}
|
|
160
|
+
if (current === '') {
|
|
161
|
+
return `${path} was deleted while you were editing it; nothing was written`;
|
|
162
|
+
}
|
|
163
|
+
return `${path} changed while you were editing it; nothing was written`;
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@young1lin/dsh-ui-gitworkbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -82,6 +82,12 @@
|
|
|
82
82
|
"vitest": "^4.1.10"
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
|
+
"@codemirror/commands": "6.11.0",
|
|
86
|
+
"@codemirror/language": "6.12.4",
|
|
87
|
+
"@codemirror/merge": "6.12.2",
|
|
88
|
+
"@codemirror/search": "6.7.1",
|
|
89
|
+
"@codemirror/state": "6.7.1",
|
|
90
|
+
"@codemirror/view": "6.43.9",
|
|
85
91
|
"@shikijs/langs": "4.3.1",
|
|
86
92
|
"shiki": "4.3.1"
|
|
87
93
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `applyBlocks` sequence: turn "these hunk lines of this block" into a git
|
|
3
|
+
* index or working-tree mutation.
|
|
4
|
+
*
|
|
5
|
+
* The client sends a SELECTION, never patch text — a patch is a file-addressing
|
|
6
|
+
* format, and accepting one from the browser would be a write primitive with a
|
|
7
|
+
* path argument. So the host re-fetches the layer's diff itself, proves the
|
|
8
|
+
* client is describing the same snapshot (sha1 of the fetched text must equal
|
|
9
|
+
* the `diffSha` the pane rendered), and only then emits and applies.
|
|
10
|
+
*
|
|
11
|
+
* The sequence's every branch lives here rather than in `index.ts`, because
|
|
12
|
+
* `index.ts` extends the RPC service class and imports its dsh peers as values
|
|
13
|
+
* — vitest cannot load it. Everything git- or filesystem-shaped is injected
|
|
14
|
+
* (`ApplyBlocksIo`), which is also what lets the git-backed tests drive this
|
|
15
|
+
* exact code with a real git in a temp repo instead of a mock of it.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here builds a destructive command: the whole vocabulary is
|
|
18
|
+
* `git apply`, forward into the index (`--cached`) or reverse out of the index
|
|
19
|
+
* or the working tree (`--reverse`). The one argv that carries file content is
|
|
20
|
+
* the host-written tmpfile, never a client string; the client's `path` is
|
|
21
|
+
* checked with `isSafePathArg` and reaches git only inside the layer-diff
|
|
22
|
+
* fetch, behind `--`, as every other pathspec in this plugin is.
|
|
23
|
+
*
|
|
24
|
+
* @module @young1lin/dsh-ui-gitworkbench/apply-blocks
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createHash } from 'node:crypto'
|
|
28
|
+
|
|
29
|
+
import { classifyFailure, isSafePathArg, type OpFailure } from './git-ops.js'
|
|
30
|
+
import { emitPatch, parsePatch, type LineSelector } from './patch-model.js'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a mode applies its patch with `--reverse`, which decides the
|
|
34
|
+
* emission rules: a reverse apply's target holds the patch's post-image (the
|
|
35
|
+
* index for `unstage`, the working tree for `discard`), so unselected lines
|
|
36
|
+
* must be presented the post-image sees them — `emitPatch`'s third argument.
|
|
37
|
+
*/
|
|
38
|
+
function appliesInReverse(mode: string): boolean {
|
|
39
|
+
return mode !== 'stage'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The three block mutations, in the client's vocabulary. */
|
|
43
|
+
export type ApplyMode = 'stage' | 'unstage' | 'discard'
|
|
44
|
+
|
|
45
|
+
/** One git run, in the shape `index.ts`'s `git()` helper already returns. */
|
|
46
|
+
export interface GitRun {
|
|
47
|
+
readonly stdout: string
|
|
48
|
+
readonly exitCode: number
|
|
49
|
+
readonly stderr: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The result of one `applyBlocks` call — `GitOpResult`, structurally. */
|
|
53
|
+
export interface ApplyBlocksResult {
|
|
54
|
+
readonly ok: boolean
|
|
55
|
+
readonly failure?: OpFailure
|
|
56
|
+
readonly error?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Everything the sequence needs from its host, as injected dependencies.
|
|
61
|
+
*
|
|
62
|
+
* `index.ts` binds its own `git()` spawn helper, the layer-diff fetch it shares
|
|
63
|
+
* with `fileSides`, and the tmpfile pair; the tests bind a real git in a temp
|
|
64
|
+
* repo and a patch file inside it. Either way the decisions below are the same
|
|
65
|
+
* code — there is no second implementation to fall out of step with.
|
|
66
|
+
*/
|
|
67
|
+
export interface ApplyBlocksIo {
|
|
68
|
+
/** Run git in `cwd`. Must not throw — report through `exitCode`/`stderr`. */
|
|
69
|
+
readonly git: (cwd: string, argv: readonly string[]) => Promise<GitRun>
|
|
70
|
+
/** The layer's full-context diff — the exact fetch `fileSides` serves. */
|
|
71
|
+
readonly layerDiff: (path: string, layer: string) => Promise<string>
|
|
72
|
+
/** Write patch text where git can read it; returns the path. */
|
|
73
|
+
readonly writePatch: (text: string) => Promise<string>
|
|
74
|
+
/** Delete what `writePatch` produced. Called from a `finally`. */
|
|
75
|
+
readonly dropPatch: (path: string) => Promise<void>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The mode/layer matrix as argv: which `git apply` spelling carries each
|
|
80
|
+
* mutation, and the one layer each is valid on.
|
|
81
|
+
*
|
|
82
|
+
* @returns the argv head for the mode, or null when the pair is not one the
|
|
83
|
+
* design defines — the caller reports it, never guesses a near one.
|
|
84
|
+
*/
|
|
85
|
+
export function applyArgvFor(mode: string, layer: string): readonly string[] | null {
|
|
86
|
+
if (mode === 'stage' && layer === 'unstaged') return ['apply', '--cached']
|
|
87
|
+
if (mode === 'unstage' && layer === 'staged') return ['apply', '--cached', '--reverse']
|
|
88
|
+
if (mode === 'discard' && layer === 'unstaged') return ['apply', '--reverse']
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* sha1 of a string, hex — the `diffSha` contract.
|
|
94
|
+
*
|
|
95
|
+
* This was a private helper of `index.ts` until the checker needed to be the
|
|
96
|
+
* same code as the producer: `fileSides` stamps the diff it returns and
|
|
97
|
+
* `applyBlocks` re-derives the stamp over its own fresh fetch, and the two ends
|
|
98
|
+
* of that comparison must be one function or the comparison means nothing.
|
|
99
|
+
*/
|
|
100
|
+
export function sha1Hex(text: string): string {
|
|
101
|
+
return createHash('sha1').update(text, 'utf8').digest('hex')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The selector `emitPatch` takes for a block's line indices: a hunk line is
|
|
106
|
+
* selected iff its index appears in `lines`.
|
|
107
|
+
*
|
|
108
|
+
* The indices are positional into the one hunk's `lines` — the same array
|
|
109
|
+
* `side-rows.blockLines` read client-side. Only integers ≥ 0 count: `lines`
|
|
110
|
+
* crosses the RPC boundary untyped, and a stray string or float selecting
|
|
111
|
+
* nothing is the safe direction (an empty selection is a no-op, not an error).
|
|
112
|
+
*/
|
|
113
|
+
export function lineSelector(lines: readonly number[]): LineSelector {
|
|
114
|
+
const list = Array.isArray(lines) ? lines : []
|
|
115
|
+
const wanted = new Set(list.filter(line => Number.isInteger(line) && line >= 0))
|
|
116
|
+
return (_hunkIndex: number, lineIndex: number) => wanted.has(lineIndex)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run one block mutation end to end. Never throws: every failure, including an
|
|
121
|
+
* IO failure, is a result the RPC can carry back as a sentence.
|
|
122
|
+
*
|
|
123
|
+
* The order of the steps is the contract: the stale check before anything is
|
|
124
|
+
* emitted, the multi-hunk guard before anything is applied, `--check` before
|
|
125
|
+
* the apply that matters, and the tmpfile deleted whichever way it ends.
|
|
126
|
+
*/
|
|
127
|
+
export async function runApplyBlocks(
|
|
128
|
+
io: ApplyBlocksIo,
|
|
129
|
+
cwd: string,
|
|
130
|
+
path: string,
|
|
131
|
+
layer: string,
|
|
132
|
+
diffSha: string,
|
|
133
|
+
lines: readonly number[],
|
|
134
|
+
mode: string,
|
|
135
|
+
): Promise<ApplyBlocksResult> {
|
|
136
|
+
try {
|
|
137
|
+
return await applyBlocksChecked(io, cwd, path, layer, diffSha, lines, mode)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// Nothing may throw across the RPC boundary: a failed helper is a failed
|
|
140
|
+
// operation with a message, not a broken call.
|
|
141
|
+
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function applyBlocksChecked(
|
|
146
|
+
io: ApplyBlocksIo,
|
|
147
|
+
cwd: string,
|
|
148
|
+
path: string,
|
|
149
|
+
layer: string,
|
|
150
|
+
diffSha: string,
|
|
151
|
+
lines: readonly number[],
|
|
152
|
+
mode: string,
|
|
153
|
+
): Promise<ApplyBlocksResult> {
|
|
154
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
155
|
+
return { ok: false, failure: 'invalid', error: `unsafe path argument: ${JSON.stringify(path)}` }
|
|
156
|
+
}
|
|
157
|
+
const argv = applyArgvFor(mode, layer)
|
|
158
|
+
if (argv === null) {
|
|
159
|
+
return { ok: false, failure: 'invalid', error: `cannot ${String(mode)} a block on the ${String(layer)} layer` }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The stale check. The selection's line indices have meaning only against
|
|
163
|
+
// the exact diff the pane rendered; a file that changed since makes them
|
|
164
|
+
// point at different lines, so nothing is emitted, let alone applied.
|
|
165
|
+
const diff = await io.layerDiff(path, layer)
|
|
166
|
+
if (sha1Hex(diff) !== diffSha) {
|
|
167
|
+
return { ok: false, failure: 'stale', error: `${path} changed since the diff was loaded; nothing was applied` }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const file = parsePatch(diff)
|
|
171
|
+
// No hunk at all (empty or binary diff, with a sha that matches): there is
|
|
172
|
+
// nothing to select, which is the empty selection's no-op, not an error.
|
|
173
|
+
if (file === null) return { ok: true }
|
|
174
|
+
// Hunk line indices restart per hunk, so "line 1" of a two-hunk diff names a
|
|
175
|
+
// line in EACH hunk. Full context produces one hunk; anything else must be
|
|
176
|
+
// refused whole rather than applied to the wrong lines.
|
|
177
|
+
if (file.hunks.length > 1) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false, failure: 'invalid',
|
|
180
|
+
error: `the diff for ${path} carries ${file.hunks.length} hunks; block operations need the single full-context hunk`,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const patch = emitPatch(file, lineSelector(lines), appliesInReverse(mode))
|
|
185
|
+
// An empty selection is not an error, and `git apply` rejects a patch with
|
|
186
|
+
// no hunks — so "nothing selected" must never reach git at all.
|
|
187
|
+
if (patch.length === 0) return { ok: true }
|
|
188
|
+
|
|
189
|
+
// The patch travels by tmpfile (`git()` spawns with `stdin: 'ignore'`), as
|
|
190
|
+
// the LAST argument of the apply spelling chosen above. Deleted in the
|
|
191
|
+
// finally whatever happens, so a refusal never litters the temp dir.
|
|
192
|
+
const patchFile = await io.writePatch(patch)
|
|
193
|
+
try {
|
|
194
|
+
const check = await io.git(cwd, [...argv, '--check', patchFile])
|
|
195
|
+
if (check.exitCode !== 0) return refusal(check)
|
|
196
|
+
const applied = await io.git(cwd, [...argv, patchFile])
|
|
197
|
+
if (applied.exitCode !== 0) return refusal(applied)
|
|
198
|
+
return { ok: true }
|
|
199
|
+
} finally {
|
|
200
|
+
await io.dropPatch(patchFile).catch(() => {
|
|
201
|
+
// Cleanup must never mask the result it follows; a tmpfile that is
|
|
202
|
+
// already gone has done its job.
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A refused apply: git's own message verbatim, classified for the banner. */
|
|
208
|
+
function refusal(run: GitRun): ApplyBlocksResult {
|
|
209
|
+
const failure = classifyFailure(run.exitCode, run.stderr, run.stdout)
|
|
210
|
+
const error = (run.stderr || run.stdout).trim().slice(-1000)
|
|
211
|
+
// `classifyFailure` answers null only for a zero exit, which is not a
|
|
212
|
+
// refusal and cannot reach here. The key is omitted rather than sent as
|
|
213
|
+
// null anyway, because the gateway's payloads carry no empty fields.
|
|
214
|
+
return failure === null ? { ok: false, error } : { ok: false, failure, error }
|
|
215
|
+
}
|