@civilization/dsh-git-workbench 0.0.0-stage → 0.4.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/LICENSE +201 -0
- package/README.md +119 -2
- package/cordis.patch.yml +3 -0
- package/icon.svg +26 -0
- package/lib/client.js +2943 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1274 -0
- package/package.json +91 -4
- package/src/client/GitBody.module.css +1283 -0
- package/src/client/GitBody.tsx +1496 -0
- package/src/client/GitBranchPill.module.css +292 -0
- package/src/client/GitBranchPill.tsx +368 -0
- package/src/client/GitTitle.tsx +27 -0
- package/src/client/definition.tsx +63 -0
- package/src/client/git-helpers.ts +351 -0
- package/src/client/index.ts +221 -0
- package/src/client/locales.ts +183 -0
- package/src/css.d.ts +4 -0
- package/src/git-executor.ts +1025 -0
- package/src/index.ts +220 -0
- package/src/tool.ts +299 -0
- package/src/types.ts +187 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definition of the `git` tab type for the right Sidebar.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react'
|
|
6
|
+
|
|
7
|
+
/** The tab kind this package owns */
|
|
8
|
+
export const GIT_KIND = 'git'
|
|
9
|
+
|
|
10
|
+
/** Implementation identity in the tab system */
|
|
11
|
+
export const GIT_ID = '@civilization/dsh-git-workbench'
|
|
12
|
+
|
|
13
|
+
export interface SidebarRightTabGuide {
|
|
14
|
+
id?: string
|
|
15
|
+
commandId?: string
|
|
16
|
+
order?: number
|
|
17
|
+
title: () => string
|
|
18
|
+
description: () => string
|
|
19
|
+
icon?: () => React.ReactElement
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SidebarRightTabDefinition {
|
|
23
|
+
id: string
|
|
24
|
+
kind: string
|
|
25
|
+
priority?: string
|
|
26
|
+
title: () => string
|
|
27
|
+
guide?: SidebarRightTabGuide[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Git artwork for the Sidebar Guide */
|
|
31
|
+
export function GuideArtworkGit(): React.ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<svg width="40" height="40" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
34
|
+
<line x1="12" y1="9" x2="12" y2="27" stroke="#F05133" strokeWidth="2.5" strokeLinecap="round" />
|
|
35
|
+
<path d="M12 21C12 18 15 15 18 15H22C23.5 15 24 14.5 24 13V12" stroke="#F05133" strokeWidth="2.5" strokeLinecap="round" fill="none" />
|
|
36
|
+
<circle cx="12" cy="26" r="3.5" fill="#FFFFFF" stroke="#F05133" strokeWidth="2.5" />
|
|
37
|
+
<circle cx="12" cy="10" r="3.5" fill="#FFFFFF" stroke="#F05133" strokeWidth="2.5" />
|
|
38
|
+
<circle cx="24" cy="11" r="3.5" fill="#FFFFFF" stroke="#3680F6" strokeWidth="2.5" />
|
|
39
|
+
</svg>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Tab definition for the Git right-sidebar viewer.
|
|
45
|
+
* @param t - namespace-bound translation function.
|
|
46
|
+
* @returns Complete sidebar tab definition.
|
|
47
|
+
*/
|
|
48
|
+
export function gitDefinition(t: (key: string) => string): SidebarRightTabDefinition {
|
|
49
|
+
return {
|
|
50
|
+
id: GIT_ID,
|
|
51
|
+
kind: GIT_KIND,
|
|
52
|
+
priority: 'builtin',
|
|
53
|
+
title: () => t('type.label'),
|
|
54
|
+
guide: [{
|
|
55
|
+
id: 'git',
|
|
56
|
+
commandId: 'workspace.git',
|
|
57
|
+
order: 15,
|
|
58
|
+
title: () => t('guide.title'),
|
|
59
|
+
description: () => t('guide.description'),
|
|
60
|
+
icon: GuideArtworkGit,
|
|
61
|
+
}],
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conventional Commits and presentation helpers for Git commits and avatars.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type ConventionalType =
|
|
6
|
+
| 'feat'
|
|
7
|
+
| 'fix'
|
|
8
|
+
| 'style'
|
|
9
|
+
| 'refactor'
|
|
10
|
+
| 'docs'
|
|
11
|
+
| 'test'
|
|
12
|
+
| 'chore'
|
|
13
|
+
| 'release'
|
|
14
|
+
| 'merge'
|
|
15
|
+
| 'revert'
|
|
16
|
+
|
|
17
|
+
export interface ParsedCommitSubject {
|
|
18
|
+
readonly type?: ConventionalType
|
|
19
|
+
readonly scope?: string
|
|
20
|
+
readonly cleanSubject: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse conventional commit prefix from a commit subject.
|
|
25
|
+
* @param subject - Raw first line of commit message.
|
|
26
|
+
* @returns Parsed type, scope, and clean subject.
|
|
27
|
+
*/
|
|
28
|
+
export function parseConventionalCommit(subject: string): ParsedCommitSubject {
|
|
29
|
+
const trimmed = subject.trim()
|
|
30
|
+
|
|
31
|
+
// Match merge commits
|
|
32
|
+
if (/^merge\s+(pull\s+request|branch|remote-tracking\s+branch)/i.test(trimmed)) {
|
|
33
|
+
return {
|
|
34
|
+
type: 'merge',
|
|
35
|
+
cleanSubject: trimmed,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Match standard conventional commit prefix: "type(scope): message" or "type: message"
|
|
40
|
+
const match = /^(feat|fix|style|refactor|docs|test|chore|release|revert)(?:\(([^)]+)\))?!?:\s*(.*)$/i.exec(trimmed)
|
|
41
|
+
if (match && match[1]) {
|
|
42
|
+
const rawType = match[1].toLowerCase() as ConventionalType
|
|
43
|
+
const scope = match[2]?.trim()
|
|
44
|
+
return {
|
|
45
|
+
type: rawType,
|
|
46
|
+
...scope !== undefined ? { scope } : {},
|
|
47
|
+
cleanSubject: trimmed,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
cleanSubject: trimmed,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const AVATAR_PALETTE = [
|
|
57
|
+
'#388bfd', // Blue
|
|
58
|
+
'#2ea043', // Green
|
|
59
|
+
'#f0883e', // Orange
|
|
60
|
+
'#a371f7', // Purple
|
|
61
|
+
'#db61a2', // Rose
|
|
62
|
+
'#39c5bb', // Cyan
|
|
63
|
+
'#f9c513', // Amber
|
|
64
|
+
'#6f42c1', // Violet
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Deterministically compute avatar initial and background color for an author name.
|
|
69
|
+
* @param author - Author name or email.
|
|
70
|
+
* @returns Initial letter and hex color code.
|
|
71
|
+
*/
|
|
72
|
+
export function getAuthorAvatar(author: string): { initial: string; color: string } {
|
|
73
|
+
const clean = author.trim()
|
|
74
|
+
const initial = clean.length > 0 ? (clean[0]?.toUpperCase() ?? '?') : '?'
|
|
75
|
+
|
|
76
|
+
let hash = 0
|
|
77
|
+
for (let i = 0; i < clean.length; i++) {
|
|
78
|
+
hash = (hash << 5) - hash + clean.charCodeAt(i)
|
|
79
|
+
hash |= 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const index = Math.abs(hash) % AVATAR_PALETTE.length
|
|
83
|
+
const color = AVATAR_PALETTE[index] ?? '#388bfd'
|
|
84
|
+
|
|
85
|
+
return { initial, color }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format date string into YYYY-MM-DD HH:mm:ss.
|
|
90
|
+
* @param dateStr - ISO or Git date string.
|
|
91
|
+
* @returns Clean formatted local date and time string.
|
|
92
|
+
*/
|
|
93
|
+
export function formatFullDate(dateStr: string): string {
|
|
94
|
+
const d = new Date(dateStr)
|
|
95
|
+
if (Number.isNaN(d.getTime())) return dateStr
|
|
96
|
+
|
|
97
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
98
|
+
const year = d.getFullYear()
|
|
99
|
+
const month = pad(d.getMonth() + 1)
|
|
100
|
+
const day = pad(d.getDate())
|
|
101
|
+
const hours = pad(d.getHours())
|
|
102
|
+
const minutes = pad(d.getMinutes())
|
|
103
|
+
const seconds = pad(d.getSeconds())
|
|
104
|
+
|
|
105
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const GRAPH_LANE_PALETTE = [
|
|
109
|
+
'#388bfd', // 0: Mainline Blue
|
|
110
|
+
'#3fb950', // 1: Emerald Green
|
|
111
|
+
'#f0883e', // 2: Coral Orange
|
|
112
|
+
'#a371f7', // 3: Violet Purple
|
|
113
|
+
'#58a6ff', // 4: Sky Blue
|
|
114
|
+
'#bc8cff', // 5: Lavender
|
|
115
|
+
'#db61a2', // 6: Magenta
|
|
116
|
+
'#2ea043', // 7: Forest Green
|
|
117
|
+
] as const
|
|
118
|
+
|
|
119
|
+
export interface DagNode {
|
|
120
|
+
readonly hash: string
|
|
121
|
+
readonly lane: number
|
|
122
|
+
readonly color: string
|
|
123
|
+
readonly x: number
|
|
124
|
+
readonly y: number
|
|
125
|
+
readonly isHead: boolean
|
|
126
|
+
readonly hasTag: boolean
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DagEdge {
|
|
130
|
+
readonly key: string
|
|
131
|
+
readonly fromHash: string
|
|
132
|
+
readonly toHash?: string
|
|
133
|
+
readonly color: string
|
|
134
|
+
readonly d: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface DagGraphModel {
|
|
138
|
+
readonly nodes: readonly DagNode[]
|
|
139
|
+
readonly edges: readonly DagEdge[]
|
|
140
|
+
readonly maxLane: number
|
|
141
|
+
readonly graphWidth: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ROW_HEIGHT = 28
|
|
145
|
+
const LANE_WIDTH = 16
|
|
146
|
+
const LANE_X_OFFSET = 10
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compute global continuous SVG DAG topology graph for commit items (IntelliJ IDEA grade).
|
|
150
|
+
* Eliminates per-row segmentation gaps, floating nodes, and misaligned curves.
|
|
151
|
+
* @param commits - Ordered commit items.
|
|
152
|
+
* @returns Fully computed nodes, edges, maxLane, and width.
|
|
153
|
+
*/
|
|
154
|
+
export function computeDagGraph(commits: readonly {
|
|
155
|
+
hash: string
|
|
156
|
+
parents?: readonly string[]
|
|
157
|
+
refs?: readonly string[]
|
|
158
|
+
}[]): DagGraphModel {
|
|
159
|
+
if (commits.length === 0) {
|
|
160
|
+
return { nodes: [], edges: [], maxLane: 0, graphWidth: 36 }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 1. Map commit hash -> index
|
|
164
|
+
const hashToIndex = new Map<string, number>()
|
|
165
|
+
commits.forEach((c, idx) => hashToIndex.set(c.hash, idx))
|
|
166
|
+
|
|
167
|
+
// 2. Identify mainline (first-parent chain starting from HEAD or newest commit)
|
|
168
|
+
const mainlineSet = new Set<string>()
|
|
169
|
+
let curr = commits.find(c => c.refs?.some(r => r.includes('HEAD') || r === 'master' || r === 'main'))?.hash
|
|
170
|
+
?? commits[0]?.hash
|
|
171
|
+
while (curr && hashToIndex.has(curr)) {
|
|
172
|
+
mainlineSet.add(curr)
|
|
173
|
+
const commitIdx = hashToIndex.get(curr)
|
|
174
|
+
if (commitIdx === undefined) break
|
|
175
|
+
const c = commits[commitIdx]
|
|
176
|
+
if (!c || !c.parents || c.parents.length === 0) break
|
|
177
|
+
curr = c.parents[0]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 3. Assign lanes to each commit
|
|
181
|
+
const activeLanes: (string | null)[] = [null]
|
|
182
|
+
const commitLanes: number[] = Array.from({ length: commits.length }, () => 0)
|
|
183
|
+
|
|
184
|
+
commits.forEach((c, idx) => {
|
|
185
|
+
let lane = 0
|
|
186
|
+
if (mainlineSet.has(c.hash)) {
|
|
187
|
+
lane = 0
|
|
188
|
+
for (let l = 1; l < activeLanes.length; l++) {
|
|
189
|
+
if (activeLanes[l] === c.hash) activeLanes[l] = null
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
let foundLane = -1
|
|
193
|
+
for (let l = 1; l < activeLanes.length; l++) {
|
|
194
|
+
if (activeLanes[l] === c.hash) {
|
|
195
|
+
foundLane = l
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (foundLane !== -1) {
|
|
201
|
+
lane = foundLane
|
|
202
|
+
activeLanes[lane] = null
|
|
203
|
+
} else {
|
|
204
|
+
let slot = -1
|
|
205
|
+
for (let l = 1; l < activeLanes.length; l++) {
|
|
206
|
+
if (activeLanes[l] === null) {
|
|
207
|
+
slot = l
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (slot !== -1) {
|
|
212
|
+
lane = slot
|
|
213
|
+
} else {
|
|
214
|
+
lane = Math.max(1, activeLanes.length)
|
|
215
|
+
while (activeLanes.length <= lane) activeLanes.push(null)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
commitLanes[idx] = lane
|
|
221
|
+
|
|
222
|
+
// Reserve lanes for non-mainline parents
|
|
223
|
+
if (c.parents && c.parents.length > 0) {
|
|
224
|
+
c.parents.forEach((parentHash, pIdx) => {
|
|
225
|
+
if (mainlineSet.has(parentHash)) return
|
|
226
|
+
if (activeLanes.includes(parentHash)) return
|
|
227
|
+
|
|
228
|
+
if (pIdx === 0 && lane >= 1 && activeLanes[lane] === null) {
|
|
229
|
+
activeLanes[lane] = parentHash
|
|
230
|
+
} else {
|
|
231
|
+
let slot = -1
|
|
232
|
+
for (let l = 1; l < activeLanes.length; l++) {
|
|
233
|
+
if (activeLanes[l] === null) {
|
|
234
|
+
slot = l
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (slot !== -1) {
|
|
239
|
+
activeLanes[slot] = parentHash
|
|
240
|
+
} else {
|
|
241
|
+
activeLanes.push(parentHash)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
while (activeLanes.length > 1 && activeLanes.at(-1) === null) {
|
|
248
|
+
activeLanes.pop()
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const maxLane = Math.max(0, ...commitLanes)
|
|
253
|
+
|
|
254
|
+
// 4. Compute Edges (Connections from Child -> Parent)
|
|
255
|
+
const edges: DagEdge[] = []
|
|
256
|
+
const processedEdges = new Set<string>()
|
|
257
|
+
|
|
258
|
+
commits.forEach((c, idx) => {
|
|
259
|
+
const fromLane = commitLanes[idx] ?? 0
|
|
260
|
+
const x1 = fromLane * LANE_WIDTH + LANE_X_OFFSET
|
|
261
|
+
const y1 = idx * ROW_HEIGHT + ROW_HEIGHT / 2
|
|
262
|
+
const nodeColor = GRAPH_LANE_PALETTE[fromLane % GRAPH_LANE_PALETTE.length] ?? '#388bfd'
|
|
263
|
+
|
|
264
|
+
if (!c.parents || c.parents.length === 0) return
|
|
265
|
+
|
|
266
|
+
c.parents.forEach((pHash) => {
|
|
267
|
+
const edgeKey = `${c.hash}->${pHash}`
|
|
268
|
+
if (processedEdges.has(edgeKey)) return
|
|
269
|
+
processedEdges.add(edgeKey)
|
|
270
|
+
|
|
271
|
+
const pIdxInList = hashToIndex.get(pHash)
|
|
272
|
+
if (pIdxInList !== undefined) {
|
|
273
|
+
const toLane = commitLanes[pIdxInList] ?? 0
|
|
274
|
+
const x2 = toLane * LANE_WIDTH + LANE_X_OFFSET
|
|
275
|
+
const y2 = pIdxInList * ROW_HEIGHT + ROW_HEIGHT / 2
|
|
276
|
+
const edgeColor = fromLane > 0
|
|
277
|
+
? nodeColor
|
|
278
|
+
: (toLane > 0 ? (GRAPH_LANE_PALETTE[toLane % GRAPH_LANE_PALETTE.length] ?? nodeColor) : nodeColor)
|
|
279
|
+
|
|
280
|
+
if (x1 === x2) {
|
|
281
|
+
edges.push({
|
|
282
|
+
key: edgeKey,
|
|
283
|
+
fromHash: c.hash,
|
|
284
|
+
toHash: pHash,
|
|
285
|
+
color: edgeColor,
|
|
286
|
+
d: `M ${x1} ${y1} L ${x2} ${y2}`,
|
|
287
|
+
})
|
|
288
|
+
} else if (pIdxInList === idx + 1) {
|
|
289
|
+
edges.push({
|
|
290
|
+
key: edgeKey,
|
|
291
|
+
fromHash: c.hash,
|
|
292
|
+
toHash: pHash,
|
|
293
|
+
color: edgeColor,
|
|
294
|
+
d: `M ${x1} ${y1} C ${x1} ${y1 + 14}, ${x2} ${y2 - 14}, ${x2} ${y2}`,
|
|
295
|
+
})
|
|
296
|
+
} else if (toLane === 0) {
|
|
297
|
+
edges.push({
|
|
298
|
+
key: edgeKey,
|
|
299
|
+
fromHash: c.hash,
|
|
300
|
+
toHash: pHash,
|
|
301
|
+
color: edgeColor,
|
|
302
|
+
d: `M ${x1} ${y1} L ${x1} ${y2 - 28} C ${x1} ${y2 - 14}, ${x2} ${y2 - 14}, ${x2} ${y2}`,
|
|
303
|
+
})
|
|
304
|
+
} else {
|
|
305
|
+
edges.push({
|
|
306
|
+
key: edgeKey,
|
|
307
|
+
fromHash: c.hash,
|
|
308
|
+
toHash: pHash,
|
|
309
|
+
color: edgeColor,
|
|
310
|
+
d: `M ${x1} ${y1} C ${x1} ${y1 + 14}, ${x2} ${y1 + 14}, ${x2} ${y1 + 28} L ${x2} ${y2}`,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
const bottomY = commits.length * ROW_HEIGHT
|
|
315
|
+
edges.push({
|
|
316
|
+
key: edgeKey,
|
|
317
|
+
fromHash: c.hash,
|
|
318
|
+
color: nodeColor,
|
|
319
|
+
d: `M ${x1} ${y1} L ${x1} ${bottomY}`,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// 5. Compute Nodes
|
|
326
|
+
const nodes: DagNode[] = commits.map((c, idx) => {
|
|
327
|
+
const lane = commitLanes[idx] ?? 0
|
|
328
|
+
const x = lane * LANE_WIDTH + LANE_X_OFFSET
|
|
329
|
+
const y = idx * ROW_HEIGHT + ROW_HEIGHT / 2
|
|
330
|
+
const color = GRAPH_LANE_PALETTE[lane % GRAPH_LANE_PALETTE.length] ?? '#388bfd'
|
|
331
|
+
const isHead = Boolean(c.refs?.some(r => r.includes('HEAD') || r === 'master' || r === 'main'))
|
|
332
|
+
const hasTag = Boolean(c.refs?.some(r => r.startsWith('tag: ') || r.includes('tag:')))
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
hash: c.hash,
|
|
336
|
+
lane,
|
|
337
|
+
color,
|
|
338
|
+
x,
|
|
339
|
+
y,
|
|
340
|
+
isHead,
|
|
341
|
+
hasTag,
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
nodes,
|
|
347
|
+
edges,
|
|
348
|
+
maxLane,
|
|
349
|
+
graphWidth: Math.max(36, (maxLane + 1) * LANE_WIDTH + 16),
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry for the Git right-sidebar plugin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import type {
|
|
7
|
+
GitBranchesSummary,
|
|
8
|
+
GitCommitDetail,
|
|
9
|
+
GitCommitItem,
|
|
10
|
+
GitDiffSummary,
|
|
11
|
+
GitStatusSummary,
|
|
12
|
+
GitTagItem,
|
|
13
|
+
} from '../types.ts'
|
|
14
|
+
import { GIT_ID, gitDefinition } from './definition.tsx'
|
|
15
|
+
import { GitBody, type GitInjected } from './GitBody.tsx'
|
|
16
|
+
import { GitBranchPill } from './GitBranchPill.tsx'
|
|
17
|
+
import { GitTitle } from './GitTitle.tsx'
|
|
18
|
+
import { en, NS, zh } from './locales.ts'
|
|
19
|
+
|
|
20
|
+
export type { GitBodyProps, GitInjected } from './GitBody.tsx'
|
|
21
|
+
export type { GitViewKey } from './locales.ts'
|
|
22
|
+
|
|
23
|
+
export interface ConnectionHandle {
|
|
24
|
+
rpc: {
|
|
25
|
+
call: (
|
|
26
|
+
channel: string,
|
|
27
|
+
method: string,
|
|
28
|
+
params?: unknown,
|
|
29
|
+
) => Promise<{ ok: true; value: unknown } | { ok: false; error: { message: string } }>
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ClientContext extends Context {
|
|
34
|
+
locale?: any
|
|
35
|
+
sidebarRightTabs?: any
|
|
36
|
+
sidebarRight?: any
|
|
37
|
+
slots?: any
|
|
38
|
+
shortcuts?: any
|
|
39
|
+
connection?: ConnectionHandle
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Required browser services.
|
|
44
|
+
*/
|
|
45
|
+
export const inject = ['connection', 'slots', 'locale', 'sidebarRightTabs', 'sidebarRight']
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Client plugin body: registers Git tab definition, locales, shortcut, and components.
|
|
49
|
+
* @param ctx - Client context carrying slots and connection services.
|
|
50
|
+
*/
|
|
51
|
+
export function apply(ctx: Context): void {
|
|
52
|
+
const client = ctx as ClientContext
|
|
53
|
+
const t = client.locale?.bind?.(NS) ?? ((key: string) => key)
|
|
54
|
+
|
|
55
|
+
// Register localization dictionaries
|
|
56
|
+
ctx.effect(() => client.locale?.register?.(NS, { zh, en }), 'git: locales')
|
|
57
|
+
|
|
58
|
+
// Register tab definition
|
|
59
|
+
ctx.effect(() => client.sidebarRightTabs?.register?.(gitDefinition(t)), 'git: tab definition')
|
|
60
|
+
|
|
61
|
+
// Register shortcut command
|
|
62
|
+
ctx.inject(['shortcuts'], (scope: any) => {
|
|
63
|
+
scope.effect(() => scope.shortcuts?.register?.({
|
|
64
|
+
id: 'workspace.git',
|
|
65
|
+
label: () => t('guide.title'),
|
|
66
|
+
aliases: ['workspace git', 'git', 'source control'],
|
|
67
|
+
defaults: {
|
|
68
|
+
'desktop:macos': { code: 'KeyG', modifiers: ['primary', 'shift'] },
|
|
69
|
+
'desktop:windows': { code: 'KeyG', modifiers: ['primary', 'shift'] },
|
|
70
|
+
'desktop:linux': { code: 'KeyG', modifiers: ['primary', 'shift'] },
|
|
71
|
+
'web:macos': { code: 'KeyG', modifiers: ['primary', 'shift'] },
|
|
72
|
+
'web:windows': { code: 'KeyG', modifiers: ['primary', 'shift'] },
|
|
73
|
+
},
|
|
74
|
+
regions: ['page', 'editable', 'terminal'],
|
|
75
|
+
modals: [],
|
|
76
|
+
resolve: ({ target: element }: { target: any }) => {
|
|
77
|
+
const target = scope.sidebarRight?.commandTarget?.(element)
|
|
78
|
+
if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') }
|
|
79
|
+
return { status: 'handled', run: () => { scope.sidebarRight?.openTabFromTarget?.('git', target) } }
|
|
80
|
+
},
|
|
81
|
+
}), 'git: shortcut')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Define RPC actions bound to Connection
|
|
85
|
+
const getConnection = (): ConnectionHandle => {
|
|
86
|
+
const handle = (client.get?.('connection') ?? client.connection) as ConnectionHandle | undefined
|
|
87
|
+
if (!handle) throw new Error('Connection service not available')
|
|
88
|
+
return handle
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const actions: GitInjected = {
|
|
92
|
+
getStatus: async (sessionId) => {
|
|
93
|
+
const conn = getConnection()
|
|
94
|
+
const res = await conn.rpc.call('/git', 'status', { sessionId })
|
|
95
|
+
if (!res.ok) throw new Error(res.error.message)
|
|
96
|
+
return res.value as GitStatusSummary
|
|
97
|
+
},
|
|
98
|
+
getLog: async (sessionId, maxCount) => {
|
|
99
|
+
const conn = getConnection()
|
|
100
|
+
const res = await conn.rpc.call('/git', 'log', { sessionId, maxCount })
|
|
101
|
+
if (!res.ok) throw new Error(res.error.message)
|
|
102
|
+
return res.value as readonly GitCommitItem[]
|
|
103
|
+
},
|
|
104
|
+
getCommitDetails: async (sessionId, commit) => {
|
|
105
|
+
const conn = getConnection()
|
|
106
|
+
const res = await conn.rpc.call('/git', 'commitDetails', { sessionId, commit })
|
|
107
|
+
if (!res.ok) throw new Error(res.error.message)
|
|
108
|
+
return res.value as GitCommitDetail | undefined
|
|
109
|
+
},
|
|
110
|
+
getDiff: async (sessionId, options) => {
|
|
111
|
+
const conn = getConnection()
|
|
112
|
+
const res = await conn.rpc.call('/git', 'diff', { sessionId, ...options })
|
|
113
|
+
if (!res.ok) throw new Error(res.error.message)
|
|
114
|
+
return res.value as GitDiffSummary
|
|
115
|
+
},
|
|
116
|
+
getBranches: async (sessionId) => {
|
|
117
|
+
const conn = getConnection()
|
|
118
|
+
const res = await conn.rpc.call('/git', 'branches', { sessionId })
|
|
119
|
+
if (!res.ok) throw new Error(res.error.message)
|
|
120
|
+
return res.value as GitBranchesSummary
|
|
121
|
+
},
|
|
122
|
+
checkout: async (sessionId, branch, create) => {
|
|
123
|
+
const conn = getConnection()
|
|
124
|
+
const res = await conn.rpc.call('/git', 'checkout', { sessionId, branch, create })
|
|
125
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
126
|
+
return res.value as { success: boolean; message?: string }
|
|
127
|
+
},
|
|
128
|
+
stage: async (sessionId, paths) => {
|
|
129
|
+
const conn = getConnection()
|
|
130
|
+
const res = await conn.rpc.call('/git', 'stage', { sessionId, paths })
|
|
131
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
132
|
+
return res.value as { success: boolean; message?: string }
|
|
133
|
+
},
|
|
134
|
+
unstage: async (sessionId, paths) => {
|
|
135
|
+
const conn = getConnection()
|
|
136
|
+
const res = await conn.rpc.call('/git', 'unstage', { sessionId, paths })
|
|
137
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
138
|
+
return res.value as { success: boolean; message?: string }
|
|
139
|
+
},
|
|
140
|
+
discard: async (sessionId, paths) => {
|
|
141
|
+
const conn = getConnection()
|
|
142
|
+
const res = await conn.rpc.call('/git', 'discard', { sessionId, paths })
|
|
143
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
144
|
+
return res.value as { success: boolean; message?: string }
|
|
145
|
+
},
|
|
146
|
+
commit: async (sessionId, message) => {
|
|
147
|
+
const conn = getConnection()
|
|
148
|
+
const res = await conn.rpc.call('/git', 'commit', { sessionId, message })
|
|
149
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
150
|
+
return res.value as { success: boolean; message?: string }
|
|
151
|
+
},
|
|
152
|
+
fetch: async (sessionId) => {
|
|
153
|
+
const conn = getConnection()
|
|
154
|
+
const res = await conn.rpc.call('/git', 'fetch', { sessionId })
|
|
155
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
156
|
+
return res.value as { success: boolean; message?: string }
|
|
157
|
+
},
|
|
158
|
+
pull: async (sessionId) => {
|
|
159
|
+
const conn = getConnection()
|
|
160
|
+
const res = await conn.rpc.call('/git', 'pull', { sessionId })
|
|
161
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
162
|
+
return res.value as { success: boolean; message?: string }
|
|
163
|
+
},
|
|
164
|
+
push: async (sessionId) => {
|
|
165
|
+
const conn = getConnection()
|
|
166
|
+
const res = await conn.rpc.call('/git', 'push', { sessionId })
|
|
167
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
168
|
+
return res.value as { success: boolean; message?: string }
|
|
169
|
+
},
|
|
170
|
+
getTags: async (sessionId) => {
|
|
171
|
+
const conn = getConnection()
|
|
172
|
+
const res = await conn.rpc.call('/git', 'tags', { sessionId })
|
|
173
|
+
if (!res.ok) throw new Error(res.error.message)
|
|
174
|
+
return res.value as readonly GitTagItem[]
|
|
175
|
+
},
|
|
176
|
+
createTag: async (sessionId, name, commit, message) => {
|
|
177
|
+
const conn = getConnection()
|
|
178
|
+
const res = await conn.rpc.call('/git', 'createTag', { sessionId, name, commit, message })
|
|
179
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
180
|
+
return res.value as { success: boolean; message?: string }
|
|
181
|
+
},
|
|
182
|
+
deleteTag: async (sessionId, name) => {
|
|
183
|
+
const conn = getConnection()
|
|
184
|
+
const res = await conn.rpc.call('/git', 'deleteTag', { sessionId, name })
|
|
185
|
+
if (!res.ok) return { success: false, message: res.error.message }
|
|
186
|
+
return res.value as { success: boolean; message?: string }
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
// Register slot implementations
|
|
192
|
+
ctx.effect(() => client.slots?.inject?.('sidebar.right.pane.tab', () => client.slots.register(
|
|
193
|
+
{
|
|
194
|
+
name: 'sidebar.right.pane.tab',
|
|
195
|
+
key: GIT_ID,
|
|
196
|
+
locale: NS,
|
|
197
|
+
inject: () => actions,
|
|
198
|
+
},
|
|
199
|
+
GitBody,
|
|
200
|
+
)), 'git: tab body')
|
|
201
|
+
|
|
202
|
+
ctx.effect(() => client.slots?.inject?.('sidebar.right.pane.tab.title', () => client.slots.register(
|
|
203
|
+
{
|
|
204
|
+
name: 'sidebar.right.pane.tab.title',
|
|
205
|
+
key: GIT_ID,
|
|
206
|
+
},
|
|
207
|
+
GitTitle,
|
|
208
|
+
)), 'git: tab title')
|
|
209
|
+
|
|
210
|
+
// Register Git Branch Pill in conversation composer dock (status bar at bottom)
|
|
211
|
+
ctx.effect(() => client.slots?.inject?.('conversation.composer.dock', () => client.slots.register(
|
|
212
|
+
{
|
|
213
|
+
name: 'conversation.composer.dock',
|
|
214
|
+
id: 'git-branch',
|
|
215
|
+
order: -1,
|
|
216
|
+
locale: NS,
|
|
217
|
+
inject: () => actions,
|
|
218
|
+
},
|
|
219
|
+
GitBranchPill,
|
|
220
|
+
)), 'git: composer dock pill')
|
|
221
|
+
}
|