dsh-git-ui 0.0.2 → 0.1.0
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/README.md +27 -20
- package/README.zh.md +28 -21
- package/cordis.patch.yml +1 -1
- package/lib/client.js +36 -35
- package/lib/client.js.map +4 -4
- package/lib/host/actions.d.ts +22 -2
- package/lib/host/core.d.ts +7 -1
- package/lib/host/index.d.ts +12 -10
- package/lib/host/index.js +366 -21
- package/lib/host/index.js.map +4 -4
- package/lib/host/parser.d.ts +37 -1
- package/lib/host/queries.d.ts +17 -0
- package/lib/host/types.d.ts +131 -1
- package/package.json +1 -1
- package/src/client/GitCenter.tsx +1411 -148
- package/src/client/GitPill.tsx +280 -55
- package/src/client/changes-diff.ts +63 -0
- package/src/client/controller.ts +36 -1
- package/src/client/error-text.ts +21 -0
- package/src/client/file-tree.ts +101 -0
- package/src/client/git-graph.ts +188 -0
- package/src/client/icons.tsx +292 -0
- package/src/client/index.ts +2 -1
- package/src/client/locales.ts +124 -0
- package/src/client/popup-close.ts +19 -0
- package/src/client/remote.ts +83 -1
- package/src/client/select-menu.tsx +113 -0
- package/src/client/side-by-side.ts +150 -0
- package/src/client/styles.ts +1375 -86
- package/src/client/time-format.ts +32 -0
- package/src/host/actions.ts +66 -15
- package/src/host/core.ts +9 -2
- package/src/host/index.ts +21 -10
- package/src/host/parser.ts +155 -12
- package/src/host/queries.ts +289 -0
- package/src/host/types.ts +104 -0
package/src/client/locales.ts
CHANGED
|
@@ -21,6 +21,8 @@ export const zh = {
|
|
|
21
21
|
'popup.empty': '工作区干净',
|
|
22
22
|
'popup.emptyCommits': '暂无提交',
|
|
23
23
|
'center.open': '打开 Git 中心',
|
|
24
|
+
'error.localChangesBlock': '无法切换分支:工作区有未提交的变更会被目标分支覆盖。请先提交或暂存(stash)这些变更,然后再切换。',
|
|
25
|
+
'error.handleChanges': '处理变更',
|
|
24
26
|
'center.title': 'Git 中心',
|
|
25
27
|
'center.staged': '已暂存',
|
|
26
28
|
'center.unstaged': '未暂存',
|
|
@@ -37,12 +39,72 @@ export const zh = {
|
|
|
37
39
|
'center.commitHint': '勾选文件时仅提交所选;未勾选时提交全部已暂存内容',
|
|
38
40
|
'center.commitSelected': '已选 {count} 个文件',
|
|
39
41
|
'center.empty': '工作区干净,无需操作',
|
|
42
|
+
'center.untrackedFiles': '未进行版本管理的文件',
|
|
43
|
+
'center.selectFileDiff': '选择文件以对照查看更改',
|
|
40
44
|
'center.done': '操作成功',
|
|
41
45
|
'center.busy': '执行中…',
|
|
46
|
+
'center.changes': '变更',
|
|
47
|
+
'center.history': '历史',
|
|
48
|
+
'center.branches': '分支',
|
|
49
|
+
'center.noCommits': '暂无提交',
|
|
50
|
+
'history.commit': '提交',
|
|
51
|
+
'history.hash': '哈希',
|
|
52
|
+
'history.author': '作者',
|
|
53
|
+
'history.time': '时间',
|
|
54
|
+
'history.allBranches': '全部分支',
|
|
55
|
+
'history.tags': '标签',
|
|
56
|
+
'history.search': '搜索文本或哈希',
|
|
57
|
+
'history.branch': '分支',
|
|
58
|
+
'history.allUsers': '全部用户',
|
|
59
|
+
'history.allTime': '全部时间',
|
|
60
|
+
'history.today': '今天',
|
|
61
|
+
'history.last7d': '近 7 天',
|
|
62
|
+
'history.last30d': '近 30 天',
|
|
63
|
+
'history.last90d': '近 90 天',
|
|
64
|
+
'right.selectCommit': '选择要查看更改的提交',
|
|
65
|
+
'right.commitDetails': '提交详细信息',
|
|
66
|
+
'right.files': '文件变更',
|
|
67
|
+
'right.expandAll': '全部展开',
|
|
68
|
+
'right.collapseAll': '全部收起',
|
|
69
|
+
'history.searchTree': '分支或标签',
|
|
70
|
+
'center.close': '关闭',
|
|
71
|
+
'center.selectCommit': '选择一条提交以查看详情与差异',
|
|
72
|
+
'center.loading': '加载…',
|
|
73
|
+
'center.commitFull': '提交信息',
|
|
74
|
+
'center.diffEmpty': '无文件变更',
|
|
75
|
+
'center.createBranch': '新建分支',
|
|
76
|
+
'center.branchName': '分支名',
|
|
77
|
+
'center.branchFrom': '基于',
|
|
78
|
+
'center.createAndSwitch': '创建并切换',
|
|
79
|
+
'center.switchTo': '切换',
|
|
80
|
+
'center.localBranches': '本地分支',
|
|
81
|
+
'center.remoteBranches': '远程分支',
|
|
82
|
+
'center.currentBranch': '当前分支',
|
|
42
83
|
'time.justNow': '刚刚',
|
|
43
84
|
'time.minutesAgo': '{n} 分钟前',
|
|
44
85
|
'time.hoursAgo': '{n} 小时前',
|
|
45
86
|
'time.daysAgo': '{n} 天前',
|
|
87
|
+
'time.today': '今天',
|
|
88
|
+
'time.yesterday': '昨天',
|
|
89
|
+
'changes.groupStaged': '已暂存更改',
|
|
90
|
+
'changes.groupUnstaged': '更改',
|
|
91
|
+
'changes.groupUnversioned': '未版本控制的文件',
|
|
92
|
+
'changes.actionDiff': '对照查看',
|
|
93
|
+
'changes.dir': '目录(不可对照查看)',
|
|
94
|
+
'changes.selectAll': '全选',
|
|
95
|
+
'diff.baseStaged': '暂存区',
|
|
96
|
+
'diff.baseWorktree': '工作区',
|
|
97
|
+
'diff.prev': '上一个更改',
|
|
98
|
+
'diff.next': '下一个更改',
|
|
99
|
+
'diff.truncated': '差异过大,仅显示前 {count} 行',
|
|
100
|
+
'diff.foldCollapsed': '… {n} 行未变更',
|
|
101
|
+
'diff.binary': '二进制文件,无法显示差异',
|
|
102
|
+
'changes.listTruncated': '仅显示前 {count} 个,共 {total} 个变更',
|
|
103
|
+
'right.noMessage': '无提交信息',
|
|
104
|
+
'history.noResults': '未找到匹配的提交',
|
|
105
|
+
'center.fetch': '拉取远程',
|
|
106
|
+
'center.fetching': '拉取中…',
|
|
107
|
+
'center.fetchDone': '已同步远程',
|
|
46
108
|
} satisfies Record<string, string>
|
|
47
109
|
|
|
48
110
|
export type GitKey = keyof typeof zh
|
|
@@ -68,6 +130,8 @@ export const en: Record<GitKey, string> = {
|
|
|
68
130
|
'popup.empty': 'Working tree clean',
|
|
69
131
|
'popup.emptyCommits': 'No commits yet',
|
|
70
132
|
'center.open': 'Open Git center',
|
|
133
|
+
'error.localChangesBlock': 'Cannot switch branches: uncommitted local changes would be overwritten. Commit or stash them first.',
|
|
134
|
+
'error.handleChanges': 'Review changes',
|
|
71
135
|
'center.title': 'Git center',
|
|
72
136
|
'center.staged': 'staged',
|
|
73
137
|
'center.unstaged': 'unstaged',
|
|
@@ -84,10 +148,70 @@ export const en: Record<GitKey, string> = {
|
|
|
84
148
|
'center.commitHint': 'Selected files are committed; otherwise everything staged is committed',
|
|
85
149
|
'center.commitSelected': '{count} file(s) selected',
|
|
86
150
|
'center.empty': 'Working tree clean — nothing to do',
|
|
151
|
+
'center.untrackedFiles': 'Unversioned files',
|
|
152
|
+
'center.selectFileDiff': 'Select a file to compare changes',
|
|
87
153
|
'center.done': 'Done',
|
|
88
154
|
'center.busy': 'Working…',
|
|
155
|
+
'center.changes': 'Changes',
|
|
156
|
+
'center.history': 'History',
|
|
157
|
+
'center.branches': 'Branches',
|
|
158
|
+
'center.noCommits': 'No commits yet',
|
|
159
|
+
'history.commit': 'Commit',
|
|
160
|
+
'history.hash': 'Hash',
|
|
161
|
+
'history.author': 'Author',
|
|
162
|
+
'history.time': 'Time',
|
|
163
|
+
'history.allBranches': 'All branches',
|
|
164
|
+
'history.tags': 'Tags',
|
|
165
|
+
'history.search': 'Text or hash',
|
|
166
|
+
'history.branch': 'Branch',
|
|
167
|
+
'history.allUsers': 'All users',
|
|
168
|
+
'history.allTime': 'All time',
|
|
169
|
+
'history.today': 'Today',
|
|
170
|
+
'history.last7d': 'Last 7 days',
|
|
171
|
+
'history.last30d': 'Last 30 days',
|
|
172
|
+
'history.last90d': 'Last 90 days',
|
|
173
|
+
'right.selectCommit': 'Select a commit to view its changes',
|
|
174
|
+
'right.commitDetails': 'Commit details',
|
|
175
|
+
'right.files': 'Changed files',
|
|
176
|
+
'right.expandAll': 'Expand all',
|
|
177
|
+
'right.collapseAll': 'Collapse all',
|
|
178
|
+
'history.searchTree': 'Branch or tag',
|
|
179
|
+
'center.close': 'Close',
|
|
180
|
+
'center.selectCommit': 'Select a commit to view details and diffs',
|
|
181
|
+
'center.loading': 'Loading…',
|
|
182
|
+
'center.commitFull': 'Commit details',
|
|
183
|
+
'center.diffEmpty': 'No file changes',
|
|
184
|
+
'center.createBranch': 'New branch',
|
|
185
|
+
'center.branchName': 'Branch name',
|
|
186
|
+
'center.branchFrom': 'From',
|
|
187
|
+
'center.createAndSwitch': 'Create & switch',
|
|
188
|
+
'center.switchTo': 'Switch',
|
|
189
|
+
'center.localBranches': 'Local branches',
|
|
190
|
+
'center.remoteBranches': 'Remote branches',
|
|
191
|
+
'center.currentBranch': 'Current branch',
|
|
89
192
|
'time.justNow': 'just now',
|
|
90
193
|
'time.minutesAgo': '{n}m ago',
|
|
91
194
|
'time.hoursAgo': '{n}h ago',
|
|
92
195
|
'time.daysAgo': '{n}d ago',
|
|
196
|
+
'time.today': 'Today',
|
|
197
|
+
'time.yesterday': 'Yesterday',
|
|
198
|
+
'changes.groupStaged': 'Staged Changes',
|
|
199
|
+
'changes.groupUnstaged': 'Changes',
|
|
200
|
+
'changes.groupUnversioned': 'Unversioned Files',
|
|
201
|
+
'changes.actionDiff': 'Show diff',
|
|
202
|
+
'changes.dir': 'Directory (no diff)',
|
|
203
|
+
'changes.selectAll': 'Select all',
|
|
204
|
+
'diff.baseStaged': 'Staged',
|
|
205
|
+
'diff.baseWorktree': 'Worktree',
|
|
206
|
+
'diff.prev': 'Previous change',
|
|
207
|
+
'diff.next': 'Next change',
|
|
208
|
+
'diff.truncated': 'Diff too large, showing first {count} lines',
|
|
209
|
+
'diff.foldCollapsed': '… {n} unchanged lines',
|
|
210
|
+
'diff.binary': 'Binary file — diff unavailable',
|
|
211
|
+
'changes.listTruncated': 'Showing first {count} of {total} changes',
|
|
212
|
+
'right.noMessage': 'No commit message',
|
|
213
|
+
'history.noResults': 'No matching commits found',
|
|
214
|
+
'center.fetch': 'Fetch remote',
|
|
215
|
+
'center.fetching': 'Fetching…',
|
|
216
|
+
'center.fetchDone': 'Remote synced',
|
|
93
217
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* popup 外部点击关闭判定(纯函数,不依赖 React——测试环境无宿主提供的 react)。
|
|
3
|
+
*
|
|
4
|
+
* 回归:popup 头部 SelectMenu 下拉 portaled 到 body(class `dsh-git-ui__select-menu`),
|
|
5
|
+
* 点击其选项时 mousedown target 不在 popup 卡片内——旧实现误判为「点击外部」,
|
|
6
|
+
* popup 在分支切换未及完成时关闭(分支看起来「没切换 + pill 自动关闭」)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 外部 mousedown 是否应关闭 popup。命中 wrapper / popup 卡片、或其内部
|
|
11
|
+
* portaled 浮层(`dsh-git-ui__select-menu`)视为内部交互,不关闭;其余外部点击关闭。
|
|
12
|
+
*/
|
|
13
|
+
export function shouldClosePopup(target: EventTarget | null, wrap: Node | null, pop: Node | null): boolean {
|
|
14
|
+
if (target === null || !(target instanceof Node)) return true
|
|
15
|
+
if (wrap !== null && wrap.contains(target)) return false
|
|
16
|
+
if (pop !== null && pop.contains(target)) return false
|
|
17
|
+
if (target instanceof Element && target.closest('.dsh-git-ui__select-menu') !== null) return false
|
|
18
|
+
return true
|
|
19
|
+
}
|
package/src/client/remote.ts
CHANGED
|
@@ -18,10 +18,28 @@ export const gitCommitSchema = z.object({
|
|
|
18
18
|
dateIso: z.string(),
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
+
/** GraphCommit:带父提交哈希与 ref 装饰的提交(图渲染 + 分支胶囊)。 */
|
|
22
|
+
export const gitGraphCommitSchema = z.object({
|
|
23
|
+
hash: z.string(),
|
|
24
|
+
shortHash: z.string(),
|
|
25
|
+
subject: z.string(),
|
|
26
|
+
author: z.string(),
|
|
27
|
+
dateIso: z.string(),
|
|
28
|
+
parents: z.array(z.string()),
|
|
29
|
+
refs: z.array(z.object({
|
|
30
|
+
kind: z.enum(['branch', 'remote', 'tag']),
|
|
31
|
+
name: z.string(),
|
|
32
|
+
head: z.boolean(),
|
|
33
|
+
})),
|
|
34
|
+
})
|
|
35
|
+
|
|
21
36
|
export const gitChangeSchema = z.object({
|
|
22
37
|
path: z.string(),
|
|
23
38
|
status: z.enum(['added', 'modified', 'deleted', 'renamed', 'untracked', 'conflicted', 'typechange']),
|
|
24
39
|
staged: z.boolean(),
|
|
40
|
+
// host 权威目录标记:zod z.object 默认 strip 未知键,漏声明会在 wire 边界
|
|
41
|
+
// 剥掉该字段——展示层目录识别(GitChange.isDirectory)将整体失效。
|
|
42
|
+
isDirectory: z.boolean(),
|
|
25
43
|
})
|
|
26
44
|
|
|
27
45
|
export const gitSnapshotSchema = z.object({
|
|
@@ -74,12 +92,19 @@ export const gitActionSchema = z.discriminatedUnion('kind', [
|
|
|
74
92
|
message: z.string(),
|
|
75
93
|
paths: z.array(z.string()).optional(),
|
|
76
94
|
}),
|
|
95
|
+
z.object({ kind: z.literal('branch-create'), name: z.string(), from: z.string().optional() }),
|
|
96
|
+
z.object({ kind: z.literal('branch-checkout'), name: z.string() }),
|
|
97
|
+
z.object({ kind: z.literal('branch-delete'), name: z.string(), force: z.boolean().optional() }),
|
|
98
|
+
z.object({ kind: z.literal('fetch') }),
|
|
77
99
|
])
|
|
78
100
|
|
|
79
101
|
export const gitOperationErrorSchema = z.object({
|
|
102
|
+
// 与 host GitOperationErrorCode 保持一致:zod z.enum 手工镜像枚举,缺一项
|
|
103
|
+
// 会在 strict 解码时 reject,把可预期业务错误改写为晦涩的 git-error。
|
|
80
104
|
code: z.enum([
|
|
81
105
|
'session-not-found', 'cwd-unavailable', 'path-not-found',
|
|
82
|
-
'not-a-git-repo', 'invalid-path', 'git-error', 'timeout',
|
|
106
|
+
'not-a-git-repo', 'invalid-path', 'invalid-name', 'git-error', 'timeout',
|
|
107
|
+
'local-changes-block',
|
|
83
108
|
]),
|
|
84
109
|
message: z.string().optional(),
|
|
85
110
|
})
|
|
@@ -94,6 +119,38 @@ export const gitActionRequestSchema = z.object({
|
|
|
94
119
|
action: gitActionSchema,
|
|
95
120
|
})
|
|
96
121
|
|
|
122
|
+
/** 一条只读查询(镜像 src/host/types.ts 的 GitQuery)。 */
|
|
123
|
+
export const gitQuerySchema = z.discriminatedUnion('kind', [
|
|
124
|
+
z.object({ kind: z.literal('history'), limit: z.number(), skip: z.number(), ref: z.string().optional(), search: z.string().optional(), author: z.string().optional(), since: z.string().optional() }),
|
|
125
|
+
z.object({ kind: z.literal('diff'), path: z.string(), base: z.enum(['worktree', 'staged']) }),
|
|
126
|
+
z.object({ kind: z.literal('show'), ref: z.string() }),
|
|
127
|
+
z.object({ kind: z.literal('branches') }),
|
|
128
|
+
z.object({ kind: z.literal('tags') }),
|
|
129
|
+
z.object({ kind: z.literal('authors') }),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
const gitFileStatSchema = z.object({ path: z.string(), status: z.enum(['added', 'modified', 'deleted', 'renamed', 'untracked', 'conflicted', 'typechange']) })
|
|
133
|
+
const gitBranchSchema = z.object({ name: z.string(), shortHash: z.string().nullable(), ahead: z.number().optional(), behind: z.number().optional() })
|
|
134
|
+
|
|
135
|
+
export const gitQueryResultSchema = z.discriminatedUnion('kind', [
|
|
136
|
+
z.object({ kind: z.literal('history'), commits: z.array(gitGraphCommitSchema), total: z.number() }),
|
|
137
|
+
z.object({ kind: z.literal('diff'), path: z.string(), text: z.string() }),
|
|
138
|
+
z.object({ kind: z.literal('show'), ref: z.string(), commit: gitCommitSchema.nullable(), body: z.string(), stats: z.array(gitFileStatSchema) }),
|
|
139
|
+
z.object({ kind: z.literal('branches'), current: z.string().nullable(), defaultBranch: z.string().nullable(), local: z.array(gitBranchSchema), remote: z.array(gitBranchSchema) }),
|
|
140
|
+
z.object({ kind: z.literal('tags'), tags: z.array(gitBranchSchema) }),
|
|
141
|
+
z.object({ kind: z.literal('authors'), authors: z.array(z.string()) }),
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
export const gitQueryResponseSchema = z.discriminatedUnion('ok', [
|
|
145
|
+
z.object({ ok: z.literal(true), value: gitQueryResultSchema }),
|
|
146
|
+
z.object({ ok: z.literal(false), error: gitOperationErrorSchema }),
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
export const gitQueryRequestSchema = z.object({
|
|
150
|
+
sessionId: z.string(),
|
|
151
|
+
query: gitQuerySchema,
|
|
152
|
+
})
|
|
153
|
+
|
|
97
154
|
/** The contribution mounted into `ctx.remote` by the client plugin body. */
|
|
98
155
|
export const gitInfoRemote: TypertRemoteContribution = {
|
|
99
156
|
package: 'dsh-git-ui',
|
|
@@ -151,5 +208,30 @@ export const gitInfoRemote: TypertRemoteContribution = {
|
|
|
151
208
|
schema: gitActionResultSchema,
|
|
152
209
|
},
|
|
153
210
|
},
|
|
211
|
+
{
|
|
212
|
+
id: 'dsh-git-ui#gitInfo/query',
|
|
213
|
+
service: 'gitInfo',
|
|
214
|
+
namespace: 'gitInfo',
|
|
215
|
+
method: 'query',
|
|
216
|
+
invocation: { kind: 'direct' },
|
|
217
|
+
cancellation: { parameter: 'signal' },
|
|
218
|
+
parameters: [
|
|
219
|
+
{
|
|
220
|
+
name: 'request',
|
|
221
|
+
wire: 'request',
|
|
222
|
+
source: 'json',
|
|
223
|
+
codec: {
|
|
224
|
+
mode: 'strict',
|
|
225
|
+
typeSymbol: 'dsh-git-ui/types#GitQueryRequest',
|
|
226
|
+
schema: gitQueryRequestSchema,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
result: {
|
|
231
|
+
mode: 'strict',
|
|
232
|
+
typeSymbol: 'dsh-git-ui/types#GitQueryResponse',
|
|
233
|
+
schema: gitQueryResponseSchema,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
154
236
|
],
|
|
155
237
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自绘下拉选择器(平台 Menu 规范):取代原生 select,明暗主题与系统样式统一。
|
|
3
|
+
* 从 GitCenter.tsx 提取为共享组件,供 GitPill 分支管理复用。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
6
|
+
import { createPortal } from 'react-dom'
|
|
7
|
+
import type { CSSProperties, JSX } from 'react'
|
|
8
|
+
import * as css from './styles.ts'
|
|
9
|
+
|
|
10
|
+
export interface SelectMenuProps {
|
|
11
|
+
value: string
|
|
12
|
+
options: readonly { value: string; label: string }[]
|
|
13
|
+
onSelect: (value: string) => void
|
|
14
|
+
ariaLabel: string
|
|
15
|
+
/** 按钮自定义样式(默认 toolbarSelect;头部内联用时传无框变体)。 */
|
|
16
|
+
buttonStyle?: CSSProperties
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SelectMenu({
|
|
20
|
+
value, options, onSelect, ariaLabel, buttonStyle,
|
|
21
|
+
}: SelectMenuProps): JSX.Element {
|
|
22
|
+
const [open, setOpen] = useState(false)
|
|
23
|
+
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null)
|
|
24
|
+
const btnRef = useRef<HTMLButtonElement>(null)
|
|
25
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
26
|
+
const current = options.find((o) => o.value === value)
|
|
27
|
+
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
if (!open) {
|
|
30
|
+
setPos(null)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
const place = (): void => {
|
|
34
|
+
const r = btnRef.current?.getBoundingClientRect()
|
|
35
|
+
if (r) setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 140) })
|
|
36
|
+
}
|
|
37
|
+
place()
|
|
38
|
+
window.addEventListener('resize', place)
|
|
39
|
+
return () => window.removeEventListener('resize', place)
|
|
40
|
+
}, [open])
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) return
|
|
44
|
+
const onDown = (e: MouseEvent): void => {
|
|
45
|
+
const target = e.target as Node
|
|
46
|
+
if (btnRef.current?.contains(target) ?? false) return
|
|
47
|
+
if (menuRef.current?.contains(target) ?? false) return
|
|
48
|
+
setOpen(false)
|
|
49
|
+
}
|
|
50
|
+
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false) }
|
|
51
|
+
document.addEventListener('mousedown', onDown)
|
|
52
|
+
document.addEventListener('keydown', onKey)
|
|
53
|
+
return () => {
|
|
54
|
+
document.removeEventListener('mousedown', onDown)
|
|
55
|
+
document.removeEventListener('keydown', onKey)
|
|
56
|
+
}
|
|
57
|
+
}, [open])
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<button
|
|
62
|
+
ref={btnRef}
|
|
63
|
+
type="button"
|
|
64
|
+
className="dsh-git-ui__toolbar-select"
|
|
65
|
+
style={buttonStyle ?? css.toolbarSelect}
|
|
66
|
+
aria-label={ariaLabel}
|
|
67
|
+
aria-haspopup="listbox"
|
|
68
|
+
aria-expanded={open}
|
|
69
|
+
onClick={() => setOpen(!open)}
|
|
70
|
+
>
|
|
71
|
+
<span style={css.selectLabel}>{current?.label ?? ''}</span>
|
|
72
|
+
<span
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
style={{
|
|
75
|
+
display: 'inline-flex',
|
|
76
|
+
flex: 'none',
|
|
77
|
+
transition: 'transform var(--ds-transition-duration-fast) var(--ds-ease-in-out)',
|
|
78
|
+
transform: open ? 'rotate(180deg)' : 'none',
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<svg width={10} height={10} viewBox="0 0 10 10">
|
|
82
|
+
<path d="M1.5 3 L5 7 L8.5 3" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
83
|
+
</svg>
|
|
84
|
+
</span>
|
|
85
|
+
</button>
|
|
86
|
+
{open && pos !== null && createPortal(
|
|
87
|
+
<div
|
|
88
|
+
ref={menuRef}
|
|
89
|
+
role="listbox"
|
|
90
|
+
aria-label={ariaLabel}
|
|
91
|
+
className="dsh-git-ui__select-menu"
|
|
92
|
+
style={{ ...css.selectMenu, top: pos.top, left: pos.left, minWidth: pos.width }}
|
|
93
|
+
>
|
|
94
|
+
{options.map((o) => (
|
|
95
|
+
<button
|
|
96
|
+
key={o.value}
|
|
97
|
+
type="button"
|
|
98
|
+
role="option"
|
|
99
|
+
aria-selected={o.value === value}
|
|
100
|
+
style={o.value === value ? { ...css.selectOption, ...css.selectOptionActive } : css.selectOption}
|
|
101
|
+
className="dsh-git-ui__row"
|
|
102
|
+
onClick={() => { onSelect(o.value); setOpen(false) }}
|
|
103
|
+
>
|
|
104
|
+
<span style={{ ...css.treeCaret, visibility: o.value === value ? 'visible' : 'hidden' }} aria-hidden="true">✓</span>
|
|
105
|
+
<span style={css.selectLabel}>{o.label}</span>
|
|
106
|
+
</button>
|
|
107
|
+
))}
|
|
108
|
+
</div>,
|
|
109
|
+
document.body,
|
|
110
|
+
)}
|
|
111
|
+
</>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unified diff → 左右对照行序列(IDEA 式并排查看)。
|
|
3
|
+
* 上下文行两侧同号同文;删除/添加块按索引配对成行,
|
|
4
|
+
* 长短不齐处以空位(empty)补齐;行号按 @@ 头起算。
|
|
5
|
+
* 纯函数,可单元测试。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 单侧单元格。num 为该行在旧/新文件中的行号;empty 侧 num 为 null。 */
|
|
9
|
+
export interface SideCell {
|
|
10
|
+
readonly num: number | null
|
|
11
|
+
readonly text: string
|
|
12
|
+
readonly kind: 'context' | 'add' | 'del' | 'empty'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 一行对照:左 = 变更前,右 = 变更后。 */
|
|
16
|
+
export interface SideBySideRow {
|
|
17
|
+
readonly left: SideCell
|
|
18
|
+
readonly right: SideCell
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 由 unified diff 文本构建对照行序列。空输入返回空数组。 */
|
|
22
|
+
export function buildSideBySide(unified: string): readonly SideBySideRow[] {
|
|
23
|
+
if (unified === '') return []
|
|
24
|
+
const rows: SideBySideRow[] = []
|
|
25
|
+
let leftNum = 0
|
|
26
|
+
let rightNum = 0
|
|
27
|
+
let dels: string[] = []
|
|
28
|
+
let adds: string[] = []
|
|
29
|
+
|
|
30
|
+
/** 将积累的删除/添加块按索引配对成对照行。 */
|
|
31
|
+
const flush = (): void => {
|
|
32
|
+
const n = Math.max(dels.length, adds.length)
|
|
33
|
+
for (let i = 0; i < n; i += 1) {
|
|
34
|
+
const d = dels[i]
|
|
35
|
+
const a = adds[i]
|
|
36
|
+
rows.push({
|
|
37
|
+
left: d !== undefined ? { num: (leftNum += 1), text: d, kind: 'del' } : { num: null, text: '', kind: 'empty' },
|
|
38
|
+
right: a !== undefined ? { num: (rightNum += 1), text: a, kind: 'add' } : { num: null, text: '', kind: 'empty' },
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
dels = []
|
|
42
|
+
adds = []
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const line of unified.split('\n')) {
|
|
46
|
+
if (line.startsWith('@@')) {
|
|
47
|
+
flush()
|
|
48
|
+
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
|
|
49
|
+
if (m !== null) {
|
|
50
|
+
leftNum = Number(m[1]) - 1
|
|
51
|
+
rightNum = Number(m[2]) - 1
|
|
52
|
+
}
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
// 元信息行(diff --git / index / --- / +++ / new file 等)跳过。
|
|
56
|
+
if (/^(diff --git|index |--- |\+\+\+ |new file |deleted file |old mode |new mode |similarity index |rename from |rename to |Binary files |GIT binary patch)/.test(line)) {
|
|
57
|
+
flush()
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (line.startsWith('-')) {
|
|
61
|
+
if (adds.length > 0) flush()
|
|
62
|
+
dels.push(line.slice(1))
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (line.startsWith('+')) {
|
|
66
|
+
adds.push(line.slice(1))
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
// 上下文行(含空行:unified 的上下文行以空格开头;裸空行视为文件尾)。
|
|
70
|
+
flush()
|
|
71
|
+
if (line === '') continue
|
|
72
|
+
const text = line.slice(1)
|
|
73
|
+
rows.push({
|
|
74
|
+
left: { num: (leftNum += 1), text, kind: 'context' },
|
|
75
|
+
right: { num: (rightNum += 1), text, kind: 'context' },
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
flush()
|
|
79
|
+
return rows
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 渲染截断:超大差异仅保留前 max 行(防万行级 diff 卡死渲染)。 */
|
|
83
|
+
export function capSideBySideRows(rows: readonly SideBySideRow[], max: number): readonly SideBySideRow[] {
|
|
84
|
+
return rows.length > max ? rows.slice(0, max) : rows
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 判定一行为上下文行(两侧皆 context)。 */
|
|
88
|
+
function isContextRow(row: SideBySideRow): boolean {
|
|
89
|
+
return row.left.kind === 'context' && row.right.kind === 'context'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 折叠块:连续上下文行超阈值时折叠为单个可展开标记,保留原行供展开。 */
|
|
93
|
+
export interface DiffFoldBlock {
|
|
94
|
+
readonly kind: 'fold'
|
|
95
|
+
readonly count: number
|
|
96
|
+
readonly rows: readonly SideBySideRow[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 普通行块。 */
|
|
100
|
+
export interface DiffRowBlock {
|
|
101
|
+
readonly kind: 'row'
|
|
102
|
+
readonly row: SideBySideRow
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 差异渲染块:普通行或折叠块(按 kind 判别)。 */
|
|
106
|
+
export type DiffBlock = DiffRowBlock | DiffFoldBlock
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 折叠连续上下文行:超 threshold 的连续 context 段折叠为单个 fold 块
|
|
110
|
+
* (保留原行供查看器展开);不超过阈值则原样保留。threshold < 1 视为不折叠。
|
|
111
|
+
* 纯函数,可单测。
|
|
112
|
+
*/
|
|
113
|
+
export function foldContext(rows: readonly SideBySideRow[], threshold = 3): readonly DiffBlock[] {
|
|
114
|
+
if (threshold < 1) return rows.map((row) => ({ kind: 'row' as const, row }))
|
|
115
|
+
const blocks: DiffBlock[] = []
|
|
116
|
+
let i = 0
|
|
117
|
+
while (i < rows.length) {
|
|
118
|
+
if (!isContextRow(rows[i]!)) {
|
|
119
|
+
blocks.push({ kind: 'row', row: rows[i]! })
|
|
120
|
+
i += 1
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
let j = i
|
|
124
|
+
while (j < rows.length && isContextRow(rows[j]!)) j += 1
|
|
125
|
+
const run = rows.slice(i, j)
|
|
126
|
+
if (run.length > threshold) {
|
|
127
|
+
blocks.push({ kind: 'fold', count: run.length, rows: run })
|
|
128
|
+
} else {
|
|
129
|
+
for (const row of run) blocks.push({ kind: 'row', row })
|
|
130
|
+
}
|
|
131
|
+
i = j
|
|
132
|
+
}
|
|
133
|
+
return blocks
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 统计增删行数(左 del + 右 add)。纯函数。 */
|
|
137
|
+
export function summarizeChanges(rows: readonly SideBySideRow[]): { readonly add: number; readonly del: number } {
|
|
138
|
+
let add = 0
|
|
139
|
+
let del = 0
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
if (row.left.kind === 'del') del += 1
|
|
142
|
+
if (row.right.kind === 'add') add += 1
|
|
143
|
+
}
|
|
144
|
+
return { add, del }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 判定二进制差异(git 输出 "Binary files … differ",buildSideBySide 会跳过→空)。 */
|
|
148
|
+
export function isBinaryDiff(unified: string): boolean {
|
|
149
|
+
return /^Binary files /m.test(unified)
|
|
150
|
+
}
|