@young1lin/dsh-ui-gitworkbench 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +48 -0
- package/CHANGELOG_EN.md +48 -0
- package/README.md +36 -6
- package/README_EN.md +3 -2
- package/lib/client.js +2126 -304
- package/lib/discard-ops.js +158 -0
- package/lib/fs-remove.js +73 -0
- package/lib/git-log.js +10 -6
- package/lib/git-ops.js +30 -9
- package/lib/index.js +191 -5
- package/lib/log-filter.js +71 -0
- package/lib/shortlog.js +40 -0
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +523 -6
- package/src/client/GitWorkbenchPanel.tsx +1030 -26
- package/src/client/active-file.ts +83 -0
- package/src/client/calendar.ts +99 -0
- package/src/client/commit-filter.ts +49 -0
- package/src/client/dir-tree.ts +112 -0
- package/src/client/discard-flow.ts +82 -0
- package/src/client/file-filter.ts +66 -0
- package/src/client/index.ts +52 -5
- package/src/client/locales.ts +94 -0
- package/src/client/log-filter-query.ts +189 -0
- package/src/client/path-select.ts +131 -0
- package/src/discard-ops.ts +197 -0
- package/src/fs-remove.ts +76 -0
- package/src/git-log.ts +24 -6
- package/src/git-ops.ts +39 -7
- package/src/index.ts +191 -4
- package/src/log-filter.ts +87 -0
- package/src/shortlog.ts +46 -0
|
@@ -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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a roll-back click does with the host's answer.
|
|
3
|
+
*
|
|
4
|
+
* The click never acts on its own: it asks the host what rolling this file
|
|
5
|
+
* back WOULD do, and this decides what the answer means. Four answers are
|
|
6
|
+
* possible and three of them used to collapse into one — the reader who
|
|
7
|
+
* clicked saw the same nothing whether the file was already clean, the host
|
|
8
|
+
* threw, or the request never arrived. "Nothing visibly happened" is the one
|
|
9
|
+
* outcome a destructive control must never produce ambiguously: it is
|
|
10
|
+
* indistinguishable from a dead button, and the natural response to a dead
|
|
11
|
+
* button is to click it again.
|
|
12
|
+
*
|
|
13
|
+
* So a failure REPORTS and a stale row REFRESHES, and those are different
|
|
14
|
+
* things. Refreshing is the honest answer to "git says this file has no
|
|
15
|
+
* changes": the row disappears, which is both the feedback and the fix, and a
|
|
16
|
+
* banner reading "nothing happened" would leave the row that caused it sitting
|
|
17
|
+
* right there. A failure has no such self-explaining fix, so it has to be said.
|
|
18
|
+
*
|
|
19
|
+
* Pure so vitest can load it — the panel it serves pulls React and a CSS
|
|
20
|
+
* module. The panel imports {@link DiscardPreview} from here for the same
|
|
21
|
+
* reason: a decision about the shape cannot be tested where the shape lives.
|
|
22
|
+
*
|
|
23
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/discard-flow
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** What the host says rolling one file back would do. */
|
|
27
|
+
export interface DiscardPreview {
|
|
28
|
+
/** Absent when git reports nothing to roll back for that path. */
|
|
29
|
+
readonly effect?: 'restore' | 'delete' | 'recover' | 'unrename'
|
|
30
|
+
readonly irreversible?: boolean
|
|
31
|
+
readonly previousPath?: string
|
|
32
|
+
readonly error?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The host's reply to `discardPlan`, failure included. */
|
|
36
|
+
export type DiscardAnswer =
|
|
37
|
+
/** The call returned; the plan may still be empty. */
|
|
38
|
+
| { readonly kind: 'plan'; readonly plan: DiscardPreview }
|
|
39
|
+
/** The call did not return a plan — it errored, threw, or was refused. */
|
|
40
|
+
| { readonly kind: 'failed'; readonly error: string }
|
|
41
|
+
|
|
42
|
+
/** What the drawer does next. */
|
|
43
|
+
export type DiscardNext =
|
|
44
|
+
/** Open the confirmation naming this plan's consequence. */
|
|
45
|
+
| { readonly kind: 'confirm'; readonly plan: DiscardPreview }
|
|
46
|
+
/** Carry it out with no dialog — nothing is lost. */
|
|
47
|
+
| { readonly kind: 'run'; readonly effect: string }
|
|
48
|
+
/** The row was stale; reload the tree and let it go away. */
|
|
49
|
+
| { readonly kind: 'refresh' }
|
|
50
|
+
/** Say why nothing was done. */
|
|
51
|
+
| { readonly kind: 'report'; readonly error: string }
|
|
52
|
+
|
|
53
|
+
/** Fallback text for a failure that arrived with nothing to say. */
|
|
54
|
+
export const UNKNOWN_DISCARD_ERROR = 'discardPlan failed'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decide what a roll-back click does with the answer it got.
|
|
58
|
+
*
|
|
59
|
+
* @param answer - the host's reply, or the failure that replaced it.
|
|
60
|
+
* @returns the single next step; never null, because every answer including a
|
|
61
|
+
* broken one has to lead somewhere the reader can see.
|
|
62
|
+
*/
|
|
63
|
+
export function nextAfterPlan(answer: DiscardAnswer): DiscardNext {
|
|
64
|
+
if (answer.kind === 'failed') {
|
|
65
|
+
const error = answer.error.trim()
|
|
66
|
+
return { kind: 'report', error: error.length > 0 ? error : UNKNOWN_DISCARD_ERROR }
|
|
67
|
+
}
|
|
68
|
+
const plan = answer.plan
|
|
69
|
+
// The host reports a refusal in-band too, so a plan carrying an error is a
|
|
70
|
+
// failure that happened to arrive over a successful call.
|
|
71
|
+
if (typeof plan.error === 'string' && plan.error.trim().length > 0) {
|
|
72
|
+
return { kind: 'report', error: plan.error.trim() }
|
|
73
|
+
}
|
|
74
|
+
if (plan.effect === undefined) return { kind: 'refresh' }
|
|
75
|
+
// `recover` — a deleted file coming back — loses nothing, and a confirmation
|
|
76
|
+
// in front of a pure gain is how people learn to dismiss confirmations
|
|
77
|
+
// without reading them. Only an EXPLICIT `false` skips the dialog: a host
|
|
78
|
+
// newer than this bundle can name an effect this client has no copy for, and
|
|
79
|
+
// a missing flag read as "reversible" would act on it silently.
|
|
80
|
+
if (plan.irreversible === false) return { kind: 'run', effect: plan.effect }
|
|
81
|
+
return { kind: 'confirm', plan }
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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 DiscardAnswer, 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
|
-
|
|
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,30 @@ 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<DiscardAnswer> => {
|
|
215
|
+
// A throw here used to be nobody's: the click had already put the
|
|
216
|
+
// drawer into "asking the host", and an unhandled rejection left it
|
|
217
|
+
// there with no dialog and no way back except closing the drawer.
|
|
218
|
+
try {
|
|
219
|
+
const result = await connection.rpc.call(
|
|
220
|
+
'/api',
|
|
221
|
+
'gitWorkbench/discardPlan',
|
|
222
|
+
{ args: { worktreePath: worktreePath ?? '', path } },
|
|
223
|
+
signal,
|
|
224
|
+
) as { ok: boolean; value?: DiscardPreview; error?: { message?: string } }
|
|
225
|
+
if (result.ok && result.value !== undefined) return { kind: 'plan', plan: result.value }
|
|
226
|
+
return { kind: 'failed', error: result.error?.message ?? '' }
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return { kind: 'failed', error: error instanceof Error ? error.message : String(error) }
|
|
229
|
+
}
|
|
230
|
+
},
|
|
184
231
|
runGitOp: async (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal): Promise<GitOpResult> => {
|
|
185
232
|
const result = await connection.rpc.call(
|
|
186
233
|
'/api',
|