@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CHANGELOG_EN.md +43 -0
  3. package/lib/apply-blocks.js +159 -0
  4. package/lib/atomic-json.js +23 -5
  5. package/lib/blame.js +83 -0
  6. package/lib/client.js +34960 -11826
  7. package/lib/git-ops.js +25 -0
  8. package/lib/image-sniff.js +197 -0
  9. package/lib/index.js +401 -7
  10. package/lib/patch-model.js +223 -0
  11. package/lib/side-guard.js +55 -0
  12. package/lib/write-checked.js +164 -0
  13. package/package.json +7 -1
  14. package/src/apply-blocks.ts +215 -0
  15. package/src/atomic-json.ts +29 -5
  16. package/src/blame.ts +94 -0
  17. package/src/client/CodeEditor.tsx +317 -0
  18. package/src/client/FileBrowser.tsx +657 -0
  19. package/src/client/GitWorkbenchPanel.module.css +491 -12
  20. package/src/client/GitWorkbenchPanel.tsx +1655 -190
  21. package/src/client/ImageView.tsx +120 -0
  22. package/src/client/blame-gutter.ts +108 -0
  23. package/src/client/blame-view.ts +104 -0
  24. package/src/client/cm-diff.ts +108 -0
  25. package/src/client/cm-tokens.ts +79 -0
  26. package/src/client/diff-nav.ts +198 -0
  27. package/src/client/file-icon.ts +190 -0
  28. package/src/client/file-rows.ts +184 -0
  29. package/src/client/files-place.ts +178 -0
  30. package/src/client/glyphs.tsx +86 -0
  31. package/src/client/highlight.ts +25 -0
  32. package/src/client/history-layout.ts +52 -0
  33. package/src/client/idle-value.ts +53 -0
  34. package/src/client/image-view.ts +106 -0
  35. package/src/client/indent.ts +74 -0
  36. package/src/client/index.ts +59 -0
  37. package/src/client/locales.ts +179 -4
  38. package/src/client/pane-size.ts +71 -0
  39. package/src/client/side-edit.ts +244 -0
  40. package/src/client/side-rows.ts +258 -0
  41. package/src/client/stable-list.ts +31 -0
  42. package/src/client/use-change-nav.ts +83 -0
  43. package/src/client/worktree-view.ts +11 -1
  44. package/src/git-ops.ts +36 -1
  45. package/src/image-sniff.ts +204 -0
  46. package/src/index.ts +447 -7
  47. package/src/patch-model.ts +267 -0
  48. package/src/side-guard.ts +58 -0
  49. 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
+ }