@civilization/dsh-git-workbench 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }