@young1lin/dsh-ui-gitworkbench 0.1.5 → 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 +33 -0
- package/CHANGELOG_EN.md +33 -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 +24360 -1436
- 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 +453 -7
- package/src/client/GitWorkbenchPanel.tsx +1436 -143
- 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/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 +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/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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walking a diff change by change.
|
|
3
|
+
*
|
|
4
|
+
* A file whose whole delta is `+1 −1` is unreadable by scrolling: the change
|
|
5
|
+
* is one tinted line somewhere in two thousand, and the tint is only visible
|
|
6
|
+
* once you are already looking at it. The row model already knows where every
|
|
7
|
+
* change is — `alignRows` groups changed rows into blocks and every rendered
|
|
8
|
+
* code cell carries its block id — so nothing here has to find the changes.
|
|
9
|
+
* What it decides is which one is NEXT.
|
|
10
|
+
*
|
|
11
|
+
* Position comes from the VIEWPORT, not from a remembered index. A reader
|
|
12
|
+
* hunting for a change also scrolls with the wheel, and a counter kept in
|
|
13
|
+
* state goes stale the moment they do — pressing the button would then jump
|
|
14
|
+
* back to wherever the last press left off, which reads as the button being
|
|
15
|
+
* broken. Asking the scroller where it is costs one layout read per press and
|
|
16
|
+
* is always right.
|
|
17
|
+
*
|
|
18
|
+
* The peek is the part that is easy to get wrong. Landing a change flush
|
|
19
|
+
* against the top edge hides the line above it, which is usually the line that
|
|
20
|
+
* makes the change legible — so the scroll stops a few rows short. That offset
|
|
21
|
+
* must then be added BACK when deciding what comes next, or the block just
|
|
22
|
+
* landed on still counts as "below the top of the viewport" and every press
|
|
23
|
+
* lands on it again. {@link anchorFor} and {@link scrollTopFor} are the two
|
|
24
|
+
* halves of that one invariant, which is why they are named rather than
|
|
25
|
+
* inlined as arithmetic at the call site.
|
|
26
|
+
*
|
|
27
|
+
* Pure: no React, no DOM. The component measures, this decides.
|
|
28
|
+
*
|
|
29
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/diff-nav
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The pane's row height, from `.sideColGrid`'s `line-height`. */
|
|
33
|
+
export const NAV_ROW_PX = 20
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rows of context kept above a change the reader is sent to.
|
|
37
|
+
*
|
|
38
|
+
* Three, because a one-line edit is read against its neighbours: the line
|
|
39
|
+
* above is what tells you which rule, which function, which property this is.
|
|
40
|
+
*/
|
|
41
|
+
export const NAV_PEEK_ROWS = 3
|
|
42
|
+
|
|
43
|
+
/** The peek in pixels. */
|
|
44
|
+
export const NAV_PEEK_PX = NAV_ROW_PX * NAV_PEEK_ROWS
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sub-pixel tolerance. `getBoundingClientRect` reports fractions, and a block
|
|
48
|
+
* sitting a third of a pixel below the anchor is the block the reader is
|
|
49
|
+
* already looking at, not the next one.
|
|
50
|
+
*/
|
|
51
|
+
const EPSILON_PX = 1
|
|
52
|
+
|
|
53
|
+
/** Where one change block starts, in the scroller's content coordinates. */
|
|
54
|
+
export interface BlockTop {
|
|
55
|
+
/** The block id the rows carry. */
|
|
56
|
+
readonly block: number
|
|
57
|
+
/** Offset from the top of the scrolled content, in pixels. */
|
|
58
|
+
readonly top: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The content position that counts as "where the reader is".
|
|
63
|
+
*
|
|
64
|
+
* The top of the viewport plus the peek, so a change the reader was just sent
|
|
65
|
+
* to sits exactly AT the anchor rather than below it — which is what stops
|
|
66
|
+
* "next" from choosing it a second time.
|
|
67
|
+
*/
|
|
68
|
+
export function anchorFor(scrollTop: number): number {
|
|
69
|
+
return scrollTop + NAV_PEEK_PX
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Where to scroll so a block sits {@link NAV_PEEK_ROWS} rows below the top. */
|
|
73
|
+
export function scrollTopFor(top: number): number {
|
|
74
|
+
return Math.max(0, top - NAV_PEEK_PX)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The block a press landed on, and the scroll position it actually achieved. */
|
|
78
|
+
export interface NavMemory {
|
|
79
|
+
readonly block: number
|
|
80
|
+
/** `scrollTop` AFTER the browser clamped it, not the value asked for. */
|
|
81
|
+
readonly scrollTop: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where to step from.
|
|
86
|
+
*
|
|
87
|
+
* Normally the viewport answers this, and {@link anchorFor} is the whole
|
|
88
|
+
* story. It stops being the whole story at the END of a file: a change in the
|
|
89
|
+
* last screenful cannot be scrolled to the peek line, because the scroller
|
|
90
|
+
* runs out of content first. `scrollTop` then stops changing between presses,
|
|
91
|
+
* the viewport can no longer say which change was last visited, and "next"
|
|
92
|
+
* chooses that same block forever — the reader is stuck on the final change
|
|
93
|
+
* with no way to wrap around.
|
|
94
|
+
*
|
|
95
|
+
* So when the scroll has NOT moved since the last press, the block that press
|
|
96
|
+
* landed on is the anchor instead. The two agree everywhere they both apply: a
|
|
97
|
+
* block parked at the peek line has a top exactly equal to `anchorFor` of the
|
|
98
|
+
* resulting scroll. Any hand-scrolling invalidates the memory and the viewport
|
|
99
|
+
* takes over again, which is what keeps the wheel and the buttons consistent.
|
|
100
|
+
*
|
|
101
|
+
* @param tops - every block's top, as measured for this press.
|
|
102
|
+
* @param scrollTop - the scroller's current position.
|
|
103
|
+
* @param held - what the previous press recorded, or null.
|
|
104
|
+
*/
|
|
105
|
+
export function anchorFrom(
|
|
106
|
+
tops: readonly BlockTop[],
|
|
107
|
+
scrollTop: number,
|
|
108
|
+
held: NavMemory | null,
|
|
109
|
+
): number {
|
|
110
|
+
if (held !== null && Math.abs(held.scrollTop - scrollTop) < 1) {
|
|
111
|
+
const landed = tops.find(entry => entry.block === held.block)
|
|
112
|
+
// A block that no longer exists — the diff was refreshed under the reader
|
|
113
|
+
// — leaves nothing to step from, so the viewport answers after all.
|
|
114
|
+
if (landed !== undefined) return landed.top
|
|
115
|
+
}
|
|
116
|
+
return anchorFor(scrollTop)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The next or previous change block, wrapping at the ends.
|
|
121
|
+
*
|
|
122
|
+
* Wrapping rather than stopping: a reader pressing the button repeatedly is
|
|
123
|
+
* taking a tour of the file's changes, and a button that goes dead at the last
|
|
124
|
+
* one asks them to notice why. Coming back around to the first says the same
|
|
125
|
+
* thing — you have seen them all — without a disabled control to interpret.
|
|
126
|
+
*
|
|
127
|
+
* @param anchors - every block's top; order does not matter.
|
|
128
|
+
* @param anchorTop - from {@link anchorFor}, never a raw `scrollTop`.
|
|
129
|
+
* @param direction - 1 for the next change, -1 for the previous one.
|
|
130
|
+
* @returns the block to go to, or null when the diff has no changes at all.
|
|
131
|
+
*/
|
|
132
|
+
export function stepToBlock(
|
|
133
|
+
anchors: readonly BlockTop[],
|
|
134
|
+
anchorTop: number,
|
|
135
|
+
direction: 1 | -1,
|
|
136
|
+
): BlockTop | null {
|
|
137
|
+
if (anchors.length === 0) return null
|
|
138
|
+
const ordered = [...anchors].sort((a, b) => a.top - b.top)
|
|
139
|
+
if (direction === 1) {
|
|
140
|
+
const ahead = ordered.find(entry => entry.top > anchorTop + EPSILON_PX)
|
|
141
|
+
return ahead ?? ordered[0] ?? null
|
|
142
|
+
}
|
|
143
|
+
let behind: BlockTop | null = null
|
|
144
|
+
for (const entry of ordered) {
|
|
145
|
+
if (entry.top < anchorTop - EPSILON_PX) behind = entry
|
|
146
|
+
else break
|
|
147
|
+
}
|
|
148
|
+
return behind ?? ordered[ordered.length - 1] ?? null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One unified-diff row, as far as grouping is concerned. */
|
|
152
|
+
export type RowKind = 'add' | 'del' | 'context' | 'hunk'
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Group a unified diff's rows into change blocks.
|
|
156
|
+
*
|
|
157
|
+
* A block is a maximal run of added and deleted rows. A replacement arrives
|
|
158
|
+
* from git as deletions followed by additions with nothing between them, and
|
|
159
|
+
* that is ONE change to a reader — sending them to the `-` lines and then
|
|
160
|
+
* again to the `+` lines directly below would be counting the same edit twice.
|
|
161
|
+
* Context and hunk headers both end a run: a hunk header means git skipped
|
|
162
|
+
* lines, so what follows is somewhere else in the file.
|
|
163
|
+
*
|
|
164
|
+
* The side-by-side pane does not need this — its row model already carries a
|
|
165
|
+
* block id per row, because the blocks there are also what the stage and
|
|
166
|
+
* roll-back buttons act on. The unified view has no such model, so the runs
|
|
167
|
+
* are read off the kinds.
|
|
168
|
+
*
|
|
169
|
+
* @param kinds - every row's kind, in document order.
|
|
170
|
+
* @returns a block id per row, aligned with the input; -1 for a row that is
|
|
171
|
+
* not part of any change.
|
|
172
|
+
*/
|
|
173
|
+
export function unifiedBlocks(kinds: readonly RowKind[]): readonly number[] {
|
|
174
|
+
const ids: number[] = []
|
|
175
|
+
let block = -1
|
|
176
|
+
let inRun = false
|
|
177
|
+
for (const kind of kinds) {
|
|
178
|
+
const changed = kind === 'add' || kind === 'del'
|
|
179
|
+
if (!changed) {
|
|
180
|
+
inRun = false
|
|
181
|
+
ids.push(-1)
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (!inRun) {
|
|
185
|
+
block += 1
|
|
186
|
+
inRun = true
|
|
187
|
+
}
|
|
188
|
+
ids.push(block)
|
|
189
|
+
}
|
|
190
|
+
return ids
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** How many change blocks {@link unifiedBlocks} found. */
|
|
194
|
+
export function countBlocks(ids: readonly number[]): number {
|
|
195
|
+
let top = -1
|
|
196
|
+
for (const id of ids) if (id > top) top = id
|
|
197
|
+
return top + 1
|
|
198
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a file's icon says about its language: a colour and a one- or two-letter
|
|
3
|
+
* monogram, keyed off the path.
|
|
4
|
+
*
|
|
5
|
+
* The obvious ask is the mascots — the gopher, the coffee cup, the crab. Those
|
|
6
|
+
* are not ours to ship: the gopher is a CC-BY work of Renée French, the cup and
|
|
7
|
+
* the two snakes are registered marks of Oracle and the PSF, and this is a
|
|
8
|
+
* package published to npm rather than a screenshot. So the icon keeps the
|
|
9
|
+
* drawer's own sheet outline and carries the language's canonical brand colour
|
|
10
|
+
* with a monogram on it — recognisable at a glance in a column of files, and
|
|
11
|
+
* nobody's mark reproduced.
|
|
12
|
+
*
|
|
13
|
+
* Colours are each language's own published one where it has it (Go's cyan,
|
|
14
|
+
* Rust's rust, TypeScript's blue), which is what makes the column scannable:
|
|
15
|
+
* you learn "the cyan ones are Go" in about four files without reading a
|
|
16
|
+
* single letter.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no React, no DOM. `tests/file-icon.test.ts` loads it directly.
|
|
19
|
+
*
|
|
20
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/file-icon
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** How one file type paints. */
|
|
24
|
+
export interface FileIcon {
|
|
25
|
+
/** One or two characters, uppercase; '' for a file with no language. */
|
|
26
|
+
readonly mono: string
|
|
27
|
+
/** The tint, as a CSS colour. */
|
|
28
|
+
readonly color: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a file whose type we have nothing to say about looks like: the
|
|
32
|
+
* drawer's ordinary foreground, no monogram. */
|
|
33
|
+
export const PLAIN: FileIcon = { mono: '', color: 'var(--gs-fg-faint)' }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* By extension. Two letters where one would collide (JS/JSON, TS/TSX), one
|
|
37
|
+
* where it reads cleanly.
|
|
38
|
+
*/
|
|
39
|
+
const BY_EXT: Readonly<Record<string, FileIcon>> = {
|
|
40
|
+
go: { mono: 'GO', color: '#00add8' },
|
|
41
|
+
mod: { mono: 'GO', color: '#00add8' },
|
|
42
|
+
sum: { mono: 'GO', color: '#00add8' },
|
|
43
|
+
java: { mono: 'J', color: '#e76f00' },
|
|
44
|
+
class: { mono: 'J', color: '#e76f00' },
|
|
45
|
+
jar: { mono: 'J', color: '#e76f00' },
|
|
46
|
+
kt: { mono: 'KT', color: '#7f52ff' },
|
|
47
|
+
kts: { mono: 'KT', color: '#7f52ff' },
|
|
48
|
+
scala: { mono: 'SC', color: '#dc322f' },
|
|
49
|
+
groovy: { mono: 'GR', color: '#4298b8' },
|
|
50
|
+
ts: { mono: 'TS', color: '#3178c6' },
|
|
51
|
+
mts: { mono: 'TS', color: '#3178c6' },
|
|
52
|
+
cts: { mono: 'TS', color: '#3178c6' },
|
|
53
|
+
tsx: { mono: 'TX', color: '#3178c6' },
|
|
54
|
+
js: { mono: 'JS', color: '#f7df1e' },
|
|
55
|
+
mjs: { mono: 'JS', color: '#f7df1e' },
|
|
56
|
+
cjs: { mono: 'JS', color: '#f7df1e' },
|
|
57
|
+
jsx: { mono: 'JX', color: '#f7df1e' },
|
|
58
|
+
py: { mono: 'PY', color: '#3776ab' },
|
|
59
|
+
pyi: { mono: 'PY', color: '#3776ab' },
|
|
60
|
+
rs: { mono: 'RS', color: '#dea584' },
|
|
61
|
+
rb: { mono: 'RB', color: '#cc342d' },
|
|
62
|
+
php: { mono: 'PH', color: '#777bb4' },
|
|
63
|
+
cs: { mono: 'C#', color: '#68217a' },
|
|
64
|
+
c: { mono: 'C', color: '#5588cc' },
|
|
65
|
+
h: { mono: 'H', color: '#5588cc' },
|
|
66
|
+
cpp: { mono: 'C+', color: '#00599c' },
|
|
67
|
+
cc: { mono: 'C+', color: '#00599c' },
|
|
68
|
+
cxx: { mono: 'C+', color: '#00599c' },
|
|
69
|
+
hpp: { mono: 'H+', color: '#00599c' },
|
|
70
|
+
hh: { mono: 'H+', color: '#00599c' },
|
|
71
|
+
m: { mono: 'OC', color: '#438eff' },
|
|
72
|
+
mm: { mono: 'OC', color: '#438eff' },
|
|
73
|
+
swift: { mono: 'SW', color: '#f05138' },
|
|
74
|
+
dart: { mono: 'DA', color: '#0175c2' },
|
|
75
|
+
lua: { mono: 'LU', color: '#000080' },
|
|
76
|
+
r: { mono: 'R', color: '#276dc3' },
|
|
77
|
+
jl: { mono: 'JL', color: '#9558b2' },
|
|
78
|
+
ex: { mono: 'EX', color: '#6e4a7e' },
|
|
79
|
+
exs: { mono: 'EX', color: '#6e4a7e' },
|
|
80
|
+
erl: { mono: 'ER', color: '#a90533' },
|
|
81
|
+
hs: { mono: 'HS', color: '#5e5086' },
|
|
82
|
+
clj: { mono: 'CL', color: '#5881d8' },
|
|
83
|
+
cljs: { mono: 'CL', color: '#5881d8' },
|
|
84
|
+
zig: { mono: 'ZI', color: '#f7a41d' },
|
|
85
|
+
nim: { mono: 'NI', color: '#ffe953' },
|
|
86
|
+
pl: { mono: 'PL', color: '#39457e' },
|
|
87
|
+
pm: { mono: 'PL', color: '#39457e' },
|
|
88
|
+
vue: { mono: 'VU', color: '#41b883' },
|
|
89
|
+
svelte: { mono: 'SV', color: '#ff3e00' },
|
|
90
|
+
astro: { mono: 'AS', color: '#ff5d01' },
|
|
91
|
+
sh: { mono: 'SH', color: '#89e051' },
|
|
92
|
+
bash: { mono: 'SH', color: '#89e051' },
|
|
93
|
+
zsh: { mono: 'SH', color: '#89e051' },
|
|
94
|
+
fish: { mono: 'SH', color: '#89e051' },
|
|
95
|
+
ps1: { mono: 'PS', color: '#012456' },
|
|
96
|
+
bat: { mono: 'BT', color: '#c1f12e' },
|
|
97
|
+
sql: { mono: 'SQ', color: '#e38c00' },
|
|
98
|
+
html: { mono: 'HT', color: '#e34c26' },
|
|
99
|
+
htm: { mono: 'HT', color: '#e34c26' },
|
|
100
|
+
css: { mono: 'CS', color: '#563d7c' },
|
|
101
|
+
scss: { mono: 'SA', color: '#c6538c' },
|
|
102
|
+
sass: { mono: 'SA', color: '#c6538c' },
|
|
103
|
+
less: { mono: 'LE', color: '#1d365d' },
|
|
104
|
+
json: { mono: 'JN', color: '#cbcb41' },
|
|
105
|
+
jsonc: { mono: 'JN', color: '#cbcb41' },
|
|
106
|
+
yaml: { mono: 'YM', color: '#cb171e' },
|
|
107
|
+
yml: { mono: 'YM', color: '#cb171e' },
|
|
108
|
+
toml: { mono: 'TM', color: '#9c4221' },
|
|
109
|
+
ini: { mono: 'IN', color: '#6d8086' },
|
|
110
|
+
cfg: { mono: 'IN', color: '#6d8086' },
|
|
111
|
+
conf: { mono: 'IN', color: '#6d8086' },
|
|
112
|
+
env: { mono: 'EN', color: '#edd100' },
|
|
113
|
+
xml: { mono: 'XM', color: '#0060ac' },
|
|
114
|
+
md: { mono: 'MD', color: '#7aa6da' },
|
|
115
|
+
mdx: { mono: 'MD', color: '#7aa6da' },
|
|
116
|
+
rst: { mono: 'RS', color: '#7aa6da' },
|
|
117
|
+
txt: { mono: 'TX', color: 'var(--gs-fg-faint)' },
|
|
118
|
+
csv: { mono: 'CV', color: '#41a05f' },
|
|
119
|
+
svg: { mono: 'SV', color: '#ffb13b' },
|
|
120
|
+
png: { mono: 'IM', color: '#a074c4' },
|
|
121
|
+
jpg: { mono: 'IM', color: '#a074c4' },
|
|
122
|
+
jpeg: { mono: 'IM', color: '#a074c4' },
|
|
123
|
+
gif: { mono: 'IM', color: '#a074c4' },
|
|
124
|
+
webp: { mono: 'IM', color: '#a074c4' },
|
|
125
|
+
ico: { mono: 'IM', color: '#a074c4' },
|
|
126
|
+
pdf: { mono: 'PD', color: '#d93831' },
|
|
127
|
+
zip: { mono: 'ZP', color: '#b8a038' },
|
|
128
|
+
gz: { mono: 'ZP', color: '#b8a038' },
|
|
129
|
+
tar: { mono: 'ZP', color: '#b8a038' },
|
|
130
|
+
lock: { mono: 'LK', color: '#8b8b8b' },
|
|
131
|
+
proto: { mono: 'PB', color: '#4285f4' },
|
|
132
|
+
graphql: { mono: 'GQ', color: '#e10098' },
|
|
133
|
+
gql: { mono: 'GQ', color: '#e10098' },
|
|
134
|
+
tf: { mono: 'TF', color: '#7b42bc' },
|
|
135
|
+
vim: { mono: 'VI', color: '#019833' },
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* By whole filename, for the files that carry their type in their NAME rather
|
|
140
|
+
* than an extension — a Dockerfile has no suffix, and `.gitignore` is all
|
|
141
|
+
* suffix. Matched case-insensitively before the extension table.
|
|
142
|
+
*/
|
|
143
|
+
const BY_NAME: Readonly<Record<string, FileIcon>> = {
|
|
144
|
+
dockerfile: { mono: 'DK', color: '#2496ed' },
|
|
145
|
+
'docker-compose.yml': { mono: 'DK', color: '#2496ed' },
|
|
146
|
+
'docker-compose.yaml': { mono: 'DK', color: '#2496ed' },
|
|
147
|
+
makefile: { mono: 'MK', color: '#427819' },
|
|
148
|
+
cmakelists: { mono: 'CM', color: '#064f8c' },
|
|
149
|
+
'cmakelists.txt': { mono: 'CM', color: '#064f8c' },
|
|
150
|
+
'.gitignore': { mono: 'GI', color: '#f05033' },
|
|
151
|
+
'.gitattributes': { mono: 'GI', color: '#f05033' },
|
|
152
|
+
'.gitmodules': { mono: 'GI', color: '#f05033' },
|
|
153
|
+
'.npmrc': { mono: 'NP', color: '#cb3837' },
|
|
154
|
+
'.nvmrc': { mono: 'NP', color: '#cb3837' },
|
|
155
|
+
'package.json': { mono: 'NP', color: '#cb3837' },
|
|
156
|
+
'package-lock.json': { mono: 'NP', color: '#cb3837' },
|
|
157
|
+
'pnpm-lock.yaml': { mono: 'PN', color: '#f69220' },
|
|
158
|
+
'yarn.lock': { mono: 'YN', color: '#2c8ebb' },
|
|
159
|
+
'cargo.toml': { mono: 'RS', color: '#dea584' },
|
|
160
|
+
'cargo.lock': { mono: 'RS', color: '#dea584' },
|
|
161
|
+
'go.mod': { mono: 'GO', color: '#00add8' },
|
|
162
|
+
'go.sum': { mono: 'GO', color: '#00add8' },
|
|
163
|
+
'pom.xml': { mono: 'MV', color: '#c71a36' },
|
|
164
|
+
'build.gradle': { mono: 'GD', color: '#02303a' },
|
|
165
|
+
'build.gradle.kts': { mono: 'GD', color: '#02303a' },
|
|
166
|
+
license: { mono: 'LI', color: '#d0b000' },
|
|
167
|
+
'license.md': { mono: 'LI', color: '#d0b000' },
|
|
168
|
+
readme: { mono: 'MD', color: '#7aa6da' },
|
|
169
|
+
'readme.md': { mono: 'MD', color: '#7aa6da' },
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The icon for one path.
|
|
174
|
+
*
|
|
175
|
+
* The whole-name table wins over the extension table, so `package.json` reads
|
|
176
|
+
* as npm rather than as generic JSON — the name is the more specific fact.
|
|
177
|
+
*
|
|
178
|
+
* @param path - repo-relative or bare filename; only the last segment matters.
|
|
179
|
+
*/
|
|
180
|
+
export function fileIcon(path: string): FileIcon {
|
|
181
|
+
const name = (path.split('/').pop() ?? '').toLowerCase()
|
|
182
|
+
if (name.length === 0) return PLAIN
|
|
183
|
+
const byName = BY_NAME[name]
|
|
184
|
+
if (byName !== undefined) return byName
|
|
185
|
+
const dot = name.lastIndexOf('.')
|
|
186
|
+
// A leading dot is the whole name of a dotfile, not the start of a suffix:
|
|
187
|
+
// `.gitignore` has no extension, and `git` is not a language.
|
|
188
|
+
if (dot <= 0) return PLAIN
|
|
189
|
+
return BY_EXT[name.slice(dot + 1)] ?? PLAIN
|
|
190
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file browser's row list: which rows a repository tree renders, given
|
|
3
|
+
* which directories the reader has opened.
|
|
4
|
+
*
|
|
5
|
+
* This sits on top of {@link buildDirTree}, which the history filter's path
|
|
6
|
+
* picker already uses, and adds the two things a BROWSER needs that a picker
|
|
7
|
+
* did not. First, root-level files: `buildDirTree` returns the root's child
|
|
8
|
+
* directories, so a file living on no directory — `package.json`, `README.md`
|
|
9
|
+
* — never appears in it. A picker could leave those to its search; a browser
|
|
10
|
+
* that cannot open `package.json` is not a browser. Second, a flat row list
|
|
11
|
+
* with depth, because the tree renders as rows and the component should not
|
|
12
|
+
* be walking a recursive structure while it also handles clicks.
|
|
13
|
+
*
|
|
14
|
+
* Search results are FILE rows only. The picker lists directories too, since
|
|
15
|
+
* a directory is a tickable pathspec there; here a row is something to open,
|
|
16
|
+
* and a directory does not open.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no React, no DOM, no git. `tests/file-rows.test.ts` loads it directly.
|
|
19
|
+
*
|
|
20
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/file-rows
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { DirEntry } from './dir-tree.ts'
|
|
24
|
+
|
|
25
|
+
/** One rendered row of the browser's tree. */
|
|
26
|
+
export interface FileRow {
|
|
27
|
+
/** `more` is the "and N others" marker a capped directory ends with. */
|
|
28
|
+
readonly kind: 'dir' | 'file' | 'more'
|
|
29
|
+
/** Repo-relative path — for a file row, what gets opened. */
|
|
30
|
+
readonly path: string
|
|
31
|
+
/** What the row shows: the last segment, or the whole path in a search. */
|
|
32
|
+
readonly name: string
|
|
33
|
+
/** Indent level; 0 at the top. */
|
|
34
|
+
readonly depth: number
|
|
35
|
+
/** Whether this directory is expanded. Always false on a file row. */
|
|
36
|
+
readonly open: boolean
|
|
37
|
+
/** On a `more` row: how many entries the cap held back. */
|
|
38
|
+
readonly hidden?: number
|
|
39
|
+
/**
|
|
40
|
+
* On a `more` row: what it is holding back.
|
|
41
|
+
*
|
|
42
|
+
* A directory can end with both markers — too many subdirectories AND too
|
|
43
|
+
* many files — and they sit at the same depth under the same prefix, so
|
|
44
|
+
* without this they are indistinguishable, including to the key the renderer
|
|
45
|
+
* builds from a row.
|
|
46
|
+
*/
|
|
47
|
+
readonly more?: 'dirs' | 'files'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The files that live directly in the repository root, sorted by name.
|
|
52
|
+
* @param paths - repo-relative paths, exactly as `repoTree` returned them.
|
|
53
|
+
*/
|
|
54
|
+
export function rootFiles(paths: readonly string[]): readonly string[] {
|
|
55
|
+
return paths.filter(path => path.length > 0 && !path.includes('/')).sort((a, b) => a.localeCompare(b))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Flatten the tree into the rows to render.
|
|
60
|
+
*
|
|
61
|
+
* Directories come before files at every level — the shape every file tree
|
|
62
|
+
* has — and a directory's contents appear only while it is expanded. An entry
|
|
63
|
+
* in `expanded` whose parent is closed contributes nothing: expansion is only
|
|
64
|
+
* meaningful along a path that is itself visible.
|
|
65
|
+
*
|
|
66
|
+
* @param tree - top-level directories from {@link buildDirTree}.
|
|
67
|
+
* @param roots - root-level files from {@link rootFiles}.
|
|
68
|
+
* @param expanded - paths of the directories the reader has opened.
|
|
69
|
+
* @param cap - most SUBDIRECTORIES and most files rendered per directory; the
|
|
70
|
+
* rest become one `more` row each. Each row is a button and two
|
|
71
|
+
* icons, so the cost is the DOM rather than this walk, and one
|
|
72
|
+
* click must not be able to put an unbounded number of them
|
|
73
|
+
* there. Capping files alone was not enough: a directory holding
|
|
74
|
+
* 6,000 subdirectories froze the tab for 628ms on expanding it,
|
|
75
|
+
* measured, and that grows with the directory. Both caps have the
|
|
76
|
+
* same escape hatch — the search box, which reads the whole path
|
|
77
|
+
* list and ignores the tree. Omit for no cap.
|
|
78
|
+
*/
|
|
79
|
+
export function treeRows(
|
|
80
|
+
tree: readonly DirEntry[],
|
|
81
|
+
roots: readonly string[],
|
|
82
|
+
expanded: ReadonlySet<string>,
|
|
83
|
+
cap = Number.POSITIVE_INFINITY,
|
|
84
|
+
): readonly FileRow[] {
|
|
85
|
+
const out: FileRow[] = []
|
|
86
|
+
/** Emit a directory's files, then the marker if the cap bit. */
|
|
87
|
+
const files = (names: readonly string[], prefix: string, depth: number): void => {
|
|
88
|
+
const shown = names.length > cap ? names.slice(0, cap) : names
|
|
89
|
+
for (const name of shown) {
|
|
90
|
+
out.push({ kind: 'file', path: `${prefix}${name}`, name, depth, open: false })
|
|
91
|
+
}
|
|
92
|
+
if (names.length > shown.length) {
|
|
93
|
+
out.push({
|
|
94
|
+
kind: 'more',
|
|
95
|
+
path: prefix,
|
|
96
|
+
name: '',
|
|
97
|
+
depth,
|
|
98
|
+
open: false,
|
|
99
|
+
hidden: names.length - shown.length,
|
|
100
|
+
more: 'files',
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const walk = (dirs: readonly DirEntry[], prefix: string, depth: number): void => {
|
|
105
|
+
const shown = dirs.length > cap ? dirs.slice(0, cap) : dirs
|
|
106
|
+
for (const dir of shown) {
|
|
107
|
+
const open = expanded.has(dir.path)
|
|
108
|
+
out.push({ kind: 'dir', path: dir.path, name: dir.name, depth, open })
|
|
109
|
+
if (!open) continue
|
|
110
|
+
walk(dir.children, `${dir.path}/`, depth + 1)
|
|
111
|
+
files(dir.files, `${dir.path}/`, depth + 1)
|
|
112
|
+
}
|
|
113
|
+
if (dirs.length > shown.length) {
|
|
114
|
+
out.push({
|
|
115
|
+
kind: 'more',
|
|
116
|
+
path: prefix,
|
|
117
|
+
name: '',
|
|
118
|
+
depth,
|
|
119
|
+
open: false,
|
|
120
|
+
hidden: dirs.length - shown.length,
|
|
121
|
+
more: 'dirs',
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
walk(tree, '', 0)
|
|
126
|
+
files(roots, '', 0)
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Every directory above a path, outermost first — what to expand so that
|
|
132
|
+
* opening a file reveals it in the tree.
|
|
133
|
+
*/
|
|
134
|
+
export function ancestorsOf(path: string): readonly string[] {
|
|
135
|
+
const parts = path.split('/')
|
|
136
|
+
const out: string[] = []
|
|
137
|
+
for (let i = 1; i < parts.length; i += 1) out.push(parts.slice(0, i).join('/'))
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Search the repository's files, case-insensitively over the whole path.
|
|
143
|
+
*
|
|
144
|
+
* Each hit carries its full path as its name: a flat list of bare filenames
|
|
145
|
+
* cannot be told apart, and a repository usually holds several `index.ts`.
|
|
146
|
+
*
|
|
147
|
+
* @param paths - repo-relative paths as `repoTree` returned them.
|
|
148
|
+
* @param needle - raw search text; blank matches nothing.
|
|
149
|
+
* @param cap - most rows to return, so a one-letter search cannot render the
|
|
150
|
+
* whole repository.
|
|
151
|
+
*/
|
|
152
|
+
export function searchRows(paths: readonly string[], needle: string, cap: number): readonly FileRow[] {
|
|
153
|
+
const n = needle.trim().toLowerCase()
|
|
154
|
+
if (n.length === 0) return []
|
|
155
|
+
const out: FileRow[] = []
|
|
156
|
+
for (const path of paths) {
|
|
157
|
+
if (out.length >= cap) break
|
|
158
|
+
if (path.toLowerCase().includes(n)) {
|
|
159
|
+
out.push({ kind: 'file', path, name: path, depth: 0, open: false })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The browsable path list: everything git tracks, plus files that exist on
|
|
167
|
+
* disk but not in HEAD.
|
|
168
|
+
*
|
|
169
|
+
* `repoTree` is `git ls-tree HEAD`, so a file created five minutes ago is not
|
|
170
|
+
* in it — and a browser that cannot open the file you just wrote reads as
|
|
171
|
+
* broken rather than as principled. The drawer already holds the working
|
|
172
|
+
* tree's own file list, so the union costs one pass.
|
|
173
|
+
*
|
|
174
|
+
* @param tracked - paths from `repoTree`.
|
|
175
|
+
* @param extra - paths from the working-tree status; deleted files must be
|
|
176
|
+
* filtered out by the caller, since opening one would fail.
|
|
177
|
+
*/
|
|
178
|
+
export function mergePaths(tracked: readonly string[], extra: readonly string[]): readonly string[] {
|
|
179
|
+
const all = new Set(tracked)
|
|
180
|
+
for (const path of extra) {
|
|
181
|
+
if (path.length > 0) all.add(path)
|
|
182
|
+
}
|
|
183
|
+
return [...all].sort((a, b) => a.localeCompare(b))
|
|
184
|
+
}
|