@young1lin/dsh-ui-gitworkbench 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Which file a view opens on.
3
+ *
4
+ * The drawer keeps ONE selected path across commits, so walking down a
5
+ * filtered history is meant to be a walk through one file's life. That only
6
+ * works if the fallback — what happens when the selection is not in the
7
+ * commit you just clicked — knows about the path filter. It did not: the
8
+ * fallback was `files[0]`, so filtering by `xx/aa/dd.ts` and clicking a
9
+ * commit opened whatever sorted first in that commit, and the file the
10
+ * filter was ABOUT sat unhighlighted somewhere down the tree.
11
+ *
12
+ * Pure and React-free so it can be tested; the panel only wires it up.
13
+ *
14
+ * @module @young1lin/dsh-ui-gitworkbench/active-file
15
+ */
16
+
17
+ /** Just the field this module reads off the view's file list. */
18
+ export interface PathLike {
19
+ readonly path: string
20
+ }
21
+
22
+ /** No filter — a shared empty so callers keep a stable reference. */
23
+ export const NO_PATHS: readonly string[] = []
24
+
25
+ /**
26
+ * Whether a file is what a pathspec selected: the file itself, or anything in
27
+ * its subtree.
28
+ *
29
+ * A pathspec from the picker is either a file path or a directory path with no
30
+ * trailing slash (`dir.path` / the file's full path — `path-select.ts`), and
31
+ * the two cases are told apart by the file rather than by the spec: `===` is
32
+ * the file, `spec + '/'` prefix is the subtree. Guessing which KIND a spec is
33
+ * from its string alone is what a trailing-slash convention would force, and
34
+ * it would be wrong for any file without an extension.
35
+ */
36
+ function covers(spec: string, path: string): boolean {
37
+ return path === spec || path.startsWith(`${spec}/`)
38
+ }
39
+
40
+ /**
41
+ * The file a view should highlight.
42
+ *
43
+ * The order of preference, and why it is this order:
44
+ *
45
+ * 1. **The selection, if this view has it.** Stepping down a filtered list is
46
+ * the whole point of filtering; changing the file under the reader every
47
+ * time they move a row would undo it. This also means an explicit click
48
+ * outranks the filter — the reader looked somewhere on purpose.
49
+ * 2. **A file the filter names EXACTLY.** Ticking `xx/aa/dd.ts` is a statement
50
+ * about that file; ticking `xx` is a statement about a region. When a
51
+ * commit touches both kinds, the named file is the more specific intent, so
52
+ * it wins. (The two can only coexist across disjoint trees: the picker's
53
+ * invariant is that no ticked path covers another.)
54
+ * 3. **A file under a filtered directory.**
55
+ * 4. **The first file.** No filter, or nothing in this commit matched it —
56
+ * the behaviour before any of this existed.
57
+ *
58
+ * Ties inside 2 and 3 go to the commit's own file order, which is the order
59
+ * the tree renders: the highlight lands on the topmost matching row, so it is
60
+ * where the reader is already looking and never needs a scroll to find. The
61
+ * alternative — first match in FILTER order — would be arbitrary, since that
62
+ * order is an artifact of the sequence the boxes were ticked in and is never
63
+ * shown anywhere.
64
+ *
65
+ * @param files - the view's files, in the order the tree shows them.
66
+ * @param filterPaths - active path filter; empty on views that have none.
67
+ * @param previous - the currently selected path, or null.
68
+ * @returns the path to highlight, or null when there are no files at all.
69
+ */
70
+ export function preferredFile(
71
+ files: readonly PathLike[],
72
+ filterPaths: readonly string[],
73
+ previous: string | null,
74
+ ): string | null {
75
+ if (previous !== null && files.some(file => file.path === previous)) return previous
76
+ if (filterPaths.length > 0) {
77
+ const exact = files.find(file => filterPaths.some(spec => file.path === spec))
78
+ if (exact !== undefined) return exact.path
79
+ const under = files.find(file => filterPaths.some(spec => covers(spec, file.path)))
80
+ if (under !== undefined) return under.path
81
+ }
82
+ return files[0]?.path ?? null
83
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The pure arithmetic of the filter's calendar.
3
+ *
4
+ * A month is a fixed 6×7 grid, Monday-first: a stable height whatever the
5
+ * month (no layout jump when February needs four rows), with neighbouring
6
+ * months' days filling the lead-in and tail — greyed, still clickable, the
7
+ * way every modern calendar behaves. Everything here is pure and takes
8
+ * `todayIso` explicitly so tests are deterministic.
9
+ *
10
+ * @module @young1lin/dsh-ui-gitworkbench/calendar
11
+ */
12
+
13
+ /** One day cell. `null` where the grid has no day at all (never, at 6×7). */
14
+ export interface CalendarCell {
15
+ /** The day, `yyyy-mm-dd` — the grammar the filter speaks end to end. */
16
+ readonly iso: string
17
+ /** Day-of-month number shown in the cell. */
18
+ readonly day: number
19
+ /** Whether the cell belongs to the displayed month. */
20
+ readonly inMonth: boolean
21
+ /** Whether the cell is the supplied today. */
22
+ readonly isToday: boolean
23
+ }
24
+
25
+ const DAY_MS = 86_400_000
26
+
27
+ function toIso(date: Date): string {
28
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0')
29
+ const d = String(date.getUTCDate()).padStart(2, '0')
30
+ return `${date.getUTCFullYear()}-${m}-${d}`
31
+ }
32
+
33
+ /**
34
+ * The 6×7 Monday-first grid for one month.
35
+ * @param year - displayed year.
36
+ * @param month - displayed month, 0-based like `Date`.
37
+ * @param todayIso - what counts as today, for the accent; `''` accents nothing.
38
+ */
39
+ export function monthGrid(year: number, month: number, todayIso: string): readonly (readonly (CalendarCell | null)[])[] {
40
+ // Day 1 of the month, at a UTC midnight so arithmetic never drifts an hour.
41
+ const first = new Date(Date.UTC(year, month, 1))
42
+ // getUTCDay is Sunday=0; Monday-first offset.
43
+ const lead = (first.getUTCDay() + 6) % 7
44
+ const start = new Date(first.getTime() - lead * DAY_MS)
45
+ const weeks: (CalendarCell | null)[][] = []
46
+ for (let w = 0; w < 6; w += 1) {
47
+ const row: (CalendarCell | null)[] = []
48
+ for (let d = 0; d < 7; d += 1) {
49
+ const date = new Date(start.getTime() + (w * 7 + d) * DAY_MS)
50
+ const iso = toIso(date)
51
+ row.push({ iso, day: date.getUTCDate(), inMonth: date.getUTCMonth() === month, isToday: iso === todayIso })
52
+ }
53
+ weeks.push(row)
54
+ }
55
+ return weeks
56
+ }
57
+
58
+ /**
59
+ * Single-letter weekday header row, Monday-first, via the viewer's own locale
60
+ * (or an explicit one, which is what the test does).
61
+ * @param locale - BCP 47 tag; undefined means the runtime default.
62
+ */
63
+ export function weekdayLabels(locale?: string): readonly string[] {
64
+ // 2023-01-02..08 is a Monday..Sunday — label those, whatever the locale.
65
+ const labels: string[] = []
66
+ for (let d = 2; d <= 8; d += 1) {
67
+ labels.push(new Intl.DateTimeFormat(locale, { weekday: 'narrow' }).format(new Date(Date.UTC(2023, 0, d))))
68
+ }
69
+ return labels
70
+ }
71
+
72
+ /**
73
+ * Whether a day falls strictly BETWEEN the two bounds, so the grid can tint
74
+ * the span the filter admits rather than only its two endpoints.
75
+ *
76
+ * Both bounds have to be `yyyy-mm-dd` for a range to exist: the bounds also
77
+ * accept approxidate text (`1 week ago`), which names no grid day at all, and
78
+ * lexical comparison on iso dates is the same as chronological. An inverted
79
+ * pair (after > before) is a range git returns nothing for, and it tints
80
+ * nothing here for the same reason.
81
+ *
82
+ * @param iso - the cell's day.
83
+ * @param after - lower bound, exclusive here (it renders as an endpoint).
84
+ * @param before - upper bound, exclusive here.
85
+ */
86
+ export function inCalRange(iso: string, after: string, before: string): boolean {
87
+ if (!ISO_DAY.test(after) || !ISO_DAY.test(before)) return false
88
+ return iso > after && iso < before
89
+ }
90
+
91
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/
92
+
93
+ /** Today as `yyyy-mm-dd` in the viewer's local timezone (for `todayIso`). */
94
+ export function localTodayIso(): string {
95
+ const now = new Date()
96
+ const m = String(now.getMonth() + 1).padStart(2, '0')
97
+ const d = String(now.getDate()).padStart(2, '0')
98
+ return `${now.getFullYear()}-${m}-${d}`
99
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Exact-date rendering for the history hover card.
3
+ *
4
+ * The rows stay compact — abbreviated hash, name, "3 weeks ago" — and the
5
+ * details live in the hover card. An exact date cannot be derived from git's
6
+ * relative "%cr" prose, so the host log format carries `%cI` alongside
7
+ * (`src/git-log.ts`); this module renders it in the viewer's own clock.
8
+ *
9
+ * (The person-name MATCHING that once lived here is retired: history
10
+ * filtering is compiled into git log arguments host-side — `log-filter.ts`,
11
+ * `log-filter-query.ts` — so it runs over all history, not the loaded pages.)
12
+ *
13
+ * @module @young1lin/dsh-ui-gitworkbench/commit-filter
14
+ */
15
+
16
+ /** Options for {@link formatCommitDate}; both default to the viewer's own. */
17
+ export interface CommitDateOptions {
18
+ /** BCP 47 locale for the formatter; undefined means the runtime default. */
19
+ readonly locale?: string
20
+ /** IANA timezone; undefined means the viewer's local timezone. */
21
+ readonly timeZone?: string
22
+ }
23
+
24
+ /**
25
+ * Render a commit's ISO 8601 date in full — "Aug 4, 2026, 5:30 PM", in the
26
+ * viewer's locale and timezone (or the overrides, which exist for tests).
27
+ *
28
+ * git's relative prose ("3 weeks ago") is right for the row and useless for
29
+ * the hover card, where the question is exactly WHEN. `%cI` is a strict ISO
30
+ * timestamp, so `new Date` parses it and the formatter renders local time —
31
+ * the same moment the viewer's own clock shows, which is the only timezone a
32
+ * hover card should speak. Unparsable input yields an empty string rather
33
+ * than a thrown RangeError: the card simply omits the line.
34
+ * @param iso - `%cI` string from the host log, possibly empty or absent.
35
+ * @param options - locale/timezone overrides; both optional.
36
+ */
37
+ export function formatCommitDate(iso: string, options: CommitDateOptions = {}): string {
38
+ if (iso.length === 0) return ''
39
+ const date = new Date(iso)
40
+ if (Number.isNaN(date.getTime())) return ''
41
+ return new Intl.DateTimeFormat(options.locale, {
42
+ year: 'numeric',
43
+ month: 'short',
44
+ day: 'numeric',
45
+ hour: '2-digit',
46
+ minute: '2-digit',
47
+ ...(options.timeZone !== undefined ? { timeZone: options.timeZone } : {}),
48
+ }).format(date)
49
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Directory tree for the history filter's path picker, aggregated from a flat
3
+ * file list.
4
+ *
5
+ * The host sends `git ls-tree -r --name-only HEAD` verbatim; this module
6
+ * folds it into DIRECTORIES with their FILES as leaf rows — "when did this
7
+ * file change" is the question a file row answers, and it is the most common
8
+ * one. Everything is pure so the aggregation and the search are testable from
9
+ * a literal file list.
10
+ *
11
+ * @module @young1lin/dsh-ui-gitworkbench/dir-tree
12
+ */
13
+
14
+ /** One directory in the picker. */
15
+ export interface DirEntry {
16
+ readonly name: string
17
+ /** Repo-relative path — also the pathspec a tick produces. */
18
+ readonly path: string
19
+ /** Files in this SUBTREE — what ticking this row would match. */
20
+ readonly fileCount: number
21
+ /** File NAMES directly in this directory (leaf rows). */
22
+ readonly files: readonly string[]
23
+ /** Subdirectories, sorted by name. */
24
+ readonly children: readonly DirEntry[]
25
+ }
26
+
27
+ /** A mutable builder node; frozen into the readonly shape at the end. */
28
+ interface BuildNode {
29
+ name: string
30
+ path: string
31
+ files: string[]
32
+ children: Map<string, BuildNode>
33
+ }
34
+
35
+ /**
36
+ * Fold a flat path list into a sorted directory tree carrying its files.
37
+ * Root-level files live on no directory; the SEARCH ({@link searchPaths}) is
38
+ * where they surface.
39
+ * @param paths - repo-relative file paths, any order, no duplicates assumed.
40
+ * @returns the top-level directories, children and files sorted by name.
41
+ */
42
+ export function buildDirTree(paths: readonly string[]): readonly DirEntry[] {
43
+ const rootNode: BuildNode = { name: '', path: '', files: [], children: new Map() }
44
+ for (const path of paths) {
45
+ if (path.length === 0) continue
46
+ const parts = path.split('/')
47
+ // A trailing part is the file; every part before it must exist as a
48
+ // directory, whether or not any other file mentioned it.
49
+ let node = rootNode
50
+ for (let i = 0; i < parts.length - 1; i += 1) {
51
+ const name = parts[i]!
52
+ let child = node.children.get(name)
53
+ if (child === undefined) {
54
+ child = { name, path: parts.slice(0, i + 1).join('/'), files: [], children: new Map() }
55
+ node.children.set(name, child)
56
+ }
57
+ node = child
58
+ }
59
+ node.files.push(parts[parts.length - 1]!)
60
+ }
61
+
62
+ const freeze = (node: BuildNode): DirEntry => {
63
+ const children = [...node.children.values()].sort((a, b) => a.name.localeCompare(b.name)).map(freeze)
64
+ const files = [...node.files].sort((a, b) => a.localeCompare(b))
65
+ const subtreeCount = files.length + children.reduce((sum, child) => sum + child.fileCount, 0)
66
+ return { name: node.name, path: node.path, fileCount: subtreeCount, files, children }
67
+ }
68
+
69
+ return freeze(rootNode).children
70
+ }
71
+
72
+ /** One flat search hit: a directory or a file path, tickable as a pathspec. */
73
+ export interface PathHit {
74
+ readonly path: string
75
+ readonly isFile: boolean
76
+ }
77
+
78
+ /**
79
+ * Search the repository's paths for a fragment — case-insensitive, over the
80
+ * full path. Results are FLAT: a search list is not a tree (the same honesty
81
+ * as the filtered commit list), and each hit ticks as a pathspec directly.
82
+ *
83
+ * Directories match too: every directory is some file's prefix, and ticking a
84
+ * directory covers its subtree — the search takes the raw path list the host
85
+ * sent, so root-level files and unexpanded directories are all in scope.
86
+ * @param paths - repo-relative file paths, exactly as `repoTree` returned.
87
+ * @param needle - raw search text; blank matches nothing (caller shows the tree).
88
+ */
89
+ export function searchPaths(paths: readonly string[], needle: string): readonly PathHit[] {
90
+ const n = needle.trim().toLowerCase()
91
+ if (n.length === 0) return []
92
+ const hits: PathHit[] = []
93
+ const seen = new Set<string>()
94
+ for (const path of paths) {
95
+ if (path.toLowerCase().includes(n)) {
96
+ hits.push({ path, isFile: true })
97
+ seen.add(path)
98
+ }
99
+ // Every directory prefix is a candidate too; deduped via `seen`.
100
+ const parts = path.split('/')
101
+ for (let i = 1; i < parts.length; i += 1) {
102
+ const dir = parts.slice(0, i).join('/')
103
+ if (!seen.has(dir) && dir.toLowerCase().includes(n)) {
104
+ seen.add(dir)
105
+ hits.push({ path: dir, isFile: false })
106
+ }
107
+ }
108
+ }
109
+ // Files first — the common query is a file; the directory that contains it
110
+ // reads better below it than above.
111
+ return [...hits.filter(hit => hit.isFile), ...hits.filter(hit => !hit.isFile)]
112
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Narrowing a file list by typing at it.
3
+ *
4
+ * A commit that touched 140 files is a scroll, not a list, and the drawer's
5
+ * tree is the same object in every tab — so the rule lives here once and both
6
+ * the working tree and a commit's contents get it.
7
+ *
8
+ * Two decisions worth stating, because both are the kind that get "simplified"
9
+ * later:
10
+ *
11
+ * - **Terms are ANDed, in any order.** `panel css` finds
12
+ * `src/client/GitWorkbenchPanel.module.css` — which is how anyone types
13
+ * when they half-remember a path, and is the behaviour a single-substring
14
+ * match gets wrong for exactly the paths that are long enough to need
15
+ * filtering.
16
+ * - **Smart case.** An all-lowercase query ignores case; the moment the
17
+ * reader types a capital they mean it. `README` should not match
18
+ * `readme-generator`, and `readme` should still find `README.md`.
19
+ *
20
+ * The result keeps the caller's order and its element type: the tree is built
21
+ * from whatever survives, so filtering never has to know what a file is beyond
22
+ * its path.
23
+ *
24
+ * @module @young1lin/dsh-ui-gitworkbench/client/file-filter
25
+ */
26
+
27
+ /** Split a raw query into the terms every path must contain. */
28
+ function termsOf(query: string): readonly string[] {
29
+ return query.split(/\s+/).filter(term => term.length > 0)
30
+ }
31
+
32
+ /**
33
+ * Whether one path satisfies a query.
34
+ *
35
+ * @param path - repo-relative path, as the tree lists it.
36
+ * @param query - raw text from the filter box; blank matches everything, so a
37
+ * caller that renders `filterFiles` unconditionally shows the
38
+ * whole list until something is typed.
39
+ */
40
+ export function matchesPath(path: string, query: string): boolean {
41
+ const terms = termsOf(query)
42
+ if (terms.length === 0) return true
43
+ return terms.every(term => {
44
+ // Per TERM, not per query: `src README` is a sensible thing to type, and
45
+ // deciding the whole query's case from one capital would make the `src`
46
+ // half case-sensitive too.
47
+ const cased = term.toLowerCase() !== term
48
+ return cased ? path.includes(term) : path.toLowerCase().includes(term.toLowerCase())
49
+ })
50
+ }
51
+
52
+ /**
53
+ * Keep the files whose path satisfies the query, in the order given.
54
+ *
55
+ * @param files - anything carrying a `path`; the tree's own file objects.
56
+ * @param query - raw text from the filter box.
57
+ * @returns the same array instance when nothing is filtered out, so a blank
58
+ * query costs no re-render downstream.
59
+ */
60
+ export function filterFiles<T extends { readonly path: string }>(
61
+ files: readonly T[],
62
+ query: string,
63
+ ): readonly T[] {
64
+ if (termsOf(query).length === 0) return files
65
+ return files.filter(file => matchesPath(file.path, query))
66
+ }
@@ -19,10 +19,12 @@ import type {} from '@deepseek-ai/dsh-client-runtime' // informational inject ed
19
19
  import type {} from '@deepseek-ai/dsh-client-ui-slots' // SlotMap is reused, not extended
20
20
  import {
21
21
  GitWorkbenchPanel,
22
- type GitCommit, type GitOpName, type GitOpPayload, type GitOpResult,
22
+ type DiscardPreview, type GitCommit, type GitOpName, type GitOpPayload, type GitOpResult,
23
23
  type WorkbenchStats, type SyncStatus, type WorktreeStatus,
24
24
  } from './GitWorkbenchPanel.tsx'
25
25
  import type { StyleEntry, StyleScope, StyleSettings } from './themes.ts'
26
+ import type { LogFilter } from '../log-filter.ts'
27
+ import type { AuthorEntry } from '../shortlog.ts'
26
28
  import { en, zh } from './locales.ts'
27
29
 
28
30
  /**
@@ -91,14 +93,35 @@ export function apply(ctx: ClientContext): void {
91
93
  return result.ok ? result.value : null
92
94
  },
93
95
  // One page of the commit log past what `stats` bundles, so the history
94
- // list can grow instead of stopping at the first page.
95
- fetchCommits: async (worktreePath: string | undefined, ref: string, skip: number, limit: number, signal: AbortSignal): Promise<{ commits: GitCommit[]; hasMore: boolean } | null> => {
96
+ // list can grow instead of stopping at the first page. The filter is
97
+ // compiled into git log arguments host-side (IDEA-style pushdown).
98
+ fetchCommits: async (worktreePath: string | undefined, ref: string, skip: number, limit: number, filter: LogFilter, signal: AbortSignal): Promise<{ commits: GitCommit[]; hasMore: boolean } | null> => {
96
99
  const result = await connection.rpc.call(
97
100
  '/api',
98
101
  'gitWorkbench/commits',
99
- { args: { worktreePath: worktreePath ?? '', ref, skip, limit } },
102
+ { args: { worktreePath: worktreePath ?? '', ref, skip, limit, filter } },
100
103
  signal,
101
- ) as { ok: true; value: { commits: GitCommit[]; hasMore: boolean } } | { ok: false; error: { message?: string } }
104
+ ) as { ok: true; value: { commits: GitCommit[]; hasMore: boolean; error?: string } } | { ok: false; error: { message?: string } }
105
+ return result.ok ? result.value : null
106
+ },
107
+ // Author roster for the ref the history walks, busiest first.
108
+ fetchAuthors: async (worktreePath: string | undefined, ref: string, signal: AbortSignal): Promise<{ authors: AuthorEntry[]; truncated: boolean } | null> => {
109
+ const result = await connection.rpc.call(
110
+ '/api',
111
+ 'gitWorkbench/authors',
112
+ { args: { worktreePath: worktreePath ?? '', ref } },
113
+ signal,
114
+ ) as { ok: true; value: { authors: AuthorEntry[]; truncated: boolean } } | { ok: false; error: { message?: string } }
115
+ return result.ok ? result.value : null
116
+ },
117
+ // Every path on HEAD — the path picker's raw material.
118
+ fetchRepoTree: async (worktreePath: string | undefined, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean } | null> => {
119
+ const result = await connection.rpc.call(
120
+ '/api',
121
+ 'gitWorkbench/repoTree',
122
+ { args: { worktreePath: worktreePath ?? '' } },
123
+ signal,
124
+ ) as { ok: true; value: { paths: string[]; truncated: boolean } } | { ok: false; error: { message?: string } }
102
125
  return result.ok ? result.value : null
103
126
  },
104
127
  // Two refs compared as `base...head`, in the same shape as every other
@@ -181,6 +204,22 @@ export function apply(ctx: ClientContext): void {
181
204
  ) as { ok: true; value: SyncStatus } | { ok: false; error: { message?: string } }
182
205
  return result.ok ? result.value : null
183
206
  },
207
+ // Read-only, and the only reason it is a separate call rather than a
208
+ // field on the file row: the confirmation must state what discarding
209
+ // this file would do NOW, not what the last poll saw. A row that says
210
+ // "modified" while git has since had the file staged, edited or removed
211
+ // is the difference between "goes back to its committed content" and
212
+ // "leaves the disk and cannot come back" — which is the entire question
213
+ // the dialog exists to ask.
214
+ fetchDiscardPlan: async (worktreePath: string | undefined, path: string, signal: AbortSignal): Promise<DiscardPreview | null> => {
215
+ const result = await connection.rpc.call(
216
+ '/api',
217
+ 'gitWorkbench/discardPlan',
218
+ { args: { worktreePath: worktreePath ?? '', path } },
219
+ signal,
220
+ ) as { ok: true; value: DiscardPreview } | { ok: false; error: { message?: string } }
221
+ return result.ok ? result.value : null
222
+ },
184
223
  runGitOp: async (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal): Promise<GitOpResult> => {
185
224
  const result = await connection.rpc.call(
186
225
  '/api',
@@ -20,11 +20,17 @@
20
20
  /** Every key this plugin looks up — the two dictionaries below must both cover it. */
21
21
  export type WorkbenchKey =
22
22
  | 'aheadTitle' | 'behindTitle' | 'files'
23
+ | 'filterFiles' | 'filterFilesPlaceholder' | 'filterFilesClear' | 'filesFiltered' | 'filterNoMatch'
23
24
  | 'drawerLabel' | 'totalsDim' | 'refresh' | 'close'
24
25
  | 'tabsLabel' | 'tabChanges' | 'tabHistory' | 'tabCompare'
25
26
  | 'sourceLabel' | 'workingTree'
26
27
  | 'loadingCommit' | 'renamedFrom' | 'binaryFile' | 'loadingDiff' | 'noTextDiff'
27
28
  | 'noCommits' | 'historyLabel' | 'historyEnd' | 'loading' | 'maximize' | 'restore'
29
+ | 'commitAuthor' | 'commitCommitter' | 'commitDate' | 'historyFilterPlaceholder' | 'historyNoMatch'
30
+ | 'filterClearAll' | 'filterBy' | 'filterUsers' | 'filterUserSearch' | 'filterAuthorsMore'
31
+ | 'filterDate' | 'filterToday' | 'filterLast7' | 'filterLast30' | 'filterAfter' | 'filterBefore'
32
+ | 'filterPaths' | 'filterPathsMore' | 'allBranches' | 'filterPathSearch'
33
+ | 'filterCalendarSets' | 'filterSelected' | 'filterLocale'
28
34
  | 'compareBase' | 'compareHead' | 'comparePick' | 'compareCommits' | 'loadingCompare' | 'noBranches'
29
35
  | 'refSearch' | 'refNone' | 'refCount' | 'refTruncated' | 'refWorktrees' | 'refBranches' | 'historyRefLabel'
30
36
  | 'settings' | 'themeMode' | 'themePalette' | 'themeScope' | 'themeBackground' | 'themeCss'
@@ -42,6 +48,9 @@ export type WorkbenchKey =
42
48
  | 'stage' | 'unstage' | 'stageAll' | 'unstageAll' | 'stagedCount'
43
49
  | 'commit' | 'amend' | 'commitPlaceholder' | 'commitNeedMessage' | 'commitLead'
44
50
  | 'op.ok.stage' | 'op.ok.unstage' | 'op.ok.commit' | 'op.ok.fetch' | 'op.ok.pull' | 'op.ok.push'
51
+ | 'op.ok.discardFile'
52
+ | 'discardAction' | 'discardTitle' | 'discardConfirm' | 'discardCancel'
53
+ | 'discardBodyRestore' | 'discardBodyDelete' | 'discardBodyUnrename'
45
54
  | 'op.fail.auth' | 'op.fail.network' | 'op.fail.no-upstream' | 'op.fail.diverged' | 'op.fail.conflict'
46
55
  | 'op.fail.nothing-to-commit' | 'op.fail.dirty' | 'op.fail.unknown'
47
56
 
@@ -112,8 +121,44 @@ export const zh: Record<WorkbenchKey, string> = {
112
121
  noTextDiff: '无文本差异',
113
122
  noCommits: '无提交历史',
114
123
  historyLabel: '提交历史',
124
+ commitAuthor: '作者',
125
+ commitCommitter: '提交者',
126
+ commitDate: '提交时间',
127
+ historyFilterPlaceholder: '筛选:user: 名字 / path: 路径 / after: 日期 / 关键词',
128
+ historyNoMatch: '没有匹配的提交',
129
+ filterClearAll: '清除全部',
130
+ filterBy: '筛选条件',
131
+ filterUsers: '用户',
132
+ filterUserSearch: '搜索作者',
133
+ filterAuthorsMore: '仅显示提交最多的 500 位作者',
134
+ filterDate: '日期',
135
+ filterToday: '今天',
136
+ filterLast7: '最近 7 天',
137
+ filterLast30: '最近 30 天',
138
+ filterAfter: '之后',
139
+ filterBefore: '之前',
140
+ filterPaths: '路径',
141
+ filterPathsMore: '文件过多,目录树已截断',
142
+ filterPathSearch: '搜索文件或目录',
143
+ filterCalendarSets: '日历写入',
144
+ filterSelected: '已选 {count} 项',
145
+ // BCP-47 tag for the filter calendar. The month title and the weekday row
146
+ // used to come from `Intl.DateTimeFormat(undefined, …)`, i.e. the BROWSER's
147
+ // language — an English drawer on a zh-CN machine printed "2026年8月" over
148
+ // 一二三四五六日. The dictionary is what knows which language the drawer is
149
+ // speaking, so the tag lives here.
150
+ filterLocale: 'zh-CN',
151
+ allBranches: '全部分支',
115
152
  expandAll: '展开全部',
116
153
  collapseAll: '收起全部',
154
+ // Filtering the file list. Separate from the funnel above the commit list:
155
+ // that one asks git for a different set of commits, this one only hides rows
156
+ // already on screen.
157
+ filterFiles: '过滤文件',
158
+ filterFilesPlaceholder: '过滤文件,空格分隔多个关键字',
159
+ filterFilesClear: '清除过滤',
160
+ filesFiltered: '{shown} / {count} 文件',
161
+ filterNoMatch: '没有匹配的文件',
117
162
  noBranch: '(无分支)',
118
163
  copyCommit: '复制提交说明',
119
164
  copiedCommit: '已复制',
@@ -147,6 +192,19 @@ export const zh: Record<WorkbenchKey, string> = {
147
192
  'op.ok.fetch': '已获取远端信息',
148
193
  'op.ok.pull': '拉取完成',
149
194
  'op.ok.push': '推送成功',
195
+ 'op.ok.discardFile': '已撤回',
196
+ // The row action, and the dialog it opens. IDEA calls this Rollback and
197
+ // means "take the file back to its committed state" — not "undo my last
198
+ // edit", which is the editor's job and a different promise.
199
+ discardAction: '撤回改动',
200
+ discardTitle: '撤回改动?',
201
+ discardConfirm: '撤回',
202
+ discardCancel: '取消',
203
+ // One body per consequence. The dialog never says a generic "are you sure":
204
+ // the whole point of it is to name which of these three is about to happen.
205
+ discardBodyRestore: '{path} 将还原成上次提交时的样子。这里的 {added} 行新增、{deleted} 行删除无法找回。',
206
+ discardBodyDelete: '{path} 从未被 git 记录过,删除后无法找回。',
207
+ discardBodyUnrename: '撤销重命名:{path} 改回 {previousPath},改名期间的内容改动一并丢弃。',
150
208
  'op.fail.auth': '认证失败。凭据提示已被禁用,请先在终端里配置好凭据再重试。',
151
209
  'op.fail.network': '网络不可达:主机名解析失败或连接不上。检查网络与远程地址后重试。',
152
210
  'op.fail.no-upstream': '当前分支没有上游分支。',
@@ -224,8 +282,36 @@ export const en: Record<WorkbenchKey, string> = {
224
282
  noTextDiff: 'No text changes',
225
283
  noCommits: 'No commit history',
226
284
  historyLabel: 'Commit history',
285
+ commitAuthor: 'Author',
286
+ commitCommitter: 'Committer',
287
+ commitDate: 'Committed',
288
+ historyFilterPlaceholder: 'Filter: user: name / path: dir / after: date / text',
289
+ historyNoMatch: 'No matching commits',
290
+ filterClearAll: 'Clear all',
291
+ filterBy: 'Filter by',
292
+ filterUsers: 'Users',
293
+ filterUserSearch: 'Search authors',
294
+ filterAuthorsMore: 'Showing the 500 busiest authors only',
295
+ filterDate: 'Date',
296
+ filterToday: 'Today',
297
+ filterLast7: 'Last 7 days',
298
+ filterLast30: 'Last 30 days',
299
+ filterAfter: 'After',
300
+ filterBefore: 'Before',
301
+ filterPaths: 'Paths',
302
+ filterPathsMore: 'Too many files — tree truncated',
303
+ filterPathSearch: 'Search files or folders',
304
+ filterCalendarSets: 'Calendar sets',
305
+ filterSelected: '{count} selected',
306
+ filterLocale: 'en-US',
307
+ allBranches: 'All branches',
227
308
  expandAll: 'Expand all',
228
309
  collapseAll: 'Collapse all',
310
+ filterFiles: 'Filter files',
311
+ filterFilesPlaceholder: 'Filter files; space-separated terms',
312
+ filterFilesClear: 'Clear filter',
313
+ filesFiltered: '{shown} / {count} files',
314
+ filterNoMatch: 'No file matches',
229
315
  noBranch: '(no branch)',
230
316
  copyCommit: 'Copy message',
231
317
  copiedCommit: 'Copied',
@@ -259,6 +345,14 @@ export const en: Record<WorkbenchKey, string> = {
259
345
  'op.ok.fetch': 'Fetched',
260
346
  'op.ok.pull': 'Pulled',
261
347
  'op.ok.push': 'Pushed',
348
+ 'op.ok.discardFile': 'Rolled back',
349
+ discardAction: 'Roll back changes',
350
+ discardTitle: 'Roll back changes?',
351
+ discardConfirm: 'Roll back',
352
+ discardCancel: 'Cancel',
353
+ discardBodyRestore: '{path} goes back to its committed content. The {added} added and {deleted} deleted lines here cannot be recovered.',
354
+ discardBodyDelete: '{path} was never recorded by git. Deleting it cannot be undone.',
355
+ discardBodyUnrename: 'Undo the rename: {path} goes back to {previousPath}, and content changed along the way is lost.',
262
356
  'op.fail.auth': 'Authentication failed. Credential prompts are disabled here — set your credentials up in a terminal first.',
263
357
  'op.fail.network': 'The network was unreachable — the host could not be resolved or the connection failed. Check connectivity and the remote URL, then retry.',
264
358
  'op.fail.no-upstream': 'This branch has no upstream.',