dsh-git-ui 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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/index.ts CHANGED
@@ -1,107 +1,102 @@
1
1
  /**
2
- * dsh-git-ui host half: the `gitInfo` Remote service.
2
+ * dsh-git-ui host 适配层:Cordis/typert 壳。
3
3
  *
4
- * Cordis shell only every behavior lives in `core.ts`/`actions.ts`/
5
- * `queries.ts` behind injected structural faces, so tests never need a
6
- * cordis runtime. The class is a plugin in its own right (class form),
7
- * mounted by the bundle patch row with the package name; the gateway exposes
8
- * `gitInfo/snapshot`, `gitInfo/run` and `gitInfo/query` through SRC
9
- * discovery (`typertRemote` binding + `@Remote` marker).
4
+ * 本文件是 host 端**唯一** import `@deepseek-ai/*` 的地方。职责:
5
+ * 1. Cordis 服务(subprocess / sessions / sessionPersistence)适配为
6
+ * 结构化 `SnapshotDeps` 接口;
7
+ * 2. 调用 `createHostEndpoints(deps, config)` 获得纯业务端点;
8
+ * 3. `@Remote` 装饰器将端点暴露给 typert Gateway。
9
+ *
10
+ * 业务逻辑全部在 `contracts/host-endpoints.ts` → `host/core.ts` /
11
+ * `host/actions.ts` / `host/queries.ts`,与框架无关。
10
12
  */
11
13
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
12
14
  import type { Context } from '@deepseek-ai/cordis'
13
15
  import { realpath, stat } from 'node:fs/promises'
14
16
  import { createGitRunner, type SubprocessLike } from './git.ts'
15
- import { normalizeConfig, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
16
- import { runAction } from './actions.ts'
17
- import { runQuery } from './queries.ts'
18
- import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
17
+ import { normalizeConfig, type GitStatusConfig, type SnapshotDeps } from './core.ts'
18
+ import { createHostEndpoints, type HostEndpoints } from '../contracts/host-endpoints.ts'
19
+ import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
19
20
 
20
21
  export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest, GitQuery, GitQueryResult, GitQueryRequest, GitQueryResponse, GitBranch, GitFileStat, GitRef } from './types.ts'
21
22
  export { normalizeConfig, DEFAULT_CONFIG } from './core.ts'
22
23
  export { parseStatusOutput, parseLogOutput, parseBranchOutput, parseNameStatusOutput } from './parser.ts'
23
24
  export { isSafePath, isValidBranchName, runAction } from './actions.ts'
24
25
  export { runQuery } from './queries.ts'
26
+ export { createHostEndpoints, type HostEndpoints } from '../contracts/host-endpoints.ts'
25
27
 
26
- /** Structural face of a live session header. */
27
- interface SessionLike {
28
- readonly header?: { readonly cwd?: string }
29
- }
30
-
31
- /** Structural face of the sessions service. */
28
+ /** Cordis sessions 服务的结构化切片。 */
32
29
  interface SessionsLike {
33
- get(id: string): SessionLike | undefined
30
+ get(id: string): { readonly header?: { readonly cwd?: string } } | undefined
34
31
  }
35
32
 
36
- /** Structural face of the session-persistence service. */
33
+ /** Cordis sessionPersistence 服务的结构化切片。 */
37
34
  interface SessionPersistenceLike {
38
35
  inspect(id: string): Promise<{ readonly meta: { readonly cwd?: string } }>
39
36
  }
40
37
 
41
- /** The `gitInfo` service: `snapshot` (read) and `run` (management) endpoints. */
38
+ /**
39
+ * gitInfo Remote 服务:Cordis 壳。
40
+ *
41
+ * 构造时从 Cordis Context 取出宿主服务,适配为 `SnapshotDeps`,再调用
42
+ * `createHostEndpoints` 获得纯业务端点。三个 `@Remote` 方法仅做委托。
43
+ */
42
44
  export class GitStatusService extends TypertRemoteService {
43
45
  static inject = ['subprocess', 'sessions', 'sessionPersistence']
44
46
 
45
- private readonly config: GitStatusConfig
47
+ private readonly endpoints: HostEndpoints
46
48
 
47
49
  constructor(ctx: Context, config: unknown) {
48
50
  super(ctx, 'gitInfo')
49
- this.config = normalizeConfig(config)
51
+ const normalizedConfig = normalizeConfig(config)
52
+ const deps = this.buildDeps(ctx, normalizedConfig)
53
+ this.endpoints = createHostEndpoints(deps, normalizedConfig)
50
54
  }
51
55
 
52
- /** Adapter face shared by both endpoints (injected services + runner). */
53
- private deps(signal?: AbortSignal): { readonly deps: SnapshotDeps } | { readonly failure: { readonly code: 'git-unavailable'; readonly detail: string } } {
54
- const subprocess = this.ctx.get('subprocess') as SubprocessLike | undefined
56
+ /** Cordis 服务适配为结构化 SnapshotDeps。 */
57
+ private buildDeps(ctx: Context, config: GitStatusConfig): SnapshotDeps {
58
+ const subprocess = ctx.get('subprocess') as SubprocessLike | undefined
55
59
  if (subprocess === undefined) {
56
- return { failure: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
60
+ // 返回一个永远失败的 deps——端点调用会走到 git-unavailable 降级路径。
61
+ return {
62
+ run: { run: async () => { throw new Error('subprocess service unavailable') } },
63
+ fs: { realpath, stat },
64
+ sessions: { liveCwd: () => undefined, persistedMeta: async () => undefined },
65
+ }
57
66
  }
58
- const sessions = this.ctx.get('sessions') as SessionsLike | undefined
59
- const persistence = this.ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
60
- const runner = createGitRunner(subprocess, this.config.timeoutMs, this.config.maxStatusBytes)
67
+ const sessions = ctx.get('sessions') as SessionsLike | undefined
68
+ const persistence = ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
61
69
  return {
62
- deps: {
63
- run: runner,
64
- fs: { realpath, stat },
65
- sessions: {
66
- liveCwd: (id) => sessions?.get(id)?.header?.cwd,
67
- persistedMeta: async (id) => {
68
- if (persistence === undefined) return undefined
69
- try {
70
- const inspection = await persistence.inspect(id)
71
- return { cwd: inspection.meta.cwd }
72
- } catch {
73
- return undefined
74
- }
75
- },
70
+ run: createGitRunner(subprocess, config.timeoutMs, config.maxStatusBytes),
71
+ fs: { realpath, stat },
72
+ sessions: {
73
+ liveCwd: (id) => sessions?.get(id)?.header?.cwd,
74
+ persistedMeta: async (id) => {
75
+ if (persistence === undefined) return undefined
76
+ try {
77
+ const inspection = await persistence.inspect(id)
78
+ return { cwd: inspection.meta.cwd }
79
+ } catch {
80
+ return undefined
81
+ }
76
82
  },
77
- signal,
78
83
  },
79
84
  }
80
85
  }
81
86
 
82
87
  @Remote('snapshot')
83
88
  async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
84
- const adapted = this.deps(signal)
85
- if ('failure' in adapted) return { ok: false, error: adapted.failure }
86
- return snapshotForSession(adapted.deps, this.config, request.sessionId)
89
+ return this.endpoints.snapshot(request, signal)
87
90
  }
88
91
 
89
92
  @Remote('run')
90
93
  async run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult> {
91
- const adapted = this.deps(signal)
92
- if ('failure' in adapted) {
93
- return { ok: false, error: { code: 'git-error', message: adapted.failure.detail } }
94
- }
95
- return runAction(adapted.deps, this.config, request)
94
+ return this.endpoints.run(request, signal)
96
95
  }
97
96
 
98
97
  @Remote('query')
99
98
  async query(request: GitQueryRequest, signal?: AbortSignal): Promise<GitQueryResponse> {
100
- const adapted = this.deps(signal)
101
- if ('failure' in adapted) {
102
- return { ok: false, error: { code: 'git-error', message: adapted.failure.detail } }
103
- }
104
- return runQuery(adapted.deps, this.config, request)
99
+ return this.endpoints.query(request, signal)
105
100
  }
106
101
  }
107
102