@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CHANGELOG_EN.md +26 -0
  3. package/README.md +30 -5
  4. package/README_EN.md +1 -1
  5. package/lib/client.js +1600 -519
  6. package/lib/dir-listing.js +34 -0
  7. package/lib/fs-remove.js +5 -36
  8. package/lib/index.js +233 -52
  9. package/lib/path-lock.js +54 -0
  10. package/lib/worktree.js +133 -0
  11. package/lib/write-checked.js +1 -1
  12. package/package.json +1 -1
  13. package/src/client/ChromeGlyph.tsx +5 -0
  14. package/src/client/CodeEditor.tsx +19 -1
  15. package/src/client/DiffViews.tsx +122 -123
  16. package/src/client/FileBrowser.tsx +196 -23
  17. package/src/client/GitWorkbenchPanel.module.css +1 -0
  18. package/src/client/GitWorkbenchPanel.tsx +114 -12
  19. package/src/client/SideRails.tsx +106 -0
  20. package/src/client/diff-cells.tsx +147 -0
  21. package/src/client/diff-model.ts +20 -0
  22. package/src/client/diff-nav.ts +4 -1
  23. package/src/client/dir-tree.ts +31 -1
  24. package/src/client/file-rows.ts +40 -0
  25. package/src/client/h-rail.ts +70 -0
  26. package/src/client/ignored-cache.ts +193 -0
  27. package/src/client/index.ts +22 -3
  28. package/src/client/locales.ts +18 -4
  29. package/src/client/row-heights.ts +225 -0
  30. package/src/client/styles/changes.css +33 -2
  31. package/src/client/styles/controls.css +5 -0
  32. package/src/client/styles/files.css +5 -0
  33. package/src/client/styles/rails.css +72 -0
  34. package/src/client/use-row-window.ts +7 -3
  35. package/src/client/use-variable-row-window.ts +210 -0
  36. package/src/dir-listing.ts +47 -0
  37. package/src/fs-remove.ts +5 -36
  38. package/src/index.ts +257 -55
  39. package/src/path-lock.ts +56 -0
  40. package/src/types/dsh-shim.d.ts +12 -2
  41. package/src/worktree.ts +153 -0
  42. 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<{ paths: string[]; truncated: boolean } | null>
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
- cached: { readonly paths: readonly string[]; readonly truncated: boolean }
100
- onTree: (next: { readonly paths: readonly string[]; readonly truncated: boolean }) => void
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
- onTree({ paths: answer.paths, truncated: answer.truncated })
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
- const all = useMemo(() => mergePaths(paths, steadyExtra), [paths, steadyExtra])
250
- const tree = useMemo(() => buildDirTree(all), [all])
251
- const roots = useMemo(() => rootFiles(all), [all])
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
- const foldDir = (path: string): void => { onPlace(toggleDir(place, path)) }
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
- : css.fbRow}
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>
@@ -11,6 +11,7 @@
11
11
  @import './styles/history-filters.css';
12
12
  @import './styles/history.css';
13
13
  @import './styles/changes.css';
14
+ @import './styles/rails.css';
14
15
  @import './styles/operations.css';
15
16
  @import './styles/controls.css';
16
17
  @import './styles/files.css';
@@ -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<{ paths: string[]; truncated: boolean } | null>
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
- /** One worktree's last-read file list. */
258
- interface FilesTree {
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 = { paths: [], truncated: false }
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
- const rememberTree = useCallback((key: string, next: FilesTree): void => {
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, next)
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<{ paths: string[]; truncated: boolean } | null>
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, next: FilesTree) => void
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
- (next: FilesTree) => { onFilesTree(filesKey, next) }, [onFilesTree, filesKey])
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
  )}