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
@@ -1,150 +1,54 @@
1
1
  /**
2
- * Git status widget client half entry.
2
+ * dsh-git-ui client 入口:Cordis 约定字段 + 委托。
3
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.)
4
+ * 导出 Cordis 插件约定字段(`inject` / `name` / `apply`);`apply` 内部
5
+ * Cordis Context 适配为 `ClientPlatform` 接口后委托给纯业务函数。
6
+ * dsh 适配逻辑在 `adapters/dsh/client-adapter.ts`。
20
7
  */
21
- import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
22
- import { GitController, type GitView, type GitObservable, type GitRemoteLike } from './controller.ts'
8
+ import { createElement, type ReactNode } from 'react'
9
+ import { GitPill, type GitPillProps } from './GitPill.tsx'
10
+ import { UIPrimitivesProvider } from '../contracts/ui-context.tsx'
11
+ import { dshUIPrimitives } from '../adapters/dsh/ui-primitives.ts'
12
+ import { adaptDshClientContext, type DshClientContext } from '../adapters/dsh/client-adapter.ts'
13
+ import { activatePlugin, type PluginDependencies } from '../contracts/plugin-activation.ts'
14
+ import { GitController } from './controller.ts'
23
15
  import { gitInfoRemote } from './remote.ts'
24
- import { GitPill, type GitInjected } from './GitPill.tsx'
25
16
  import { en, zh } from './locales.ts'
26
17
 
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
18
  /**
67
- * Required services: slot registry, Remote base (gateway client), and copy.
19
+ * Cordis 插件约定:声明需要的服务。
68
20
  *
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).
21
+ * `remote.gitInfo` 不在此列出——它由我们自己的 apply 挂载,
22
+ * 若在 inject 中声明会导致死锁(服务在 apply 执行后才存在)。
72
23
  */
73
24
  export const inject = ['slots', 'remote', 'locale'] as const
74
25
 
75
- /** Plugin identity: the factory handoff returns this plus the exports above. */
26
+ /** 插件标识。 */
76
27
  export const name = 'dsh-git-ui'
77
28
 
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>()
29
+ /** 插件入口使用的 UI 基础组件实现(dsh 宿主提供)。 */
30
+ const uiPrimitives = dshUIPrimitives
90
31
 
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/run) 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
- run: (action) => controller.run(action),
128
- }
129
- faces.set(sessionId, face)
130
- }
131
- return face
132
- },
133
- }, GitPill)
134
- return () => {
135
- dispose()
136
- for (const controller of controllers.values()) controller.dispose()
137
- controllers.clear()
138
- faces.clear()
139
- }
140
- })
141
- },
142
- })
143
- await child
32
+ /** 包裹 GitPill,注入 UI 基础组件上下文。 */
33
+ function GitPillWithUI(props: GitPillProps): ReactNode {
34
+ return createElement(UIPrimitivesProvider, { value: uiPrimitives }, createElement(GitPill, props))
35
+ }
144
36
 
145
- // A reconnect can only invalidate what was already read; a cold Session
146
- // stays cold until something asks for it.
147
- ctx.on('connection/reset', () => {
148
- for (const controller of controllers.values()) controller.resync()
149
- })
37
+ /**
38
+ * Cordis 插件入口:适配 Context 后委托给纯业务函数。
39
+ *
40
+ * dsh 升级导致插件 API 变更时,只需更新 `adapters/dsh/client-adapter.ts`
41
+ * 中的 `DshClientContext` 接口和 `adaptDshClientContext` 实现。
42
+ */
43
+ export async function apply(ctx: DshClientContext): Promise<void> {
44
+ const platform = adaptDshClientContext(ctx)
45
+
46
+ // 提供插件依赖
47
+ const deps: PluginDependencies = {
48
+ remoteContribution: gitInfoRemote,
49
+ locales: { zh, en },
50
+ createController: (remote, sessionId) => new GitController(remote, sessionId),
51
+ }
52
+
53
+ await activatePlugin(platform, deps, GitPillWithUI)
150
54
  }
@@ -21,6 +21,8 @@ export const zh = {
21
21
  'popup.empty': '工作区干净',
22
22
  'popup.emptyCommits': '暂无提交',
23
23
  'center.open': '打开 Git 中心',
24
+ 'error.localChangesBlock': '无法切换分支:工作区有未提交的变更会被目标分支覆盖。请先提交或暂存(stash)这些变更,然后再切换。',
25
+ 'error.handleChanges': '处理变更',
24
26
  'center.title': 'Git 中心',
25
27
  'center.staged': '已暂存',
26
28
  'center.unstaged': '未暂存',
@@ -37,12 +39,72 @@ export const zh = {
37
39
  'center.commitHint': '勾选文件时仅提交所选;未勾选时提交全部已暂存内容',
38
40
  'center.commitSelected': '已选 {count} 个文件',
39
41
  'center.empty': '工作区干净,无需操作',
42
+ 'center.untrackedFiles': '未进行版本管理的文件',
43
+ 'center.selectFileDiff': '选择文件以对照查看更改',
40
44
  'center.done': '操作成功',
41
45
  'center.busy': '执行中…',
46
+ 'center.changes': '变更',
47
+ 'center.history': '历史',
48
+ 'center.branches': '分支',
49
+ 'center.noCommits': '暂无提交',
50
+ 'history.commit': '提交',
51
+ 'history.hash': '哈希',
52
+ 'history.author': '作者',
53
+ 'history.time': '时间',
54
+ 'history.allBranches': '全部分支',
55
+ 'history.tags': '标签',
56
+ 'history.search': '搜索文本或哈希',
57
+ 'history.branch': '分支',
58
+ 'history.allUsers': '全部用户',
59
+ 'history.allTime': '全部时间',
60
+ 'history.today': '今天',
61
+ 'history.last7d': '近 7 天',
62
+ 'history.last30d': '近 30 天',
63
+ 'history.last90d': '近 90 天',
64
+ 'right.selectCommit': '选择要查看更改的提交',
65
+ 'right.commitDetails': '提交详细信息',
66
+ 'right.files': '文件变更',
67
+ 'right.expandAll': '全部展开',
68
+ 'right.collapseAll': '全部收起',
69
+ 'history.searchTree': '分支或标签',
70
+ 'center.close': '关闭',
71
+ 'center.selectCommit': '选择一条提交以查看详情与差异',
72
+ 'center.loading': '加载…',
73
+ 'center.commitFull': '提交信息',
74
+ 'center.diffEmpty': '无文件变更',
75
+ 'center.createBranch': '新建分支',
76
+ 'center.branchName': '分支名',
77
+ 'center.branchFrom': '基于',
78
+ 'center.createAndSwitch': '创建并切换',
79
+ 'center.switchTo': '切换',
80
+ 'center.localBranches': '本地分支',
81
+ 'center.remoteBranches': '远程分支',
82
+ 'center.currentBranch': '当前分支',
42
83
  'time.justNow': '刚刚',
43
84
  'time.minutesAgo': '{n} 分钟前',
44
85
  'time.hoursAgo': '{n} 小时前',
45
86
  'time.daysAgo': '{n} 天前',
87
+ 'time.today': '今天',
88
+ 'time.yesterday': '昨天',
89
+ 'changes.groupStaged': '已暂存更改',
90
+ 'changes.groupUnstaged': '更改',
91
+ 'changes.groupUnversioned': '未版本控制的文件',
92
+ 'changes.actionDiff': '对照查看',
93
+ 'changes.dir': '目录(不可对照查看)',
94
+ 'changes.selectAll': '全选',
95
+ 'diff.baseStaged': '暂存区',
96
+ 'diff.baseWorktree': '工作区',
97
+ 'diff.prev': '上一个更改',
98
+ 'diff.next': '下一个更改',
99
+ 'diff.truncated': '差异过大,仅显示前 {count} 行',
100
+ 'diff.foldCollapsed': '… {n} 行未变更',
101
+ 'diff.binary': '二进制文件,无法显示差异',
102
+ 'changes.listTruncated': '仅显示前 {count} 个,共 {total} 个变更',
103
+ 'right.noMessage': '无提交信息',
104
+ 'history.noResults': '未找到匹配的提交',
105
+ 'center.fetch': '拉取远程',
106
+ 'center.fetching': '拉取中…',
107
+ 'center.fetchDone': '已同步远程',
46
108
  } satisfies Record<string, string>
47
109
 
48
110
  export type GitKey = keyof typeof zh
@@ -68,6 +130,8 @@ export const en: Record<GitKey, string> = {
68
130
  'popup.empty': 'Working tree clean',
69
131
  'popup.emptyCommits': 'No commits yet',
70
132
  'center.open': 'Open Git center',
133
+ 'error.localChangesBlock': 'Cannot switch branches: uncommitted local changes would be overwritten. Commit or stash them first.',
134
+ 'error.handleChanges': 'Review changes',
71
135
  'center.title': 'Git center',
72
136
  'center.staged': 'staged',
73
137
  'center.unstaged': 'unstaged',
@@ -84,10 +148,70 @@ export const en: Record<GitKey, string> = {
84
148
  'center.commitHint': 'Selected files are committed; otherwise everything staged is committed',
85
149
  'center.commitSelected': '{count} file(s) selected',
86
150
  'center.empty': 'Working tree clean — nothing to do',
151
+ 'center.untrackedFiles': 'Unversioned files',
152
+ 'center.selectFileDiff': 'Select a file to compare changes',
87
153
  'center.done': 'Done',
88
154
  'center.busy': 'Working…',
155
+ 'center.changes': 'Changes',
156
+ 'center.history': 'History',
157
+ 'center.branches': 'Branches',
158
+ 'center.noCommits': 'No commits yet',
159
+ 'history.commit': 'Commit',
160
+ 'history.hash': 'Hash',
161
+ 'history.author': 'Author',
162
+ 'history.time': 'Time',
163
+ 'history.allBranches': 'All branches',
164
+ 'history.tags': 'Tags',
165
+ 'history.search': 'Text or hash',
166
+ 'history.branch': 'Branch',
167
+ 'history.allUsers': 'All users',
168
+ 'history.allTime': 'All time',
169
+ 'history.today': 'Today',
170
+ 'history.last7d': 'Last 7 days',
171
+ 'history.last30d': 'Last 30 days',
172
+ 'history.last90d': 'Last 90 days',
173
+ 'right.selectCommit': 'Select a commit to view its changes',
174
+ 'right.commitDetails': 'Commit details',
175
+ 'right.files': 'Changed files',
176
+ 'right.expandAll': 'Expand all',
177
+ 'right.collapseAll': 'Collapse all',
178
+ 'history.searchTree': 'Branch or tag',
179
+ 'center.close': 'Close',
180
+ 'center.selectCommit': 'Select a commit to view details and diffs',
181
+ 'center.loading': 'Loading…',
182
+ 'center.commitFull': 'Commit details',
183
+ 'center.diffEmpty': 'No file changes',
184
+ 'center.createBranch': 'New branch',
185
+ 'center.branchName': 'Branch name',
186
+ 'center.branchFrom': 'From',
187
+ 'center.createAndSwitch': 'Create & switch',
188
+ 'center.switchTo': 'Switch',
189
+ 'center.localBranches': 'Local branches',
190
+ 'center.remoteBranches': 'Remote branches',
191
+ 'center.currentBranch': 'Current branch',
89
192
  'time.justNow': 'just now',
90
193
  'time.minutesAgo': '{n}m ago',
91
194
  'time.hoursAgo': '{n}h ago',
92
195
  'time.daysAgo': '{n}d ago',
196
+ 'time.today': 'Today',
197
+ 'time.yesterday': 'Yesterday',
198
+ 'changes.groupStaged': 'Staged Changes',
199
+ 'changes.groupUnstaged': 'Changes',
200
+ 'changes.groupUnversioned': 'Unversioned Files',
201
+ 'changes.actionDiff': 'Show diff',
202
+ 'changes.dir': 'Directory (no diff)',
203
+ 'changes.selectAll': 'Select all',
204
+ 'diff.baseStaged': 'Staged',
205
+ 'diff.baseWorktree': 'Worktree',
206
+ 'diff.prev': 'Previous change',
207
+ 'diff.next': 'Next change',
208
+ 'diff.truncated': 'Diff too large, showing first {count} lines',
209
+ 'diff.foldCollapsed': '… {n} unchanged lines',
210
+ 'diff.binary': 'Binary file — diff unavailable',
211
+ 'changes.listTruncated': 'Showing first {count} of {total} changes',
212
+ 'right.noMessage': 'No commit message',
213
+ 'history.noResults': 'No matching commits found',
214
+ 'center.fetch': 'Fetch remote',
215
+ 'center.fetching': 'Fetching…',
216
+ 'center.fetchDone': 'Remote synced',
93
217
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * popup 外部点击关闭判定(纯函数,不依赖 React——测试环境无宿主提供的 react)。
3
+ *
4
+ * 回归:popup 头部 SelectMenu 下拉 portaled 到 body(class `dsh-git-ui__select-menu`),
5
+ * 点击其选项时 mousedown target 不在 popup 卡片内——旧实现误判为「点击外部」,
6
+ * popup 在分支切换未及完成时关闭(分支看起来「没切换 + pill 自动关闭」)。
7
+ */
8
+
9
+ /**
10
+ * 外部 mousedown 是否应关闭 popup。命中 wrapper / popup 卡片、或其内部
11
+ * portaled 浮层(`dsh-git-ui__select-menu`)视为内部交互,不关闭;其余外部点击关闭。
12
+ */
13
+ export function shouldClosePopup(target: EventTarget | null, wrap: Node | null, pop: Node | null): boolean {
14
+ if (target === null || !(target instanceof Node)) return true
15
+ if (wrap !== null && wrap.contains(target)) return false
16
+ if (pop !== null && pop.contains(target)) return false
17
+ if (target instanceof Element && target.closest('.dsh-git-ui__select-menu') !== null) return false
18
+ return true
19
+ }
@@ -8,7 +8,7 @@
8
8
  * sync by parsing host-typed samples through these schemas.
9
9
  */
10
10
  import { z } from 'zod'
11
- import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
11
+ import type { RemoteContribution } from '../contracts/client-platform.ts'
12
12
 
13
13
  export const gitCommitSchema = z.object({
14
14
  hash: z.string(),
@@ -18,10 +18,28 @@ export const gitCommitSchema = z.object({
18
18
  dateIso: z.string(),
19
19
  })
20
20
 
21
+ /** GraphCommit:带父提交哈希与 ref 装饰的提交(图渲染 + 分支胶囊)。 */
22
+ export const gitGraphCommitSchema = z.object({
23
+ hash: z.string(),
24
+ shortHash: z.string(),
25
+ subject: z.string(),
26
+ author: z.string(),
27
+ dateIso: z.string(),
28
+ parents: z.array(z.string()),
29
+ refs: z.array(z.object({
30
+ kind: z.enum(['branch', 'remote', 'tag']),
31
+ name: z.string(),
32
+ head: z.boolean(),
33
+ })),
34
+ })
35
+
21
36
  export const gitChangeSchema = z.object({
22
37
  path: z.string(),
23
38
  status: z.enum(['added', 'modified', 'deleted', 'renamed', 'untracked', 'conflicted', 'typechange']),
24
39
  staged: z.boolean(),
40
+ // host 权威目录标记:zod z.object 默认 strip 未知键,漏声明会在 wire 边界
41
+ // 剥掉该字段——展示层目录识别(GitChange.isDirectory)将整体失效。
42
+ isDirectory: z.boolean(),
25
43
  })
26
44
 
27
45
  export const gitSnapshotSchema = z.object({
@@ -74,12 +92,19 @@ export const gitActionSchema = z.discriminatedUnion('kind', [
74
92
  message: z.string(),
75
93
  paths: z.array(z.string()).optional(),
76
94
  }),
95
+ z.object({ kind: z.literal('branch-create'), name: z.string(), from: z.string().optional() }),
96
+ z.object({ kind: z.literal('branch-checkout'), name: z.string() }),
97
+ z.object({ kind: z.literal('branch-delete'), name: z.string(), force: z.boolean().optional() }),
98
+ z.object({ kind: z.literal('fetch') }),
77
99
  ])
78
100
 
79
101
  export const gitOperationErrorSchema = z.object({
102
+ // 与 host GitOperationErrorCode 保持一致:zod z.enum 手工镜像枚举,缺一项
103
+ // 会在 strict 解码时 reject,把可预期业务错误改写为晦涩的 git-error。
80
104
  code: z.enum([
81
105
  'session-not-found', 'cwd-unavailable', 'path-not-found',
82
- 'not-a-git-repo', 'invalid-path', 'git-error', 'timeout',
106
+ 'not-a-git-repo', 'invalid-path', 'invalid-name', 'git-error', 'timeout',
107
+ 'local-changes-block',
83
108
  ]),
84
109
  message: z.string().optional(),
85
110
  })
@@ -94,8 +119,40 @@ export const gitActionRequestSchema = z.object({
94
119
  action: gitActionSchema,
95
120
  })
96
121
 
122
+ /** 一条只读查询(镜像 src/host/types.ts 的 GitQuery)。 */
123
+ export const gitQuerySchema = z.discriminatedUnion('kind', [
124
+ z.object({ kind: z.literal('history'), limit: z.number(), skip: z.number(), ref: z.string().optional(), search: z.string().optional(), author: z.string().optional(), since: z.string().optional() }),
125
+ z.object({ kind: z.literal('diff'), path: z.string(), base: z.enum(['worktree', 'staged']) }),
126
+ z.object({ kind: z.literal('show'), ref: z.string() }),
127
+ z.object({ kind: z.literal('branches') }),
128
+ z.object({ kind: z.literal('tags') }),
129
+ z.object({ kind: z.literal('authors') }),
130
+ ])
131
+
132
+ const gitFileStatSchema = z.object({ path: z.string(), status: z.enum(['added', 'modified', 'deleted', 'renamed', 'untracked', 'conflicted', 'typechange']) })
133
+ const gitBranchSchema = z.object({ name: z.string(), shortHash: z.string().nullable(), ahead: z.number().optional(), behind: z.number().optional() })
134
+
135
+ export const gitQueryResultSchema = z.discriminatedUnion('kind', [
136
+ z.object({ kind: z.literal('history'), commits: z.array(gitGraphCommitSchema), total: z.number() }),
137
+ z.object({ kind: z.literal('diff'), path: z.string(), text: z.string() }),
138
+ z.object({ kind: z.literal('show'), ref: z.string(), commit: gitCommitSchema.nullable(), body: z.string(), stats: z.array(gitFileStatSchema) }),
139
+ z.object({ kind: z.literal('branches'), current: z.string().nullable(), defaultBranch: z.string().nullable(), local: z.array(gitBranchSchema), remote: z.array(gitBranchSchema) }),
140
+ z.object({ kind: z.literal('tags'), tags: z.array(gitBranchSchema) }),
141
+ z.object({ kind: z.literal('authors'), authors: z.array(z.string()) }),
142
+ ])
143
+
144
+ export const gitQueryResponseSchema = z.discriminatedUnion('ok', [
145
+ z.object({ ok: z.literal(true), value: gitQueryResultSchema }),
146
+ z.object({ ok: z.literal(false), error: gitOperationErrorSchema }),
147
+ ])
148
+
149
+ export const gitQueryRequestSchema = z.object({
150
+ sessionId: z.string(),
151
+ query: gitQuerySchema,
152
+ })
153
+
97
154
  /** The contribution mounted into `ctx.remote` by the client plugin body. */
98
- export const gitInfoRemote: TypertRemoteContribution = {
155
+ export const gitInfoRemote: RemoteContribution = {
99
156
  package: 'dsh-git-ui',
100
157
  descriptors: [
101
158
  {
@@ -151,5 +208,30 @@ export const gitInfoRemote: TypertRemoteContribution = {
151
208
  schema: gitActionResultSchema,
152
209
  },
153
210
  },
211
+ {
212
+ id: 'dsh-git-ui#gitInfo/query',
213
+ service: 'gitInfo',
214
+ namespace: 'gitInfo',
215
+ method: 'query',
216
+ invocation: { kind: 'direct' },
217
+ cancellation: { parameter: 'signal' },
218
+ parameters: [
219
+ {
220
+ name: 'request',
221
+ wire: 'request',
222
+ source: 'json',
223
+ codec: {
224
+ mode: 'strict',
225
+ typeSymbol: 'dsh-git-ui/types#GitQueryRequest',
226
+ schema: gitQueryRequestSchema,
227
+ },
228
+ },
229
+ ],
230
+ result: {
231
+ mode: 'strict',
232
+ typeSymbol: 'dsh-git-ui/types#GitQueryResponse',
233
+ schema: gitQueryResponseSchema,
234
+ },
235
+ },
154
236
  ],
155
237
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 自绘下拉选择器(平台 Menu 规范):取代原生 select,明暗主题与系统样式统一。
3
+ * 从 GitCenter.tsx 提取为共享组件,供 GitPill 分支管理复用。
4
+ */
5
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
6
+ import { createPortal } from 'react-dom'
7
+ import type { CSSProperties, JSX } from 'react'
8
+ import * as css from './styles.ts'
9
+
10
+ export interface SelectMenuProps {
11
+ value: string
12
+ options: readonly { value: string; label: string }[]
13
+ onSelect: (value: string) => void
14
+ ariaLabel: string
15
+ /** 按钮自定义样式(默认 toolbarSelect;头部内联用时传无框变体)。 */
16
+ buttonStyle?: CSSProperties
17
+ }
18
+
19
+ export function SelectMenu({
20
+ value, options, onSelect, ariaLabel, buttonStyle,
21
+ }: SelectMenuProps): JSX.Element {
22
+ const [open, setOpen] = useState(false)
23
+ const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null)
24
+ const btnRef = useRef<HTMLButtonElement>(null)
25
+ const menuRef = useRef<HTMLDivElement>(null)
26
+ const current = options.find((o) => o.value === value)
27
+
28
+ useLayoutEffect(() => {
29
+ if (!open) {
30
+ setPos(null)
31
+ return
32
+ }
33
+ const place = (): void => {
34
+ const r = btnRef.current?.getBoundingClientRect()
35
+ if (r) setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 140) })
36
+ }
37
+ place()
38
+ window.addEventListener('resize', place)
39
+ return () => window.removeEventListener('resize', place)
40
+ }, [open])
41
+
42
+ useEffect(() => {
43
+ if (!open) return
44
+ const onDown = (e: MouseEvent): void => {
45
+ const target = e.target as Node
46
+ if (btnRef.current?.contains(target) ?? false) return
47
+ if (menuRef.current?.contains(target) ?? false) return
48
+ setOpen(false)
49
+ }
50
+ const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false) }
51
+ document.addEventListener('mousedown', onDown)
52
+ document.addEventListener('keydown', onKey)
53
+ return () => {
54
+ document.removeEventListener('mousedown', onDown)
55
+ document.removeEventListener('keydown', onKey)
56
+ }
57
+ }, [open])
58
+
59
+ return (
60
+ <>
61
+ <button
62
+ ref={btnRef}
63
+ type="button"
64
+ className="dsh-git-ui__toolbar-select"
65
+ style={buttonStyle ?? css.toolbarSelect}
66
+ aria-label={ariaLabel}
67
+ aria-haspopup="listbox"
68
+ aria-expanded={open}
69
+ onClick={() => setOpen(!open)}
70
+ >
71
+ <span style={css.selectLabel}>{current?.label ?? ''}</span>
72
+ <span
73
+ aria-hidden="true"
74
+ style={{
75
+ display: 'inline-flex',
76
+ flex: 'none',
77
+ transition: 'transform var(--ds-transition-duration-fast) var(--ds-ease-in-out)',
78
+ transform: open ? 'rotate(180deg)' : 'none',
79
+ }}
80
+ >
81
+ <svg width={10} height={10} viewBox="0 0 10 10">
82
+ <path d="M1.5 3 L5 7 L8.5 3" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
83
+ </svg>
84
+ </span>
85
+ </button>
86
+ {open && pos !== null && createPortal(
87
+ <div
88
+ ref={menuRef}
89
+ role="listbox"
90
+ aria-label={ariaLabel}
91
+ className="dsh-git-ui__select-menu"
92
+ style={{ ...css.selectMenu, top: pos.top, left: pos.left, minWidth: pos.width }}
93
+ >
94
+ {options.map((o) => (
95
+ <button
96
+ key={o.value}
97
+ type="button"
98
+ role="option"
99
+ aria-selected={o.value === value}
100
+ style={o.value === value ? { ...css.selectOption, ...css.selectOptionActive } : css.selectOption}
101
+ className="dsh-git-ui__row"
102
+ onClick={() => { onSelect(o.value); setOpen(false) }}
103
+ >
104
+ <span style={{ ...css.treeCaret, visibility: o.value === value ? 'visible' : 'hidden' }} aria-hidden="true">✓</span>
105
+ <span style={css.selectLabel}>{o.label}</span>
106
+ </button>
107
+ ))}
108
+ </div>,
109
+ document.body,
110
+ )}
111
+ </>
112
+ )
113
+ }