@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CHANGELOG_EN.md +16 -0
  3. package/README.md +4 -3
  4. package/README_EN.md +1 -1
  5. package/lib/client.js +1519 -477
  6. package/lib/dir-listing.js +34 -0
  7. package/lib/fs-remove.js +5 -36
  8. package/lib/index.js +192 -41
  9. package/lib/path-lock.js +54 -0
  10. package/lib/worktree.js +83 -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 +116 -121
  16. package/src/client/FileBrowser.tsx +196 -23
  17. package/src/client/GitWorkbenchPanel.module.css +1 -0
  18. package/src/client/GitWorkbenchPanel.tsx +100 -12
  19. package/src/client/SideRails.tsx +106 -0
  20. package/src/client/diff-cells.tsx +147 -0
  21. package/src/client/diff-nav.ts +4 -1
  22. package/src/client/dir-tree.ts +31 -1
  23. package/src/client/file-rows.ts +40 -0
  24. package/src/client/h-rail.ts +70 -0
  25. package/src/client/ignored-cache.ts +193 -0
  26. package/src/client/index.ts +22 -3
  27. package/src/client/locales.ts +12 -2
  28. package/src/client/row-heights.ts +225 -0
  29. package/src/client/styles/changes.css +33 -2
  30. package/src/client/styles/controls.css +5 -0
  31. package/src/client/styles/files.css +5 -0
  32. package/src/client/styles/rails.css +72 -0
  33. package/src/client/use-row-window.ts +7 -3
  34. package/src/client/use-variable-row-window.ts +210 -0
  35. package/src/dir-listing.ts +47 -0
  36. package/src/fs-remove.ts +5 -36
  37. package/src/index.ts +217 -43
  38. package/src/path-lock.ts +56 -0
  39. package/src/types/dsh-shim.d.ts +12 -2
  40. package/src/worktree.ts +97 -0
  41. 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,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<{ paths: string[]; truncated: boolean } | null>
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
- /** One worktree's last-read file list. */
258
- interface FilesTree {
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 = { paths: [], truncated: false }
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
- const rememberTree = useCallback((key: string, next: FilesTree): void => {
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, next)
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<{ paths: string[]; truncated: boolean } | null>
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, next: FilesTree) => void
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
- (next: FilesTree) => { onFilesTree(filesKey, next) }, [onFilesTree, filesKey])
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
  )}