@young1lin/dsh-ui-gitworkbench 0.1.15 → 0.1.17
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 +26 -0
- package/CHANGELOG_EN.md +26 -0
- package/README.md +30 -5
- package/README_EN.md +1 -1
- package/lib/client.js +1600 -519
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +233 -52
- package/lib/path-lock.js +54 -0
- package/lib/worktree.js +133 -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 +122 -123
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +114 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -0
- package/src/client/diff-model.ts +20 -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 +18 -4
- 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 +257 -55
- package/src/path-lock.ts +56 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +153 -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,9 +72,11 @@ 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'
|
|
79
|
+
import { isPhantomModified } from './diff-model.ts'
|
|
78
80
|
import { NO_PATHS, preferredFile } from './active-file.ts'
|
|
79
81
|
import type { LogFilter } from '../log-filter.ts'
|
|
80
82
|
import type { AuthorEntry } from '../shortlog.ts'
|
|
@@ -86,6 +88,7 @@ import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesCl
|
|
|
86
88
|
import css from './GitWorkbenchPanel.module.css'
|
|
87
89
|
|
|
88
90
|
export type * from './git-workbench-types.ts'
|
|
91
|
+
export type { IgnoredChild } from './ignored-cache.ts'
|
|
89
92
|
import type {
|
|
90
93
|
BlameAnswer, BlockAsk, BlockMode, FileImage, FileSides, GitCommit, GitFile,
|
|
91
94
|
GitOpName, GitOpPayload, GitOpResult, SideLayer, SyncStatus, Translate, WorkbenchStats,
|
|
@@ -114,7 +117,12 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
|
114
117
|
* ref the history walks, so every listed author actually has commits there. */
|
|
115
118
|
readonly fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
|
|
116
119
|
/** Every path on HEAD — the path picker's raw material. */
|
|
117
|
-
readonly fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<
|
|
120
|
+
readonly fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<RepoTreeAnswer | null>
|
|
121
|
+
/** One level of one ignored directory, read from the filesystem — the step
|
|
122
|
+
* behind expanding `node_modules/` in the Files tab. Null when the host
|
|
123
|
+
* half is older than this client, which also sends no ignored entries, so
|
|
124
|
+
* the call is never made. */
|
|
125
|
+
readonly fetchIgnoredDir: (worktreePath: string | undefined, dir: string, signal: AbortSignal) => Promise<DirRead | null>
|
|
118
126
|
readonly fetchCompare: (worktreePath: string | undefined, base: string, head: string, signal: AbortSignal) => Promise<WorkbenchStats | null>
|
|
119
127
|
readonly fetchStyle: (worktreePath: string | undefined, signal: AbortSignal) => Promise<StyleSettings | null>
|
|
120
128
|
readonly saveStyle: (worktreePath: string | undefined, scope: StyleScope, entry: StyleEntry, signal: AbortSignal) => Promise<{ ok: boolean; error?: string }>
|
|
@@ -169,6 +177,10 @@ const STORE_FILES = 'dsh-ui-gitworkbench:files'
|
|
|
169
177
|
* field of the appearance object: that one is about colour, and this choice
|
|
170
178
|
* has to survive a build that adds a palette. */
|
|
171
179
|
const STORE_HISTORY_LAYOUT = 'dsh-ui-gitworkbench:history-layout'
|
|
180
|
+
/** Soft wrap, on or off. A reading preference rather than a project setting:
|
|
181
|
+
* it says how THIS person wants long lines shown, so it lives beside the
|
|
182
|
+
* pane sizes in localStorage rather than in the shared style store. */
|
|
183
|
+
const STORE_WRAP = 'dsh-ui-gitworkbench:wrap'
|
|
172
184
|
|
|
173
185
|
/** Dragged pane sizes in px; null on any of them keeps that pane's CSS default. */
|
|
174
186
|
interface PaneWidths {
|
|
@@ -254,15 +266,44 @@ const EMPTY_STATS: WorkbenchStats = {
|
|
|
254
266
|
/** How long the Files place must hold still before it is written. */
|
|
255
267
|
const PLACES_WRITE_MS = 500
|
|
256
268
|
|
|
257
|
-
/**
|
|
258
|
-
|
|
269
|
+
/**
|
|
270
|
+
* What `repoTree` answers. The ignored fields are OPTIONAL because a host
|
|
271
|
+
* half older than this client sends none of them — the browser then simply
|
|
272
|
+
* has no ignored rows to show. Named rather than inlined so the panel, the
|
|
273
|
+
* drawer and the browser cannot drift apart on what crosses the wire.
|
|
274
|
+
*/
|
|
275
|
+
export interface RepoTreeAnswer {
|
|
276
|
+
paths: string[]
|
|
277
|
+
truncated: boolean
|
|
278
|
+
ignored?: string[]
|
|
279
|
+
ignoredTruncated?: boolean
|
|
280
|
+
/** Set when the ignored listing itself failed, so "nothing ignored" and
|
|
281
|
+
* "could not ask" are not the same silence. */
|
|
282
|
+
ignoredError?: string
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** One worktree's last-read file list. Exported because the browser takes it
|
|
286
|
+
* whole: one shape for the cache and the update, so the two cannot drift. */
|
|
287
|
+
export interface FilesTree {
|
|
259
288
|
readonly paths: readonly string[]
|
|
260
289
|
readonly truncated: boolean
|
|
290
|
+
/** Ignored entries exactly as `repoTree` sent them: full paths for ignored
|
|
291
|
+
* files, one trailing-slash line per directory a rule ignores whole. */
|
|
292
|
+
readonly ignored: readonly string[]
|
|
293
|
+
readonly ignoredTruncated: boolean
|
|
294
|
+
/** Why the ignored listing is missing, when it is. Absent on success — an
|
|
295
|
+
* empty list then means the repository really has nothing ignored. */
|
|
296
|
+
readonly ignoredError?: string
|
|
297
|
+
/** Children read so far — the payload of every lazy expansion the reader
|
|
298
|
+
* has made in this worktree, bounded and evicting (`ignored-cache.ts`). */
|
|
299
|
+
readonly children: IgnoredCache
|
|
261
300
|
}
|
|
262
301
|
|
|
263
302
|
/** A worktree nobody has opened the Files tab on yet. One instance, so an
|
|
264
303
|
* unvisited worktree does not re-render the browser on every pass. */
|
|
265
|
-
const EMPTY_TREE: FilesTree = {
|
|
304
|
+
const EMPTY_TREE: FilesTree = {
|
|
305
|
+
paths: [], truncated: false, ignored: [], ignoredTruncated: false, children: NO_IGNORED_READS,
|
|
306
|
+
}
|
|
266
307
|
|
|
267
308
|
/** The overlay with nothing on it — one instance, so an empty overlay never
|
|
268
309
|
* re-renders the tree that receives it. */
|
|
@@ -296,7 +337,7 @@ function defaultBase(branches: readonly string[], head: string): string {
|
|
|
296
337
|
|
|
297
338
|
|
|
298
339
|
|
|
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) {
|
|
340
|
+
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
341
|
const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
|
|
301
342
|
state?.byId?.[sessionId]?.cwd) as string | undefined
|
|
302
343
|
/** Whether the session's agent has a turn in flight — the store mirrors it
|
|
@@ -332,10 +373,17 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
332
373
|
const rememberPlace = useCallback((key: string, next: FilesPlace): void => {
|
|
333
374
|
setFilesPlaces(prev => withPlace(prev, key, next))
|
|
334
375
|
}, [])
|
|
335
|
-
|
|
376
|
+
// An UPDATER, not a value: two lazy directory reads can land in the same
|
|
377
|
+
// microtask, before React has re-rendered either into the browser. Both
|
|
378
|
+
// would then build their write from the same stale tree and the first one's
|
|
379
|
+
// children would be silently dropped — which the effect below the browser
|
|
380
|
+
// notices and re-reads, turning N expanded directories into O(N²) disk
|
|
381
|
+
// reads. Folding the merge into the setState is what makes each write see
|
|
382
|
+
// the one before it.
|
|
383
|
+
const rememberTree = useCallback((key: string, update: (prev: FilesTree) => FilesTree): void => {
|
|
336
384
|
setFilesTrees(prev => {
|
|
337
385
|
const map = new Map(prev)
|
|
338
|
-
map.set(key,
|
|
386
|
+
map.set(key, update(prev.get(key) ?? EMPTY_TREE))
|
|
339
387
|
return map
|
|
340
388
|
})
|
|
341
389
|
}, [])
|
|
@@ -421,6 +469,12 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
421
469
|
const [historyLayout, setHistoryLayout] = useState<HistoryLayout>(
|
|
422
470
|
() => readStored(STORE_HISTORY_LAYOUT, isHistoryLayout, DEFAULT_HISTORY_LAYOUT),
|
|
423
471
|
)
|
|
472
|
+
/** Soft wrap. Default OFF, which is what the panes have always done: code is
|
|
473
|
+
* written in columns, and wrapping it is a choice about one long file, not
|
|
474
|
+
* a better default for every file. */
|
|
475
|
+
const [wrap, setWrap] = useState<boolean>(
|
|
476
|
+
() => readStored(STORE_WRAP, (value): value is boolean => typeof value === 'boolean', false),
|
|
477
|
+
)
|
|
424
478
|
/** Per-project and global styling; both scopes, unresolved. */
|
|
425
479
|
const [style, setStyle] = useState<StyleSettings>(EMPTY_SETTINGS)
|
|
426
480
|
/** Whether dsh's resolved palette is currently dark. */
|
|
@@ -1126,6 +1180,22 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1126
1180
|
writeStored(STORE_HISTORY_LAYOUT, next)
|
|
1127
1181
|
}
|
|
1128
1182
|
|
|
1183
|
+
/**
|
|
1184
|
+
* Turn soft wrap on or off, and remember it.
|
|
1185
|
+
*
|
|
1186
|
+
* A plain function, like every other handler below the two guards above:
|
|
1187
|
+
* those `return null`s are the reason nothing past this point may be a HOOK.
|
|
1188
|
+
* A `useCallback` here rendered fewer hooks than the previous pass on the
|
|
1189
|
+
* frame the first stats arrived, which React reports as error #310 and the
|
|
1190
|
+
* shell reports as "slot entry crashed" — the chip simply vanishes.
|
|
1191
|
+
*/
|
|
1192
|
+
const toggleWrap = (): void => {
|
|
1193
|
+
setWrap(prev => {
|
|
1194
|
+
writeStored(STORE_WRAP, !prev)
|
|
1195
|
+
return !prev
|
|
1196
|
+
})
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1129
1199
|
/** Tab switch. No direction refetches the working tree: `viewKey` already
|
|
1130
1200
|
* separates the tabs' per-file diff caches, so bumping `gen` here only cost a
|
|
1131
1201
|
* redundant round trip. */
|
|
@@ -1229,6 +1299,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1229
1299
|
onHistoryLayout={applyHistoryLayout}
|
|
1230
1300
|
onClose={() => setOpen(false)}
|
|
1231
1301
|
onRefresh={refresh}
|
|
1302
|
+
wrap={wrap}
|
|
1303
|
+
onToggleWrap={toggleWrap}
|
|
1232
1304
|
commitDraft={commitDraft}
|
|
1233
1305
|
onCommitDraft={setCommitDraft}
|
|
1234
1306
|
commitAmend={commitAmend}
|
|
@@ -1246,6 +1318,7 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1246
1318
|
writeChecked={writeChecked}
|
|
1247
1319
|
fetchBlame={fetchBlame}
|
|
1248
1320
|
fetchFileImage={fetchFileImage}
|
|
1321
|
+
fetchIgnoredDir={fetchIgnoredDir}
|
|
1249
1322
|
viewKey={viewKey}
|
|
1250
1323
|
gen={gen}
|
|
1251
1324
|
collapsed={collapsed}
|
|
@@ -1329,7 +1402,8 @@ interface DrawerProps {
|
|
|
1329
1402
|
/** Author roster for the funnel popup's user picker. */
|
|
1330
1403
|
fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
|
|
1331
1404
|
/** Every path on HEAD — the path picker's raw material. */
|
|
1332
|
-
fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<
|
|
1405
|
+
fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<RepoTreeAnswer | null>
|
|
1406
|
+
fetchIgnoredDir: (worktreePath: string | undefined, dir: string, signal: AbortSignal) => Promise<DirRead | null>
|
|
1333
1407
|
/** Every local branch — the ref pickers' options, worktree or not. */
|
|
1334
1408
|
branches: readonly string[]
|
|
1335
1409
|
/** Branches that have a worktree, grouped to the top of every picker. */
|
|
@@ -1383,6 +1457,10 @@ interface DrawerProps {
|
|
|
1383
1457
|
onHistoryLayout: (next: HistoryLayout) => void
|
|
1384
1458
|
onClose: () => void
|
|
1385
1459
|
onRefresh: () => void
|
|
1460
|
+
/** Soft wrap, shared by every pane that shows code — the Files editor and
|
|
1461
|
+
* both diff views — because it is one reading preference, not three. */
|
|
1462
|
+
wrap: boolean
|
|
1463
|
+
onToggleWrap: () => void
|
|
1386
1464
|
/** Commit draft, lifted so a tab switch cannot discard it. */
|
|
1387
1465
|
commitDraft: string
|
|
1388
1466
|
onCommitDraft: (next: string) => void
|
|
@@ -1423,11 +1501,11 @@ interface DrawerProps {
|
|
|
1423
1501
|
filesPlaces: FilesPlaces
|
|
1424
1502
|
onFilesPlace: (key: string, next: FilesPlace) => void
|
|
1425
1503
|
filesTrees: ReadonlyMap<string, FilesTree>
|
|
1426
|
-
onFilesTree: (key: string,
|
|
1504
|
+
onFilesTree: (key: string, update: (prev: FilesTree) => FilesTree) => void
|
|
1427
1505
|
onCollapsedChange: (next: Set<string>) => void
|
|
1428
1506
|
}
|
|
1429
1507
|
|
|
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 {
|
|
1508
|
+
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
1509
|
// Empty stand-in while a commit's change set loads, so every hook below keeps a
|
|
1432
1510
|
// stable shape and the panes simply render nothing.
|
|
1433
1511
|
const body = shown ?? EMPTY_STATS
|
|
@@ -1457,7 +1535,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1457
1535
|
const rememberPlaceHere = useCallback(
|
|
1458
1536
|
(next: FilesPlace) => { onFilesPlace(filesKey, next) }, [onFilesPlace, filesKey])
|
|
1459
1537
|
const rememberTreeHere = useCallback(
|
|
1460
|
-
(
|
|
1538
|
+
(update: (prev: FilesTree) => FilesTree) => { onFilesTree(filesKey, update) }, [onFilesTree, filesKey])
|
|
1461
1539
|
const browsablePaths = useMemo(
|
|
1462
1540
|
() => stats.files.filter(file => file.status !== 'deleted').map(file => file.path),
|
|
1463
1541
|
[stats.files],
|
|
@@ -1477,6 +1555,11 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1477
1555
|
// content in the working tree and in every commit.
|
|
1478
1556
|
const activeKey = active === null ? null : `${viewKey}\x1f${active}`
|
|
1479
1557
|
const segment = bundled.length > 0 ? bundled : activeKey === null ? '' : fetched.get(activeKey) ?? ''
|
|
1558
|
+
/** The CRLF phantom: listed modified, whole-file diff empty (see
|
|
1559
|
+
* {@link isPhantomModified}). Changes-tab only — history and compare list
|
|
1560
|
+
* files from real ref diffs, where an empty segment means a failed fetch,
|
|
1561
|
+
* and the phantom notice would mislead. */
|
|
1562
|
+
const phantomListed = tab === 'changes' && isPhantomModified(activeFile?.status, segment)
|
|
1480
1563
|
|
|
1481
1564
|
// On-demand diff for files absent from the bundled payload (cap-truncated
|
|
1482
1565
|
// untracked files, oversize paths).
|
|
@@ -1822,6 +1905,14 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1822
1905
|
title={maximized ? t('restore') : t('maximize')}
|
|
1823
1906
|
onClick={onToggleMaximized}
|
|
1824
1907
|
><ChromeGlyph of={maximized ? 'restore' : 'maximize'} /></button>
|
|
1908
|
+
<button
|
|
1909
|
+
type="button"
|
|
1910
|
+
className={wrap ? `${css.btn} ${css.btnIcon} ${css.btnIconOn}` : `${css.btn} ${css.btnIcon}`}
|
|
1911
|
+
aria-pressed={wrap}
|
|
1912
|
+
aria-label={wrap ? t('wrapLinesOff') : t('wrapLines')}
|
|
1913
|
+
title={wrap ? t('wrapLinesOff') : t('wrapLines')}
|
|
1914
|
+
onClick={onToggleWrap}
|
|
1915
|
+
><ChromeGlyph of="wrap" /></button>
|
|
1825
1916
|
<button
|
|
1826
1917
|
type="button"
|
|
1827
1918
|
className={`${css.btn} ${css.btnIcon}`}
|
|
@@ -1984,7 +2075,9 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1984
2075
|
onPlace={rememberPlaceHere}
|
|
1985
2076
|
cached={filesTrees.get(filesKey) ?? EMPTY_TREE}
|
|
1986
2077
|
onTree={rememberTreeHere}
|
|
2078
|
+
wrap={wrap}
|
|
1987
2079
|
fetchRepoTree={fetchRepoTree}
|
|
2080
|
+
fetchIgnoredDir={fetchIgnoredDir}
|
|
1988
2081
|
fetchFileSides={fetchFileSides}
|
|
1989
2082
|
writeChecked={writeChecked}
|
|
1990
2083
|
fetchBlame={fetchBlame}
|
|
@@ -2047,6 +2140,13 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2047
2140
|
) : activeFile !== null && activeFile.previousPath !== undefined ? (
|
|
2048
2141
|
<div className={css.renameLine}>{t('renamedFrom')} <code>{activeFile.previousPath}</code></div>
|
|
2049
2142
|
) : null}
|
|
2143
|
+
{/* An empty compare result is usually the three-dot DIRECTION, not
|
|
2144
|
+
"no differences": A...B diffs from the fork point up to B, so a
|
|
2145
|
+
B that never moved past the fork shows nothing at all. Say so —
|
|
2146
|
+
an empty tree with no word reads as a broken one. */}
|
|
2147
|
+
{tab === 'compare' && comparable && shown !== null && body.files.length === 0 ? (
|
|
2148
|
+
<div className={css.empty}>{t('compareEmptyHint', { base: baseRef ?? '', head: headRef ?? '' })}</div>
|
|
2149
|
+
) : null}
|
|
2050
2150
|
{(shown === null && tab !== 'changes') || (tab === 'compare' && !comparable) ? null
|
|
2051
2151
|
: activeFile !== null && activeFile.binary ? (
|
|
2052
2152
|
<div className={css.empty}>{t('binaryFile')}</div>
|
|
@@ -2055,6 +2155,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2055
2155
|
t={t}
|
|
2056
2156
|
path={active}
|
|
2057
2157
|
palette={theme}
|
|
2158
|
+
wrap={wrap}
|
|
2058
2159
|
statsPath={statsPath}
|
|
2059
2160
|
fetchSides={fetchFileSides}
|
|
2060
2161
|
writeChecked={writeChecked}
|
|
@@ -2062,6 +2163,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2062
2163
|
gen={gen}
|
|
2063
2164
|
fallbackSegment={segment}
|
|
2064
2165
|
fallbackLoading={loading && segment.length === 0}
|
|
2166
|
+
phantomListed={phantomListed}
|
|
2065
2167
|
onBlockAction={askBlockAction}
|
|
2066
2168
|
onSaved={onRefresh}
|
|
2067
2169
|
onDirtyChange={onSideDirty}
|
|
@@ -2069,7 +2171,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2069
2171
|
) : loading && segment.length === 0 ? (
|
|
2070
2172
|
<div className={css.empty}>{t('loadingDiff')}</div>
|
|
2071
2173
|
) : segment.length > 0 ? (
|
|
2072
|
-
<DiffView segment={segment} path={active ?? ''} palette={theme} t={t} />
|
|
2174
|
+
<DiffView segment={segment} path={active ?? ''} palette={theme} t={t} wrap={wrap} />
|
|
2073
2175
|
) : (
|
|
2074
2176
|
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
2075
2177
|
)}
|