@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.
- 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 +1371 -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 +304 -0
- package/src/tool.ts +299 -0
- package/src/types.ts +187 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/* Status bar Git branch pill and popover branch switcher (unified DSH design) */
|
|
2
|
+
|
|
3
|
+
.anchor {
|
|
4
|
+
position: relative;
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
min-width: 0;
|
|
7
|
+
font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
|
|
8
|
+
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.pill {
|
|
12
|
+
display: inline-flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
gap: 6px;
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
max-width: 100%;
|
|
17
|
+
padding: 1px 8px;
|
|
18
|
+
border: none;
|
|
19
|
+
border-radius: var(--dsw-radius-sm);
|
|
20
|
+
background: transparent;
|
|
21
|
+
color: var(--dsw-alias-label-tertiary);
|
|
22
|
+
font: inherit;
|
|
23
|
+
font-weight: 500;
|
|
24
|
+
font-variant-numeric: tabular-nums;
|
|
25
|
+
line-height: inherit;
|
|
26
|
+
white-space: nowrap;
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.pill:hover,
|
|
32
|
+
.pill[aria-expanded='true'] {
|
|
33
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
34
|
+
color: var(--dsw-alias-label-primary);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.icon {
|
|
38
|
+
width: 14px;
|
|
39
|
+
height: 14px;
|
|
40
|
+
flex: none;
|
|
41
|
+
color: var(--dsw-alias-label-tertiary);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.pill:hover .icon,
|
|
45
|
+
.pill[aria-expanded='true'] .icon {
|
|
46
|
+
color: var(--dsw-alias-state-business-primary);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.branchName {
|
|
50
|
+
max-width: 150px;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
text-overflow: ellipsis;
|
|
53
|
+
white-space: nowrap;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.badge {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
padding: 0 4px;
|
|
60
|
+
border-radius: var(--dsw-radius-xs, 3px);
|
|
61
|
+
font-size: 11px;
|
|
62
|
+
line-height: 14px;
|
|
63
|
+
font-weight: 500;
|
|
64
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
65
|
+
color: var(--dsw-alias-label-secondary);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.badgeAhead {
|
|
69
|
+
color: var(--dsw-alias-state-business-primary);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.badgeBehind {
|
|
73
|
+
color: var(--dsw-alias-state-warn-primary);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Branch switch popover panel (wrapped in MenuSurface) */
|
|
77
|
+
.popover {
|
|
78
|
+
position: fixed;
|
|
79
|
+
z-index: 1100;
|
|
80
|
+
width: 310px;
|
|
81
|
+
max-height: 440px;
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-direction: column;
|
|
84
|
+
box-sizing: border-box;
|
|
85
|
+
border: 0;
|
|
86
|
+
border-radius: var(--dsw-radius-lg);
|
|
87
|
+
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
|
88
|
+
box-shadow: var(--dsw-elevation-prominent);
|
|
89
|
+
color: var(--dsw-alias-label-primary);
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
line-height: 18px;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
cursor: default;
|
|
94
|
+
animation: popoverFadeIn 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@keyframes popoverFadeIn {
|
|
98
|
+
from {
|
|
99
|
+
opacity: 0;
|
|
100
|
+
transform: translateY(4px) scale(0.98);
|
|
101
|
+
}
|
|
102
|
+
to {
|
|
103
|
+
opacity: 1;
|
|
104
|
+
transform: translateY(0) scale(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.header {
|
|
109
|
+
padding: 8px 10px 6px;
|
|
110
|
+
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: 6px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.searchRow {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: 6px;
|
|
120
|
+
padding: 4px 8px;
|
|
121
|
+
border-radius: var(--dsw-radius-sm);
|
|
122
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
123
|
+
border: 0.5px solid var(--dsw-alias-border-l2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.searchIcon {
|
|
127
|
+
width: 13px;
|
|
128
|
+
height: 13px;
|
|
129
|
+
color: var(--dsw-alias-label-tertiary);
|
|
130
|
+
flex: none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.searchInput {
|
|
134
|
+
flex: 1;
|
|
135
|
+
border: none;
|
|
136
|
+
background: transparent;
|
|
137
|
+
color: var(--dsw-alias-label-primary);
|
|
138
|
+
font-size: 12px;
|
|
139
|
+
outline: none;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.searchInput::placeholder {
|
|
143
|
+
color: var(--dsw-alias-label-tertiary);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.actionBar {
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: space-between;
|
|
150
|
+
gap: 4px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.toolBtnGroup {
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
gap: 4px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.toolBtn {
|
|
160
|
+
display: inline-flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
gap: 4px;
|
|
163
|
+
padding: 3px 8px;
|
|
164
|
+
border: 0.5px solid var(--dsw-alias-border-l2);
|
|
165
|
+
border-radius: var(--dsw-radius-sm);
|
|
166
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
167
|
+
color: var(--dsw-alias-label-secondary);
|
|
168
|
+
font-size: 11px;
|
|
169
|
+
font-family: inherit;
|
|
170
|
+
font-weight: 500;
|
|
171
|
+
cursor: pointer;
|
|
172
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.toolBtn:hover {
|
|
176
|
+
background: var(--dsw-alias-interactive-bg-active);
|
|
177
|
+
color: var(--dsw-alias-label-primary);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.btnIcon {
|
|
181
|
+
width: 12px;
|
|
182
|
+
height: 12px;
|
|
183
|
+
flex: none;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.newBranchBox {
|
|
187
|
+
display: flex;
|
|
188
|
+
gap: 6px;
|
|
189
|
+
margin-top: 2px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.newBranchInput {
|
|
193
|
+
flex: 1;
|
|
194
|
+
padding: 4px 8px;
|
|
195
|
+
border-radius: var(--dsw-radius-sm);
|
|
196
|
+
border: 0.5px solid var(--dsw-alias-state-business-primary);
|
|
197
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
198
|
+
color: var(--dsw-alias-label-primary);
|
|
199
|
+
font-size: 12px;
|
|
200
|
+
outline: none;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.newBranchInput::placeholder {
|
|
204
|
+
color: var(--dsw-alias-label-tertiary);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.branchList {
|
|
208
|
+
flex: 1;
|
|
209
|
+
overflow-y: auto;
|
|
210
|
+
padding: 4px;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.groupLabel {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 6px;
|
|
217
|
+
padding: 6px 8px 3px;
|
|
218
|
+
font-size: 11px;
|
|
219
|
+
font-weight: 500;
|
|
220
|
+
color: var(--dsw-alias-label-tertiary);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.branchItem {
|
|
224
|
+
display: flex;
|
|
225
|
+
align-items: center;
|
|
226
|
+
justify-content: space-between;
|
|
227
|
+
width: 100%;
|
|
228
|
+
padding: 5px 8px;
|
|
229
|
+
border-radius: var(--dsw-radius-sm);
|
|
230
|
+
border: none;
|
|
231
|
+
background: transparent;
|
|
232
|
+
color: var(--dsw-alias-label-secondary);
|
|
233
|
+
font: inherit;
|
|
234
|
+
font-size: 12px;
|
|
235
|
+
text-align: left;
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
transition: background 0.12s ease, color 0.12s ease;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.branchItem:hover {
|
|
241
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
242
|
+
color: var(--dsw-alias-label-primary);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.currentBranch {
|
|
246
|
+
background: var(--dsw-alias-interactive-bg-active);
|
|
247
|
+
color: var(--dsw-alias-state-business-primary);
|
|
248
|
+
font-weight: 500;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.currentBranch:hover {
|
|
252
|
+
background: var(--dsw-alias-interactive-bg-active);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.branchItemLeft {
|
|
256
|
+
display: flex;
|
|
257
|
+
align-items: center;
|
|
258
|
+
gap: 6px;
|
|
259
|
+
min-width: 0;
|
|
260
|
+
flex: 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.itemIcon {
|
|
264
|
+
width: 13px;
|
|
265
|
+
height: 13px;
|
|
266
|
+
flex: none;
|
|
267
|
+
color: var(--dsw-alias-label-tertiary);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.currentBranch .itemIcon {
|
|
271
|
+
color: var(--dsw-alias-state-business-primary);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.itemText {
|
|
275
|
+
overflow: hidden;
|
|
276
|
+
text-overflow: ellipsis;
|
|
277
|
+
white-space: nowrap;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.checkMark {
|
|
281
|
+
width: 14px;
|
|
282
|
+
height: 14px;
|
|
283
|
+
color: var(--dsw-alias-state-business-primary);
|
|
284
|
+
flex: none;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.emptyTip {
|
|
288
|
+
padding: 16px 8px;
|
|
289
|
+
text-align: center;
|
|
290
|
+
color: var(--dsw-alias-label-tertiary);
|
|
291
|
+
font-size: 12px;
|
|
292
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status bar Git branch pill and popover branch switcher (IDEA / VS Code style).
|
|
3
|
+
* Injected into the 'conversation.composer.dock' slot at order: -1.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import type { KeyboardEvent, MouseEvent } from 'react'
|
|
8
|
+
import { createPortal } from 'react-dom'
|
|
9
|
+
import type { GitBranchesSummary, GitStatusSummary } from '../types.ts'
|
|
10
|
+
import css from './GitBranchPill.module.css'
|
|
11
|
+
|
|
12
|
+
const MenuSurface = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
13
|
+
({ children, className, style, ...rest }, ref) => (
|
|
14
|
+
<div ref={ref} className={className} style={style} role="dialog" {...rest}>
|
|
15
|
+
{children}
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
MenuSurface.displayName = 'MenuSurface'
|
|
20
|
+
|
|
21
|
+
function IconBranchOutlineRegular({ className }: { className?: string }): React.ReactElement {
|
|
22
|
+
return (
|
|
23
|
+
<svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
24
|
+
<path d="M11.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zm-2.25.75a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25zM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zM3.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0z" />
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function IconCheckOutlineRegular({ className }: { className?: string }): React.ReactElement {
|
|
30
|
+
return (
|
|
31
|
+
<svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
32
|
+
<polyline points="3 8 7 12 13 4" />
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function IconPlusOutlineRegular({ className }: { className?: string }): React.ReactElement {
|
|
38
|
+
return (
|
|
39
|
+
<svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
40
|
+
<line x1="8" y1="3" x2="8" y2="13" />
|
|
41
|
+
<line x1="3" y1="8" x2="13" y2="8" />
|
|
42
|
+
</svg>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function IconRefreshOutlineRegular({ className }: { className?: string }): React.ReactElement {
|
|
47
|
+
return (
|
|
48
|
+
<svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
49
|
+
<path d="M2.5 8a5.5 5.5 0 1 0 1.6-3.9L2 6" />
|
|
50
|
+
<path d="M2 2v4h4" />
|
|
51
|
+
</svg>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function IconSearchOutlineRegular({ className }: { className?: string }): React.ReactElement {
|
|
56
|
+
return (
|
|
57
|
+
<svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
|
58
|
+
<circle cx="7" cy="7" r="4.5" />
|
|
59
|
+
<line x1="10.5" y1="10.5" x2="14" y2="14" />
|
|
60
|
+
</svg>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
export interface GitBranchPillProps {
|
|
66
|
+
getStatus?: ((sessionId?: string) => Promise<GitStatusSummary>) | undefined
|
|
67
|
+
getBranches?: ((sessionId?: string) => Promise<GitBranchesSummary>) | undefined
|
|
68
|
+
checkout?: (
|
|
69
|
+
(sessionId: string | undefined, branch: string, create?: boolean) => Promise<{ success: boolean; message?: string }>
|
|
70
|
+
) | undefined
|
|
71
|
+
pull?: ((sessionId?: string) => Promise<{ success: boolean; message?: string }>) | undefined
|
|
72
|
+
push?: ((sessionId?: string) => Promise<{ success: boolean; message?: string }>) | undefined
|
|
73
|
+
sessionId?: string | undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Git Branch Pill rendered in the Composer Dock.
|
|
78
|
+
*/
|
|
79
|
+
export const GitBranchPill = memo(function GitBranchPill({
|
|
80
|
+
getStatus,
|
|
81
|
+
getBranches,
|
|
82
|
+
checkout,
|
|
83
|
+
pull,
|
|
84
|
+
push,
|
|
85
|
+
sessionId,
|
|
86
|
+
}: GitBranchPillProps) {
|
|
87
|
+
const [status, setStatus] = useState<GitStatusSummary | null>(null)
|
|
88
|
+
const [branches, setBranches] = useState<GitBranchesSummary | null>(null)
|
|
89
|
+
const [open, setOpen] = useState(false)
|
|
90
|
+
const [filter, setFilter] = useState('')
|
|
91
|
+
const [creatingBranch, setCreatingBranch] = useState(false)
|
|
92
|
+
const [newBranchName, setNewBranchName] = useState('')
|
|
93
|
+
const [switching, setSwitching] = useState(false)
|
|
94
|
+
|
|
95
|
+
const anchorRef = useRef<HTMLSpanElement>(null)
|
|
96
|
+
const popoverRef = useRef<HTMLDivElement>(null)
|
|
97
|
+
const [popoverPos, setPopoverPos] = useState<{ bottom: number; left: number } | null>(null)
|
|
98
|
+
|
|
99
|
+
// Fetch status and branches from injected actions
|
|
100
|
+
const refresh = useCallback(async () => {
|
|
101
|
+
if (!getStatus || !getBranches) return
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const [statusRes, branchRes] = await Promise.all([
|
|
105
|
+
getStatus(sessionId),
|
|
106
|
+
getBranches(sessionId),
|
|
107
|
+
])
|
|
108
|
+
setStatus(statusRes)
|
|
109
|
+
setBranches(branchRes)
|
|
110
|
+
} catch {
|
|
111
|
+
// safe fallback on communication errors
|
|
112
|
+
}
|
|
113
|
+
}, [getStatus, getBranches, sessionId])
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
void refresh()
|
|
117
|
+
const timer = setInterval(() => { void refresh() }, 5000)
|
|
118
|
+
return () => { clearInterval(timer) }
|
|
119
|
+
}, [refresh])
|
|
120
|
+
|
|
121
|
+
// Recalculate popover position relative to anchor button
|
|
122
|
+
const updatePosition = useCallback(() => {
|
|
123
|
+
if (!anchorRef.current) return
|
|
124
|
+
const rect = anchorRef.current.getBoundingClientRect()
|
|
125
|
+
setPopoverPos({
|
|
126
|
+
bottom: window.innerHeight - rect.top + 6,
|
|
127
|
+
left: Math.max(8, Math.min(rect.left, window.innerWidth - 330)),
|
|
128
|
+
})
|
|
129
|
+
}, [])
|
|
130
|
+
|
|
131
|
+
const handleToggle = useCallback((e: MouseEvent) => {
|
|
132
|
+
e.stopPropagation()
|
|
133
|
+
if (!open) {
|
|
134
|
+
updatePosition()
|
|
135
|
+
void refresh()
|
|
136
|
+
}
|
|
137
|
+
setOpen(prev => !prev)
|
|
138
|
+
setCreatingBranch(false)
|
|
139
|
+
setFilter('')
|
|
140
|
+
}, [open, refresh, updatePosition])
|
|
141
|
+
|
|
142
|
+
// Dismiss popover on outside click or escape
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!open) return
|
|
145
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
146
|
+
const target = e.target as Node | null
|
|
147
|
+
if (popoverRef.current?.contains(target) || anchorRef.current?.contains(target)) return
|
|
148
|
+
setOpen(false)
|
|
149
|
+
}
|
|
150
|
+
const onKeyDown = (e: globalThis.KeyboardEvent) => {
|
|
151
|
+
if (e.key === 'Escape') setOpen(false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
155
|
+
document.addEventListener('keydown', onKeyDown)
|
|
156
|
+
return () => {
|
|
157
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
158
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
159
|
+
}
|
|
160
|
+
}, [open])
|
|
161
|
+
|
|
162
|
+
// Checkout branch
|
|
163
|
+
const handleCheckout = useCallback(async (branchName: string, create = false) => {
|
|
164
|
+
if (!checkout || switching) return
|
|
165
|
+
|
|
166
|
+
setSwitching(true)
|
|
167
|
+
try {
|
|
168
|
+
const res = await checkout(sessionId, branchName, create)
|
|
169
|
+
if (res.success) {
|
|
170
|
+
setOpen(false)
|
|
171
|
+
setCreatingBranch(false)
|
|
172
|
+
setNewBranchName('')
|
|
173
|
+
await refresh()
|
|
174
|
+
} else {
|
|
175
|
+
alert(res.message ?? 'Checkout failed')
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
setSwitching(false)
|
|
179
|
+
}
|
|
180
|
+
}, [checkout, refresh, sessionId, switching])
|
|
181
|
+
|
|
182
|
+
// Create new branch
|
|
183
|
+
const handleCreateBranchKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
|
|
184
|
+
if (e.key === 'Enter') {
|
|
185
|
+
const trimmed = newBranchName.trim()
|
|
186
|
+
if (trimmed) void handleCheckout(trimmed, true)
|
|
187
|
+
} else if (e.key === 'Escape') {
|
|
188
|
+
setCreatingBranch(false)
|
|
189
|
+
}
|
|
190
|
+
}, [handleCheckout, newBranchName])
|
|
191
|
+
|
|
192
|
+
// Pull
|
|
193
|
+
const handlePull = useCallback(async () => {
|
|
194
|
+
if (!pull) return
|
|
195
|
+
await pull(sessionId)
|
|
196
|
+
await refresh()
|
|
197
|
+
}, [pull, refresh, sessionId])
|
|
198
|
+
|
|
199
|
+
// Push
|
|
200
|
+
const handlePush = useCallback(async () => {
|
|
201
|
+
if (!push) return
|
|
202
|
+
await push(sessionId)
|
|
203
|
+
await refresh()
|
|
204
|
+
}, [push, refresh, sessionId])
|
|
205
|
+
|
|
206
|
+
// Filtered branches
|
|
207
|
+
const filteredLocal = useMemo(() => {
|
|
208
|
+
if (!branches?.local) return []
|
|
209
|
+
const q = filter.trim().toLowerCase()
|
|
210
|
+
return q ? branches.local.filter(b => b.name.toLowerCase().includes(q)) : branches.local
|
|
211
|
+
}, [branches?.local, filter])
|
|
212
|
+
|
|
213
|
+
const filteredRemote = useMemo(() => {
|
|
214
|
+
if (!branches?.remote) return []
|
|
215
|
+
const q = filter.trim().toLowerCase()
|
|
216
|
+
return q ? branches.remote.filter(b => b.name.toLowerCase().includes(q)) : branches.remote
|
|
217
|
+
}, [branches?.remote, filter])
|
|
218
|
+
|
|
219
|
+
if (status && !status.isRepo) {
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const currentBranchName = status?.branch || 'master'
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<span ref={anchorRef} className={css.anchor} data-composer-stat="git-branch">
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className={css.pill}
|
|
230
|
+
aria-haspopup="dialog"
|
|
231
|
+
aria-expanded={open}
|
|
232
|
+
title={`Git Branch: ${currentBranchName}`}
|
|
233
|
+
onClick={handleToggle}
|
|
234
|
+
>
|
|
235
|
+
<IconBranchOutlineRegular className={css.icon} />
|
|
236
|
+
<span className={css.branchName}>{currentBranchName}</span>
|
|
237
|
+
{status !== null && status.ahead > 0 && <span className={`${css.badge} ${css.badgeAhead}`}>↑{status.ahead}</span>}
|
|
238
|
+
{status !== null && status.behind > 0 && <span className={`${css.badge} ${css.badgeBehind}`}>↓{status.behind}</span>}
|
|
239
|
+
</button>
|
|
240
|
+
|
|
241
|
+
{open && popoverPos && createPortal(
|
|
242
|
+
<MenuSurface
|
|
243
|
+
ref={popoverRef}
|
|
244
|
+
className={css.popover}
|
|
245
|
+
style={{ bottom: `${popoverPos.bottom}px`, left: `${popoverPos.left}px` }}
|
|
246
|
+
>
|
|
247
|
+
<div className={css.header}>
|
|
248
|
+
<div className={css.searchRow}>
|
|
249
|
+
<IconSearchOutlineRegular className={css.searchIcon} />
|
|
250
|
+
<input
|
|
251
|
+
autoFocus
|
|
252
|
+
className={css.searchInput}
|
|
253
|
+
placeholder="搜索或过滤分支..."
|
|
254
|
+
value={filter}
|
|
255
|
+
onChange={(e) => { setFilter(e.target.value) }}
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div className={css.actionBar}>
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
className={css.toolBtn}
|
|
263
|
+
title="新建分支"
|
|
264
|
+
onClick={() => { setCreatingBranch(prev => !prev) }}
|
|
265
|
+
>
|
|
266
|
+
<IconPlusOutlineRegular className={css.btnIcon} />
|
|
267
|
+
<span>新建分支</span>
|
|
268
|
+
</button>
|
|
269
|
+
<div className={css.toolBtnGroup}>
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
className={css.toolBtn}
|
|
273
|
+
title="拉取 (Pull)"
|
|
274
|
+
onClick={() => { void handlePull() }}
|
|
275
|
+
>
|
|
276
|
+
Pull
|
|
277
|
+
</button>
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
className={css.toolBtn}
|
|
281
|
+
title="推送 (Push)"
|
|
282
|
+
onClick={() => { void handlePush() }}
|
|
283
|
+
>
|
|
284
|
+
Push
|
|
285
|
+
</button>
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
className={css.toolBtn}
|
|
289
|
+
title="刷新状态"
|
|
290
|
+
onClick={() => { void refresh() }}
|
|
291
|
+
>
|
|
292
|
+
<IconRefreshOutlineRegular className={css.btnIcon} />
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{creatingBranch && (
|
|
298
|
+
<div className={css.newBranchBox}>
|
|
299
|
+
<input
|
|
300
|
+
autoFocus
|
|
301
|
+
className={css.newBranchInput}
|
|
302
|
+
placeholder="新分支名称 (按 Enter 创建)..."
|
|
303
|
+
value={newBranchName}
|
|
304
|
+
onChange={(e) => { setNewBranchName(e.target.value) }}
|
|
305
|
+
onKeyDown={handleCreateBranchKeyDown}
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<div className={css.branchList}>
|
|
312
|
+
{/* Local Branches */}
|
|
313
|
+
<div className={css.groupLabel}>本地分支 ({filteredLocal.length})</div>
|
|
314
|
+
{filteredLocal.map(b => (
|
|
315
|
+
<button
|
|
316
|
+
key={b.name}
|
|
317
|
+
type="button"
|
|
318
|
+
className={`${css.branchItem} ${b.isCurrent ? css.currentBranch : ''}`}
|
|
319
|
+
disabled={switching}
|
|
320
|
+
onClick={() => {
|
|
321
|
+
if (!b.isCurrent) void handleCheckout(b.name)
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
<div className={css.branchItemLeft}>
|
|
325
|
+
<IconBranchOutlineRegular className={css.itemIcon} />
|
|
326
|
+
<span className={css.itemText}>{b.name}</span>
|
|
327
|
+
</div>
|
|
328
|
+
{b.isCurrent ? (
|
|
329
|
+
<IconCheckOutlineRegular className={css.checkMark} />
|
|
330
|
+
) : (
|
|
331
|
+
b.ahead !== undefined && b.ahead > 0 && <span className={css.badge}>↑{b.ahead}</span>
|
|
332
|
+
)}
|
|
333
|
+
</button>
|
|
334
|
+
))}
|
|
335
|
+
|
|
336
|
+
{/* Remote Branches */}
|
|
337
|
+
{filteredRemote.length > 0 && (
|
|
338
|
+
<>
|
|
339
|
+
<div className={css.groupLabel} style={{ marginTop: '6px' }}>
|
|
340
|
+
远程分支 ({filteredRemote.length})
|
|
341
|
+
</div>
|
|
342
|
+
{filteredRemote.map(b => (
|
|
343
|
+
<button
|
|
344
|
+
key={b.name}
|
|
345
|
+
type="button"
|
|
346
|
+
className={css.branchItem}
|
|
347
|
+
disabled={switching}
|
|
348
|
+
onClick={() => { void handleCheckout(b.name) }}
|
|
349
|
+
>
|
|
350
|
+
<div className={css.branchItemLeft}>
|
|
351
|
+
<IconBranchOutlineRegular className={css.itemIcon} />
|
|
352
|
+
<span className={css.itemText}>{b.name}</span>
|
|
353
|
+
</div>
|
|
354
|
+
</button>
|
|
355
|
+
))}
|
|
356
|
+
</>
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
{filteredLocal.length === 0 && filteredRemote.length === 0 && (
|
|
360
|
+
<div className={css.emptyTip}>未找到匹配的分支</div>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
</MenuSurface>,
|
|
364
|
+
document.body,
|
|
365
|
+
)}
|
|
366
|
+
</span>
|
|
367
|
+
)
|
|
368
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Git tab's chip title in the right-sidebar header.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import css from './GitBody.module.css'
|
|
7
|
+
|
|
8
|
+
export interface GitTitleProps {
|
|
9
|
+
useTabInfo: () => { tab: { title: string } }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Git chip title component with branch glyph and title text.
|
|
14
|
+
* @param props - Slot props including tab info hook.
|
|
15
|
+
* @returns React node for tab chip.
|
|
16
|
+
*/
|
|
17
|
+
export function GitTitle({ useTabInfo }: GitTitleProps): ReactNode {
|
|
18
|
+
const { tab } = useTabInfo()
|
|
19
|
+
return (
|
|
20
|
+
<span className={css.titleContainer}>
|
|
21
|
+
<svg className={css.titleIcon} width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
22
|
+
<path d="M11.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zm-2.25.75a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25zM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zM3.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0z" />
|
|
23
|
+
</svg>
|
|
24
|
+
<span className={css.titleText}>{tab.title}</span>
|
|
25
|
+
</span>
|
|
26
|
+
)
|
|
27
|
+
}
|