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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/README.zh.md +110 -0
- package/cordis.patch.yml +14 -0
- package/lib/.keep +0 -0
- package/lib/client.js +71 -0
- package/lib/client.js.map +7 -0
- package/lib/host/core.d.ts +53 -0
- package/lib/host/git.d.ts +89 -0
- package/lib/host/index.d.ts +23 -0
- package/lib/host/index.js +380 -0
- package/lib/host/index.js.map +7 -0
- package/lib/host/parser.d.ts +57 -0
- package/lib/host/types.d.ts +74 -0
- package/package.json +80 -0
- package/src/client/GitPill.tsx +354 -0
- package/src/client/controller.ts +158 -0
- package/src/client/index.ts +149 -0
- package/src/client/locales.ts +55 -0
- package/src/client/remote.ts +97 -0
- package/src/client/styles.ts +229 -0
- package/src/client/turn-signal.ts +33 -0
- package/src/host/core.ts +199 -0
- package/src/host/git.ts +152 -0
- package/src/host/index.ts +80 -0
- package/src/host/parser.ts +176 -0
- package/src/host/types.ts +74 -0
|
@@ -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
|
+
}
|