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.
@@ -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
+ }
@@ -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
- <PopRefresher refresh={refresh} t={t} />
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 view={display} refresh={refresh} t={t} />
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
  }
@@ -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
@@ -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
  }
@@ -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',
@@ -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
  }