@young1lin/dsh-ui-gitworkbench 0.1.15 → 0.1.17
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 +26 -0
- package/CHANGELOG_EN.md +26 -0
- package/README.md +30 -5
- package/README_EN.md +1 -1
- package/lib/client.js +1600 -519
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +233 -52
- package/lib/path-lock.js +54 -0
- package/lib/worktree.js +133 -0
- package/lib/write-checked.js +1 -1
- package/package.json +1 -1
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +122 -123
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +114 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -0
- package/src/client/diff-model.ts +20 -0
- package/src/client/diff-nav.ts +4 -1
- package/src/client/dir-tree.ts +31 -1
- package/src/client/file-rows.ts +40 -0
- package/src/client/h-rail.ts +70 -0
- package/src/client/ignored-cache.ts +193 -0
- package/src/client/index.ts +22 -3
- package/src/client/locales.ts +18 -4
- package/src/client/row-heights.ts +225 -0
- package/src/client/styles/changes.css +33 -2
- package/src/client/styles/controls.css +5 -0
- package/src/client/styles/files.css +5 -0
- package/src/client/styles/rails.css +72 -0
- package/src/client/use-row-window.ts +7 -3
- package/src/client/use-variable-row-window.ts +210 -0
- package/src/dir-listing.ts +47 -0
- package/src/fs-remove.ts +5 -36
- package/src/index.ts +257 -55
- package/src/path-lock.ts +56 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +153 -0
- package/src/write-checked.ts +1 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The windowing hook for panes whose rows are not all one height — a diff
|
|
3
|
+
* pane with soft wrap on.
|
|
4
|
+
*
|
|
5
|
+
* The fixed-height hook beside this one (`use-row-window.ts`) needs nothing
|
|
6
|
+
* from the DOM but the scroll offset, because `i * 20px` is the answer. Once
|
|
7
|
+
* lines wrap, only the DOM knows how tall a row came out, so this one measures
|
|
8
|
+
* — and measures ONLY the rows it just rendered, which is the window plus its
|
|
9
|
+
* overscan. Every other row carries an estimate computed from its text, so the
|
|
10
|
+
* scrollbar is close from the first paint instead of growing as the reader
|
|
11
|
+
* scrolls.
|
|
12
|
+
*
|
|
13
|
+
* A measurement that replaces an estimate for a row ABOVE the viewport moves
|
|
14
|
+
* everything below it, including what the reader is looking at. The scroll
|
|
15
|
+
* offset is corrected by the same delta in the same layout pass, so the rows
|
|
16
|
+
* on screen stay under the eye rather than sliding.
|
|
17
|
+
*
|
|
18
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/use-variable-row-window
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
|
22
|
+
|
|
23
|
+
import { RowHeights, estimateRowHeight, variableRowWindow } from './row-heights.ts'
|
|
24
|
+
import { DIFF_ROW_H, sameRowWindow, type RowWindow } from './row-window.ts'
|
|
25
|
+
|
|
26
|
+
/** Attribute a pane puts on each measurable element, as `scope:index`. */
|
|
27
|
+
export const ROW_INDEX_ATTR = 'data-gw-row'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a pane writes on an element it wants measured.
|
|
31
|
+
*
|
|
32
|
+
* The scope is half the value rather than a second attribute because two
|
|
33
|
+
* height models can be mounted inside one scroller — the aligned diff and the
|
|
34
|
+
* dense index column beside the editor — and a bare index would let each read
|
|
35
|
+
* the other's rows as its own.
|
|
36
|
+
*
|
|
37
|
+
* @param scope - which height model the element belongs to.
|
|
38
|
+
* @param index - the row's index WITHIN that model.
|
|
39
|
+
*/
|
|
40
|
+
export function rowMark(scope: string, index: number): Record<string, string> {
|
|
41
|
+
return { [ROW_INDEX_ATTR]: `${scope}:${index}` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Columns that fit across a pane, from its width and one character's advance.
|
|
46
|
+
*
|
|
47
|
+
* Measured off a probe rather than assumed: the panes' font size is a theme
|
|
48
|
+
* variable a reader can change, so a hard-coded character width would put the
|
|
49
|
+
* estimate out by a third on the smallest setting.
|
|
50
|
+
*
|
|
51
|
+
* @param grid - the element the rows are laid out in.
|
|
52
|
+
* @returns columns across, or 0 when the element is not laid out yet.
|
|
53
|
+
*/
|
|
54
|
+
export function columnsAcross(grid: HTMLElement | null): number {
|
|
55
|
+
if (grid === null) return 0
|
|
56
|
+
const width = grid.clientWidth
|
|
57
|
+
if (!Number.isFinite(width) || width <= 0) return 0
|
|
58
|
+
const probe = document.createElement('span')
|
|
59
|
+
probe.textContent = '0'.repeat(100)
|
|
60
|
+
probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;pointer-events:none'
|
|
61
|
+
grid.appendChild(probe)
|
|
62
|
+
const advance = probe.getBoundingClientRect().width / 100
|
|
63
|
+
probe.remove()
|
|
64
|
+
if (!Number.isFinite(advance) || advance <= 0) return 0
|
|
65
|
+
return Math.max(1, Math.floor(width / advance))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What a pane needs from this hook: the rows to render, where any row starts
|
|
69
|
+
* — the change walk jumps to rows that are not in the DOM, so it cannot ask
|
|
70
|
+
* the DOM where they are — and how tall each one came out. */
|
|
71
|
+
export interface FlowWindow {
|
|
72
|
+
readonly win: RowWindow
|
|
73
|
+
readonly rowTop: (index: number) => number
|
|
74
|
+
/**
|
|
75
|
+
* How tall a row came out — what the side-by-side pane imposes as a
|
|
76
|
+
* `min-height` on BOTH of a row's cells.
|
|
77
|
+
*
|
|
78
|
+
* Two aligned columns are two separate grids, because a single grid's tracks
|
|
79
|
+
* are sized by the widest line in the file and a divider dragged across one
|
|
80
|
+
* would move nothing. Separate grids align only while every row is the same
|
|
81
|
+
* height in both, which soft wrap ends: the left cell can wrap to three lines
|
|
82
|
+
* and the right to one. Giving both the tallest side's height puts the rows
|
|
83
|
+
* back in step without merging the grids.
|
|
84
|
+
*/
|
|
85
|
+
readonly rowHeight: (index: number) => number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useVariableRowWindow({
|
|
89
|
+
scrollRef, rowsRef, widthRef, texts, mountKey, scope, enabled, rowH = DIFF_ROW_H,
|
|
90
|
+
}: {
|
|
91
|
+
/** The element that scrolls the rows. */
|
|
92
|
+
scrollRef: { current: HTMLElement | null }
|
|
93
|
+
/** The subtree the measurable rows live in. For an aligned diff this is the
|
|
94
|
+
* element holding BOTH columns, since a row's height is the taller side. */
|
|
95
|
+
rowsRef: { current: HTMLElement | null }
|
|
96
|
+
/** What the wrap width is measured from; defaults to `rowsRef`. Separate
|
|
97
|
+
* because the element holding both columns is twice as wide as the column
|
|
98
|
+
* a line actually wraps inside. */
|
|
99
|
+
widthRef?: { current: HTMLElement | null }
|
|
100
|
+
/** One string per row, for the estimate. Identity matters: a new array
|
|
101
|
+
* rebuilds the heights, so callers must memoize it. */
|
|
102
|
+
texts: readonly string[]
|
|
103
|
+
/** Identifies the mounted diff; a new one starts over. */
|
|
104
|
+
mountKey: string
|
|
105
|
+
/** Which rows in `rowsRef` are this model's — see {@link rowMark}. */
|
|
106
|
+
scope: string
|
|
107
|
+
/** Whether wrapping is on. When it is not, this hook attaches nothing and
|
|
108
|
+
* allocates nothing: the pane uses the fixed-height window beside it, and
|
|
109
|
+
* paying for a Fenwick tree over 20,000 rows to answer `i * 20` would be a
|
|
110
|
+
* regression on the path that was never broken. */
|
|
111
|
+
enabled: boolean
|
|
112
|
+
/** One line's height in px. */
|
|
113
|
+
rowH?: number
|
|
114
|
+
}): FlowWindow {
|
|
115
|
+
/** Columns across the pane. State, because the estimate depends on it and a
|
|
116
|
+
* dragged divider changes it. */
|
|
117
|
+
const [columns, setColumns] = useState(0)
|
|
118
|
+
/**
|
|
119
|
+
* Bumped when a measurement moved a row.
|
|
120
|
+
*
|
|
121
|
+
* The heights live in a mutable structure, so changing one changes no
|
|
122
|
+
* identity React watches — and the side-by-side pane reads them DURING
|
|
123
|
+
* render, to impose each row's height on both of its cells. Without this
|
|
124
|
+
* the first pass would measure correctly and then never re-render to apply
|
|
125
|
+
* what it measured, which is a row that is one line taller on one side than
|
|
126
|
+
* on the other. Bumped only when something actually moved, and imposing a
|
|
127
|
+
* height does not change what is measured (the measured box is inside the
|
|
128
|
+
* cell), so it settles in one extra pass.
|
|
129
|
+
*/
|
|
130
|
+
const [, bumpHeights] = useState(0)
|
|
131
|
+
const [win, setWin] = useState<RowWindow>(() => ({ start: 0, end: texts.length, padTop: 0, padBottom: 0 }))
|
|
132
|
+
|
|
133
|
+
/** Rebuilt only when the diff or the width really changes — never on scroll. */
|
|
134
|
+
const heights = useMemo(
|
|
135
|
+
() => new RowHeights(enabled ? texts.map(text => estimateRowHeight(text, columns, rowH)) : []),
|
|
136
|
+
// `mountKey` is in the list on purpose: two diffs can have identical text
|
|
137
|
+
// arrays by identity only if they are the same diff.
|
|
138
|
+
[texts, columns, rowH, mountKey, enabled],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const read = useCallback((): void => {
|
|
142
|
+
const el = scrollRef.current
|
|
143
|
+
if (el === null || heights.count === 0) return
|
|
144
|
+
const next = variableRowWindow(el.scrollTop, el.clientHeight, heights)
|
|
145
|
+
setWin(prev => sameRowWindow(prev, next) ? prev : next)
|
|
146
|
+
}, [scrollRef, heights])
|
|
147
|
+
|
|
148
|
+
// Scroll and resize. Passive: this listener never calls preventDefault, and
|
|
149
|
+
// saying so keeps it off the scroll's critical path.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const el = scrollRef.current
|
|
152
|
+
if (el === null || !enabled) return
|
|
153
|
+
read()
|
|
154
|
+
const observer = new ResizeObserver(() => {
|
|
155
|
+
read()
|
|
156
|
+
setColumns(prev => {
|
|
157
|
+
const measured = columnsAcross((widthRef ?? rowsRef).current)
|
|
158
|
+
return measured === 0 || measured === prev ? prev : measured
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
el.addEventListener('scroll', read, { passive: true })
|
|
162
|
+
observer.observe(el)
|
|
163
|
+
return () => {
|
|
164
|
+
el.removeEventListener('scroll', read)
|
|
165
|
+
observer.disconnect()
|
|
166
|
+
}
|
|
167
|
+
}, [scrollRef, rowsRef, widthRef, read, enabled])
|
|
168
|
+
|
|
169
|
+
// What the rows actually came out as. Layout effect because the correction
|
|
170
|
+
// below must land in the same frame as the paint that needs it — a scroll
|
|
171
|
+
// offset fixed one frame late is a visible jump.
|
|
172
|
+
useLayoutEffect(() => {
|
|
173
|
+
const root = rowsRef.current
|
|
174
|
+
const scroller = scrollRef.current
|
|
175
|
+
if (!enabled || root === null || scroller === null) return
|
|
176
|
+
const before = heights.topAt(win.start)
|
|
177
|
+
// A row can have several measured elements — the two sides of an aligned
|
|
178
|
+
// diff — and the row is as tall as the tallest of them. Collected first,
|
|
179
|
+
// then written: writing each as it is read would leave the row at whichever
|
|
180
|
+
// side the DOM happened to list last.
|
|
181
|
+
const tallest = new Map<number, number>()
|
|
182
|
+
for (const node of root.querySelectorAll<HTMLElement>(`[${ROW_INDEX_ATTR}^="${scope}:"]`)) {
|
|
183
|
+
const index = Number(node.getAttribute(ROW_INDEX_ATTR)?.slice(scope.length + 1))
|
|
184
|
+
if (!Number.isInteger(index)) continue
|
|
185
|
+
tallest.set(index, Math.max(tallest.get(index) ?? 0, node.getBoundingClientRect().height))
|
|
186
|
+
}
|
|
187
|
+
let moved = false
|
|
188
|
+
for (const [index, height] of tallest) {
|
|
189
|
+
if (heights.set(index, height)) moved = true
|
|
190
|
+
}
|
|
191
|
+
if (!moved) return
|
|
192
|
+
// Rows above the viewport that turned out taller (or shorter) than their
|
|
193
|
+
// estimate move the whole document under the reader. Give the scroller the
|
|
194
|
+
// same delta back, so the row they were looking at stays where it was.
|
|
195
|
+
const after = heights.topAt(win.start)
|
|
196
|
+
if (after !== before) scroller.scrollTop += after - before
|
|
197
|
+
bumpHeights(version => version + 1)
|
|
198
|
+
read()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const rowTop = useCallback(
|
|
202
|
+
(index: number) => enabled ? heights.topAt(index) : Math.max(0, Math.trunc(index)) * rowH,
|
|
203
|
+
[enabled, heights, rowH],
|
|
204
|
+
)
|
|
205
|
+
const rowHeight = useCallback(
|
|
206
|
+
(index: number) => enabled ? heights.heightAt(index) : rowH,
|
|
207
|
+
[enabled, heights, rowH],
|
|
208
|
+
)
|
|
209
|
+
return { win, rowTop, rowHeight }
|
|
210
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One lazily listed directory, shaped for the wire.
|
|
3
|
+
*
|
|
4
|
+
* The Files tab browses ignored directories by reading them from the
|
|
5
|
+
* filesystem ONE level at a time — git cannot do this scoped: a pathspec
|
|
6
|
+
* under `--directory` still collapses the whole ignored directory to a
|
|
7
|
+
* single line, and dropping `--directory` would enumerate every file inside
|
|
8
|
+
* `node_modules` at once. A `readdir` is one level by construction, costs
|
|
9
|
+
* milliseconds, and says what a browser wants to know: what is HERE.
|
|
10
|
+
*
|
|
11
|
+
* Only the shaping is pure (ordering, capping); the read itself stays in
|
|
12
|
+
* `index.ts`, which vitest cannot load. Same split as `fs-remove.ts`.
|
|
13
|
+
*
|
|
14
|
+
* @module @young1lin/dsh-ui-gitworkbench/dir-listing
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One entry of a listed directory: a name plus whether expanding it again
|
|
18
|
+
* makes sense. `dir` decides the row's glyph and whether it is clickable
|
|
19
|
+
* as a folder. */
|
|
20
|
+
export interface DirChild {
|
|
21
|
+
readonly name: string
|
|
22
|
+
readonly dir: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The most entries one expansion returns. A real directory level is a few
|
|
26
|
+
* hundred at most (`node_modules`'s own top level); a cap beyond that is a
|
|
27
|
+
* reported fuse against a pathological directory, not a working number. */
|
|
28
|
+
export const DIR_CHILD_CAP = 5_000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Order and cap raw readdir results: directories before files (the shape
|
|
32
|
+
* every file tree has, matching `treeRows`), each run by name, and a cut
|
|
33
|
+
* REPORTED rather than silent.
|
|
34
|
+
*
|
|
35
|
+
* @param raw - the directory's entries with their best-known dir-ness
|
|
36
|
+
* (symlinks already resolved by the caller).
|
|
37
|
+
* @param cap - most entries to return.
|
|
38
|
+
*/
|
|
39
|
+
export function shapeDirChildren(
|
|
40
|
+
raw: readonly DirChild[],
|
|
41
|
+
cap: number = DIR_CHILD_CAP,
|
|
42
|
+
): { entries: DirChild[]; truncated: boolean } {
|
|
43
|
+
const byName = (a: DirChild, b: DirChild): number => a.name.localeCompare(b.name)
|
|
44
|
+
const ordered = [...raw.filter(entry => entry.dir).sort(byName), ...raw.filter(entry => !entry.dir).sort(byName)]
|
|
45
|
+
const truncated = ordered.length > cap
|
|
46
|
+
return { entries: truncated ? ordered.slice(0, cap) : ordered, truncated }
|
|
47
|
+
}
|
package/src/fs-remove.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* `git clean` refuses paths it cannot index, which on Windows includes every
|
|
7
7
|
* reserved device name (`nul`, `con`, `aux`, `com1`, and the same names with
|
|
8
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
|
|
10
|
-
* rather than a bare `rm`.
|
|
9
|
+
* refusal to leave the repository does not apply — hence the path lock in
|
|
10
|
+
* `path-lock.ts` rather than a bare `rm`.
|
|
11
11
|
*
|
|
12
12
|
* Lives outside `index.ts` so vitest can load it: the class there needs the
|
|
13
13
|
* dsh runtime, and the property worth testing is "what does this delete, and
|
|
@@ -17,37 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { rm } from 'node:fs/promises'
|
|
20
|
-
import { resolve, sep } from 'node:path'
|
|
21
20
|
|
|
22
|
-
import {
|
|
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
|
-
}
|
|
21
|
+
import { resolveInside } from './path-lock.js'
|
|
51
22
|
|
|
52
23
|
/**
|
|
53
24
|
* Remove one entry from the worktree, having proven it is inside it.
|
|
@@ -63,10 +34,8 @@ export function resolveInside(root: string, relative: string): string {
|
|
|
63
34
|
* `force` makes an absent entry a success: the reader asked for it to be gone,
|
|
64
35
|
* and it is.
|
|
65
36
|
*
|
|
66
|
-
* A symlinked directory inside the worktree could still point outward
|
|
67
|
-
*
|
|
68
|
-
* per segment on every delete would cost a stat per segment for a case git
|
|
69
|
-
* itself does not defend against.
|
|
37
|
+
* A symlinked directory inside the worktree could still point outward — the
|
|
38
|
+
* limit of a lexical resolve, stated where the lock is.
|
|
70
39
|
*
|
|
71
40
|
* @param root - the worktree directory, absolute.
|
|
72
41
|
* @param relative - repo-relative path from a plan step.
|