@young1lin/dsh-ui-gitworkbench 0.1.11 → 0.1.13

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 (36) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/CHANGELOG_EN.md +28 -0
  4. package/README.md +100 -50
  5. package/README_EN.md +2 -1
  6. package/lib/client.js +6902 -6256
  7. package/package.json +1 -1
  8. package/src/client/ChangesFileTree.tsx +550 -0
  9. package/src/client/ChromeGlyph.tsx +27 -0
  10. package/src/client/CodeEditor.tsx +129 -3
  11. package/src/client/CommitHistory.tsx +1001 -0
  12. package/src/client/DiffViews.tsx +1070 -0
  13. package/src/client/GitWorkbenchPanel.module.css +15 -2513
  14. package/src/client/GitWorkbenchPanel.tsx +37 -3959
  15. package/src/client/PaneDivider.tsx +74 -0
  16. package/src/client/WorkbenchControls.tsx +1047 -0
  17. package/src/client/WorktreeGlyph.tsx +24 -0
  18. package/src/client/cm-search-theme.ts +250 -0
  19. package/src/client/diff-nav.ts +59 -0
  20. package/src/client/git-workbench-types.ts +252 -0
  21. package/src/client/locales.ts +14 -8
  22. package/src/client/row-window.ts +23 -0
  23. package/src/client/search-count.ts +125 -0
  24. package/src/client/side-rows.ts +66 -0
  25. package/src/client/styles/changes.css +505 -0
  26. package/src/client/styles/controls.css +236 -0
  27. package/src/client/styles/environment.css +89 -0
  28. package/src/client/styles/files.css +113 -0
  29. package/src/client/styles/history-filters.css +400 -0
  30. package/src/client/styles/history.css +276 -0
  31. package/src/client/styles/image.css +67 -0
  32. package/src/client/styles/operations.css +179 -0
  33. package/src/client/styles/shell.css +431 -0
  34. package/src/client/styles/themes.css +224 -0
  35. package/src/client/use-change-nav.ts +6 -5
  36. package/src/client/use-row-window.ts +55 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-ui-gitworkbench",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,550 @@
1
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
2
+
3
+ import { filterFiles } from './file-filter.ts'
4
+ import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
5
+ import { fileCheckState, rollUp, type CheckState } from './stage-tree.ts'
6
+ import type { GitFile, GitFileStatus, Translate } from './git-workbench-types.ts'
7
+ import css from './GitWorkbenchPanel.module.css'
8
+
9
+ const STATUS_BADGE: Record<GitFileStatus, string> = {
10
+ added: css.stAdded, untracked: css.stUntracked, modified: css.stModified,
11
+ renamed: css.stRenamed, deleted: css.stDeleted,
12
+ }
13
+
14
+ /**
15
+ * Filter this list: a magnifier, not the funnel above the commit list. The two
16
+ * are deliberately different glyphs because they do different things — the
17
+ * funnel asks git for a different set of commits, this only hides rows already
18
+ * on screen — and the drawer shows both at once.
19
+ */
20
+ function FilterGlyph(): ReactNode {
21
+ return (
22
+ <svg
23
+ width="13" height="13" viewBox="0 0 16 16"
24
+ fill="none" stroke="currentColor" strokeWidth="1.25"
25
+ strokeLinecap="round" strokeLinejoin="round"
26
+ aria-hidden="true"
27
+ >
28
+ <circle cx="7" cy="7" r="4" />
29
+ <path d="M10 10l3.5 3.5" />
30
+ </svg>
31
+ )
32
+ }
33
+
34
+ /** Nothing folded. A constant so the filtered tree does not allocate a new Set
35
+ * on every render and re-run `TreeChildren`'s memo. */
36
+ const EMPTY_COLLAPSED: ReadonlySet<string> = new Set<string>()
37
+
38
+ /**
39
+ * Roll back: the counter-clockwise arc every editor and VCS uses for undo,
40
+ * drawn in the same New UI idiom as the node glyphs beside it — 16px grid,
41
+ * 1px stroke, no fill — so the row does not mix an outlined file icon with a
42
+ * solid action icon.
43
+ */
44
+ function RollbackGlyph(): ReactNode {
45
+ return (
46
+ <svg
47
+ width="14" height="14" viewBox="0 0 16 16"
48
+ fill="none" stroke="currentColor" strokeWidth="1.25"
49
+ strokeLinecap="round" strokeLinejoin="round"
50
+ aria-hidden="true"
51
+ >
52
+ {/* The arc, open at the upper left where the head goes. */}
53
+ <path d="M3.5 6.5a5 5 0 1 0 1.9-2.2" />
54
+ {/* The head: a corner, not a triangle — a filled arrowhead this small
55
+ turns into a dot at 1x. */}
56
+ <path d="M2.6 3.2v3.4h3.4" />
57
+ </svg>
58
+ )
59
+ }
60
+
61
+ /* ---------- file tree ---------- */
62
+
63
+ /** Horizontal step per nesting level. */
64
+ const TREE_INDENT = 14
65
+ /** The gutter every row starts at. Equals `--gs-gutter-pane`, so depth-0 ticks
66
+ * line up with the toolbar's own content — and everything interactive stays
67
+ * clear of the 10px resizer the drawer paints over its left edge. */
68
+ const TREE_BASE_INDENT = 12
69
+ /** Chevron width plus its gap. A file row adds this so its status badge starts at
70
+ * the directory NAME's column rather than under the directory's chevron. */
71
+ const TREE_LEAF_OFFSET = 16
72
+ /** Where a level's indent guide sits: inside the chevron, so it points at the
73
+ * rows it groups. */
74
+ const TREE_RAIL_OFFSET = 12
75
+ /** The tick's own width. Must match `.checkBox` — the row's content starts after
76
+ * it, and the indent guides are positioned from it. */
77
+ const TREE_CHECK_W = 22
78
+ /** Custom property the stylesheet reads to place one level's indent guide. */
79
+ const RAIL_VAR = '--gs-rail'
80
+
81
+ interface DirNode {
82
+ readonly name: string
83
+ readonly path: string
84
+ readonly dirs: Map<string, DirNode>
85
+ readonly files: GitFile[]
86
+ fileCount: number
87
+ added: number
88
+ deleted: number
89
+ /** Every descendant's tick, rolled up. Computed once with the other totals. */
90
+ check: CheckState
91
+ }
92
+
93
+ function buildTree(files: readonly GitFile[]): DirNode {
94
+ const root: DirNode = { name: '', path: '', dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
95
+ for (const file of files) {
96
+ let node = root
97
+ const parts = file.path.split('/')
98
+ for (let i = 0; i < parts.length - 1; i += 1) {
99
+ const name = parts[i]
100
+ let child = node.dirs.get(name)
101
+ if (child === undefined) {
102
+ child = { name, path: parts.slice(0, i + 1).join('/'), dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
103
+ node.dirs.set(name, child)
104
+ }
105
+ node = child
106
+ }
107
+ node.files.push(file)
108
+ }
109
+ const aggregate = (node: DirNode): void => {
110
+ node.fileCount = node.files.length
111
+ node.added = node.files.reduce((sum, f) => sum + f.addedLines, 0)
112
+ node.deleted = node.files.reduce((sum, f) => sum + f.deletedLines, 0)
113
+ const ticks: CheckState[] = node.files.map(fileCheckState)
114
+ for (const child of node.dirs.values()) {
115
+ aggregate(child)
116
+ node.fileCount += child.fileCount
117
+ node.added += child.added
118
+ node.deleted += child.deleted
119
+ ticks.push(child.check)
120
+ }
121
+ node.check = rollUp(ticks)
122
+ }
123
+ aggregate(root)
124
+ return compactChains(root)
125
+ }
126
+
127
+ /**
128
+ * Merge every directory that holds nothing but one subdirectory into that child.
129
+ *
130
+ * `docs/superpowers/specs/design.md` otherwise costs three rows and three indent
131
+ * levels to reach one file, and none of those three rows carries a choice — each
132
+ * has exactly one way down. Merging them into a single `docs/superpowers/specs`
133
+ * row is what VS Code calls compact folders, and it makes indentation depth mean
134
+ * "where the tree branches" rather than "how long the path is".
135
+ *
136
+ * The merged node keeps the DEEPEST path, so it stays the one the collapse set
137
+ * and the reveal-the-active-file walk already address.
138
+ * @param node - directory whose descendants are compacted.
139
+ * @returns the node with compacted children.
140
+ */
141
+ function compactChains(node: DirNode): DirNode {
142
+ const dirs = new Map<string, DirNode>()
143
+ for (const child of node.dirs.values()) {
144
+ let merged = compactChains(child)
145
+ while (merged.files.length === 0 && merged.dirs.size === 1) {
146
+ const only = merged.dirs.values().next().value as DirNode
147
+ merged = { ...only, name: `${merged.name}/${only.name}` }
148
+ }
149
+ dirs.set(merged.name, merged)
150
+ }
151
+ return { ...node, dirs }
152
+ }
153
+
154
+ interface FileTreeProps {
155
+ t: Translate
156
+ /** Whether the view has nothing to show yet — already resolved by the caller
157
+ * via {@link showsPending}, NOT the raw in-flight flag. This pane used to
158
+ * re-derive it from `loading && files.length === 0`, and that second copy of
159
+ * the rule is precisely what the header then got wrong. */
160
+ loading?: boolean
161
+ /** What the list holds, prepended to the count — the working-tree view says
162
+ * which worktree it is reading; commit views are already named by history. */
163
+ lead?: string
164
+ files: readonly GitFile[]
165
+ active: string | null
166
+ onSelect: (path: string) => void
167
+ /** Undefined until the user interacts: then it shows defaults. Lifted to the
168
+ * panel so background polls (new `files` identity) and drawer close/reopen
169
+ * never reset the user's expansion choices. */
170
+ collapsed: Set<string> | undefined
171
+ onCollapsedChange: (next: Set<string>) => void
172
+ /** Add or remove files from the commit set. Undefined outside the working-tree
173
+ * view, where what a commit contains was decided long ago. */
174
+ onCheck?: (files: readonly GitFile[], state: CheckState) => void
175
+ /** Roll one file back to HEAD; working-tree view only. */
176
+ onDiscard?: (file: GitFile) => void
177
+ /** Rendered under the tree in the working-tree view only. */
178
+ footer?: ReactNode
179
+ /** Names what this list is OF — the working tree, or one commit, or one
180
+ * comparison. The filter clears when it changes: a query typed against a
181
+ * 140-file commit would otherwise carry over to the next commit and hide
182
+ * most of it, with nothing on screen saying why. */
183
+ scopeKey: string
184
+ }
185
+
186
+ export function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onCollapsedChange, onCheck, onDiscard, footer, scopeKey }: FileTreeProps): ReactNode {
187
+ /**
188
+ * The filter over this list. Local, because it describes a way of LOOKING at
189
+ * the pane rather than anything the drawer stores: closing and reopening on
190
+ * an unfiltered list is what someone expects, and a query kept in the panel
191
+ * would have to be cleared from four places instead of one.
192
+ */
193
+ const [query, setQuery] = useState('')
194
+ const [filterOpen, setFilterOpen] = useState(false)
195
+ const filterRef = useRef<HTMLInputElement>(null)
196
+ useEffect(() => { setQuery(''); setFilterOpen(false) }, [scopeKey])
197
+
198
+ const shownFiles = useMemo(() => filterFiles(files, query), [files, query])
199
+ const filtering = shownFiles !== files
200
+ const tree = useMemo(() => buildTree(shownFiles), [shownFiles])
201
+ /** Default: a dir collapses when it holds more than 12 files anywhere below it. */
202
+ const effective = collapsed ?? defaultCollapsed(tree)
203
+
204
+ // Reveal the active file by expanding its ancestor chain — ONLY when the
205
+ // selection itself changes. Listening to `collapsed` here would instantly
206
+ // revert manual folds of any directory containing the active file.
207
+ useEffect(() => {
208
+ if (active === null) return
209
+ const parts = active.split('/')
210
+ let touched = false
211
+ const next = new Set(collapsed ?? defaultCollapsed(tree))
212
+ for (let i = 1; i < parts.length; i += 1) {
213
+ const dir = parts.slice(0, i).join('/')
214
+ if (next.delete(dir)) touched = true
215
+ }
216
+ if (touched) onCollapsedChange(next)
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reveal is a selection-change event, not an invariant over `collapsed`
218
+ }, [active])
219
+
220
+ const setAll = (open: boolean): void => {
221
+ onCollapsedChange(open ? new Set() : allDirs(tree))
222
+ }
223
+
224
+ const toggleOne = (path: string): void => {
225
+ const next = new Set(effective)
226
+ if (next.has(path)) next.delete(path)
227
+ else next.add(path)
228
+ onCollapsedChange(next)
229
+ }
230
+
231
+ const stageLabels = { stage: t('stage'), unstage: t('unstage') }
232
+
233
+ return (
234
+ <div className={css.treeWrap}>
235
+ <div className={css.treeTools}>
236
+ {/* The root tick replaces the Stage all / Unstage all pair: it says the
237
+ same two things in the column the rows already read down, and gives
238
+ the toolbar back the room those two buttons needed. The toolbar's own
239
+ pane gutter is its indent, so it lines up with the depth-0 rows. */}
240
+ <div className={css.treeLead}>
241
+ {onCheck !== undefined ? (
242
+ <CheckBox
243
+ state={tree.check}
244
+ label={tree.check === 'on' ? t('unstageAll') : t('stageAll')}
245
+ indent={0}
246
+ // `shownFiles`, not `files`: a tick IS a git call, and the root
247
+ // one must stage exactly the rows it sits above. Reaching past a
248
+ // filter into files the pane is hiding is how "stage all" ends up
249
+ // meaning something the reader never saw.
250
+ onToggle={() => onCheck(shownFiles, tree.check)}
251
+ />
252
+ ) : null}
253
+ <span className={css.treeLabel}>
254
+ {loading === true
255
+ ? t('loading')
256
+ : `${lead !== undefined ? `${lead} · ` : ''}${filtering
257
+ ? t('filesFiltered', { shown: shownFiles.length, count: files.length })
258
+ : t('files', { count: files.length })}`}
259
+ </span>
260
+ </div>
261
+ <div className={css.treeActions} data-gs-part="tree-actions">
262
+ {/* Filtering is about the list, so it sits with the list's own two
263
+ controls rather than in the drawer chrome — and it stays lit while
264
+ a query is set, because a pane showing 6 of 140 files with no
265
+ visible reason is the one way this feature can mislead. */}
266
+ <button
267
+ type="button"
268
+ className={filterOpen || filtering ? `${css.treeIcon} ${css.treeIconOn}` : css.treeIcon}
269
+ data-gs-part="filter-files"
270
+ title={t('filterFiles')} aria-label={t('filterFiles')}
271
+ aria-pressed={filterOpen}
272
+ onClick={() => {
273
+ // Closing is also clearing. A hidden box still holding a query
274
+ // would leave the pane filtered with its only explanation
275
+ // folded away.
276
+ if (filterOpen) { setQuery(''); setFilterOpen(false); return }
277
+ setFilterOpen(true)
278
+ window.setTimeout(() => filterRef.current?.focus(), 0)
279
+ }}
280
+ ><FilterGlyph /></button>
281
+ {/* Icon-only, with the label on `title`/`aria-label`: the glyph is the
282
+ same one the rows carry, so each button previews its own result. */}
283
+ <button
284
+ type="button" className={css.treeIcon} data-gs-part="expand-all"
285
+ title={t('expandAll')} aria-label={t('expandAll')}
286
+ onClick={() => setAll(true)}
287
+ ><span className={`${css.treeIconGlyph} ${css.treeIconDown}`}>▸</span></button>
288
+ <button
289
+ type="button" className={css.treeIcon} data-gs-part="collapse-all"
290
+ title={t('collapseAll')} aria-label={t('collapseAll')}
291
+ onClick={() => setAll(false)}
292
+ ><span className={css.treeIconGlyph}>▸</span></button>
293
+ </div>
294
+ </div>
295
+ {filterOpen ? (
296
+ <div className={css.treeFilter}>
297
+ <input
298
+ ref={filterRef}
299
+ className={css.treeFilterInput}
300
+ type="text"
301
+ value={query}
302
+ placeholder={t('filterFilesPlaceholder')}
303
+ aria-label={t('filterFiles')}
304
+ spellCheck={false}
305
+ onChange={event => setQuery(event.target.value)}
306
+ onKeyDown={event => {
307
+ if (event.key !== 'Escape') return
308
+ // Escape belongs to the box while it has something to undo;
309
+ // only an already-empty box lets it through to close the drawer.
310
+ if (query.length > 0) { event.stopPropagation(); setQuery(''); return }
311
+ event.stopPropagation()
312
+ setFilterOpen(false)
313
+ }}
314
+ />
315
+ {query.length > 0 ? (
316
+ <button
317
+ type="button" className={css.treeFilterClear}
318
+ title={t('filterFilesClear')} aria-label={t('filterFilesClear')}
319
+ onClick={() => { setQuery(''); filterRef.current?.focus() }}
320
+ >×</button>
321
+ ) : null}
322
+ </div>
323
+ ) : null}
324
+ {loading === true ? (
325
+ <div className={css.treeEmpty} data-gs-part="tree-loading">{t('loading')}</div>
326
+ ) : filtering && shownFiles.length === 0 ? (
327
+ <div className={css.treeEmpty} data-gs-part="tree-no-match">{t('filterNoMatch')}</div>
328
+ ) : (
329
+ <ul className={css.tree}>
330
+ {/* A filtered tree ignores the fold state entirely: the reader asked
331
+ for these files, and leaving them behind a directory they
332
+ collapsed twenty minutes ago reads as "no matches". */}
333
+ <TreeChildren
334
+ node={tree} depth={0} active={active} collapsed={filtering ? EMPTY_COLLAPSED : effective}
335
+ onToggle={toggleOne} onSelect={onSelect} onCheck={onCheck} onDiscard={onDiscard} stageLabels={stageLabels} discardLabel={t('discardAction')}
336
+ />
337
+ </ul>
338
+ )}
339
+ {footer}
340
+ </div>
341
+ )
342
+ }
343
+
344
+ function defaultCollapsed(root: DirNode): Set<string> {
345
+ const out = new Set<string>()
346
+ const walk = (node: DirNode): void => {
347
+ for (const child of node.dirs.values()) {
348
+ if (child.fileCount > 12) out.add(child.path)
349
+ walk(child)
350
+ }
351
+ }
352
+ walk(root)
353
+ return out
354
+ }
355
+
356
+ function allDirs(node: DirNode): Set<string> {
357
+ const out = new Set<string>()
358
+ const walk = (n: DirNode): void => {
359
+ for (const child of n.dirs.values()) { out.add(child.path); walk(child) }
360
+ }
361
+ walk(node)
362
+ return out
363
+ }
364
+
365
+ /**
366
+ * One tick. A sibling of the row it belongs to rather than a child of it: a
367
+ * button inside a button is invalid HTML, and the two clicks mean different
368
+ * things — this one changes the commit set, the row opens the diff.
369
+ *
370
+ * The tick carries its row's own indent and stands at the node it includes,
371
+ * IDEA-style, rather than in a column pinned to the pane edge. A pinned column
372
+ * reads at a glance, but it detaches each tick from its node — and its first
373
+ * 10px sat underneath the drawer's edge resizer, so a click on the left half of
374
+ * a depth-0 tick dragged the drawer instead of staging anything.
375
+ */
376
+ function CheckBox({ state, label, indent, onToggle }: {
377
+ state: CheckState
378
+ label: string
379
+ /** The row's left edge, carried here so the tick stands at its own node. */
380
+ indent: number
381
+ onToggle: () => void
382
+ }): ReactNode {
383
+ const mark = state === 'on' ? css.checkMarkOn : state === 'partial' ? css.checkMarkPartial : ''
384
+ return (
385
+ <button
386
+ type="button"
387
+ role="checkbox"
388
+ aria-checked={state === 'partial' ? 'mixed' : state === 'on'}
389
+ className={css.checkBox}
390
+ style={{ marginLeft: indent }}
391
+ title={label}
392
+ aria-label={label}
393
+ onClick={onToggle}
394
+ >
395
+ <span className={`${css.checkMark} ${mark}`} aria-hidden="true">
396
+ {state === 'on' ? '✓' : state === 'partial' ? '–' : ''}
397
+ </span>
398
+ </button>
399
+ )
400
+ }
401
+
402
+ /** Every file at or under a node, for a tick that acts on a whole directory. */
403
+ function filesUnder(node: DirNode): GitFile[] {
404
+ const out = [...node.files]
405
+ for (const child of node.dirs.values()) out.push(...filesUnder(child))
406
+ return out
407
+ }
408
+
409
+ interface TreeChildrenProps {
410
+ node: DirNode
411
+ depth: number
412
+ active: string | null
413
+ /** Read-only: a filtered tree is handed a shared empty set rather than a copy. */
414
+ collapsed: ReadonlySet<string>
415
+ onToggle: (path: string) => void
416
+ onSelect: (path: string) => void
417
+ /** Add or remove files from the commit set. Undefined outside the working-tree
418
+ * view, where what a commit contains was decided long ago. */
419
+ onCheck?: (files: readonly GitFile[], state: CheckState) => void
420
+ /** Roll one file back to HEAD. Undefined outside the working-tree view for
421
+ * the same reason `onCheck` is: a commit's files are history, and there is
422
+ * nothing there to roll back. Directories never offer it — the irreversible
423
+ * action does not get a gesture that takes a subtree with it. */
424
+ onDiscard?: (file: GitFile) => void
425
+ /** Pre-translated, so the row does not have to carry `t` for two strings. */
426
+ stageLabels: { stage: string; unstage: string }
427
+ /** Label for the roll-back action, pre-translated like `stageLabels`. */
428
+ discardLabel?: string
429
+ }
430
+
431
+ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCheck, onDiscard, stageLabels, discardLabel }: TreeChildrenProps): ReactNode {
432
+ const dirNodes = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
433
+ const fileNodes = [...node.files].sort((a, b) => basePart(a.path).localeCompare(basePart(b.path)))
434
+ const checkColumn = onCheck !== undefined ? TREE_CHECK_W : 0
435
+ // With ticks, the tick carries this indent and the button starts at 0; without
436
+ // them (history, compare) the button carries it, as it always did.
437
+ const indent = TREE_BASE_INDENT + depth * TREE_INDENT
438
+ return (
439
+ <>
440
+ {dirNodes.map(dir => {
441
+ const open = !collapsed.has(dir.path)
442
+ const containsActive = active !== null && active.startsWith(`${dir.path}/`)
443
+ return (
444
+ <li key={dir.path} className={css.treeDirLi}>
445
+ <div className={css.treeRow}>
446
+ {onCheck !== undefined ? (
447
+ <CheckBox
448
+ state={dir.check}
449
+ label={dir.check === 'on' ? stageLabels.unstage : stageLabels.stage}
450
+ indent={indent}
451
+ onToggle={() => onCheck(filesUnder(dir), dir.check)}
452
+ />
453
+ ) : null}
454
+ <button
455
+ type="button"
456
+ className={`${css.treeDir} ${containsActive ? css.treeDirActive : ''}`}
457
+ style={{ paddingLeft: onCheck !== undefined ? 0 : indent }}
458
+ onClick={() => onToggle(dir.path)}
459
+ title={dir.path}
460
+ >
461
+ <span className={`${css.chevron} ${open ? css.chevronOpen : ''}`}>▸</span>
462
+ <PathDirGlyph />
463
+ <span className={css.treeDirName}>{dir.name}</span>
464
+ <span className={css.treeDirCount}>{dir.fileCount}</span>
465
+ <span className={css.treeDirCounts}>
466
+ {dir.added > 0 ? <span className={css.fileCountAdd}>+{dir.added}</span> : null}
467
+ {dir.deleted > 0 ? <span className={css.fileCountDel}>−{dir.deleted}</span> : null}
468
+ </span>
469
+ </button>
470
+ </div>
471
+ {open ? (
472
+ <ul
473
+ className={css.treeSub}
474
+ // The rail hangs off the parent's chevron, which sits after the
475
+ // row's tick — so the tick's width is part of the offset.
476
+ style={{ [RAIL_VAR]: `${checkColumn + TREE_BASE_INDENT + depth * TREE_INDENT + TREE_RAIL_OFFSET}px` } as CSSProperties}
477
+ >
478
+ <TreeChildren node={dir} depth={depth + 1} active={active} collapsed={collapsed} onToggle={onToggle} onSelect={onSelect} onCheck={onCheck} onDiscard={onDiscard} stageLabels={stageLabels} discardLabel={discardLabel} />
479
+ </ul>
480
+ ) : null}
481
+ </li>
482
+ )
483
+ })}
484
+ {fileNodes.map(file => {
485
+ const check = fileCheckState(file)
486
+ return (
487
+ <li key={file.path} className={css.fileLi}>
488
+ {onCheck !== undefined ? (
489
+ <CheckBox
490
+ state={check}
491
+ label={check === 'on' ? stageLabels.unstage : stageLabels.stage}
492
+ indent={indent}
493
+ onToggle={() => onCheck([file], check)}
494
+ />
495
+ ) : null}
496
+ <button
497
+ type="button"
498
+ className={active === file.path ? `${css.file} ${css.fileActive}` : css.file}
499
+ style={{ paddingLeft: (onCheck !== undefined ? 0 : indent) + TREE_LEAF_OFFSET }}
500
+ onClick={() => onSelect(file.path)}
501
+ title={file.previousPath !== undefined ? `${file.previousPath} → ${file.path}` : file.path}
502
+ >
503
+ {/* Icon then name, status on the right with the line counts.
504
+ The badge used to lead, which put two glyphs side by side the
505
+ moment the row gained a file icon; both IDEA and VS Code read
506
+ left-to-right as "what this is, then what happened to it",
507
+ and the badge still lands in an aligned column — `.filePath`
508
+ is the only flexible child. */}
509
+ <PathFileGlyph path={file.path} />
510
+ <span className={css.filePath}>{basePart(file.path)}</span>
511
+ {file.binary ? <span className={css.fileBinary}>BIN</span> : (
512
+ <span className={css.fileCounts}>
513
+ <span className={css.fileCountAdd}>{file.addedLines > 0 ? `+${file.addedLines}` : ''}</span>{' '}
514
+ <span className={css.fileCountDel}>{file.deletedLines > 0 ? `−${file.deletedLines}` : ''}</span>
515
+ </span>
516
+ )}
517
+ <span className={`${css.fileStatus} ${STATUS_BADGE[file.status]}`}>{statusGlyph(file.status)}</span>
518
+ </button>
519
+ {onDiscard !== undefined ? (
520
+ /* Outside the row button, not inside it: a button in a button is
521
+ invalid, and clicking roll-back must not also select the file. */
522
+ <button
523
+ type="button"
524
+ className={css.fileDiscard}
525
+ title={discardLabel}
526
+ aria-label={`${discardLabel ?? ''} ${file.path}`}
527
+ onClick={event => { event.stopPropagation(); onDiscard(file) }}
528
+ ><RollbackGlyph /></button>
529
+ ) : null}
530
+ </li>
531
+ )
532
+ })}
533
+ </>
534
+ )
535
+ }
536
+
537
+ function statusGlyph(status: GitFileStatus): string {
538
+ switch (status) {
539
+ case 'added': return 'A'
540
+ case 'untracked': return 'U'
541
+ case 'modified': return 'M'
542
+ case 'renamed': return 'R'
543
+ case 'deleted': return 'D'
544
+ }
545
+ }
546
+
547
+ function basePart(path: string): string {
548
+ const cut = path.lastIndexOf('/')
549
+ return cut >= 0 ? path.slice(cut + 1) : path
550
+ }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * The drawer's window controls, as glyphs.
5
+ *
6
+ * Four words in four identical pills read as a paragraph, not as controls — and
7
+ * three of these four are the actions every window on the machine already spells
8
+ * with a picture. Bootstrap Icons (MIT), one 16 viewBox, one fill, so the row
9
+ * reads as a set rather than four drawings that happen to sit together.
10
+ */
11
+ const CHROME_GLYPH = {
12
+ settings: 'M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z',
13
+ maximize: 'M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z',
14
+ restore: 'M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z',
15
+ // Single arrow, deliberately: the sync bar's Fetch is the two-arrow circle,
16
+ // and at 14px the only thing telling them apart is the arrow count.
17
+ refresh: 'M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z',
18
+ close: 'M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z',
19
+ } as const
20
+
21
+ export function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
22
+ return (
23
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
24
+ <path d={CHROME_GLYPH[of]} />
25
+ </svg>
26
+ )
27
+ }