@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
package/src/client/locales.ts
CHANGED
|
@@ -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.',
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query-box grammar for the history filter, and its chip model.
|
|
3
|
+
*
|
|
4
|
+
* The box and the funnel popup are two views of ONE LogFilter: typing
|
|
5
|
+
* `user:lia after:2 weeks ago` and ticking authors in the popup produce the
|
|
6
|
+
* same object, rendered back into the box by {@link serializeLogQuery} and
|
|
7
|
+
* into removable chips by {@link chipsFromFilter}. Everything is pure so the
|
|
8
|
+
* grammar round-trips under test: parse(serialize(f)) === f.
|
|
9
|
+
*
|
|
10
|
+
* Grammar (prefixes case-insensitive, IDEA-style):
|
|
11
|
+
* bare words → the text criterion (commit-message match)
|
|
12
|
+
* user:<name> → author; repeatable, multiple OR
|
|
13
|
+
* path:<spec> → pathspec; repeatable, union
|
|
14
|
+
* after:<date> → approxidate lower bound, single (last wins)
|
|
15
|
+
* before:<date> → approxidate upper bound, single
|
|
16
|
+
* "..." → quoted span: exact value after a prefix, or exact text
|
|
17
|
+
* unknown:prefix → an ordinary word (lenient, like IDEA)
|
|
18
|
+
*
|
|
19
|
+
* Only DATE values auto-extend over following words ("after:2 weeks ago") —
|
|
20
|
+
* approxidate is naturally multi-word. user/path take their inline value;
|
|
21
|
+
* names with spaces come from the popup or quotes.
|
|
22
|
+
*
|
|
23
|
+
* @module @young1lin/dsh-ui-gitworkbench/log-filter-query
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { LogFilter } from '../log-filter.ts'
|
|
27
|
+
|
|
28
|
+
/** The filter that filters nothing. */
|
|
29
|
+
export function emptyQueryFilter(): LogFilter {
|
|
30
|
+
return { users: [], text: '', textRegex: false, paths: [], after: '', before: '' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Criterion kinds a chip can represent. */
|
|
34
|
+
export type ChipKind = 'user' | 'path' | 'after' | 'before' | 'text'
|
|
35
|
+
|
|
36
|
+
/** One removable criterion, as shown under the filter box. */
|
|
37
|
+
export interface FilterChip {
|
|
38
|
+
readonly kind: ChipKind
|
|
39
|
+
readonly value: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PREFIXES: readonly ChipKind[] = ['user', 'path', 'after', 'before']
|
|
43
|
+
const PREFIX_RE = /^(user|path|after|before):(.*)$/i
|
|
44
|
+
|
|
45
|
+
interface Token {
|
|
46
|
+
readonly value: string
|
|
47
|
+
readonly quoted: boolean
|
|
48
|
+
readonly kind?: ChipKind
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tokenize(query: string): Token[] {
|
|
52
|
+
const tokens: Token[] = []
|
|
53
|
+
let i = 0
|
|
54
|
+
while (i < query.length) {
|
|
55
|
+
while (i < query.length && /\s/.test(query[i]!)) i += 1
|
|
56
|
+
if (i >= query.length) break
|
|
57
|
+
if (query[i] === '"') {
|
|
58
|
+
const end = query.indexOf('"', i + 1)
|
|
59
|
+
const value = end === -1 ? query.slice(i + 1) : query.slice(i + 1, end)
|
|
60
|
+
tokens.push({ value, quoted: true })
|
|
61
|
+
i = end === -1 ? query.length : end + 1
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
// `prefix:"value with spaces"` — the quote opens INSIDE the word, right
|
|
65
|
+
// after the colon, so the word scanner below must not eat it as text.
|
|
66
|
+
const prefixQuote = /^(user|path|after|before):"/i.exec(query.slice(i))
|
|
67
|
+
if (prefixQuote !== null) {
|
|
68
|
+
const kind = prefixQuote[1]!.toLowerCase() as ChipKind
|
|
69
|
+
const open = i + prefixQuote[0].length
|
|
70
|
+
const end = query.indexOf('"', open)
|
|
71
|
+
const value = end === -1 ? query.slice(open) : query.slice(open, end)
|
|
72
|
+
tokens.push({ value, quoted: true, kind })
|
|
73
|
+
i = end === -1 ? query.length : end + 1
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
const start = i
|
|
77
|
+
while (i < query.length && !/\s/.test(query[i]!)) i += 1
|
|
78
|
+
const word = query.slice(start, i)
|
|
79
|
+
const match = PREFIX_RE.exec(word)
|
|
80
|
+
tokens.push(match === null
|
|
81
|
+
? { value: word, quoted: false }
|
|
82
|
+
: { value: match[2]!, quoted: false, kind: match[1]!.toLowerCase() as ChipKind })
|
|
83
|
+
}
|
|
84
|
+
return tokens
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse the box's text into a filter.
|
|
89
|
+
* @param query - raw box contents.
|
|
90
|
+
*/
|
|
91
|
+
export function parseLogQuery(query: string): LogFilter {
|
|
92
|
+
const tokens = tokenize(query)
|
|
93
|
+
const users: string[] = []
|
|
94
|
+
const paths: string[] = []
|
|
95
|
+
let text = ''
|
|
96
|
+
let after = ''
|
|
97
|
+
let before = ''
|
|
98
|
+
|
|
99
|
+
const textWords: string[] = []
|
|
100
|
+
let i = 0
|
|
101
|
+
while (i < tokens.length) {
|
|
102
|
+
const token = tokens[i]!
|
|
103
|
+
if (token.kind === undefined) {
|
|
104
|
+
textWords.push(token.value)
|
|
105
|
+
i += 1
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (token.kind === 'user' || token.kind === 'path') {
|
|
109
|
+
const list = token.kind === 'user' ? users : paths
|
|
110
|
+
if (token.value.length > 0 && !list.includes(token.value)) list.push(token.value)
|
|
111
|
+
i += 1
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
// Date bounds: approxidate is naturally multi-word, so an unquoted value
|
|
115
|
+
// swallows the bare words that follow, stopping at the next prefix token
|
|
116
|
+
// or a quoted span. A QUOTED base value is exact — no extension.
|
|
117
|
+
const parts: string[] = [token.value]
|
|
118
|
+
let j = token.quoted ? i : i + 1
|
|
119
|
+
while (j < tokens.length && tokens[j]!.kind === undefined && !tokens[j]!.quoted && tokens[j]!.value.length > 0) {
|
|
120
|
+
parts.push(tokens[j]!.value)
|
|
121
|
+
j += 1
|
|
122
|
+
}
|
|
123
|
+
const value = parts.join(' ').trim()
|
|
124
|
+
if (token.kind === 'after') after = value
|
|
125
|
+
else before = value
|
|
126
|
+
i = token.quoted ? i + 1 : j
|
|
127
|
+
}
|
|
128
|
+
text = textWords.join(' ').trim()
|
|
129
|
+
return { users, text, textRegex: false, paths, after, before }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Quote a serialized value iff it would not reparse as itself. */
|
|
133
|
+
function quote(value: string): string {
|
|
134
|
+
return /\s/.test(value) ? `"${value}"` : value
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render a filter back into the box's grammar. The text criterion goes last
|
|
139
|
+
* and is quoted when any of its words would parse as a prefix token.
|
|
140
|
+
* @param filter - the filter to render.
|
|
141
|
+
*/
|
|
142
|
+
export function serializeLogQuery(filter: LogFilter): string {
|
|
143
|
+
const parts: string[] = []
|
|
144
|
+
for (const user of filter.users) parts.push(`user:${quote(user)}`)
|
|
145
|
+
for (const path of filter.paths) parts.push(`path:${quote(path)}`)
|
|
146
|
+
if (filter.after.length > 0) parts.push(`after:${quote(filter.after)}`)
|
|
147
|
+
if (filter.before.length > 0) parts.push(`before:${quote(filter.before)}`)
|
|
148
|
+
if (filter.text.length > 0) {
|
|
149
|
+
const looksPrefixed = filter.text.split(/\s+/).some(word => PREFIX_RE.test(word))
|
|
150
|
+
parts.push(looksPrefixed ? `"${filter.text}"` : filter.text)
|
|
151
|
+
}
|
|
152
|
+
return parts.join(' ')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One chip per criterion, in grammar order: users, paths, bounds, text.
|
|
157
|
+
* @param filter - the filter to decompose.
|
|
158
|
+
*/
|
|
159
|
+
export function chipsFromFilter(filter: LogFilter): readonly FilterChip[] {
|
|
160
|
+
const chips: FilterChip[] = []
|
|
161
|
+
for (const user of filter.users) chips.push({ kind: 'user', value: user })
|
|
162
|
+
for (const path of filter.paths) chips.push({ kind: 'path', value: path })
|
|
163
|
+
if (filter.after.length > 0) chips.push({ kind: 'after', value: filter.after })
|
|
164
|
+
if (filter.before.length > 0) chips.push({ kind: 'before', value: filter.before })
|
|
165
|
+
if (filter.text.length > 0) chips.push({ kind: 'text', value: filter.text })
|
|
166
|
+
return chips
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The filter minus one chip. Immutable; dropping the last criterion yields
|
|
171
|
+
* the empty filter.
|
|
172
|
+
* @param filter - current filter.
|
|
173
|
+
* @param kind - the chip's criterion kind.
|
|
174
|
+
* @param value - the chip's value (which user, which path).
|
|
175
|
+
*/
|
|
176
|
+
export function removeChip(filter: LogFilter, kind: ChipKind, value: string): LogFilter {
|
|
177
|
+
switch (kind) {
|
|
178
|
+
case 'user':
|
|
179
|
+
return { ...filter, users: filter.users.filter(user => user !== value) }
|
|
180
|
+
case 'path':
|
|
181
|
+
return { ...filter, paths: filter.paths.filter(path => path !== value) }
|
|
182
|
+
case 'after':
|
|
183
|
+
return { ...filter, after: '' }
|
|
184
|
+
case 'before':
|
|
185
|
+
return { ...filter, before: '' }
|
|
186
|
+
case 'text':
|
|
187
|
+
return { ...filter, text: '', textRegex: false }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkbox-tree semantics for the path picker.
|
|
3
|
+
*
|
|
4
|
+
* Ticking a FOLDER means its whole subtree — so the selection is a set of
|
|
5
|
+
* pathspecs with an invariant: no member covers another. Ticking a folder
|
|
6
|
+
* absorbs the files already ticked inside it (the lone file's chip gives way
|
|
7
|
+
* to the folder's one); unticking one file under a checked folder cascades
|
|
8
|
+
* OUT — the folder is replaced by its other children, level by level. Rows
|
|
9
|
+
* then DERIVE their checkbox state (on / off / partial) from the set, which
|
|
10
|
+
* is why ticking a folder visibly checks everything under it.
|
|
11
|
+
*
|
|
12
|
+
* Pure throughout; the index is built from the same raw path list the host
|
|
13
|
+
* sent, so children order is the tree's alphabetical order.
|
|
14
|
+
*
|
|
15
|
+
* @module @young1lin/dsh-ui-gitworkbench/path-select
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Directory path → its direct children, full paths, files and dirs apart. */
|
|
19
|
+
export interface PathIndex {
|
|
20
|
+
readonly dirs: ReadonlyMap<string, readonly string[]>
|
|
21
|
+
readonly files: ReadonlyMap<string, readonly string[]>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Index a raw path list for child lookup.
|
|
26
|
+
* @param paths - repo-relative file paths, exactly as `repoTree` returned.
|
|
27
|
+
*/
|
|
28
|
+
export function buildIndex(paths: readonly string[]): PathIndex {
|
|
29
|
+
const dirs = new Map<string, string[]>()
|
|
30
|
+
const files = new Map<string, string[]>()
|
|
31
|
+
const noteDir = (dir: string): void => {
|
|
32
|
+
if (!dirs.has(dir)) dirs.set(dir, [])
|
|
33
|
+
}
|
|
34
|
+
noteDir('')
|
|
35
|
+
for (const path of paths) {
|
|
36
|
+
if (path.length === 0) continue
|
|
37
|
+
const parts = path.split('/')
|
|
38
|
+
let dir = ''
|
|
39
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
40
|
+
const childDir = dir === '' ? parts[i]! : `${dir}/${parts[i]!}`
|
|
41
|
+
noteDir(childDir)
|
|
42
|
+
const list = dirs.get(dir)!
|
|
43
|
+
if (!list.includes(childDir)) list.push(childDir)
|
|
44
|
+
dir = childDir
|
|
45
|
+
}
|
|
46
|
+
const list = files.get(dir) ?? []
|
|
47
|
+
list.push(path)
|
|
48
|
+
files.set(dir, list)
|
|
49
|
+
}
|
|
50
|
+
for (const list of dirs.values()) list.sort()
|
|
51
|
+
for (const list of files.values()) list.sort()
|
|
52
|
+
return { dirs, files }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Children of a directory, alphabetical by full path — the tree's order. */
|
|
56
|
+
function childrenOf(index: PathIndex, dir: string): readonly string[] {
|
|
57
|
+
return [...(index.dirs.get(dir) ?? []), ...(index.files.get(dir) ?? [])].sort()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Is `p` selected — itself ticked, or inside a ticked directory?
|
|
62
|
+
* (Segment-boundary prefix: `src` does not cover `src2`.)
|
|
63
|
+
*/
|
|
64
|
+
export function isCovered(paths: readonly string[], p: string): boolean {
|
|
65
|
+
return paths.some(tick => tick === p || p.startsWith(`${tick}/`))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tick a path. No-op when an ancestor already covers it; absorbs every
|
|
70
|
+
* descendant it covers, keeping the set minimal — one folder chip, never the
|
|
71
|
+
* pile of files under it.
|
|
72
|
+
*/
|
|
73
|
+
export function addPath(paths: readonly string[], p: string): readonly string[] {
|
|
74
|
+
if (isCovered(paths, p)) return paths
|
|
75
|
+
const kept = paths.filter(tick => !(tick === p || tick.startsWith(`${p}/`)))
|
|
76
|
+
return [...kept, p]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Untick a path. Removing an exact tick drops it; removing a file COVERED by
|
|
81
|
+
* a ticked folder replaces that folder with its other children, level by
|
|
82
|
+
* level down to the file — the standard cascade-out.
|
|
83
|
+
*/
|
|
84
|
+
export function removePath(paths: readonly string[], p: string, index: PathIndex): readonly string[] {
|
|
85
|
+
const out: string[] = []
|
|
86
|
+
for (const tick of paths) {
|
|
87
|
+
if (tick !== p && !p.startsWith(`${tick}/`)) {
|
|
88
|
+
out.push(tick)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
// This tick is p itself or an ancestor of it; replace it with the subtree
|
|
92
|
+
// minus p. Walk down the chain, adding each level's other children.
|
|
93
|
+
let dir = tick
|
|
94
|
+
while (dir !== p) {
|
|
95
|
+
// The child of `dir` on the way to p: the next path segment.
|
|
96
|
+
const rest = p.slice(dir.length + 1)
|
|
97
|
+
const nextName = dir === '' ? p.split('/')[0]! : rest.split('/')[0]!
|
|
98
|
+
const next = dir === '' ? nextName : `${dir}/${nextName}`
|
|
99
|
+
for (const child of childrenOf(index, dir)) {
|
|
100
|
+
if (child !== next) out.push(child)
|
|
101
|
+
}
|
|
102
|
+
dir = next
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every file under a directory (empty for a file path). */
|
|
109
|
+
function filesUnder(index: PathIndex, dir: string): readonly string[] {
|
|
110
|
+
const out: string[] = []
|
|
111
|
+
const stack = [dir]
|
|
112
|
+
while (stack.length > 0) {
|
|
113
|
+
const current = stack.pop()!
|
|
114
|
+
out.push(...(index.files.get(current) ?? []))
|
|
115
|
+
stack.push(...(index.dirs.get(current) ?? []))
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A row's checkbox state, derived: `on` when covered — by an ancestor tick OR
|
|
122
|
+
* by every file under it being covered individually; `partial` when a
|
|
123
|
+
* directory holds some but not all of its files; else `off`.
|
|
124
|
+
*/
|
|
125
|
+
export function checkedState(paths: readonly string[], p: string, index: PathIndex): 'on' | 'off' | 'partial' {
|
|
126
|
+
if (isCovered(paths, p)) return 'on'
|
|
127
|
+
const files = filesUnder(index, p)
|
|
128
|
+
if (files.length === 0) return 'off'
|
|
129
|
+
const covered = files.filter(file => isCovered(paths, file)).length
|
|
130
|
+
return covered === files.length ? 'on' : covered > 0 ? 'partial' : 'off'
|
|
131
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What "discard this file" resolves to, as a plan the host can carry out.
|
|
3
|
+
*
|
|
4
|
+
* This is the confirmation design `git-ops.ts` says a destructive button owes
|
|
5
|
+
* before it may exist. Three rules hold here, and each one closes a way this
|
|
6
|
+
* feature could quietly destroy the wrong thing:
|
|
7
|
+
*
|
|
8
|
+
* - **The plan is derived from git's OWN report of the file, never from what
|
|
9
|
+
* the client said it was.** A client that mislabels a tracked file as
|
|
10
|
+
* untracked would otherwise turn a restore into a delete. `planFor` takes
|
|
11
|
+
* a porcelain XY pair that the host read itself.
|
|
12
|
+
* - **No plan may contain a destructive spelling.** Not `clean`, not
|
|
13
|
+
* `reset --hard`, not `checkout -f`, no `--force`. `git restore` scoped to
|
|
14
|
+
* one pathspec after `--` is the whole vocabulary; a stray `clean` would
|
|
15
|
+
* take out every untracked file in the tree instead of the one named.
|
|
16
|
+
* `tests/discard-ops.test.ts` scans every plan this module can produce.
|
|
17
|
+
* - **A path that is deleted from disk is checked far harder than a
|
|
18
|
+
* pathspec.** git refuses to leave the repository; `fs.rm` does not, so
|
|
19
|
+
* `isSafeRelativePath` rejects absolutes, drive letters, UNC prefixes and
|
|
20
|
+
* any `..` segment before a delete step can name a file.
|
|
21
|
+
*
|
|
22
|
+
* IDEA parity decides the semantics: Rollback there takes a file back to its
|
|
23
|
+
* committed state in one gesture and does not ask whether the change was
|
|
24
|
+
* staged, so neither does this. A file that was never committed goes away; a
|
|
25
|
+
* file that was deleted comes back.
|
|
26
|
+
*
|
|
27
|
+
* Pure throughout — no spawning, no fs. `src/index.ts` executes the steps.
|
|
28
|
+
*
|
|
29
|
+
* @module @young1lin/dsh-ui-gitworkbench/discard-ops
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { isSafePathArg, parseStatusLine } from './git-ops.js'
|
|
33
|
+
|
|
34
|
+
/** One thing the host does, in order. */
|
|
35
|
+
export type DiscardStep =
|
|
36
|
+
/** Run git with this argv. */
|
|
37
|
+
| { readonly kind: 'git'; readonly argv: readonly string[] }
|
|
38
|
+
/** Remove this repo-relative path from disk. Tolerates an absent file. */
|
|
39
|
+
| { readonly kind: 'delete'; readonly path: string }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the reader is about to lose, which is what the confirmation must say.
|
|
43
|
+
* The client maps these to copy; keeping them as data means the wording can be
|
|
44
|
+
* bilingual without this module knowing about locales.
|
|
45
|
+
*/
|
|
46
|
+
export type DiscardEffect =
|
|
47
|
+
/** Tracked file returns to its committed content. Local edits are gone. */
|
|
48
|
+
| 'restore'
|
|
49
|
+
/** File leaves the disk. git never had it, so nothing can bring it back. */
|
|
50
|
+
| 'delete'
|
|
51
|
+
/** A deleted file comes back. Nothing is lost — no confirmation needed. */
|
|
52
|
+
| 'recover'
|
|
53
|
+
/** A rename is undone: the old path returns, the new one goes. */
|
|
54
|
+
| 'unrename'
|
|
55
|
+
|
|
56
|
+
export interface DiscardPlan {
|
|
57
|
+
readonly steps: readonly DiscardStep[]
|
|
58
|
+
readonly effect: DiscardEffect
|
|
59
|
+
/**
|
|
60
|
+
* Whether carrying this out can lose work no git object holds. Drives
|
|
61
|
+
* whether the client confirms at all: recovering a deleted file is pure
|
|
62
|
+
* gain, and a dialog in front of it is noise that teaches people to click
|
|
63
|
+
* through dialogs.
|
|
64
|
+
*/
|
|
65
|
+
readonly irreversible: boolean
|
|
66
|
+
/** The path the reader named, for the confirmation copy. */
|
|
67
|
+
readonly path: string
|
|
68
|
+
/** For `unrename`, the path the file is going back to. */
|
|
69
|
+
readonly previousPath?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a path is safe to hand a filesystem delete.
|
|
74
|
+
*
|
|
75
|
+
* Stricter than {@link isSafePathArg}, which only has to keep git from reading
|
|
76
|
+
* a path as an option: git will not step outside the repository whatever it is
|
|
77
|
+
* given, so a pathspec needs no traversal check. A delete has no such backstop.
|
|
78
|
+
* Rejected: absolute paths (POSIX and Windows), UNC prefixes, drive letters,
|
|
79
|
+
* NUL bytes, and any `..` segment — including one buried mid-path, which is
|
|
80
|
+
* how traversal is usually spelled.
|
|
81
|
+
* @param path - repo-relative path from a plan step.
|
|
82
|
+
*/
|
|
83
|
+
export function isSafeRelativePath(path: string): boolean {
|
|
84
|
+
if (!isSafePathArg(path)) return false
|
|
85
|
+
if (path.includes('\0')) return false
|
|
86
|
+
// Windows accepts both separators, so normalise before splitting or
|
|
87
|
+
// `a\..\..\b` walks out through a check that only knew about `/`.
|
|
88
|
+
const unified = path.replace(/\\/g, '/')
|
|
89
|
+
if (unified.startsWith('/')) return false
|
|
90
|
+
if (/^[A-Za-z]:/.test(unified)) return false
|
|
91
|
+
if (unified.startsWith('//')) return false
|
|
92
|
+
return !unified.split('/').includes('..')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Take one file back to HEAD in both the index and the working tree. */
|
|
96
|
+
function restoreBoth(path: string): DiscardStep {
|
|
97
|
+
return { kind: 'git', argv: ['restore', '--source=HEAD', '--staged', '--worktree', '--', path] }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop a file's index entry, leaving the working tree untouched. */
|
|
101
|
+
function unstage(path: string): DiscardStep {
|
|
102
|
+
return { kind: 'git', argv: ['restore', '--staged', '--', path] }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The plan for one file, from git's own porcelain line.
|
|
107
|
+
*
|
|
108
|
+
* @param xy - the two porcelain status columns for this path, e.g. ` M`, `??`,
|
|
109
|
+
* `R `. Read by the host from `git status --porcelain`, never
|
|
110
|
+
* supplied by the client.
|
|
111
|
+
* @param path - repo-relative path, as git printed it.
|
|
112
|
+
* @param previousPath - for a rename, the path HEAD still knows the file by.
|
|
113
|
+
* @returns the ordered plan, or null when the file has nothing to discard.
|
|
114
|
+
* @throws if a path is not safe to pass on.
|
|
115
|
+
*/
|
|
116
|
+
export function planFor(xy: string, path: string, previousPath?: string): DiscardPlan | null {
|
|
117
|
+
if (!isSafePathArg(path)) throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
118
|
+
const index = xy[0] ?? ' '
|
|
119
|
+
const worktree = xy[1] ?? ' '
|
|
120
|
+
|
|
121
|
+
// Untracked and ignored: git has no copy, so the only way back is the
|
|
122
|
+
// filesystem's, and there is none.
|
|
123
|
+
if (xy === '??' || xy === '!!') {
|
|
124
|
+
if (!isSafeRelativePath(path)) throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`)
|
|
125
|
+
return { steps: [{ kind: 'delete', path }], effect: 'delete', irreversible: true, path }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A rename: HEAD holds `previousPath`, the index holds `path`. Bring the old
|
|
129
|
+
// one back first, then retire the new one — doing it the other way round
|
|
130
|
+
// would leave the tree with neither name for as long as the second step
|
|
131
|
+
// takes, which a reader watching a file tree would see.
|
|
132
|
+
if (index === 'R' || index === 'C') {
|
|
133
|
+
if (previousPath === undefined || !isSafePathArg(previousPath)) {
|
|
134
|
+
throw new Error(`rename without a usable previous path: ${JSON.stringify(path)}`)
|
|
135
|
+
}
|
|
136
|
+
if (!isSafeRelativePath(path)) throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`)
|
|
137
|
+
return {
|
|
138
|
+
steps: [restoreBoth(previousPath), unstage(path), { kind: 'delete', path }],
|
|
139
|
+
effect: 'unrename',
|
|
140
|
+
irreversible: true,
|
|
141
|
+
path,
|
|
142
|
+
previousPath,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Added to the index but absent from HEAD: rolling back means the file was
|
|
147
|
+
// never committed, so it leaves. Unstage first — otherwise the index would
|
|
148
|
+
// still carry an entry for a path that no longer exists on disk.
|
|
149
|
+
if (index === 'A') {
|
|
150
|
+
if (!isSafeRelativePath(path)) throw new Error(`unsafe path to delete: ${JSON.stringify(path)}`)
|
|
151
|
+
return {
|
|
152
|
+
steps: [unstage(path), { kind: 'delete', path }],
|
|
153
|
+
effect: 'delete',
|
|
154
|
+
irreversible: true,
|
|
155
|
+
path,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Deleted, either side. HEAD still has the content, so this is recovery:
|
|
160
|
+
// nothing is lost and nothing needs confirming.
|
|
161
|
+
if (index === 'D' || worktree === 'D') {
|
|
162
|
+
return { steps: [restoreBoth(path)], effect: 'recover', irreversible: false, path }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Everything else that git reported as changed — modified, type-changed,
|
|
166
|
+
// staged, unstaged, or both — goes back to HEAD wholesale. Content only the
|
|
167
|
+
// working tree ever held has no object behind it.
|
|
168
|
+
if (index !== ' ' || worktree !== ' ') {
|
|
169
|
+
return { steps: [restoreBoth(path)], effect: 'restore', irreversible: true, path }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Clean: git reported the path with nothing to say about it.
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Find the file in a status report and plan for it.
|
|
178
|
+
*
|
|
179
|
+
* The report is the WHOLE tree's, not one narrowed by a pathspec: git detects
|
|
180
|
+
* a rename by pairing a deletion with an addition, and a pathspec that admits
|
|
181
|
+
* only one of the pair turns `R old -> new` into an unrelated `D` and `??`.
|
|
182
|
+
* The plan for those two is delete-and-lose where the truth is un-rename.
|
|
183
|
+
*
|
|
184
|
+
* @param stdout - `git status --porcelain=v1` over the whole worktree.
|
|
185
|
+
* @param path - the file the reader asked about, as the drawer lists it.
|
|
186
|
+
* @returns the plan, or null when git does not report that path as changed —
|
|
187
|
+
* which is also what a stale tree looks like, and is not an error.
|
|
188
|
+
*/
|
|
189
|
+
export function planFromStatus(stdout: string, path: string): DiscardPlan | null {
|
|
190
|
+
if (!isSafePathArg(path)) throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
191
|
+
for (const line of stdout.split('\n')) {
|
|
192
|
+
const parsed = parseStatusLine(line)
|
|
193
|
+
if (parsed === null || parsed.path !== path) continue
|
|
194
|
+
return planFor(parsed.xy, parsed.path, parsed.renamed ? parsed.previousPath : undefined)
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|