dsh-git-ui 0.0.1 → 0.0.2
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 +22 -4
- package/README.zh.md +22 -4
- package/lib/client.js +34 -34
- package/lib/client.js.map +4 -4
- package/lib/host/actions.d.ts +14 -0
- package/lib/host/core.d.ts +24 -0
- package/lib/host/index.d.ts +13 -8
- package/lib/host/index.js +108 -12
- package/lib/host/index.js.map +3 -3
- package/lib/host/types.d.ts +44 -0
- package/package.json +1 -1
- package/src/client/GitCenter.tsx +261 -0
- package/src/client/GitPill.tsx +29 -5
- package/src/client/controller.ts +48 -1
- package/src/client/index.ts +2 -1
- package/src/client/locales.ts +38 -0
- package/src/client/remote.ts +58 -0
- package/src/client/styles.ts +153 -0
- package/src/host/actions.ts +127 -0
- package/src/host/core.ts +31 -12
- package/src/host/index.ts +34 -17
- package/src/host/types.ts +39 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git center — the IDE-style management panel for one session's repository.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 ships the Changes view: grouped file lists (staged / unstaged /
|
|
5
|
+
* untracked) with per-file and bulk stage / unstage / discard actions, and a
|
|
6
|
+
* commit box (message + optional selected paths). The panel is a platform
|
|
7
|
+
* `Modal` (headless, width overridden) so it never participates in header
|
|
8
|
+
* layout; operation results ride back through the controller and the view
|
|
9
|
+
* updates from the returned snapshot.
|
|
10
|
+
*
|
|
11
|
+
* Discard is destructive (tracked changes only in Phase 1) and therefore
|
|
12
|
+
* two-step: the button arms itself and requires a second click within 3s.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
15
|
+
import type { JSX } from 'react'
|
|
16
|
+
import { Button, Modal, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
17
|
+
import type { GitAction, GitActionResult, GitChange, GitSnapshot } from '../host/types.ts'
|
|
18
|
+
import type { GitKey } from './locales.ts'
|
|
19
|
+
import * as css from './styles.ts'
|
|
20
|
+
|
|
21
|
+
/** One change-row action callback; all are disabled while busy. */
|
|
22
|
+
interface RowActions {
|
|
23
|
+
readonly onToggle: (path: string) => void
|
|
24
|
+
readonly onStage: (path: string) => void
|
|
25
|
+
readonly onUnstage: (path: string) => void
|
|
26
|
+
readonly onDiscard: (path: string) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GitCenterProps {
|
|
30
|
+
readonly open: boolean
|
|
31
|
+
readonly onClose: () => void
|
|
32
|
+
readonly snapshot: GitSnapshot
|
|
33
|
+
readonly run: (action: GitAction) => Promise<GitActionResult>
|
|
34
|
+
readonly t: (key: GitKey) => string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Feedback = { readonly kind: 'error'; readonly text: string } | null
|
|
38
|
+
|
|
39
|
+
/** One transient success toast (keyed by seq so repeats restart the cycle). */
|
|
40
|
+
interface ToastState {
|
|
41
|
+
readonly text: string
|
|
42
|
+
readonly seq: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Change row with checkbox (commit selection) and per-file actions. */
|
|
46
|
+
function ChangeRow({
|
|
47
|
+
change, checked, busy, actions, t,
|
|
48
|
+
}: {
|
|
49
|
+
change: GitChange
|
|
50
|
+
checked: boolean
|
|
51
|
+
busy: boolean
|
|
52
|
+
actions: RowActions
|
|
53
|
+
t: (key: GitKey) => string
|
|
54
|
+
}): JSX.Element {
|
|
55
|
+
const untracked = change.status === 'untracked'
|
|
56
|
+
return (
|
|
57
|
+
<div className="dsh-git-ui__row" style={css.centerRow}>
|
|
58
|
+
<input
|
|
59
|
+
type="checkbox"
|
|
60
|
+
style={css.changeCheckbox}
|
|
61
|
+
checked={checked}
|
|
62
|
+
disabled={busy}
|
|
63
|
+
onChange={() => { actions.onToggle(change.path) }}
|
|
64
|
+
aria-label={change.path}
|
|
65
|
+
/>
|
|
66
|
+
<span style={css.changeChip} title={change.status}>
|
|
67
|
+
{CHIP_LETTERS[change.status] ?? '•'}
|
|
68
|
+
</span>
|
|
69
|
+
<span style={css.changePathText} title={change.path}>{change.path}</span>
|
|
70
|
+
{change.staged
|
|
71
|
+
? <Button size="sm" disabled={busy} onClick={() => actions.onUnstage(change.path)}>{t('center.unstage')}</Button>
|
|
72
|
+
: <Button size="sm" disabled={busy} onClick={() => actions.onStage(change.path)}>{t('center.stage')}</Button>}
|
|
73
|
+
{!untracked && (
|
|
74
|
+
<Button size="sm" disabled={busy} onClick={() => actions.onDiscard(change.path)}>{t('center.discard')}</Button>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const CHIP_LETTERS: Record<string, string> = {
|
|
81
|
+
added: 'A', modified: 'M', deleted: 'D', renamed: 'R',
|
|
82
|
+
untracked: '?', conflicted: '!', typechange: 'T',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The management panel. Rendered by GitPill inside the platform Modal; the
|
|
87
|
+
* snapshot prop comes from the live controller view, so every successful
|
|
88
|
+
* operation re-renders this component with fresh state.
|
|
89
|
+
*/
|
|
90
|
+
export function GitCenter({
|
|
91
|
+
open, onClose, snapshot, run, t,
|
|
92
|
+
}: GitCenterProps): JSX.Element | null {
|
|
93
|
+
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
|
|
94
|
+
const [message, setMessage] = useState('')
|
|
95
|
+
const [busy, setBusy] = useState(false)
|
|
96
|
+
const [feedback, setFeedback] = useState<Feedback>(null)
|
|
97
|
+
const [toast, setToast] = useState<ToastState | null>(null)
|
|
98
|
+
const [armed, setArmed] = useState<string | 'all' | null>(null)
|
|
99
|
+
|
|
100
|
+
// Auto-disarm the destructive confirm after 3s.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (armed === null) return
|
|
103
|
+
const timer = setTimeout(() => setArmed(null), 3000)
|
|
104
|
+
return () => clearTimeout(timer)
|
|
105
|
+
}, [armed])
|
|
106
|
+
|
|
107
|
+
const staged = useMemo(() => snapshot.changes.filter((c) => c.staged), [snapshot])
|
|
108
|
+
const unstaged = useMemo(() => snapshot.changes.filter((c) => !c.staged && c.status !== 'untracked'), [snapshot])
|
|
109
|
+
const untracked = useMemo(() => snapshot.changes.filter((c) => c.status === 'untracked'), [snapshot])
|
|
110
|
+
const hasTrackedChanges = staged.length + unstaged.length > 0
|
|
111
|
+
|
|
112
|
+
const toggle = (path: string): void => {
|
|
113
|
+
setSelected((prev) => {
|
|
114
|
+
const next = new Set(prev)
|
|
115
|
+
if (next.has(path)) next.delete(path)
|
|
116
|
+
else next.add(path)
|
|
117
|
+
return next
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const doRun = async (action: GitAction, successText: string): Promise<void> => {
|
|
122
|
+
if (busy) return
|
|
123
|
+
setBusy(true)
|
|
124
|
+
setFeedback(null)
|
|
125
|
+
const result = await run(action)
|
|
126
|
+
setBusy(false)
|
|
127
|
+
setArmed(null)
|
|
128
|
+
if (result.ok) {
|
|
129
|
+
setSelected(new Set())
|
|
130
|
+
setMessage('')
|
|
131
|
+
// Transient system toast (holds ~3s, fades, unmounts itself).
|
|
132
|
+
setToast({ text: successText, seq: Date.now() })
|
|
133
|
+
} else {
|
|
134
|
+
// Persistent panel-level error banner with a dismiss button.
|
|
135
|
+
setFeedback({ kind: 'error', text: result.error.message ?? result.error.code })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const armDiscard = (target: string | 'all'): void => {
|
|
140
|
+
setArmed((prev) => (prev === target ? null : target))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const discardTarget = (path: string): void => {
|
|
144
|
+
if (armed === path) void doRun({ kind: 'discard', paths: [path] }, t('center.done'))
|
|
145
|
+
else armDiscard(path)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const discardAll = (): void => {
|
|
149
|
+
if (armed === 'all') void doRun({ kind: 'discard-all' }, t('center.done'))
|
|
150
|
+
else armDiscard('all')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const commit = (): void => {
|
|
154
|
+
const text = message.trim()
|
|
155
|
+
if (text === '' || busy) return
|
|
156
|
+
const paths = selected.size > 0 ? [...selected] : undefined
|
|
157
|
+
void doRun({ kind: 'commit', message: text, ...(paths === undefined ? {} : { paths }) }, t('center.done'))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const actions: RowActions = {
|
|
161
|
+
onToggle: toggle,
|
|
162
|
+
onStage: (path) => void doRun({ kind: 'stage', paths: [path] }, t('center.done')),
|
|
163
|
+
onUnstage: (path) => void doRun({ kind: 'unstage', paths: [path] }, t('center.done')),
|
|
164
|
+
onDiscard: discardTarget,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Modal open={open} onClose={onClose} title={t('center.title')} closeLabel={t('popup.refresh')} headless className="dsh-git-ui__center">
|
|
169
|
+
<div style={css.centerShell}>
|
|
170
|
+
<div style={css.centerHeader}>
|
|
171
|
+
<h2 style={css.centerTitle} title={snapshot.root}>{snapshot.branch ?? '(detached)'} — {t('center.title')}</h2>
|
|
172
|
+
<Button size="sm" onClick={onClose} aria-label={t('popup.refresh')}>✕</Button>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div style={css.centerBody}>
|
|
176
|
+
{feedback !== null && (
|
|
177
|
+
<div style={css.feedbackError} role="alert">
|
|
178
|
+
<span style={{ flex: 1 }}>{feedback.text}</span>
|
|
179
|
+
<button type="button" style={css.feedbackClose} onClick={() => setFeedback(null)} aria-label={t('popup.refresh')}>✕</button>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
<div style={css.toolRow}>
|
|
184
|
+
<Button size="sm" disabled={busy || (unstaged.length === 0 && untracked.length === 0)} onClick={() => void doRun({ kind: 'stage-all' }, t('center.done'))}>
|
|
185
|
+
{t('center.stageAll')}
|
|
186
|
+
</Button>
|
|
187
|
+
<Button size="sm" disabled={busy || staged.length === 0} onClick={() => void doRun({ kind: 'unstage-all' }, t('center.done'))}>
|
|
188
|
+
{t('center.unstageAll')}
|
|
189
|
+
</Button>
|
|
190
|
+
<Button size="sm" disabled={busy || !hasTrackedChanges} onClick={discardAll}>
|
|
191
|
+
{armed === 'all' ? t('center.confirmDiscard') : t('center.discardAll')}
|
|
192
|
+
</Button>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{snapshot.changes.length === 0
|
|
196
|
+
? <div style={css.emptyNote}>{t('center.empty')}</div>
|
|
197
|
+
: (
|
|
198
|
+
<>
|
|
199
|
+
{staged.length > 0 && <GroupedList title={t('center.staged')} changes={staged} checked={selected} busy={busy} actions={actions} t={t} />}
|
|
200
|
+
{unstaged.length > 0 && <GroupedList title={t('center.unstaged')} changes={unstaged} checked={selected} busy={busy} actions={actions} t={t} />}
|
|
201
|
+
{untracked.length > 0 && <GroupedList title={t('center.untracked')} changes={untracked} checked={selected} busy={busy} actions={actions} t={t} />}
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div style={css.commitBox}>
|
|
207
|
+
<textarea
|
|
208
|
+
className="dsh-git-ui__commit-input"
|
|
209
|
+
style={css.commitInput}
|
|
210
|
+
placeholder={t('center.commitMessage')}
|
|
211
|
+
value={message}
|
|
212
|
+
disabled={busy}
|
|
213
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
214
|
+
onKeyDown={(e) => {
|
|
215
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) commit()
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
<div style={css.commitFooter}>
|
|
219
|
+
<span style={css.commitHint}>
|
|
220
|
+
{selected.size > 0 ? t('center.commitSelected').replace('{count}', String(selected.size)) : t('center.commitHint')}
|
|
221
|
+
</span>
|
|
222
|
+
<Button variant="primary" size="sm" disabled={busy || message.trim() === ''} onClick={commit}>
|
|
223
|
+
{busy ? t('center.busy') : t('center.commit')}
|
|
224
|
+
</Button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
{toast !== null && (
|
|
229
|
+
<Toast key={toast.seq} text={toast.text} onDone={() => setToast(null)} />
|
|
230
|
+
)}
|
|
231
|
+
</Modal>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** One group of change rows (staged / unstaged / untracked). */
|
|
236
|
+
function GroupedList({
|
|
237
|
+
title, changes, checked, busy, actions, t,
|
|
238
|
+
}: {
|
|
239
|
+
title: string
|
|
240
|
+
changes: readonly GitChange[]
|
|
241
|
+
checked: ReadonlySet<string>
|
|
242
|
+
busy: boolean
|
|
243
|
+
actions: RowActions
|
|
244
|
+
t: (key: GitKey) => string
|
|
245
|
+
}): JSX.Element {
|
|
246
|
+
return (
|
|
247
|
+
<>
|
|
248
|
+
<div style={css.groupTitle}>{title}</div>
|
|
249
|
+
{changes.map((change) => (
|
|
250
|
+
<ChangeRow
|
|
251
|
+
key={change.path}
|
|
252
|
+
change={change}
|
|
253
|
+
checked={checked.has(change.path)}
|
|
254
|
+
busy={busy}
|
|
255
|
+
actions={actions}
|
|
256
|
+
t={t}
|
|
257
|
+
/>
|
|
258
|
+
))}
|
|
259
|
+
</>
|
|
260
|
+
)
|
|
261
|
+
}
|
package/src/client/GitPill.tsx
CHANGED
|
@@ -16,6 +16,8 @@ import { createPortal } from 'react-dom'
|
|
|
16
16
|
import type { JSX } from 'react'
|
|
17
17
|
import { completedTurnCount, type TurnSignalSnapshot } from './turn-signal.ts'
|
|
18
18
|
import type { GitObservable, GitView } from './controller.ts'
|
|
19
|
+
import { GitCenter } from './GitCenter.tsx'
|
|
20
|
+
import type { GitAction, GitActionResult } from '../host/types.ts'
|
|
19
21
|
import type { GitKey } from './locales.ts'
|
|
20
22
|
import * as css from './styles.ts'
|
|
21
23
|
|
|
@@ -31,6 +33,8 @@ export interface GitInjected {
|
|
|
31
33
|
}
|
|
32
34
|
/** Force an immediate re-check (same path as polling). */
|
|
33
35
|
refresh: () => Promise<void>
|
|
36
|
+
/** Execute one management action (host returns a fresh snapshot). */
|
|
37
|
+
run: (action: GitAction) => Promise<GitActionResult>
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/** Selector hook shape the slot runtime binds from `hooks.git`. */
|
|
@@ -122,10 +126,11 @@ function DegradedPill({ label, title, t }: { label: string; title?: string; t: (
|
|
|
122
126
|
|
|
123
127
|
/** Popup body (rendered inside the portaled card): root, counts, commits, changes, refresh. */
|
|
124
128
|
function GitPopupBody({
|
|
125
|
-
view, refresh, t,
|
|
129
|
+
view, refresh, openCenter, t,
|
|
126
130
|
}: {
|
|
127
131
|
view: GitView & { state: 'ready' }
|
|
128
132
|
refresh: () => Promise<void>
|
|
133
|
+
openCenter: () => void
|
|
129
134
|
t: (key: GitKey) => string
|
|
130
135
|
}): JSX.Element {
|
|
131
136
|
const now = Date.now()
|
|
@@ -178,7 +183,12 @@ function GitPopupBody({
|
|
|
178
183
|
)}
|
|
179
184
|
<div style={css.footerRow}>
|
|
180
185
|
<span style={css.checkedAt}>{t('popup.checkedAt').replace('{time}', new Date(s.checkedAt).toLocaleTimeString())}</span>
|
|
181
|
-
<
|
|
186
|
+
<span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
|
187
|
+
<button type="button" className="dsh-git-ui__refresh" style={css.refreshButton} onClick={openCenter}>
|
|
188
|
+
{t('center.open')}
|
|
189
|
+
</button>
|
|
190
|
+
<PopRefresher refresh={refresh} t={t} />
|
|
191
|
+
</span>
|
|
182
192
|
</div>
|
|
183
193
|
</>
|
|
184
194
|
)
|
|
@@ -207,9 +217,10 @@ const POPUP_GUTTER = 6
|
|
|
207
217
|
const VIEW_GUTTER = 8
|
|
208
218
|
|
|
209
219
|
/**
|
|
210
|
-
* The header utility entry: a branch pill that opens a portaled detail popup
|
|
220
|
+
* The header utility entry: a branch pill that opens a portaled detail popup
|
|
221
|
+
* and the Git center management panel.
|
|
211
222
|
*/
|
|
212
|
-
export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.Element | null {
|
|
223
|
+
export function GitPill({ useGit, useSession, refresh, run, t }: GitPillProps): JSX.Element | null {
|
|
213
224
|
// The selector hook requires a selector function (with-selector calls it
|
|
214
225
|
// unconditionally); identity selection reads the whole view snapshot.
|
|
215
226
|
const view = useGit((view) => view)
|
|
@@ -238,6 +249,7 @@ export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.E
|
|
|
238
249
|
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
239
250
|
const popRef = useRef<HTMLDivElement>(null)
|
|
240
251
|
const [open, setOpen] = useState(false)
|
|
252
|
+
const [centerOpen, setCenterOpen] = useState(false)
|
|
241
253
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
242
254
|
|
|
243
255
|
useEffect(() => {
|
|
@@ -345,10 +357,22 @@ export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.E
|
|
|
345
357
|
role="dialog"
|
|
346
358
|
aria-label={t('popup.title')}
|
|
347
359
|
>
|
|
348
|
-
<GitPopupBody
|
|
360
|
+
<GitPopupBody
|
|
361
|
+
view={display}
|
|
362
|
+
refresh={refresh}
|
|
363
|
+
openCenter={() => { setOpen(false); setPos(null); setCenterOpen(true) }}
|
|
364
|
+
t={t}
|
|
365
|
+
/>
|
|
349
366
|
</div>,
|
|
350
367
|
document.body,
|
|
351
368
|
)}
|
|
369
|
+
<GitCenter
|
|
370
|
+
open={centerOpen}
|
|
371
|
+
onClose={() => setCenterOpen(false)}
|
|
372
|
+
snapshot={display.snapshot}
|
|
373
|
+
run={run}
|
|
374
|
+
t={t}
|
|
375
|
+
/>
|
|
352
376
|
</span>
|
|
353
377
|
)
|
|
354
378
|
}
|
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, 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,6 +35,7 @@ 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>>
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** Failure codes that mean "no working directory to watch" — degrade to a
|
|
@@ -126,6 +127,52 @@ export class GitController implements GitObservable<GitView> {
|
|
|
126
127
|
void this.refresh()
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Run one management action. On success the host returns a fresh snapshot
|
|
132
|
+
* which becomes the view immediately (no waiting for the next poll); the
|
|
133
|
+
* returned result lets the caller show operation feedback. Shares the
|
|
134
|
+
* single-flight slot with refresh, so an action never overlaps a poll.
|
|
135
|
+
*/
|
|
136
|
+
run(action: GitActionRequest['action']): Promise<GitActionResult> {
|
|
137
|
+
if (this.inflight !== undefined) return this.inflight.then(() => this.run(action))
|
|
138
|
+
if (this.disposed) return Promise.resolve({ ok: false, error: { code: 'git-error', message: 'controller disposed' } })
|
|
139
|
+
// Deliberately no loading view here: an operation must not blank the
|
|
140
|
+
// pill/panel while it runs — the UI shows its own busy state, and a
|
|
141
|
+
// failure keeps the current view for context.
|
|
142
|
+
const promise = this.remote.run({ sessionId: this.sessionId, action })
|
|
143
|
+
.then((result) => {
|
|
144
|
+
if (this.disposed) return { ok: false, error: { code: 'git-error', message: 'controller disposed' } } as GitActionResult
|
|
145
|
+
if (!result.ok) {
|
|
146
|
+
const detail = [result.error.code, result.error.message].filter(Boolean).join(': ')
|
|
147
|
+
this.setView({ state: 'error', error: { code: 'git-unavailable', detail: detail || 'rpc failure' } })
|
|
148
|
+
return { ok: false, error: { code: 'git-error', message: detail || 'rpc failure' } } as GitActionResult
|
|
149
|
+
}
|
|
150
|
+
const inner = result.value
|
|
151
|
+
if (inner.ok) {
|
|
152
|
+
this.pollMs = inner.snapshot.refreshIntervalMs
|
|
153
|
+
this.setView({ state: 'ready', snapshot: inner.snapshot })
|
|
154
|
+
} else if (TERMINAL_CODES.has(inner.error.code)) {
|
|
155
|
+
this.setView({ state: 'no-cwd' })
|
|
156
|
+
}
|
|
157
|
+
// Other failures keep the current view (context for the panel); the
|
|
158
|
+
// error rides back to the caller for display.
|
|
159
|
+
return inner
|
|
160
|
+
})
|
|
161
|
+
.catch((error: unknown) => {
|
|
162
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
163
|
+
if (!this.disposed) {
|
|
164
|
+
this.setView({ state: 'error', error: { code: 'git-unavailable', detail: 'transport failure' } })
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, error: { code: 'git-error', message } } as GitActionResult
|
|
167
|
+
})
|
|
168
|
+
.finally(() => {
|
|
169
|
+
this.inflight = undefined
|
|
170
|
+
if (!this.disposed) this.schedulePoll()
|
|
171
|
+
})
|
|
172
|
+
this.inflight = promise.then(() => undefined)
|
|
173
|
+
return promise
|
|
174
|
+
}
|
|
175
|
+
|
|
129
176
|
/** Tear down: stop the timer; in-flight work settles into a no-op. */
|
|
130
177
|
dispose(): void {
|
|
131
178
|
this.disposed = true
|
package/src/client/index.ts
CHANGED
|
@@ -116,7 +116,7 @@ export async function apply(ctx: ClientContext): Promise<void> {
|
|
|
116
116
|
// `refresh` reference staying stable (a fresh arrow function per
|
|
117
117
|
// call would re-run mount effects and loop: refresh → view
|
|
118
118
|
// change → re-render → new refresh → refresh …). Cache the face
|
|
119
|
-
// so the same controller (and its bound refresh) is always
|
|
119
|
+
// so the same controller (and its bound refresh/run) is always
|
|
120
120
|
// handed out per session.
|
|
121
121
|
let face = faces.get(sessionId)
|
|
122
122
|
if (face === undefined) {
|
|
@@ -124,6 +124,7 @@ export async function apply(ctx: ClientContext): Promise<void> {
|
|
|
124
124
|
face = {
|
|
125
125
|
hooks: { git: controller as GitInjected['hooks']['git'] },
|
|
126
126
|
refresh: () => controller.refresh(),
|
|
127
|
+
run: (action) => controller.run(action),
|
|
127
128
|
}
|
|
128
129
|
faces.set(sessionId, face)
|
|
129
130
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -20,6 +20,25 @@ export const zh = {
|
|
|
20
20
|
'popup.checkedAt': '检查于 {time}',
|
|
21
21
|
'popup.empty': '工作区干净',
|
|
22
22
|
'popup.emptyCommits': '暂无提交',
|
|
23
|
+
'center.open': '打开 Git 中心',
|
|
24
|
+
'center.title': 'Git 中心',
|
|
25
|
+
'center.staged': '已暂存',
|
|
26
|
+
'center.unstaged': '未暂存',
|
|
27
|
+
'center.untracked': '未跟踪',
|
|
28
|
+
'center.stage': '暂存',
|
|
29
|
+
'center.unstage': '取消暂存',
|
|
30
|
+
'center.discard': '丢弃',
|
|
31
|
+
'center.discardAll': '全部丢弃',
|
|
32
|
+
'center.confirmDiscard': '确认丢弃?',
|
|
33
|
+
'center.stageAll': '全部暂存',
|
|
34
|
+
'center.unstageAll': '取消全部暂存',
|
|
35
|
+
'center.commitMessage': '提交信息',
|
|
36
|
+
'center.commit': '提交',
|
|
37
|
+
'center.commitHint': '勾选文件时仅提交所选;未勾选时提交全部已暂存内容',
|
|
38
|
+
'center.commitSelected': '已选 {count} 个文件',
|
|
39
|
+
'center.empty': '工作区干净,无需操作',
|
|
40
|
+
'center.done': '操作成功',
|
|
41
|
+
'center.busy': '执行中…',
|
|
23
42
|
'time.justNow': '刚刚',
|
|
24
43
|
'time.minutesAgo': '{n} 分钟前',
|
|
25
44
|
'time.hoursAgo': '{n} 小时前',
|
|
@@ -48,6 +67,25 @@ export const en: Record<GitKey, string> = {
|
|
|
48
67
|
'popup.checkedAt': 'Checked at {time}',
|
|
49
68
|
'popup.empty': 'Working tree clean',
|
|
50
69
|
'popup.emptyCommits': 'No commits yet',
|
|
70
|
+
'center.open': 'Open Git center',
|
|
71
|
+
'center.title': 'Git center',
|
|
72
|
+
'center.staged': 'staged',
|
|
73
|
+
'center.unstaged': 'unstaged',
|
|
74
|
+
'center.untracked': 'untracked',
|
|
75
|
+
'center.stage': 'Stage',
|
|
76
|
+
'center.unstage': 'Unstage',
|
|
77
|
+
'center.discard': 'Discard',
|
|
78
|
+
'center.discardAll': 'Discard all',
|
|
79
|
+
'center.confirmDiscard': 'Confirm?',
|
|
80
|
+
'center.stageAll': 'Stage all',
|
|
81
|
+
'center.unstageAll': 'Unstage all',
|
|
82
|
+
'center.commitMessage': 'Commit message',
|
|
83
|
+
'center.commit': 'Commit',
|
|
84
|
+
'center.commitHint': 'Selected files are committed; otherwise everything staged is committed',
|
|
85
|
+
'center.commitSelected': '{count} file(s) selected',
|
|
86
|
+
'center.empty': 'Working tree clean — nothing to do',
|
|
87
|
+
'center.done': 'Done',
|
|
88
|
+
'center.busy': 'Working…',
|
|
51
89
|
'time.justNow': 'just now',
|
|
52
90
|
'time.minutesAgo': '{n}m ago',
|
|
53
91
|
'time.hoursAgo': '{n}h ago',
|
package/src/client/remote.ts
CHANGED
|
@@ -61,6 +61,39 @@ export const gitSnapshotRequestSchema = z.object({
|
|
|
61
61
|
sessionId: z.string(),
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
/** One management action (mirrors `GitAction` in src/host/types.ts). */
|
|
65
|
+
export const gitActionSchema = z.discriminatedUnion('kind', [
|
|
66
|
+
z.object({ kind: z.literal('stage'), paths: z.array(z.string()) }),
|
|
67
|
+
z.object({ kind: z.literal('stage-all') }),
|
|
68
|
+
z.object({ kind: z.literal('unstage'), paths: z.array(z.string()) }),
|
|
69
|
+
z.object({ kind: z.literal('unstage-all') }),
|
|
70
|
+
z.object({ kind: z.literal('discard'), paths: z.array(z.string()) }),
|
|
71
|
+
z.object({ kind: z.literal('discard-all') }),
|
|
72
|
+
z.object({
|
|
73
|
+
kind: z.literal('commit'),
|
|
74
|
+
message: z.string(),
|
|
75
|
+
paths: z.array(z.string()).optional(),
|
|
76
|
+
}),
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
export const gitOperationErrorSchema = z.object({
|
|
80
|
+
code: z.enum([
|
|
81
|
+
'session-not-found', 'cwd-unavailable', 'path-not-found',
|
|
82
|
+
'not-a-git-repo', 'invalid-path', 'git-error', 'timeout',
|
|
83
|
+
]),
|
|
84
|
+
message: z.string().optional(),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
export const gitActionResultSchema = z.discriminatedUnion('ok', [
|
|
88
|
+
z.object({ ok: z.literal(true), snapshot: gitSnapshotSchema, output: z.string().optional() }),
|
|
89
|
+
z.object({ ok: z.literal(false), error: gitOperationErrorSchema }),
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
export const gitActionRequestSchema = z.object({
|
|
93
|
+
sessionId: z.string(),
|
|
94
|
+
action: gitActionSchema,
|
|
95
|
+
})
|
|
96
|
+
|
|
64
97
|
/** The contribution mounted into `ctx.remote` by the client plugin body. */
|
|
65
98
|
export const gitInfoRemote: TypertRemoteContribution = {
|
|
66
99
|
package: 'dsh-git-ui',
|
|
@@ -93,5 +126,30 @@ export const gitInfoRemote: TypertRemoteContribution = {
|
|
|
93
126
|
schema: gitSnapshotResultSchema,
|
|
94
127
|
},
|
|
95
128
|
},
|
|
129
|
+
{
|
|
130
|
+
id: 'dsh-git-ui#gitInfo/run',
|
|
131
|
+
service: 'gitInfo',
|
|
132
|
+
namespace: 'gitInfo',
|
|
133
|
+
method: 'run',
|
|
134
|
+
invocation: { kind: 'direct' },
|
|
135
|
+
cancellation: { parameter: 'signal' },
|
|
136
|
+
parameters: [
|
|
137
|
+
{
|
|
138
|
+
name: 'request',
|
|
139
|
+
wire: 'request',
|
|
140
|
+
source: 'json',
|
|
141
|
+
codec: {
|
|
142
|
+
mode: 'strict',
|
|
143
|
+
typeSymbol: 'dsh-git-ui/types#GitActionRequest',
|
|
144
|
+
schema: gitActionRequestSchema,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
result: {
|
|
149
|
+
mode: 'strict',
|
|
150
|
+
typeSymbol: 'dsh-git-ui/types#GitActionResult',
|
|
151
|
+
schema: gitActionResultSchema,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
96
154
|
],
|
|
97
155
|
}
|