dsh-git-ui 0.0.2 → 0.1.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.
Files changed (47) hide show
  1. package/README.md +55 -20
  2. package/README.zh.md +55 -21
  3. package/cordis.patch.yml +1 -1
  4. package/lib/client.js +36 -35
  5. package/lib/client.js.map +4 -4
  6. package/lib/contracts/host-endpoints.d.ts +27 -0
  7. package/lib/host/actions.d.ts +22 -2
  8. package/lib/host/core.d.ts +7 -1
  9. package/lib/host/index.d.ts +25 -15
  10. package/lib/host/index.js +419 -53
  11. package/lib/host/index.js.map +4 -4
  12. package/lib/host/parser.d.ts +37 -1
  13. package/lib/host/queries.d.ts +17 -0
  14. package/lib/host/types.d.ts +131 -1
  15. package/package.json +1 -1
  16. package/src/adapters/dsh/client-adapter.ts +120 -0
  17. package/src/adapters/dsh/types/cordis.d.ts +48 -0
  18. package/src/adapters/dsh/types/typert-protocol.d.ts +81 -0
  19. package/src/adapters/dsh/types/ui-primitives.d.ts +45 -0
  20. package/src/adapters/dsh/ui-primitives.ts +16 -0
  21. package/src/client/GitCenter.tsx +1414 -149
  22. package/src/client/GitPill.tsx +282 -67
  23. package/src/client/changes-diff.ts +63 -0
  24. package/src/client/controller.ts +34 -31
  25. package/src/client/error-text.ts +21 -0
  26. package/src/client/file-tree.ts +101 -0
  27. package/src/client/git-graph.ts +188 -0
  28. package/src/client/icons.tsx +292 -0
  29. package/src/client/index.ts +38 -134
  30. package/src/client/locales.ts +124 -0
  31. package/src/client/popup-close.ts +19 -0
  32. package/src/client/remote.ts +85 -3
  33. package/src/client/select-menu.tsx +113 -0
  34. package/src/client/side-by-side.ts +150 -0
  35. package/src/client/styles.ts +1375 -86
  36. package/src/client/time-format.ts +32 -0
  37. package/src/contracts/client-platform.ts +147 -0
  38. package/src/contracts/host-endpoints.ts +58 -0
  39. package/src/contracts/plugin-activation.ts +129 -0
  40. package/src/contracts/ui-context.tsx +28 -0
  41. package/src/contracts/ui-primitives.ts +48 -0
  42. package/src/host/actions.ts +66 -15
  43. package/src/host/core.ts +9 -2
  44. package/src/host/index.ts +60 -54
  45. package/src/host/parser.ts +155 -12
  46. package/src/host/queries.ts +289 -0
  47. package/src/host/types.ts +104 -0
@@ -15,27 +15,25 @@ 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 type { GitObservable, GitView } from './controller.ts'
18
+ import { useUI } from '../contracts/ui-context.tsx'
19
+ import type { GitObservable, GitQueryOutcome, GitView } from './controller.ts'
20
+ import type { GitInjected } from '../contracts/client-platform.ts'
19
21
  import { GitCenter } from './GitCenter.tsx'
20
- import type { GitAction, GitActionResult } from '../host/types.ts'
22
+ import { fileIconForPath, FolderIcon, AlertIcon, CloseIcon, RollbackIcon, StageIcon, UnstageIcon } from './icons.tsx'
23
+ import type { GitAction, GitActionResult, GitBranch, GitOperationErrorCode, GitQueryRequest } from '../host/types.ts'
21
24
  import type { GitKey } from './locales.ts'
25
+ import { SelectMenu } from './select-menu.tsx'
26
+ import { splitChangePath } from './file-tree.ts'
27
+ import { shouldClosePopup } from './popup-close.ts'
28
+ import { diffBaseOf } from './changes-diff.ts'
29
+ import { errorText, errorAction } from './error-text.ts'
22
30
  import * as css from './styles.ts'
23
31
 
24
32
  // Inject the plugin's interaction styles once (idempotent, browser-only).
25
33
  css.ensureGlobalCss()
26
34
 
27
- /** Injected business face of the header utility entry. */
28
- export interface GitInjected {
29
- hooks: {
30
- /** The owning Session's git view source. The slot runtime binds this
31
- * observable into the `useGit` selector hook the component consumes. */
32
- git: GitObservable<GitView>
33
- }
34
- /** Force an immediate re-check (same path as polling). */
35
- refresh: () => Promise<void>
36
- /** Execute one management action (host returns a fresh snapshot). */
37
- run: (action: GitAction) => Promise<GitActionResult>
38
- }
35
+ // Re-export for backward compatibility
36
+ export type { GitInjected } from '../contracts/client-platform.ts'
39
37
 
40
38
  /** Selector hook shape the slot runtime binds from `hooks.git`. */
41
39
  export type UseGit = <S = GitView>(
@@ -57,16 +55,7 @@ export interface GitPillProps extends GitInjected {
57
55
  readonly t: (key: GitKey) => string
58
56
  }
59
57
 
60
- /** Status chip colors for the changed-file list. */
61
- const CHIP_COLORS: Record<string, string> = {
62
- added: '#2e7d32',
63
- modified: '#b26a00',
64
- deleted: '#c62828',
65
- renamed: '#1565c0',
66
- untracked: '#6a6a6a',
67
- conflicted: '#d32f2f',
68
- typechange: '#7b1fa2',
69
- }
58
+ /** 状态字符映射(配色见 styles.chipStyles,全语义 token)。 */
70
59
 
71
60
  const CHIP_LETTERS: Record<string, string> = {
72
61
  added: 'A', modified: 'M', deleted: 'D', renamed: 'R',
@@ -102,16 +91,14 @@ function timeAgo(iso: string, now: number, t: (key: GitKey) => string): string {
102
91
  return fill('time.daysAgo', Math.floor(seconds / 86_400))
103
92
  }
104
93
 
105
- /** The pill label for a ready snapshot. */
106
- function pillLabel(view: GitView & { state: 'ready' }, t: (key: GitKey) => string): string {
94
+ /** The pill label parts: 分支名(可 ellipsis 收缩)+ 徽标(不截断保留)。 */
95
+ function pillParts(view: GitView & { state: 'ready' }, t: (key: GitKey) => string): { branch: string; badges: string[] } {
107
96
  const s = view.snapshot
108
97
  const branch = s.branch === null
109
98
  ? `(${t('pill.detached')}) · ${s.head ?? ''}`
110
- : s.branch
111
- const base = s.unborn ? `${branch} · ${t('pill.noCommits')}` : branch
112
- const badge = dirtyBadge(view)
113
- const ahead = aheadBehind(view)
114
- return [base, badge, ahead].filter(Boolean).join(' · ')
99
+ : (s.unborn ? `${s.branch} · ${t('pill.noCommits')}` : s.branch)
100
+ const badges = [dirtyBadge(view), aheadBehind(view)].filter(Boolean)
101
+ return { branch, badges }
115
102
  }
116
103
 
117
104
  /** Dimmed pill for degraded states. */
@@ -124,40 +111,196 @@ function DegradedPill({ label, title, t }: { label: string; title?: string; t: (
124
111
  )
125
112
  }
126
113
 
127
- /** Popup body (rendered inside the portaled card): root, counts, commits, changes, refresh. */
114
+ /**
115
+ * Popup body (rendered inside the portaled card)。
116
+ * 分支管理(切换/新建)已并入本组件:头部内联切换 + 新建行上提;
117
+ * 变更行带 hover 内联操作(暂存/取消/丢弃两步)。
118
+ */
128
119
  function GitPopupBody({
129
- view, refresh, openCenter, t,
120
+ view, refresh, openCenter, onOpenDiff, run, query, t,
130
121
  }: {
131
122
  view: GitView & { state: 'ready' }
132
123
  refresh: () => Promise<void>
133
124
  openCenter: () => void
125
+ /** 变更文件点击:打开 Git 中心并定位该文件的对照视图。 */
126
+ onOpenDiff: (path: string, base: 'worktree' | 'staged') => void
127
+ run: (action: GitAction) => Promise<GitActionResult>
128
+ query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
134
129
  t: (key: GitKey) => string
135
130
  }): JSX.Element {
131
+ const { Button } = useUI()
136
132
  const now = Date.now()
137
133
  const s = view.snapshot
134
+ const branchLabel = s.branch === null ? `(${t('pill.detached')})` : s.branch
135
+
136
+ /** 分支管理状态(自原 BranchQuickManage 并入):切换(头部内联)+ 新建(上提)。 */
137
+ const [branchData, setBranchData] = useState<{ current: string | null; local: readonly GitBranch[] } | null>(null)
138
+ const [busy, setBusy] = useState(false)
139
+ const [newName, setNewName] = useState('')
140
+ const [note, setNote] = useState<{ text: string; detail?: string; action?: 'open-center' } | null>(null)
141
+ // 变更行丢弃两步确认:armed 记录待确认的路径,3s 自动解除。
142
+ const [armed, setArmed] = useState<string | null>(null)
143
+
144
+ /** 操作失败 → 友好告警:业务错误用 i18n 文案 + 行动按钮,原始信息留 detail。 */
145
+ const setErrorNote = (err: { code: GitOperationErrorCode; message?: string }): void => {
146
+ setNote({
147
+ text: errorText(err.code, err.message, t),
148
+ ...(err.message === undefined ? {} : { detail: err.message }),
149
+ ...(errorAction(err.code) === null ? {} : { action: errorAction(err.code) ?? undefined }),
150
+ })
151
+ }
152
+
153
+ const reload = async (): Promise<void> => {
154
+ const outcome = await query({ kind: 'branches' })
155
+ if (outcome.ok && outcome.value.kind === 'branches') {
156
+ setBranchData({ current: outcome.value.current, local: outcome.value.local })
157
+ }
158
+ }
159
+
160
+ useEffect(() => {
161
+ void reload()
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
163
+ }, [])
164
+
165
+ useEffect(() => {
166
+ if (armed === null) return
167
+ const timer = setTimeout(() => setArmed(null), 3000)
168
+ return () => clearTimeout(timer)
169
+ }, [armed])
170
+
171
+ const switchTo = async (name: string): Promise<void> => {
172
+ if (busy || name === '') return
173
+ setBusy(true)
174
+ setNote(null)
175
+ const result = await run({ kind: 'branch-checkout', name })
176
+ setBusy(false)
177
+ if (!result.ok) setErrorNote(result.error)
178
+ await reload()
179
+ }
180
+
181
+ const createAndSwitch = async (): Promise<void> => {
182
+ const name = newName.trim()
183
+ if (name === '' || busy) return
184
+ setBusy(true)
185
+ setNote(null)
186
+ const created = await run({ kind: 'branch-create', name })
187
+ if (created.ok) {
188
+ const switched = await run({ kind: 'branch-checkout', name })
189
+ if (!switched.ok) setErrorNote(switched.error)
190
+ setNewName('')
191
+ } else {
192
+ setErrorNote(created.error)
193
+ }
194
+ setBusy(false)
195
+ await reload()
196
+ }
197
+
198
+ /** 执行一条变更行操作(暂存/取消/丢弃),失败以 note 显示。 */
199
+ const runChange = async (action: GitAction, path: string): Promise<void> => {
200
+ if (busy) return
201
+ setBusy(true)
202
+ setNote(null)
203
+ const result = await run(action)
204
+ setBusy(false)
205
+ if (!result.ok) setErrorNote(result.error)
206
+ }
207
+
208
+ const stage = (path: string): void => void runChange({ kind: 'stage', paths: [path] }, path)
209
+ const unstage = (path: string): void => void runChange({ kind: 'unstage', paths: [path] }, path)
210
+ const discard = (path: string): void => {
211
+ if (busy) return
212
+ if (armed !== path) { setArmed(path); return }
213
+ setArmed(null)
214
+ void runChange({ kind: 'discard', paths: [path] }, path)
215
+ }
216
+
138
217
  return (
139
218
  <>
140
- <h4 style={css.popupTitle}>{t('popup.title')}</h4>
141
- <div style={css.rootLine} title={s.root}>{s.root}</div>
142
- <div style={css.countGrid}>
219
+ <header style={css.popupHeader}>
220
+ <div style={css.popupHeaderMain}>
221
+ <span style={s.dirty ? css.dotDirty : css.dot} aria-hidden="true" />
222
+ {branchData === null ? (
223
+ <span style={css.popupHeaderBranch}>{branchLabel}</span>
224
+ ) : (
225
+ <SelectMenu
226
+ value={branchData.current ?? ''}
227
+ options={[
228
+ // 游离 HEAD:注入伪选项让头部显示游离标签,仍可下拉切换本地分支。
229
+ ...(branchData.current === null ? [{ value: '', label: branchLabel }] : []),
230
+ ...branchData.local.map((b) => ({ value: b.name, label: b.name })),
231
+ ]}
232
+ onSelect={(name) => void switchTo(name)}
233
+ ariaLabel={t('center.currentBranch')}
234
+ buttonStyle={css.popupBranchMenu}
235
+ />
236
+ )}
237
+ {s.unborn && <span style={css.popupBadge}>{t('pill.noCommits')}</span>}
238
+ {s.dirty && <span style={css.popupBadge}>{dirtyBadge(view)}</span>}
239
+ {(s.ahead > 0 || s.behind > 0) && <span style={css.popupBadge}>{aheadBehind(view)}</span>}
240
+ </div>
241
+ <div style={css.popupHeaderRoot} title={s.root}>
242
+ <FolderIcon />
243
+ <span style={css.popupHeaderRootText}>{s.root}</span>
244
+ </div>
245
+ </header>
246
+ <div style={css.popupStatusBar}>
143
247
  {([
144
248
  ['popup.staged', s.staged], ['popup.modified', s.modified], ['popup.untracked', s.untracked],
145
- ['popup.ahead', s.ahead], ['popup.behind', s.behind],
146
249
  ] as const).map(([key, value]) => (
147
- <div key={key} style={css.countCell}>
148
- <div style={css.countValue}>{value}</div>
149
- <div style={css.countLabel}>{t(key)}</div>
150
- </div>
250
+ <span key={key} style={css.popupStatItem}>
251
+ <span style={css.popupStatValue}>{value}</span>
252
+ <span style={css.popupStatLabel}>{t(key)}</span>
253
+ </span>
151
254
  ))}
152
255
  </div>
256
+ <div style={css.popupBranchOps}>
257
+ <input
258
+ className="dsh-git-ui__branch-input"
259
+ style={css.branchNameInput}
260
+ placeholder={t('center.branchName')}
261
+ value={newName}
262
+ disabled={busy}
263
+ onChange={(e) => setNewName(e.target.value)}
264
+ onKeyDown={(e) => { if (e.key === 'Enter') void createAndSwitch() }}
265
+ />
266
+ <Button size="sm" disabled={busy || newName.trim() === ''} onClick={() => void createAndSwitch()}>
267
+ {t('center.createAndSwitch')}
268
+ </Button>
269
+ </div>
270
+ {note !== null && (
271
+ <div style={css.popupNote} role="alert">
272
+ <span style={css.popupNoteIcon} aria-hidden="true"><AlertIcon /></span>
273
+ <span style={css.popupNoteText} title={note.detail}>{note.text}</span>
274
+ {note.action === 'open-center' && (
275
+ <button type="button" className="dsh-git-ui__change-link" style={css.popupNoteAction} onClick={openCenter}>
276
+ {t('error.handleChanges')}
277
+ </button>
278
+ )}
279
+ <button
280
+ type="button"
281
+ className="dsh-git-ui__icon-btn"
282
+ style={css.popupNoteClose}
283
+ title={t('center.close')}
284
+ aria-label={t('center.close')}
285
+ onClick={() => setNote(null)}
286
+ >
287
+ <CloseIcon />
288
+ </button>
289
+ </div>
290
+ )}
153
291
  <div style={css.sectionTitle}>{t('popup.recentCommits')}</div>
154
292
  {s.recentCommits.length === 0
155
293
  ? <div style={css.emptyNote}>{t('popup.emptyCommits')}</div>
156
- : s.recentCommits.map((commit) => (
157
- <div key={commit.hash} style={css.commitRow}>
158
- <span style={css.commitHash}>{commit.shortHash}</span>
159
- <span style={css.commitSubject} title={commit.subject}>{commit.subject}</span>
160
- <span style={css.commitMeta}>{commit.author} · {timeAgo(commit.dateIso, now, t)}</span>
294
+ : s.recentCommits.slice(0, 3).map((commit) => (
295
+ <div key={commit.hash} className="dsh-git-ui__row" style={css.commitRow}>
296
+ <div style={css.commitSubjectPop} title={commit.subject}>{commit.subject}</div>
297
+ <div style={css.commitMetaLine}>
298
+ <span style={css.commitHash}>{commit.shortHash}</span>
299
+ <span style={css.commitDot}>·</span>
300
+ <span style={css.commitMeta}>{commit.author}</span>
301
+ <span style={css.commitDot}>·</span>
302
+ <span style={css.commitMeta}>{timeAgo(commit.dateIso, now, t)}</span>
303
+ </div>
161
304
  </div>
162
305
  ))}
163
306
  <div style={css.sectionTitle}>{t('popup.changes')}</div>
@@ -165,17 +308,75 @@ function GitPopupBody({
165
308
  ? <div style={css.emptyNote}>{t('popup.empty')}</div>
166
309
  : (
167
310
  <>
168
- {s.changes.map((change) => (
169
- <div key={change.path} style={css.changeRow}>
170
- <span
171
- style={{ ...css.changeChip, background: CHIP_COLORS[change.status] ?? '#888' }}
172
- title={change.status}
173
- >
174
- {CHIP_LETTERS[change.status] ?? '•'}
175
- </span>
176
- <span style={css.changePath} title={change.path}>{change.path}</span>
177
- </div>
178
- ))}
311
+ {s.changes.map((change) => {
312
+ const { name, dir, isDir } = splitChangePath(change.path, change.isDirectory)
313
+ const untracked = change.status === 'untracked'
314
+ return (
315
+ <div key={change.path} className="dsh-git-ui__row" style={css.changeRow}>
316
+ <span
317
+ style={{ ...css.changeChip, ...(css.chipStyles[change.status] ?? css.chipStyles.untracked) }}
318
+ title={change.status}
319
+ >
320
+ {CHIP_LETTERS[change.status] ?? '•'}
321
+ </span>
322
+ <span style={css.rowFileIcon} aria-hidden="true">
323
+ {isDir ? <FolderIcon /> : fileIconForPath(change.path)}
324
+ </span>
325
+ {isDir ? (
326
+ // 目录条目:点击打开 Git 中心变更页(目录无 diff 语义,展开后选具体文件)。
327
+ <button
328
+ type="button"
329
+ className="dsh-git-ui__change-link"
330
+ style={css.changeNamePopBtn}
331
+ title={change.path}
332
+ aria-label={`${name} — ${t('center.open')}`}
333
+ onClick={openCenter}
334
+ >
335
+ {name}
336
+ </button>
337
+ ) : (
338
+ // 文件条目:点击打开 Git 中心并直接展示该文件对照。
339
+ <button
340
+ type="button"
341
+ className="dsh-git-ui__change-link"
342
+ style={css.changeNamePopBtn}
343
+ title={change.path}
344
+ aria-label={`${name} — ${t('changes.actionDiff')}`}
345
+ onClick={() => onOpenDiff(change.path, diffBaseOf(change))}
346
+ >
347
+ {name}
348
+ </button>
349
+ )}
350
+ {dir !== '' ? <span style={css.changeDirPop}>{dir}</span> : <span style={{ flex: '1 1 0%', minWidth: 0 }} />}
351
+ <span className="dsh-git-ui__row-actions" style={css.rowActions}>
352
+ {change.staged
353
+ ? (
354
+ <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)}>
355
+ <UnstageIcon />
356
+ </button>
357
+ )
358
+ : (
359
+ <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)}>
360
+ <StageIcon />
361
+ </button>
362
+ )}
363
+ {!untracked && (
364
+ <button
365
+ type="button"
366
+ className="dsh-git-ui__icon-btn"
367
+ style={armed === change.path ? { ...css.rowIconButton, color: 'var(--dsw-alias-state-error-primary)' } : css.rowIconButton}
368
+ title={armed === change.path ? t('center.confirmDiscard') : t('center.discard')}
369
+ aria-label={armed === change.path ? t('center.confirmDiscard') : t('center.discard')}
370
+ disabled={busy}
371
+ onClick={() => discard(change.path)}
372
+ >
373
+ <RollbackIcon />
374
+ </button>
375
+ )}
376
+ </span>
377
+ </div>
378
+ )
379
+ })}
179
380
  {s.truncated && (
180
381
  <div style={css.emptyNote}>{t('popup.changesTruncated').replace('{count}', String(s.changes.length))}</div>
181
382
  )}
@@ -183,11 +384,11 @@ function GitPopupBody({
183
384
  )}
184
385
  <div style={css.footerRow}>
185
386
  <span style={css.checkedAt}>{t('popup.checkedAt').replace('{time}', new Date(s.checkedAt).toLocaleTimeString())}</span>
186
- <span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
187
- <button type="button" className="dsh-git-ui__refresh" style={css.refreshButton} onClick={openCenter}>
387
+ <span style={css.footerActions}>
388
+ <PopRefresher refresh={refresh} t={t} />
389
+ <button type="button" className="dsh-git-ui__footer-primary" style={{ ...css.refreshButton, ...css.footerPrimary, padding: '4px 10px' }} onClick={openCenter}>
188
390
  {t('center.open')}
189
391
  </button>
190
- <PopRefresher refresh={refresh} t={t} />
191
392
  </span>
192
393
  </div>
193
394
  </>
@@ -220,7 +421,7 @@ const VIEW_GUTTER = 8
220
421
  * The header utility entry: a branch pill that opens a portaled detail popup
221
422
  * and the Git center management panel.
222
423
  */
223
- export function GitPill({ useGit, useSession, refresh, run, t }: GitPillProps): JSX.Element | null {
424
+ export function GitPill({ useGit, useSession, refresh, run, query, t }: GitPillProps): JSX.Element | null {
224
425
  // The selector hook requires a selector function (with-selector calls it
225
426
  // unconditionally); identity selection reads the whole view snapshot.
226
427
  const view = useGit((view) => view)
@@ -251,6 +452,16 @@ export function GitPill({ useGit, useSession, refresh, run, t }: GitPillProps):
251
452
  const [open, setOpen] = useState(false)
252
453
  const [centerOpen, setCenterOpen] = useState(false)
253
454
  const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
455
+ /** 从 pill 变更行点击「打开 Git 中心并定位该文件 diff」的请求。 */
456
+ const [centerRequest, setCenterRequest] = useState<{ path: string; base: 'worktree' | 'staged' } | null>(null)
457
+
458
+ /** 打开 Git 中心并直接定位到该文件的对照视图(关 popup、切 changes 标签、查询 diff)。 */
459
+ const openDiffInCenter = (path: string, base: 'worktree' | 'staged'): void => {
460
+ setCenterRequest({ path, base })
461
+ setOpen(false)
462
+ setPos(null)
463
+ setCenterOpen(true)
464
+ }
254
465
 
255
466
  useEffect(() => {
256
467
  // First mount only: kick the controller once (single-flight; a cold
@@ -302,10 +513,7 @@ export function GitPill({ useGit, useSession, refresh, run, t }: GitPillProps):
302
513
  if (!open) return
303
514
  const close = (): void => { setOpen(false); setPos(null) }
304
515
  const onDown = (e: MouseEvent): void => {
305
- const target = e.target as Node
306
- if (wrapRef.current?.contains(target) ?? false) return
307
- if (popRef.current?.contains(target) ?? false) return
308
- close()
516
+ if (shouldClosePopup(e.target, wrapRef.current, popRef.current)) close()
309
517
  }
310
518
  const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') close() }
311
519
  document.addEventListener('mousedown', onDown)
@@ -335,6 +543,7 @@ export function GitPill({ useGit, useSession, refresh, run, t }: GitPillProps):
335
543
  }
336
544
 
337
545
  const dirty = display.snapshot.dirty
546
+ const parts = pillParts(display, t)
338
547
  return (
339
548
  <span ref={wrapRef} style={{ display: 'inline-flex' }}>
340
549
  <button
@@ -344,10 +553,11 @@ export function GitPill({ useGit, useSession, refresh, run, t }: GitPillProps):
344
553
  onClick={() => setOpen(!open)}
345
554
  aria-haspopup="dialog"
346
555
  aria-expanded={open}
347
- title={`${display.snapshot.root}\n${pillLabel(display, t)}`}
556
+ title={`${display.snapshot.root}\n${[parts.branch, ...parts.badges].filter(Boolean).join(' · ')}`}
348
557
  >
349
558
  <span style={dirty ? css.dotDirty : css.dot} aria-hidden="true" />
350
- <span>{pillLabel(display, t)}</span>
559
+ <span style={css.pillBranch}>{parts.branch}</span>
560
+ {parts.badges.length > 0 && <span style={css.pillBadges}>{parts.badges.join(' · ')}</span>}
351
561
  </button>
352
562
  {open && pos !== null && createPortal(
353
563
  <div
@@ -361,6 +571,9 @@ export function GitPill({ useGit, useSession, refresh, run, t }: GitPillProps):
361
571
  view={display}
362
572
  refresh={refresh}
363
573
  openCenter={() => { setOpen(false); setPos(null); setCenterOpen(true) }}
574
+ onOpenDiff={openDiffInCenter}
575
+ run={run}
576
+ query={query}
364
577
  t={t}
365
578
  />
366
579
  </div>,
@@ -371,7 +584,9 @@ export function GitPill({ useGit, useSession, refresh, run, t }: GitPillProps):
371
584
  onClose={() => setCenterOpen(false)}
372
585
  snapshot={display.snapshot}
373
586
  run={run}
587
+ query={query}
374
588
  t={t}
589
+ openRequest={centerRequest}
375
590
  />
376
591
  </span>
377
592
  )
@@ -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
+ }
@@ -5,38 +5,12 @@
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 { GitActionResult, GitActionRequest, GitSnapshot, GitSnapshotFailure, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
8
+ import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshot, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
9
+ import type { GitObservable, GitView, GitRemoteLike, GitQueryOutcome, RemoteEnvelope } from '../contracts/client-platform.ts'
9
10
 
10
- /** The observable view contract components consume (useSyncExternalStore shape). */
11
- export interface GitObservable<V> {
12
- subscribe(listener: () => void): () => void
13
- getSnapshot(): V
14
- }
15
-
16
- export type GitView =
17
- | { readonly state: 'no-cwd' }
18
- | { readonly state: 'cold' }
19
- | { readonly state: 'loading' }
20
- | { readonly state: 'ready'; readonly snapshot: GitSnapshot }
21
- | { readonly state: 'error'; readonly error: GitSnapshotFailure }
22
-
23
- /**
24
- * RPC envelope returned by every mounted Remote method (see the api-gateway
25
- * client's `invoke`): `ok` reflects the transport/gateway outcome, and the
26
- * business return value of the host method rides inside `value`. For
27
- * `gitInfo/snapshot` that business value is a `GitSnapshotResult` — so a
28
- * successful call resolves to `{ ok: true, value: { ok: true, value:
29
- * GitSnapshot } }`.
30
- */
31
- export type GitRemoteEnvelope<T> =
32
- | { readonly ok: true; readonly value: T }
33
- | { readonly ok: false; readonly error: { readonly code?: string; readonly message?: string; readonly details?: unknown } }
34
-
35
- /** Structural face of the mounted gitInfo Remote namespace. */
36
- export interface GitRemoteLike {
37
- snapshot(request: GitSnapshotRequest): Promise<GitRemoteEnvelope<GitSnapshotResult>>
38
- run(request: GitActionRequest): Promise<GitRemoteEnvelope<GitActionResult>>
39
- }
11
+ // Re-export for backward compatibility 其他模块仍可从 controller 导入这些类型
12
+ export type { GitObservable, GitView, GitRemoteLike, GitQueryOutcome } from '../contracts/client-platform.ts'
13
+ export type { RemoteEnvelope as GitRemoteEnvelope } from '../contracts/client-platform.ts'
40
14
 
41
15
  /** Failure codes that mean "no working directory to watch" — degrade to a
42
16
  * low-frequency probe instead of a normal poll. */
@@ -173,6 +147,35 @@ export class GitController implements GitObservable<GitView> {
173
147
  return promise
174
148
  }
175
149
 
150
+ /**
151
+ * Run one read-only query (history / diff / show / branches). The view is
152
+ * untouched — the result goes straight back to the caller. Queues behind
153
+ * any in-flight refresh/run like everything else (single-flight).
154
+ */
155
+ query(query: GitQueryRequest['query']): Promise<GitQueryOutcome> {
156
+ if (this.inflight !== undefined) return this.inflight.then(() => this.query(query))
157
+ if (this.disposed) return Promise.resolve({ ok: false, message: 'controller disposed' })
158
+ const promise = this.remote.query({ sessionId: this.sessionId, query })
159
+ .then((result): GitQueryOutcome => {
160
+ if (!result.ok) {
161
+ const detail = [result.error.code, result.error.message].filter(Boolean).join(': ')
162
+ return { ok: false, message: detail || 'rpc failure' }
163
+ }
164
+ const inner = result.value
165
+ if (inner.ok) return { ok: true, value: inner.value }
166
+ return { ok: false, message: inner.error.message ?? inner.error.code }
167
+ })
168
+ .catch((error: unknown): GitQueryOutcome => {
169
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
170
+ })
171
+ .finally(() => {
172
+ this.inflight = undefined
173
+ if (!this.disposed) this.schedulePoll()
174
+ })
175
+ this.inflight = promise.then(() => undefined)
176
+ return promise
177
+ }
178
+
176
179
  /** Tear down: stop the timer; in-flight work settles into a no-op. */
177
180
  dispose(): void {
178
181
  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
+ }