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,158 @@
1
+ /**
2
+ * Per-session Git view controller (React-free object layer).
3
+ * Lifecycle: `ensure()` on first mount, single-flight `refresh()`, polling at
4
+ * the interval the host snapshot carries (0 disables), `resync()` on
5
+ * connection reset, `dispose()` on slot teardown (clears the timer and
6
+ * rejects nothing — in-flight work settles into a withdrawn view).
7
+ */
8
+ import type { GitSnapshot, GitSnapshotFailure, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
9
+
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
+ }
39
+
40
+ /** Failure codes that mean "no working directory to watch" — degrade to a
41
+ * low-frequency probe instead of a normal poll. */
42
+ const TERMINAL_CODES: ReadonlySet<string> = new Set(['cwd-unavailable', 'session-not-found'])
43
+
44
+ /** Fallback poll interval while no snapshot has been received yet (ms). */
45
+ const DEFAULT_POLL_MS = 30_000
46
+
47
+ /** Probe interval for the no-cwd state: the session may gain a working
48
+ * directory later (workspace selection, host-side session update) without a
49
+ * slot remount, so keep a cheap retry instead of parking forever. */
50
+ const NO_CWD_POLL_MS = 60_000
51
+
52
+ export class GitController implements GitObservable<GitView> {
53
+ private view: GitView = { state: 'cold' }
54
+ private readonly listeners = new Set<() => void>()
55
+ private timer: ReturnType<typeof setTimeout> | undefined
56
+ private inflight: Promise<void> | undefined
57
+ private disposed = false
58
+ private pollMs: number = DEFAULT_POLL_MS
59
+
60
+ constructor(
61
+ private readonly remote: GitRemoteLike,
62
+ private readonly sessionId: string,
63
+ ) {}
64
+
65
+ subscribe(listener: () => void): () => void {
66
+ this.listeners.add(listener)
67
+ return () => { this.listeners.delete(listener) }
68
+ }
69
+
70
+ getSnapshot(): GitView {
71
+ return this.view
72
+ }
73
+
74
+ /** Load once on first mount (cold) or after a terminal no-cwd state. */
75
+ ensure(): void {
76
+ if (this.view.state !== 'cold' && this.view.state !== 'no-cwd') return
77
+ void this.refresh()
78
+ }
79
+
80
+ /** Single-flight refresh; shares the in-flight request when busy. */
81
+ refresh(): Promise<void> {
82
+ if (this.inflight !== undefined) return this.inflight
83
+ if (this.disposed) return Promise.resolve()
84
+ this.setView({ state: 'loading' })
85
+ this.inflight = this.remote.snapshot({ sessionId: this.sessionId })
86
+ .then((result) => {
87
+ if (this.disposed) return
88
+ // RPC envelope: `result.ok` is the transport/gateway outcome; the
89
+ // business GitSnapshotResult (ok/value or ok/error) sits in
90
+ // `result.value`.
91
+ if (!result.ok) {
92
+ const detail = [result.error.code, result.error.message].filter(Boolean).join(': ')
93
+ this.setView({ state: 'error', error: { code: 'git-unavailable', detail: detail || 'rpc failure' } })
94
+ return
95
+ }
96
+ const inner = result.value
97
+ if (inner.ok) {
98
+ this.pollMs = inner.value.refreshIntervalMs
99
+ this.setView({ state: 'ready', snapshot: inner.value })
100
+ } else if (TERMINAL_CODES.has(inner.error.code)) {
101
+ // Terminal no-cwd view: the finally-branch schedules the low-
102
+ // frequency probe (schedulePoll), so no explicit stop here.
103
+ this.setView({ state: 'no-cwd' })
104
+ } else {
105
+ this.setView({ state: 'error', error: inner.error })
106
+ }
107
+ })
108
+ .catch(() => {
109
+ // Transport failure: surface an error view and keep polling so the
110
+ // state recovers automatically.
111
+ if (this.disposed) return
112
+ this.setView({ state: 'error', error: { code: 'git-unavailable', detail: 'transport failure' } })
113
+ })
114
+ .finally(() => {
115
+ this.inflight = undefined
116
+ if (!this.disposed) this.schedulePoll()
117
+ })
118
+ return this.inflight
119
+ }
120
+
121
+ /** Re-sync after a connection reset (reconnect). */
122
+ resync(): void {
123
+ // no-cwd is skipped: the low-frequency probe already covers recovery,
124
+ // and a reconnect alone does not create a working directory.
125
+ if (this.disposed || this.view.state === 'cold' || this.view.state === 'no-cwd') return
126
+ void this.refresh()
127
+ }
128
+
129
+ /** Tear down: stop the timer; in-flight work settles into a no-op. */
130
+ dispose(): void {
131
+ this.disposed = true
132
+ this.stopPolling()
133
+ }
134
+
135
+ private schedulePoll(): void {
136
+ this.stopPolling()
137
+ if (this.disposed || this.pollMs <= 0 || this.view.state === 'cold') return
138
+ // no-cwd keeps a low-frequency probe so a later cwd arrival recovers
139
+ // without a remount; every other state polls at the snapshot interval.
140
+ const interval = this.view.state === 'no-cwd' ? NO_CWD_POLL_MS : this.pollMs
141
+ this.timer = setTimeout(() => {
142
+ if (this.disposed || this.inflight !== undefined) return
143
+ void this.refresh()
144
+ }, interval)
145
+ }
146
+
147
+ private stopPolling(): void {
148
+ if (this.timer !== undefined) {
149
+ clearTimeout(this.timer)
150
+ this.timer = undefined
151
+ }
152
+ }
153
+
154
+ private setView(view: GitView): void {
155
+ this.view = view
156
+ for (const listener of [...this.listeners]) listener()
157
+ }
158
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Git status widget — client half entry.
3
+ *
4
+ * The bundle is a ModuleLoader closure: `factory(require)` returns the plugin
5
+ * shape `{ name, inject, apply }`; the framework activates it once the listed
6
+ * services exist.
7
+ *
8
+ * Service-access contract (verified against cordis 0.1.0-rc.x): the gitInfo
9
+ * namespace service is provided by OUR OWN `ctx.remote.$mount(gitInfoRemote)`
10
+ * inside apply, so the main fiber can never declare `remote.gitInfo` in its
11
+ * `inject` — cordis would wait for the service before running apply, and the
12
+ * service only appears once apply runs (deadlock). Conversely, accessing
13
+ * `ctx.remote.gitInfo` without the inject declaration throws cordis's
14
+ * "cannot get property ... without inject". The consumer therefore lives in a
15
+ * CHILD fiber created after the mount: its inject declares `remote.gitInfo`,
16
+ * and by the time it activates the service already exists — no wait, no
17
+ * access violation. (In-repo plugins like ui-message-feedback can inject
18
+ * their namespace directly because a separate assembly package mounts it; a
19
+ * standalone plugin mounts its own.)
20
+ */
21
+ import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
22
+ import { GitController, type GitView, type GitObservable, type GitRemoteLike } from './controller.ts'
23
+ import { gitInfoRemote } from './remote.ts'
24
+ import { GitPill, type GitInjected } from './GitPill.tsx'
25
+ import { en, zh } from './locales.ts'
26
+
27
+ /** Structural face of the browser plugin context (host-provided). */
28
+ interface ClientContext {
29
+ get<T = unknown>(key: string): T | undefined
30
+ /** Subscribe to an application event (auto-cleaned on fiber dispose). */
31
+ on(event: string, listener: (...args: never[]) => void): (() => void) | void
32
+ /** Register a side effect with auto-cleanup on fiber dispose. */
33
+ effect(callback: () => void | (() => void | Promise<void>), label?: string): void
34
+ /** Register a nested cordis plugin (fiber) under this context. */
35
+ plugin(definition: {
36
+ readonly name: string
37
+ readonly inject: readonly string[]
38
+ apply: (ctx: ClientContext) => void | Promise<void>
39
+ }): Promise<unknown>
40
+ /** The typed Client Remote mount + mounted namespaces. */
41
+ remote: {
42
+ $mount(contribution: TypertRemoteContribution): Promise<() => Promise<void>>
43
+ gitInfo: GitRemoteLike
44
+ }
45
+ /** The slot registry (ui-slots). */
46
+ slots: {
47
+ inject(slotName: string, provider: () => (() => void) | void): void
48
+ register(
49
+ options: {
50
+ readonly name: string
51
+ readonly id: string
52
+ readonly order?: number
53
+ readonly locale?: string
54
+ readonly inject: (sessionId: string) => GitInjected
55
+ },
56
+ component: unknown,
57
+ ): () => void
58
+ }
59
+ /** The locale service (ui-locale). */
60
+ locale: {
61
+ register(namespace: string, dictionaries: { readonly zh: Record<string, string>; readonly en: Record<string, string> }): void
62
+ }
63
+ [key: string]: unknown
64
+ }
65
+
66
+ /**
67
+ * Required services: slot registry, Remote base (gateway client), and copy.
68
+ *
69
+ * `remote.gitInfo` is deliberately NOT listed here — see the module comment:
70
+ * the namespace is mounted by our own apply, so injecting it would deadlock.
71
+ * The child fiber that consumes it declares it instead (after the mount).
72
+ */
73
+ export const inject = ['slots', 'remote', 'locale'] as const
74
+
75
+ /** Plugin identity: the factory handoff returns this plus the exports above. */
76
+ export const name = 'dsh-git-ui'
77
+
78
+ /**
79
+ * Plugin body: register copy, mount the gitInfo Remote, then host the header
80
+ * utility in a child fiber that may legitimately access `remote.gitInfo`.
81
+ */
82
+ export async function apply(ctx: ClientContext): Promise<void> {
83
+ ctx.effect(() => ctx.locale.register('git', { zh, en }), 'dsh-git-ui: dictionaries')
84
+
85
+ // Mount first — the namespace service only exists after this resolves.
86
+ await ctx.remote.$mount(gitInfoRemote)
87
+
88
+ const controllers = new Map<string, GitController>()
89
+ const faces = new Map<string, GitInjected>()
90
+
91
+ // Consumer fiber: `remote.gitInfo` is declared in ITS inject list, so the
92
+ // access inside the controller factory is legal; the service is already
93
+ // provided by the mount above, so activation does not wait (no deadlock).
94
+ const child = ctx.plugin({
95
+ name: 'dsh-git-ui:git',
96
+ inject: ['slots', 'remote.gitInfo'],
97
+ apply: (sub) => {
98
+ const controllerFor = (sessionId: string): GitController => {
99
+ let controller = controllers.get(sessionId)
100
+ if (controller === undefined) {
101
+ controller = new GitController(sub.remote.gitInfo, sessionId)
102
+ controllers.set(sessionId, controller)
103
+ }
104
+ return controller
105
+ }
106
+
107
+ sub.slots.inject('conversation.session.header.utilities', () => {
108
+ const dispose = sub.slots.register({
109
+ name: 'conversation.session.header.utilities',
110
+ id: 'git',
111
+ order: 10,
112
+ locale: 'git',
113
+ inject: (sessionId): GitInjected => {
114
+ // Per-session stable face: the slot runtime may re-invoke the
115
+ // inject factory on every render, and components depend on the
116
+ // `refresh` reference staying stable (a fresh arrow function per
117
+ // call would re-run mount effects and loop: refresh → view
118
+ // change → re-render → new refresh → refresh …). Cache the face
119
+ // so the same controller (and its bound refresh) is always
120
+ // handed out per session.
121
+ let face = faces.get(sessionId)
122
+ if (face === undefined) {
123
+ const controller = controllerFor(sessionId)
124
+ face = {
125
+ hooks: { git: controller as GitInjected['hooks']['git'] },
126
+ refresh: () => controller.refresh(),
127
+ }
128
+ faces.set(sessionId, face)
129
+ }
130
+ return face
131
+ },
132
+ }, GitPill)
133
+ return () => {
134
+ dispose()
135
+ for (const controller of controllers.values()) controller.dispose()
136
+ controllers.clear()
137
+ faces.clear()
138
+ }
139
+ })
140
+ },
141
+ })
142
+ await child
143
+
144
+ // A reconnect can only invalidate what was already read; a cold Session
145
+ // stays cold until something asks for it.
146
+ ctx.on('connection/reset', () => {
147
+ for (const controller of controllers.values()) controller.resync()
148
+ })
149
+ }
@@ -0,0 +1,55 @@
1
+ /** `git` namespace dictionaries (zh is the key-set source of truth). */
2
+
3
+ export const zh = {
4
+ 'pill.branch': '分支',
5
+ 'pill.detached': '游离 HEAD',
6
+ 'pill.noCommits': '无提交',
7
+ 'pill.noRepo': '无 Git 仓库',
8
+ 'pill.unavailable': 'Git 不可用',
9
+ 'popup.title': 'Git 状态',
10
+ 'popup.root': '仓库根目录',
11
+ 'popup.staged': '已暂存',
12
+ 'popup.modified': '已修改',
13
+ 'popup.untracked': '未跟踪',
14
+ 'popup.ahead': '领先',
15
+ 'popup.behind': '落后',
16
+ 'popup.recentCommits': '最近提交',
17
+ 'popup.changes': '变更文件',
18
+ 'popup.changesTruncated': '仅显示前 {count} 条',
19
+ 'popup.refresh': '刷新',
20
+ 'popup.checkedAt': '检查于 {time}',
21
+ 'popup.empty': '工作区干净',
22
+ 'popup.emptyCommits': '暂无提交',
23
+ 'time.justNow': '刚刚',
24
+ 'time.minutesAgo': '{n} 分钟前',
25
+ 'time.hoursAgo': '{n} 小时前',
26
+ 'time.daysAgo': '{n} 天前',
27
+ } satisfies Record<string, string>
28
+
29
+ export type GitKey = keyof typeof zh
30
+
31
+ export const en: Record<GitKey, string> = {
32
+ 'pill.branch': 'branch',
33
+ 'pill.detached': 'detached HEAD',
34
+ 'pill.noCommits': 'no commits',
35
+ 'pill.noRepo': 'No Git repository',
36
+ 'pill.unavailable': 'Git unavailable',
37
+ 'popup.title': 'Git status',
38
+ 'popup.root': 'Repository root',
39
+ 'popup.staged': 'staged',
40
+ 'popup.modified': 'modified',
41
+ 'popup.untracked': 'untracked',
42
+ 'popup.ahead': 'ahead',
43
+ 'popup.behind': 'behind',
44
+ 'popup.recentCommits': 'Recent commits',
45
+ 'popup.changes': 'Changed files',
46
+ 'popup.changesTruncated': 'Showing first {count} entries',
47
+ 'popup.refresh': 'Refresh',
48
+ 'popup.checkedAt': 'Checked at {time}',
49
+ 'popup.empty': 'Working tree clean',
50
+ 'popup.emptyCommits': 'No commits yet',
51
+ 'time.justNow': 'just now',
52
+ 'time.minutesAgo': '{n}m ago',
53
+ 'time.hoursAgo': '{n}h ago',
54
+ 'time.daysAgo': '{n}d ago',
55
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Hand-written Typert Remote contribution for the gitInfo namespace.
3
+ *
4
+ * The host side exposes `gitInfo/snapshot` through SRC discovery (decorator
5
+ * metadata); the browser side must mount an equivalent strict contribution
6
+ * (requireStrictDescriptor enforces zod codecs), so the schemas here mirror
7
+ * `src/host/types.ts` by hand. `tests/client/remote.spec.ts` keeps the two in
8
+ * sync by parsing host-typed samples through these schemas.
9
+ */
10
+ import { z } from 'zod'
11
+ import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
12
+
13
+ export const gitCommitSchema = z.object({
14
+ hash: z.string(),
15
+ shortHash: z.string(),
16
+ subject: z.string(),
17
+ author: z.string(),
18
+ dateIso: z.string(),
19
+ })
20
+
21
+ export const gitChangeSchema = z.object({
22
+ path: z.string(),
23
+ status: z.enum(['added', 'modified', 'deleted', 'renamed', 'untracked', 'conflicted', 'typechange']),
24
+ staged: z.boolean(),
25
+ })
26
+
27
+ export const gitSnapshotSchema = z.object({
28
+ root: z.string(),
29
+ branch: z.string().nullable(),
30
+ head: z.string().nullable(),
31
+ unborn: z.boolean(),
32
+ dirty: z.boolean(),
33
+ staged: z.number(),
34
+ modified: z.number(),
35
+ untracked: z.number(),
36
+ ahead: z.number(),
37
+ behind: z.number(),
38
+ lastCommit: gitCommitSchema.nullable(),
39
+ recentCommits: z.array(gitCommitSchema),
40
+ changes: z.array(gitChangeSchema),
41
+ truncated: z.boolean(),
42
+ refreshIntervalMs: z.number(),
43
+ checkedAt: z.number(),
44
+ })
45
+
46
+ export const gitSnapshotFailureSchema = z.discriminatedUnion('code', [
47
+ z.object({ code: z.literal('session-not-found'), sessionId: z.string() }),
48
+ z.object({ code: z.literal('cwd-unavailable'), sessionId: z.string() }),
49
+ z.object({ code: z.literal('path-not-found'), path: z.string() }),
50
+ z.object({ code: z.literal('git-unavailable'), detail: z.string() }),
51
+ z.object({ code: z.literal('timeout') }),
52
+ z.object({ code: z.literal('not-a-git-repo') }),
53
+ ])
54
+
55
+ export const gitSnapshotResultSchema = z.discriminatedUnion('ok', [
56
+ z.object({ ok: z.literal(true), value: gitSnapshotSchema }),
57
+ z.object({ ok: z.literal(false), error: gitSnapshotFailureSchema }),
58
+ ])
59
+
60
+ export const gitSnapshotRequestSchema = z.object({
61
+ sessionId: z.string(),
62
+ })
63
+
64
+ /** The contribution mounted into `ctx.remote` by the client plugin body. */
65
+ export const gitInfoRemote: TypertRemoteContribution = {
66
+ package: 'dsh-git-ui',
67
+ descriptors: [
68
+ {
69
+ id: 'dsh-git-ui#gitInfo/snapshot',
70
+ service: 'gitInfo',
71
+ namespace: 'gitInfo',
72
+ method: 'snapshot',
73
+ invocation: { kind: 'direct' },
74
+ // Mirrors the host SRC descriptor: the trailing `signal` parameter is
75
+ // the cancellation slot, so an aborted call also stops the host-side
76
+ // git runs instead of letting them finish.
77
+ cancellation: { parameter: 'signal' },
78
+ parameters: [
79
+ {
80
+ name: 'request',
81
+ wire: 'request',
82
+ source: 'json',
83
+ codec: {
84
+ mode: 'strict',
85
+ typeSymbol: 'dsh-git-ui/types#GitSnapshotRequest',
86
+ schema: gitSnapshotRequestSchema,
87
+ },
88
+ },
89
+ ],
90
+ result: {
91
+ mode: 'strict',
92
+ typeSymbol: 'dsh-git-ui/types#GitSnapshotResult',
93
+ schema: gitSnapshotResultSchema,
94
+ },
95
+ },
96
+ ],
97
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Style constants for the git widget.
3
+ *
4
+ * Colors resolve exclusively through the host theme's `--dsw-alias-*` design
5
+ * tokens (ui-theme design-platform.css), so the widget follows the active
6
+ * light/dark theme automatically — never hard-coded surfaces. Layout mirrors
7
+ * the official primitives: the pill is a 24px `Pill`-style chip, the popup a
8
+ * `Menu`-surface card (border-l1, radius 12, shadow-lv3).
9
+ *
10
+ * The popup is a fixed-position card portaled to document.body (see
11
+ * GitPill.tsx) — it must never participate in header layout, otherwise it
12
+ * grows the header and distorts it.
13
+ */
14
+ import type { CSSProperties } from 'react'
15
+
16
+ /** Pill: compact branch chip, right-aligned in the session header. */
17
+ export const pill: CSSProperties = {
18
+ display: 'inline-flex',
19
+ alignItems: 'center',
20
+ gap: 6,
21
+ height: 24,
22
+ padding: '0 8px',
23
+ border: 0,
24
+ borderRadius: 12,
25
+ font: 'inherit',
26
+ fontSize: 12,
27
+ lineHeight: '18px',
28
+ whiteSpace: 'nowrap',
29
+ cursor: 'pointer',
30
+ color: 'var(--dsw-alias-label-secondary)',
31
+ background: 'var(--dsw-alias-bg-layer-2)',
32
+ }
33
+
34
+ /** Dimmed pill for degraded states (no repo / unavailable). */
35
+ export const pillDimmed: CSSProperties = {
36
+ ...pill,
37
+ opacity: 0.55,
38
+ cursor: 'default',
39
+ }
40
+
41
+ /** Status dot: green when clean, warn when the tree is dirty. */
42
+ export const dot: CSSProperties = {
43
+ flex: 'none',
44
+ width: 8,
45
+ height: 8,
46
+ borderRadius: 999,
47
+ background: 'var(--dsw-alias-state-success-primary)',
48
+ }
49
+
50
+ export const dotDirty: CSSProperties = {
51
+ ...dot,
52
+ background: 'var(--dsw-alias-state-warn-primary)',
53
+ }
54
+
55
+ /** Popup panel: fixed-position card (top/left come from the anchor math). */
56
+ export const popup: CSSProperties = {
57
+ position: 'fixed',
58
+ zIndex: 1100,
59
+ boxSizing: 'border-box',
60
+ width: 340,
61
+ maxHeight: 420,
62
+ overflowY: 'auto',
63
+ padding: '10px 12px',
64
+ fontSize: 12,
65
+ lineHeight: '18px',
66
+ background: 'var(--dsw-alias-bg-layer-3)',
67
+ color: 'var(--dsw-alias-label-primary)',
68
+ border: '1px solid var(--dsw-alias-border-l1)',
69
+ borderRadius: 12,
70
+ boxShadow: 'var(--dsw-shadow-lv3)',
71
+ }
72
+
73
+ export const popupTitle: CSSProperties = {
74
+ margin: 0,
75
+ fontSize: 13,
76
+ fontWeight: 600,
77
+ marginBottom: 6,
78
+ }
79
+
80
+ export const rootLine: CSSProperties = {
81
+ overflow: 'hidden',
82
+ textOverflow: 'ellipsis',
83
+ whiteSpace: 'nowrap',
84
+ color: 'var(--dsw-alias-label-tertiary)',
85
+ marginBottom: 8,
86
+ }
87
+
88
+ export const countGrid: CSSProperties = {
89
+ display: 'grid',
90
+ gridTemplateColumns: 'repeat(5, 1fr)',
91
+ gap: 6,
92
+ marginBottom: 8,
93
+ }
94
+
95
+ export const countCell: CSSProperties = {
96
+ textAlign: 'center',
97
+ padding: '4px 2px',
98
+ borderRadius: 8,
99
+ background: 'var(--dsw-alias-bg-layer-2)',
100
+ }
101
+
102
+ export const countValue: CSSProperties = {
103
+ fontWeight: 600,
104
+ fontSize: 14,
105
+ color: 'var(--dsw-alias-label-primary)',
106
+ }
107
+
108
+ export const countLabel: CSSProperties = {
109
+ fontSize: 11,
110
+ color: 'var(--dsw-alias-label-tertiary)',
111
+ }
112
+
113
+ export const sectionTitle: CSSProperties = {
114
+ margin: '8px 0 4px',
115
+ fontSize: 11,
116
+ fontWeight: 600,
117
+ color: 'var(--dsw-alias-label-secondary)',
118
+ textTransform: 'uppercase',
119
+ letterSpacing: 0.4,
120
+ }
121
+
122
+ export const commitRow: CSSProperties = {
123
+ display: 'flex',
124
+ gap: 6,
125
+ alignItems: 'baseline',
126
+ padding: '2px 0',
127
+ }
128
+
129
+ export const commitHash: CSSProperties = {
130
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
131
+ fontSize: 11,
132
+ color: 'var(--dsw-alias-label-secondary)',
133
+ }
134
+
135
+ export const commitSubject: CSSProperties = {
136
+ overflow: 'hidden',
137
+ textOverflow: 'ellipsis',
138
+ whiteSpace: 'nowrap',
139
+ flex: 1,
140
+ color: 'var(--dsw-alias-label-primary)',
141
+ }
142
+
143
+ export const commitMeta: CSSProperties = {
144
+ fontSize: 11,
145
+ color: 'var(--dsw-alias-label-tertiary)',
146
+ whiteSpace: 'nowrap',
147
+ }
148
+
149
+ export const changeRow: CSSProperties = {
150
+ display: 'flex',
151
+ gap: 6,
152
+ alignItems: 'center',
153
+ padding: '1px 0',
154
+ borderRadius: 6,
155
+ }
156
+
157
+ export const changeChip: CSSProperties = {
158
+ width: 20,
159
+ textAlign: 'center',
160
+ borderRadius: 4,
161
+ fontSize: 11,
162
+ fontWeight: 600,
163
+ flexShrink: 0,
164
+ color: '#fff',
165
+ }
166
+
167
+ export const changePath: CSSProperties = {
168
+ overflow: 'hidden',
169
+ textOverflow: 'ellipsis',
170
+ whiteSpace: 'nowrap',
171
+ flex: 1,
172
+ color: 'var(--dsw-alias-label-primary)',
173
+ }
174
+
175
+ export const footerRow: CSSProperties = {
176
+ display: 'flex',
177
+ alignItems: 'center',
178
+ justifyContent: 'space-between',
179
+ gap: 8,
180
+ marginTop: 10,
181
+ }
182
+
183
+ export const checkedAt: CSSProperties = {
184
+ fontSize: 11,
185
+ color: 'var(--dsw-alias-label-tertiary)',
186
+ }
187
+
188
+ /** Text button inside the popup (refresh). */
189
+ export const refreshButton: CSSProperties = {
190
+ border: 0,
191
+ background: 'transparent',
192
+ padding: '2px 6px',
193
+ fontSize: 12,
194
+ cursor: 'pointer',
195
+ color: 'var(--dsw-alias-label-secondary)',
196
+ borderRadius: 6,
197
+ font: 'inherit',
198
+ }
199
+
200
+ export const emptyNote: CSSProperties = {
201
+ color: 'var(--dsw-alias-label-tertiary)',
202
+ padding: '4px 0',
203
+ }
204
+
205
+ /**
206
+ * Global styles for pseudo-class interactions (inline styles can't express
207
+ * :hover/:focus-visible). Injected once per document under a plugin-scoped id.
208
+ */
209
+ const GLOBAL_CSS_ID = 'dsh-git-ui/styles'
210
+
211
+ const globalCss = [
212
+ '.dsh-git-ui__pill:hover { background: var(--dsw-alias-interactive-bg-hover); }',
213
+ '.dsh-git-ui__pill:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: -2px; }',
214
+ '.dsh-git-ui__row { border-radius: 6px; }',
215
+ '.dsh-git-ui__row:hover { background: var(--dsw-alias-interactive-bg-hover); }',
216
+ '.dsh-git-ui__refresh:hover { color: var(--dsw-alias-state-business-primary); }',
217
+ '.dsh-git-ui__refresh:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }',
218
+ ].join('\n')
219
+
220
+ /** Ensure the global interaction styles exist (idempotent; browser only). */
221
+ export function ensureGlobalCss(): void {
222
+ if (typeof document === 'undefined') return
223
+ if (document.getElementById(GLOBAL_CSS_ID) !== null) return
224
+ const tag = document.createElement('style')
225
+ tag.id = GLOBAL_CSS_ID
226
+ tag.dataset.plugin = 'dsh-git-ui'
227
+ tag.textContent = globalCss
228
+ document.head.appendChild(tag)
229
+ }