@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,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/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).
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this file actually an image, and which kind?
|
|
3
|
+
*
|
|
4
|
+
* The extension is a hint, never the answer: `.png` is a filename, not a
|
|
5
|
+
* format, and a repository full of generated assets has mislabelled files in
|
|
6
|
+
* it. So the bytes decide — every format below is identified by the signature
|
|
7
|
+
* its own specification mandates in the first few bytes.
|
|
8
|
+
*
|
|
9
|
+
* The obvious alternative is the `file-type` package, which is the mature
|
|
10
|
+
* packaging of exactly this idea. It is not used here for three reasons: it
|
|
11
|
+
* is four transitive dependencies in a package that is published to npm, it
|
|
12
|
+
* identifies two hundred types where this needs eight, and — the deciding one
|
|
13
|
+
* — it could not make the render any safer than it already is. Sniffing is
|
|
14
|
+
* the FIRST of two gates; the second is the browser's own image decoder, and
|
|
15
|
+
* that one is authoritative in a way no table can be. If the trade ever turns,
|
|
16
|
+
* `sniffImage` is the only function to replace.
|
|
17
|
+
*
|
|
18
|
+
* Membership in the table is decided by one rule: browsers render it in an
|
|
19
|
+
* `<img>` element. That is why TIFF and HEIC are absent — recognising them
|
|
20
|
+
* would only let the view promise a picture it cannot draw, and "binary file"
|
|
21
|
+
* is the more honest answer for a format the reader's browser will refuse.
|
|
22
|
+
*
|
|
23
|
+
* SVG is the one member with no magic number, because it is XML rather than a
|
|
24
|
+
* container. It is admitted anyway: rendering happens through `<img>`, which
|
|
25
|
+
* the HTML specification defines as a non-scripted context — script elements,
|
|
26
|
+
* event handlers and external references inside the document do not run and do
|
|
27
|
+
* not load. That property, not a sanitiser, is what makes it safe.
|
|
28
|
+
*
|
|
29
|
+
* Pure: no node, no fs, no git. `tests/image-sniff.test.ts` loads it directly.
|
|
30
|
+
*
|
|
31
|
+
* @module @young1lin/dsh-ui-gitworkbench/image-sniff
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** A file the sniffer recognised. */
|
|
35
|
+
export interface SniffedImage {
|
|
36
|
+
/** MIME type, as the `<img>` blob should be labelled. */
|
|
37
|
+
readonly mime: string
|
|
38
|
+
/** Short human label for the caption: 'PNG', 'WebP', … */
|
|
39
|
+
readonly kind: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Largest image handed to the browser, in bytes.
|
|
44
|
+
*
|
|
45
|
+
* This is a WIRE budget, not a rendering one. The bytes cross the RPC channel
|
|
46
|
+
* base64-encoded, which costs a third again on top, so a cap of four megabytes
|
|
47
|
+
* is a payload of five and a third — already the largest single message the
|
|
48
|
+
* drawer sends. Screenshots and icons, which is what repositories actually
|
|
49
|
+
* hold, sit two orders of magnitude below it.
|
|
50
|
+
*/
|
|
51
|
+
export const IMAGE_BYTE_CAP = 4_000_000
|
|
52
|
+
|
|
53
|
+
/** How many leading bytes any signature below needs. */
|
|
54
|
+
const SNIFF_BYTES = 64
|
|
55
|
+
|
|
56
|
+
/** How much of a text file is read looking for an SVG root element.
|
|
57
|
+
*
|
|
58
|
+
* Larger than any magic number needs because the prologue in front of that
|
|
59
|
+
* root is unbounded in principle: an XML declaration, a DOCTYPE with an
|
|
60
|
+
* internal subset of declarations, and any number of comments all come
|
|
61
|
+
* first, and real files use all three. */
|
|
62
|
+
const SVG_PROLOGUE_BYTES = 4096
|
|
63
|
+
|
|
64
|
+
/** Do `bytes` begin with these byte values at `at`? */
|
|
65
|
+
function at(bytes: Uint8Array, offset: number, signature: readonly number[]): boolean {
|
|
66
|
+
if (bytes.length < offset + signature.length) return false
|
|
67
|
+
for (let i = 0; i < signature.length; i += 1) {
|
|
68
|
+
if (bytes[offset + i] !== signature[i]) return false
|
|
69
|
+
}
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The bytes of an ASCII marker, so the tables read as the specs write them. */
|
|
74
|
+
function ascii(text: string): readonly number[] {
|
|
75
|
+
return [...text].map(ch => ch.charCodeAt(0))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Little-endian uint32 at `offset`, or -1 when the buffer is too short. */
|
|
79
|
+
function u32le(bytes: Uint8Array, offset: number): number {
|
|
80
|
+
if (bytes.length < offset + 4) return -1
|
|
81
|
+
return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** DIB header sizes BMP has ever defined. A `BM` prefix alone is two bytes of
|
|
85
|
+
* evidence, which any text beginning "BM" would satisfy; the header size is
|
|
86
|
+
* what makes the match a bitmap. */
|
|
87
|
+
const BMP_HEADERS: readonly number[] = [12, 16, 40, 52, 56, 64, 108, 124]
|
|
88
|
+
|
|
89
|
+
/** ISO base-media brands that carry a still image a browser will draw. */
|
|
90
|
+
const AVIF_BRANDS: readonly string[] = ['avif', 'avis']
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Identify one file from its leading bytes.
|
|
94
|
+
*
|
|
95
|
+
* @param bytes - the file's content, or at least its first {@link SNIFF_BYTES}.
|
|
96
|
+
* @returns what it is, or null for anything not in the table.
|
|
97
|
+
*/
|
|
98
|
+
export function sniffImage(bytes: Uint8Array): SniffedImage | null {
|
|
99
|
+
if (bytes.length === 0) return null
|
|
100
|
+
|
|
101
|
+
// PNG: the eight-byte signature from the specification, chosen there
|
|
102
|
+
// precisely so that no other format collides with it.
|
|
103
|
+
if (at(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
|
104
|
+
return { mime: 'image/png', kind: 'PNG' }
|
|
105
|
+
}
|
|
106
|
+
// JPEG: SOI marker, then the first marker of the next segment.
|
|
107
|
+
if (at(bytes, 0, [0xff, 0xd8, 0xff])) {
|
|
108
|
+
return { mime: 'image/jpeg', kind: 'JPEG' }
|
|
109
|
+
}
|
|
110
|
+
if (at(bytes, 0, ascii('GIF87a')) || at(bytes, 0, ascii('GIF89a'))) {
|
|
111
|
+
return { mime: 'image/gif', kind: 'GIF' }
|
|
112
|
+
}
|
|
113
|
+
// WebP is a RIFF container; the four bytes after the length field are what
|
|
114
|
+
// separate it from a WAV or an AVI.
|
|
115
|
+
if (at(bytes, 0, ascii('RIFF')) && at(bytes, 8, ascii('WEBP'))) {
|
|
116
|
+
return { mime: 'image/webp', kind: 'WebP' }
|
|
117
|
+
}
|
|
118
|
+
// ISO base media: `ftyp` box, then the brand. Also matches HEIC and MP4,
|
|
119
|
+
// which is why only the still-image AVIF brands are admitted.
|
|
120
|
+
if (at(bytes, 4, ascii('ftyp')) && AVIF_BRANDS.some(brand => at(bytes, 8, ascii(brand)))) {
|
|
121
|
+
return { mime: 'image/avif', kind: 'AVIF' }
|
|
122
|
+
}
|
|
123
|
+
if (at(bytes, 0, ascii('BM')) && BMP_HEADERS.includes(u32le(bytes, 14))) {
|
|
124
|
+
return { mime: 'image/bmp', kind: 'BMP' }
|
|
125
|
+
}
|
|
126
|
+
// ICO: reserved zero, type 1 (icon) or 2 (cursor), then a non-zero count of
|
|
127
|
+
// images. The count is the check that a run of zero bytes cannot pass.
|
|
128
|
+
if (at(bytes, 0, [0x00, 0x00, 0x01, 0x00]) && (bytes[4] | (bytes[5] << 8)) > 0) {
|
|
129
|
+
return { mime: 'image/x-icon', kind: 'ICO' }
|
|
130
|
+
}
|
|
131
|
+
if (looksLikeSvg(bytes)) {
|
|
132
|
+
return { mime: 'image/svg+xml', kind: 'SVG' }
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Does this text begin an SVG document?
|
|
139
|
+
*
|
|
140
|
+
* Structural rather than a substring search: the prologue XML allows before a
|
|
141
|
+
* root element is skipped, and then the root element itself must be `svg`. A
|
|
142
|
+
* file that merely CONTAINS `<svg` somewhere — an HTML page with an inline
|
|
143
|
+
* icon, a TypeScript file with a template literal — is not one.
|
|
144
|
+
*/
|
|
145
|
+
function looksLikeSvg(bytes: Uint8Array): boolean {
|
|
146
|
+
const head = bytes.subarray(0, SVG_PROLOGUE_BYTES)
|
|
147
|
+
// A NUL rules out text before any parsing: the same test the diff side uses
|
|
148
|
+
// to call a file binary.
|
|
149
|
+
if (head.includes(0)) return false
|
|
150
|
+
let text: string
|
|
151
|
+
try {
|
|
152
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(head)
|
|
153
|
+
} catch {
|
|
154
|
+
// A truncated multi-byte character at the cut is not a decode failure of
|
|
155
|
+
// the FILE, so retry lenient; a genuinely non-UTF-8 file yields U+FFFD,
|
|
156
|
+
// which no prologue below accepts.
|
|
157
|
+
text = new TextDecoder('utf-8').decode(head)
|
|
158
|
+
}
|
|
159
|
+
// Strip a byte-order mark, which is legal before an XML declaration.
|
|
160
|
+
let rest = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
161
|
+
for (;;) {
|
|
162
|
+
rest = rest.replace(/^\s+/, '')
|
|
163
|
+
if (rest.startsWith('<?')) {
|
|
164
|
+
const end = rest.indexOf('?>')
|
|
165
|
+
// Prologue cut off by the sniff window: undecidable, so not an image.
|
|
166
|
+
if (end === -1) return false
|
|
167
|
+
rest = rest.slice(end + 2)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (rest.startsWith('<!--')) {
|
|
171
|
+
const end = rest.indexOf('-->')
|
|
172
|
+
if (end === -1) return false
|
|
173
|
+
rest = rest.slice(end + 3)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (rest.startsWith('<!')) {
|
|
177
|
+
// A DOCTYPE may carry an internal subset in brackets, and the
|
|
178
|
+
// declarations inside it end with '>' characters of their own —
|
|
179
|
+
// scanning to the first one lands in the middle of the subset and the
|
|
180
|
+
// root element is never reached. Real files do this: matplotlib ships
|
|
181
|
+
// an SVG whose DOCTYPE declares an ATTLIST, and it was the one file in
|
|
182
|
+
// a hundred and twenty-five thousand that this missed.
|
|
183
|
+
const bracket = rest.indexOf('[')
|
|
184
|
+
const end = rest.indexOf('>')
|
|
185
|
+
if (end === -1) return false
|
|
186
|
+
if (bracket === -1 || bracket > end) {
|
|
187
|
+
rest = rest.slice(end + 1)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
const closed = rest.indexOf(']', bracket)
|
|
191
|
+
if (closed === -1) return false
|
|
192
|
+
const after = rest.indexOf('>', closed)
|
|
193
|
+
if (after === -1) return false
|
|
194
|
+
rest = rest.slice(after + 1)
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
break
|
|
198
|
+
}
|
|
199
|
+
// The root element, and only `svg`: the character after the name must end
|
|
200
|
+
// it, so `<svgfoo>` is not a match.
|
|
201
|
+
if (!rest.startsWith('<svg')) return false
|
|
202
|
+
const after = rest.charAt(4)
|
|
203
|
+
return after === '' || after === '>' || after === '/' || /\s/.test(after)
|
|
204
|
+
}
|