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.
- package/README.md +55 -20
- package/README.zh.md +55 -21
- package/cordis.patch.yml +1 -1
- package/lib/client.js +36 -35
- package/lib/client.js.map +4 -4
- package/lib/contracts/host-endpoints.d.ts +27 -0
- package/lib/host/actions.d.ts +22 -2
- package/lib/host/core.d.ts +7 -1
- package/lib/host/index.d.ts +25 -15
- package/lib/host/index.js +419 -53
- package/lib/host/index.js.map +4 -4
- package/lib/host/parser.d.ts +37 -1
- package/lib/host/queries.d.ts +17 -0
- package/lib/host/types.d.ts +131 -1
- package/package.json +1 -1
- package/src/adapters/dsh/client-adapter.ts +120 -0
- package/src/adapters/dsh/types/cordis.d.ts +48 -0
- package/src/adapters/dsh/types/typert-protocol.d.ts +81 -0
- package/src/adapters/dsh/types/ui-primitives.d.ts +45 -0
- package/src/adapters/dsh/ui-primitives.ts +16 -0
- package/src/client/GitCenter.tsx +1414 -149
- package/src/client/GitPill.tsx +282 -67
- package/src/client/changes-diff.ts +63 -0
- package/src/client/controller.ts +34 -31
- package/src/client/error-text.ts +21 -0
- package/src/client/file-tree.ts +101 -0
- package/src/client/git-graph.ts +188 -0
- package/src/client/icons.tsx +292 -0
- package/src/client/index.ts +38 -134
- package/src/client/locales.ts +124 -0
- package/src/client/popup-close.ts +19 -0
- package/src/client/remote.ts +85 -3
- package/src/client/select-menu.tsx +113 -0
- package/src/client/side-by-side.ts +150 -0
- package/src/client/styles.ts +1375 -86
- package/src/client/time-format.ts +32 -0
- package/src/contracts/client-platform.ts +147 -0
- package/src/contracts/host-endpoints.ts +58 -0
- package/src/contracts/plugin-activation.ts +129 -0
- package/src/contracts/ui-context.tsx +28 -0
- package/src/contracts/ui-primitives.ts +48 -0
- package/src/host/actions.ts +66 -15
- package/src/host/core.ts +9 -2
- package/src/host/index.ts +60 -54
- package/src/host/parser.ts +155 -12
- package/src/host/queries.ts +289 -0
- package/src/host/types.ts +104 -0
package/src/client/index.ts
CHANGED
|
@@ -1,150 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* dsh-git-ui client 入口:Cordis 约定字段 + 委托。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
22
|
-
import {
|
|
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
|
-
*
|
|
19
|
+
* Cordis 插件约定:声明需要的服务。
|
|
68
20
|
*
|
|
69
|
-
* `remote.gitInfo`
|
|
70
|
-
*
|
|
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
|
-
/**
|
|
26
|
+
/** 插件标识。 */
|
|
76
27
|
export const name = 'dsh-git-ui'
|
|
77
28
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/remote.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
+
}
|