dsh-git-ui 0.0.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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pure parsers for the git porcelain/log output shapes used by the widget.
3
+ * No side effects and no I/O — fully unit-testable against literal fixtures
4
+ * (verified against real `git status --porcelain=v1 -z --branch` output).
5
+ */
6
+ import type { GitChange, GitCommit } from './types.ts';
7
+ /** Parsed status counts plus the (possibly capped) change list. */
8
+ export interface ParsedStatus {
9
+ readonly branch: string | null;
10
+ readonly unborn: boolean;
11
+ readonly staged: number;
12
+ readonly modified: number;
13
+ readonly untracked: number;
14
+ readonly ahead: number;
15
+ readonly behind: number;
16
+ readonly changes: readonly GitChange[];
17
+ readonly truncated: boolean;
18
+ }
19
+ interface StatusHeader {
20
+ readonly branch: string | null;
21
+ readonly unborn: boolean;
22
+ readonly ahead: number;
23
+ readonly behind: number;
24
+ }
25
+ /**
26
+ * Parse the `## ` header line of `git status --porcelain=v1 -z --branch`.
27
+ * Recognized shapes (verified against git 2.x):
28
+ * `## main`
29
+ * `## main...origin/main`
30
+ * `## main...origin/main [ahead 1]`
31
+ * `## main...origin/main [behind 2]`
32
+ * `## main...origin/main [ahead 1, behind 2]`
33
+ * `## HEAD (no branch)` (detached)
34
+ * `## HEAD (detached at <hash>)` (detached, older git)
35
+ * `## No commits yet on main` (unborn)
36
+ * `## Initial commit on main` (unborn, older git)
37
+ */
38
+ export declare function parseStatusHeader(line: string): StatusHeader;
39
+ /**
40
+ * Parse the full `git status --porcelain=v1 -z --branch` output.
41
+ * -z format: every entry (header and each `XY path`) is NUL-terminated; a
42
+ * rename/copy entry emits `R <new>\0<old>\0` so the following item is the
43
+ * source path and must be consumed without becoming a change itself.
44
+ */
45
+ export declare function parseStatusOutput(output: string, maxChanges: number): ParsedStatus;
46
+ /**
47
+ * Parse `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI` output.
48
+ * One commit per line, fields separated by the unit separator; empty output
49
+ * (unborn repository) yields `[]`.
50
+ */
51
+ export declare function parseLogOutput(output: string): readonly GitCommit[];
52
+ /**
53
+ * Parse `git branch --show-current` output: the branch name, or null when
54
+ * empty (detached HEAD).
55
+ */
56
+ export declare function parseBranchOutput(output: string): string | null;
57
+ export {};
@@ -0,0 +1,74 @@
1
+ /**
2
+ * dsh-git-ui host data model. This file is the authoritative type source;
3
+ * the client half mirrors it with zod schemas (see src/client/remote.ts) and
4
+ * `tests/remote.spec.ts` keeps the two in sync.
5
+ */
6
+ /** Wire request: the browser never sends paths — only the session identity. */
7
+ export interface GitSnapshotRequest {
8
+ readonly sessionId: string;
9
+ }
10
+ /** Discriminated outcome of one snapshot attempt. */
11
+ export type GitSnapshotResult = {
12
+ readonly ok: true;
13
+ readonly value: GitSnapshot;
14
+ } | {
15
+ readonly ok: false;
16
+ readonly error: GitSnapshotFailure;
17
+ };
18
+ export type GitSnapshotFailure = {
19
+ readonly code: 'session-not-found';
20
+ readonly sessionId: string;
21
+ } | {
22
+ readonly code: 'cwd-unavailable';
23
+ readonly sessionId: string;
24
+ } | {
25
+ readonly code: 'path-not-found';
26
+ readonly path: string;
27
+ } | {
28
+ readonly code: 'git-unavailable';
29
+ readonly detail: string;
30
+ } | {
31
+ readonly code: 'timeout';
32
+ } | {
33
+ readonly code: 'not-a-git-repo';
34
+ };
35
+ /** Immutable frozen snapshot of one repository's status at `checkedAt`. */
36
+ export interface GitSnapshot {
37
+ /** Realpath of the repository root (work tree top). */
38
+ readonly root: string;
39
+ /** Current branch name; null when detached. */
40
+ readonly branch: string | null;
41
+ /** Short HEAD hash; null when the repository has no commits (unborn). */
42
+ readonly head: string | null;
43
+ /** True when the repository has no commits yet. */
44
+ readonly unborn: boolean;
45
+ /** staged + modified + untracked > 0. */
46
+ readonly dirty: boolean;
47
+ readonly staged: number;
48
+ readonly modified: number;
49
+ readonly untracked: number;
50
+ readonly ahead: number;
51
+ readonly behind: number;
52
+ readonly lastCommit: GitCommit | null;
53
+ readonly recentCommits: readonly GitCommit[];
54
+ readonly changes: readonly GitChange[];
55
+ /** True when `changes` was capped at maxChanges or status output overflowed. */
56
+ readonly truncated: boolean;
57
+ /** Polling interval the client should use after this snapshot (0 = off). */
58
+ readonly refreshIntervalMs: number;
59
+ /** Epoch millis of the snapshot. */
60
+ readonly checkedAt: number;
61
+ }
62
+ export interface GitCommit {
63
+ readonly hash: string;
64
+ readonly shortHash: string;
65
+ readonly subject: string;
66
+ readonly author: string;
67
+ readonly dateIso: string;
68
+ }
69
+ export interface GitChange {
70
+ readonly path: string;
71
+ readonly status: GitChangeStatus;
72
+ readonly staged: boolean;
73
+ }
74
+ export type GitChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' | 'conflicted' | 'typechange';
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "dsh-git-ui",
3
+ "version": "0.0.1",
4
+ "description": "DeepSeek Harness (dsh) plugin: visualize Git status in the Web UI — current branch, HEAD, staged/modified/untracked counts, ahead/behind, recent commits and changed files.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Julyves",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+ssh://git@github.com:Julyves/dsh-git-ui.git"
11
+ },
12
+ "keywords": [
13
+ "dsh",
14
+ "deepseek-harness",
15
+ "plugin",
16
+ "git",
17
+ "web-ui",
18
+ "status"
19
+ ],
20
+ "engines": {
21
+ "node": "^22.19.0 || >=24.0.0"
22
+ },
23
+ "main": "lib/host/index.js",
24
+ "types": "lib/host/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./lib/host/index.d.ts",
28
+ "default": "./lib/host/index.js"
29
+ },
30
+ "./client": {
31
+ "types": "./lib/client/index.d.ts",
32
+ "default": "./lib/client.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "lib",
38
+ "src",
39
+ "cordis.patch.yml",
40
+ "README.md",
41
+ "README.zh.md",
42
+ "LICENSE"
43
+ ],
44
+ "scripts": {
45
+ "typecheck": "tsc -p tsconfig.json --noEmit",
46
+ "test": "vitest run",
47
+ "build": "node build.mjs",
48
+ "prepare": "node build.mjs",
49
+ "pack": "npm pack"
50
+ },
51
+ "dsh": {
52
+ "bundle": {
53
+ "patch": "./cordis.patch.yml"
54
+ },
55
+ "client": {
56
+ "platform": "web",
57
+ "inject": [
58
+ "@deepseek-ai/dsh-client-runtime",
59
+ "@deepseek-ai/dsh-client-locale",
60
+ "@deepseek-ai/dsh-client-ui-conversation"
61
+ ]
62
+ }
63
+ },
64
+ "peerDependencies": {
65
+ "@deepseek-ai/cordis": "^4.0.1",
66
+ "@deepseek-ai/dsh-session": ">=0.0.1-rc.1 <0.2.0",
67
+ "@deepseek-ai/dsh-session-persistence": ">=0.0.1-rc.1 <0.2.0",
68
+ "@deepseek-ai/dsh-subprocess": ">=0.0.1-rc.1 <0.2.0",
69
+ "@deepseek-ai/dsh-typert-protocol": ">=0.0.1-rc.1 <0.2.0"
70
+ },
71
+ "devDependencies": {
72
+ "@types/node": "^26.2.0",
73
+ "@types/react": "^19.2.18",
74
+ "esbuild": "^0.27.0",
75
+ "jsdom": "^27.0.0",
76
+ "typescript": "^7.0.2",
77
+ "vitest": "^4.1.10",
78
+ "zod": "^4.4.3"
79
+ }
80
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Git status pill + popup. Consumes the injected per-session controller
3
+ * through the framework-standard observable shape: the slot runtime binds the
4
+ * inject face's `hooks.git` observable into a `useGit` selector hook (see
5
+ * bindInjectHooks in dsh-client-web-react), so the component reads the view
6
+ * with `useGit()` instead of subscribing manually. Renders nothing for
7
+ * cold/no-cwd states and a dimmed placeholder for degraded states.
8
+ *
9
+ * Layout contract: the popup is a fixed-position card portaled to
10
+ * document.body and anchored to the wrapper's rect — exactly the machinery
11
+ * the host's Menu/HoverCard use. It never participates in header layout, so
12
+ * opening it cannot stretch the header.
13
+ */
14
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
15
+ import { createPortal } from 'react-dom'
16
+ import type { JSX } from 'react'
17
+ import { completedTurnCount, type TurnSignalSnapshot } from './turn-signal.ts'
18
+ import type { GitObservable, GitView } from './controller.ts'
19
+ import type { GitKey } from './locales.ts'
20
+ import * as css from './styles.ts'
21
+
22
+ // Inject the plugin's interaction styles once (idempotent, browser-only).
23
+ css.ensureGlobalCss()
24
+
25
+ /** Injected business face of the header utility entry. */
26
+ export interface GitInjected {
27
+ hooks: {
28
+ /** The owning Session's git view source. The slot runtime binds this
29
+ * observable into the `useGit` selector hook the component consumes. */
30
+ git: GitObservable<GitView>
31
+ }
32
+ /** Force an immediate re-check (same path as polling). */
33
+ refresh: () => Promise<void>
34
+ }
35
+
36
+ /** Selector hook shape the slot runtime binds from `hooks.git`. */
37
+ export type UseGit = <S = GitView>(
38
+ selector?: (view: GitView) => S,
39
+ equality?: (a: S, b: S) => boolean,
40
+ ) => S
41
+
42
+ export type { TurnSignalSnapshot } from './turn-signal.ts'
43
+
44
+ /** Full props of the git pill entry (framework kit + our inject + locale). */
45
+ export interface GitPillProps extends GitInjected {
46
+ /** Session scope identity from the standard session kit. */
47
+ readonly sessionId: string
48
+ /** Selector hook bound from `hooks.git` by the slot runtime. */
49
+ readonly useGit: UseGit
50
+ /** Standard session kit selector hook (narrowed to the turn signal). */
51
+ readonly useSession: <S>(selector: (snapshot: TurnSignalSnapshot) => S) => S
52
+ /** Namespace-bound dictionary accessor. */
53
+ readonly t: (key: GitKey) => string
54
+ }
55
+
56
+ /** Status chip colors for the changed-file list. */
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
+ }
66
+
67
+ const CHIP_LETTERS: Record<string, string> = {
68
+ added: 'A', modified: 'M', deleted: 'D', renamed: 'R',
69
+ untracked: '?', conflicted: '!', typechange: 'T',
70
+ }
71
+
72
+ /** Format a count badge like `+2 −1 ?3` (staged / modified / untracked). */
73
+ function dirtyBadge(snapshot: GitView & { state: 'ready' }): string {
74
+ const parts: string[] = []
75
+ if (snapshot.snapshot.staged > 0) parts.push(`+${snapshot.snapshot.staged}`)
76
+ if (snapshot.snapshot.modified > 0) parts.push(`−${snapshot.snapshot.modified}`)
77
+ if (snapshot.snapshot.untracked > 0) parts.push(`?${snapshot.snapshot.untracked}`)
78
+ return parts.join(' ')
79
+ }
80
+
81
+ /** Format ahead/behind like `↑1 ↓2`. */
82
+ function aheadBehind(snapshot: GitView & { state: 'ready' }): string {
83
+ const parts: string[] = []
84
+ if (snapshot.snapshot.ahead > 0) parts.push(`↑${snapshot.snapshot.ahead}`)
85
+ if (snapshot.snapshot.behind > 0) parts.push(`↓${snapshot.snapshot.behind}`)
86
+ return parts.join(' ')
87
+ }
88
+
89
+ /** Short relative time from an ISO date, localized through dictionary templates. */
90
+ function timeAgo(iso: string, now: number, t: (key: GitKey) => string): string {
91
+ const then = Date.parse(iso)
92
+ if (!Number.isFinite(then)) return iso
93
+ const seconds = Math.max(0, Math.floor((now - then) / 1000))
94
+ const fill = (template: GitKey, n: number): string => t(template).replace('{n}', String(n))
95
+ if (seconds < 60) return t('time.justNow')
96
+ if (seconds < 3600) return fill('time.minutesAgo', Math.floor(seconds / 60))
97
+ if (seconds < 86_400) return fill('time.hoursAgo', Math.floor(seconds / 3600))
98
+ return fill('time.daysAgo', Math.floor(seconds / 86_400))
99
+ }
100
+
101
+ /** The pill label for a ready snapshot. */
102
+ function pillLabel(view: GitView & { state: 'ready' }, t: (key: GitKey) => string): string {
103
+ const s = view.snapshot
104
+ const branch = s.branch === null
105
+ ? `(${t('pill.detached')}) · ${s.head ?? ''}`
106
+ : s.branch
107
+ const base = s.unborn ? `${branch} · ${t('pill.noCommits')}` : branch
108
+ const badge = dirtyBadge(view)
109
+ const ahead = aheadBehind(view)
110
+ return [base, badge, ahead].filter(Boolean).join(' · ')
111
+ }
112
+
113
+ /** Dimmed pill for degraded states. */
114
+ function DegradedPill({ label, title, t }: { label: string; title?: string; t: (key: GitKey) => string }): JSX.Element {
115
+ void t
116
+ return (
117
+ <span className="dsh-git-ui__pill" style={css.pillDimmed} title={title} aria-label={label}>
118
+ {label}
119
+ </span>
120
+ )
121
+ }
122
+
123
+ /** Popup body (rendered inside the portaled card): root, counts, commits, changes, refresh. */
124
+ function GitPopupBody({
125
+ view, refresh, t,
126
+ }: {
127
+ view: GitView & { state: 'ready' }
128
+ refresh: () => Promise<void>
129
+ t: (key: GitKey) => string
130
+ }): JSX.Element {
131
+ const now = Date.now()
132
+ const s = view.snapshot
133
+ return (
134
+ <>
135
+ <h4 style={css.popupTitle}>{t('popup.title')}</h4>
136
+ <div style={css.rootLine} title={s.root}>{s.root}</div>
137
+ <div style={css.countGrid}>
138
+ {([
139
+ ['popup.staged', s.staged], ['popup.modified', s.modified], ['popup.untracked', s.untracked],
140
+ ['popup.ahead', s.ahead], ['popup.behind', s.behind],
141
+ ] as const).map(([key, value]) => (
142
+ <div key={key} style={css.countCell}>
143
+ <div style={css.countValue}>{value}</div>
144
+ <div style={css.countLabel}>{t(key)}</div>
145
+ </div>
146
+ ))}
147
+ </div>
148
+ <div style={css.sectionTitle}>{t('popup.recentCommits')}</div>
149
+ {s.recentCommits.length === 0
150
+ ? <div style={css.emptyNote}>{t('popup.emptyCommits')}</div>
151
+ : s.recentCommits.map((commit) => (
152
+ <div key={commit.hash} style={css.commitRow}>
153
+ <span style={css.commitHash}>{commit.shortHash}</span>
154
+ <span style={css.commitSubject} title={commit.subject}>{commit.subject}</span>
155
+ <span style={css.commitMeta}>{commit.author} · {timeAgo(commit.dateIso, now, t)}</span>
156
+ </div>
157
+ ))}
158
+ <div style={css.sectionTitle}>{t('popup.changes')}</div>
159
+ {s.changes.length === 0
160
+ ? <div style={css.emptyNote}>{t('popup.empty')}</div>
161
+ : (
162
+ <>
163
+ {s.changes.map((change) => (
164
+ <div key={change.path} style={css.changeRow}>
165
+ <span
166
+ style={{ ...css.changeChip, background: CHIP_COLORS[change.status] ?? '#888' }}
167
+ title={change.status}
168
+ >
169
+ {CHIP_LETTERS[change.status] ?? '•'}
170
+ </span>
171
+ <span style={css.changePath} title={change.path}>{change.path}</span>
172
+ </div>
173
+ ))}
174
+ {s.truncated && (
175
+ <div style={css.emptyNote}>{t('popup.changesTruncated').replace('{count}', String(s.changes.length))}</div>
176
+ )}
177
+ </>
178
+ )}
179
+ <div style={css.footerRow}>
180
+ <span style={css.checkedAt}>{t('popup.checkedAt').replace('{time}', new Date(s.checkedAt).toLocaleTimeString())}</span>
181
+ <PopRefresher refresh={refresh} t={t} />
182
+ </div>
183
+ </>
184
+ )
185
+ }
186
+
187
+ /** The popup refresh verb (kept as its own component so it may own state). */
188
+ function PopRefresher({ refresh, t }: { refresh: () => Promise<void>; t: (key: GitKey) => string }): JSX.Element {
189
+ const [refreshing, setRefreshing] = useState(false)
190
+ const onRefresh = (): void => {
191
+ if (refreshing) return
192
+ setRefreshing(true)
193
+ void refresh().finally(() => setRefreshing(false))
194
+ }
195
+ return (
196
+ <button type="button" className="dsh-git-ui__refresh" style={css.refreshButton} onClick={onRefresh} disabled={refreshing}>
197
+ {refreshing ? '…' : t('popup.refresh')}
198
+ </button>
199
+ )
200
+ }
201
+
202
+ // Popup geometry (matches the host Menu/HoverCard portal pattern).
203
+ const POPUP_WIDTH = 340
204
+ const POPUP_MAX_HEIGHT = 420
205
+ const POPUP_GAP = 6
206
+ const POPUP_GUTTER = 6
207
+ const VIEW_GUTTER = 8
208
+
209
+ /**
210
+ * The header utility entry: a branch pill that opens a portaled detail popup.
211
+ */
212
+ export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.Element | null {
213
+ // The selector hook requires a selector function (with-selector calls it
214
+ // unconditionally); identity selection reads the whole view snapshot.
215
+ const view = useGit((view) => view)
216
+ // Last ready snapshot: while a refresh is in flight the controller reports
217
+ // 'loading'; render the previous content instead of unmounting (a null
218
+ // here unmounts the whole entry and makes sibling utilities in the same
219
+ // seat flicker on every poll).
220
+ const lastReady = useRef<GitView & { state: 'ready' } | null>(null)
221
+ if (view.state === 'ready') lastReady.current = view
222
+ const display: GitView = view.state === 'loading' && lastReady.current !== null ? lastReady.current : view
223
+
224
+ // Best-effort activity trigger: an agent turn completing is the most
225
+ // likely moment the working tree changed, so refresh right away instead of
226
+ // waiting for the next poll. Polling stays the fallback (external edits,
227
+ // window-slot misses). The ref starts at the CURRENT count so the mount
228
+ // kick (below) is not duplicated; a session switch remounts and resets it.
229
+ const turnCount = useSession((s) => completedTurnCount(s))
230
+ const lastTurnRef = useRef(turnCount)
231
+ useEffect(() => {
232
+ if (turnCount <= lastTurnRef.current) return
233
+ lastTurnRef.current = turnCount
234
+ void refresh()
235
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- refresh is per-session stable by contract.
236
+ }, [turnCount])
237
+
238
+ const wrapRef = useRef<HTMLSpanElement>(null)
239
+ const popRef = useRef<HTMLDivElement>(null)
240
+ const [open, setOpen] = useState(false)
241
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
242
+
243
+ useEffect(() => {
244
+ // First mount only: kick the controller once (single-flight; a cold
245
+ // controller loads, a no-cwd controller retries). The inject face is
246
+ // stable per session, so the controller must not be re-kicked on
247
+ // re-renders — polling takes over after the first snapshot.
248
+ void refresh()
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only kick; refresh is per-session stable by contract.
250
+ }, [])
251
+
252
+ // Anchor the popup to the wrapper rect; re-place on scroll/resize while open.
253
+ useLayoutEffect(() => {
254
+ if (!open) {
255
+ setPos(null)
256
+ return
257
+ }
258
+ const wrapper = wrapRef.current
259
+ if (wrapper === null) return
260
+ const place = (): void => {
261
+ const r = wrapper.getBoundingClientRect()
262
+ const left = Math.max(VIEW_GUTTER, Math.min(r.right - POPUP_WIDTH, window.innerWidth - POPUP_WIDTH - VIEW_GUTTER))
263
+ const below = r.bottom + POPUP_GAP
264
+ const top = (below + POPUP_MAX_HEIGHT > window.innerHeight - VIEW_GUTTER)
265
+ ? Math.max(VIEW_GUTTER, r.top - POPUP_MAX_HEIGHT - POPUP_GUTTER)
266
+ : below
267
+ setPos({ top, left })
268
+ }
269
+ place()
270
+ window.addEventListener('scroll', place, true)
271
+ window.addEventListener('resize', place)
272
+ return () => {
273
+ window.removeEventListener('scroll', place, true)
274
+ window.removeEventListener('resize', place)
275
+ }
276
+ }, [open])
277
+
278
+ // Reconcile a popup that opened upward with its measured height.
279
+ useLayoutEffect(() => {
280
+ if (!open || pos === null) return
281
+ const h = popRef.current?.offsetHeight ?? POPUP_MAX_HEIGHT
282
+ if (pos.top + h > window.innerHeight - VIEW_GUTTER) {
283
+ setPos({ left: pos.left, top: Math.max(VIEW_GUTTER, window.innerHeight - h - VIEW_GUTTER) })
284
+ }
285
+ }, [open, pos])
286
+
287
+ // Close on outside press or Escape (the popup is portaled to body, so the
288
+ // wrapper ref can't cover it — check the card ref explicitly too).
289
+ useEffect(() => {
290
+ if (!open) return
291
+ const close = (): void => { setOpen(false); setPos(null) }
292
+ const onDown = (e: MouseEvent): void => {
293
+ const target = e.target as Node
294
+ if (wrapRef.current?.contains(target) ?? false) return
295
+ if (popRef.current?.contains(target) ?? false) return
296
+ close()
297
+ }
298
+ const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') close() }
299
+ document.addEventListener('mousedown', onDown)
300
+ document.addEventListener('keydown', onKey)
301
+ return () => {
302
+ document.removeEventListener('mousedown', onDown)
303
+ document.removeEventListener('keydown', onKey)
304
+ }
305
+ }, [open])
306
+
307
+ if (display.state === 'cold' || display.state === 'no-cwd') return null
308
+ if (display.state === 'loading') {
309
+ // First load only: nothing to show yet.
310
+ return null
311
+ }
312
+ if (display.state === 'error') {
313
+ if (display.error.code === 'not-a-git-repo') {
314
+ return <DegradedPill label={t('pill.noRepo')} t={t} />
315
+ }
316
+ return (
317
+ <DegradedPill
318
+ label={t('pill.unavailable')}
319
+ title={display.error.code === 'git-unavailable' ? display.error.detail : display.error.code}
320
+ t={t}
321
+ />
322
+ )
323
+ }
324
+
325
+ const dirty = display.snapshot.dirty
326
+ return (
327
+ <span ref={wrapRef} style={{ display: 'inline-flex' }}>
328
+ <button
329
+ type="button"
330
+ className="dsh-git-ui__pill"
331
+ style={css.pill}
332
+ onClick={() => setOpen(!open)}
333
+ aria-haspopup="dialog"
334
+ aria-expanded={open}
335
+ title={`${display.snapshot.root}\n${pillLabel(display, t)}`}
336
+ >
337
+ <span style={dirty ? css.dotDirty : css.dot} aria-hidden="true" />
338
+ <span>{pillLabel(display, t)}</span>
339
+ </button>
340
+ {open && pos !== null && createPortal(
341
+ <div
342
+ ref={popRef}
343
+ className="dsh-git-ui__pop"
344
+ style={{ ...css.popup, top: pos.top, left: pos.left }}
345
+ role="dialog"
346
+ aria-label={t('popup.title')}
347
+ >
348
+ <GitPopupBody view={display} refresh={refresh} t={t} />
349
+ </div>,
350
+ document.body,
351
+ )}
352
+ </span>
353
+ )
354
+ }