@young1lin/dsh-ui-gitworkbench 0.1.3 → 0.1.5

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.
@@ -57,6 +57,16 @@ import {
57
57
  } from './themes.ts'
58
58
  import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
59
59
  import { layoutGraph, type GraphRow } from './commit-graph.ts'
60
+ import { formatCommitDate } from './commit-filter.ts'
61
+ import { chipsFromFilter, emptyQueryFilter, parseLogQuery, removeChip, serializeLogQuery } from './log-filter-query.ts'
62
+ import { buildDirTree, searchPaths, type DirEntry } from './dir-tree.ts'
63
+ import { nextAfterPlan, type DiscardAnswer, type DiscardPreview } from './discard-flow.ts'
64
+ import { filterFiles } from './file-filter.ts'
65
+ import { addPath, buildIndex, checkedState, isCovered, removePath } from './path-select.ts'
66
+ import { inCalRange, localTodayIso, monthGrid, weekdayLabels } from './calendar.ts'
67
+ import { NO_PATHS, preferredFile } from './active-file.ts'
68
+ import type { LogFilter } from '../log-filter.ts'
69
+ import type { AuthorEntry } from '../shortlog.ts'
60
70
  import {
61
71
  fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
62
72
  type CheckState, type Tick, type TickAction,
@@ -91,6 +101,12 @@ export interface GitCommit {
91
101
  readonly when: string
92
102
  /** Everything after the subject. Empty string when the commit has none. */
93
103
  readonly body: string
104
+ /** Author name (`%an`). Optional only because a pre-0.1.4 host half sends none. */
105
+ readonly authorName?: string
106
+ /** Committer name (`%cn`); equals the author except on rebases and patches a maintainer applied. */
107
+ readonly committerName?: string
108
+ /** Committer date, strict ISO 8601 (`%cI`) — the exact moment `when` summarizes. */
109
+ readonly dateIso?: string
94
110
  /** Abbreviated parent hashes, first parent first — the graph's edges. */
95
111
  readonly parents?: readonly string[]
96
112
  /** Branch and tag names pointing here, already stripped of git's decoration syntax. */
@@ -184,7 +200,7 @@ export interface GitOpResult {
184
200
  }
185
201
 
186
202
  /** The host endpoints under `gitWorkbench/` that change something. */
187
- export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push'
203
+ export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile'
188
204
 
189
205
  /** Extra arguments an operation needs beyond the worktree path. */
190
206
  export interface GitOpPayload {
@@ -192,8 +208,17 @@ export interface GitOpPayload {
192
208
  readonly message?: string
193
209
  readonly amend?: boolean
194
210
  readonly mode?: 'ff-only' | 'rebase' | 'merge'
211
+ /** `discardFile` only, and deliberately singular: the one irreversible thing
212
+ * the drawer does takes one file per call, so a mistaken click costs one
213
+ * file. */
214
+ readonly path?: string
215
+ /** `discardFile` only: the effect the confirmation stated. The host refuses
216
+ * if the file changed underneath the dialog and now means something else. */
217
+ readonly expectedEffect?: string
195
218
  }
196
219
 
220
+ export type { DiscardAnswer, DiscardNext, DiscardPreview } from './discard-flow.ts'
221
+
197
222
  /** Translate a key of this plugin's namespace, with optional `{name}` params. */
198
223
  type Translate = (key: string, params?: Record<string, string | number>) => string
199
224
 
@@ -205,12 +230,20 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
205
230
  /** Binding only, no git — the probe the shut chip can afford to poll. */
206
231
  readonly fetchSessionBinding: (sessionId: string, signal: AbortSignal) => Promise<{ worktreePath: string | null; name: string | null } | null>
207
232
  readonly fetchCommitStats: (worktreePath: string | undefined, hash: string, signal: AbortSignal) => Promise<WorkbenchStats | null>
208
- readonly fetchCommits: (worktreePath: string | undefined, ref: string, skip: number, limit: number, signal: AbortSignal) => Promise<{ commits: GitCommit[]; hasMore: boolean } | null>
233
+ readonly fetchCommits: (worktreePath: string | undefined, ref: string, skip: number, limit: number, filter: LogFilter, signal: AbortSignal) => Promise<{ commits: GitCommit[]; hasMore: boolean; error?: string } | null>
234
+ /** Author roster for the filter popup's user picker, busiest first — for the
235
+ * ref the history walks, so every listed author actually has commits there. */
236
+ readonly fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
237
+ /** Every path on HEAD — the path picker's raw material. */
238
+ readonly fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<{ paths: string[]; truncated: boolean } | null>
209
239
  readonly fetchCompare: (worktreePath: string | undefined, base: string, head: string, signal: AbortSignal) => Promise<WorkbenchStats | null>
210
240
  readonly fetchStyle: (worktreePath: string | undefined, signal: AbortSignal) => Promise<StyleSettings | null>
211
241
  readonly saveStyle: (worktreePath: string | undefined, scope: StyleScope, entry: StyleEntry, signal: AbortSignal) => Promise<{ ok: boolean; error?: string }>
212
242
  readonly fetchSync: (worktreePath: string | undefined, signal: AbortSignal) => Promise<SyncStatus | null>
213
243
  readonly runGitOp: (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal) => Promise<GitOpResult>
244
+ /** What rolling this file back WOULD do, read fresh so the confirmation
245
+ * states the real consequence rather than one derived from a polled row. */
246
+ readonly fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardAnswer>
214
247
  }
215
248
 
216
249
  /**
@@ -415,7 +448,7 @@ const STATUS_BADGE: Record<GitFileStatus, string> = {
415
448
  renamed: css.stRenamed, deleted: css.stDeleted,
416
449
  }
417
450
 
418
- export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp }: Props) {
451
+ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
419
452
  const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
420
453
  state?.byId?.[sessionId]?.cwd) as string | undefined
421
454
  /** Whether the session's agent has a turn in flight — the store mirrors it
@@ -463,6 +496,25 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
463
496
  /** First page of the history list in flight — the pane says "loading", not
464
497
  * "no commit history", which is a claim about the repository. */
465
498
  const [historyLoading, setHistoryLoading] = useState(false)
499
+ /** Why the history list is empty when it is git's word, not the log's: a
500
+ * bad filter pattern or date, with the stderr tail to say so. */
501
+ const [historyError, setHistoryError] = useState<string | null>(null)
502
+ /** The history filter box's raw text. Parsed into the LogFilter the host
503
+ * compiles into git log arguments — the funnel popup writes here too: one
504
+ * grammar, one filter, however the criterion arrived. */
505
+ const [historyQuery, setHistoryQuery] = useState('')
506
+ const historyFilterKey = serializeLogQuery(parseLogQuery(historyQuery))
507
+ /** Debounced by KEY, not by text: "liam " and "liam" are the same query and
508
+ * must not refetch. 300ms is a keystroke's pause, not a page's wait. */
509
+ const [liveFilterKey, setLiveFilterKey] = useState('')
510
+ useEffect(() => {
511
+ const id = window.setTimeout(() => setLiveFilterKey(historyFilterKey), 300)
512
+ return () => window.clearTimeout(id)
513
+ }, [historyFilterKey])
514
+ const liveFilter = useMemo(
515
+ () => (liveFilterKey.length === 0 ? emptyQueryFilter() : parseLogQuery(liveFilterKey)),
516
+ [liveFilterKey],
517
+ )
466
518
  const [loadingMore, setLoadingMore] = useState(false)
467
519
  /** In-flight marker for paging, read synchronously — see {@link loadMoreCommits}. */
468
520
  const loadingRef = useRef(false)
@@ -823,17 +875,19 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
823
875
  setCommitHash(null)
824
876
  setCommitStats(null)
825
877
  setHistoryLoading(true)
826
- fetchCommits(statsPath, effectiveHistoryRef, 0, HISTORY_PAGE, ctrl.signal)
878
+ setHistoryError(null)
879
+ fetchCommits(statsPath, effectiveHistoryRef, 0, HISTORY_PAGE, liveFilter, ctrl.signal)
827
880
  .then(page => {
828
881
  if (!alive) return
829
882
  setHistoryLoading(false)
830
883
  if (page === null) return
831
884
  setHistoryCommits(page.commits)
832
885
  setHistoryHasMore(page.hasMore)
886
+ setHistoryError(page.error ?? null)
833
887
  })
834
888
  .catch(() => { if (alive) setHistoryLoading(false) })
835
889
  return () => { alive = false; ctrl.abort() }
836
- }, [open, statsPath, effectiveHistoryRef, fetchCommits, gen])
890
+ }, [open, statsPath, effectiveHistoryRef, fetchCommits, gen, liveFilter])
837
891
 
838
892
  // Never leave the history pane empty: with a list loaded and nothing picked,
839
893
  // the newest commit is the selection.
@@ -972,6 +1026,19 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
972
1026
  }
973
1027
  }
974
1028
 
1029
+ /**
1030
+ * Put a failure the drawer produced itself into the same banner git failures
1031
+ * use.
1032
+ *
1033
+ * Roll-back is the caller: it asks the host what a file's roll-back would do
1034
+ * before it does anything, and that question can fail on its own, with no
1035
+ * `runOp` behind it to report through. Everything else the drawer does is
1036
+ * either a git call or has a visible result of its own.
1037
+ */
1038
+ const reportOpError = (op: GitOpName, error: string): void => {
1039
+ setOpResult({ op, result: { ok: false, failure: 'unknown', error } })
1040
+ }
1041
+
975
1042
  /** Wait for the git lock, so a queued tick batch waits out a heavy
976
1043
  * operation instead of being refused by it. */
977
1044
  const waitNotBusy = async (): Promise<void> => {
@@ -1152,7 +1219,7 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1152
1219
  loadingRef.current = true
1153
1220
  setLoadingMore(true)
1154
1221
  const ctrl = new AbortController()
1155
- fetchCommits(statsPath, effectiveHistoryRef, historyCommits.length, HISTORY_PAGE, ctrl.signal)
1222
+ fetchCommits(statsPath, effectiveHistoryRef, historyCommits.length, HISTORY_PAGE, liveFilter, ctrl.signal)
1156
1223
  .then(page => {
1157
1224
  if (page === null) return
1158
1225
  setHistoryCommits(prev => [...prev, ...page.commits])
@@ -1197,6 +1264,11 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1197
1264
  onLoadMoreCommits={loadMoreCommits}
1198
1265
  historyRef={effectiveHistoryRef}
1199
1266
  onHistoryRef={setHistoryRef}
1267
+ historyQuery={historyQuery}
1268
+ onHistoryQuery={setHistoryQuery}
1269
+ historyError={historyError}
1270
+ fetchAuthors={fetchAuthors}
1271
+ fetchRepoTree={fetchRepoTree}
1200
1272
  branches={branches}
1201
1273
  worktreeBranches={worktreeBranches}
1202
1274
  branchesTruncated={branchesTruncated}
@@ -1238,6 +1310,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1238
1310
  busy={busy}
1239
1311
  opResult={opResult}
1240
1312
  runOp={runOp}
1313
+ fetchDiscardPlan={fetchDiscardPlan}
1314
+ onOpError={reportOpError}
1241
1315
  pendingTicks={pendingTicks}
1242
1316
  onTick={queueTicks}
1243
1317
  fetchFileDiff={fetchDiffForView}
@@ -1353,6 +1427,16 @@ interface DrawerProps {
1353
1427
  /** Ref the history list walks. */
1354
1428
  historyRef: string
1355
1429
  onHistoryRef: (ref: string) => void
1430
+ /** The history filter box's text — the single source of the LogFilter both
1431
+ * the box's grammar and the funnel popup write into. */
1432
+ historyQuery: string
1433
+ onHistoryQuery: (query: string) => void
1434
+ /** git's complaint when the log failed (bad pattern/date), verbatim. */
1435
+ historyError: string | null
1436
+ /** Author roster for the funnel popup's user picker. */
1437
+ fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
1438
+ /** Every path on HEAD — the path picker's raw material. */
1439
+ fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<{ paths: string[]; truncated: boolean } | null>
1356
1440
  /** Every local branch — the ref pickers' options, worktree or not. */
1357
1441
  branches: readonly string[]
1358
1442
  /** Branches that have a worktree, grouped to the top of every picker. */
@@ -1419,6 +1503,9 @@ interface DrawerProps {
1419
1503
  /** The last write operation's outcome, or null once a new one starts. */
1420
1504
  opResult: { op: GitOpName; result: GitOpResult } | null
1421
1505
  runOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
1506
+ fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardAnswer>
1507
+ /** Say why an operation the drawer started did nothing. */
1508
+ onOpError: (op: GitOpName, error: string) => void
1422
1509
  /** Ticks awaiting their git call, keyed by path — overlaid over the file
1423
1510
  * list so the click is on screen before git confirms it. */
1424
1511
  pendingTicks: ReadonlyMap<string, TickAction>
@@ -1432,7 +1519,7 @@ interface DrawerProps {
1432
1519
  onCollapsedChange: (next: Set<string>) => void
1433
1520
  }
1434
1521
 
1435
- function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, 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, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
1522
+ 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, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
1436
1523
  // Empty stand-in while a commit's change set loads, so every hook below keeps a
1437
1524
  // stable shape and the panes simply render nothing.
1438
1525
  const body = shown ?? EMPTY_STATS
@@ -1445,12 +1532,22 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1445
1532
  * while a refresh lands over it. Derived once and handed to both the header
1446
1533
  * and the tree: spelling it twice is what let the header get it wrong. */
1447
1534
  const pending = showsPending(treeLoading, body.files.length)
1535
+ /** The history filter's paths, which decide what a commit OPENS on. Only the
1536
+ * history tab has one: the changes and compare trees are not filtered, and
1537
+ * steering their default selection by a query the reader cannot see from
1538
+ * there would be a spooky action. */
1539
+ const activeFilterPaths = useMemo(
1540
+ () => tab === 'history' ? parseLogQuery(historyQuery).paths : NO_PATHS,
1541
+ [tab, historyQuery],
1542
+ )
1448
1543
  // A selection the current source no longer lists (e.g. after a source or tab
1449
- // switch) falls back to the first file — never a dangling highlight.
1450
- const active = selected !== null && body.files.some(file => file.path === selected)
1451
- ? selected
1452
- : body.files[0]?.path ?? null
1544
+ // switch) falls back to the filtered file, else the first — never a dangling
1545
+ // highlight. See `active-file.ts` for the order and the reasoning.
1546
+ const active = preferredFile(body.files, activeFilterPaths, selected)
1453
1547
  const activeFile = body.files.find(file => file.path === active) ?? null
1548
+ /** The file whose roll-back is being asked about; `plan` is null while the
1549
+ * host is still being asked what it would do. */
1550
+ const [discardPending, setDiscardPending] = useState<{ file: GitFile; plan: DiscardPreview | null } | null>(null)
1454
1551
  const [fetched, setFetched] = useState<Map<string, string>>(new Map())
1455
1552
  const [loading, setLoading] = useState(false)
1456
1553
  const bundled = active === null ? '' : segments.get(active) ?? ''
@@ -1481,6 +1578,46 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1481
1578
 
1482
1579
  const selectAndReveal = (path: string): void => onSelect(path)
1483
1580
 
1581
+ /**
1582
+ * Roll-back, in two steps that are deliberately not one.
1583
+ *
1584
+ * The click asks the host what rolling this file back would DO, and only the
1585
+ * answer opens the dialog. Deriving the wording from the clicked row instead
1586
+ * would mean describing a file as the last poll saw it: the difference
1587
+ * between "goes back to its committed content" and "leaves the disk and
1588
+ * cannot come back" is the entire subject of the question being asked, and it
1589
+ * is exactly the thing a stale row gets wrong.
1590
+ *
1591
+ * `recover` — a deleted file coming back — shows no dialog at all. It loses
1592
+ * nothing, and a confirmation in front of a pure gain is how people learn to
1593
+ * dismiss confirmations without reading them.
1594
+ *
1595
+ * Every other answer is `nextAfterPlan`'s to classify, and the one it exists
1596
+ * for is failure: a plan that never arrives reports, where it used to leave
1597
+ * the reader looking at a button that did nothing.
1598
+ */
1599
+ const askDiscard = (file: GitFile): void => {
1600
+ setDiscardPending({ file, plan: null })
1601
+ void (async () => {
1602
+ const next = nextAfterPlan(await fetchDiscardPlan(statsPath, file.path, new AbortController().signal))
1603
+ if (next.kind === 'confirm') {
1604
+ setDiscardPending({ file, plan: next.plan })
1605
+ return
1606
+ }
1607
+ setDiscardPending(null)
1608
+ if (next.kind === 'run') void runOp('discardFile', { path: file.path, expectedEffect: next.effect })
1609
+ else if (next.kind === 'refresh') onRefresh()
1610
+ else onOpError('discardFile', next.error)
1611
+ })()
1612
+ }
1613
+
1614
+ const confirmDiscard = (): void => {
1615
+ const pending = discardPending
1616
+ if (pending === null || pending.plan === null) return
1617
+ setDiscardPending(null)
1618
+ void runOp('discardFile', { path: pending.file.path, expectedEffect: pending.plan.effect })
1619
+ }
1620
+
1484
1621
  const drawerRef = useRef<HTMLDivElement>(null)
1485
1622
  const commitsRef = useRef<HTMLDivElement>(null)
1486
1623
  const treeRef = useRef<HTMLDivElement>(null)
@@ -1684,6 +1821,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1684
1821
  t={t} label={t('historyRefLabel')} value={historyRef}
1685
1822
  branches={branches} worktreeBranches={worktreeBranches} truncated={branchesTruncated}
1686
1823
  onPick={onHistoryRef}
1824
+ allLabel={t('allBranches')}
1687
1825
  />
1688
1826
  </div>
1689
1827
  ) : null}
@@ -1713,6 +1851,13 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1713
1851
  hasMore={hasMoreCommits}
1714
1852
  loadingMore={loadingMore}
1715
1853
  onLoadMore={onLoadMoreCommits}
1854
+ query={historyQuery}
1855
+ onQueryChange={onHistoryQuery}
1856
+ error={historyError}
1857
+ statsPath={statsPath}
1858
+ refName={historyRef}
1859
+ fetchAuthors={fetchAuthors}
1860
+ fetchRepoTree={fetchRepoTree}
1716
1861
  />
1717
1862
  <PaneDivider label={t('resizeCommits')} onDrag={paneDrag('commits', commitsRef)} />
1718
1863
  </>
@@ -1720,6 +1865,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1720
1865
  <div ref={treeRef} className={css.treeCol} style={paneStyle(panes.tree)} data-gs-part="tree">
1721
1866
  <FileTree
1722
1867
  t={t}
1868
+ scopeKey={viewKey}
1723
1869
  loading={pending}
1724
1870
  lead={tab === 'changes' ? t('workingTree') : undefined}
1725
1871
  files={tickedFiles}
@@ -1744,6 +1890,10 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1744
1890
  const paths = pathsFor(checked, action)
1745
1891
  if (paths.length > 0) onTick(action, paths)
1746
1892
  } : undefined}
1893
+ // Roll back, likewise working-tree only. The click does not act:
1894
+ // it asks the host what the act WOULD be, and that answer is what
1895
+ // the dialog states. See `askDiscard`.
1896
+ onDiscard={tab === 'changes' ? askDiscard : undefined}
1747
1897
  footer={tab === 'changes'
1748
1898
  ? (
1749
1899
  <CommitBox
@@ -1776,6 +1926,75 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1776
1926
  )}
1777
1927
  </div>
1778
1928
  </div>
1929
+ {discardPending?.plan != null ? (
1930
+ <DiscardConfirm
1931
+ t={t}
1932
+ file={discardPending.file}
1933
+ plan={discardPending.plan}
1934
+ onCancel={() => setDiscardPending(null)}
1935
+ onConfirm={confirmDiscard}
1936
+ />
1937
+ ) : null}
1938
+ </div>
1939
+ </div>
1940
+ )
1941
+ }
1942
+
1943
+ /**
1944
+ * The one dialog in this drawer, because this is the one act it cannot undo.
1945
+ *
1946
+ * It never asks a generic "are you sure": the body names the file and states
1947
+ * which of the three consequences is about to happen, in the host's own reading
1948
+ * of that file taken moments ago. Cancel holds the initial focus and Escape
1949
+ * closes, because the default answer to an irreversible question is no.
1950
+ *
1951
+ * There is deliberately no "don't ask again". This is the only path in the
1952
+ * drawer with nothing behind it, and a checkbox whose whole function is to
1953
+ * switch off the last guard is a feature that eventually gets clicked.
1954
+ */
1955
+ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
1956
+ t: Translate
1957
+ file: GitFile
1958
+ plan: DiscardPreview
1959
+ onCancel: () => void
1960
+ onConfirm: () => void
1961
+ }): ReactNode {
1962
+ const cancelRef = useRef<HTMLButtonElement>(null)
1963
+ useEffect(() => { cancelRef.current?.focus() }, [])
1964
+ useEffect(() => {
1965
+ // Capture phase: the drawer's own Escape handler closes the whole drawer,
1966
+ // and answering a question about deleting a file should not also dismiss
1967
+ // the thing that asked it.
1968
+ const onKey = (event: KeyboardEvent): void => {
1969
+ if (event.key !== 'Escape') return
1970
+ event.stopPropagation()
1971
+ onCancel()
1972
+ }
1973
+ window.addEventListener('keydown', onKey, true)
1974
+ return () => { window.removeEventListener('keydown', onKey, true) }
1975
+ }, [onCancel])
1976
+
1977
+ const body = plan.effect === 'delete'
1978
+ ? t('discardBodyDelete', { path: file.path })
1979
+ : plan.effect === 'unrename'
1980
+ ? t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
1981
+ : t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
1982
+
1983
+ return (
1984
+ <div className={css.confirmScrim} onClick={onCancel}>
1985
+ <div
1986
+ className={css.confirmBox}
1987
+ role="alertdialog"
1988
+ aria-modal="true"
1989
+ aria-label={t('discardTitle')}
1990
+ onClick={event => event.stopPropagation()}
1991
+ >
1992
+ <div className={css.confirmTitle}>{t('discardTitle')}</div>
1993
+ <div className={css.confirmBody}>{body}</div>
1994
+ <div className={css.confirmActions}>
1995
+ <button ref={cancelRef} type="button" className={css.btn} onClick={onCancel}>{t('discardCancel')}</button>
1996
+ <button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('discardConfirm')}</button>
1997
+ </div>
1779
1998
  </div>
1780
1999
  </div>
1781
2000
  )
@@ -2260,7 +2479,13 @@ function useDismissable(open: boolean, setOpen: Dispatch<SetStateAction<boolean>
2260
2479
  * checked-out branch is the likeliest thing to want. Enter takes the first
2261
2480
  * match, so a distinctive substring plus Enter reaches any branch in the list.
2262
2481
  */
2263
- function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onPick }: {
2482
+ /** Sentinel ref meaning "walk every ref" same string the host special-cases
2483
+ * into `--all`. A real ref cannot begin with a dash, so it collides with
2484
+ * nothing; defined separately on both halves (client bundles import no host
2485
+ * values), tied by this comment and the probe. */
2486
+ const ALL_REFS = '--all'
2487
+
2488
+ function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onPick, allLabel }: {
2264
2489
  t: Translate
2265
2490
  label: string
2266
2491
  value: string
@@ -2270,6 +2495,10 @@ function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onP
2270
2495
  /** Whether the host cut the branch list short. */
2271
2496
  truncated: boolean
2272
2497
  onPick: (ref: string) => void
2498
+ /** When set, an "all branches" entry is offered above the list and shown for
2499
+ * the {@link ALL_REFS} sentinel — the history picker's answer to "search
2500
+ * must not require knowing which branch holds the commit". */
2501
+ allLabel?: string
2273
2502
  }): ReactNode {
2274
2503
  const [open, setOpen] = useState(false)
2275
2504
  const [query, setQuery] = useState('')
@@ -2312,7 +2541,7 @@ function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onP
2312
2541
  title={value.length > 0 ? value : undefined}
2313
2542
  onClick={() => setOpen(isOpen => !isOpen)}
2314
2543
  >
2315
- <Elided text={value.length > 0 ? value : '—'} className={css.refValue} />
2544
+ <Elided text={value === ALL_REFS && allLabel !== undefined ? allLabel : (value.length > 0 ? value : '—')} className={css.refValue} />
2316
2545
  <span className={css.refCaret}>▾</span>
2317
2546
  </button>
2318
2547
  {open ? (
@@ -2326,11 +2555,24 @@ function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onP
2326
2555
  onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
2327
2556
  />
2328
2557
  <div className={css.refList} role="listbox" aria-label={label}>
2558
+ {allLabel !== undefined && (needle.length === 0 || allLabel.toLowerCase().includes(needle)) ? (
2559
+ <button
2560
+ type="button"
2561
+ role="option"
2562
+ aria-selected={value === ALL_REFS}
2563
+ className={value === ALL_REFS ? `${css.refRow} ${css.refRowActive}` : css.refRow}
2564
+ title={allLabel}
2565
+ onClick={() => choose(ALL_REFS)}
2566
+ >
2567
+ <span className={css.refRowSpacer} />
2568
+ <Elided text={allLabel} className={css.refRowName} />
2569
+ </button>
2570
+ ) : null}
2329
2571
  {checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refWorktrees')}</div> : null}
2330
2572
  {checkedOut.map(ref => row(ref, true))}
2331
2573
  {checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refBranches')}</div> : null}
2332
2574
  {rest.map(ref => row(ref, false))}
2333
- {matched.length === 0 ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
2575
+ {matched.length === 0 && !(allLabel !== undefined && needle.length > 0 && allLabel.toLowerCase().includes(needle)) ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
2334
2576
  </div>
2335
2577
  <div className={css.refFoot}>
2336
2578
  {t('refCount', { shown: matched.length, total: branches.length })}
@@ -2430,6 +2672,53 @@ function SyncGlyph({ of }: { of: keyof typeof SYNC_GLYPH }): ReactNode {
2430
2672
  )
2431
2673
  }
2432
2674
 
2675
+ /**
2676
+ * Filter this list: a magnifier, not the funnel above the commit list. The two
2677
+ * are deliberately different glyphs because they do different things — the
2678
+ * funnel asks git for a different set of commits, this only hides rows already
2679
+ * on screen — and the drawer shows both at once.
2680
+ */
2681
+ function FilterGlyph(): ReactNode {
2682
+ return (
2683
+ <svg
2684
+ width="13" height="13" viewBox="0 0 16 16"
2685
+ fill="none" stroke="currentColor" strokeWidth="1.25"
2686
+ strokeLinecap="round" strokeLinejoin="round"
2687
+ aria-hidden="true"
2688
+ >
2689
+ <circle cx="7" cy="7" r="4" />
2690
+ <path d="M10 10l3.5 3.5" />
2691
+ </svg>
2692
+ )
2693
+ }
2694
+
2695
+ /** Nothing folded. A constant so the filtered tree does not allocate a new Set
2696
+ * on every render and re-run `TreeChildren`'s memo. */
2697
+ const EMPTY_COLLAPSED: ReadonlySet<string> = new Set<string>()
2698
+
2699
+ /**
2700
+ * Roll back: the counter-clockwise arc every editor and VCS uses for undo,
2701
+ * drawn in the same New UI idiom as the node glyphs beside it — 16px grid,
2702
+ * 1px stroke, no fill — so the row does not mix an outlined file icon with a
2703
+ * solid action icon.
2704
+ */
2705
+ function RollbackGlyph(): ReactNode {
2706
+ return (
2707
+ <svg
2708
+ width="14" height="14" viewBox="0 0 16 16"
2709
+ fill="none" stroke="currentColor" strokeWidth="1.25"
2710
+ strokeLinecap="round" strokeLinejoin="round"
2711
+ aria-hidden="true"
2712
+ >
2713
+ {/* The arc, open at the upper left where the head goes. */}
2714
+ <path d="M3.5 6.5a5 5 0 1 0 1.9-2.2" />
2715
+ {/* The head: a corner, not a triangle — a filled arrowhead this small
2716
+ turns into a dot at 1x. */}
2717
+ <path d="M2.6 3.2v3.4h3.4" />
2718
+ </svg>
2719
+ )
2720
+ }
2721
+
2433
2722
  const PULL_MODES = ['ff-only', 'rebase', 'merge'] as const
2434
2723
  type PullMode = typeof PULL_MODES[number]
2435
2724
 
@@ -2817,6 +3106,10 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
2817
3106
  const enterTimer = useRef(0)
2818
3107
  const leaveTimer = useRef(0)
2819
3108
  const body = commit.body ?? ''
3109
+ const authorName = commit.authorName ?? ''
3110
+ const committerName = commit.committerName ?? ''
3111
+ // The viewer's own locale and timezone — that is the whole point of the line.
3112
+ const exactDate = formatCommitDate(commit.dateIso ?? '')
2820
3113
 
2821
3114
  const cancel = (): void => {
2822
3115
  window.clearTimeout(enterTimer.current)
@@ -2870,6 +3163,7 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
2870
3163
  >
2871
3164
  <span className={css.commitTop}>
2872
3165
  <code className={css.commitHash}>{commit.hash}</code>
3166
+ {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
2873
3167
  <span className={css.commitWhen}>{commit.when}</span>
2874
3168
  </span>
2875
3169
  <span className={css.commitSubjectRow}>
@@ -2902,6 +3196,19 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
2902
3196
  <span className={css.commitWhen}>{commit.when}</span>
2903
3197
  <CopyCommitButton t={t} text={commitMessageText(commit)} />
2904
3198
  </div>
3199
+ {/* Who and exactly when. The row summarizes ("3 weeks ago"); the
3200
+ hover card is where the precise question gets a precise answer —
3201
+ full date in the VIEWER's timezone, author, and the committer
3202
+ whenever git recorded someone other than the author. */}
3203
+ {authorName.length > 0 || committerName.length > 0 || exactDate.length > 0 ? (
3204
+ <div className={css.commitPopMeta}>
3205
+ {authorName.length > 0 ? <span>{t('commitAuthor')}: {authorName}</span> : null}
3206
+ {committerName.length > 0 && committerName !== authorName ? (
3207
+ <span>{t('commitCommitter')}: {committerName}</span>
3208
+ ) : null}
3209
+ {exactDate.length > 0 ? <span>{t('commitDate')}: {exactDate}</span> : null}
3210
+ </div>
3211
+ ) : null}
2905
3212
  <div className={css.commitPopSubject}>{commit.subject}</div>
2906
3213
  {body.length > 0 ? <pre className={css.commitPopBody}>{body}</pre> : null}
2907
3214
  </div>,
@@ -2911,6 +3218,207 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
2911
3218
  )
2912
3219
  }
2913
3220
 
3221
+ /** The filter's own calendar — a hand-rolled 6×7 Monday-first grid (pure
3222
+ * arithmetic in `calendar.ts`), because the native date input renders as the
3223
+ * platform's bare widget and the bundle's purity gate forbids pulling in a
3224
+ * library. Picking a day hands `yyyy-mm-dd` to the bound the segmented
3225
+ * control armed; the host expands it to the whole day. */
3226
+ function FilterCalendar({ year, month, after, before, locale, onPick, onShift }: {
3227
+ year: number
3228
+ month: number
3229
+ /** Current bounds, to mark the picked days (approxidate text never matches
3230
+ * an iso, so a preset like "1 week ago" simply marks nothing). */
3231
+ after: string
3232
+ before: string
3233
+ /** BCP-47 tag from the drawer's own dictionary (`filterLocale`), NOT the
3234
+ * browser's — those disagree the moment the UI language is not the OS one,
3235
+ * and the grid printed its month in the other language. */
3236
+ locale: string
3237
+ onPick: (iso: string) => void
3238
+ onShift: (deltaMonths: number) => void
3239
+ }): ReactNode {
3240
+ const grid = monthGrid(year, month, localTodayIso())
3241
+ const title = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long' }).format(new Date(year, month, 1))
3242
+ return (
3243
+ <div className={css.cal}>
3244
+ <div className={css.calHead}>
3245
+ <button type="button" className={css.calNav} aria-label="‹" onClick={() => onShift(-1)}>‹</button>
3246
+ <span className={css.calTitle}>{title}</span>
3247
+ <button type="button" className={css.calNav} aria-label="›" onClick={() => onShift(1)}>›</button>
3248
+ </div>
3249
+ <div className={css.calWeek}>
3250
+ {weekdayLabels(locale).map((label, index) => <span key={index}>{label}</span>)}
3251
+ </div>
3252
+ <div className={css.calGrid}>
3253
+ {grid.flat().map(cell => cell === null ? null : (
3254
+ <button
3255
+ key={cell.iso}
3256
+ type="button"
3257
+ aria-label={cell.iso}
3258
+ title={cell.iso}
3259
+ className={[
3260
+ cell.inMonth ? '' : css.calOut,
3261
+ cell.isToday ? css.calToday : '',
3262
+ // Between the bounds, not one of them: the two endpoints alone
3263
+ // never showed which days the filter actually admits. Both
3264
+ // bounds are iso here or the comparison is simply false, which
3265
+ // is what an approxidate preset should render as.
3266
+ inCalRange(cell.iso, after, before) ? css.calIn : '',
3267
+ cell.iso === after || cell.iso === before ? css.calMark : '',
3268
+ ].filter(cls => cls.length > 0).join(' ')}
3269
+ onClick={() => onPick(cell.iso)}
3270
+ ><span>{cell.day}</span></button>
3271
+ ))}
3272
+ </div>
3273
+ </div>
3274
+ )
3275
+ }
3276
+
3277
+ /**
3278
+ * The two node glyphs, in IntelliJ's New UI icon idiom: a 16px grid, 1px
3279
+ * strokes, no fill, rounded joins — outlines, where the old UI shipped filled
3280
+ * silhouettes. Hand-drawn here rather than imported, because the bundle purity
3281
+ * gate forbids an icon package and the drawer needs exactly these two; they
3282
+ * are shapes in that language, not JetBrains' own assets.
3283
+ *
3284
+ * `strokeWidth` is 1 against a viewBox that renders 1:1 at 16px, so every
3285
+ * stroke lands on a whole pixel instead of straddling two.
3286
+ *
3287
+ * Every place the drawer names a file or a directory uses these: the path
3288
+ * picker in the history filter, and the file tree behind all three tabs. The
3289
+ * CLASS names keep their `path` prefix — `scripts/verify_history_feature.py`
3290
+ * selects the picker's file rows by `label:has([class*="pathFileGlyph"])`.
3291
+ */
3292
+ function PathDirGlyph(): ReactNode {
3293
+ return (
3294
+ <svg
3295
+ className={css.pathDirGlyph}
3296
+ width="16" height="16" viewBox="0 0 16 16"
3297
+ fill="none" stroke="currentColor" strokeWidth="1"
3298
+ strokeLinejoin="round" strokeLinecap="round"
3299
+ aria-hidden="true"
3300
+ >
3301
+ {/* Body, with the tab stepping up over the left third. The step is a
3302
+ full 2px: at 1.3px it read as a rounded rectangle with a nick in it
3303
+ rather than a folder. Every straight edge sits on a .5 coordinate so
3304
+ a 1px stroke lands on one pixel instead of straddling two. */}
3305
+ <path d="M2.5 12.75V4.25A.75.75 0 0 1 3.25 3.5H6l1.6 2h5.15A.75.75 0 0 1 13.5 6.25v6.5a.75.75 0 0 1-.75.75H3.25a.75.75 0 0 1-.75-.75Z" />
3306
+ </svg>
3307
+ )
3308
+ }
3309
+
3310
+ function PathFileGlyph(): ReactNode {
3311
+ return (
3312
+ <svg
3313
+ className={css.pathFileGlyph}
3314
+ width="16" height="16" viewBox="0 0 16 16"
3315
+ fill="none" stroke="currentColor" strokeWidth="1"
3316
+ strokeLinejoin="round" strokeLinecap="round"
3317
+ aria-hidden="true"
3318
+ >
3319
+ {/* Sheet, cut back at the top-right for the fold. Narrower and one step
3320
+ taller than the folder, sharing its optical band, so the two never
3321
+ look like different-sized icons in one column. */}
3322
+ <path d="M3.5 12.75V3.25A.75.75 0 0 1 4.25 2.5H9l3.5 3.5v6.75a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75Z" />
3323
+ {/* The fold itself — the corner turned back on the sheet. */}
3324
+ <path d="M9 2.5v2.75a.75.75 0 0 0 .75.75h2.75" />
3325
+ </svg>
3326
+ )
3327
+ }
3328
+
3329
+ /** Files shown per expanded directory. The search box is the way to a file in
3330
+ * a crowded directory; the tree shows enough to browse without flooding the
3331
+ * list, and says so when it cut the tail. */
3332
+ const PATH_FILES_SHOWN = 100
3333
+
3334
+ /** Horizontal step per nesting level in the path picker. The whole indent now
3335
+ * comes from this one number: `.pathChildren` used to add a margin and a rail
3336
+ * of its own on top of it, so every level cost 29px and a 320px popover ran
3337
+ * out of width three directories deep. */
3338
+ const PATH_INDENT = 14
3339
+
3340
+ /** One level of the path picker's directory tree — directories (chevron,
3341
+ * subtree count) then their files (doc glyph, leaf rows). Collapsed subtrees
3342
+ * are not in the DOM at all, so a monorepo costs only what the reader has
3343
+ * opened. */
3344
+ /** A checkbox that also carries the tree's third state — `indeterminate` is a
3345
+ * DOM property, not an attribute, so it is set through the ref. */
3346
+ function TriStateCheckbox({ state, onChange, ariaLabel }: {
3347
+ state: 'on' | 'off' | 'partial'
3348
+ onChange: () => void
3349
+ ariaLabel: string
3350
+ }): ReactNode {
3351
+ return (
3352
+ <input
3353
+ type="checkbox"
3354
+ aria-label={ariaLabel}
3355
+ checked={state === 'on'}
3356
+ ref={el => { if (el !== null) el.indeterminate = state === 'partial' }}
3357
+ onChange={onChange}
3358
+ />
3359
+ )
3360
+ }
3361
+
3362
+ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePath }: {
3363
+ dirs: readonly DirEntry[]
3364
+ depth: number
3365
+ expanded: readonly string[]
3366
+ /** Derived on/partial/off for any row path — the single source of truth. */
3367
+ stateOf: (path: string) => 'on' | 'off' | 'partial'
3368
+ onToggleOpen: (path: string) => void
3369
+ onTogglePath: (path: string) => void
3370
+ }): ReactNode {
3371
+ return (
3372
+ <>
3373
+ {dirs.map(dir => {
3374
+ const open = expanded.includes(dir.path)
3375
+ const expandable = dir.children.length > 0 || dir.files.length > 0
3376
+ const shown = dir.files.slice(0, PATH_FILES_SHOWN)
3377
+ return (
3378
+ <div key={dir.path} className={css.pathNode}>
3379
+ <div className={css.funnelRow} style={{ paddingLeft: depth * PATH_INDENT + 4 }}>
3380
+ <button
3381
+ type="button"
3382
+ className={css.funnelChevron}
3383
+ disabled={!expandable}
3384
+ aria-expanded={open}
3385
+ onClick={() => onToggleOpen(dir.path)}
3386
+ >{expandable ? (open ? '▾' : '▸') : ''}</button>
3387
+ <TriStateCheckbox state={stateOf(dir.path)} ariaLabel={dir.path} onChange={() => onTogglePath(dir.path)} />
3388
+ <PathDirGlyph />
3389
+ <span className={css.funnelName} title={dir.path}>{dir.name}</span>
3390
+ <span className={css.funnelCount}>{dir.fileCount}</span>
3391
+ </div>
3392
+ {open ? (
3393
+ <div className={css.pathChildren}>
3394
+ <PathTreeRows
3395
+ dirs={dir.children}
3396
+ depth={depth + 1}
3397
+ expanded={expanded}
3398
+ stateOf={stateOf}
3399
+ onToggleOpen={onToggleOpen}
3400
+ onTogglePath={onTogglePath}
3401
+ />
3402
+ {shown.map(file => (
3403
+ <label key={file} className={css.funnelRow} style={{ paddingLeft: (depth + 1) * PATH_INDENT + 4 }}>
3404
+ <span className={css.funnelChevron} aria-hidden="true" />
3405
+ <TriStateCheckbox state={stateOf(`${dir.path}/${file}`)} ariaLabel={`${dir.path}/${file}`} onChange={() => onTogglePath(`${dir.path}/${file}`)} />
3406
+ <PathFileGlyph />
3407
+ <span className={css.funnelName} title={`${dir.path}/${file}`}>{file}</span>
3408
+ </label>
3409
+ ))}
3410
+ {dir.files.length > PATH_FILES_SHOWN ? (
3411
+ <div className={css.funnelMore}>+{dir.files.length - PATH_FILES_SHOWN}</div>
3412
+ ) : null}
3413
+ </div>
3414
+ ) : null}
3415
+ </div>
3416
+ )
3417
+ })}
3418
+ </>
3419
+ )
3420
+ }
3421
+
2914
3422
  /**
2915
3423
  * The commit log as its own full-height pane.
2916
3424
  *
@@ -2929,7 +3437,7 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
2929
3437
  * what GitHub and GitLens do. The observer is rebuilt whenever the list grows,
2930
3438
  * so a page too short to fill the pane immediately triggers the next one.
2931
3439
  */
2932
- function CommitList({ paneRef, style, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore }: {
3440
+ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
2933
3441
  /** The pane element, which the divider beside it measures from. Not named
2934
3442
  * `ref`: React reserves that on a function component, so it would be stripped
2935
3443
  * from props and never reach this element. */
@@ -2946,12 +3454,160 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
2946
3454
  hasMore: boolean
2947
3455
  loadingMore: boolean
2948
3456
  onLoadMore: () => void
3457
+ /** The filter box's text. Parsed here for chips; the parent debounces the
3458
+ * same parse into the server-side fetch. */
3459
+ query: string
3460
+ onQueryChange: (query: string) => void
3461
+ /** git's complaint when the log itself failed (bad pattern/date), verbatim. */
3462
+ error: string | null
3463
+ /** Which tree the author roster counts — the drawer's current source. */
3464
+ statsPath: string | undefined
3465
+ /** Which ref the roster and the list both walk — the picker's people are the
3466
+ * list's people, so a tick can never name someone with nothing to show. */
3467
+ refName: string
3468
+ fetchAuthors: (worktreePath: string | undefined, ref: string, signal: AbortSignal) => Promise<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>
3469
+ fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<{ paths: string[]; truncated: boolean } | null>
2949
3470
  }): ReactNode {
2950
3471
  const scrollRef = useRef<HTMLDivElement>(null)
2951
3472
  const sentinelRef = useRef<HTMLDivElement>(null)
3473
+ // One grammar, one filter: chips are the parsed criteria, and removing one
3474
+ // rewrites the box through that same grammar.
3475
+ const filterModel = useMemo(() => parseLogQuery(query), [query])
3476
+ const chips = chipsFromFilter(filterModel)
3477
+ // What the panel is currently asking git for. Each tab shows its own share
3478
+ // so the two sections nobody is looking at still say they hold something,
3479
+ // and the footer shows the total — the chip row that used to be the only
3480
+ // feedback sits BEHIND the popup, so the ticks looked inert until it closed.
3481
+ // A date bound counts as one criterion each; free text is the box's, not
3482
+ // the popup's, so it stays out of both.
3483
+ const dateCount = (filterModel.after.length > 0 ? 1 : 0) + (filterModel.before.length > 0 ? 1 : 0)
3484
+ const selectedCount = filterModel.users.length + filterModel.paths.length + dateCount
3485
+
3486
+ // ---- funnel popup: user picker + date bounds + path tree --------------
3487
+ const [funnelOpen, setFunnelOpen] = useState(false)
3488
+ const [authors, setAuthors] = useState<{ authors: readonly AuthorEntry[]; truncated: boolean } | null>(null)
3489
+ const [authorsQuery, setAuthorsQuery] = useState('')
3490
+ const [pathTree, setPathTree] = useState<{ dirs: readonly DirEntry[]; paths: readonly string[]; truncated: boolean } | null>(null)
3491
+ const [expandedDirs, setExpandedDirs] = useState<readonly string[]>([])
3492
+ const [pathsQuery, setPathsQuery] = useState('')
3493
+ // The popup shows ONE section at a time (tabs), so a roster of dozens
3494
+ // cannot grow the panel past the paths section — every section is reachable
3495
+ // in one click whatever the others hold.
3496
+ const [funnelSection, setFunnelSection] = useState<'users' | 'date' | 'paths'>('users')
3497
+ // The calendar's displayed month, and which bound a picked day lands in.
3498
+ const [calMonth, setCalMonth] = useState(() => { const now = new Date(); return { year: now.getFullYear(), month: now.getMonth() } })
3499
+ const [calBound, setCalBound] = useState<'after' | 'before'>('after')
3500
+
3501
+ // The panel is PORTALLED to the drawer overlay (position: fixed, clamped to
3502
+ // the viewport — the commits pane can be narrower than the panel, and an
3503
+ // absolute panel anchored at its right edge runs off-screen). Dismissal
3504
+ // therefore checks TWO refs: the anchor button and the panel itself; a
3505
+ // single useDismissable root would see every click inside the portalled
3506
+ // panel as "outside" and close it out from under the click.
3507
+ const funnelAnchorRef = useRef<HTMLDivElement>(null)
3508
+ const funnelPanelRef = useRef<HTMLDivElement>(null)
3509
+ const [funnelBox, setFunnelBox] = useState<{ top: number; left: number; maxHeight: number } | null>(null)
3510
+
3511
+ useEffect(() => {
3512
+ if (!funnelOpen) { setFunnelBox(null); return }
3513
+ const onDown = (event: MouseEvent): void => {
3514
+ const target = event.target as Node
3515
+ if (funnelAnchorRef.current?.contains(target) === true) return
3516
+ if (funnelPanelRef.current?.contains(target) === true) return
3517
+ setFunnelOpen(false)
3518
+ }
3519
+ const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') setFunnelOpen(false) }
3520
+ // Bound on the next tick: the opening click is still travelling.
3521
+ const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0)
3522
+ document.addEventListener('keydown', onKey)
3523
+ return () => {
3524
+ window.clearTimeout(id)
3525
+ document.removeEventListener('mousedown', onDown)
3526
+ document.removeEventListener('keydown', onKey)
3527
+ }
3528
+ }, [funnelOpen])
3529
+
3530
+ useEffect(() => {
3531
+ if (!funnelOpen) return
3532
+ const rect = funnelAnchorRef.current?.getBoundingClientRect()
3533
+ if (rect === undefined) return
3534
+ const width = 300
3535
+ const left = Math.max(12, Math.min(rect.left + rect.width - width, window.innerWidth - width - 12))
3536
+ const top = rect.bottom + 4
3537
+ setFunnelBox({ top, left, maxHeight: Math.max(160, window.innerHeight - top - 16) })
3538
+ }, [funnelOpen])
3539
+
3540
+ // The roster and the tree are fetched when the funnel OPENS (not when the
3541
+ // pane mounts — most visits never filter) and again when the source or the
3542
+ // ref moves: the roster counts the very history the list walks, so the two
3543
+ // can never disagree about who has commits.
3544
+ useEffect(() => {
3545
+ if (!funnelOpen) return
3546
+ const ctrl = new AbortController()
3547
+ setAuthors(null)
3548
+ setPathTree(null)
3549
+ fetchAuthors(statsPath, refName, ctrl.signal).then(roster => {
3550
+ if (!ctrl.signal.aborted) setAuthors(roster)
3551
+ }).catch(() => {})
3552
+ fetchRepoTree(statsPath, ctrl.signal).then(tree => {
3553
+ if (!ctrl.signal.aborted && tree !== null) {
3554
+ setPathTree({ dirs: buildDirTree(tree.paths), paths: tree.paths, truncated: tree.truncated })
3555
+ }
3556
+ }).catch(() => {})
3557
+ return () => { ctrl.abort() }
3558
+ }, [funnelOpen, statsPath, refName, fetchAuthors, fetchRepoTree])
3559
+
3560
+ /** Every funnel interaction writes the filter through the box's grammar, so
3561
+ * the box, the chips and the fetch can never disagree about the query. */
3562
+ const applyFilter = (next: LogFilter): void => { onQueryChange(serializeLogQuery(next)) }
3563
+ const toggleUser = (name: string): void => {
3564
+ const has = filterModel.users.includes(name)
3565
+ applyFilter({
3566
+ ...filterModel,
3567
+ users: has ? filterModel.users.filter(user => user !== name) : [...filterModel.users, name],
3568
+ })
3569
+ }
3570
+ // Checkbox-tree semantics: ticking a folder covers its subtree (and absorbs
3571
+ // the files already ticked inside it); unticking a file under a checked
3572
+ // folder cascades out. Rows DERIVE their state — on/partial/off — from the
3573
+ // set, so a folder tick visibly checks everything under it.
3574
+ const pathIndex = useMemo(
3575
+ () => (pathTree === null ? null : buildIndex(pathTree.paths)),
3576
+ [pathTree],
3577
+ )
3578
+ const pathState = (path: string): 'on' | 'off' | 'partial' =>
3579
+ pathIndex === null ? 'off' : checkedState(filterModel.paths, path, pathIndex)
3580
+ const togglePath = (path: string): void => {
3581
+ if (pathIndex === null) return
3582
+ applyFilter({
3583
+ ...filterModel,
3584
+ paths: isCovered(filterModel.paths, path)
3585
+ ? removePath(filterModel.paths, path, pathIndex)
3586
+ : addPath(filterModel.paths, path),
3587
+ })
3588
+ }
3589
+ const toggleDirOpen = (path: string): void => {
3590
+ setExpandedDirs(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path])
3591
+ }
3592
+ const needle = authorsQuery.trim().toLowerCase()
3593
+ const matchedAuthors = authors === null
3594
+ ? []
3595
+ : needle.length === 0
3596
+ ? authors.authors
3597
+ : authors.authors.filter(entry =>
3598
+ entry.name.toLowerCase().includes(needle) || entry.email.toLowerCase().includes(needle))
3599
+ const DATE_PRESETS: readonly { key: WorkbenchKey; value: string }[] = [
3600
+ { key: 'filterToday', value: 'midnight' },
3601
+ { key: 'filterLast7', value: '1 week ago' },
3602
+ { key: 'filterLast30', value: '30 days ago' },
3603
+ ]
2952
3604
  // Recomputed only when a page lands. The layout is a single pass over the
2953
3605
  // loaded prefix, and every row's geometry depends on the rows above it, so
2954
3606
  // there is nothing finer to memoise than the whole list.
3607
+ //
3608
+ // Filtering does not suspend the graph: the server returns one contiguous
3609
+ // walk of the FILTERED log, so lanes stay truthful — unlike a client-side
3610
+ // filter, which would break the very walk it draws from.
2955
3611
  const graph = useMemo(
2956
3612
  () => layoutGraph(commits.map(commit => ({ hash: commit.hash, parents: commit.parents ?? [] }))),
2957
3613
  [commits],
@@ -2978,9 +3634,251 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
2978
3634
  worse than none. */}
2979
3635
  <div className={css.paneHead}>
2980
3636
  <span className={css.paneTitle}>{t('historyLabel')}</span>
3637
+ <div className={css.funnel} ref={funnelAnchorRef}>
3638
+ <button
3639
+ type="button"
3640
+ className={funnelOpen || chips.length > 0 ? `${css.funnelButton} ${css.funnelButtonActive}` : css.funnelButton}
3641
+ aria-expanded={funnelOpen}
3642
+ onClick={() => setFunnelOpen(isOpen => !isOpen)}
3643
+ >{t('filterBy')} ▾</button>
3644
+ </div>
3645
+ <input
3646
+ className={css.commitFilter}
3647
+ type="search"
3648
+ value={query}
3649
+ onChange={event => onQueryChange(event.target.value)}
3650
+ placeholder={t('historyFilterPlaceholder')}
3651
+ aria-label={t('historyFilterPlaceholder')}
3652
+ spellCheck={false}
3653
+ />
2981
3654
  </div>
3655
+ {funnelOpen && funnelBox !== null ? createPortal(
3656
+ <div
3657
+ ref={funnelPanelRef}
3658
+ className={css.funnelPop}
3659
+ style={funnelBox}
3660
+ role="dialog"
3661
+ aria-label={t('filterBy')}
3662
+ >
3663
+ {/* One section at a time: a roster of dozens cannot grow the panel
3664
+ past the other sections, and each tab carries its own active
3665
+ count so the criteria are visible without visiting the tab. */}
3666
+ <div className={css.funnelTabs} role="tablist">
3667
+ <button
3668
+ type="button" role="tab" aria-selected={funnelSection === 'users'}
3669
+ className={funnelSection === 'users' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
3670
+ onClick={() => setFunnelSection('users')}
3671
+ >
3672
+ {t('filterUsers')}
3673
+ {filterModel.users.length > 0 ? <span className={css.funnelTabCount}>{filterModel.users.length}</span> : null}
3674
+ </button>
3675
+ <button
3676
+ type="button" role="tab" aria-selected={funnelSection === 'date'}
3677
+ className={funnelSection === 'date' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
3678
+ onClick={() => setFunnelSection('date')}
3679
+ >
3680
+ {t('filterDate')}
3681
+ {dateCount > 0 ? <span className={css.funnelTabCount}>{dateCount}</span> : null}
3682
+ </button>
3683
+ <button
3684
+ type="button" role="tab" aria-selected={funnelSection === 'paths'}
3685
+ className={funnelSection === 'paths' ? `${css.funnelTab} ${css.funnelTabActive}` : css.funnelTab}
3686
+ onClick={() => setFunnelSection('paths')}
3687
+ >
3688
+ {t('filterPaths')}
3689
+ {filterModel.paths.length > 0 ? <span className={css.funnelTabCount}>{filterModel.paths.length}</span> : null}
3690
+ </button>
3691
+ </div>
3692
+ {funnelSection === 'users' ? (
3693
+ <div className={css.funnelPane}>
3694
+ <input
3695
+ className={css.funnelSearch}
3696
+ type="search"
3697
+ value={authorsQuery}
3698
+ onChange={event => setAuthorsQuery(event.target.value)}
3699
+ placeholder={t('filterUserSearch')}
3700
+ aria-label={t('filterUserSearch')}
3701
+ spellCheck={false}
3702
+ />
3703
+ <div className={css.funnelList}>
3704
+ {authors === null ? (
3705
+ <div className={css.funnelMore}>{t('loading')}</div>
3706
+ ) : matchedAuthors.length === 0 ? (
3707
+ <div className={css.funnelMore}>{authors.authors.length === 0 ? t('noCommits') : t('historyNoMatch')}</div>
3708
+ ) : matchedAuthors.map(entry => (
3709
+ <label key={`${entry.name}\x1f${entry.email}`} className={css.funnelRow}>
3710
+ <input
3711
+ type="checkbox"
3712
+ checked={filterModel.users.includes(entry.name)}
3713
+ onChange={() => toggleUser(entry.name)}
3714
+ />
3715
+ <span className={css.funnelName} title={`${entry.name} <${entry.email}>`}>{entry.name}</span>
3716
+ <span className={css.funnelCount}>{entry.count}</span>
3717
+ </label>
3718
+ ))}
3719
+ {authors?.truncated === true ? (
3720
+ <div className={css.funnelMore}>{t('filterAuthorsMore')}</div>
3721
+ ) : null}
3722
+ </div>
3723
+ </div>
3724
+ ) : null}
3725
+ {funnelSection === 'date' ? (
3726
+ <div className={css.funnelPane}>
3727
+ <div className={css.funnelPresets}>
3728
+ {DATE_PRESETS.map(preset => (
3729
+ <button
3730
+ key={preset.key}
3731
+ type="button"
3732
+ className={filterModel.after === preset.value ? `${css.funnelPreset} ${css.funnelPresetActive}` : css.funnelPreset}
3733
+ onClick={() => applyFilter({ ...filterModel, after: filterModel.after === preset.value ? '' : preset.value })}
3734
+ >{t(preset.key)}</button>
3735
+ ))}
3736
+ </div>
3737
+ {/* Which bound a picked day lands in — the calendar is one, the
3738
+ range is two picks apart. Captioned, and shaped as a rect
3739
+ track rather than the tab strip's pills: two identical pill
3740
+ rows six pixels apart never said they meant different
3741
+ things. */}
3742
+ <span className={css.funnelCaption}>{t('filterCalendarSets')}</span>
3743
+ <div className={css.funnelBounds} role="group" aria-label={t('filterCalendarSets')}>
3744
+ <button
3745
+ type="button"
3746
+ aria-pressed={calBound === 'after'}
3747
+ className={calBound === 'after' ? `${css.funnelBoundBtn} ${css.funnelBoundBtnActive}` : css.funnelBoundBtn}
3748
+ onClick={() => setCalBound('after')}
3749
+ >{t('filterAfter')}</button>
3750
+ <button
3751
+ type="button"
3752
+ aria-pressed={calBound === 'before'}
3753
+ className={calBound === 'before' ? `${css.funnelBoundBtn} ${css.funnelBoundBtnActive}` : css.funnelBoundBtn}
3754
+ onClick={() => setCalBound('before')}
3755
+ >{t('filterBefore')}</button>
3756
+ </div>
3757
+ <FilterCalendar
3758
+ year={calMonth.year}
3759
+ month={calMonth.month}
3760
+ after={filterModel.after}
3761
+ before={filterModel.before}
3762
+ locale={t('filterLocale')}
3763
+ onPick={iso => applyFilter({ ...filterModel, [calBound]: iso })}
3764
+ onShift={delta => setCalMonth(current => {
3765
+ const next = new Date(current.year, current.month + delta, 1)
3766
+ return { year: next.getFullYear(), month: next.getMonth() }
3767
+ })}
3768
+ />
3769
+ <div className={css.funnelBoundRows}>
3770
+ <span className={css.funnelBoundRow}>
3771
+ <span className={css.funnelBoundKey}>{t('filterAfter')}</span>
3772
+ <span className={filterModel.after.length > 0 ? `${css.funnelBoundVal} ${css.funnelBoundValSet}` : css.funnelBoundVal}>
3773
+ {filterModel.after.length > 0 ? filterModel.after : '—'}
3774
+ </span>
3775
+ {filterModel.after.length > 0 ? (
3776
+ <button type="button" className={css.funnelBoundClear} aria-label={t('filterAfter')} onClick={() => applyFilter({ ...filterModel, after: '' })}>×</button>
3777
+ ) : null}
3778
+ </span>
3779
+ <span className={css.funnelBoundRow}>
3780
+ <span className={css.funnelBoundKey}>{t('filterBefore')}</span>
3781
+ <span className={filterModel.before.length > 0 ? `${css.funnelBoundVal} ${css.funnelBoundValSet}` : css.funnelBoundVal}>
3782
+ {filterModel.before.length > 0 ? filterModel.before : '—'}
3783
+ </span>
3784
+ {filterModel.before.length > 0 ? (
3785
+ <button type="button" className={css.funnelBoundClear} aria-label={t('filterBefore')} onClick={() => applyFilter({ ...filterModel, before: '' })}>×</button>
3786
+ ) : null}
3787
+ </span>
3788
+ </div>
3789
+ </div>
3790
+ ) : null}
3791
+ {funnelSection === 'paths' ? (
3792
+ <div className={css.funnelPane}>
3793
+ <input
3794
+ className={css.funnelSearch}
3795
+ type="search"
3796
+ value={pathsQuery}
3797
+ onChange={event => setPathsQuery(event.target.value)}
3798
+ placeholder={t('filterPathSearch')}
3799
+ aria-label={t('filterPathSearch')}
3800
+ spellCheck={false}
3801
+ />
3802
+ <div className={css.funnelList}>
3803
+ {pathTree === null ? (
3804
+ <div className={css.funnelMore}>{t('loading')}</div>
3805
+ ) : pathsQuery.trim().length > 0 ? (
3806
+ /* Search results are FLAT — the honest shape for hits (same
3807
+ argument as the filtered commit list), each row ticking a
3808
+ pathspec directly: files first, then directories. */
3809
+ (() => {
3810
+ const hits = searchPaths(pathTree.paths, pathsQuery).slice(0, 200)
3811
+ if (hits.length === 0) return <div className={css.funnelMore}>{t('historyNoMatch')}</div>
3812
+ return (
3813
+ <>
3814
+ {hits.map(hit => (
3815
+ <label key={hit.path} className={css.funnelRow}>
3816
+ <TriStateCheckbox state={pathState(hit.path)} ariaLabel={hit.path} onChange={() => togglePath(hit.path)} />
3817
+ {hit.isFile ? <PathFileGlyph /> : <PathDirGlyph />}
3818
+ <span className={css.funnelName} title={hit.path}>{hit.path}</span>
3819
+ </label>
3820
+ ))}
3821
+ {searchPaths(pathTree.paths, pathsQuery).length > 200 ? (
3822
+ <div className={css.funnelMore}>{t('filterPathsMore')}</div>
3823
+ ) : null}
3824
+ </>
3825
+ )
3826
+ })()
3827
+ ) : pathTree.dirs.length === 0 ? (
3828
+ <div className={css.funnelMore}>{t('noCommits')}</div>
3829
+ ) : (
3830
+ <PathTreeRows
3831
+ dirs={pathTree.dirs}
3832
+ depth={0}
3833
+ expanded={expandedDirs}
3834
+ stateOf={pathState}
3835
+ onToggleOpen={toggleDirOpen}
3836
+ onTogglePath={togglePath}
3837
+ />
3838
+ )}
3839
+ {pathTree?.truncated === true ? (
3840
+ <div className={css.funnelMore}>{t('filterPathsMore')}</div>
3841
+ ) : null}
3842
+ </div>
3843
+ </div>
3844
+ ) : null}
3845
+ {/* The panel's own readout. Clearing goes through the box's grammar
3846
+ like every other funnel interaction, so one query string stays
3847
+ the single source of truth. */}
3848
+ <div className={css.funnelFoot}>
3849
+ <span className={selectedCount > 0 ? `${css.funnelFootCount} ${css.funnelFootCountOn}` : css.funnelFootCount}>
3850
+ {t('filterSelected', { count: selectedCount })}
3851
+ </span>
3852
+ <button
3853
+ type="button"
3854
+ className={css.funnelFootClear}
3855
+ disabled={selectedCount === 0}
3856
+ onClick={() => onQueryChange('')}
3857
+ >{t('filterClearAll')}</button>
3858
+ </div>
3859
+ </div>,
3860
+ funnelAnchorRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body),
3861
+ ) : null}
3862
+ {chips.length > 0 ? (
3863
+ <div className={css.filterChips}>
3864
+ {chips.map(chip => (
3865
+ <span key={`${chip.kind}\x1f${chip.value}`} className={css.filterChip}>
3866
+ <span className={css.filterChipLabel}>{chip.kind}:{chip.value}</span>
3867
+ <button
3868
+ type="button"
3869
+ className={css.filterChipRemove}
3870
+ aria-label={`${chip.kind} ${chip.value}`}
3871
+ onClick={() => onQueryChange(serializeLogQuery(removeChip(filterModel, chip.kind, chip.value)))}
3872
+ >×</button>
3873
+ </span>
3874
+ ))}
3875
+ <button type="button" className={css.filterClear} onClick={() => onQueryChange('')}>{t('filterClearAll')}</button>
3876
+ </div>
3877
+ ) : null}
2982
3878
  {commits.length === 0 ? (
2983
- <div className={css.empty}>{loading ? t('loading') : t('noCommits')}</div>
3879
+ <div className={css.empty}>
3880
+ {loading ? t('loading') : error !== null ? error : chips.length > 0 ? t('historyNoMatch') : t('noCommits')}
3881
+ </div>
2984
3882
  ) : (
2985
3883
  <div className={css.commits} role="listbox" aria-label={t('historyLabel')} ref={scrollRef}>
2986
3884
  {commits.map((commit, index) => (
@@ -3118,12 +4016,32 @@ interface FileTreeProps {
3118
4016
  /** Add or remove files from the commit set. Undefined outside the working-tree
3119
4017
  * view, where what a commit contains was decided long ago. */
3120
4018
  onCheck?: (files: readonly GitFile[], state: CheckState) => void
4019
+ /** Roll one file back to HEAD; working-tree view only. */
4020
+ onDiscard?: (file: GitFile) => void
3121
4021
  /** Rendered under the tree in the working-tree view only. */
3122
4022
  footer?: ReactNode
4023
+ /** Names what this list is OF — the working tree, or one commit, or one
4024
+ * comparison. The filter clears when it changes: a query typed against a
4025
+ * 140-file commit would otherwise carry over to the next commit and hide
4026
+ * most of it, with nothing on screen saying why. */
4027
+ scopeKey: string
3123
4028
  }
3124
4029
 
3125
- function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onCollapsedChange, onCheck, footer }: FileTreeProps): ReactNode {
3126
- const tree = useMemo(() => buildTree(files), [files])
4030
+ function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onCollapsedChange, onCheck, onDiscard, footer, scopeKey }: FileTreeProps): ReactNode {
4031
+ /**
4032
+ * The filter over this list. Local, because it describes a way of LOOKING at
4033
+ * the pane rather than anything the drawer stores: closing and reopening on
4034
+ * an unfiltered list is what someone expects, and a query kept in the panel
4035
+ * would have to be cleared from four places instead of one.
4036
+ */
4037
+ const [query, setQuery] = useState('')
4038
+ const [filterOpen, setFilterOpen] = useState(false)
4039
+ const filterRef = useRef<HTMLInputElement>(null)
4040
+ useEffect(() => { setQuery(''); setFilterOpen(false) }, [scopeKey])
4041
+
4042
+ const shownFiles = useMemo(() => filterFiles(files, query), [files, query])
4043
+ const filtering = shownFiles !== files
4044
+ const tree = useMemo(() => buildTree(shownFiles), [shownFiles])
3127
4045
  /** Default: a dir collapses when it holds more than 12 files anywhere below it. */
3128
4046
  const effective = collapsed ?? defaultCollapsed(tree)
3129
4047
 
@@ -3169,16 +4087,41 @@ function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onColl
3169
4087
  state={tree.check}
3170
4088
  label={tree.check === 'on' ? t('unstageAll') : t('stageAll')}
3171
4089
  indent={0}
3172
- onToggle={() => onCheck(files, tree.check)}
4090
+ // `shownFiles`, not `files`: a tick IS a git call, and the root
4091
+ // one must stage exactly the rows it sits above. Reaching past a
4092
+ // filter into files the pane is hiding is how "stage all" ends up
4093
+ // meaning something the reader never saw.
4094
+ onToggle={() => onCheck(shownFiles, tree.check)}
3173
4095
  />
3174
4096
  ) : null}
3175
4097
  <span className={css.treeLabel}>
3176
4098
  {loading === true
3177
4099
  ? t('loading')
3178
- : `${lead !== undefined ? `${lead} · ` : ''}${t('files', { count: files.length })}`}
4100
+ : `${lead !== undefined ? `${lead} · ` : ''}${filtering
4101
+ ? t('filesFiltered', { shown: shownFiles.length, count: files.length })
4102
+ : t('files', { count: files.length })}`}
3179
4103
  </span>
3180
4104
  </div>
3181
4105
  <div className={css.treeActions} data-gs-part="tree-actions">
4106
+ {/* Filtering is about the list, so it sits with the list's own two
4107
+ controls rather than in the drawer chrome — and it stays lit while
4108
+ a query is set, because a pane showing 6 of 140 files with no
4109
+ visible reason is the one way this feature can mislead. */}
4110
+ <button
4111
+ type="button"
4112
+ className={filterOpen || filtering ? `${css.treeIcon} ${css.treeIconOn}` : css.treeIcon}
4113
+ data-gs-part="filter-files"
4114
+ title={t('filterFiles')} aria-label={t('filterFiles')}
4115
+ aria-pressed={filterOpen}
4116
+ onClick={() => {
4117
+ // Closing is also clearing. A hidden box still holding a query
4118
+ // would leave the pane filtered with its only explanation
4119
+ // folded away.
4120
+ if (filterOpen) { setQuery(''); setFilterOpen(false); return }
4121
+ setFilterOpen(true)
4122
+ window.setTimeout(() => filterRef.current?.focus(), 0)
4123
+ }}
4124
+ ><FilterGlyph /></button>
3182
4125
  {/* Icon-only, with the label on `title`/`aria-label`: the glyph is the
3183
4126
  same one the rows carry, so each button previews its own result. */}
3184
4127
  <button
@@ -3193,13 +4136,47 @@ function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onColl
3193
4136
  ><span className={css.treeIconGlyph}>▸</span></button>
3194
4137
  </div>
3195
4138
  </div>
4139
+ {filterOpen ? (
4140
+ <div className={css.treeFilter}>
4141
+ <input
4142
+ ref={filterRef}
4143
+ className={css.treeFilterInput}
4144
+ type="text"
4145
+ value={query}
4146
+ placeholder={t('filterFilesPlaceholder')}
4147
+ aria-label={t('filterFiles')}
4148
+ spellCheck={false}
4149
+ onChange={event => setQuery(event.target.value)}
4150
+ onKeyDown={event => {
4151
+ if (event.key !== 'Escape') return
4152
+ // Escape belongs to the box while it has something to undo;
4153
+ // only an already-empty box lets it through to close the drawer.
4154
+ if (query.length > 0) { event.stopPropagation(); setQuery(''); return }
4155
+ event.stopPropagation()
4156
+ setFilterOpen(false)
4157
+ }}
4158
+ />
4159
+ {query.length > 0 ? (
4160
+ <button
4161
+ type="button" className={css.treeFilterClear}
4162
+ title={t('filterFilesClear')} aria-label={t('filterFilesClear')}
4163
+ onClick={() => { setQuery(''); filterRef.current?.focus() }}
4164
+ >×</button>
4165
+ ) : null}
4166
+ </div>
4167
+ ) : null}
3196
4168
  {loading === true ? (
3197
4169
  <div className={css.treeEmpty} data-gs-part="tree-loading">{t('loading')}</div>
4170
+ ) : filtering && shownFiles.length === 0 ? (
4171
+ <div className={css.treeEmpty} data-gs-part="tree-no-match">{t('filterNoMatch')}</div>
3198
4172
  ) : (
3199
4173
  <ul className={css.tree}>
4174
+ {/* A filtered tree ignores the fold state entirely: the reader asked
4175
+ for these files, and leaving them behind a directory they
4176
+ collapsed twenty minutes ago reads as "no matches". */}
3200
4177
  <TreeChildren
3201
- node={tree} depth={0} active={active} collapsed={effective}
3202
- onToggle={toggleOne} onSelect={onSelect} onCheck={onCheck} stageLabels={stageLabels}
4178
+ node={tree} depth={0} active={active} collapsed={filtering ? EMPTY_COLLAPSED : effective}
4179
+ onToggle={toggleOne} onSelect={onSelect} onCheck={onCheck} onDiscard={onDiscard} stageLabels={stageLabels} discardLabel={t('discardAction')}
3203
4180
  />
3204
4181
  </ul>
3205
4182
  )}
@@ -3277,17 +4254,25 @@ interface TreeChildrenProps {
3277
4254
  node: DirNode
3278
4255
  depth: number
3279
4256
  active: string | null
3280
- collapsed: Set<string>
4257
+ /** Read-only: a filtered tree is handed a shared empty set rather than a copy. */
4258
+ collapsed: ReadonlySet<string>
3281
4259
  onToggle: (path: string) => void
3282
4260
  onSelect: (path: string) => void
3283
4261
  /** Add or remove files from the commit set. Undefined outside the working-tree
3284
4262
  * view, where what a commit contains was decided long ago. */
3285
4263
  onCheck?: (files: readonly GitFile[], state: CheckState) => void
4264
+ /** Roll one file back to HEAD. Undefined outside the working-tree view for
4265
+ * the same reason `onCheck` is: a commit's files are history, and there is
4266
+ * nothing there to roll back. Directories never offer it — the irreversible
4267
+ * action does not get a gesture that takes a subtree with it. */
4268
+ onDiscard?: (file: GitFile) => void
3286
4269
  /** Pre-translated, so the row does not have to carry `t` for two strings. */
3287
4270
  stageLabels: { stage: string; unstage: string }
4271
+ /** Label for the roll-back action, pre-translated like `stageLabels`. */
4272
+ discardLabel?: string
3288
4273
  }
3289
4274
 
3290
- function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCheck, stageLabels }: TreeChildrenProps): ReactNode {
4275
+ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCheck, onDiscard, stageLabels, discardLabel }: TreeChildrenProps): ReactNode {
3291
4276
  const dirNodes = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
3292
4277
  const fileNodes = [...node.files].sort((a, b) => basePart(a.path).localeCompare(basePart(b.path)))
3293
4278
  const checkColumn = onCheck !== undefined ? TREE_CHECK_W : 0
@@ -3318,6 +4303,7 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
3318
4303
  title={dir.path}
3319
4304
  >
3320
4305
  <span className={`${css.chevron} ${open ? css.chevronOpen : ''}`}>▸</span>
4306
+ <PathDirGlyph />
3321
4307
  <span className={css.treeDirName}>{dir.name}</span>
3322
4308
  <span className={css.treeDirCount}>{dir.fileCount}</span>
3323
4309
  <span className={css.treeDirCounts}>
@@ -3333,7 +4319,7 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
3333
4319
  // row's tick — so the tick's width is part of the offset.
3334
4320
  style={{ [RAIL_VAR]: `${checkColumn + TREE_BASE_INDENT + depth * TREE_INDENT + TREE_RAIL_OFFSET}px` } as CSSProperties}
3335
4321
  >
3336
- <TreeChildren node={dir} depth={depth + 1} active={active} collapsed={collapsed} onToggle={onToggle} onSelect={onSelect} onCheck={onCheck} stageLabels={stageLabels} />
4322
+ <TreeChildren node={dir} depth={depth + 1} active={active} collapsed={collapsed} onToggle={onToggle} onSelect={onSelect} onCheck={onCheck} onDiscard={onDiscard} stageLabels={stageLabels} discardLabel={discardLabel} />
3337
4323
  </ul>
3338
4324
  ) : null}
3339
4325
  </li>
@@ -3358,7 +4344,13 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
3358
4344
  onClick={() => onSelect(file.path)}
3359
4345
  title={file.previousPath !== undefined ? `${file.previousPath} → ${file.path}` : file.path}
3360
4346
  >
3361
- <span className={`${css.fileStatus} ${STATUS_BADGE[file.status]}`}>{statusGlyph(file.status)}</span>
4347
+ {/* Icon then name, status on the right with the line counts.
4348
+ The badge used to lead, which put two glyphs side by side the
4349
+ moment the row gained a file icon; both IDEA and VS Code read
4350
+ left-to-right as "what this is, then what happened to it",
4351
+ and the badge still lands in an aligned column — `.filePath`
4352
+ is the only flexible child. */}
4353
+ <PathFileGlyph />
3362
4354
  <span className={css.filePath}>{basePart(file.path)}</span>
3363
4355
  {file.binary ? <span className={css.fileBinary}>BIN</span> : (
3364
4356
  <span className={css.fileCounts}>
@@ -3366,7 +4358,19 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
3366
4358
  <span className={css.fileCountDel}>{file.deletedLines > 0 ? `−${file.deletedLines}` : ''}</span>
3367
4359
  </span>
3368
4360
  )}
4361
+ <span className={`${css.fileStatus} ${STATUS_BADGE[file.status]}`}>{statusGlyph(file.status)}</span>
3369
4362
  </button>
4363
+ {onDiscard !== undefined ? (
4364
+ /* Outside the row button, not inside it: a button in a button is
4365
+ invalid, and clicking roll-back must not also select the file. */
4366
+ <button
4367
+ type="button"
4368
+ className={css.fileDiscard}
4369
+ title={discardLabel}
4370
+ aria-label={`${discardLabel ?? ''} ${file.path}`}
4371
+ onClick={event => { event.stopPropagation(); onDiscard(file) }}
4372
+ ><RollbackGlyph /></button>
4373
+ ) : null}
3370
4374
  </li>
3371
4375
  )
3372
4376
  })}