@young1lin/dsh-ui-gitworkbench 0.1.15 → 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 +16 -0
- package/CHANGELOG_EN.md +16 -0
- package/README.md +4 -3
- package/README_EN.md +1 -1
- package/lib/client.js +1519 -477
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +192 -41
- package/lib/path-lock.js +54 -0
- package/lib/worktree.js +83 -0
- package/lib/write-checked.js +1 -1
- package/package.json +1 -1
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +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 +217 -43
- package/src/path-lock.ts +56 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +97 -0
- package/src/write-checked.ts +1 -1
|
@@ -23,12 +23,13 @@
|
|
|
23
23
|
* @module @young1lin/dsh-ui-gitworkbench/client/FileBrowser
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
26
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
27
27
|
|
|
28
28
|
import css from './GitWorkbenchPanel.module.css'
|
|
29
29
|
import { CodeEditor } from './CodeEditor.tsx'
|
|
30
30
|
import { buildDirTree } from './dir-tree.ts'
|
|
31
|
-
import { mergePaths, rootFiles, searchRows, treeRows, type FileRow } from './file-rows.ts'
|
|
31
|
+
import { ancestorsOf, isIgnoredPath, mergePaths, rootFiles, searchRows, splitIgnored, treeRows, type FileRow } from './file-rows.ts'
|
|
32
|
+
import { NO_IGNORED_READS, anyDirTruncated, attachReads, rememberDir, type DirRead } from './ignored-cache.ts'
|
|
32
33
|
import { openAt, reconcilePlace, toggleDir, type FilesPlace } from './files-place.ts'
|
|
33
34
|
import { sameList } from './stable-list.ts'
|
|
34
35
|
import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
|
|
@@ -44,7 +45,7 @@ import {
|
|
|
44
45
|
DISARMED, applySaveOk, applySides, armEdit, armRefusal, isDirty, markConflict,
|
|
45
46
|
type EditState, type WriteResult,
|
|
46
47
|
} from './side-edit.ts'
|
|
47
|
-
import type { BlameAnswer, BlameLine, FileImage, FileSides, SideLayer, Translate } from './GitWorkbenchPanel.tsx'
|
|
48
|
+
import type { BlameAnswer, BlameLine, FileImage, FileSides, FilesTree, RepoTreeAnswer, SideLayer, Translate } from './GitWorkbenchPanel.tsx'
|
|
48
49
|
|
|
49
50
|
/** Most search hits rendered at once — a one-letter query must not paint a
|
|
50
51
|
* whole repository into the DOM. */
|
|
@@ -61,8 +62,8 @@ const INDENT_EM = 0.85
|
|
|
61
62
|
const FILES_PER_DIR = 100
|
|
62
63
|
|
|
63
64
|
export function FileBrowser({
|
|
64
|
-
t, palette, statsPath, extraPaths, gen, treeStyle, treeRef, divider, place, onPlace, cached, onTree,
|
|
65
|
-
fetchRepoTree, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, onSaved, onDirtyChange, onShowHistory,
|
|
65
|
+
t, palette, statsPath, extraPaths, gen, treeStyle, treeRef, divider, place, onPlace, cached, onTree, wrap,
|
|
66
|
+
fetchRepoTree, fetchIgnoredDir, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, onSaved, onDirtyChange, onShowHistory,
|
|
66
67
|
}: {
|
|
67
68
|
t: Translate
|
|
68
69
|
palette: string
|
|
@@ -78,7 +79,12 @@ export function FileBrowser({
|
|
|
78
79
|
/** The drawer's own drag handle, passed in rather than imported, so this
|
|
79
80
|
* module does not import a value out of the panel that imports it. */
|
|
80
81
|
divider: ReactNode
|
|
81
|
-
fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<
|
|
82
|
+
fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<RepoTreeAnswer | null>
|
|
83
|
+
/** One level of one ignored directory, read from the filesystem: git
|
|
84
|
+
* collapses ignored directories by design and cannot be asked for their
|
|
85
|
+
* contents scoped, so the expansion of a `node_modules/` row is a disk
|
|
86
|
+
* read, not a git listing. */
|
|
87
|
+
fetchIgnoredDir: (worktreePath: string | undefined, dir: string, signal: AbortSignal) => Promise<DirRead | null>
|
|
82
88
|
fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
83
89
|
writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
|
|
84
90
|
fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
|
|
@@ -95,12 +101,20 @@ export function FileBrowser({
|
|
|
95
101
|
place: FilesPlace
|
|
96
102
|
onPlace: (next: FilesPlace) => void
|
|
97
103
|
/** The last file list read, kept for the same reason: coming back should
|
|
98
|
-
* render the tree, not blank it while the repository is re-read.
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
* render the tree, not blank it while the repository is re-read. The
|
|
105
|
+
* ignored fields carry the collapsed listing and every lazy directory's
|
|
106
|
+
* read children. */
|
|
107
|
+
cached: FilesTree
|
|
108
|
+
/** An UPDATER rather than a value: lazy directory reads land concurrently,
|
|
109
|
+
* and a write built from a tree that a sibling write already replaced
|
|
110
|
+
* drops that sibling's children on the floor. */
|
|
111
|
+
onTree: (update: (prev: FilesTree) => FilesTree) => void
|
|
112
|
+
/** Soft wrap for the editor. The drawer's header owns the switch, so the
|
|
113
|
+
* Files tab and the diff panes cannot disagree about it. */
|
|
114
|
+
wrap: boolean
|
|
101
115
|
}): ReactNode {
|
|
102
116
|
const { open, query, blameOn } = place
|
|
103
|
-
const { paths, truncated } = cached
|
|
117
|
+
const { paths, truncated, ignored, ignoredTruncated, ignoredError, children } = cached
|
|
104
118
|
const expanded = useMemo(() => new Set(place.expanded), [place.expanded])
|
|
105
119
|
/** A file that was open and is not in the repository any more. Reported
|
|
106
120
|
* rather than silently applied: a selection that clears itself with no
|
|
@@ -158,7 +172,19 @@ export function FileBrowser({
|
|
|
158
172
|
void fetchRepoTree(statsPath, ctrl.signal)
|
|
159
173
|
.then(answer => {
|
|
160
174
|
if (!alive || answer === null) return
|
|
161
|
-
|
|
175
|
+
// A refresh starts the lazy children over: the tree they were read
|
|
176
|
+
// against is gone. The ignored fields are optional against an older
|
|
177
|
+
// host half, which then simply has no ignored rows to show.
|
|
178
|
+
onTree(() => ({
|
|
179
|
+
paths: answer.paths,
|
|
180
|
+
truncated: answer.truncated,
|
|
181
|
+
ignored: answer.ignored ?? [],
|
|
182
|
+
ignoredTruncated: answer.ignoredTruncated ?? false,
|
|
183
|
+
// Omitted, not undefined: the field says WHY the listing is missing,
|
|
184
|
+
// and a present-but-undefined key would read as an empty reason.
|
|
185
|
+
...(answer.ignoredError !== undefined ? { ignoredError: answer.ignoredError } : {}),
|
|
186
|
+
children: NO_IGNORED_READS,
|
|
187
|
+
}))
|
|
162
188
|
})
|
|
163
189
|
.catch(() => { /* an old host half: the tree stays empty and says so */ })
|
|
164
190
|
return () => { alive = false; ctrl.abort() }
|
|
@@ -246,9 +272,54 @@ export function FileBrowser({
|
|
|
246
272
|
if (!sameList(extraHeld.current, extraPaths)) extraHeld.current = extraPaths
|
|
247
273
|
const steadyExtra = extraHeld.current
|
|
248
274
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
275
|
+
/** The ignored half of the listing, split by the trailing slash that is its
|
|
276
|
+
* only kind marker: ignored files join the browsable list (they open,
|
|
277
|
+
* edit and save through the same path untracked files already take);
|
|
278
|
+
* collapsed directories become tree hints the rows below render as
|
|
279
|
+
* expandable folders. */
|
|
280
|
+
const { files: ignoredFiles, dirs: collapsedDirs } = useMemo(() => splitIgnored(ignored), [ignored])
|
|
281
|
+
const ignoredFilesOf = useMemo(() => new Set(ignoredFiles), [ignoredFiles])
|
|
282
|
+
const collapsedOf = useMemo(() => new Set(collapsedDirs), [collapsedDirs])
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The list git can speak for, and the tree over it. Both change only when
|
|
286
|
+
* the REPOSITORY is re-read — never when a folder is expanded, which is the
|
|
287
|
+
* whole point of the split below: merging the lazy children back into this
|
|
288
|
+
* list meant a fresh `Set`, a fresh `localeCompare` sort and a fresh tree
|
|
289
|
+
* walk over every path in the repository on every click (140ms at 50,000
|
|
290
|
+
* paths, measured, synchronously on the click).
|
|
291
|
+
*/
|
|
292
|
+
const base = useMemo(
|
|
293
|
+
() => mergePaths(mergePaths(paths, steadyExtra), ignoredFiles),
|
|
294
|
+
[paths, steadyExtra, ignoredFiles],
|
|
295
|
+
)
|
|
296
|
+
/** Collapsed directories exist with nothing under them YET, and a path list
|
|
297
|
+
* cannot express that; tracked directories are all implied by their files
|
|
298
|
+
* and never need a hint. */
|
|
299
|
+
const baseTree = useMemo(() => buildDirTree(base, collapsedOf), [base, collapsedOf])
|
|
300
|
+
/** Everything read since, grafted onto the nodes it belongs to — work
|
|
301
|
+
* proportional to what was clicked, not to the repository. */
|
|
302
|
+
const tree = useMemo(() => attachReads(baseTree, children), [baseTree, children])
|
|
303
|
+
const roots = useMemo(() => rootFiles(base), [base])
|
|
304
|
+
|
|
305
|
+
/** Lazy children as ordinary openable paths. The search and the
|
|
306
|
+
* open-file reconciliation both read a flat list, so they get one — a
|
|
307
|
+
* CONCATENATION, deliberately: `base` is already sorted and deduped, and
|
|
308
|
+
* nothing under a collapsed directory can appear in it (git lists neither
|
|
309
|
+
* ignored files nor untracked ones from inside a collapsed directory). */
|
|
310
|
+
const lazyFiles = useMemo(() => {
|
|
311
|
+
const out: string[] = []
|
|
312
|
+
for (const [dir, read] of Object.entries(children.reads)) {
|
|
313
|
+
for (const child of read.entries) if (!child.dir) out.push(`${dir}/${child.name}`)
|
|
314
|
+
}
|
|
315
|
+
return out.sort((a, b) => a.localeCompare(b))
|
|
316
|
+
}, [children])
|
|
317
|
+
const all = useMemo(
|
|
318
|
+
() => lazyFiles.length === 0 ? base : [...base, ...lazyFiles],
|
|
319
|
+
[base, lazyFiles],
|
|
320
|
+
)
|
|
321
|
+
/** Whether a directory the reader can still see was cut at the host's cap. */
|
|
322
|
+
const dirCut = useMemo(() => anyDirTruncated(children), [children])
|
|
252
323
|
const rows = useMemo(
|
|
253
324
|
() => query.trim().length > 0
|
|
254
325
|
? searchRows(all, query, SEARCH_CAP)
|
|
@@ -366,7 +437,34 @@ export function FileBrowser({
|
|
|
366
437
|
onPlace(openAt(place, path))
|
|
367
438
|
}
|
|
368
439
|
|
|
369
|
-
|
|
440
|
+
/** Directories whose children are in flight, so the effect further down
|
|
441
|
+
* does not ask twice for the same one. */
|
|
442
|
+
const pendingDirs = useRef<Set<string>>(new Set())
|
|
443
|
+
/**
|
|
444
|
+
* How many times each directory has been expanded. A read belongs to ONE
|
|
445
|
+
* attempt: fold a directory while its read is in flight, expand it again,
|
|
446
|
+
* and the first read's answer — including its decision to fold the row back
|
|
447
|
+
* on failure — would otherwise land on the second attempt and undo the
|
|
448
|
+
* reader's click.
|
|
449
|
+
*/
|
|
450
|
+
const attempts = useRef<Map<string, number>>(new Map())
|
|
451
|
+
|
|
452
|
+
const foldDir = (path: string): void => {
|
|
453
|
+
// Folding disowns whatever read is in flight for this row: its answer must
|
|
454
|
+
// not land on a later expansion, and a re-expand has to be free to ask
|
|
455
|
+
// again rather than wait on a read nobody will accept.
|
|
456
|
+
if (place.expanded.includes(path)) {
|
|
457
|
+
attempts.current.set(path, (attempts.current.get(path) ?? 0) + 1)
|
|
458
|
+
pendingDirs.current.delete(path)
|
|
459
|
+
}
|
|
460
|
+
onPlace(toggleDir(place, path))
|
|
461
|
+
}
|
|
462
|
+
/** Collapse a directory only if it stands open — the failure path of a
|
|
463
|
+
* lazy read, where a plain toggle would re-open what the reader already
|
|
464
|
+
* folded while the read was in flight. */
|
|
465
|
+
const foldIfOpen = (path: string): void => {
|
|
466
|
+
if (place.expanded.includes(path)) onPlace(toggleDir(place, path))
|
|
467
|
+
}
|
|
370
468
|
|
|
371
469
|
const idRef = useRef(0)
|
|
372
470
|
useEffect(() => { idRef.current += 1 }, [open])
|
|
@@ -416,8 +514,66 @@ export function FileBrowser({
|
|
|
416
514
|
* their identity — would rebuild every row on every render, which is the
|
|
417
515
|
* cost this memo exists to remove.
|
|
418
516
|
*/
|
|
419
|
-
const acts = useRef({ foldDir, openFile })
|
|
420
|
-
acts.current = { foldDir, openFile }
|
|
517
|
+
const acts = useRef({ foldDir, openFile, foldIfOpen })
|
|
518
|
+
acts.current = { foldDir, openFile, foldIfOpen }
|
|
519
|
+
|
|
520
|
+
/** What eviction must not touch: the directories standing open, and the
|
|
521
|
+
* ancestors of the open file — whose path has to stay in the browsable
|
|
522
|
+
* list, or the reconciliation below would report the open file vanished. */
|
|
523
|
+
const keepRef = useRef<ReadonlySet<string>>(new Set())
|
|
524
|
+
keepRef.current = useMemo(
|
|
525
|
+
() => new Set([...place.expanded, ...(open === null ? [] : ancestorsOf(open))]),
|
|
526
|
+
[place.expanded, open],
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
/** Read one ignored directory's children into the tree cache. A no-op when
|
|
530
|
+
* they are already there or in flight. */
|
|
531
|
+
const loadDirChildren = useCallback((dir: string): void => {
|
|
532
|
+
if (pendingDirs.current.has(dir)) return
|
|
533
|
+
pendingDirs.current.add(dir)
|
|
534
|
+
const attempt = (attempts.current.get(dir) ?? 0) + 1
|
|
535
|
+
attempts.current.set(dir, attempt)
|
|
536
|
+
const settle = (): boolean => {
|
|
537
|
+
pendingDirs.current.delete(dir)
|
|
538
|
+
return attempts.current.get(dir) === attempt
|
|
539
|
+
}
|
|
540
|
+
void fetchIgnoredDir(statsPath, dir, new AbortController().signal)
|
|
541
|
+
.then(answer => {
|
|
542
|
+
if (!settle()) return // the reader folded this row and opened it again
|
|
543
|
+
if (answer === null) {
|
|
544
|
+
// The read failed — the host half went dark, or refused the path. Fold
|
|
545
|
+
// the row back: a folder standing open and empty forever reads as
|
|
546
|
+
// broken, one that declines to open reads as "nothing to ask again".
|
|
547
|
+
// The effect below retries once anything moves.
|
|
548
|
+
acts.current.foldIfOpen(dir)
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
// Merged inside the setState, against whatever tree is current: two
|
|
552
|
+
// reads can land before React re-renders either, and a write built
|
|
553
|
+
// from the tree this callback captured would drop the other's
|
|
554
|
+
// children — which the effect would then read again, and again.
|
|
555
|
+
onTree(prev => ({
|
|
556
|
+
...prev,
|
|
557
|
+
children: rememberDir(prev.children, dir, answer, keepRef.current),
|
|
558
|
+
}))
|
|
559
|
+
})
|
|
560
|
+
.catch(() => { settle() })
|
|
561
|
+
}, [statsPath, fetchIgnoredDir, onTree])
|
|
562
|
+
|
|
563
|
+
// Any expanded directory in ignored territory whose children have not been
|
|
564
|
+
// read — one just clicked open, or one restored from a previous run; the
|
|
565
|
+
// PLACE survives restarts while the tree does not — gets them read now. The
|
|
566
|
+
// click itself is the plain folder toggle; children appear as they land,
|
|
567
|
+
// one level at a time, so no click can enumerate a whole ignored tree at
|
|
568
|
+
// once. A directory read while its parent is still unread grafts itself the
|
|
569
|
+
// moment the parent's read creates the node for it.
|
|
570
|
+
useEffect(() => {
|
|
571
|
+
for (const dir of place.expanded) {
|
|
572
|
+
if (children.reads[dir] === undefined && isIgnoredPath(dir, ignoredFilesOf, collapsedOf)) {
|
|
573
|
+
loadDirChildren(dir)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}, [place.expanded, children, ignoredFilesOf, collapsedOf, loadDirChildren])
|
|
421
577
|
|
|
422
578
|
/**
|
|
423
579
|
* `t` arrives in the host's slot props and its identity is not ours to rely
|
|
@@ -425,6 +581,8 @@ export function FileBrowser({
|
|
|
425
581
|
* to change when the language does, and it costs one lookup per render.
|
|
426
582
|
*/
|
|
427
583
|
const langKey = t('filesMore', { count: 0 })
|
|
584
|
+
/** Same trick for the ignored rows' title suffix. */
|
|
585
|
+
const ignoredLabel = t('filesIgnored')
|
|
428
586
|
|
|
429
587
|
/**
|
|
430
588
|
* The rendered rows.
|
|
@@ -436,7 +594,12 @@ export function FileBrowser({
|
|
|
436
594
|
* the reader opens — the shape of "it gets laggy once there are a lot of
|
|
437
595
|
* files".
|
|
438
596
|
*/
|
|
439
|
-
const list = useMemo(() => rows.map(row =>
|
|
597
|
+
const list = useMemo(() => rows.map(row => {
|
|
598
|
+
/** Ignored territory, by file or by descent from a collapsed directory.
|
|
599
|
+
* The active row is exempt in the className below: being the file on
|
|
600
|
+
* screen is the louder state, and the two must not fight. */
|
|
601
|
+
const ignored = row.kind !== 'more' && isIgnoredPath(row.path, ignoredFilesOf, collapsedOf)
|
|
602
|
+
return (
|
|
440
603
|
<li key={rowKey(row)}>
|
|
441
604
|
{row.kind === 'more' ? (
|
|
442
605
|
<span
|
|
@@ -446,11 +609,13 @@ export function FileBrowser({
|
|
|
446
609
|
) : (
|
|
447
610
|
<button
|
|
448
611
|
type="button"
|
|
449
|
-
title={row.path}
|
|
612
|
+
title={ignored ? `${row.path} — ${ignoredLabel}` : row.path}
|
|
450
613
|
aria-expanded={row.kind === 'dir' ? row.open : undefined}
|
|
451
614
|
className={row.kind === 'file' && row.path === open
|
|
452
615
|
? `${css.fbRow} ${css.fbRowActive}`
|
|
453
|
-
:
|
|
616
|
+
: ignored
|
|
617
|
+
? `${css.fbRow} ${css.fbRowIgnored}`
|
|
618
|
+
: css.fbRow}
|
|
454
619
|
style={{ paddingLeft: `${0.4 + row.depth * INDENT_EM}em` }}
|
|
455
620
|
onClick={() => {
|
|
456
621
|
row.kind === 'dir' ? acts.current.foldDir(row.path) : acts.current.openFile(row.path)
|
|
@@ -471,10 +636,11 @@ export function FileBrowser({
|
|
|
471
636
|
</button>
|
|
472
637
|
)}
|
|
473
638
|
</li>
|
|
474
|
-
|
|
639
|
+
)
|
|
640
|
+
}),
|
|
475
641
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- `t` is read through
|
|
476
642
|
// `langKey`, and the handlers through `acts`; see both comments above.
|
|
477
|
-
[rows, open, langKey])
|
|
643
|
+
[rows, open, langKey, ignoredLabel, ignoredFilesOf, collapsedOf])
|
|
478
644
|
|
|
479
645
|
return (
|
|
480
646
|
<>
|
|
@@ -487,7 +653,13 @@ export function FileBrowser({
|
|
|
487
653
|
aria-label={t('fileSearchPlaceholder')}
|
|
488
654
|
onChange={event => { onPlace({ ...place, query: event.target.value }) }}
|
|
489
655
|
/>
|
|
490
|
-
{truncated ? <div className={css.fbNote}>{t('filesTruncated')}</div> : null}
|
|
656
|
+
{truncated || ignoredTruncated ? <div className={css.fbNote}>{t('filesTruncated')}</div> : null}
|
|
657
|
+
{/* Separate from the note above because the advice differs: the search
|
|
658
|
+
reads the path list, and entries a DIRECTORY read cut are not in
|
|
659
|
+
it. Derived from the cache, so folding or re-reading that directory
|
|
660
|
+
takes the note away instead of leaving it standing. */}
|
|
661
|
+
{dirCut ? <div className={css.fbNote}>{t('filesIgnoredCut')}</div> : null}
|
|
662
|
+
{ignoredError !== undefined ? <div className={css.fbNote}>{t('filesIgnoredFailed')}</div> : null}
|
|
491
663
|
{rows.length === 0 ? (
|
|
492
664
|
<div className={css.empty}>{all.length === 0 ? t('filesEmpty') : t('filesNoMatch')}</div>
|
|
493
665
|
) : (
|
|
@@ -648,6 +820,7 @@ export function FileBrowser({
|
|
|
648
820
|
onBlameClick={line => { setPicked(line) }}
|
|
649
821
|
notCommitted={t('blameUncommitted')}
|
|
650
822
|
readOnly={readOnly}
|
|
823
|
+
wrap={wrap}
|
|
651
824
|
/>
|
|
652
825
|
)}
|
|
653
826
|
</div>
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
opMessage, RefPicker, SettingsMenu, SourceChip, SyncBar,
|
|
73
73
|
} from './WorkbenchControls.tsx'
|
|
74
74
|
import { decodePlaces, encodePlaces, placeAt, withPlace, type FilesPlace, type FilesPlaces } from './files-place.ts'
|
|
75
|
+
import { NO_IGNORED_READS, type DirRead, type IgnoredCache } from './ignored-cache.ts'
|
|
75
76
|
import { useIdleValue } from './idle-value.ts'
|
|
76
77
|
import { emptyQueryFilter, parseLogQuery, serializeLogQuery } from './log-filter-query.ts'
|
|
77
78
|
import { nextAfterPlan, type DiscardAnswer, type DiscardPreview } from './discard-flow.ts'
|
|
@@ -86,6 +87,7 @@ import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesCl
|
|
|
86
87
|
import css from './GitWorkbenchPanel.module.css'
|
|
87
88
|
|
|
88
89
|
export type * from './git-workbench-types.ts'
|
|
90
|
+
export type { IgnoredChild } from './ignored-cache.ts'
|
|
89
91
|
import type {
|
|
90
92
|
BlameAnswer, BlockAsk, BlockMode, FileImage, FileSides, GitCommit, GitFile,
|
|
91
93
|
GitOpName, GitOpPayload, GitOpResult, SideLayer, SyncStatus, Translate, WorkbenchStats,
|
|
@@ -114,7 +116,12 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
|
114
116
|
* ref the history walks, so every listed author actually has commits there. */
|
|
115
117
|
readonly fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
|
|
116
118
|
/** Every path on HEAD — the path picker's raw material. */
|
|
117
|
-
readonly fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<
|
|
119
|
+
readonly fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<RepoTreeAnswer | null>
|
|
120
|
+
/** One level of one ignored directory, read from the filesystem — the step
|
|
121
|
+
* behind expanding `node_modules/` in the Files tab. Null when the host
|
|
122
|
+
* half is older than this client, which also sends no ignored entries, so
|
|
123
|
+
* the call is never made. */
|
|
124
|
+
readonly fetchIgnoredDir: (worktreePath: string | undefined, dir: string, signal: AbortSignal) => Promise<DirRead | null>
|
|
118
125
|
readonly fetchCompare: (worktreePath: string | undefined, base: string, head: string, signal: AbortSignal) => Promise<WorkbenchStats | null>
|
|
119
126
|
readonly fetchStyle: (worktreePath: string | undefined, signal: AbortSignal) => Promise<StyleSettings | null>
|
|
120
127
|
readonly saveStyle: (worktreePath: string | undefined, scope: StyleScope, entry: StyleEntry, signal: AbortSignal) => Promise<{ ok: boolean; error?: string }>
|
|
@@ -169,6 +176,10 @@ const STORE_FILES = 'dsh-ui-gitworkbench:files'
|
|
|
169
176
|
* field of the appearance object: that one is about colour, and this choice
|
|
170
177
|
* has to survive a build that adds a palette. */
|
|
171
178
|
const STORE_HISTORY_LAYOUT = 'dsh-ui-gitworkbench:history-layout'
|
|
179
|
+
/** Soft wrap, on or off. A reading preference rather than a project setting:
|
|
180
|
+
* it says how THIS person wants long lines shown, so it lives beside the
|
|
181
|
+
* pane sizes in localStorage rather than in the shared style store. */
|
|
182
|
+
const STORE_WRAP = 'dsh-ui-gitworkbench:wrap'
|
|
172
183
|
|
|
173
184
|
/** Dragged pane sizes in px; null on any of them keeps that pane's CSS default. */
|
|
174
185
|
interface PaneWidths {
|
|
@@ -254,15 +265,44 @@ const EMPTY_STATS: WorkbenchStats = {
|
|
|
254
265
|
/** How long the Files place must hold still before it is written. */
|
|
255
266
|
const PLACES_WRITE_MS = 500
|
|
256
267
|
|
|
257
|
-
/**
|
|
258
|
-
|
|
268
|
+
/**
|
|
269
|
+
* What `repoTree` answers. The ignored fields are OPTIONAL because a host
|
|
270
|
+
* half older than this client sends none of them — the browser then simply
|
|
271
|
+
* has no ignored rows to show. Named rather than inlined so the panel, the
|
|
272
|
+
* drawer and the browser cannot drift apart on what crosses the wire.
|
|
273
|
+
*/
|
|
274
|
+
export interface RepoTreeAnswer {
|
|
275
|
+
paths: string[]
|
|
276
|
+
truncated: boolean
|
|
277
|
+
ignored?: string[]
|
|
278
|
+
ignoredTruncated?: boolean
|
|
279
|
+
/** Set when the ignored listing itself failed, so "nothing ignored" and
|
|
280
|
+
* "could not ask" are not the same silence. */
|
|
281
|
+
ignoredError?: string
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One worktree's last-read file list. Exported because the browser takes it
|
|
285
|
+
* whole: one shape for the cache and the update, so the two cannot drift. */
|
|
286
|
+
export interface FilesTree {
|
|
259
287
|
readonly paths: readonly string[]
|
|
260
288
|
readonly truncated: boolean
|
|
289
|
+
/** Ignored entries exactly as `repoTree` sent them: full paths for ignored
|
|
290
|
+
* files, one trailing-slash line per directory a rule ignores whole. */
|
|
291
|
+
readonly ignored: readonly string[]
|
|
292
|
+
readonly ignoredTruncated: boolean
|
|
293
|
+
/** Why the ignored listing is missing, when it is. Absent on success — an
|
|
294
|
+
* empty list then means the repository really has nothing ignored. */
|
|
295
|
+
readonly ignoredError?: string
|
|
296
|
+
/** Children read so far — the payload of every lazy expansion the reader
|
|
297
|
+
* has made in this worktree, bounded and evicting (`ignored-cache.ts`). */
|
|
298
|
+
readonly children: IgnoredCache
|
|
261
299
|
}
|
|
262
300
|
|
|
263
301
|
/** A worktree nobody has opened the Files tab on yet. One instance, so an
|
|
264
302
|
* unvisited worktree does not re-render the browser on every pass. */
|
|
265
|
-
const EMPTY_TREE: FilesTree = {
|
|
303
|
+
const EMPTY_TREE: FilesTree = {
|
|
304
|
+
paths: [], truncated: false, ignored: [], ignoredTruncated: false, children: NO_IGNORED_READS,
|
|
305
|
+
}
|
|
266
306
|
|
|
267
307
|
/** The overlay with nothing on it — one instance, so an empty overlay never
|
|
268
308
|
* re-renders the tree that receives it. */
|
|
@@ -296,7 +336,7 @@ function defaultBase(branches: readonly string[], head: string): string {
|
|
|
296
336
|
|
|
297
337
|
|
|
298
338
|
|
|
299
|
-
export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
|
|
339
|
+
export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchIgnoredDir, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
|
|
300
340
|
const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
|
|
301
341
|
state?.byId?.[sessionId]?.cwd) as string | undefined
|
|
302
342
|
/** Whether the session's agent has a turn in flight — the store mirrors it
|
|
@@ -332,10 +372,17 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
332
372
|
const rememberPlace = useCallback((key: string, next: FilesPlace): void => {
|
|
333
373
|
setFilesPlaces(prev => withPlace(prev, key, next))
|
|
334
374
|
}, [])
|
|
335
|
-
|
|
375
|
+
// An UPDATER, not a value: two lazy directory reads can land in the same
|
|
376
|
+
// microtask, before React has re-rendered either into the browser. Both
|
|
377
|
+
// would then build their write from the same stale tree and the first one's
|
|
378
|
+
// children would be silently dropped — which the effect below the browser
|
|
379
|
+
// notices and re-reads, turning N expanded directories into O(N²) disk
|
|
380
|
+
// reads. Folding the merge into the setState is what makes each write see
|
|
381
|
+
// the one before it.
|
|
382
|
+
const rememberTree = useCallback((key: string, update: (prev: FilesTree) => FilesTree): void => {
|
|
336
383
|
setFilesTrees(prev => {
|
|
337
384
|
const map = new Map(prev)
|
|
338
|
-
map.set(key,
|
|
385
|
+
map.set(key, update(prev.get(key) ?? EMPTY_TREE))
|
|
339
386
|
return map
|
|
340
387
|
})
|
|
341
388
|
}, [])
|
|
@@ -421,6 +468,12 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
421
468
|
const [historyLayout, setHistoryLayout] = useState<HistoryLayout>(
|
|
422
469
|
() => readStored(STORE_HISTORY_LAYOUT, isHistoryLayout, DEFAULT_HISTORY_LAYOUT),
|
|
423
470
|
)
|
|
471
|
+
/** Soft wrap. Default OFF, which is what the panes have always done: code is
|
|
472
|
+
* written in columns, and wrapping it is a choice about one long file, not
|
|
473
|
+
* a better default for every file. */
|
|
474
|
+
const [wrap, setWrap] = useState<boolean>(
|
|
475
|
+
() => readStored(STORE_WRAP, (value): value is boolean => typeof value === 'boolean', false),
|
|
476
|
+
)
|
|
424
477
|
/** Per-project and global styling; both scopes, unresolved. */
|
|
425
478
|
const [style, setStyle] = useState<StyleSettings>(EMPTY_SETTINGS)
|
|
426
479
|
/** Whether dsh's resolved palette is currently dark. */
|
|
@@ -1126,6 +1179,22 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1126
1179
|
writeStored(STORE_HISTORY_LAYOUT, next)
|
|
1127
1180
|
}
|
|
1128
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* Turn soft wrap on or off, and remember it.
|
|
1184
|
+
*
|
|
1185
|
+
* A plain function, like every other handler below the two guards above:
|
|
1186
|
+
* those `return null`s are the reason nothing past this point may be a HOOK.
|
|
1187
|
+
* A `useCallback` here rendered fewer hooks than the previous pass on the
|
|
1188
|
+
* frame the first stats arrived, which React reports as error #310 and the
|
|
1189
|
+
* shell reports as "slot entry crashed" — the chip simply vanishes.
|
|
1190
|
+
*/
|
|
1191
|
+
const toggleWrap = (): void => {
|
|
1192
|
+
setWrap(prev => {
|
|
1193
|
+
writeStored(STORE_WRAP, !prev)
|
|
1194
|
+
return !prev
|
|
1195
|
+
})
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1129
1198
|
/** Tab switch. No direction refetches the working tree: `viewKey` already
|
|
1130
1199
|
* separates the tabs' per-file diff caches, so bumping `gen` here only cost a
|
|
1131
1200
|
* redundant round trip. */
|
|
@@ -1229,6 +1298,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1229
1298
|
onHistoryLayout={applyHistoryLayout}
|
|
1230
1299
|
onClose={() => setOpen(false)}
|
|
1231
1300
|
onRefresh={refresh}
|
|
1301
|
+
wrap={wrap}
|
|
1302
|
+
onToggleWrap={toggleWrap}
|
|
1232
1303
|
commitDraft={commitDraft}
|
|
1233
1304
|
onCommitDraft={setCommitDraft}
|
|
1234
1305
|
commitAmend={commitAmend}
|
|
@@ -1246,6 +1317,7 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1246
1317
|
writeChecked={writeChecked}
|
|
1247
1318
|
fetchBlame={fetchBlame}
|
|
1248
1319
|
fetchFileImage={fetchFileImage}
|
|
1320
|
+
fetchIgnoredDir={fetchIgnoredDir}
|
|
1249
1321
|
viewKey={viewKey}
|
|
1250
1322
|
gen={gen}
|
|
1251
1323
|
collapsed={collapsed}
|
|
@@ -1329,7 +1401,8 @@ interface DrawerProps {
|
|
|
1329
1401
|
/** Author roster for the funnel popup's user picker. */
|
|
1330
1402
|
fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
|
|
1331
1403
|
/** Every path on HEAD — the path picker's raw material. */
|
|
1332
|
-
fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<
|
|
1404
|
+
fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<RepoTreeAnswer | null>
|
|
1405
|
+
fetchIgnoredDir: (worktreePath: string | undefined, dir: string, signal: AbortSignal) => Promise<DirRead | null>
|
|
1333
1406
|
/** Every local branch — the ref pickers' options, worktree or not. */
|
|
1334
1407
|
branches: readonly string[]
|
|
1335
1408
|
/** Branches that have a worktree, grouped to the top of every picker. */
|
|
@@ -1383,6 +1456,10 @@ interface DrawerProps {
|
|
|
1383
1456
|
onHistoryLayout: (next: HistoryLayout) => void
|
|
1384
1457
|
onClose: () => void
|
|
1385
1458
|
onRefresh: () => void
|
|
1459
|
+
/** Soft wrap, shared by every pane that shows code — the Files editor and
|
|
1460
|
+
* both diff views — because it is one reading preference, not three. */
|
|
1461
|
+
wrap: boolean
|
|
1462
|
+
onToggleWrap: () => void
|
|
1386
1463
|
/** Commit draft, lifted so a tab switch cannot discard it. */
|
|
1387
1464
|
commitDraft: string
|
|
1388
1465
|
onCommitDraft: (next: string) => void
|
|
@@ -1423,11 +1500,11 @@ interface DrawerProps {
|
|
|
1423
1500
|
filesPlaces: FilesPlaces
|
|
1424
1501
|
onFilesPlace: (key: string, next: FilesPlace) => void
|
|
1425
1502
|
filesTrees: ReadonlyMap<string, FilesTree>
|
|
1426
|
-
onFilesTree: (key: string,
|
|
1503
|
+
onFilesTree: (key: string, update: (prev: FilesTree) => FilesTree) => void
|
|
1427
1504
|
onCollapsedChange: (next: Set<string>) => void
|
|
1428
1505
|
}
|
|
1429
1506
|
|
|
1430
|
-
function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onCommitsTall, historyLayout, onHistoryLayout, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, viewKey, gen, collapsed, onCollapsedChange, filesPlaces, onFilesPlace, filesTrees, onFilesTree }: DrawerProps): ReactNode {
|
|
1507
|
+
function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, fetchIgnoredDir, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onCommitsTall, historyLayout, onHistoryLayout, onClose, onRefresh, wrap, onToggleWrap, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, viewKey, gen, collapsed, onCollapsedChange, filesPlaces, onFilesPlace, filesTrees, onFilesTree }: DrawerProps): ReactNode {
|
|
1431
1508
|
// Empty stand-in while a commit's change set loads, so every hook below keeps a
|
|
1432
1509
|
// stable shape and the panes simply render nothing.
|
|
1433
1510
|
const body = shown ?? EMPTY_STATS
|
|
@@ -1457,7 +1534,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1457
1534
|
const rememberPlaceHere = useCallback(
|
|
1458
1535
|
(next: FilesPlace) => { onFilesPlace(filesKey, next) }, [onFilesPlace, filesKey])
|
|
1459
1536
|
const rememberTreeHere = useCallback(
|
|
1460
|
-
(
|
|
1537
|
+
(update: (prev: FilesTree) => FilesTree) => { onFilesTree(filesKey, update) }, [onFilesTree, filesKey])
|
|
1461
1538
|
const browsablePaths = useMemo(
|
|
1462
1539
|
() => stats.files.filter(file => file.status !== 'deleted').map(file => file.path),
|
|
1463
1540
|
[stats.files],
|
|
@@ -1822,6 +1899,14 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1822
1899
|
title={maximized ? t('restore') : t('maximize')}
|
|
1823
1900
|
onClick={onToggleMaximized}
|
|
1824
1901
|
><ChromeGlyph of={maximized ? 'restore' : 'maximize'} /></button>
|
|
1902
|
+
<button
|
|
1903
|
+
type="button"
|
|
1904
|
+
className={wrap ? `${css.btn} ${css.btnIcon} ${css.btnIconOn}` : `${css.btn} ${css.btnIcon}`}
|
|
1905
|
+
aria-pressed={wrap}
|
|
1906
|
+
aria-label={wrap ? t('wrapLinesOff') : t('wrapLines')}
|
|
1907
|
+
title={wrap ? t('wrapLinesOff') : t('wrapLines')}
|
|
1908
|
+
onClick={onToggleWrap}
|
|
1909
|
+
><ChromeGlyph of="wrap" /></button>
|
|
1825
1910
|
<button
|
|
1826
1911
|
type="button"
|
|
1827
1912
|
className={`${css.btn} ${css.btnIcon}`}
|
|
@@ -1984,7 +2069,9 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1984
2069
|
onPlace={rememberPlaceHere}
|
|
1985
2070
|
cached={filesTrees.get(filesKey) ?? EMPTY_TREE}
|
|
1986
2071
|
onTree={rememberTreeHere}
|
|
2072
|
+
wrap={wrap}
|
|
1987
2073
|
fetchRepoTree={fetchRepoTree}
|
|
2074
|
+
fetchIgnoredDir={fetchIgnoredDir}
|
|
1988
2075
|
fetchFileSides={fetchFileSides}
|
|
1989
2076
|
writeChecked={writeChecked}
|
|
1990
2077
|
fetchBlame={fetchBlame}
|
|
@@ -2055,6 +2142,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2055
2142
|
t={t}
|
|
2056
2143
|
path={active}
|
|
2057
2144
|
palette={theme}
|
|
2145
|
+
wrap={wrap}
|
|
2058
2146
|
statsPath={statsPath}
|
|
2059
2147
|
fetchSides={fetchFileSides}
|
|
2060
2148
|
writeChecked={writeChecked}
|
|
@@ -2069,7 +2157,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2069
2157
|
) : loading && segment.length === 0 ? (
|
|
2070
2158
|
<div className={css.empty}>{t('loadingDiff')}</div>
|
|
2071
2159
|
) : segment.length > 0 ? (
|
|
2072
|
-
<DiffView segment={segment} path={active ?? ''} palette={theme} t={t} />
|
|
2160
|
+
<DiffView segment={segment} path={active ?? ''} palette={theme} t={t} wrap={wrap} />
|
|
2073
2161
|
) : (
|
|
2074
2162
|
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
2075
2163
|
)}
|