@young1lin/dsh-ui-gitworkbench 0.1.2 → 0.1.4

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