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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IDEA 式时间格式化:不足 60 分钟「x 分钟前」、今天「今天 HH:mm」、
|
|
3
|
+
* 昨天「昨天 HH:mm」、其余「Y/M/D HH:mm」。纯函数,可单元测试。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 本地化标签由调用方注入(组件侧经字典提供)。 */
|
|
7
|
+
export interface TimeLabels {
|
|
8
|
+
readonly minutesAgo: (n: number) => string
|
|
9
|
+
readonly today: string
|
|
10
|
+
readonly yesterday: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
14
|
+
|
|
15
|
+
/** 日历日键(本地时区),用于今天/昨天判定。 */
|
|
16
|
+
function dayKey(x: Date): string {
|
|
17
|
+
return `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatWhen(iso: string, now: number, labels: TimeLabels): string {
|
|
21
|
+
const then = Date.parse(iso)
|
|
22
|
+
if (!Number.isFinite(then)) return iso
|
|
23
|
+
const d = new Date(then)
|
|
24
|
+
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
25
|
+
const seconds = Math.floor((now - then) / 1000)
|
|
26
|
+
if (seconds >= 0 && seconds < 3600) {
|
|
27
|
+
return labels.minutesAgo(Math.max(1, Math.floor(seconds / 60)))
|
|
28
|
+
}
|
|
29
|
+
if (dayKey(d) === dayKey(new Date(now))) return `${labels.today} ${hm}`
|
|
30
|
+
if (dayKey(d) === dayKey(new Date(now - 86_400_000))) return `${labels.yesterday} ${hm}`
|
|
31
|
+
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()} ${hm}`
|
|
32
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client 插件平台契约:业务层定义「我需要宿主做什么」,适配层实现「宿主怎么做」。
|
|
3
|
+
*
|
|
4
|
+
* 当 dsh 升级导致 Cordis / typert / slot API 变更时,只需更新
|
|
5
|
+
* `adapters/dsh/client-adapter.ts`,本文件及业务代码零改动。
|
|
6
|
+
*/
|
|
7
|
+
import type { GitAction, GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
|
|
8
|
+
|
|
9
|
+
/** 国际化字典:中英双语。 */
|
|
10
|
+
export interface LocaleDicts {
|
|
11
|
+
readonly zh: Record<string, string>
|
|
12
|
+
readonly en: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** RPC 信封:传输层结果(ok/error)包裹业务返回值。 */
|
|
16
|
+
export type RemoteEnvelope<T> =
|
|
17
|
+
| { readonly ok: true; readonly value: T }
|
|
18
|
+
| { readonly ok: false; readonly error: { readonly code?: string; readonly message?: string; readonly details?: unknown } }
|
|
19
|
+
|
|
20
|
+
/** Git Remote 服务接口:客户端通过此接口与宿主 RPC 通信。 */
|
|
21
|
+
export interface GitRemoteLike {
|
|
22
|
+
snapshot(request: GitSnapshotRequest): Promise<RemoteEnvelope<GitSnapshotResult>>
|
|
23
|
+
run(request: GitActionRequest): Promise<RemoteEnvelope<GitActionResult>>
|
|
24
|
+
query(request: GitQueryRequest): Promise<RemoteEnvelope<GitQueryResponse>>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 简化的查询结果:解包 RPC 信封和业务错误。 */
|
|
28
|
+
export type GitQueryOutcome =
|
|
29
|
+
| { readonly ok: true; readonly value: Extract<GitQueryResponse, { ok: true }>['value'] }
|
|
30
|
+
| { readonly ok: false; readonly message: string }
|
|
31
|
+
|
|
32
|
+
/** Git 视图状态:客户端组件消费的可观察快照。 */
|
|
33
|
+
export type GitView =
|
|
34
|
+
| { readonly state: 'no-cwd' }
|
|
35
|
+
| { readonly state: 'cold' }
|
|
36
|
+
| { readonly state: 'loading' }
|
|
37
|
+
| { readonly state: 'ready'; readonly snapshot: import('../host/types.ts').GitSnapshot }
|
|
38
|
+
| { readonly state: 'error'; readonly error: import('../host/types.ts').GitSnapshotFailure }
|
|
39
|
+
|
|
40
|
+
/** 可观察对象:useSyncExternalStore 形状。 */
|
|
41
|
+
export interface GitObservable<V> {
|
|
42
|
+
subscribe(listener: () => void): () => void
|
|
43
|
+
getSnapshot(): V
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Slot 注入接口:组件通过此接口与控制器交互。 */
|
|
47
|
+
export interface GitInjected {
|
|
48
|
+
hooks: {
|
|
49
|
+
/** 会话的 Git 视图源。Slot 运行时将其绑定为 useGit 选择器钩子。 */
|
|
50
|
+
git: GitObservable<GitView>
|
|
51
|
+
}
|
|
52
|
+
/** 强制立即刷新(与轮询相同路径)。 */
|
|
53
|
+
refresh: () => Promise<void>
|
|
54
|
+
/** 执行一条管理操作(宿主返回新快照)。 */
|
|
55
|
+
run: (action: GitAction) => Promise<GitActionResult>
|
|
56
|
+
/** 执行一条只读查询(历史/差异/分支等)。 */
|
|
57
|
+
query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Slot 条目描述符:注册到宿主 slot 系统的一条记录。 */
|
|
61
|
+
export interface SlotEntryDescriptor {
|
|
62
|
+
/** Slot 名称(如 'conversation.session.header.utilities')。 */
|
|
63
|
+
readonly name: string
|
|
64
|
+
/** 条目 ID(同一 slot 内唯一)。 */
|
|
65
|
+
readonly id: string
|
|
66
|
+
/** 渲染顺序(升序)。 */
|
|
67
|
+
readonly order?: number
|
|
68
|
+
/** 国际化命名空间。 */
|
|
69
|
+
readonly locale?: string
|
|
70
|
+
/** 注入工厂:每次渲染调用,返回该条目的业务接口。 */
|
|
71
|
+
readonly inject: (sessionId: string) => GitInjected
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Client 平台能力:业务层需要的全部宿主服务。
|
|
76
|
+
*
|
|
77
|
+
* 设计原则:
|
|
78
|
+
* - 只声明「做什么」,不暴露「怎么做」
|
|
79
|
+
* - 方法签名取业务层实际使用的最小集
|
|
80
|
+
* - 返回类型为业务层需要的接口,非宿主原始类型
|
|
81
|
+
*/
|
|
82
|
+
export interface ClientPlatform {
|
|
83
|
+
/** 注册国际化字典。 */
|
|
84
|
+
registerLocale(namespace: string, dicts: LocaleDicts): void
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 挂载 Remote 贡献并返回命名空间服务对象。
|
|
88
|
+
*
|
|
89
|
+
* 挂载完成后,返回的对象可用于创建业务控制器。适配层负责处理
|
|
90
|
+
* 宿主的挂载机制(如 Cordis 的 child fiber + inject 模式)。
|
|
91
|
+
*/
|
|
92
|
+
mountRemoteAndGetService(contribution: RemoteContribution, namespace: string): Promise<GitRemoteLike>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 注册一个 slot 条目。
|
|
96
|
+
*
|
|
97
|
+
* 宿主负责在合适的时机渲染该条目。返回释放函数,调用后注销条目。
|
|
98
|
+
*/
|
|
99
|
+
registerSlotEntry(options: SlotEntryDescriptor, component: unknown): () => void
|
|
100
|
+
|
|
101
|
+
/** 订阅应用事件。返回取消订阅函数(若宿主支持)。 */
|
|
102
|
+
onEvent(event: string, listener: (...args: unknown[]) => void): (() => void) | void
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 注册副作用。回调可返回清理函数,在插件卸载时调用。
|
|
106
|
+
* 用于管理定时器等需要生命周期管理的资源。
|
|
107
|
+
*/
|
|
108
|
+
effect(callback: () => void | (() => void | Promise<void>), label?: string): void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Remote 贡献声明:描述一个命名空间下的全部 RPC 方法。
|
|
113
|
+
*
|
|
114
|
+
* 本接口是业务层对 RPC 声明的最小需求。适配层负责将其转换为宿主
|
|
115
|
+
* 的具体格式(如 TypertRemoteContribution)。
|
|
116
|
+
*/
|
|
117
|
+
export interface RemoteContribution {
|
|
118
|
+
readonly package: string
|
|
119
|
+
readonly descriptors: readonly RemoteMethodDescriptor[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 单条 RPC 方法描述。 */
|
|
123
|
+
export interface RemoteMethodDescriptor {
|
|
124
|
+
readonly id: string
|
|
125
|
+
readonly service: string
|
|
126
|
+
readonly namespace: string
|
|
127
|
+
readonly method: string
|
|
128
|
+
readonly invocation: { readonly kind: 'direct' } | { readonly kind: 'context'; readonly context: string; readonly wire: string }
|
|
129
|
+
readonly cancellation?: { readonly parameter: 'signal' }
|
|
130
|
+
readonly parameters: readonly RemoteParameterDescriptor[]
|
|
131
|
+
readonly result: RemoteCodecDescriptor
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** RPC 参数描述。 */
|
|
135
|
+
export interface RemoteParameterDescriptor {
|
|
136
|
+
readonly name: string
|
|
137
|
+
readonly wire: string
|
|
138
|
+
readonly source: 'json'
|
|
139
|
+
readonly codec: RemoteCodecDescriptor
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** RPC 编解码器描述。 */
|
|
143
|
+
export interface RemoteCodecDescriptor {
|
|
144
|
+
readonly mode: 'strict'
|
|
145
|
+
readonly typeSymbol: string
|
|
146
|
+
readonly schema: { parse(value: unknown): unknown }
|
|
147
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 端点契约:纯业务逻辑,零框架依赖。
|
|
3
|
+
*
|
|
4
|
+
* 将 `snapshotForSession` / `runAction` / `runQuery` 聚合为统一的
|
|
5
|
+
* `HostEndpoints` 接口——宿主适配层(Cordis/typert 或其他框架)只需调用
|
|
6
|
+
* `createHostEndpoints(deps, config)` 即可获得全部端点方法,再以任何
|
|
7
|
+
* RPC 机制暴露。业务层对此一无所知。
|
|
8
|
+
*/
|
|
9
|
+
import { snapshotForSession, type GitStatusConfig, type SnapshotDeps } from '../host/core.ts'
|
|
10
|
+
import { runAction } from '../host/actions.ts'
|
|
11
|
+
import { runQuery } from '../host/queries.ts'
|
|
12
|
+
import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 业务端点集合:三个 RPC 方法的纯函数实现。
|
|
16
|
+
* 每个方法接收 wire 请求、可选取消信号,返回 wire 响应。
|
|
17
|
+
*/
|
|
18
|
+
export interface HostEndpoints {
|
|
19
|
+
snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult>
|
|
20
|
+
run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult>
|
|
21
|
+
query(request: GitQueryRequest, signal?: AbortSignal): Promise<GitQueryResponse>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 构造业务端点实例。
|
|
26
|
+
*
|
|
27
|
+
* `deps` 携带全部宿主能力(子进程、会话查找、文件系统);`config` 携带
|
|
28
|
+
* 运行参数。两者均为结构化接口,与任何框架无关。返回的端点对象可直接
|
|
29
|
+
* 绑定到 RPC 装饰器、HTTP 路由、或测试桩。
|
|
30
|
+
*/
|
|
31
|
+
export function createHostEndpoints(deps: SnapshotDeps, config: GitStatusConfig): HostEndpoints {
|
|
32
|
+
return {
|
|
33
|
+
snapshot(request, signal) {
|
|
34
|
+
// 合并调用方信号与 deps 自带信号(deps.signal 来自 Cordis Remote 的
|
|
35
|
+
// 取消槽;此处 signal 来自适配层传入——两者取并集,任一触发即中止)。
|
|
36
|
+
const merged = mergeSignals(deps.signal, signal)
|
|
37
|
+
const effectiveDeps = merged === deps.signal ? deps : { ...deps, signal: merged }
|
|
38
|
+
return snapshotForSession(effectiveDeps, config, request.sessionId)
|
|
39
|
+
},
|
|
40
|
+
run(request, signal) {
|
|
41
|
+
const merged = mergeSignals(deps.signal, signal)
|
|
42
|
+
const effectiveDeps = merged === deps.signal ? deps : { ...deps, signal: merged }
|
|
43
|
+
return runAction(effectiveDeps, config, request)
|
|
44
|
+
},
|
|
45
|
+
query(request, signal) {
|
|
46
|
+
const merged = mergeSignals(deps.signal, signal)
|
|
47
|
+
const effectiveDeps = merged === deps.signal ? deps : { ...deps, signal: merged }
|
|
48
|
+
return runQuery(effectiveDeps, config, request)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 合并两个可选信号:任一 undefined 取另一个,均存在则 AbortSignal.any。 */
|
|
54
|
+
function mergeSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
|
|
55
|
+
if (a === undefined) return b
|
|
56
|
+
if (b === undefined) return a
|
|
57
|
+
return AbortSignal.any([a, b])
|
|
58
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件激活逻辑:纯业务编排,零框架依赖。
|
|
3
|
+
*
|
|
4
|
+
* 接收 `ClientPlatform` 接口和插件依赖,完成:
|
|
5
|
+
* 1. 注册国际化字典
|
|
6
|
+
* 2. 挂载 Remote 并获取 gitInfo 服务
|
|
7
|
+
* 3. 注册 header utility slot
|
|
8
|
+
* 4. 订阅连接重置事件
|
|
9
|
+
*
|
|
10
|
+
* 适配层(如 `adapters/dsh/client-adapter.ts`)负责将宿主 API 翻译为
|
|
11
|
+
* `ClientPlatform`,然后调用本函数。业务层对宿主一无所知。
|
|
12
|
+
*/
|
|
13
|
+
import type { ClientPlatform, SlotEntryDescriptor, GitInjected, GitRemoteLike } from './client-platform.ts'
|
|
14
|
+
import type { RemoteContribution } from './client-platform.ts'
|
|
15
|
+
import type { LocaleDicts } from './client-platform.ts'
|
|
16
|
+
import type { GitAction } from '../host/types.ts'
|
|
17
|
+
import type { GitQueryRequest, GitActionResult } from '../host/types.ts'
|
|
18
|
+
import type { GitQueryOutcome } from './client-platform.ts'
|
|
19
|
+
|
|
20
|
+
/** Slot 名称常量。 */
|
|
21
|
+
const HEADER_UTILITIES_SLOT = 'conversation.session.header.utilities'
|
|
22
|
+
|
|
23
|
+
/** 本插件的 slot 条目 ID。 */
|
|
24
|
+
const SLOT_ENTRY_ID = 'git'
|
|
25
|
+
|
|
26
|
+
/** 国际化命名空间。 */
|
|
27
|
+
const LOCALE_NS = 'git'
|
|
28
|
+
|
|
29
|
+
/** 控制器接口:插件激活逻辑依赖的控制器能力。 */
|
|
30
|
+
export interface ControllerLike {
|
|
31
|
+
subscribe(listener: () => void): () => void
|
|
32
|
+
getSnapshot(): import('./client-platform.ts').GitView
|
|
33
|
+
refresh(): Promise<void>
|
|
34
|
+
resync(): void
|
|
35
|
+
run(action: GitAction): Promise<GitActionResult>
|
|
36
|
+
query(query: GitQueryRequest['query']): Promise<GitQueryOutcome>
|
|
37
|
+
dispose(): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 插件依赖:由调用方提供的具体实现。 */
|
|
41
|
+
export interface PluginDependencies {
|
|
42
|
+
/** Remote 贡献配置 */
|
|
43
|
+
remoteContribution: RemoteContribution
|
|
44
|
+
/** 国际化字典 */
|
|
45
|
+
locales: LocaleDicts
|
|
46
|
+
/** 控制器构造函数 */
|
|
47
|
+
createController: (remote: GitRemoteLike, sessionId: string) => ControllerLike
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 激活插件。
|
|
52
|
+
*
|
|
53
|
+
* @param platform 宿主平台能力
|
|
54
|
+
* @param deps 插件依赖(由调用方提供)
|
|
55
|
+
* @param component 要注册的 UI 组件(已包裹 UI 基础组件上下文)
|
|
56
|
+
*/
|
|
57
|
+
export async function activatePlugin(
|
|
58
|
+
platform: ClientPlatform,
|
|
59
|
+
deps: PluginDependencies,
|
|
60
|
+
component: unknown,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
// 1. 注册国际化字典
|
|
63
|
+
platform.effect(
|
|
64
|
+
() => platform.registerLocale(LOCALE_NS, deps.locales),
|
|
65
|
+
'dsh-git-ui: dictionaries',
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// 2. 挂载 Remote 并获取 gitInfo 服务
|
|
69
|
+
const gitInfoService = await platform.mountRemoteAndGetService(deps.remoteContribution, 'gitInfo')
|
|
70
|
+
|
|
71
|
+
// 3. 控制器缓存(按 sessionId)
|
|
72
|
+
const controllers = new Map<string, ControllerLike>()
|
|
73
|
+
const faces = new Map<string, GitInjected>()
|
|
74
|
+
|
|
75
|
+
const controllerFor = (sessionId: string): ControllerLike => {
|
|
76
|
+
let controller = controllers.get(sessionId)
|
|
77
|
+
if (controller === undefined) {
|
|
78
|
+
controller = deps.createController(gitInfoService, sessionId)
|
|
79
|
+
controllers.set(sessionId, controller)
|
|
80
|
+
}
|
|
81
|
+
return controller
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 4. 注册 slot 条目
|
|
85
|
+
const slotOptions: SlotEntryDescriptor = {
|
|
86
|
+
name: HEADER_UTILITIES_SLOT,
|
|
87
|
+
id: SLOT_ENTRY_ID,
|
|
88
|
+
order: 10,
|
|
89
|
+
locale: LOCALE_NS,
|
|
90
|
+
inject: (sessionId): GitInjected => {
|
|
91
|
+
// Per-session stable face: the slot runtime may re-invoke the
|
|
92
|
+
// inject factory on every render, and components depend on the
|
|
93
|
+
// `refresh` reference staying stable (a fresh arrow function per
|
|
94
|
+
// call would re-run mount effects and loop: refresh → view
|
|
95
|
+
// change → re-render → new refresh → refresh …). Cache the face
|
|
96
|
+
// so the same controller (and its bound refresh/run) is always
|
|
97
|
+
// handed out per session.
|
|
98
|
+
let face = faces.get(sessionId)
|
|
99
|
+
if (face === undefined) {
|
|
100
|
+
const controller = controllerFor(sessionId)
|
|
101
|
+
face = {
|
|
102
|
+
hooks: { git: controller as GitInjected['hooks']['git'] },
|
|
103
|
+
refresh: () => controller.refresh(),
|
|
104
|
+
run: (action) => controller.run(action),
|
|
105
|
+
query: (query) => controller.query(query),
|
|
106
|
+
}
|
|
107
|
+
faces.set(sessionId, face)
|
|
108
|
+
}
|
|
109
|
+
return face
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const disposeSlot = platform.registerSlotEntry(slotOptions, component)
|
|
114
|
+
|
|
115
|
+
// 5. 注册清理:释放 slot 并销毁全部控制器
|
|
116
|
+
platform.effect(() => {
|
|
117
|
+
return () => {
|
|
118
|
+
disposeSlot()
|
|
119
|
+
for (const controller of controllers.values()) controller.dispose()
|
|
120
|
+
controllers.clear()
|
|
121
|
+
faces.clear()
|
|
122
|
+
}
|
|
123
|
+
}, 'dsh-git-ui: slot and controller lifecycle')
|
|
124
|
+
|
|
125
|
+
// 6. 订阅连接重置事件:重连后刷新全部控制器
|
|
126
|
+
platform.onEvent('connection/reset', () => {
|
|
127
|
+
for (const controller of controllers.values()) controller.resync()
|
|
128
|
+
})
|
|
129
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 基础组件 React Context:业务组件通过 `useUI()` 获取实现,
|
|
3
|
+
* 适配层在插件激活时通过 `<UIPrimitivesProvider>` 注入宿主组件。
|
|
4
|
+
*
|
|
5
|
+
* 设计要点:
|
|
6
|
+
* - 默认值为 null——若未提供则 `useUI()` 抛出明确错误,而非静默渲染空组件。
|
|
7
|
+
* - Provider 在插件入口(client/index.ts)包裹整棵组件树。
|
|
8
|
+
* - 测试可直接用 Provider 注入 mock 组件,无需模拟宿主环境。
|
|
9
|
+
*/
|
|
10
|
+
import { createContext, useContext } from 'react'
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
import type { UIPrimitives } from './ui-primitives.ts'
|
|
13
|
+
|
|
14
|
+
const UIPrimitivesContext = createContext<UIPrimitives | null>(null)
|
|
15
|
+
|
|
16
|
+
/** 消费 UI 基础组件。必须在 `<UIPrimitivesProvider>` 内使用。 */
|
|
17
|
+
export function useUI(): UIPrimitives {
|
|
18
|
+
const value = useContext(UIPrimitivesContext)
|
|
19
|
+
if (value === null) {
|
|
20
|
+
throw new Error('useUI(): missing <UIPrimitivesProvider> — 插件入口未注入 UI 基础组件')
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 注入 UI 基础组件实现。在插件入口包裹整棵组件树。 */
|
|
26
|
+
export function UIPrimitivesProvider({ value, children }: { value: UIPrimitives; children?: ReactNode }): ReactNode {
|
|
27
|
+
return <UIPrimitivesContext.Provider value={value}>{children}</UIPrimitivesContext.Provider>
|
|
28
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 基础组件契约:业务层只依赖此接口,不直接 import 任何宿主 UI 库。
|
|
3
|
+
*
|
|
4
|
+
* 属性集取「实际使用的最小并集」——GitPill / GitCenter 用到的全部 props
|
|
5
|
+
* 均已覆盖;宿主适配层可提供更丰富的实现,但业务代码保证只消费此处的
|
|
6
|
+
* 声明。未来 dsh 升级导致 Modal/Button/Toast 变更时,只需更新适配层,
|
|
7
|
+
* 业务组件零改动。
|
|
8
|
+
*/
|
|
9
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
10
|
+
|
|
11
|
+
/** 受控全屏对话框:遮罩 + Escape 关闭。 */
|
|
12
|
+
export interface ModalProps {
|
|
13
|
+
readonly open: boolean
|
|
14
|
+
readonly onClose: () => void
|
|
15
|
+
readonly title: string
|
|
16
|
+
readonly closeLabel?: string
|
|
17
|
+
readonly description?: string
|
|
18
|
+
readonly children?: ReactNode
|
|
19
|
+
readonly footer?: ReactNode
|
|
20
|
+
readonly className?: string
|
|
21
|
+
readonly contentClassName?: string
|
|
22
|
+
/** 隐藏默认标题栏(自定义头部时使用)。 */
|
|
23
|
+
readonly headless?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 令牌化按钮原子。原生 button 属性透传。 */
|
|
27
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
28
|
+
readonly variant?: 'primary' | 'ghost' | 'outline' | 'toolbar'
|
|
29
|
+
readonly size?: 'md' | 'sm'
|
|
30
|
+
readonly icon?: ReactNode
|
|
31
|
+
readonly className?: string | undefined
|
|
32
|
+
readonly children?: ReactNode
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 瞬时顶部提示条:自动消失后调用 onDone 通知父级卸载。 */
|
|
36
|
+
export interface ToastProps {
|
|
37
|
+
readonly text: string
|
|
38
|
+
readonly icon?: ReactNode
|
|
39
|
+
readonly anchor?: HTMLElement | null
|
|
40
|
+
readonly onDone: () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 三个基础组件的聚合:适配层一次性提供整套实现。 */
|
|
44
|
+
export interface UIPrimitives {
|
|
45
|
+
readonly Modal: (props: ModalProps) => ReactNode
|
|
46
|
+
readonly Button: (props: ButtonProps) => ReactNode
|
|
47
|
+
readonly Toast: (props: ToastProps) => ReactNode
|
|
48
|
+
}
|
package/src/host/actions.ts
CHANGED
|
@@ -15,21 +15,21 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { resolve, sep } from 'node:path'
|
|
17
17
|
import { resolveWorkspace, runCommand, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
|
|
18
|
-
import type { GitAction, GitActionResult, GitActionRequest } from './types.ts'
|
|
18
|
+
import type { GitAction, GitActionResult, GitActionRequest, GitOperationErrorCode } from './types.ts'
|
|
19
19
|
|
|
20
20
|
/** Build the command sequence for one action, validating every path against the root. */
|
|
21
21
|
function buildArgv(action: GitAction, root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
|
|
22
22
|
switch (action.kind) {
|
|
23
23
|
case 'stage':
|
|
24
|
-
return withPaths(['git', 'add', '--'], action.paths, root)
|
|
24
|
+
return withPaths([['git', 'add', '--']], action.paths, root)
|
|
25
25
|
case 'stage-all':
|
|
26
26
|
return { argv: [['git', 'add', '-A']] }
|
|
27
27
|
case 'unstage':
|
|
28
|
-
return withPaths(['git', 'restore', '--staged', '--'], action.paths, root)
|
|
28
|
+
return withPaths([['git', 'restore', '--staged', '--']], action.paths, root)
|
|
29
29
|
case 'unstage-all':
|
|
30
30
|
return { argv: [['git', 'restore', '--staged', '--', '.']] }
|
|
31
31
|
case 'discard':
|
|
32
|
-
return withPaths(['git', 'restore', '--'], action.paths, root)
|
|
32
|
+
return withPaths([['git', 'restore', '--']], action.paths, root)
|
|
33
33
|
case 'discard-all':
|
|
34
34
|
// Reset the index to HEAD first, then the work tree to the index — the
|
|
35
35
|
// IDE-style "roll back everything tracked" semantics.
|
|
@@ -40,21 +40,49 @@ function buildArgv(action: GitAction, root: string): { readonly argv: readonly (
|
|
|
40
40
|
if (action.paths === undefined || action.paths.length === 0) {
|
|
41
41
|
return { argv: [['git', 'commit', '-m', message]] }
|
|
42
42
|
}
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
43
|
+
// 两步序列(IDE 式「提交所选文件」语义,含未跟踪文件):
|
|
44
|
+
// 1. `git add -- <paths>` 先把所选路径纳入索引——裸的
|
|
45
|
+
// `git commit -- <未跟踪路径>` 会报 pathspec 错误,先行暂存使其可匹配;
|
|
46
|
+
// 2. `git commit -m <msg> -- <paths>` 按路径限定提交这些路径的工作区内容,
|
|
47
|
+
// 其余已暂存文件不受影响。对已跟踪路径与单命令完全等价(已实测验证)。
|
|
48
|
+
return withPaths([['git', 'add', '--'], ['git', 'commit', '-m', message, '--']], action.paths, root)
|
|
47
49
|
}
|
|
50
|
+
case 'branch-create': {
|
|
51
|
+
// Name validity is validated by runAction (invalid-name), not here.
|
|
52
|
+
const from = action.from === undefined || action.from === '' ? [] : [action.from]
|
|
53
|
+
return { argv: [['git', 'branch', action.name, ...from]] }
|
|
54
|
+
}
|
|
55
|
+
case 'branch-checkout':
|
|
56
|
+
return { argv: [['git', 'checkout', action.name]] }
|
|
57
|
+
case 'branch-delete':
|
|
58
|
+
return { argv: [['git', 'branch', action.force === true ? '-D' : '-d', action.name]] }
|
|
59
|
+
case 'fetch':
|
|
60
|
+
// fetch --all --prune:拉取所有远程引用更新 + 清理已删除的远程跟踪分支。
|
|
61
|
+
return { argv: [['git', 'fetch', '--all', '--prune']] }
|
|
48
62
|
}
|
|
49
63
|
}
|
|
50
64
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
65
|
+
/**
|
|
66
|
+
* A branch name is valid when it matches git's ref-name grammar at the level
|
|
67
|
+
* we care about: non-empty, ASCII ref chars only, no leading `-` (option
|
|
68
|
+
* injection guard, though argv never shells out), no `..` (path traversal of
|
|
69
|
+
* refs), no trailing `/`, and no double slashes.
|
|
70
|
+
*/
|
|
71
|
+
export function isValidBranchName(name: string): boolean {
|
|
72
|
+
if (name === '' || name.startsWith('-') || name.includes('..') || name.endsWith('/') || name.includes('//')) return false
|
|
73
|
+
return /^[A-Za-z0-9._/-]+$/.test(name)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 校验仓库相对路径后追加到 `--` 之后;`prefixes` 可给出多条命令序列,
|
|
78
|
+
* 校验后的路径逐一附加到每条序列(commit 所选路径即两步序列)。
|
|
79
|
+
*/
|
|
80
|
+
function withPaths(prefixes: readonly (readonly string[])[], paths: readonly string[], root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
|
|
53
81
|
if (paths.length === 0) return { error: 'no paths given' }
|
|
54
82
|
for (const path of paths) {
|
|
55
83
|
if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` }
|
|
56
84
|
}
|
|
57
|
-
return { argv: [
|
|
85
|
+
return { argv: prefixes.map((prefix) => [...prefix, ...paths]) }
|
|
58
86
|
}
|
|
59
87
|
|
|
60
88
|
/**
|
|
@@ -71,13 +99,26 @@ export function isSafePath(path: string, root: string): boolean {
|
|
|
71
99
|
}
|
|
72
100
|
|
|
73
101
|
/** Map a snapshot-flow failure (which may carry git-unavailable) onto the operation error shape. */
|
|
74
|
-
function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, { ok: false }>['error']): GitActionResult & { ok: false } {
|
|
102
|
+
export function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, { ok: false }>['error']): GitActionResult & { ok: false } {
|
|
75
103
|
if (failure.code === 'git-unavailable') {
|
|
76
104
|
return { ok: false, error: { code: 'git-error', message: failure.detail } }
|
|
77
105
|
}
|
|
78
106
|
return { ok: false, error: failure }
|
|
79
107
|
}
|
|
80
108
|
|
|
109
|
+
/**
|
|
110
|
+
* 把 git 命令失败归类为可预期的业务错误(其余保持 git-error)。
|
|
111
|
+
* 切分支被工作区未提交变更阻止是最常见的可预期失败:git 输出
|
|
112
|
+
* "would be overwritten by checkout"(或中文本地化 "将被 checkout 覆盖"),
|
|
113
|
+
* 归一化为 local-changes-block,client 据此给友好提示 + 处理变更引导。
|
|
114
|
+
*/
|
|
115
|
+
export function classifyOperationError(kind: GitAction['kind'], message: string): GitOperationErrorCode {
|
|
116
|
+
if (kind === 'branch-checkout' && /would be overwritten by checkout|将被 checkout 覆盖|有未跟踪工作区文件将会被 checkout 覆盖/i.test(message)) {
|
|
117
|
+
return 'local-changes-block'
|
|
118
|
+
}
|
|
119
|
+
return 'git-error'
|
|
120
|
+
}
|
|
121
|
+
|
|
81
122
|
/**
|
|
82
123
|
* Execute one management action against the session's repository and return
|
|
83
124
|
* the refreshed snapshot on success (the caller re-renders from it, so the
|
|
@@ -96,11 +137,20 @@ export async function runAction(
|
|
|
96
137
|
return { ok: false, error: { code: 'git-error', message: 'commit message is empty' } }
|
|
97
138
|
}
|
|
98
139
|
|
|
140
|
+
const kind = request.action.kind
|
|
141
|
+
if (kind === 'branch-create' || kind === 'branch-checkout' || kind === 'branch-delete') {
|
|
142
|
+
const name = request.action.name
|
|
143
|
+
if (!isValidBranchName(name)) {
|
|
144
|
+
return { ok: false, error: { code: 'invalid-name', message: `invalid branch name: ${name}` } }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
const built = buildArgv(request.action, root)
|
|
100
149
|
if ('error' in built) return { ok: false, error: { code: 'invalid-path', message: built.error } }
|
|
101
150
|
|
|
102
|
-
// Run the command sequence; a failure stops the rest
|
|
103
|
-
//
|
|
151
|
+
// Run the command sequence; a failure stops the rest. 先行命令可能已生效:
|
|
152
|
+
// restore 类命令幂等可重入;两步提交若 add 成功后 commit 失败,所选路径
|
|
153
|
+
// 留在暂存区(IDE 行为相同,下次重试即可成功)。
|
|
104
154
|
let lastStdout = ''
|
|
105
155
|
for (const argv of built.argv) {
|
|
106
156
|
const outcome = await runCommand(deps.run, argv, root, `action ${request.action.kind}`, deps.signal)
|
|
@@ -110,10 +160,11 @@ export async function runAction(
|
|
|
110
160
|
// git writes user-facing failures to stderr OR stdout (e.g. a clean
|
|
111
161
|
// repo's `git commit` reports "nothing to commit" on stdout).
|
|
112
162
|
const message = outcome.run.stderr.trim() || outcome.run.stdout.trim()
|
|
163
|
+
const code = classifyOperationError(request.action.kind, message)
|
|
113
164
|
return {
|
|
114
165
|
ok: false,
|
|
115
166
|
error: {
|
|
116
|
-
code
|
|
167
|
+
code,
|
|
117
168
|
message: message !== '' ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`,
|
|
118
169
|
},
|
|
119
170
|
}
|
package/src/host/core.ts
CHANGED
|
@@ -157,8 +157,14 @@ export async function resolveWorkspace(
|
|
|
157
157
|
* 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
|
|
158
158
|
* 2. `git branch --show-current` — null when detached
|
|
159
159
|
* 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
|
|
160
|
-
* 4. `git status --porcelain=v1 -z --branch`
|
|
160
|
+
* 4. `git status --porcelain=v1 -z --branch --untracked-files=all`
|
|
161
161
|
* 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
|
|
162
|
+
*
|
|
163
|
+
* --untracked-files=all:git 默认 normal 模式会把整目录未跟踪折叠为单条
|
|
164
|
+
* `?? dir/`(尾斜杠)且不枚举其内部文件——隐藏目录(.agent/.tianqi 等)的
|
|
165
|
+
* 变更因此从不进入变更清单。`all` 强制逐文件枚举(与 IDEA / VSCode 一致),
|
|
166
|
+
* 内部文件得以展示;maxChanges 截断列表、maxStatusBytes spill 保计数精确,
|
|
167
|
+
* 超大未跟踪树(如未 gitignore 的构建产物)经此路径优雅降级。
|
|
162
168
|
*/
|
|
163
169
|
export async function snapshotForSession(
|
|
164
170
|
deps: SnapshotDeps,
|
|
@@ -182,7 +188,8 @@ export async function snapshotForSession(
|
|
|
182
188
|
// main`), so a corrupt repo is never misreported as "no commits".
|
|
183
189
|
const head = headRun.run.exitCode === 0 ? (headRun.run.stdout.trim() || null) : null
|
|
184
190
|
|
|
185
|
-
|
|
191
|
+
// --untracked-files=all:强制枚举未跟踪目录内部文件(根因修复——见模块注释)。
|
|
192
|
+
const status = await runCommand(deps.run, ['git', 'status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], root, 'status', deps.signal)
|
|
186
193
|
if ('failure' in status) return { ok: false, error: status.failure }
|
|
187
194
|
if (status.run.timedOut) return { ok: false, error: { code: 'timeout' } }
|
|
188
195
|
if (status.run.exitCode !== 0) {
|