dsh-git-ui 0.0.1 → 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 +41 -16
- package/README.zh.md +42 -17
- 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 +34 -0
- package/lib/host/core.d.ts +31 -1
- package/lib/host/index.d.ts +15 -8
- package/lib/host/index.js +464 -23
- 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 +174 -0
- package/package.json +1 -1
- package/src/client/GitCenter.tsx +1524 -0
- package/src/client/GitPill.tsx +303 -54
- package/src/client/changes-diff.ts +63 -0
- package/src/client/controller.ts +83 -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 +4 -2
- package/src/client/locales.ts +162 -0
- package/src/client/popup-close.ts +19 -0
- package/src/client/remote.ts +140 -0
- package/src/client/select-menu.tsx +113 -0
- package/src/client/side-by-side.ts +150 -0
- package/src/client/styles.ts +1488 -46
- package/src/client/time-format.ts +32 -0
- package/src/host/actions.ts +178 -0
- package/src/host/core.ts +39 -13
- package/src/host/index.ts +45 -17
- package/src/host/parser.ts +155 -12
- package/src/host/queries.ts +289 -0
- package/src/host/types.ts +143 -0
package/src/client/GitPill.tsx
CHANGED
|
@@ -15,8 +15,17 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
|
15
15
|
import { createPortal } from 'react-dom'
|
|
16
16
|
import type { JSX } from 'react'
|
|
17
17
|
import { completedTurnCount, type TurnSignalSnapshot } from './turn-signal.ts'
|
|
18
|
-
import
|
|
18
|
+
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
19
|
+
import type { GitObservable, GitQueryOutcome, GitView } from './controller.ts'
|
|
20
|
+
import { GitCenter } from './GitCenter.tsx'
|
|
21
|
+
import { fileIconForPath, FolderIcon, AlertIcon, CloseIcon, RollbackIcon, StageIcon, UnstageIcon } from './icons.tsx'
|
|
22
|
+
import type { GitAction, GitActionResult, GitBranch, GitOperationErrorCode, GitQueryRequest } from '../host/types.ts'
|
|
19
23
|
import type { GitKey } from './locales.ts'
|
|
24
|
+
import { SelectMenu } from './select-menu.tsx'
|
|
25
|
+
import { splitChangePath } from './file-tree.ts'
|
|
26
|
+
import { shouldClosePopup } from './popup-close.ts'
|
|
27
|
+
import { diffBaseOf } from './changes-diff.ts'
|
|
28
|
+
import { errorText, errorAction } from './error-text.ts'
|
|
20
29
|
import * as css from './styles.ts'
|
|
21
30
|
|
|
22
31
|
// Inject the plugin's interaction styles once (idempotent, browser-only).
|
|
@@ -31,6 +40,10 @@ export interface GitInjected {
|
|
|
31
40
|
}
|
|
32
41
|
/** Force an immediate re-check (same path as polling). */
|
|
33
42
|
refresh: () => Promise<void>
|
|
43
|
+
/** Execute one management action (host returns a fresh snapshot). */
|
|
44
|
+
run: (action: GitAction) => Promise<GitActionResult>
|
|
45
|
+
/** Run one read-only query (history / diff / show / branches). */
|
|
46
|
+
query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
/** Selector hook shape the slot runtime binds from `hooks.git`. */
|
|
@@ -53,16 +66,7 @@ export interface GitPillProps extends GitInjected {
|
|
|
53
66
|
readonly t: (key: GitKey) => string
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
/**
|
|
57
|
-
const CHIP_COLORS: Record<string, string> = {
|
|
58
|
-
added: '#2e7d32',
|
|
59
|
-
modified: '#b26a00',
|
|
60
|
-
deleted: '#c62828',
|
|
61
|
-
renamed: '#1565c0',
|
|
62
|
-
untracked: '#6a6a6a',
|
|
63
|
-
conflicted: '#d32f2f',
|
|
64
|
-
typechange: '#7b1fa2',
|
|
65
|
-
}
|
|
69
|
+
/** 状态字符映射(配色见 styles.chipStyles,全语义 token)。 */
|
|
66
70
|
|
|
67
71
|
const CHIP_LETTERS: Record<string, string> = {
|
|
68
72
|
added: 'A', modified: 'M', deleted: 'D', renamed: 'R',
|
|
@@ -98,16 +102,14 @@ function timeAgo(iso: string, now: number, t: (key: GitKey) => string): string {
|
|
|
98
102
|
return fill('time.daysAgo', Math.floor(seconds / 86_400))
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
/** The pill label
|
|
102
|
-
function
|
|
105
|
+
/** The pill label parts: 分支名(可 ellipsis 收缩)+ 徽标(不截断保留)。 */
|
|
106
|
+
function pillParts(view: GitView & { state: 'ready' }, t: (key: GitKey) => string): { branch: string; badges: string[] } {
|
|
103
107
|
const s = view.snapshot
|
|
104
108
|
const branch = s.branch === null
|
|
105
109
|
? `(${t('pill.detached')}) · ${s.head ?? ''}`
|
|
106
|
-
: s.branch
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
const ahead = aheadBehind(view)
|
|
110
|
-
return [base, badge, ahead].filter(Boolean).join(' · ')
|
|
110
|
+
: (s.unborn ? `${s.branch} · ${t('pill.noCommits')}` : s.branch)
|
|
111
|
+
const badges = [dirtyBadge(view), aheadBehind(view)].filter(Boolean)
|
|
112
|
+
return { branch, badges }
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/** Dimmed pill for degraded states. */
|
|
@@ -120,39 +122,195 @@ function DegradedPill({ label, title, t }: { label: string; title?: string; t: (
|
|
|
120
122
|
)
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Popup body (rendered inside the portaled card)。
|
|
127
|
+
* 分支管理(切换/新建)已并入本组件:头部内联切换 + 新建行上提;
|
|
128
|
+
* 变更行带 hover 内联操作(暂存/取消/丢弃两步)。
|
|
129
|
+
*/
|
|
124
130
|
function GitPopupBody({
|
|
125
|
-
view, refresh, t,
|
|
131
|
+
view, refresh, openCenter, onOpenDiff, run, query, t,
|
|
126
132
|
}: {
|
|
127
133
|
view: GitView & { state: 'ready' }
|
|
128
134
|
refresh: () => Promise<void>
|
|
135
|
+
openCenter: () => void
|
|
136
|
+
/** 变更文件点击:打开 Git 中心并定位该文件的对照视图。 */
|
|
137
|
+
onOpenDiff: (path: string, base: 'worktree' | 'staged') => void
|
|
138
|
+
run: (action: GitAction) => Promise<GitActionResult>
|
|
139
|
+
query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
|
|
129
140
|
t: (key: GitKey) => string
|
|
130
141
|
}): JSX.Element {
|
|
131
142
|
const now = Date.now()
|
|
132
143
|
const s = view.snapshot
|
|
144
|
+
const branchLabel = s.branch === null ? `(${t('pill.detached')})` : s.branch
|
|
145
|
+
|
|
146
|
+
/** 分支管理状态(自原 BranchQuickManage 并入):切换(头部内联)+ 新建(上提)。 */
|
|
147
|
+
const [branchData, setBranchData] = useState<{ current: string | null; local: readonly GitBranch[] } | null>(null)
|
|
148
|
+
const [busy, setBusy] = useState(false)
|
|
149
|
+
const [newName, setNewName] = useState('')
|
|
150
|
+
const [note, setNote] = useState<{ text: string; detail?: string; action?: 'open-center' } | null>(null)
|
|
151
|
+
// 变更行丢弃两步确认:armed 记录待确认的路径,3s 自动解除。
|
|
152
|
+
const [armed, setArmed] = useState<string | null>(null)
|
|
153
|
+
|
|
154
|
+
/** 操作失败 → 友好告警:业务错误用 i18n 文案 + 行动按钮,原始信息留 detail。 */
|
|
155
|
+
const setErrorNote = (err: { code: GitOperationErrorCode; message?: string }): void => {
|
|
156
|
+
setNote({
|
|
157
|
+
text: errorText(err.code, err.message, t),
|
|
158
|
+
...(err.message === undefined ? {} : { detail: err.message }),
|
|
159
|
+
...(errorAction(err.code) === null ? {} : { action: errorAction(err.code) ?? undefined }),
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const reload = async (): Promise<void> => {
|
|
164
|
+
const outcome = await query({ kind: 'branches' })
|
|
165
|
+
if (outcome.ok && outcome.value.kind === 'branches') {
|
|
166
|
+
setBranchData({ current: outcome.value.current, local: outcome.value.local })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
void reload()
|
|
172
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (armed === null) return
|
|
177
|
+
const timer = setTimeout(() => setArmed(null), 3000)
|
|
178
|
+
return () => clearTimeout(timer)
|
|
179
|
+
}, [armed])
|
|
180
|
+
|
|
181
|
+
const switchTo = async (name: string): Promise<void> => {
|
|
182
|
+
if (busy || name === '') return
|
|
183
|
+
setBusy(true)
|
|
184
|
+
setNote(null)
|
|
185
|
+
const result = await run({ kind: 'branch-checkout', name })
|
|
186
|
+
setBusy(false)
|
|
187
|
+
if (!result.ok) setErrorNote(result.error)
|
|
188
|
+
await reload()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const createAndSwitch = async (): Promise<void> => {
|
|
192
|
+
const name = newName.trim()
|
|
193
|
+
if (name === '' || busy) return
|
|
194
|
+
setBusy(true)
|
|
195
|
+
setNote(null)
|
|
196
|
+
const created = await run({ kind: 'branch-create', name })
|
|
197
|
+
if (created.ok) {
|
|
198
|
+
const switched = await run({ kind: 'branch-checkout', name })
|
|
199
|
+
if (!switched.ok) setErrorNote(switched.error)
|
|
200
|
+
setNewName('')
|
|
201
|
+
} else {
|
|
202
|
+
setErrorNote(created.error)
|
|
203
|
+
}
|
|
204
|
+
setBusy(false)
|
|
205
|
+
await reload()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 执行一条变更行操作(暂存/取消/丢弃),失败以 note 显示。 */
|
|
209
|
+
const runChange = async (action: GitAction, path: string): Promise<void> => {
|
|
210
|
+
if (busy) return
|
|
211
|
+
setBusy(true)
|
|
212
|
+
setNote(null)
|
|
213
|
+
const result = await run(action)
|
|
214
|
+
setBusy(false)
|
|
215
|
+
if (!result.ok) setErrorNote(result.error)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stage = (path: string): void => void runChange({ kind: 'stage', paths: [path] }, path)
|
|
219
|
+
const unstage = (path: string): void => void runChange({ kind: 'unstage', paths: [path] }, path)
|
|
220
|
+
const discard = (path: string): void => {
|
|
221
|
+
if (busy) return
|
|
222
|
+
if (armed !== path) { setArmed(path); return }
|
|
223
|
+
setArmed(null)
|
|
224
|
+
void runChange({ kind: 'discard', paths: [path] }, path)
|
|
225
|
+
}
|
|
226
|
+
|
|
133
227
|
return (
|
|
134
228
|
<>
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
229
|
+
<header style={css.popupHeader}>
|
|
230
|
+
<div style={css.popupHeaderMain}>
|
|
231
|
+
<span style={s.dirty ? css.dotDirty : css.dot} aria-hidden="true" />
|
|
232
|
+
{branchData === null ? (
|
|
233
|
+
<span style={css.popupHeaderBranch}>{branchLabel}</span>
|
|
234
|
+
) : (
|
|
235
|
+
<SelectMenu
|
|
236
|
+
value={branchData.current ?? ''}
|
|
237
|
+
options={[
|
|
238
|
+
// 游离 HEAD:注入伪选项让头部显示游离标签,仍可下拉切换本地分支。
|
|
239
|
+
...(branchData.current === null ? [{ value: '', label: branchLabel }] : []),
|
|
240
|
+
...branchData.local.map((b) => ({ value: b.name, label: b.name })),
|
|
241
|
+
]}
|
|
242
|
+
onSelect={(name) => void switchTo(name)}
|
|
243
|
+
ariaLabel={t('center.currentBranch')}
|
|
244
|
+
buttonStyle={css.popupBranchMenu}
|
|
245
|
+
/>
|
|
246
|
+
)}
|
|
247
|
+
{s.unborn && <span style={css.popupBadge}>{t('pill.noCommits')}</span>}
|
|
248
|
+
{s.dirty && <span style={css.popupBadge}>{dirtyBadge(view)}</span>}
|
|
249
|
+
{(s.ahead > 0 || s.behind > 0) && <span style={css.popupBadge}>{aheadBehind(view)}</span>}
|
|
250
|
+
</div>
|
|
251
|
+
<div style={css.popupHeaderRoot} title={s.root}>
|
|
252
|
+
<FolderIcon />
|
|
253
|
+
<span style={css.popupHeaderRootText}>{s.root}</span>
|
|
254
|
+
</div>
|
|
255
|
+
</header>
|
|
256
|
+
<div style={css.popupStatusBar}>
|
|
138
257
|
{([
|
|
139
258
|
['popup.staged', s.staged], ['popup.modified', s.modified], ['popup.untracked', s.untracked],
|
|
140
|
-
['popup.ahead', s.ahead], ['popup.behind', s.behind],
|
|
141
259
|
] as const).map(([key, value]) => (
|
|
142
|
-
<
|
|
143
|
-
<
|
|
144
|
-
<
|
|
145
|
-
</
|
|
260
|
+
<span key={key} style={css.popupStatItem}>
|
|
261
|
+
<span style={css.popupStatValue}>{value}</span>
|
|
262
|
+
<span style={css.popupStatLabel}>{t(key)}</span>
|
|
263
|
+
</span>
|
|
146
264
|
))}
|
|
147
265
|
</div>
|
|
266
|
+
<div style={css.popupBranchOps}>
|
|
267
|
+
<input
|
|
268
|
+
className="dsh-git-ui__branch-input"
|
|
269
|
+
style={css.branchNameInput}
|
|
270
|
+
placeholder={t('center.branchName')}
|
|
271
|
+
value={newName}
|
|
272
|
+
disabled={busy}
|
|
273
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
274
|
+
onKeyDown={(e) => { if (e.key === 'Enter') void createAndSwitch() }}
|
|
275
|
+
/>
|
|
276
|
+
<Button size="sm" disabled={busy || newName.trim() === ''} onClick={() => void createAndSwitch()}>
|
|
277
|
+
{t('center.createAndSwitch')}
|
|
278
|
+
</Button>
|
|
279
|
+
</div>
|
|
280
|
+
{note !== null && (
|
|
281
|
+
<div style={css.popupNote} role="alert">
|
|
282
|
+
<span style={css.popupNoteIcon} aria-hidden="true"><AlertIcon /></span>
|
|
283
|
+
<span style={css.popupNoteText} title={note.detail}>{note.text}</span>
|
|
284
|
+
{note.action === 'open-center' && (
|
|
285
|
+
<button type="button" className="dsh-git-ui__change-link" style={css.popupNoteAction} onClick={openCenter}>
|
|
286
|
+
{t('error.handleChanges')}
|
|
287
|
+
</button>
|
|
288
|
+
)}
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
className="dsh-git-ui__icon-btn"
|
|
292
|
+
style={css.popupNoteClose}
|
|
293
|
+
title={t('center.close')}
|
|
294
|
+
aria-label={t('center.close')}
|
|
295
|
+
onClick={() => setNote(null)}
|
|
296
|
+
>
|
|
297
|
+
<CloseIcon />
|
|
298
|
+
</button>
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
148
301
|
<div style={css.sectionTitle}>{t('popup.recentCommits')}</div>
|
|
149
302
|
{s.recentCommits.length === 0
|
|
150
303
|
? <div style={css.emptyNote}>{t('popup.emptyCommits')}</div>
|
|
151
|
-
: s.recentCommits.map((commit) => (
|
|
152
|
-
<div key={commit.hash} style={css.commitRow}>
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
304
|
+
: s.recentCommits.slice(0, 3).map((commit) => (
|
|
305
|
+
<div key={commit.hash} className="dsh-git-ui__row" style={css.commitRow}>
|
|
306
|
+
<div style={css.commitSubjectPop} title={commit.subject}>{commit.subject}</div>
|
|
307
|
+
<div style={css.commitMetaLine}>
|
|
308
|
+
<span style={css.commitHash}>{commit.shortHash}</span>
|
|
309
|
+
<span style={css.commitDot}>·</span>
|
|
310
|
+
<span style={css.commitMeta}>{commit.author}</span>
|
|
311
|
+
<span style={css.commitDot}>·</span>
|
|
312
|
+
<span style={css.commitMeta}>{timeAgo(commit.dateIso, now, t)}</span>
|
|
313
|
+
</div>
|
|
156
314
|
</div>
|
|
157
315
|
))}
|
|
158
316
|
<div style={css.sectionTitle}>{t('popup.changes')}</div>
|
|
@@ -160,17 +318,75 @@ function GitPopupBody({
|
|
|
160
318
|
? <div style={css.emptyNote}>{t('popup.empty')}</div>
|
|
161
319
|
: (
|
|
162
320
|
<>
|
|
163
|
-
{s.changes.map((change) =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
321
|
+
{s.changes.map((change) => {
|
|
322
|
+
const { name, dir, isDir } = splitChangePath(change.path, change.isDirectory)
|
|
323
|
+
const untracked = change.status === 'untracked'
|
|
324
|
+
return (
|
|
325
|
+
<div key={change.path} className="dsh-git-ui__row" style={css.changeRow}>
|
|
326
|
+
<span
|
|
327
|
+
style={{ ...css.changeChip, ...(css.chipStyles[change.status] ?? css.chipStyles.untracked) }}
|
|
328
|
+
title={change.status}
|
|
329
|
+
>
|
|
330
|
+
{CHIP_LETTERS[change.status] ?? '•'}
|
|
331
|
+
</span>
|
|
332
|
+
<span style={css.rowFileIcon} aria-hidden="true">
|
|
333
|
+
{isDir ? <FolderIcon /> : fileIconForPath(change.path)}
|
|
334
|
+
</span>
|
|
335
|
+
{isDir ? (
|
|
336
|
+
// 目录条目:点击打开 Git 中心变更页(目录无 diff 语义,展开后选具体文件)。
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
className="dsh-git-ui__change-link"
|
|
340
|
+
style={css.changeNamePopBtn}
|
|
341
|
+
title={change.path}
|
|
342
|
+
aria-label={`${name} — ${t('center.open')}`}
|
|
343
|
+
onClick={openCenter}
|
|
344
|
+
>
|
|
345
|
+
{name}
|
|
346
|
+
</button>
|
|
347
|
+
) : (
|
|
348
|
+
// 文件条目:点击打开 Git 中心并直接展示该文件对照。
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
className="dsh-git-ui__change-link"
|
|
352
|
+
style={css.changeNamePopBtn}
|
|
353
|
+
title={change.path}
|
|
354
|
+
aria-label={`${name} — ${t('changes.actionDiff')}`}
|
|
355
|
+
onClick={() => onOpenDiff(change.path, diffBaseOf(change))}
|
|
356
|
+
>
|
|
357
|
+
{name}
|
|
358
|
+
</button>
|
|
359
|
+
)}
|
|
360
|
+
{dir !== '' ? <span style={css.changeDirPop}>{dir}</span> : <span style={{ flex: '1 1 0%', minWidth: 0 }} />}
|
|
361
|
+
<span className="dsh-git-ui__row-actions" style={css.rowActions}>
|
|
362
|
+
{change.staged
|
|
363
|
+
? (
|
|
364
|
+
<button type="button" className="dsh-git-ui__icon-btn" style={css.rowIconButton} title={t('center.unstage')} aria-label={t('center.unstage')} disabled={busy} onClick={() => unstage(change.path)}>
|
|
365
|
+
<UnstageIcon />
|
|
366
|
+
</button>
|
|
367
|
+
)
|
|
368
|
+
: (
|
|
369
|
+
<button type="button" className="dsh-git-ui__icon-btn" style={css.rowIconButton} title={t('center.stage')} aria-label={t('center.stage')} disabled={busy} onClick={() => stage(change.path)}>
|
|
370
|
+
<StageIcon />
|
|
371
|
+
</button>
|
|
372
|
+
)}
|
|
373
|
+
{!untracked && (
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
className="dsh-git-ui__icon-btn"
|
|
377
|
+
style={armed === change.path ? { ...css.rowIconButton, color: 'var(--dsw-alias-state-error-primary)' } : css.rowIconButton}
|
|
378
|
+
title={armed === change.path ? t('center.confirmDiscard') : t('center.discard')}
|
|
379
|
+
aria-label={armed === change.path ? t('center.confirmDiscard') : t('center.discard')}
|
|
380
|
+
disabled={busy}
|
|
381
|
+
onClick={() => discard(change.path)}
|
|
382
|
+
>
|
|
383
|
+
<RollbackIcon />
|
|
384
|
+
</button>
|
|
385
|
+
)}
|
|
386
|
+
</span>
|
|
387
|
+
</div>
|
|
388
|
+
)
|
|
389
|
+
})}
|
|
174
390
|
{s.truncated && (
|
|
175
391
|
<div style={css.emptyNote}>{t('popup.changesTruncated').replace('{count}', String(s.changes.length))}</div>
|
|
176
392
|
)}
|
|
@@ -178,7 +394,12 @@ function GitPopupBody({
|
|
|
178
394
|
)}
|
|
179
395
|
<div style={css.footerRow}>
|
|
180
396
|
<span style={css.checkedAt}>{t('popup.checkedAt').replace('{time}', new Date(s.checkedAt).toLocaleTimeString())}</span>
|
|
181
|
-
<
|
|
397
|
+
<span style={css.footerActions}>
|
|
398
|
+
<PopRefresher refresh={refresh} t={t} />
|
|
399
|
+
<button type="button" className="dsh-git-ui__footer-primary" style={{ ...css.refreshButton, ...css.footerPrimary, padding: '4px 10px' }} onClick={openCenter}>
|
|
400
|
+
{t('center.open')}
|
|
401
|
+
</button>
|
|
402
|
+
</span>
|
|
182
403
|
</div>
|
|
183
404
|
</>
|
|
184
405
|
)
|
|
@@ -207,9 +428,10 @@ const POPUP_GUTTER = 6
|
|
|
207
428
|
const VIEW_GUTTER = 8
|
|
208
429
|
|
|
209
430
|
/**
|
|
210
|
-
* The header utility entry: a branch pill that opens a portaled detail popup
|
|
431
|
+
* The header utility entry: a branch pill that opens a portaled detail popup
|
|
432
|
+
* and the Git center management panel.
|
|
211
433
|
*/
|
|
212
|
-
export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.Element | null {
|
|
434
|
+
export function GitPill({ useGit, useSession, refresh, run, query, t }: GitPillProps): JSX.Element | null {
|
|
213
435
|
// The selector hook requires a selector function (with-selector calls it
|
|
214
436
|
// unconditionally); identity selection reads the whole view snapshot.
|
|
215
437
|
const view = useGit((view) => view)
|
|
@@ -238,7 +460,18 @@ export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.E
|
|
|
238
460
|
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
239
461
|
const popRef = useRef<HTMLDivElement>(null)
|
|
240
462
|
const [open, setOpen] = useState(false)
|
|
463
|
+
const [centerOpen, setCenterOpen] = useState(false)
|
|
241
464
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
465
|
+
/** 从 pill 变更行点击「打开 Git 中心并定位该文件 diff」的请求。 */
|
|
466
|
+
const [centerRequest, setCenterRequest] = useState<{ path: string; base: 'worktree' | 'staged' } | null>(null)
|
|
467
|
+
|
|
468
|
+
/** 打开 Git 中心并直接定位到该文件的对照视图(关 popup、切 changes 标签、查询 diff)。 */
|
|
469
|
+
const openDiffInCenter = (path: string, base: 'worktree' | 'staged'): void => {
|
|
470
|
+
setCenterRequest({ path, base })
|
|
471
|
+
setOpen(false)
|
|
472
|
+
setPos(null)
|
|
473
|
+
setCenterOpen(true)
|
|
474
|
+
}
|
|
242
475
|
|
|
243
476
|
useEffect(() => {
|
|
244
477
|
// First mount only: kick the controller once (single-flight; a cold
|
|
@@ -290,10 +523,7 @@ export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.E
|
|
|
290
523
|
if (!open) return
|
|
291
524
|
const close = (): void => { setOpen(false); setPos(null) }
|
|
292
525
|
const onDown = (e: MouseEvent): void => {
|
|
293
|
-
|
|
294
|
-
if (wrapRef.current?.contains(target) ?? false) return
|
|
295
|
-
if (popRef.current?.contains(target) ?? false) return
|
|
296
|
-
close()
|
|
526
|
+
if (shouldClosePopup(e.target, wrapRef.current, popRef.current)) close()
|
|
297
527
|
}
|
|
298
528
|
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') close() }
|
|
299
529
|
document.addEventListener('mousedown', onDown)
|
|
@@ -323,6 +553,7 @@ export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.E
|
|
|
323
553
|
}
|
|
324
554
|
|
|
325
555
|
const dirty = display.snapshot.dirty
|
|
556
|
+
const parts = pillParts(display, t)
|
|
326
557
|
return (
|
|
327
558
|
<span ref={wrapRef} style={{ display: 'inline-flex' }}>
|
|
328
559
|
<button
|
|
@@ -332,10 +563,11 @@ export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.E
|
|
|
332
563
|
onClick={() => setOpen(!open)}
|
|
333
564
|
aria-haspopup="dialog"
|
|
334
565
|
aria-expanded={open}
|
|
335
|
-
title={`${display.snapshot.root}\n${
|
|
566
|
+
title={`${display.snapshot.root}\n${[parts.branch, ...parts.badges].filter(Boolean).join(' · ')}`}
|
|
336
567
|
>
|
|
337
568
|
<span style={dirty ? css.dotDirty : css.dot} aria-hidden="true" />
|
|
338
|
-
<span>{
|
|
569
|
+
<span style={css.pillBranch}>{parts.branch}</span>
|
|
570
|
+
{parts.badges.length > 0 && <span style={css.pillBadges}>{parts.badges.join(' · ')}</span>}
|
|
339
571
|
</button>
|
|
340
572
|
{open && pos !== null && createPortal(
|
|
341
573
|
<div
|
|
@@ -345,10 +577,27 @@ export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.E
|
|
|
345
577
|
role="dialog"
|
|
346
578
|
aria-label={t('popup.title')}
|
|
347
579
|
>
|
|
348
|
-
<GitPopupBody
|
|
580
|
+
<GitPopupBody
|
|
581
|
+
view={display}
|
|
582
|
+
refresh={refresh}
|
|
583
|
+
openCenter={() => { setOpen(false); setPos(null); setCenterOpen(true) }}
|
|
584
|
+
onOpenDiff={openDiffInCenter}
|
|
585
|
+
run={run}
|
|
586
|
+
query={query}
|
|
587
|
+
t={t}
|
|
588
|
+
/>
|
|
349
589
|
</div>,
|
|
350
590
|
document.body,
|
|
351
591
|
)}
|
|
592
|
+
<GitCenter
|
|
593
|
+
open={centerOpen}
|
|
594
|
+
onClose={() => setCenterOpen(false)}
|
|
595
|
+
snapshot={display.snapshot}
|
|
596
|
+
run={run}
|
|
597
|
+
query={query}
|
|
598
|
+
t={t}
|
|
599
|
+
openRequest={centerRequest}
|
|
600
|
+
/>
|
|
352
601
|
</span>
|
|
353
602
|
)
|
|
354
603
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changes 差异视图的选择态协调(纯函数,可单元测试)。
|
|
3
|
+
*
|
|
4
|
+
* 快照变化后(轮询 / 管理操作成功),正在对照的文件可能消失,也可能在
|
|
5
|
+
* 已暂存/未暂存两侧之间迁移(混合态拆分出的双条目)。
|
|
6
|
+
* `reconcileDiffSelection` 给出视图应维持的选择态:
|
|
7
|
+
* - null → 文件已不在变更清单,关闭对照;
|
|
8
|
+
* - 基线不变的选择 → 保持当前基线(MM 双条目优先复用原基线);
|
|
9
|
+
* - 基线变更的选择 → 按文件当前所属侧重取。
|
|
10
|
+
* 无论基线是否变化,调用方都应重取差异内容——操作可能刚改变了同一文件。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 正在对照查看的文件(base 由打开行时的暂存态决定)。 */
|
|
14
|
+
export interface DiffSelection {
|
|
15
|
+
readonly path: string
|
|
16
|
+
readonly base: 'worktree' | 'staged'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 变更行结构面(与宿主 GitChange 同构)。 */
|
|
20
|
+
export interface ChangeLike {
|
|
21
|
+
readonly path: string
|
|
22
|
+
readonly status: string
|
|
23
|
+
readonly staged: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 变更行对应的差异基线:未跟踪与未暂存侧 → 工作区;已暂存侧 → 暂存区。 */
|
|
27
|
+
export function diffBaseOf(change: ChangeLike): 'worktree' | 'staged' {
|
|
28
|
+
if (change.status === 'untracked') return 'worktree'
|
|
29
|
+
return change.staged ? 'staged' : 'worktree'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 按新的变更清单协调当前选择态;语义见模块注释。 */
|
|
33
|
+
export function reconcileDiffSelection(
|
|
34
|
+
selection: DiffSelection,
|
|
35
|
+
changes: readonly ChangeLike[],
|
|
36
|
+
): DiffSelection | null {
|
|
37
|
+
const entries = changes.filter((c) => c.path === selection.path)
|
|
38
|
+
if (entries.length === 0) return null
|
|
39
|
+
const sameBase = entries.find((c) => diffBaseOf(c) === selection.base)
|
|
40
|
+
if (sameBase !== undefined) return { path: selection.path, base: selection.base }
|
|
41
|
+
return { path: selection.path, base: diffBaseOf(entries[0]!) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 上一个/下一个更改的循环导航:`entries` 为导航序列(分组顺序),
|
|
46
|
+
* `current` 为空时定位第一条;当前项不在序列中时按首项计;
|
|
47
|
+
* `delta` 取 ±1(模长度循环,首尾相接)。空序列返回 null。
|
|
48
|
+
*/
|
|
49
|
+
export function stepDiffSelection(
|
|
50
|
+
entries: readonly ChangeLike[],
|
|
51
|
+
current: DiffSelection | null,
|
|
52
|
+
delta: number,
|
|
53
|
+
): DiffSelection | null {
|
|
54
|
+
if (entries.length === 0) return null
|
|
55
|
+
if (current === null) {
|
|
56
|
+
const first = entries[0]!
|
|
57
|
+
return { path: first.path, base: diffBaseOf(first) }
|
|
58
|
+
}
|
|
59
|
+
const found = entries.findIndex((c) => c.path === current.path && diffBaseOf(c) === current.base)
|
|
60
|
+
const index = found === -1 ? 0 : found
|
|
61
|
+
const next = entries[(index + delta + entries.length) % entries.length]!
|
|
62
|
+
return { path: next.path, base: diffBaseOf(next) }
|
|
63
|
+
}
|
package/src/client/controller.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* connection reset, `dispose()` on slot teardown (clears the timer and
|
|
6
6
|
* rejects nothing — in-flight work settles into a withdrawn view).
|
|
7
7
|
*/
|
|
8
|
-
import type { GitSnapshot, GitSnapshotFailure, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
|
|
8
|
+
import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshot, GitSnapshotFailure, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
|
|
9
9
|
|
|
10
10
|
/** The observable view contract components consume (useSyncExternalStore shape). */
|
|
11
11
|
export interface GitObservable<V> {
|
|
@@ -35,8 +35,15 @@ export type GitRemoteEnvelope<T> =
|
|
|
35
35
|
/** Structural face of the mounted gitInfo Remote namespace. */
|
|
36
36
|
export interface GitRemoteLike {
|
|
37
37
|
snapshot(request: GitSnapshotRequest): Promise<GitRemoteEnvelope<GitSnapshotResult>>
|
|
38
|
+
run(request: GitActionRequest): Promise<GitRemoteEnvelope<GitActionResult>>
|
|
39
|
+
query(request: GitQueryRequest): Promise<GitRemoteEnvelope<GitQueryResponse>>
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** Simplified query outcome for the UI (envelope + business errors unwrapped). */
|
|
43
|
+
export type GitQueryOutcome =
|
|
44
|
+
| { readonly ok: true; readonly value: Extract<GitQueryResponse, { ok: true }>['value'] }
|
|
45
|
+
| { readonly ok: false; readonly message: string }
|
|
46
|
+
|
|
40
47
|
/** Failure codes that mean "no working directory to watch" — degrade to a
|
|
41
48
|
* low-frequency probe instead of a normal poll. */
|
|
42
49
|
const TERMINAL_CODES: ReadonlySet<string> = new Set(['cwd-unavailable', 'session-not-found'])
|
|
@@ -126,6 +133,81 @@ export class GitController implements GitObservable<GitView> {
|
|
|
126
133
|
void this.refresh()
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Run one management action. On success the host returns a fresh snapshot
|
|
138
|
+
* which becomes the view immediately (no waiting for the next poll); the
|
|
139
|
+
* returned result lets the caller show operation feedback. Shares the
|
|
140
|
+
* single-flight slot with refresh, so an action never overlaps a poll.
|
|
141
|
+
*/
|
|
142
|
+
run(action: GitActionRequest['action']): Promise<GitActionResult> {
|
|
143
|
+
if (this.inflight !== undefined) return this.inflight.then(() => this.run(action))
|
|
144
|
+
if (this.disposed) return Promise.resolve({ ok: false, error: { code: 'git-error', message: 'controller disposed' } })
|
|
145
|
+
// Deliberately no loading view here: an operation must not blank the
|
|
146
|
+
// pill/panel while it runs — the UI shows its own busy state, and a
|
|
147
|
+
// failure keeps the current view for context.
|
|
148
|
+
const promise = this.remote.run({ sessionId: this.sessionId, action })
|
|
149
|
+
.then((result) => {
|
|
150
|
+
if (this.disposed) return { ok: false, error: { code: 'git-error', message: 'controller disposed' } } as GitActionResult
|
|
151
|
+
if (!result.ok) {
|
|
152
|
+
const detail = [result.error.code, result.error.message].filter(Boolean).join(': ')
|
|
153
|
+
this.setView({ state: 'error', error: { code: 'git-unavailable', detail: detail || 'rpc failure' } })
|
|
154
|
+
return { ok: false, error: { code: 'git-error', message: detail || 'rpc failure' } } as GitActionResult
|
|
155
|
+
}
|
|
156
|
+
const inner = result.value
|
|
157
|
+
if (inner.ok) {
|
|
158
|
+
this.pollMs = inner.snapshot.refreshIntervalMs
|
|
159
|
+
this.setView({ state: 'ready', snapshot: inner.snapshot })
|
|
160
|
+
} else if (TERMINAL_CODES.has(inner.error.code)) {
|
|
161
|
+
this.setView({ state: 'no-cwd' })
|
|
162
|
+
}
|
|
163
|
+
// Other failures keep the current view (context for the panel); the
|
|
164
|
+
// error rides back to the caller for display.
|
|
165
|
+
return inner
|
|
166
|
+
})
|
|
167
|
+
.catch((error: unknown) => {
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
169
|
+
if (!this.disposed) {
|
|
170
|
+
this.setView({ state: 'error', error: { code: 'git-unavailable', detail: 'transport failure' } })
|
|
171
|
+
}
|
|
172
|
+
return { ok: false, error: { code: 'git-error', message } } as GitActionResult
|
|
173
|
+
})
|
|
174
|
+
.finally(() => {
|
|
175
|
+
this.inflight = undefined
|
|
176
|
+
if (!this.disposed) this.schedulePoll()
|
|
177
|
+
})
|
|
178
|
+
this.inflight = promise.then(() => undefined)
|
|
179
|
+
return promise
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run one read-only query (history / diff / show / branches). The view is
|
|
184
|
+
* untouched — the result goes straight back to the caller. Queues behind
|
|
185
|
+
* any in-flight refresh/run like everything else (single-flight).
|
|
186
|
+
*/
|
|
187
|
+
query(query: GitQueryRequest['query']): Promise<GitQueryOutcome> {
|
|
188
|
+
if (this.inflight !== undefined) return this.inflight.then(() => this.query(query))
|
|
189
|
+
if (this.disposed) return Promise.resolve({ ok: false, message: 'controller disposed' })
|
|
190
|
+
const promise = this.remote.query({ sessionId: this.sessionId, query })
|
|
191
|
+
.then((result): GitQueryOutcome => {
|
|
192
|
+
if (!result.ok) {
|
|
193
|
+
const detail = [result.error.code, result.error.message].filter(Boolean).join(': ')
|
|
194
|
+
return { ok: false, message: detail || 'rpc failure' }
|
|
195
|
+
}
|
|
196
|
+
const inner = result.value
|
|
197
|
+
if (inner.ok) return { ok: true, value: inner.value }
|
|
198
|
+
return { ok: false, message: inner.error.message ?? inner.error.code }
|
|
199
|
+
})
|
|
200
|
+
.catch((error: unknown): GitQueryOutcome => {
|
|
201
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
|
202
|
+
})
|
|
203
|
+
.finally(() => {
|
|
204
|
+
this.inflight = undefined
|
|
205
|
+
if (!this.disposed) this.schedulePoll()
|
|
206
|
+
})
|
|
207
|
+
this.inflight = promise.then(() => undefined)
|
|
208
|
+
return promise
|
|
209
|
+
}
|
|
210
|
+
|
|
129
211
|
/** Tear down: stop the timer; in-flight work settles into a no-op. */
|
|
130
212
|
dispose(): void {
|
|
131
213
|
this.disposed = true
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GitKey } from './locales.ts'
|
|
2
|
+
import type { GitOperationErrorCode } from '../host/types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 操作错误 → 展示文案。可预期的业务错误(如切换分支被未提交变更阻止)
|
|
6
|
+
* 映射本地化友好文案;其余错误回退原始 git message(无 message 时用 code)。
|
|
7
|
+
* 原始信息由调用方经 errorTextDetail 保留,供 title 等按需展示。
|
|
8
|
+
*/
|
|
9
|
+
export function errorText(code: GitOperationErrorCode, message: string | undefined, t: (key: GitKey) => string): string {
|
|
10
|
+
switch (code) {
|
|
11
|
+
case 'local-changes-block':
|
|
12
|
+
return t('error.localChangesBlock')
|
|
13
|
+
default:
|
|
14
|
+
return message ?? code
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 该错误是否应附带「处理变更」行动(打开 Git 中心变更页)。 */
|
|
19
|
+
export function errorAction(code: GitOperationErrorCode): 'open-center' | null {
|
|
20
|
+
return code === 'local-changes-block' ? 'open-center' : null
|
|
21
|
+
}
|