@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +48 -0
- package/CHANGELOG_EN.md +48 -0
- package/README.md +36 -6
- package/README_EN.md +3 -2
- package/lib/client.js +2126 -304
- package/lib/discard-ops.js +158 -0
- package/lib/fs-remove.js +73 -0
- package/lib/git-log.js +10 -6
- package/lib/git-ops.js +30 -9
- package/lib/index.js +191 -5
- package/lib/log-filter.js +71 -0
- package/lib/shortlog.js +40 -0
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +523 -6
- package/src/client/GitWorkbenchPanel.tsx +1030 -26
- package/src/client/active-file.ts +83 -0
- package/src/client/calendar.ts +99 -0
- package/src/client/commit-filter.ts +49 -0
- package/src/client/dir-tree.ts +112 -0
- package/src/client/discard-flow.ts +82 -0
- package/src/client/file-filter.ts +66 -0
- package/src/client/index.ts +52 -5
- package/src/client/locales.ts +94 -0
- package/src/client/log-filter-query.ts +189 -0
- package/src/client/path-select.ts +131 -0
- package/src/discard-ops.ts +197 -0
- package/src/fs-remove.ts +76 -0
- package/src/git-log.ts +24 -6
- package/src/git-ops.ts +39 -7
- package/src/index.ts +191 -4
- package/src/log-filter.ts +87 -0
- package/src/shortlog.ts +46 -0
|
@@ -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
|
-
|
|
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
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
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}>
|
|
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
|
-
|
|
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
|
-
|
|
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} · ` : ''}${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})}
|