@young1lin/dsh-ui-gitworkbench 0.1.3 → 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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +39 -0
- package/CHANGELOG_EN.md +39 -0
- package/README.md +36 -6
- package/README_EN.md +3 -2
- package/lib/client.js +2058 -305
- package/lib/discard-ops.js +158 -0
- package/lib/git-log.js +10 -6
- package/lib/git-ops.js +30 -9
- package/lib/index.js +215 -7
- 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 +1024 -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/file-filter.ts +66 -0
- package/src/client/index.ts +44 -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/git-log.ts +24 -6
- package/src/git-ops.ts +39 -7
- package/src/index.ts +215 -6
- package/src/log-filter.ts +87 -0
- package/src/shortlog.ts +46 -0
|
@@ -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
|
-
|
|
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
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
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}>
|
|
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
|
-
|
|
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
|
-
|
|
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} · ` : ''}${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})}
|