@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CHANGELOG_EN.md +33 -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 +24360 -1436
  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 +453 -7
  20. package/src/client/GitWorkbenchPanel.tsx +1436 -143
  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/idle-value.ts +53 -0
  33. package/src/client/image-view.ts +106 -0
  34. package/src/client/indent.ts +74 -0
  35. package/src/client/index.ts +59 -0
  36. package/src/client/locales.ts +171 -4
  37. package/src/client/pane-size.ts +71 -0
  38. package/src/client/side-edit.ts +244 -0
  39. package/src/client/side-rows.ts +258 -0
  40. package/src/client/stable-list.ts +31 -0
  41. package/src/client/use-change-nav.ts +83 -0
  42. package/src/client/worktree-view.ts +11 -1
  43. package/src/git-ops.ts +36 -1
  44. package/src/image-sniff.ts +204 -0
  45. package/src/index.ts +447 -7
  46. package/src/patch-model.ts +267 -0
  47. package/src/side-guard.ts +58 -0
  48. package/src/write-checked.ts +223 -0
@@ -0,0 +1,657 @@
1
+ /**
2
+ * The Files tab: open any file in the repository, read it, blame it, edit it.
3
+ *
4
+ * The Changes tab lists what git has something to say about, which is the
5
+ * wrong list for "who wrote this line" — that question is almost always about
6
+ * a file nobody has touched today. So this view browses the repository itself:
7
+ * the tracked paths `repoTree` reports, plus whatever exists in the working
8
+ * tree but not in HEAD, because a browser that cannot open the file you just
9
+ * created reads as broken rather than as principled.
10
+ *
11
+ * Nothing here needed a new host RPC. `fileSides` already carries the whole
12
+ * working-tree text (it is the editor's initial buffer in the diff pane) along
13
+ * with the binary, size and encoding guards attached to it; `writeChecked`
14
+ * already refuses a save whose sha moved; `blame` already answers per line.
15
+ * This view is those three, arranged around a tree.
16
+ *
17
+ * Blame renders in a CodeMirror gutter rather than a column of the pane's own
18
+ * grid, because here the file IS the editor. It is withheld while the buffer
19
+ * is dirty: once lines have been typed the numbers no longer match the commits
20
+ * behind them, and an annotation quietly pointing at the wrong line is worse
21
+ * than no annotation.
22
+ *
23
+ * @module @young1lin/dsh-ui-gitworkbench/client/FileBrowser
24
+ */
25
+
26
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
27
+
28
+ import css from './GitWorkbenchPanel.module.css'
29
+ import { CodeEditor } from './CodeEditor.tsx'
30
+ import { buildDirTree } from './dir-tree.ts'
31
+ import { mergePaths, rootFiles, searchRows, treeRows, type FileRow } from './file-rows.ts'
32
+ import { openAt, reconcilePlace, toggleDir, type FilesPlace } from './files-place.ts'
33
+ import { sameList } from './stable-list.ts'
34
+ import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
35
+ import { ImageView, type Picture } from './ImageView.tsx'
36
+ import { decodeBase64, formatBytes, looksLikeImagePath, shouldAskForImage } from './image-view.ts'
37
+ import { IMAGE_BYTE_CAP, sniffImage } from '../image-sniff.ts'
38
+ import { blameWhen, shortHash } from './blame-view.ts'
39
+ import { emptyQueryFilter, serializeLogQuery } from './log-filter-query.ts'
40
+ import { highlightWholeFile, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded } from './highlight.ts'
41
+ import { HIGHLIGHT_IDLE_MS, HIGHLIGHT_LINE_CAP, useIdleValue } from './idle-value.ts'
42
+ import { detectIndent } from './indent.ts'
43
+ import {
44
+ DISARMED, applySaveOk, applySides, armEdit, armRefusal, isDirty, markConflict,
45
+ type EditState, type WriteResult,
46
+ } from './side-edit.ts'
47
+ import type { BlameAnswer, BlameLine, FileImage, FileSides, SideLayer, Translate } from './GitWorkbenchPanel.tsx'
48
+
49
+ /** Count lines without allocating the split — the buffer can be megabytes and
50
+ * this runs on a timer while somebody is typing. */
51
+ function countLines(text: string): number {
52
+ let n = 1
53
+ for (let i = text.indexOf(String.fromCharCode(10)); i !== -1; i = text.indexOf(String.fromCharCode(10), i + 1)) n += 1
54
+ return n
55
+ }
56
+
57
+ /** Most search hits rendered at once — a one-letter query must not paint a
58
+ * whole repository into the DOM. */
59
+ const SEARCH_CAP = 300
60
+
61
+ /** Indent per tree level, in em. Matches the changes tree's own step. */
62
+ const INDENT_EM = 0.85
63
+
64
+ /** Files rendered per directory before the rest become one "and N others"
65
+ * row. A generated directory can hold thousands, and every row is a button
66
+ * and two icons — the cost is DOM nodes, not the walk that produced them.
67
+ * The history filter's path picker has capped at this number since it
68
+ * shipped; the search box is the way to a file in a crowded directory. */
69
+ const FILES_PER_DIR = 100
70
+
71
+ export function FileBrowser({
72
+ t, palette, statsPath, extraPaths, gen, treeStyle, treeRef, divider, place, onPlace, cached, onTree,
73
+ fetchRepoTree, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, onSaved, onDirtyChange, onShowHistory,
74
+ }: {
75
+ t: Translate
76
+ palette: string
77
+ statsPath: string | undefined
78
+ /** Working-tree paths that are not in HEAD — untracked files, which
79
+ * `git ls-tree` cannot know about. Deleted files are filtered out by the
80
+ * caller: opening one would only fail. */
81
+ extraPaths: readonly string[]
82
+ /** Refresh generation; a new one re-reads the tree and the open file. */
83
+ gen: number
84
+ treeStyle: CSSProperties | undefined
85
+ treeRef: { current: HTMLDivElement | null }
86
+ /** The drawer's own drag handle, passed in rather than imported, so this
87
+ * module does not import a value out of the panel that imports it. */
88
+ divider: ReactNode
89
+ fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<{ paths: string[]; truncated: boolean } | null>
90
+ fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
91
+ writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
92
+ fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
93
+ /** One file's bytes, when the host confirms they are an image. */
94
+ fetchFileImage: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<FileImage | null>
95
+ /** After a successful save: the drawer re-reads, since the file moved. */
96
+ onSaved: () => void
97
+ /** The unsaved-edits flag the drawer guards its own gestures on. */
98
+ onDirtyChange: (dirty: boolean) => void
99
+ /** Hand a filter query to the History tab and switch to it. */
100
+ onShowHistory: (query: string) => void
101
+ /** Where the reader is — held above this component because this component
102
+ * unmounts every time the reader looks at another tab. */
103
+ place: FilesPlace
104
+ onPlace: (next: FilesPlace) => void
105
+ /** The last file list read, kept for the same reason: coming back should
106
+ * render the tree, not blank it while the repository is re-read. */
107
+ cached: { readonly paths: readonly string[]; readonly truncated: boolean }
108
+ onTree: (next: { readonly paths: readonly string[]; readonly truncated: boolean }) => void
109
+ }): ReactNode {
110
+ const { open, query, blameOn } = place
111
+ const { paths, truncated } = cached
112
+ const expanded = useMemo(() => new Set(place.expanded), [place.expanded])
113
+ /** A file that was open and is not in the repository any more. Reported
114
+ * rather than silently applied: a selection that clears itself with no
115
+ * explanation reads as a bug. */
116
+ const [vanished, setVanished] = useState<string | null>(null)
117
+ const [sides, setSides] = useState<FileSides | null>(null)
118
+ const [loading, setLoading] = useState(false)
119
+ const [edit, setEdit] = useState<EditState>(DISARMED)
120
+ const [saving, setSaving] = useState(false)
121
+ const [saveFailed, setSaveFailed] = useState<string | null>(null)
122
+ const [blame, setBlame] = useState<BlameAnswer | null>(null)
123
+ /**
124
+ * The host's answer about this file's bytes, tagged with the path it is
125
+ * about. The tag is what keeps the PREVIOUS file's picture off the screen
126
+ * while the next one is being fetched — without it, clicking through a
127
+ * directory of screenshots shows each one under the following one's name.
128
+ */
129
+ const [image, setImage] = useState<{ readonly path: string; readonly answer: FileImage | null } | null>(null)
130
+ /** Reading an SVG's markup rather than looking at the picture it draws. */
131
+ const [asSource, setAsSource] = useState(false)
132
+ // "Asked and got nothing" must not render as "not asked": a toggle that
133
+ // lights up and then shows an empty gutter reads as broken.
134
+ const [blameFailed, setBlameFailed] = useState(false)
135
+ /** A file click held back by unsaved edits, waiting on the reader's answer. */
136
+ const [pending, setPending] = useState<string | null>(null)
137
+ /** The line whose commit the reader asked to see, 1-based. */
138
+ const [picked, setPicked] = useState<number | null>(null)
139
+ const [refetch, setRefetch] = useState(0)
140
+ /** Which file the buffer currently belongs to, so a refetch can be told
141
+ * apart from a genuine open. */
142
+ const openRef = useRef<string | null>(null)
143
+ /**
144
+ * Files this view has actually shown, on disk, since it mounted.
145
+ *
146
+ * The vanished notice means "it disappeared while you were here". A place
147
+ * restored from a previous run carries a path that may have been deleted
148
+ * days ago, and reporting that on the first open would be an apology for
149
+ * something the reader does not remember doing — so a path that was never
150
+ * successfully shown is dropped in silence instead.
151
+ */
152
+ const shownRef = useRef<Set<string>>(new Set())
153
+ // A grammar loads asynchronously the first time a language is seen; this
154
+ // counter re-renders the highlight once it lands.
155
+ const [, setGrammarTick] = useState(0)
156
+
157
+ const dirty = isDirty(edit)
158
+
159
+ useEffect(() => subscribeGrammarLoaded(() => { setGrammarTick(n => n + 1) }), [])
160
+ useEffect(() => { onDirtyChange(dirty) }, [dirty, onDirtyChange])
161
+
162
+ // The repository's paths.
163
+ useEffect(() => {
164
+ const ctrl = new AbortController()
165
+ let alive = true
166
+ void fetchRepoTree(statsPath, ctrl.signal)
167
+ .then(answer => {
168
+ if (!alive || answer === null) return
169
+ onTree({ paths: answer.paths, truncated: answer.truncated })
170
+ })
171
+ .catch(() => { /* an old host half: the tree stays empty and says so */ })
172
+ return () => { alive = false; ctrl.abort() }
173
+ }, [fetchRepoTree, statsPath, gen, onTree])
174
+
175
+ // The open file's text. `unstaged` is the layer whose target is the file on
176
+ // disk — the only one this view is about.
177
+ useEffect(() => {
178
+ if (open === null) { setSides(null); setEdit(DISARMED); return }
179
+ const ctrl = new AbortController()
180
+ let alive = true
181
+ setLoading(true)
182
+ void fetchFileSides(statsPath, open, 'unstaged', ctrl.signal)
183
+ .then(answer => {
184
+ if (!alive) return
185
+ setSides(answer)
186
+ if (answer === null) { setEdit(DISARMED); return }
187
+ // A NEW file adopts the payload outright; a refetch of the SAME file
188
+ // — the drawer's poll, a worktree switch, the refetch a refused save
189
+ // triggers — goes through applySides, which keeps a dirty buffer and
190
+ // only records that the file moved underneath. Without that split,
191
+ // every poll silently threw away whatever had been typed since the
192
+ // last one. Armed straight away because this view IS an editor and
193
+ // there is no gesture to arm with; armEdit still refuses a payload
194
+ // it must not hold.
195
+ const fresh = openRef.current !== open
196
+ openRef.current = open
197
+ // An empty target sha means git has no blob for it: the path is not a
198
+ // file on disk, so this view never showed it.
199
+ if (answer.targetSha.length > 0 && open !== null) shownRef.current.add(open)
200
+ setEdit(prev => fresh ? armEdit(DISARMED, answer) : applySides(prev, answer))
201
+ })
202
+ .catch(() => { if (alive) { setSides(null); setEdit(DISARMED) } })
203
+ .finally(() => { if (alive) setLoading(false) })
204
+ return () => { alive = false; ctrl.abort() }
205
+ }, [open, fetchFileSides, statsPath, gen, refetch])
206
+
207
+ useEffect(() => {
208
+ if (!blameOn || open === null) { setBlame(null); setBlameFailed(false); return }
209
+ const ctrl = new AbortController()
210
+ let alive = true
211
+ setBlameFailed(false)
212
+ void fetchBlame(statsPath, open, ctrl.signal)
213
+ .then(answer => {
214
+ if (!alive) return
215
+ setBlame(answer)
216
+ setBlameFailed(answer === null)
217
+ })
218
+ .catch(() => { if (alive) { setBlame(null); setBlameFailed(true) } })
219
+ return () => { alive = false; ctrl.abort() }
220
+ }, [blameOn, open, fetchBlame, statsPath, gen])
221
+
222
+ // A file the text side declined may still be worth showing. `sides` is the
223
+ // trigger rather than the file's name because the name is not evidence:
224
+ // asking is driven by what the TEXT path concluded, and the host decides
225
+ // what the bytes actually are. The dependency is safe as an object because
226
+ // `sides` gets a new identity only on a real refetch — the drawer does not
227
+ // poll this view.
228
+ useEffect(() => {
229
+ if (open === null || sides === null || !shouldAskForImage(open, sides)) { setImage(null); return }
230
+ const asked = open
231
+ const ctrl = new AbortController()
232
+ let alive = true
233
+ void fetchFileImage(statsPath, asked, ctrl.signal)
234
+ .then(answer => { if (alive) setImage({ path: asked, answer }) })
235
+ // A host half older than this client has no such method and the call
236
+ // throws; the text side's own verdict then stands, unchanged.
237
+ .catch(() => { if (alive) setImage({ path: asked, answer: null }) })
238
+ return () => { alive = false; ctrl.abort() }
239
+ }, [open, sides, fetchFileImage, statsPath])
240
+
241
+ /**
242
+ * The untracked paths, held at one identity for as long as they say the same
243
+ * thing.
244
+ *
245
+ * They are derived from the drawer's polled `git status`, so a fresh array
246
+ * arrives every 3-15 seconds whether or not anything changed — and it is the
247
+ * head of the chain below: merge, then the directory tree, then the rows.
248
+ * On a 20,000-file repository that chain is ~60ms of sorting and tree
249
+ * building, spent to produce exactly what was already on screen, on a timer,
250
+ * forever. Writing a ref during render is safe here because it is a cache:
251
+ * the value it holds is always equal to the value it replaced.
252
+ */
253
+ const extraHeld = useRef<readonly string[]>(extraPaths)
254
+ if (!sameList(extraHeld.current, extraPaths)) extraHeld.current = extraPaths
255
+ const steadyExtra = extraHeld.current
256
+
257
+ const all = useMemo(() => mergePaths(paths, steadyExtra), [paths, steadyExtra])
258
+ const tree = useMemo(() => buildDirTree(all), [all])
259
+ const roots = useMemo(() => rootFiles(all), [all])
260
+ const rows = useMemo(
261
+ () => query.trim().length > 0
262
+ ? searchRows(all, query, SEARCH_CAP)
263
+ : treeRows(tree, roots, expanded, FILES_PER_DIR),
264
+ [query, all, tree, roots, expanded],
265
+ )
266
+
267
+ useEffect(() => { setPicked(null) }, [open, blameOn])
268
+ useEffect(() => { setAsSource(false) }, [open])
269
+
270
+ // A re-read — a worktree switch, a refresh, an agent's write — can remove
271
+ // the file that was open. Settled only once the new list has arrived, and
272
+ // never out from under unsaved edits: those are guarded one level up, where
273
+ // the tab and source switches are asked about.
274
+ useEffect(() => {
275
+ if (dirty) return
276
+ const settled = reconcilePlace(place, all)
277
+ if (settled.vanished === null) return
278
+ onPlace(settled.place)
279
+ setVanished(shownRef.current.has(settled.vanished) ? settled.vanished : null)
280
+ setSides(null)
281
+ setEdit(DISARMED)
282
+ }, [all, place, dirty, onPlace])
283
+
284
+ const refusal = sides === null ? null : armRefusal(sides)
285
+ const readOnly = refusal !== null
286
+ /** The commit behind the picked line, or null when nothing is picked and
287
+ * when the blame does not reach that far. */
288
+ const pickedEntry: BlameLine | null =
289
+ picked === null || blame === null ? null : blame.lines[picked - 1] ?? null
290
+ /** The host's verdict on THIS file's bytes; null while it is in flight or
291
+ * when the question does not arise. */
292
+ const shot = image !== null && image.path === open ? image.answer : null
293
+ /** The question has been asked and not yet answered. Tracked so the pane
294
+ * shows a load rather than flashing "binary file" and then a picture. */
295
+ const askingImage = open !== null && sides !== null && shouldAskForImage(open, sides)
296
+ && (image === null || image.path !== open)
297
+
298
+ /**
299
+ * An SVG the text path already fetched.
300
+ *
301
+ * SVG is markup, so it never reaches the binary branch above — it arrives
302
+ * as text and would otherwise open as XML, which is not what "click the
303
+ * picture" means. The bytes are already here, so the preview costs no round
304
+ * trip at all; it costs re-encoding the string that was decoded from them.
305
+ *
306
+ * The NAME is required as well as the signature, and that gate earns its
307
+ * place: sweeping a hundred and twenty-five thousand real files turned up
308
+ * a hundred and forty-eight `.svelte` components that begin with a literal
309
+ * `<svg>` element. They are markup about an icon, not an icon, and their
310
+ * author opened them to read the code.
311
+ *
312
+ * A lossy decode is excluded: re-encoding it would produce different bytes
313
+ * from the ones on disk, and a picture drawn from those is a picture of
314
+ * something that is not in the repository.
315
+ */
316
+ const svg: Picture | null = useMemo(() => {
317
+ if (open === null || sides === null) return null
318
+ if (sides.binary || sides.tooLarge || sides.lossyEncoding === true) return null
319
+ if (!looksLikeImagePath(open)) return null
320
+ const bytes = new TextEncoder().encode(sides.targetText)
321
+ const found = sniffImage(bytes)
322
+ return found === null ? null : { bytes, mime: found.mime, kind: found.kind }
323
+ }, [open, sides])
324
+
325
+ /** The host's picture, decoded once. Keyed on the ANSWER alone: a decode is
326
+ * a pass over four megabytes at the cap, and folding the SVG toggle into
327
+ * the same memo would re-run it every time that button is pressed. */
328
+ const decoded: Picture | null = useMemo(
329
+ () => shot !== null && shot.ok
330
+ ? { bytes: decodeBase64(shot.base64), mime: shot.mime, kind: shot.kind }
331
+ : null,
332
+ [shot],
333
+ )
334
+ /** The bytes on screen as a picture, from whichever half produced them.
335
+ * Both operands are memoised, so this keeps a stable identity. */
336
+ const picture: Picture | null = decoded ?? (asSource ? null : svg)
337
+
338
+ const showingImage = picture !== null
339
+ const showBlame = blameOn && !dirty && !showingImage
340
+ const buffer = edit.buffer
341
+
342
+ // Highlighting lags the buffer: a repaint is a Shiki pass over the whole
343
+ // file, and paying that per keystroke is what makes a large file unusable.
344
+ // CodeMirror maps the decorations it already has through each change, so
345
+ // the colours ride along with the text until this catches up.
346
+ const painted = useIdleValue(buffer, HIGHLIGHT_IDLE_MS)
347
+ const tooBigToPaint = useMemo(() => countLines(painted) > HIGHLIGHT_LINE_CAP, [painted])
348
+ const syntax = useMemo(
349
+ () => tooBigToPaint
350
+ ? undefined
351
+ : highlightWholeFile(painted.split('\n'), shikiLangOf(open ?? ''), shikiThemeOf(palette)),
352
+ [tooBigToPaint, painted, open, palette],
353
+ )
354
+ const indent = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
355
+
356
+ /** Open a file, revealing it in the tree — and asking first if the buffer
357
+ * holds edits, because opening another file drops them. */
358
+ const openFile = (path: string): void => {
359
+ if (path === open) return
360
+ if (dirty) { setPending(path); return }
361
+ setSaveFailed(null)
362
+ setVanished(null)
363
+ onPlace(openAt(place, path))
364
+ }
365
+
366
+ const foldDir = (path: string): void => { onPlace(toggleDir(place, path)) }
367
+
368
+ const idRef = useRef(0)
369
+ useEffect(() => { idRef.current += 1 }, [open])
370
+
371
+ const save = async (): Promise<void> => {
372
+ if (sides === null || !dirty || saving || readOnly) return
373
+ const savedText = edit.buffer
374
+ const session = idRef.current
375
+ setSaving(true)
376
+ try {
377
+ const result = await writeChecked(statsPath, open ?? '', savedText, edit.baseSha, new AbortController().signal)
378
+ const stillHere = idRef.current === session
379
+ if (result === null) {
380
+ if (stillHere) setSaveFailed(t('saveUnavailable'))
381
+ } else if (result.ok) {
382
+ if (stillHere) {
383
+ setSaveFailed(null)
384
+ setEdit(prev => applySaveOk(prev, savedText, result.sha ?? ''))
385
+ }
386
+ onSaved()
387
+ } else if (result.failure === 'stale') {
388
+ // The file moved under the buffer. The buffer stands; the reader
389
+ // decides, and the refetch below makes the fresh text available.
390
+ if (stillHere) {
391
+ setEdit(prev => markConflict(prev))
392
+ setSaveFailed(t('staleBody'))
393
+ setRefetch(n => n + 1)
394
+ }
395
+ } else if (stillHere) {
396
+ setSaveFailed(`${t('saveFailed')}${(result.error ?? '').trim().length > 0 ? `: ${(result.error ?? '').trim()}` : ''}`)
397
+ }
398
+ } finally {
399
+ setSaving(false)
400
+ }
401
+ }
402
+
403
+ // A directory can end with both markers, and they share a path and a
404
+ // depth; `more` is what separates them.
405
+ const rowKey = (row: FileRow): string => `${row.kind}:${row.path}:${row.more ?? ''}`
406
+
407
+ /**
408
+ * Click targets for the memoised rows.
409
+ *
410
+ * Both close over `place`, `open` and the dirty buffer, so the row elements
411
+ * must not capture the ones that existed when they were built. A ref read at
412
+ * click time always has the current pair; the alternative — depending on
413
+ * their identity — would rebuild every row on every render, which is the
414
+ * cost this memo exists to remove.
415
+ */
416
+ const acts = useRef({ foldDir, openFile })
417
+ acts.current = { foldDir, openFile }
418
+
419
+ /**
420
+ * `t` arrives in the host's slot props and its identity is not ours to rely
421
+ * on, so the memo watches the STRING it produces instead: that is what has
422
+ * to change when the language does, and it costs one lookup per render.
423
+ */
424
+ const langKey = t('filesMore', { count: 0 })
425
+
426
+ /**
427
+ * The rendered rows.
428
+ *
429
+ * An expanded tree is thousands of `li`s, each with a button and two icons,
430
+ * and the drawer re-renders on every poll. Handing React the SAME element
431
+ * array lets it skip the subtree entirely; rebuilding it was measured at
432
+ * ~180ms per poll with 1,462 rows on screen, growing with every directory
433
+ * the reader opens — the shape of "it gets laggy once there are a lot of
434
+ * files".
435
+ */
436
+ const list = useMemo(() => rows.map(row => (
437
+ <li key={rowKey(row)}>
438
+ {row.kind === 'more' ? (
439
+ <span
440
+ className={css.fbMore}
441
+ style={{ paddingLeft: `${0.4 + row.depth * INDENT_EM}em` }}
442
+ >{t('filesMore', { count: row.hidden ?? 0 })}</span>
443
+ ) : (
444
+ <button
445
+ type="button"
446
+ title={row.path}
447
+ aria-expanded={row.kind === 'dir' ? row.open : undefined}
448
+ className={row.kind === 'file' && row.path === open
449
+ ? `${css.fbRow} ${css.fbRowActive}`
450
+ : css.fbRow}
451
+ style={{ paddingLeft: `${0.4 + row.depth * INDENT_EM}em` }}
452
+ onClick={() => {
453
+ row.kind === 'dir' ? acts.current.foldDir(row.path) : acts.current.openFile(row.path)
454
+ }}
455
+ >
456
+ {row.kind === 'dir' ? (
457
+ <>
458
+ <span className={`${css.chevron} ${row.open ? css.chevronOpen : ''}`}>▸</span>
459
+ <PathDirGlyph />
460
+ </>
461
+ ) : (
462
+ <>
463
+ <span className={css.chevron} aria-hidden="true" />
464
+ <PathFileGlyph path={row.path} />
465
+ </>
466
+ )}
467
+ <span className={row.kind === 'dir' ? css.fbDirName : css.fbFileName}>{row.name}</span>
468
+ </button>
469
+ )}
470
+ </li>
471
+ )),
472
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `t` is read through
473
+ // `langKey`, and the handlers through `acts`; see both comments above.
474
+ [rows, open, langKey])
475
+
476
+ return (
477
+ <>
478
+ <div ref={treeRef} className={css.fbTree} style={treeStyle} data-gs-part="fileTree">
479
+ <input
480
+ type="search"
481
+ className={css.fbSearch}
482
+ value={query}
483
+ placeholder={t('fileSearchPlaceholder')}
484
+ aria-label={t('fileSearchPlaceholder')}
485
+ onChange={event => { onPlace({ ...place, query: event.target.value }) }}
486
+ />
487
+ {truncated ? <div className={css.fbNote}>{t('filesTruncated')}</div> : null}
488
+ {rows.length === 0 ? (
489
+ <div className={css.empty}>{all.length === 0 ? t('filesEmpty') : t('filesNoMatch')}</div>
490
+ ) : (
491
+ <ul className={css.fbList}>{list}</ul>
492
+ )}
493
+ </div>
494
+ {divider}
495
+ <div className={css.fbMain} data-gs-part="fileView">
496
+ {open === null ? (
497
+ <div className={css.empty}>
498
+ {vanished === null ? t('filesPick') : t('filesVanished', { path: vanished })}
499
+ </div>
500
+ ) : (
501
+ <>
502
+ <div className={css.fbHeader}>
503
+ <span className={css.fbPath} title={open}>{open}</span>
504
+ <span className={css.sideActions}>
505
+ {!readOnly && !showingImage && sides !== null && !sides.binary && !sides.tooLarge ? (
506
+ <>
507
+ <button
508
+ type="button"
509
+ className={`${css.blockBtn}${dirty ? ` ${css.sideSaveReady}` : ''}`}
510
+ disabled={!dirty || saving}
511
+ onClick={() => { void save() }}
512
+ >{t('fileSave')}</button>
513
+ <button
514
+ type="button"
515
+ className={css.blockBtn}
516
+ disabled={!dirty || saving}
517
+ onClick={() => { setSaveFailed(null); setEdit(prev => ({ ...prev, buffer: prev.baseText })) }}
518
+ >{t('fileRevert')}</button>
519
+ </>
520
+ ) : null}
521
+ {svg === null ? null : (
522
+ <button
523
+ type="button"
524
+ className={css.blockBtn}
525
+ aria-pressed={asSource}
526
+ onClick={() => { setAsSource(on => !on) }}
527
+ >{t(asSource ? 'imagePreview' : 'imageSource')}</button>
528
+ )}
529
+ {showingImage ? null : (
530
+ <button
531
+ type="button"
532
+ aria-pressed={blameOn}
533
+ title={t('blameHint')}
534
+ className={blameOn ? `${css.blockBtn} ${css.sideSaveReady}` : css.blockBtn}
535
+ onClick={() => { onPlace({ ...place, blameOn: !blameOn }) }}
536
+ >{t('blameToggle')}</button>
537
+ )}
538
+ </span>
539
+ </div>
540
+ {pending !== null ? (
541
+ <div className={css.sideNotice}>
542
+ {t('filesUnsavedAsk')}
543
+ <span className={css.sideActions}>
544
+ <button
545
+ type="button"
546
+ className={css.blockBtn}
547
+ onClick={() => {
548
+ const next = pending
549
+ setPending(null)
550
+ setEdit(DISARMED)
551
+ setSaveFailed(null)
552
+ setVanished(null)
553
+ onPlace(openAt(place, next))
554
+ }}
555
+ >{t('filesDiscardOpen')}</button>
556
+ <button
557
+ type="button"
558
+ className={css.blockBtn}
559
+ onClick={() => { setPending(null) }}
560
+ >{t('discardCancel')}</button>
561
+ </span>
562
+ </div>
563
+ ) : null}
564
+ {refusal !== null ? (
565
+ <div className={css.sideNotice}>{t(refusal === 'encoding' ? 'fileReadOnlyEncoding' : 'fileReadOnlyCrlf')}</div>
566
+ ) : null}
567
+ {saveFailed !== null ? <div className={css.sideNotice}>{saveFailed}</div> : null}
568
+ {tooBigToPaint ? <div className={css.sideNotice}>{t('paintTooLarge')}</div> : null}
569
+ {blameOn && dirty ? <div className={css.sideNotice}>{t('blameWhileEditing')}</div> : null}
570
+ {showBlame && (blameFailed || (blame !== null && blame.error !== undefined))
571
+ ? <div className={css.sideNotice}>{t('blameFailed')}</div> : null}
572
+ {showBlame && blame !== null && blame.truncated
573
+ ? <div className={css.sideNotice}>{t('blameTruncated')}</div> : null}
574
+ {/* The strip is present for as long as blame is, so the space does
575
+ not jump as lines are picked — and while nothing is picked it
576
+ says that picking is a thing, which is the whole affordance:
577
+ the gutter is names, and nothing about a name looks clickable. */}
578
+ {showBlame ? (
579
+ <div className={css.fbCommit}>
580
+ {pickedEntry === null ? (
581
+ <span className={css.fbCommitHint}>{t('blamePick')}</span>
582
+ ) : (
583
+ <>
584
+ <span className={css.fbCommitLine}>{t('blameLine', { line: picked ?? 0 })}</span>
585
+ {pickedEntry.uncommitted ? (
586
+ <span className={css.fbCommitWho}>{t('blameUncommitted')}</span>
587
+ ) : (
588
+ <>
589
+ <span className={css.fbCommitWho}>{pickedEntry.author}</span>
590
+ <span className={css.fbCommitWhen}>{blameWhen(pickedEntry.time)}</span>
591
+ <code className={css.fbCommitHash}>{shortHash(pickedEntry.hash)}</code>
592
+ <span className={css.fbCommitWhat} title={pickedEntry.summary}>{pickedEntry.summary}</span>
593
+ {/* The question a name in the gutter raises is rarely
594
+ about this one line — it is "what else did they do to
595
+ this file". The History tab already answers that, with
596
+ a filter grammar that takes both halves, so the strip
597
+ hands it the query rather than growing a second commit
598
+ list of its own. */}
599
+ <button
600
+ type="button"
601
+ className={css.blockBtn}
602
+ onClick={() => {
603
+ onShowHistory(serializeLogQuery({
604
+ ...emptyQueryFilter(),
605
+ users: [pickedEntry.author],
606
+ paths: [open],
607
+ }))
608
+ }}
609
+ >{t('blameInHistory')}</button>
610
+ </>
611
+ )}
612
+ <button
613
+ type="button"
614
+ className={css.blockBtn}
615
+ onClick={() => { setPicked(null) }}
616
+ >{t('close')}</button>
617
+ </>
618
+ )}
619
+ </div>
620
+ ) : null}
621
+ <div className={css.fbBody}>
622
+ {loading && sides === null ? <div className={css.empty}>{t('loading')}</div>
623
+ : sides === null ? <div className={css.empty}>{t('saveUnavailable')}</div>
624
+ : picture !== null ? <ImageView picture={picture} path={open} t={t} />
625
+ : askingImage ? <div className={css.empty}>{t('loading')}</div>
626
+ : sides.binary ? <div className={css.empty}>{t('binaryFile')}</div>
627
+ : sides.tooLarge ? (
628
+ // An image past the preview cap gets its own sentence: the
629
+ // diff pane's "too large" is about a patch nobody asked for
630
+ // here, and the size is the fact that explains the refusal.
631
+ shot !== null && shot.reason === 'tooLarge'
632
+ ? <div className={css.empty}>{t('imageTooLarge', { size: formatBytes(shot.bytes), cap: formatBytes(IMAGE_BYTE_CAP) })}</div>
633
+ : <div className={css.empty}>{t('diffTooLarge')}</div>
634
+ )
635
+ : (
636
+ <CodeEditor
637
+ key={open}
638
+ value={edit.buffer}
639
+ original={edit.baseText}
640
+ onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
641
+ syntax={syntax}
642
+ indent={indent}
643
+ ariaLabel={open}
644
+ onSave={() => { void save() }}
645
+ blame={showBlame && blame !== null ? blame.lines : null}
646
+ onBlameClick={line => { setPicked(line) }}
647
+ notCommitted={t('blameUncommitted')}
648
+ readOnly={readOnly}
649
+ />
650
+ )}
651
+ </div>
652
+ </>
653
+ )}
654
+ </div>
655
+ </>
656
+ )
657
+ }