@young1lin/dsh-ui-gitworkbench 0.1.14 → 0.1.16
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 +23 -0
- package/CHANGELOG_EN.md +23 -0
- package/README.md +29 -3
- package/README_EN.md +1 -1
- package/lib/client.js +1512 -470
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +346 -117
- package/lib/path-lock.js +54 -0
- package/lib/repo-root.js +60 -0
- package/lib/worktree.js +83 -0
- package/lib/write-checked.js +1 -1
- package/package.json +5 -5
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +116 -121
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +100 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -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 +12 -2
- 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 +372 -118
- package/src/path-lock.ts +56 -0
- package/src/repo-root.ts +66 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +97 -0
- package/src/write-checked.ts +1 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import css from './GitWorkbenchPanel.module.css'
|
|
4
|
+
import { NO_RAIL, railsShown, sameMetric, type RailMetric } from './h-rail.ts'
|
|
5
|
+
|
|
6
|
+
/** A column and the rail that scrolls it, as the DOM hands them over. */
|
|
7
|
+
type Box = { current: HTMLElement | null }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Measure one column and keep its rail's scroll position tied to it.
|
|
11
|
+
*
|
|
12
|
+
* The measurement runs on EVERY render rather than on a dependency list: the
|
|
13
|
+
* content width changes for reasons no list can name — a window scrolled to
|
|
14
|
+
* longer rows, syntax spans replacing plain text, the editor mounting into the
|
|
15
|
+
* right column, wrap turning on. Two integer reads is a cheap price for never
|
|
16
|
+
* being stale, and {@link sameMetric} is what keeps setting state on every
|
|
17
|
+
* render from becoming a render on every render.
|
|
18
|
+
*
|
|
19
|
+
* @param colRef - the column, which clips and scrolls its own content.
|
|
20
|
+
* @param railRef - the strip at the bottom of the pane that stands in for it.
|
|
21
|
+
* @returns the column's measured widths.
|
|
22
|
+
*/
|
|
23
|
+
function useRail(colRef: Box, railRef: Box): RailMetric {
|
|
24
|
+
const [metric, setMetric] = useState<RailMetric>(NO_RAIL)
|
|
25
|
+
|
|
26
|
+
const measure = (col: HTMLElement): void => {
|
|
27
|
+
const next = { view: col.clientWidth, content: col.scrollWidth }
|
|
28
|
+
setMetric(prev => sameMetric(prev, next) ? prev : next)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
useLayoutEffect(() => {
|
|
32
|
+
const col = colRef.current
|
|
33
|
+
if (col !== null) measure(col)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// A pane resized without a re-render — the drawer's own edge dragged, the
|
|
37
|
+
// window resized — changes what fits without changing what is rendered.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const col = colRef.current
|
|
40
|
+
if (col === null) return
|
|
41
|
+
const observer = new ResizeObserver(() => { measure(col) })
|
|
42
|
+
observer.observe(col)
|
|
43
|
+
return () => { observer.disconnect() }
|
|
44
|
+
}, [colRef])
|
|
45
|
+
|
|
46
|
+
// Two-way, because both ends are real scrollers: the rail is what the reader
|
|
47
|
+
// drags, and the column still scrolls on its own from a trackpad swipe or a
|
|
48
|
+
// shift-wheel over the code. Each writes only when the value differs, which
|
|
49
|
+
// is what stops the pair from echoing a scroll back and forth forever.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const col = colRef.current
|
|
52
|
+
const rail = railRef.current
|
|
53
|
+
if (col === null || rail === null) return
|
|
54
|
+
const toRail = (): void => { if (rail.scrollLeft !== col.scrollLeft) rail.scrollLeft = col.scrollLeft }
|
|
55
|
+
const toCol = (): void => { if (col.scrollLeft !== rail.scrollLeft) col.scrollLeft = rail.scrollLeft }
|
|
56
|
+
col.addEventListener('scroll', toRail, { passive: true })
|
|
57
|
+
rail.addEventListener('scroll', toCol, { passive: true })
|
|
58
|
+
toRail()
|
|
59
|
+
return () => {
|
|
60
|
+
col.removeEventListener('scroll', toRail)
|
|
61
|
+
rail.removeEventListener('scroll', toCol)
|
|
62
|
+
}
|
|
63
|
+
}, [colRef, railRef])
|
|
64
|
+
|
|
65
|
+
return metric
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The side-by-side pane's horizontal scrollbars, stuck to the bottom of the
|
|
70
|
+
* pane instead of the bottom of the file — see `h-rail.ts` for why they are
|
|
71
|
+
* here at all.
|
|
72
|
+
*
|
|
73
|
+
* Always rendered, never conditionally: the strip collapses to nothing through
|
|
74
|
+
* a class when neither column overflows, so both rails keep their elements and
|
|
75
|
+
* the effects above keep stable dependencies. A rail that mounted and
|
|
76
|
+
* unmounted with the measurement would re-attach its listeners on every file.
|
|
77
|
+
*
|
|
78
|
+
* @param leftRef - the left column.
|
|
79
|
+
* @param rightRef - the right column.
|
|
80
|
+
* @param split - the divider's position, as a fraction of the pane's width;
|
|
81
|
+
* the rails carry the same flex sizing so each sits under the
|
|
82
|
+
* column it scrolls.
|
|
83
|
+
* @returns the sticky rail strip.
|
|
84
|
+
*/
|
|
85
|
+
export function SideRails({ leftRef, rightRef, split }: {
|
|
86
|
+
leftRef: Box
|
|
87
|
+
rightRef: Box
|
|
88
|
+
split: number
|
|
89
|
+
}): ReactNode {
|
|
90
|
+
const leftRailRef = useRef<HTMLDivElement>(null)
|
|
91
|
+
const rightRailRef = useRef<HTMLDivElement>(null)
|
|
92
|
+
const left = useRail(leftRef, leftRailRef)
|
|
93
|
+
const right = useRail(rightRef, rightRailRef)
|
|
94
|
+
const shown = railsShown(left, right)
|
|
95
|
+
return (
|
|
96
|
+
<div className={shown ? `${css.sideRails} ${css.sideRailsOn}` : css.sideRails} aria-hidden="true">
|
|
97
|
+
<div ref={leftRailRef} className={css.sideRail} style={{ flexBasis: `${split * 100}%` }}>
|
|
98
|
+
<div className={css.sideRailSpan} style={{ width: left.content }} />
|
|
99
|
+
</div>
|
|
100
|
+
<div className={css.sideRailGap} />
|
|
101
|
+
<div ref={rightRailRef} className={`${css.sideRail} ${css.sideRailRight}`}>
|
|
102
|
+
<div className={css.sideRailSpan} style={{ width: right.content }} />
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side-by-side pane's leaf cells: what one row's four boxes are called and
|
|
3
|
+
* what goes inside them.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `DiffViews.tsx` because that file holds two whole views and a
|
|
6
|
+
* module the reviewer cannot hold in their head is the thing the size guard in
|
|
7
|
+
* `tests/panel-modules.test.ts` exists to prevent. Nothing here decides
|
|
8
|
+
* anything — every function takes a row and returns a class or a fragment —
|
|
9
|
+
* which is exactly the part of the view that reads better away from the state
|
|
10
|
+
* it is rendered from.
|
|
11
|
+
*
|
|
12
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/diff-cells
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MouseEventHandler, ReactNode } from 'react'
|
|
16
|
+
|
|
17
|
+
import css from './GitWorkbenchPanel.module.css'
|
|
18
|
+
import { CR_GLYPH, splitOnCr } from './cr-mark.ts'
|
|
19
|
+
import { blockEdge, type SideCell, type SideRow } from './side-rows.ts'
|
|
20
|
+
import type { HighlightRun } from './highlight.ts'
|
|
21
|
+
|
|
22
|
+
/** Classes that paint only a block's OUTER perimeter. Internal rows carry the
|
|
23
|
+
* vertical edges but no top/bottom line, avoiding the blue ladder a large
|
|
24
|
+
* addition block used to draw. Absent side-cells return no class at all. */
|
|
25
|
+
export function blockHotClass(rows: readonly SideRow[], index: number, side: 'left' | 'right', hot: boolean): string {
|
|
26
|
+
if (!hot) return ''
|
|
27
|
+
const edge = blockEdge(rows, index, side)
|
|
28
|
+
if (edge === null) return ''
|
|
29
|
+
const first = edge === 'first' || edge === 'single' ? ` ${css.sideBlockHotFirst}` : ''
|
|
30
|
+
const last = edge === 'last' || edge === 'single' ? ` ${css.sideBlockHotLast}` : ''
|
|
31
|
+
return ` ${css.sideBlockHot}${first}${last}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Line-number cell class: a PRESENT cell of a changed row carries its side's
|
|
35
|
+
* tint into the gutter; an absent one stays blank, the way a split diff shows
|
|
36
|
+
* a one-sided change with an empty opposite pane rather than a tinted void. */
|
|
37
|
+
export function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
|
|
38
|
+
const cell = side === 'left' ? row.left : row.right
|
|
39
|
+
if (cell === null || row.kind === 'same') return css.sideNum
|
|
40
|
+
return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Code cell class: deletions tint left, additions right, context stays quiet. */
|
|
44
|
+
export function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
|
|
45
|
+
const cell = side === 'left' ? row.left : row.right
|
|
46
|
+
if (cell === null || row.kind === 'same') return css.sideCodeSame
|
|
47
|
+
return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One text with every carriage return drawn as the CR glyph. No CR means
|
|
51
|
+
* the text comes back untouched — the common line, on both sides, costs one
|
|
52
|
+
* `includes`. The glyph spans are aria-hidden and unselectable, so copying a
|
|
53
|
+
* line copies code, not markers. */
|
|
54
|
+
export function renderWithCrMarks(text: string): ReactNode {
|
|
55
|
+
const parts = splitOnCr(text)
|
|
56
|
+
if (parts.length === 1) return text
|
|
57
|
+
const out: ReactNode[] = [parts[0]!]
|
|
58
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
59
|
+
out.push(<span key={`cr${i}`} className={css.crMark} aria-hidden="true">{CR_GLYPH}</span>)
|
|
60
|
+
out.push(parts[i]!)
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One cell's Shiki runs, or its plain text when no tokens exist; either way
|
|
66
|
+
* each carriage return in the cell is drawn, so a line whose only change is
|
|
67
|
+
* its ending shows the difference instead of two identical-looking cells. */
|
|
68
|
+
export function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
|
|
69
|
+
if (cell === null) return ''
|
|
70
|
+
if (tokens === undefined || tokens.length === 0) return renderWithCrMarks(cell.text)
|
|
71
|
+
if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return renderWithCrMarks(cell.text)
|
|
72
|
+
return tokens.map((tok, i) => (
|
|
73
|
+
<span
|
|
74
|
+
key={i}
|
|
75
|
+
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
76
|
+
>{renderWithCrMarks(tok.text)}</span>
|
|
77
|
+
))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The spacer standing in for the rows above or below the window.
|
|
84
|
+
*
|
|
85
|
+
* It spans every column of the grid, so a blame gutter does not change it.
|
|
86
|
+
* @param height - px of rows it stands in for; nothing is rendered for 0.
|
|
87
|
+
*/
|
|
88
|
+
export function RowSpacer({ height }: { height: number }): ReactNode {
|
|
89
|
+
if (height <= 0) return null
|
|
90
|
+
return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One side of one aligned row: its line-number cell and its code cell.
|
|
95
|
+
*
|
|
96
|
+
* Written once and used by all three columns the pane draws — the left side of
|
|
97
|
+
* a diff, the right side, and the dense index column beside the editor —
|
|
98
|
+
* because the three differ only in which cell they read, what hangs the block
|
|
99
|
+
* bar and whether a click arms the editor. Everything else about them, from
|
|
100
|
+
* the block outline to the CR markers, is the same in all three and was
|
|
101
|
+
* previously the same three times.
|
|
102
|
+
*/
|
|
103
|
+
export function SideCells({ row, side, index, rows, current, tokens, mark, minHeight, bar, armable, onArm }: {
|
|
104
|
+
row: SideRow
|
|
105
|
+
side: 'left' | 'right'
|
|
106
|
+
/** Index into `rows`, which is what the block outline keys on. */
|
|
107
|
+
index: number
|
|
108
|
+
rows: readonly SideRow[]
|
|
109
|
+
/** Whether this row belongs to the block the change walk is standing on. */
|
|
110
|
+
current: boolean
|
|
111
|
+
tokens: readonly HighlightRun[] | undefined
|
|
112
|
+
/**
|
|
113
|
+
* What marks this cell for measurement ({@link rowMark}), or undefined for
|
|
114
|
+
* "do not measure".
|
|
115
|
+
*
|
|
116
|
+
* The measured element is INSIDE the cell rather than the cell itself,
|
|
117
|
+
* because the cell carries the imposed `minHeight` below: measuring it would
|
|
118
|
+
* read back what was imposed, and a row that grew for a narrow pane could
|
|
119
|
+
* never shrink again when the pane was widened.
|
|
120
|
+
*/
|
|
121
|
+
mark?: Record<string, string>
|
|
122
|
+
/** Imposed so both sides of a wrapped row stand the same height. */
|
|
123
|
+
minHeight?: number
|
|
124
|
+
bar?: ReactNode
|
|
125
|
+
/** The working-tree column, whose cells arm the editor when clicked. */
|
|
126
|
+
armable?: boolean
|
|
127
|
+
onArm?: MouseEventHandler<HTMLElement>
|
|
128
|
+
}): ReactNode {
|
|
129
|
+
const hot = blockHotClass(rows, index, side, current)
|
|
130
|
+
const cell = side === 'left' ? row.left : row.right
|
|
131
|
+
const code = renderSideCode(cell, tokens)
|
|
132
|
+
const box = minHeight !== undefined && minHeight > 0 ? { minHeight: `${minHeight}px` } : undefined
|
|
133
|
+
return (
|
|
134
|
+
<>
|
|
135
|
+
<span className={`${sideNumClass(row, side)}${hot}`} style={box}>{cell === null ? '' : cell.line}</span>
|
|
136
|
+
<span
|
|
137
|
+
className={`${css.sideCode} ${sideCodeClass(row, side)}${hot}${armable === true ? ` ${css.sideArmable}` : ''}`}
|
|
138
|
+
style={box}
|
|
139
|
+
data-block={cell !== null && row.block >= 0 ? row.block : undefined}
|
|
140
|
+
onClick={onArm}
|
|
141
|
+
>
|
|
142
|
+
{mark === undefined ? code : <span className={css.sideFlow} {...mark}>{code}</span>}
|
|
143
|
+
{bar}
|
|
144
|
+
</span>
|
|
145
|
+
</>
|
|
146
|
+
)
|
|
147
|
+
}
|
package/src/client/diff-nav.ts
CHANGED
|
@@ -234,6 +234,7 @@ export function blockTopsFromRows(
|
|
|
234
234
|
blocks: readonly number[],
|
|
235
235
|
rowH: number,
|
|
236
236
|
offset = 0,
|
|
237
|
+
topOf?: (index: number) => number,
|
|
237
238
|
): readonly BlockTop[] {
|
|
238
239
|
const tops: BlockTop[] = []
|
|
239
240
|
const seen = new Set<number>()
|
|
@@ -241,7 +242,9 @@ export function blockTopsFromRows(
|
|
|
241
242
|
const block = blocks[i]!
|
|
242
243
|
if (!Number.isInteger(block) || block < 0 || seen.has(block)) continue
|
|
243
244
|
seen.add(block)
|
|
244
|
-
|
|
245
|
+
// `i * rowH` is the answer only while every row is one line tall. With
|
|
246
|
+
// soft wrap on, the pane's height model is the one that knows.
|
|
247
|
+
tops.push({ block, top: offset + (topOf === undefined ? i * rowH : topOf(i)) })
|
|
245
248
|
}
|
|
246
249
|
return tops
|
|
247
250
|
}
|
package/src/client/dir-tree.ts
CHANGED
|
@@ -36,10 +36,20 @@ interface BuildNode {
|
|
|
36
36
|
* Fold a flat path list into a sorted directory tree carrying its files.
|
|
37
37
|
* Root-level files live on no directory; the SEARCH ({@link searchPaths}) is
|
|
38
38
|
* where they surface.
|
|
39
|
+
*
|
|
40
|
+
* `dirHints` names directories that exist with NO file under them yet — the
|
|
41
|
+
* collapsed entries of the ignored listing (`node_modules/`), whose children
|
|
42
|
+
* are only read when the reader expands them. A path list cannot express
|
|
43
|
+
* that: a node with no files and no children is indistinguishable from a
|
|
44
|
+
* path that was never mentioned. Hints only ever ADD an empty directory; a
|
|
45
|
+
* hint that lands on a name the path list recorded as a file is skipped,
|
|
46
|
+
* because the list is the primary source and a hint is hearsay next to it.
|
|
47
|
+
*
|
|
39
48
|
* @param paths - repo-relative file paths, any order, no duplicates assumed.
|
|
49
|
+
* @param dirHints - repo-relative directory paths to show even while empty.
|
|
40
50
|
* @returns the top-level directories, children and files sorted by name.
|
|
41
51
|
*/
|
|
42
|
-
export function buildDirTree(paths: readonly string[]): readonly DirEntry[] {
|
|
52
|
+
export function buildDirTree(paths: readonly string[], dirHints?: ReadonlySet<string>): readonly DirEntry[] {
|
|
43
53
|
const rootNode: BuildNode = { name: '', path: '', files: [], children: new Map() }
|
|
44
54
|
for (const path of paths) {
|
|
45
55
|
if (path.length === 0) continue
|
|
@@ -59,6 +69,26 @@ export function buildDirTree(paths: readonly string[]): readonly DirEntry[] {
|
|
|
59
69
|
node.files.push(parts[parts.length - 1]!)
|
|
60
70
|
}
|
|
61
71
|
|
|
72
|
+
const addHint = (hint: string): void => {
|
|
73
|
+
if (hint.length === 0) return
|
|
74
|
+
const parts = hint.split('/')
|
|
75
|
+
let node = rootNode
|
|
76
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
77
|
+
const name = parts[i]!
|
|
78
|
+
const child = node.children.get(name)
|
|
79
|
+
if (child !== undefined) { node = child; continue }
|
|
80
|
+
// The path list recorded this name as a file; the hint does not demote it.
|
|
81
|
+
if (node.files.includes(name)) return
|
|
82
|
+
const created: BuildNode = { name, path: parts.slice(0, i + 1).join('/'), files: [], children: new Map() }
|
|
83
|
+
node.children.set(name, created)
|
|
84
|
+
node = created
|
|
85
|
+
}
|
|
86
|
+
const last = parts[parts.length - 1]!
|
|
87
|
+
if (node.children.has(last) || node.files.includes(last)) return
|
|
88
|
+
node.children.set(last, { name: last, path: hint, files: [], children: new Map() })
|
|
89
|
+
}
|
|
90
|
+
if (dirHints !== undefined) for (const hint of dirHints) addHint(hint)
|
|
91
|
+
|
|
62
92
|
const freeze = (node: BuildNode): DirEntry => {
|
|
63
93
|
const children = [...node.children.values()].sort((a, b) => a.name.localeCompare(b.name)).map(freeze)
|
|
64
94
|
const files = [...node.files].sort((a, b) => a.localeCompare(b))
|
package/src/client/file-rows.ts
CHANGED
|
@@ -162,6 +162,46 @@ export function searchRows(paths: readonly string[], needle: string, cap: number
|
|
|
162
162
|
return out
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** What `git ls-files --others --ignored --exclude-standard --directory`
|
|
166
|
+
* returned, split by kind: every ignored FILE (any depth, as long as no rule
|
|
167
|
+
* swallowed its whole directory) and every directory git collapsed to one
|
|
168
|
+
* line because it is ignored as a whole — the trailing slash is the only
|
|
169
|
+
* thing that tells them apart, so it is the whole test.
|
|
170
|
+
* @param entries - the listing verbatim, as `repoTree` carried it.
|
|
171
|
+
*/
|
|
172
|
+
export function splitIgnored(entries: readonly string[]): { files: readonly string[]; dirs: readonly string[] } {
|
|
173
|
+
const files: string[] = []
|
|
174
|
+
const dirs: string[] = []
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (entry.length === 0) continue
|
|
177
|
+
if (entry.endsWith('/')) dirs.push(entry.slice(0, -1))
|
|
178
|
+
else files.push(entry)
|
|
179
|
+
}
|
|
180
|
+
return { files, dirs }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether a row is gitignored territory: an ignored file itself, a directory
|
|
185
|
+
* the listing collapsed, or anything living under one. Everything under a
|
|
186
|
+
* collapsed directory is ignored by inheritance, which is what makes the
|
|
187
|
+
* ancestor walk the whole rule.
|
|
188
|
+
*
|
|
189
|
+
* @param path - the row's repo-relative path.
|
|
190
|
+
* @param files - ignored file paths, from {@link splitIgnored}.
|
|
191
|
+
* @param dirs - collapsed directory paths, from {@link splitIgnored}.
|
|
192
|
+
*/
|
|
193
|
+
export function isIgnoredPath(
|
|
194
|
+
path: string,
|
|
195
|
+
files: ReadonlySet<string>,
|
|
196
|
+
dirs: ReadonlySet<string>,
|
|
197
|
+
): boolean {
|
|
198
|
+
if (files.has(path) || dirs.has(path)) return true
|
|
199
|
+
for (const dir of ancestorsOf(path)) {
|
|
200
|
+
if (dirs.has(dir)) return true
|
|
201
|
+
}
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
|
|
165
205
|
/**
|
|
166
206
|
* The browsable path list: everything git tracks, plus files that exist on
|
|
167
207
|
* disk but not in HEAD.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a side-by-side column's horizontal scrollbar belongs.
|
|
3
|
+
*
|
|
4
|
+
* The pane scrolls vertically; each column scrolls horizontally on its own,
|
|
5
|
+
* which is what lets the divider mean something (one grid across both sides is
|
|
6
|
+
* sized by the widest line in the file, so dragging it moves nothing). The
|
|
7
|
+
* cost of that split is where the browser draws the column's scrollbar: at the
|
|
8
|
+
* bottom of the COLUMN, and the column is as tall as the file. On a
|
|
9
|
+
* two-thousand-line diff the only way to reach the control that scrolls
|
|
10
|
+
* sideways was to scroll all the way down first — and then scroll back up to
|
|
11
|
+
* see what it did.
|
|
12
|
+
*
|
|
13
|
+
* So the column's own scrollbar is hidden and a rail is stuck to the bottom of
|
|
14
|
+
* the pane instead, one per column, each a real scroller whose content is
|
|
15
|
+
* exactly as wide as its column's. Same geometry, so the two scroll positions
|
|
16
|
+
* map one to one; native, so it looks and behaves like every other scrollbar
|
|
17
|
+
* on the machine.
|
|
18
|
+
*
|
|
19
|
+
* This module is the part with no DOM in it: when a rail is warranted at all.
|
|
20
|
+
*
|
|
21
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/h-rail
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A column's visible width and the width of what is inside it. */
|
|
25
|
+
export interface RailMetric {
|
|
26
|
+
/** The column's client width — what the reader can see at once. */
|
|
27
|
+
readonly view: number
|
|
28
|
+
/** The column's scroll width — how wide the widest row made it. */
|
|
29
|
+
readonly content: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Before anything has been measured, and for a column that is not mounted. */
|
|
33
|
+
export const NO_RAIL: RailMetric = { view: 0, content: 0 }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a column overflows by enough to be worth a scrollbar.
|
|
37
|
+
*
|
|
38
|
+
* The one-pixel slack is not tidiness: a grid track rounds to a fraction, so a
|
|
39
|
+
* column whose longest line exactly fits still reports a scroll width a third
|
|
40
|
+
* of a pixel wider than its client width. Without the slack every file gets a
|
|
41
|
+
* rail, and every one of those rails scrolls nowhere.
|
|
42
|
+
*
|
|
43
|
+
* @param metric - the column's measured widths.
|
|
44
|
+
* @returns true when a rail should be shown for it.
|
|
45
|
+
*/
|
|
46
|
+
export function railNeeded(metric: RailMetric): boolean {
|
|
47
|
+
const { view, content } = metric
|
|
48
|
+
if (!Number.isFinite(view) || !Number.isFinite(content)) return false
|
|
49
|
+
if (view <= 0) return false
|
|
50
|
+
return content > view + 1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether the rail row is shown at all — either column overflowing is enough,
|
|
54
|
+
* since the row is one sticky strip and a lone rail still needs its space. */
|
|
55
|
+
export function railsShown(left: RailMetric, right: RailMetric): boolean {
|
|
56
|
+
return railNeeded(left) || railNeeded(right)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether two measurements are the same.
|
|
61
|
+
*
|
|
62
|
+
* The measuring effect runs on every render, so this is what keeps it from
|
|
63
|
+
* setting state on every render and rendering again forever.
|
|
64
|
+
*
|
|
65
|
+
* @param a - the measurement held in state.
|
|
66
|
+
* @param b - the one just read from the DOM.
|
|
67
|
+
*/
|
|
68
|
+
export function sameMetric(a: RailMetric, b: RailMetric): boolean {
|
|
69
|
+
return a.view === b.view && a.content === b.content
|
|
70
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lazily read children of ignored directories, held as a BOUNDED cache.
|
|
3
|
+
*
|
|
4
|
+
* Browsing `node_modules/` reads one level per click, and every level read
|
|
5
|
+
* stays around so folding and unfolding does not re-read the disk. Left
|
|
6
|
+
* alone that is a cache that only grows: a long session drilling through a
|
|
7
|
+
* dependency tree accumulates every level it ever opened, and "an unbounded
|
|
8
|
+
* cache is a leak with a nicer name".
|
|
9
|
+
*
|
|
10
|
+
* So the cache carries its own size and evicts, with one exception that is
|
|
11
|
+
* not negotiable: it never evicts a directory the reader is LOOKING at.
|
|
12
|
+
* Dropping the children of an expanded row would make the effect that fills
|
|
13
|
+
* it read them again, which would evict something else, which would be read
|
|
14
|
+
* again — a cache that fights the viewport spins forever. The cap is
|
|
15
|
+
* therefore a fuse against accumulated browsing, and the `keep` set is the
|
|
16
|
+
* floor it will not cut below.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no React, no DOM, no RPC. `tests/ignored-cache.test.ts` loads it.
|
|
19
|
+
*
|
|
20
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/ignored-cache
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { DirEntry } from './dir-tree.ts'
|
|
24
|
+
|
|
25
|
+
/** One entry of a lazily listed ignored directory: a name plus whether
|
|
26
|
+
* expanding it again makes sense. */
|
|
27
|
+
export interface IgnoredChild {
|
|
28
|
+
readonly name: string
|
|
29
|
+
readonly dir: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One directory's read, exactly as the host answered it. */
|
|
33
|
+
export interface DirRead {
|
|
34
|
+
readonly entries: readonly IgnoredChild[]
|
|
35
|
+
/** Whether the host cut this directory's listing at its own cap. */
|
|
36
|
+
readonly truncated: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every directory read so far, plus the two things a bound needs: the order
|
|
41
|
+
* they were read in (the eviction queue, oldest first) and the total entry
|
|
42
|
+
* count, so a test can PROVE the bound instead of trusting it.
|
|
43
|
+
*/
|
|
44
|
+
export interface IgnoredCache {
|
|
45
|
+
readonly reads: Readonly<Record<string, DirRead>>
|
|
46
|
+
readonly order: readonly string[]
|
|
47
|
+
/** Total entries held across every directory. */
|
|
48
|
+
readonly size: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Nothing read yet. One instance, so an untouched worktree does not
|
|
52
|
+
* re-render the browser on every pass. */
|
|
53
|
+
export const NO_IGNORED_READS: IgnoredCache = { reads: {}, order: [], size: 0 }
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Most entries held across all directories at once. A real level is a few
|
|
57
|
+
* hundred, so reaching this takes dozens of expansions of pathological
|
|
58
|
+
* directories; it is the fuse, not a working number.
|
|
59
|
+
*/
|
|
60
|
+
export const IGNORED_CACHE_CAP = 20_000
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Record one directory's children, evicting the oldest reads that nobody is
|
|
64
|
+
* looking at until the cache is back under the cap.
|
|
65
|
+
*
|
|
66
|
+
* @param cache - the cache as it stands.
|
|
67
|
+
* @param dir - repo-relative directory the read belongs to.
|
|
68
|
+
* @param read - what the host answered.
|
|
69
|
+
* @param keep - directories that must survive eviction: the ones expanded on
|
|
70
|
+
* screen, and the ancestors of the open file (whose path must
|
|
71
|
+
* stay in the browsable list or the editor would report it
|
|
72
|
+
* vanished). The directory being written is always kept.
|
|
73
|
+
* @param cap - most entries to hold in total.
|
|
74
|
+
*/
|
|
75
|
+
export function rememberDir(
|
|
76
|
+
cache: IgnoredCache,
|
|
77
|
+
dir: string,
|
|
78
|
+
read: DirRead,
|
|
79
|
+
keep: ReadonlySet<string>,
|
|
80
|
+
cap: number = IGNORED_CACHE_CAP,
|
|
81
|
+
): IgnoredCache {
|
|
82
|
+
const reads: Record<string, DirRead> = { ...cache.reads, [dir]: read }
|
|
83
|
+
const order = [...cache.order.filter(key => key !== dir), dir]
|
|
84
|
+
let size = cache.size - (cache.reads[dir]?.entries.length ?? 0) + read.entries.length
|
|
85
|
+
|
|
86
|
+
// Oldest first, skipping what is on screen. When everything left is kept,
|
|
87
|
+
// the walk simply ends: over the cap and holding only visible rows is the
|
|
88
|
+
// one state where the right move is to hold them.
|
|
89
|
+
let index = 0
|
|
90
|
+
while (size > cap && index < order.length) {
|
|
91
|
+
const victim = order[index]!
|
|
92
|
+
if (victim === dir || keep.has(victim)) { index += 1; continue }
|
|
93
|
+
size -= reads[victim]?.entries.length ?? 0
|
|
94
|
+
delete reads[victim]
|
|
95
|
+
order.splice(index, 1)
|
|
96
|
+
}
|
|
97
|
+
return { reads, order, size }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether any directory still held was cut at the host's per-directory cap.
|
|
101
|
+
* Derived rather than sticky: a cut directory that has been evicted, or a
|
|
102
|
+
* refresh that replaced the cache, stops claiming it. */
|
|
103
|
+
export function anyDirTruncated(cache: IgnoredCache): boolean {
|
|
104
|
+
for (const read of Object.values(cache.reads)) {
|
|
105
|
+
if (read.truncated) return true
|
|
106
|
+
}
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Files plus subtree counts — the invariant every {@link DirEntry} carries. */
|
|
111
|
+
function countOf(files: readonly string[], children: readonly DirEntry[]): number {
|
|
112
|
+
return files.length + children.reduce((sum, child) => sum + child.fileCount, 0)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Merge one directory's read into the node that stands for it. Names the
|
|
116
|
+
* node already has win: the path list is the primary source, and a read that
|
|
117
|
+
* disagrees with it must not split one name into two rows. */
|
|
118
|
+
function withChildren(entry: DirEntry, read: DirRead): DirEntry {
|
|
119
|
+
const haveDirs = new Set(entry.children.map(child => child.name))
|
|
120
|
+
const haveFiles = new Set(entry.files)
|
|
121
|
+
const files = [...entry.files]
|
|
122
|
+
const children = [...entry.children]
|
|
123
|
+
for (const child of read.entries) {
|
|
124
|
+
if (haveDirs.has(child.name) || haveFiles.has(child.name)) continue
|
|
125
|
+
if (child.dir) {
|
|
126
|
+
haveDirs.add(child.name)
|
|
127
|
+
children.push({ name: child.name, path: `${entry.path}/${child.name}`, fileCount: 0, files: [], children: [] })
|
|
128
|
+
} else {
|
|
129
|
+
haveFiles.add(child.name)
|
|
130
|
+
files.push(child.name)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
files.sort((a, b) => a.localeCompare(b))
|
|
134
|
+
children.sort((a, b) => a.name.localeCompare(b.name))
|
|
135
|
+
return { ...entry, files, children, fileCount: countOf(files, children) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Rebuild only the spine down to `parts`, leaving every other node — and
|
|
139
|
+
* every other subtree's identity — exactly as it was. Null when the path is
|
|
140
|
+
* not in the tree, which is how a read for a directory a refresh dropped
|
|
141
|
+
* becomes a no-op instead of an error. */
|
|
142
|
+
function replaceAt(
|
|
143
|
+
dirs: readonly DirEntry[],
|
|
144
|
+
parts: readonly string[],
|
|
145
|
+
depth: number,
|
|
146
|
+
apply: (entry: DirEntry) => DirEntry,
|
|
147
|
+
): readonly DirEntry[] | null {
|
|
148
|
+
const name = parts[depth]!
|
|
149
|
+
const index = dirs.findIndex(dir => dir.name === name)
|
|
150
|
+
if (index === -1) return null
|
|
151
|
+
const current = dirs[index]!
|
|
152
|
+
let next: DirEntry
|
|
153
|
+
if (depth === parts.length - 1) {
|
|
154
|
+
next = apply(current)
|
|
155
|
+
} else {
|
|
156
|
+
const grafted = replaceAt(current.children, parts, depth + 1, apply)
|
|
157
|
+
if (grafted === null) return null
|
|
158
|
+
next = { ...current, children: grafted, fileCount: countOf(current.files, grafted) }
|
|
159
|
+
}
|
|
160
|
+
const out = [...dirs]
|
|
161
|
+
out[index] = next
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Graft every read directory's children onto the tree built from the path
|
|
167
|
+
* list.
|
|
168
|
+
*
|
|
169
|
+
* This is the reason expanding an ignored directory is not O(repository):
|
|
170
|
+
* rebuilding the tree from a merged path list meant a fresh `Set`, a fresh
|
|
171
|
+
* `localeCompare` sort and a fresh walk over EVERY path on every click —
|
|
172
|
+
* measured at 140ms on a 50,000-path repository, synchronously, on the click.
|
|
173
|
+
* Grafting touches only the nodes on the path to each read directory, so the
|
|
174
|
+
* cost is the depth of what was clicked plus the width of what came back.
|
|
175
|
+
*
|
|
176
|
+
* Shallow directories are grafted first, so the empty node a parent's read
|
|
177
|
+
* creates exists by the time its own read is grafted onto it.
|
|
178
|
+
*
|
|
179
|
+
* @param tree - the tree from {@link buildDirTree} over the path list.
|
|
180
|
+
* @param cache - the directories read so far.
|
|
181
|
+
*/
|
|
182
|
+
export function attachReads(tree: readonly DirEntry[], cache: IgnoredCache): readonly DirEntry[] {
|
|
183
|
+
const dirs = Object.keys(cache.reads)
|
|
184
|
+
if (dirs.length === 0) return tree
|
|
185
|
+
const shallowFirst = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length)
|
|
186
|
+
let out = tree
|
|
187
|
+
for (const dir of shallowFirst) {
|
|
188
|
+
if (dir.length === 0) continue
|
|
189
|
+
const grafted = replaceAt(out, dir.split('/'), 0, entry => withChildren(entry, cache.reads[dir]!))
|
|
190
|
+
if (grafted !== null) out = grafted
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -181,16 +181,35 @@ export function apply(ctx: ClientContext): void {
|
|
|
181
181
|
) as { ok: true; value: { authors: AuthorEntry[]; truncated: boolean } } | { ok: false; error: { message?: string } }
|
|
182
182
|
return result.ok ? result.value : null
|
|
183
183
|
},
|
|
184
|
-
// Every path on HEAD — the path picker's raw material
|
|
185
|
-
|
|
184
|
+
// Every path on HEAD — the path picker's raw material — plus the
|
|
185
|
+
// ignored entries the Files tab browses. The ignored fields are
|
|
186
|
+
// optional because a host half older than this client does not send
|
|
187
|
+
// them: the browser then simply has no ignored rows to show.
|
|
188
|
+
fetchRepoTree: async (worktreePath: string | undefined, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean; ignored?: string[]; ignoredTruncated?: boolean; ignoredError?: string } | null> => {
|
|
186
189
|
const result = await connection.rpc.call(
|
|
187
190
|
'/api',
|
|
188
191
|
'gitWorkbench/repoTree',
|
|
189
192
|
{ args: { worktreePath: worktreePath ?? '' } },
|
|
190
193
|
signal,
|
|
191
|
-
) as { ok: true; value: { paths: string[]; truncated: boolean } } | { ok: false; error: { message?: string } }
|
|
194
|
+
) as { ok: true; value: { paths: string[]; truncated: boolean; ignored?: string[]; ignoredTruncated?: boolean; ignoredError?: string } } | { ok: false; error: { message?: string } }
|
|
192
195
|
return result.ok ? result.value : null
|
|
193
196
|
},
|
|
197
|
+
// One level of one ignored directory, read from the filesystem: git
|
|
198
|
+
// collapses ignored directories by design, so the only way into
|
|
199
|
+
// `node_modules/` is to ask the disk directly (see the host method).
|
|
200
|
+
fetchIgnoredDir: async (worktreePath: string | undefined, dir: string, signal: AbortSignal): Promise<{ entries: { name: string; dir: boolean }[]; truncated: boolean } | null> => {
|
|
201
|
+
try {
|
|
202
|
+
const result = await connection.rpc.call(
|
|
203
|
+
'/api',
|
|
204
|
+
'gitWorkbench/ignoredDir',
|
|
205
|
+
{ args: { worktreePath: worktreePath ?? '', dir } },
|
|
206
|
+
signal,
|
|
207
|
+
) as { ok: true; value: { entries: { name: string; dir: boolean }[]; truncated: boolean } } | { ok: false; error: { message?: string } }
|
|
208
|
+
return result.ok ? result.value : null
|
|
209
|
+
} catch {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
},
|
|
194
213
|
// Two refs compared as `base...head`, in the same shape as every other
|
|
195
214
|
// view, so the drawer's tree and diff panes render it unchanged.
|
|
196
215
|
fetchCompare: async (worktreePath: string | undefined, base: string, head: string, signal: AbortSignal): Promise<WorkbenchStats | null> => {
|