@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
|
@@ -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
|
|
36
|
+
return pathKey(a) === pathKey(b)
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
/**
|
package/src/fs-remove.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
|
|
19
|
+
import { rm } from 'node:fs/promises'
|
|
20
|
+
import { resolve, sep } from 'node:path'
|
|
21
|
+
|
|
22
|
+
import { isSafeRelativePath } from './discard-ops.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a repo-relative path against the worktree root, refusing to leave it.
|
|
26
|
+
*
|
|
27
|
+
* The second lock rather than the only one: {@link isSafeRelativePath} already
|
|
28
|
+
* rejected traversal spellings when the plan was made. This re-checks the
|
|
29
|
+
* RESOLVED path, which is the form the filesystem acts on, so a path that
|
|
30
|
+
* survives the first check by being spelled unusually still has to land inside
|
|
31
|
+
* the root to be acted on.
|
|
32
|
+
*
|
|
33
|
+
* @param root - the worktree directory, absolute.
|
|
34
|
+
* @param relative - repo-relative path from a plan step.
|
|
35
|
+
* @returns the absolute path to act on.
|
|
36
|
+
* @throws if the path is not a safe relative path, resolves outside the root,
|
|
37
|
+
* or IS the root.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveInside(root: string, relative: string): string {
|
|
40
|
+
if (!isSafeRelativePath(relative)) {
|
|
41
|
+
throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`)
|
|
42
|
+
}
|
|
43
|
+
const base = resolve(root)
|
|
44
|
+
const target = resolve(base, relative)
|
|
45
|
+
if (target === base) throw new Error('refusing to delete the worktree root')
|
|
46
|
+
if (!target.startsWith(base + sep)) {
|
|
47
|
+
throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`)
|
|
48
|
+
}
|
|
49
|
+
return target
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove one entry from the worktree, having proven it is inside it.
|
|
54
|
+
*
|
|
55
|
+
* `recursive` is not a widening of the blast radius: `resolveInside` has
|
|
56
|
+
* already pinned the target to one path git named, and git names a DIRECTORY
|
|
57
|
+
* whenever it will not look inside one — an untracked nested repository is
|
|
58
|
+
* reported as `sub/`, with no per-file lines even under
|
|
59
|
+
* `--untracked-files=all`. Without `recursive` that row is the only one in the
|
|
60
|
+
* drawer whose roll-back fails, and it fails as `EISDIR`, which says nothing
|
|
61
|
+
* to the person who clicked it.
|
|
62
|
+
*
|
|
63
|
+
* `force` makes an absent entry a success: the reader asked for it to be gone,
|
|
64
|
+
* and it is.
|
|
65
|
+
*
|
|
66
|
+
* A symlinked directory inside the worktree could still point outward; that is
|
|
67
|
+
* a repository someone already has write access to, and resolving link targets
|
|
68
|
+
* per segment on every delete would cost a stat per segment for a case git
|
|
69
|
+
* itself does not defend against.
|
|
70
|
+
*
|
|
71
|
+
* @param root - the worktree directory, absolute.
|
|
72
|
+
* @param relative - repo-relative path from a plan step.
|
|
73
|
+
*/
|
|
74
|
+
export async function removePathInside(root: string, relative: string): Promise<void> {
|
|
75
|
+
await rm(resolveInside(root, relative), { recursive: true, force: true })
|
|
76
|
+
}
|
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
|
-
/**
|
|
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).
|