mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(client): use directory-picker Remote
This commit is contained in:
@@ -316,7 +316,6 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
|
||||
const exercised = await page.evaluate(async () => {
|
||||
type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } }
|
||||
interface PreviewApi {
|
||||
host: { createDirectory(payload: { path: string; name: string }): Promise<Result<{ path: string }>> }
|
||||
skills: { list(payload: { sessionId: string }): Promise<Result<{ skills: unknown[] }>> }
|
||||
settings: {
|
||||
describe(payload: object): Promise<Result<{ namespaces: Array<{ ns: string; revision: number }> }>>
|
||||
@@ -349,12 +348,26 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
|
||||
const sessionId = sessions.result.value.items[0]?.sessionId
|
||||
if (sessionId === undefined) throw new Error('workspace adoption created no Session')
|
||||
|
||||
// Remote namespaces answer over the same unary carrier; the args object
|
||||
// keys every wire parameter by its name.
|
||||
const remote = async <T>(endpoint: string, args: object): Promise<T> => {
|
||||
const answered = await transport.fetch(`/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request', rpcId: `preview-${endpoint.replace('/', '-')}`,
|
||||
method: endpoint, payload: { args },
|
||||
}),
|
||||
})
|
||||
const body = await answered.json() as Result<T>
|
||||
if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`)
|
||||
return body.result.value
|
||||
}
|
||||
const api = transport.createApiClient()
|
||||
const skills = await api.skills.list({ sessionId })
|
||||
if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`)
|
||||
const createDirectory = async (path: string, name: string): Promise<void> => {
|
||||
const created = await api.host.createDirectory({ path, name })
|
||||
if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`)
|
||||
await remote<string>('directoryPicker/createDirectory', { path, name })
|
||||
await new Promise((resolve) => { setTimeout(resolve, 250) })
|
||||
const refreshed = await api.skills.list({ sessionId })
|
||||
if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`)
|
||||
|
||||
@@ -53,7 +53,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
*/
|
||||
export type {
|
||||
ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
|
||||
CredentialView, DirectoryListing, DiscoveredModelView, IApiClient,
|
||||
CredentialView, DiscoveredModelView, IApiClient,
|
||||
MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
|
||||
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
|
||||
SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
@@ -65,6 +66,7 @@
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
|
||||
// wire-fabrication boundary (the schema layer's one-cast-point posture).
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types'
|
||||
import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
|
||||
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest,
|
||||
@@ -2178,6 +2179,55 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical fixture implementation of the generated Directory Picker Remote
|
||||
* contract. The pick is deterministic — the keyless lanes drive the full
|
||||
* pick-then-adopt path without an OS chooser — over the same design-mock
|
||||
* tree the browse primitives serve.
|
||||
*/
|
||||
const directoryPickerRemotes = {
|
||||
pick(): ConnectionRpcResult<string | null> {
|
||||
return { ok: true, value: `${FIXTURE_HOME}/Documents/project` }
|
||||
},
|
||||
list(path?: string): ConnectionRpcResult<FixtureDirectoryListing> {
|
||||
const target = path ?? FIXTURE_HOME
|
||||
const children = childrenOf(target)
|
||||
if (children === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
path: target,
|
||||
home: FIXTURE_HOME,
|
||||
crumbs: crumbsOf(target),
|
||||
entries: [...children].sort((a, b) => a.localeCompare(b))
|
||||
.map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })),
|
||||
// The fixture tree is tiny; no level ever reaches a backend bound.
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
createDirectory(parent: string, name: string): ConnectionRpcResult<string> {
|
||||
const children = childrenOf(parent)
|
||||
if (children === undefined) {
|
||||
return { ok: false, error: { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } } }
|
||||
}
|
||||
// Same root special case as list's entry paths: a plain join under '/'
|
||||
// would mint '//name' and fork the tree's identity.
|
||||
const target = parent === '/' ? `/${name}` : `${parent}/${name}`
|
||||
if (children.includes(name)) {
|
||||
return { ok: false, error: { code: 'directory-exists', message: `${target} already exists`, details: { path: target } } }
|
||||
}
|
||||
directoryTree.set(parent, [...children, name])
|
||||
directoryTree.set(target, [])
|
||||
return { ok: true, value: target }
|
||||
},
|
||||
}
|
||||
|
||||
const goalRemotes = {
|
||||
create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> {
|
||||
const missing = requireGoalSession(id)
|
||||
@@ -3425,6 +3475,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
agentId: SessionId
|
||||
line?: string
|
||||
query?: string
|
||||
path?: string
|
||||
name?: string
|
||||
images?: readonly unknown[]
|
||||
ref?: { id: string; revision: number }
|
||||
agentPreset?: string
|
||||
@@ -3442,6 +3494,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? []))
|
||||
case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? ''))
|
||||
case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? ''))
|
||||
case 'directoryPicker/pick': return Promise.resolve(directoryPickerRemotes.pick())
|
||||
case 'directoryPicker/list': return Promise.resolve(directoryPickerRemotes.list(args.path))
|
||||
case 'directoryPicker/createDirectory':
|
||||
return Promise.resolve(directoryPickerRemotes.createDirectory(args.path ?? '', args.name ?? ''))
|
||||
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
|
||||
objective: (request as { objective?: string } | undefined)?.objective as string,
|
||||
...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
type FixtureOptions,
|
||||
} from '../src/client/fixture.ts'
|
||||
import type {
|
||||
ClientConnectionRpc,
|
||||
ClientConnectionRpc, ConnectionRpcResult,
|
||||
} from '../src/rpc.ts'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' }
|
||||
@@ -286,6 +287,12 @@ interface FixtureRemoteEventStream extends AsyncIterable<FixtureRemoteEventFrame
|
||||
}
|
||||
|
||||
type FixtureTestApi = ReturnType<typeof createFixtureFaces>['api'] & {
|
||||
/** The directory-picking Remote namespace as the fixture serves it. */
|
||||
readonly directoryPickerRemote: {
|
||||
pick: () => Promise<ConnectionRpcResult<string | null>>
|
||||
list: (path?: string) => Promise<ConnectionRpcResult<DirectoryListing>>
|
||||
createDirectory: (path: string, name: string) => Promise<ConnectionRpcResult<string>>
|
||||
}
|
||||
readonly sessions: FixtureSessionApi
|
||||
readonly sessionRemote: FixtureSessionRemote
|
||||
readonly workspace: FixtureWorkspaceApi
|
||||
@@ -298,6 +305,15 @@ type FixtureTestApi = ReturnType<typeof createFixtureFaces>['api'] & {
|
||||
function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi {
|
||||
const { api, rpc } = createFixtureFaces(options)
|
||||
return Object.assign(api, {
|
||||
directoryPickerRemote: {
|
||||
pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as
|
||||
Promise<ConnectionRpcResult<string | null>>,
|
||||
list: (path?: string) => rpc.call('/api', 'directoryPicker/list', { args: { path } }) as
|
||||
Promise<ConnectionRpcResult<DirectoryListing>>,
|
||||
createDirectory: (path: string, name: string) =>
|
||||
rpc.call('/api', 'directoryPicker/createDirectory', { args: { path, name } }) as
|
||||
Promise<ConnectionRpcResult<string>>,
|
||||
},
|
||||
sessions: createSessionApi(rpc),
|
||||
sessionRemote: createSessionRemote(rpc),
|
||||
workspace: createWorkspaceApi(rpc),
|
||||
@@ -1049,18 +1065,18 @@ describe('createFixtureApi', () => {
|
||||
|
||||
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.host.createDirectory(req({ path: '/', name: 'srv' }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
expect(created.result.value.path).toBe('/srv')
|
||||
const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal)
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.crumbs).toEqual([
|
||||
const created = await api.directoryPickerRemote.createDirectory('/', 'srv')
|
||||
if (!created.ok) throw new Error('create failed')
|
||||
expect(created.value).toBe('/srv')
|
||||
const listed = await api.directoryPickerRemote.list('/srv')
|
||||
if (!listed.ok) throw new Error('list failed')
|
||||
expect(listed.value.crumbs).toEqual([
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'srv', path: '/srv', hidden: false },
|
||||
])
|
||||
const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal)
|
||||
if (!root.result.ok) throw new Error('root list failed')
|
||||
expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
|
||||
const root = await api.directoryPickerRemote.list('/')
|
||||
if (!root.ok) throw new Error('root list failed')
|
||||
expect(root.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
|
||||
})
|
||||
|
||||
it('workspace/follow serves the resident baseline and create reuses on path collision', async () => {
|
||||
|
||||
@@ -35,11 +35,11 @@ describe('HTTP bridge abort', () => {
|
||||
|
||||
it('aborts a pending native picker request when the browser disconnects', async () => {
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
|
||||
type: 'client-request', rpcId: 'picker-1', method: 'directoryPicker/pick', payload: { args: {} },
|
||||
})
|
||||
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
|
||||
Object.assign(request, {
|
||||
url: '/api/host.pickDirectory',
|
||||
url: '/api/directoryPicker/pick',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../host/directory-picker"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
@@ -49,7 +49,7 @@
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
@@ -57,7 +57,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconPlusOutline16, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import css from './DirectoryBrowser.module.css'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { createElement } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the owner contract of the directory-flow holes.
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Browser half of the browse directory-picker backend: fills ui-workspace's
|
||||
* two directory-flow holes with the in-app Select Workspace Directory dialog
|
||||
* (figma `Harness` 813-23126 family), driving the node half's
|
||||
* `host.listDirectory`/`host.createDirectory` primitives. Mounting this
|
||||
* package therefore composes both sides of the browse interaction with one
|
||||
* cordis.yml row; no client code branches on a capability kind. The dialog's
|
||||
* copy is locale-registered here — the flow package owns its own strings.
|
||||
* `directoryPicker/list`/`directoryPicker/createDirectory` primitives.
|
||||
* Mounting this package therefore composes both sides of the browse
|
||||
* interaction with one cordis.yml row; no client code branches on a
|
||||
* capability kind. The dialog's copy is locale-registered here — the flow
|
||||
* package owns its own strings.
|
||||
*/
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../connection/tsconfig.client.json"
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Browser half of the native directory-picker backend: fills ui-workspace's
|
||||
* two directory-flow holes with a renderless occupant that answers each
|
||||
* `open` by driving `host.pickDirectory` (the node half's OS chooser) and
|
||||
* `open` by driving `directoryPicker/pick` (the node half's OS chooser) and
|
||||
* reporting the one outcome — picked path, cancellation, or failure — back
|
||||
* through the owner conversation. Mounting this package therefore composes
|
||||
* both sides of the native interaction with one cordis.yml row; no client
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-api-session-controller",
|
||||
"@deepseek-ai/dsh-api-workspace-controller",
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
@@ -53,6 +54,7 @@
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
@@ -62,11 +64,13 @@
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
@@ -80,6 +84,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
|
||||
@@ -59,7 +59,9 @@ const NS = 'workspace'
|
||||
* provides a waitable service. apply therefore depends on each slot
|
||||
* declaration through `slots.inject()` instead of assuming order.
|
||||
*/
|
||||
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection']
|
||||
export const inject = [
|
||||
'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker',
|
||||
]
|
||||
|
||||
/**
|
||||
* Register the browser and picker once their slot declarations are on the
|
||||
@@ -72,7 +74,8 @@ export function apply(ctx: Context): void {
|
||||
const sessions = ctx.get('sessions') as ISessions
|
||||
const workspaces = ctx.get('workspaces') as IWorkspaces
|
||||
const hostDescription = connection.hostDescription
|
||||
const uiWorkspace = new UiWorkspaceService(ctx, connection.api, workspaces, sessions)
|
||||
const uiWorkspace = new UiWorkspaceService(
|
||||
ctx, connection.api, ctx.remote.directoryPicker, workspaces, sessions)
|
||||
ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } })
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** Workspace archive and directory UI capability. */
|
||||
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
DirectoryListing, IApiClient, RpcError,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {
|
||||
ISessions,
|
||||
SessionListState,
|
||||
@@ -69,7 +69,7 @@ export class DirectoryBrowseError extends Error {
|
||||
override readonly name = 'DirectoryBrowseError'
|
||||
|
||||
/** @param rpcError - Host directory business failure. */
|
||||
constructor(readonly rpcError: RpcError) {
|
||||
constructor(readonly rpcError: RemoteFailure) {
|
||||
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,14 @@ class UiWorkspaceService extends Service implements UiWorkspace {
|
||||
/**
|
||||
* @param ctx - Client root Context.
|
||||
* @param api - shared Host API carrier.
|
||||
* @param directoryPicker - the directory-picking Remote namespace.
|
||||
* @param workspaces - pure Workspace Controller.
|
||||
* @param sessions - pure Session Controller.
|
||||
*/
|
||||
constructor(
|
||||
ctx: Context,
|
||||
private readonly api: IApiClient,
|
||||
private readonly directoryPicker: ClientRemote['directoryPicker'],
|
||||
private readonly workspaces: IWorkspaces,
|
||||
private readonly sessions: ISessions,
|
||||
) {
|
||||
@@ -144,23 +146,21 @@ class UiWorkspaceService extends Service implements UiWorkspace {
|
||||
}
|
||||
|
||||
async pickDirectory(): Promise<string | null> {
|
||||
const response = await this.api.host.pickDirectory({})
|
||||
if (!response.result.ok) {
|
||||
throw new Error(`directory picker failed: ${response.result.error.message}`)
|
||||
}
|
||||
return response.result.value.path
|
||||
const result = await this.directoryPicker.pick()
|
||||
if (!result.ok) throw new Error(`directory picker failed: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
|
||||
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
|
||||
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
|
||||
return response.result.value
|
||||
const result = await this.directoryPicker.list(path, signal)
|
||||
if (!result.ok) throw new DirectoryBrowseError(result.error)
|
||||
return result.value
|
||||
}
|
||||
|
||||
async createDirectory(path: string, name: string): Promise<string> {
|
||||
const response = await this.api.host.createDirectory({ path, name })
|
||||
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
|
||||
return response.result.value.path
|
||||
const result = await this.directoryPicker.createDirectory(path, name)
|
||||
if (!result.ok) throw new DirectoryBrowseError(result.error)
|
||||
return result.value
|
||||
}
|
||||
|
||||
async openPath(path: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -60,6 +61,10 @@ async function bench() {
|
||||
ctx.provide('connection', {
|
||||
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
} as never)
|
||||
const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' }))
|
||||
const directoryPicker = { pick: pickDirectory }
|
||||
Object.assign(new TestRemote(ctx), { directoryPicker })
|
||||
ctx.provide('remote.directoryPicker', directoryPicker as never)
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
// These specs assert the shipped Chinese copy. There is no jsdom `window`
|
||||
// in this lane, so browser-language detection never runs and the locale
|
||||
@@ -68,7 +73,7 @@ async function bench() {
|
||||
ctx.provide('locale', locale)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename,
|
||||
insertSessionBefore, open, clear, search, renameSession, binding, fork,
|
||||
insertSessionBefore, open, clear, search, renameSession, binding, fork, pickDirectory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +88,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void {
|
||||
describe('ui-workspace apply', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual([
|
||||
'slots', 'sessions', 'workspaces', 'locale', 'connection',
|
||||
'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
|
||||
@@ -37,6 +37,11 @@ async function createRuntime(): Promise<SlotTestRuntime> {
|
||||
runtime.ctx.provide('connection', {
|
||||
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
})
|
||||
// The rename flow never picks a directory; the namespace only has to be there
|
||||
// for ui-workspace's inject to settle.
|
||||
const directoryPicker = {}
|
||||
Object.assign(new TestRemote(runtime.ctx), { directoryPicker })
|
||||
runtime.ctx.provide('remote.directoryPicker', directoryPicker as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
|
||||
@@ -8,11 +8,12 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import {
|
||||
RpcId,
|
||||
type DirectoryListing,
|
||||
type IApiClient,
|
||||
type RpcError,
|
||||
type RpcResponse,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
|
||||
|
||||
@@ -180,9 +181,6 @@ class FakeApiClient implements IApiClient {
|
||||
home: '/home/u',
|
||||
canOpenPath: true,
|
||||
}))
|
||||
onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null }))
|
||||
onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing))
|
||||
onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' }))
|
||||
onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true }))
|
||||
|
||||
declare readonly skills: IApiClient['skills']
|
||||
@@ -193,9 +191,6 @@ class FakeApiClient implements IApiClient {
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)),
|
||||
pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)),
|
||||
listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)),
|
||||
createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)),
|
||||
openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)),
|
||||
}
|
||||
|
||||
@@ -209,6 +204,32 @@ class FakeApiClient implements IApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** The directory-picking Remote namespace, recorded and scripted per case. */
|
||||
class FakeDirectoryPicker {
|
||||
readonly calls: { method: string; payload: unknown }[] = []
|
||||
|
||||
onPick: () => Promise<RemoteResult<string | null>> = () => Promise.resolve({ ok: true, value: null })
|
||||
onList: () => Promise<RemoteResult<DirectoryListing>> = () => Promise.resolve({ ok: true, value: listing })
|
||||
onCreateDirectory: () => Promise<RemoteResult<string>> =
|
||||
() => Promise.resolve({ ok: true, value: '/home/u/new' })
|
||||
|
||||
readonly remote: ClientRemote['directoryPicker'] = {
|
||||
pick: () => this.record('pick', {}, this.onPick()),
|
||||
list: (path?: string) => this.record('list', { path }, this.onList()),
|
||||
createDirectory: (path: string, name: string) =>
|
||||
this.record('createDirectory', { path, name }, this.onCreateDirectory()),
|
||||
}
|
||||
|
||||
callsOf(method: string): unknown[] {
|
||||
return this.calls.filter(call => call.method === method).map(call => call.payload)
|
||||
}
|
||||
|
||||
private record<T>(method: string, payload: unknown, result: Promise<T>): Promise<T> {
|
||||
this.calls.push({ method, payload })
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
interface BenchOptions {
|
||||
readonly workspaces?: WorkspaceSnapshot
|
||||
readonly sessions?: SessionListState
|
||||
@@ -217,15 +238,17 @@ interface BenchOptions {
|
||||
function bench(options: BenchOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const directoryPicker = new FakeDirectoryPicker()
|
||||
const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
|
||||
const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
|
||||
const uiWorkspace = new UiWorkspaceService(
|
||||
ctx,
|
||||
api,
|
||||
directoryPicker.remote,
|
||||
workspaces,
|
||||
sessions as unknown as ISessions,
|
||||
)
|
||||
return { api, ctx, sessions, uiWorkspace, workspaces }
|
||||
return { api, ctx, directoryPicker, sessions, uiWorkspace, workspaces }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
@@ -451,31 +474,33 @@ describe('UiWorkspaceService', () => {
|
||||
|
||||
it('passes directory operations to the Host and preserves structured browse failures', async () => {
|
||||
const b = bench()
|
||||
b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
|
||||
b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' })
|
||||
await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
|
||||
b.api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
|
||||
b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null })
|
||||
await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
|
||||
expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}])
|
||||
expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}])
|
||||
|
||||
await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
|
||||
await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
|
||||
expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
|
||||
expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }])
|
||||
await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
|
||||
expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
|
||||
expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
|
||||
await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined()
|
||||
expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }])
|
||||
|
||||
b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} }))
|
||||
b.directoryPicker.onPick = () => Promise.resolve({
|
||||
ok: false, error: { code: 'internal', message: 'no chooser', details: {} },
|
||||
})
|
||||
await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
|
||||
b.api.onListDirectory = () => Promise.resolve(failed({
|
||||
code: 'directory-unreadable', message: 'denied', details: { path: '/private' },
|
||||
}))
|
||||
b.directoryPicker.onList = () => Promise.resolve({
|
||||
ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } },
|
||||
})
|
||||
const listFailure = b.uiWorkspace.listDirectory('/private')
|
||||
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
|
||||
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
|
||||
b.api.onCreateDirectory = () => Promise.resolve(failed({
|
||||
code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' },
|
||||
}))
|
||||
b.directoryPicker.onCreateDirectory = () => Promise.resolve({
|
||||
ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } },
|
||||
})
|
||||
await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
|
||||
rpcError: { code: 'directory-exists' },
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
|
||||
Generated
+11
-2
@@ -1615,6 +1615,9 @@ importers:
|
||||
'@deepseek-ai/dsh-host-apiproxy':
|
||||
specifier: workspace:^
|
||||
version: link:../../host/apiproxy
|
||||
'@deepseek-ai/dsh-host-directory-picker':
|
||||
specifier: workspace:^
|
||||
version: link:../../host/directory-picker
|
||||
'@deepseek-ai/dsh-host-webserver':
|
||||
specifier: workspace:^
|
||||
version: link:../../host/webserver
|
||||
@@ -2252,9 +2255,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
@@ -3624,6 +3627,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/session-controller
|
||||
@@ -3666,6 +3672,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/protocol
|
||||
'@deepseek-ai/dsh-util-workspace-path':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/workspace-path
|
||||
|
||||
Reference in New Issue
Block a user