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.
- package/README.md +28 -0
- package/README.zh.md +27 -0
- package/lib/client.js +26 -26
- package/lib/client.js.map +4 -4
- package/lib/contracts/host-endpoints.d.ts +27 -0
- package/lib/host/index.d.ts +20 -12
- package/lib/host/index.js +59 -38
- package/lib/host/index.js.map +3 -3
- package/package.json +1 -1
- package/src/adapters/dsh/client-adapter.ts +137 -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 +3 -1
- package/src/client/GitPill.tsx +5 -15
- package/src/client/controller.ts +5 -37
- package/src/client/index.ts +38 -135
- package/src/client/remote.ts +2 -2
- 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/index.ts +52 -57
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type shim for `@deepseek-ai/cordis`.
|
|
3
|
+
*
|
|
4
|
+
* The npm ecosystem does not carry the full dsh package chain (`@deepseek-ai/cordis`
|
|
5
|
+
* depends on unpublished packages), so this package cannot install cordis as a
|
|
6
|
+
* dev dependency. At runtime the host dsh installation provides the real cordis
|
|
7
|
+
* through peer dependency resolution; this file only supplies the compile-time
|
|
8
|
+
* surface used by dsh-git-ui (shapes copied from the deepseek-harness vendor
|
|
9
|
+
* tree, 0.1.0-rc.x). Keep this file in sync with the subset actually used.
|
|
10
|
+
*/
|
|
11
|
+
declare module '@deepseek-ai/cordis' {
|
|
12
|
+
/** Minimal Context surface used by host/client halves. */
|
|
13
|
+
export interface Context {
|
|
14
|
+
readonly root: Context
|
|
15
|
+
/** Service registry reflection (service list for gateway SRC discovery). */
|
|
16
|
+
readonly reflect: {
|
|
17
|
+
readonly props: Record<string, { type?: string; [key: string]: unknown }>
|
|
18
|
+
provide(name: string, value: unknown, check?: unknown): void
|
|
19
|
+
}
|
|
20
|
+
/** Look up an optional service by key (undefined when not provided). */
|
|
21
|
+
get<T = unknown>(key: string): T | undefined
|
|
22
|
+
/** Declare services this plugin needs before activation. */
|
|
23
|
+
inject(keys: readonly string[], callback: (ctx: Context) => void): void
|
|
24
|
+
/** Register a side effect with auto-cleanup on fiber dispose. */
|
|
25
|
+
effect(callback: () => void | (() => void | Promise<void>), label?: string): void
|
|
26
|
+
/** Subscribe to an application event; returns an unsubscribe function. */
|
|
27
|
+
on<K extends string>(event: K, listener: (...args: never[]) => void): (() => void) | void
|
|
28
|
+
/** Start a plugin fiber in the current context. */
|
|
29
|
+
plugin(plugin: unknown, config?: unknown): Promise<unknown> & { dispose(): Promise<void> }
|
|
30
|
+
[key: string]: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Base class for services that expose a named API on `ctx`. */
|
|
34
|
+
export abstract class Service<out T = never> {
|
|
35
|
+
static readonly init: unique symbol
|
|
36
|
+
static readonly check: unique symbol
|
|
37
|
+
static readonly config: unique symbol
|
|
38
|
+
static readonly invoke: unique symbol
|
|
39
|
+
static readonly extend: unique symbol
|
|
40
|
+
static readonly tracker: unique symbol
|
|
41
|
+
static readonly resolveConfig: unique symbol
|
|
42
|
+
/** The service name this instance is registered under. */
|
|
43
|
+
public name!: string
|
|
44
|
+
/** Owning context (registered via `super(ctx, name)`). */
|
|
45
|
+
readonly ctx: Context
|
|
46
|
+
constructor(ctx: Context, name: string)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type shim for `@deepseek-ai/dsh-typert-protocol`.
|
|
3
|
+
*
|
|
4
|
+
* Same rationale as `cordis.d.ts`: the runtime implementation is provided by the
|
|
5
|
+
* host dsh installation (peer dependency); this file supplies the compile-time
|
|
6
|
+
* surface used by dsh-git-ui (shapes copied from the deepseek-harness
|
|
7
|
+
* `packages/typert/protocol` source, 0.1.0-rc.x).
|
|
8
|
+
*/
|
|
9
|
+
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
10
|
+
import type { Context, Service } from '@deepseek-ai/cordis'
|
|
11
|
+
|
|
12
|
+
/** Visible declaration that one Service participates in Typert Gateway export. */
|
|
13
|
+
export interface TypertGatewayBinding<ServiceT extends object = object> {
|
|
14
|
+
readonly service: ServiceT
|
|
15
|
+
readonly serviceKey: string
|
|
16
|
+
readonly namespace: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Cordis Service base that exposes its registered name through Typert Gateway. */
|
|
20
|
+
export abstract class TypertRemoteService<out T = never> extends Service<T> {
|
|
21
|
+
/** Visible binding consumed by the Gateway's source-mode discovery. */
|
|
22
|
+
readonly typertRemote: TypertGatewayBinding<this>
|
|
23
|
+
protected constructor(ctx: Context, serviceKey: string, options?: { namespace?: string })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Standard method decorator marking a Remote endpoint. */
|
|
27
|
+
export type RemoteMethodDecorator = <This extends object, Args extends unknown[], Result>(
|
|
28
|
+
method: (this: This, ...args: Args) => Result,
|
|
29
|
+
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
|
30
|
+
) => void
|
|
31
|
+
|
|
32
|
+
/** Mark one public instance method as a direct Remote invocation. */
|
|
33
|
+
export function Remote(exportName: string): RemoteMethodDecorator
|
|
34
|
+
|
|
35
|
+
/** Read Remote markers attached to a live Service by decorator initializers. */
|
|
36
|
+
export function remoteMethods(service: object): readonly {
|
|
37
|
+
readonly method: string
|
|
38
|
+
readonly exportName?: string
|
|
39
|
+
readonly invocation: { readonly kind: 'direct' } | { readonly kind: 'context'; readonly context: string }
|
|
40
|
+
}[]
|
|
41
|
+
|
|
42
|
+
/** One codec for a Remote parameter/result (strict = zod-validated). */
|
|
43
|
+
export interface TypertCodec {
|
|
44
|
+
readonly mode: 'strict'
|
|
45
|
+
readonly typeSymbol: string
|
|
46
|
+
readonly schema: { parse(value: unknown): unknown }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One Remote method descriptor inside a contribution. */
|
|
50
|
+
export interface InvocationDescriptor {
|
|
51
|
+
readonly id: string
|
|
52
|
+
readonly service: string
|
|
53
|
+
readonly namespace: string
|
|
54
|
+
readonly method: string
|
|
55
|
+
readonly invocation: { readonly kind: 'direct' } | { readonly kind: 'context'; readonly context: string; readonly wire: string }
|
|
56
|
+
readonly parameters: readonly {
|
|
57
|
+
readonly name: string
|
|
58
|
+
readonly wire: string
|
|
59
|
+
readonly source: 'json'
|
|
60
|
+
readonly codec: TypertCodec
|
|
61
|
+
}[]
|
|
62
|
+
/**
|
|
63
|
+
* Optional caller-supplied cancellation slot: the descriptor carries an
|
|
64
|
+
* AbortSignal in the trailing business position, propagated over the
|
|
65
|
+
* carrier so an aborted call also aborts the host-side work.
|
|
66
|
+
*/
|
|
67
|
+
readonly cancellation?: { readonly parameter: 'signal' }
|
|
68
|
+
readonly result: TypertCodec
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Client-side contribution mounted via `ctx.remote.$mount`. */
|
|
72
|
+
export interface TypertRemoteContribution {
|
|
73
|
+
readonly package: string
|
|
74
|
+
readonly descriptors: readonly InvocationDescriptor[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The `remote` service face mounted by the api-gateway client half. */
|
|
78
|
+
export interface TypertClientRemote {
|
|
79
|
+
$mount(contribution: TypertRemoteContribution): Promise<() => Promise<void>>
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type shim for `@deepseek-ai/dsh-client-ui-primitives`.
|
|
3
|
+
*
|
|
4
|
+
* Platform module (externalized in the client bundle, provided by the host
|
|
5
|
+
* loader table); this package only needs the `Modal` dialog, the `Button`
|
|
6
|
+
* atom and the transient `Toast` banner, so the surface is declared here by
|
|
7
|
+
* hand (mirrored from the ui-primitives source, 0.1.0-rc.x).
|
|
8
|
+
*/
|
|
9
|
+
declare module '@deepseek-ai/dsh-client-ui-primitives' {
|
|
10
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
11
|
+
|
|
12
|
+
/** Controlled full-viewport dialog over a blurred mask (Escape/mask close). */
|
|
13
|
+
export function Modal(props: {
|
|
14
|
+
open: boolean
|
|
15
|
+
onClose: () => void
|
|
16
|
+
title: string
|
|
17
|
+
closeLabel?: string
|
|
18
|
+
description?: string
|
|
19
|
+
children?: ReactNode
|
|
20
|
+
footer?: ReactNode
|
|
21
|
+
className?: string
|
|
22
|
+
contentClassName?: string
|
|
23
|
+
headless?: boolean
|
|
24
|
+
}): ReactNode
|
|
25
|
+
|
|
26
|
+
/** Token-styled button atom (native button attributes pass through). */
|
|
27
|
+
export function Button(props: {
|
|
28
|
+
variant?: 'primary' | 'ghost' | 'outline' | 'toolbar'
|
|
29
|
+
size?: 'md' | 'sm'
|
|
30
|
+
icon?: ReactNode
|
|
31
|
+
className?: string | undefined
|
|
32
|
+
children?: ReactNode
|
|
33
|
+
} & ButtonHTMLAttributes<HTMLButtonElement>): ReactNode
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Transient top-center banner: slides in, holds ~3s, fades out, then calls
|
|
37
|
+
* `onDone` so the owner can unmount it.
|
|
38
|
+
*/
|
|
39
|
+
export function Toast(props: {
|
|
40
|
+
text: string
|
|
41
|
+
icon?: ReactNode
|
|
42
|
+
anchor?: HTMLElement | null
|
|
43
|
+
onDone: () => void
|
|
44
|
+
}): ReactNode
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh UI 基础组件适配:将 `@deepseek-ai/dsh-client-ui-primitives` 的
|
|
3
|
+
* Modal / Button / Toast 包装为我们的 `UIPrimitives` 接口。
|
|
4
|
+
*
|
|
5
|
+
* 本文件是 client 端**唯一** import `@deepseek-ai/dsh-client-ui-primitives`
|
|
6
|
+
* 的地方。dsh 升级导致组件 API 变更时,只需修改此文件。
|
|
7
|
+
*/
|
|
8
|
+
import { Modal as DshModal, Button as DshButton, Toast as DshToast } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
9
|
+
import type { UIPrimitives } from '../../contracts/ui-primitives.ts'
|
|
10
|
+
|
|
11
|
+
/** dsh 宿主提供的 UI 基础组件实现。 */
|
|
12
|
+
export const dshUIPrimitives: UIPrimitives = {
|
|
13
|
+
Modal: DshModal,
|
|
14
|
+
Button: DshButton,
|
|
15
|
+
Toast: DshToast,
|
|
16
|
+
}
|
package/src/client/GitCenter.tsx
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
18
18
|
import type { CSSProperties, JSX, MouseEvent as ReactMouseEvent } from 'react'
|
|
19
|
-
import {
|
|
19
|
+
import { useUI } from '../contracts/ui-context.tsx'
|
|
20
20
|
import type {
|
|
21
21
|
GitAction, GitActionResult, GitBranch, GitChange, GitFileStat,
|
|
22
22
|
GitQueryRequest, GitSnapshot,
|
|
@@ -78,6 +78,7 @@ function timeAgo(iso: string, now: number, t: (key: GitKey) => string): string {
|
|
|
78
78
|
export function GitCenter({
|
|
79
79
|
open, onClose, snapshot, run, query, t, openRequest = null,
|
|
80
80
|
}: GitCenterProps): JSX.Element | null {
|
|
81
|
+
const { Modal, Button, Toast } = useUI()
|
|
81
82
|
const [tab, setTab] = useState<TabKey>('changes')
|
|
82
83
|
const [busy, setBusy] = useState(false)
|
|
83
84
|
const [feedback, setFeedback] = useState<Feedback>(null)
|
|
@@ -192,6 +193,7 @@ function ChangesTab({
|
|
|
192
193
|
t: (key: GitKey) => string
|
|
193
194
|
openRequest: { path: string; base: 'worktree' | 'staged' } | null
|
|
194
195
|
}): JSX.Element {
|
|
196
|
+
const { Button } = useUI()
|
|
195
197
|
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
|
|
196
198
|
const [message, setMessage] = useState('')
|
|
197
199
|
const [armed, setArmed] = useState<string | 'all' | null>(null)
|
package/src/client/GitPill.tsx
CHANGED
|
@@ -15,8 +15,9 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
|
15
15
|
import { createPortal } from 'react-dom'
|
|
16
16
|
import type { JSX } from 'react'
|
|
17
17
|
import { completedTurnCount, type TurnSignalSnapshot } from './turn-signal.ts'
|
|
18
|
-
import {
|
|
18
|
+
import { useUI } from '../contracts/ui-context.tsx'
|
|
19
19
|
import type { GitObservable, GitQueryOutcome, GitView } from './controller.ts'
|
|
20
|
+
import type { GitInjected } from '../contracts/client-platform.ts'
|
|
20
21
|
import { GitCenter } from './GitCenter.tsx'
|
|
21
22
|
import { fileIconForPath, FolderIcon, AlertIcon, CloseIcon, RollbackIcon, StageIcon, UnstageIcon } from './icons.tsx'
|
|
22
23
|
import type { GitAction, GitActionResult, GitBranch, GitOperationErrorCode, GitQueryRequest } from '../host/types.ts'
|
|
@@ -31,20 +32,8 @@ import * as css from './styles.ts'
|
|
|
31
32
|
// Inject the plugin's interaction styles once (idempotent, browser-only).
|
|
32
33
|
css.ensureGlobalCss()
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
export
|
|
36
|
-
hooks: {
|
|
37
|
-
/** The owning Session's git view source. The slot runtime binds this
|
|
38
|
-
* observable into the `useGit` selector hook the component consumes. */
|
|
39
|
-
git: GitObservable<GitView>
|
|
40
|
-
}
|
|
41
|
-
/** Force an immediate re-check (same path as polling). */
|
|
42
|
-
refresh: () => Promise<void>
|
|
43
|
-
/** Execute one management action (host returns a fresh snapshot). */
|
|
44
|
-
run: (action: GitAction) => Promise<GitActionResult>
|
|
45
|
-
/** Run one read-only query (history / diff / show / branches). */
|
|
46
|
-
query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
|
|
47
|
-
}
|
|
35
|
+
// Re-export for backward compatibility
|
|
36
|
+
export type { GitInjected } from '../contracts/client-platform.ts'
|
|
48
37
|
|
|
49
38
|
/** Selector hook shape the slot runtime binds from `hooks.git`. */
|
|
50
39
|
export type UseGit = <S = GitView>(
|
|
@@ -139,6 +128,7 @@ function GitPopupBody({
|
|
|
139
128
|
query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
|
|
140
129
|
t: (key: GitKey) => string
|
|
141
130
|
}): JSX.Element {
|
|
131
|
+
const { Button } = useUI()
|
|
142
132
|
const now = Date.now()
|
|
143
133
|
const s = view.snapshot
|
|
144
134
|
const branchLabel = s.branch === null ? `(${t('pill.detached')})` : s.branch
|
package/src/client/controller.ts
CHANGED
|
@@ -5,44 +5,12 @@
|
|
|
5
5
|
* connection reset, `dispose()` on slot teardown (clears the timer and
|
|
6
6
|
* rejects nothing — in-flight work settles into a withdrawn view).
|
|
7
7
|
*/
|
|
8
|
-
import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshot,
|
|
8
|
+
import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshot, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
|
|
9
|
+
import type { GitObservable, GitView, GitRemoteLike, GitQueryOutcome, RemoteEnvelope } from '../contracts/client-platform.ts'
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
getSnapshot(): V
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type GitView =
|
|
17
|
-
| { readonly state: 'no-cwd' }
|
|
18
|
-
| { readonly state: 'cold' }
|
|
19
|
-
| { readonly state: 'loading' }
|
|
20
|
-
| { readonly state: 'ready'; readonly snapshot: GitSnapshot }
|
|
21
|
-
| { readonly state: 'error'; readonly error: GitSnapshotFailure }
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* RPC envelope returned by every mounted Remote method (see the api-gateway
|
|
25
|
-
* client's `invoke`): `ok` reflects the transport/gateway outcome, and the
|
|
26
|
-
* business return value of the host method rides inside `value`. For
|
|
27
|
-
* `gitInfo/snapshot` that business value is a `GitSnapshotResult` — so a
|
|
28
|
-
* successful call resolves to `{ ok: true, value: { ok: true, value:
|
|
29
|
-
* GitSnapshot } }`.
|
|
30
|
-
*/
|
|
31
|
-
export type GitRemoteEnvelope<T> =
|
|
32
|
-
| { readonly ok: true; readonly value: T }
|
|
33
|
-
| { readonly ok: false; readonly error: { readonly code?: string; readonly message?: string; readonly details?: unknown } }
|
|
34
|
-
|
|
35
|
-
/** Structural face of the mounted gitInfo Remote namespace. */
|
|
36
|
-
export interface GitRemoteLike {
|
|
37
|
-
snapshot(request: GitSnapshotRequest): Promise<GitRemoteEnvelope<GitSnapshotResult>>
|
|
38
|
-
run(request: GitActionRequest): Promise<GitRemoteEnvelope<GitActionResult>>
|
|
39
|
-
query(request: GitQueryRequest): Promise<GitRemoteEnvelope<GitQueryResponse>>
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Simplified query outcome for the UI (envelope + business errors unwrapped). */
|
|
43
|
-
export type GitQueryOutcome =
|
|
44
|
-
| { readonly ok: true; readonly value: Extract<GitQueryResponse, { ok: true }>['value'] }
|
|
45
|
-
| { readonly ok: false; readonly message: string }
|
|
11
|
+
// Re-export for backward compatibility — 其他模块仍可从 controller 导入这些类型
|
|
12
|
+
export type { GitObservable, GitView, GitRemoteLike, GitQueryOutcome } from '../contracts/client-platform.ts'
|
|
13
|
+
export type { RemoteEnvelope as GitRemoteEnvelope } from '../contracts/client-platform.ts'
|
|
46
14
|
|
|
47
15
|
/** Failure codes that mean "no working directory to watch" — degrade to a
|
|
48
16
|
* low-frequency probe instead of a normal poll. */
|
package/src/client/index.ts
CHANGED
|
@@ -1,151 +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
|
-
query: (query) => controller.query(query),
|
|
129
|
-
}
|
|
130
|
-
faces.set(sessionId, face)
|
|
131
|
-
}
|
|
132
|
-
return face
|
|
133
|
-
},
|
|
134
|
-
}, GitPill)
|
|
135
|
-
return () => {
|
|
136
|
-
dispose()
|
|
137
|
-
for (const controller of controllers.values()) controller.dispose()
|
|
138
|
-
controllers.clear()
|
|
139
|
-
faces.clear()
|
|
140
|
-
}
|
|
141
|
-
})
|
|
142
|
-
},
|
|
143
|
-
})
|
|
144
|
-
await child
|
|
32
|
+
/** 包裹 GitPill,注入 UI 基础组件上下文。 */
|
|
33
|
+
function GitPillWithUI(props: GitPillProps): ReactNode {
|
|
34
|
+
return createElement(UIPrimitivesProvider, { value: uiPrimitives }, createElement(GitPill, props))
|
|
35
|
+
}
|
|
145
36
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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)
|
|
151
54
|
}
|
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(),
|
|
@@ -152,7 +152,7 @@ export const gitQueryRequestSchema = z.object({
|
|
|
152
152
|
})
|
|
153
153
|
|
|
154
154
|
/** The contribution mounted into `ctx.remote` by the client plugin body. */
|
|
155
|
-
export const gitInfoRemote:
|
|
155
|
+
export const gitInfoRemote: RemoteContribution = {
|
|
156
156
|
package: 'dsh-git-ui',
|
|
157
157
|
descriptors: [
|
|
158
158
|
{
|
|
@@ -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
|
+
}
|