refactor(ui-settings-general): document action derives from the mirror
This commit is contained in:
1 parent
608fb895a6
commit
8ea21a166c
6 files changed
+100
-81
No files matched your search
@@ -25,7 +25,7 @@ import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
|
||||
import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts'
|
||||
import { SettingsDocumentStore } from './settings-document-store.ts'
|
||||
import { en, zh, type SettingsKey } from './locales.ts'
|
||||
|
||||
export type {
|
||||
@@ -54,7 +54,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
export const inject = ['slots', 'locale', 'connection', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -69,8 +69,10 @@ export function apply(ctx: ClientContext): void {
|
||||
// locale/change re-registration wiring.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
// The action follows the shared describe mirror, whose owning plugin
|
||||
// already refreshes it on document commits and reconnects.
|
||||
const documentController = connection.isLoopback
|
||||
? new SettingsDocumentStore(connection.api)
|
||||
? new SettingsDocumentStore(connection.api, ctx.settingsScope.describe())
|
||||
: undefined
|
||||
const documentInjected = documentController === undefined
|
||||
? undefined
|
||||
@@ -78,9 +80,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const useSnapshot = bindSnapshotSelector(documentController.store)
|
||||
return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot })
|
||||
})()
|
||||
ctx.effect(() => ctx.on('connection/reset', () => {
|
||||
refreshDocumentIfLoaded(documentController)
|
||||
}), 'ui-settings-general: metadata invalidations')
|
||||
ctx.effect(() => () => { documentController?.dispose() }, 'ui-settings-general: document action directory')
|
||||
// The settings shell: this package occupies the sidebar-owned hole and
|
||||
// declares the settings slots. Ledger → nav-row projection as an observable
|
||||
// source (uSES contract: getSnapshot returns the cached rows until the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
|
||||
/** Browser state of the Host-owned settings document. */
|
||||
export interface SettingsDocumentState {
|
||||
@@ -17,51 +18,37 @@ function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Loads local-document availability and invokes the pathless Host-owned open operation. */
|
||||
/** Derives local-document availability from the shared mirror and invokes the pathless Host-owned open operation. */
|
||||
export class SettingsDocumentStore {
|
||||
/** uSES-safe state source shared by the registered header action. */
|
||||
readonly store: SnapshotStore<SettingsDocumentState> = createSnapshotStore({
|
||||
status: 'idle', opening: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
private following: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* @param api - loopback settings wire face that reports and opens the provider document.
|
||||
* @param api - loopback settings wire face that opens the provider document.
|
||||
* @param describeFace - the shared mirror's read-only face (`hasDocument` source).
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly describeFace: SettingsDescribeFace,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load whether the current provider owns a local document.
|
||||
* @returns after the latest metadata response updates the store.
|
||||
* Begin following the mirror (idempotent) and reflect whether the current
|
||||
* provider owns a local document.
|
||||
* @returns settlement once the snapshot reflects the mirror.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.following ??= this.describeFace.subscribe(() => { this.derive() })
|
||||
this.store.update((state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
})
|
||||
try {
|
||||
const { result } = await this.api.settings.describe({})
|
||||
if (generation !== this.generation) return
|
||||
if (!result.ok) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.error = result.error.message
|
||||
})
|
||||
return
|
||||
}
|
||||
this.store.update((state) => {
|
||||
state.status = result.value.hasDocument ? 'ready' : 'unavailable'
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
await this.describeFace.ensure()
|
||||
this.derive()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,13 +71,30 @@ export class SettingsDocumentStore {
|
||||
this.store.update((state) => { state.opening = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh document availability after reconnect only when a surface has already requested it.
|
||||
* @param controller - optional loopback document state owner.
|
||||
*/
|
||||
export function refreshDocumentIfLoaded(controller: SettingsDocumentStore | undefined): void {
|
||||
if (controller === undefined || controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
/** Stop following the mirror. */
|
||||
dispose(): void {
|
||||
this.following?.()
|
||||
this.following = undefined
|
||||
}
|
||||
|
||||
private derive(): void {
|
||||
const mirrored = this.describeFace.getSnapshot()
|
||||
if (mirrored.view === undefined) {
|
||||
// A held failure with no answer means the document cannot be located;
|
||||
// without one the read is still in flight and loading stands.
|
||||
if (mirrored.error !== null) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.error = mirrored.error
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const { hasDocument } = mirrored.view
|
||||
this.store.update((state) => {
|
||||
state.status = hasDocument ? 'ready' : 'unavailable'
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
@@ -48,6 +49,8 @@ async function bench(isLoopback = true) {
|
||||
api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } },
|
||||
isLoopback,
|
||||
} as never)
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument }
|
||||
}
|
||||
|
||||
@@ -75,7 +78,7 @@ function generalEntry(slots: SlotRegistry) {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('fills all five seats for declarations before or after apply', async () => {
|
||||
@@ -149,16 +152,17 @@ describe('ui-settings-general apply', () => {
|
||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
|
||||
})
|
||||
|
||||
it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
|
||||
it('reads availability from the shared mirror and follows its reconnect refresh', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.action')[0]!
|
||||
const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
|
||||
b.ctx.emit('connection/reset')
|
||||
expect(b.settingsDescribe).not.toHaveBeenCalled()
|
||||
// The mirror read once at its own boot; the action's load adds no read.
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() })
|
||||
await controller.load()
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
@@ -7,7 +7,14 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import type { TriggerContentProps } from '../src/client/chrome.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
|
||||
/** Store over a real mirror derived from the same fake wire. */
|
||||
function derivedDocumentStore(api: object) {
|
||||
const wire = api as never
|
||||
return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire))
|
||||
}
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -64,7 +71,7 @@ describe('SettingsDocumentAction', () => {
|
||||
rpcId: 'document-open' as never,
|
||||
result: { ok: true as const, value: { opened: true as const } },
|
||||
}))
|
||||
const controller = new SettingsDocumentStore({
|
||||
const controller = derivedDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
@@ -87,7 +94,7 @@ describe('SettingsDocumentAction', () => {
|
||||
await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) })
|
||||
})
|
||||
|
||||
it('stays absent without a document and retries availability after remount', async () => {
|
||||
it('stays absent without a document and follows a mirror refresh to available', async () => {
|
||||
const describe = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'document-action-absent' as never,
|
||||
@@ -97,12 +104,9 @@ describe('SettingsDocumentAction', () => {
|
||||
rpcId: 'document-action-ready' as never,
|
||||
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
|
||||
})
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe,
|
||||
openDocument: vi.fn(),
|
||||
},
|
||||
} as never)
|
||||
const wire = { settings: { describe, openDocument: vi.fn() } } as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const controller = new SettingsDocumentStore(wire, mirror)
|
||||
const first = render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
@@ -118,12 +122,17 @@ describe('SettingsDocumentAction', () => {
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
// A remount alone re-reads nothing; availability moves with the mirror's
|
||||
// own refresh (a document commit or reconnect in production).
|
||||
await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
|
||||
expect(describe).toHaveBeenCalledTimes(1)
|
||||
await mirror.load()
|
||||
expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy()
|
||||
expect(describe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the action available and reports a native-open failure', async () => {
|
||||
const controller = new SettingsDocumentStore({
|
||||
const controller = derivedDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
|
||||
/** Store over a real mirror derived from the same fake wire. */
|
||||
function derivedDocumentStore(api: object) {
|
||||
const wire = api as never
|
||||
return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire))
|
||||
}
|
||||
|
||||
function response(hasDocument = false): RpcResponse<{
|
||||
writable: boolean
|
||||
hasDocument: boolean
|
||||
@@ -34,7 +41,7 @@ describe('SettingsDocumentStore', () => {
|
||||
it('loads provider metadata and asks the settings domain to open its document', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(response(true)))
|
||||
const openDocument = vi.fn(() => Promise.resolve(opened()))
|
||||
const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never)
|
||||
const controller = derivedDocumentStore({ settings: { describe, openDocument } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({
|
||||
status: 'ready', opening: false, error: null,
|
||||
@@ -45,7 +52,7 @@ describe('SettingsDocumentStore', () => {
|
||||
|
||||
it('marks absent or failed metadata unavailable without opening anything', async () => {
|
||||
const openDocument = vi.fn(() => Promise.resolve(opened()))
|
||||
const absent = new SettingsDocumentStore({
|
||||
const absent = derivedDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(response()), openDocument },
|
||||
} as never)
|
||||
await absent.load()
|
||||
@@ -53,13 +60,13 @@ describe('SettingsDocumentStore', () => {
|
||||
expect(absent.store.getSnapshot().status).toBe('unavailable')
|
||||
expect(openDocument).not.toHaveBeenCalled()
|
||||
|
||||
const failed = new SettingsDocumentStore({
|
||||
const failed = derivedDocumentStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')), openDocument },
|
||||
} as never)
|
||||
await failed.load()
|
||||
expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' })
|
||||
|
||||
const rejected = new SettingsDocumentStore({
|
||||
const rejected = derivedDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument },
|
||||
} as never)
|
||||
await rejected.load()
|
||||
@@ -71,7 +78,7 @@ describe('SettingsDocumentStore', () => {
|
||||
it('collapses concurrent open gestures and recovers after a failure', async () => {
|
||||
let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void
|
||||
const openDocument = vi.fn(() => new Promise<RpcResponse<{ opened: true }>>((resolve) => { resolveOpen = resolve }))
|
||||
const controller = new SettingsDocumentStore({
|
||||
const controller = derivedDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(response(true)), openDocument },
|
||||
} as never)
|
||||
await controller.load()
|
||||
@@ -88,23 +95,15 @@ describe('SettingsDocumentStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale metadata completions and reports non-Error native failures', async () => {
|
||||
let resolveFirst!: (value: ReturnType<typeof response>) => void
|
||||
const first = new Promise<ReturnType<typeof response>>((resolve) => { resolveFirst = resolve })
|
||||
const describe = vi.fn()
|
||||
.mockReturnValueOnce(first)
|
||||
.mockResolvedValueOnce(response(true))
|
||||
it('reports non-Error native failures and recovers availability via a mirror refresh', async () => {
|
||||
let rejectOpen!: (reason?: unknown) => void
|
||||
const controller = new SettingsDocumentStore({
|
||||
const controller = derivedDocumentStore({
|
||||
settings: {
|
||||
describe,
|
||||
describe: vi.fn(() => Promise.resolve(response(true))),
|
||||
openDocument: () => new Promise((_, reject) => { rejectOpen = reject }),
|
||||
},
|
||||
} as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
resolveFirst(response())
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
const opening = controller.open()
|
||||
rejectOpen('native unavailable')
|
||||
@@ -113,20 +112,21 @@ describe('SettingsDocumentStore', () => {
|
||||
status: 'ready', opening: false, error: 'native unavailable',
|
||||
})
|
||||
|
||||
let rejectFirst!: (error: Error) => void
|
||||
const rejectedFirst = new Promise<ReturnType<typeof response>>((_, reject) => { rejectFirst = reject })
|
||||
const caught = new SettingsDocumentStore({
|
||||
// A first read that failed leaves the action unavailable with the miss
|
||||
// recorded; the mirror's next refresh (a commit or reconnect) recovers it.
|
||||
const wire = {
|
||||
settings: {
|
||||
describe: vi.fn()
|
||||
.mockReturnValueOnce(rejectedFirst)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(response(true)),
|
||||
openDocument: vi.fn(),
|
||||
},
|
||||
} as never)
|
||||
const staleRejection = caught.load()
|
||||
} as never
|
||||
const mirror = new SettingsDescribeMirror(wire)
|
||||
const caught = new SettingsDocumentStore(wire, mirror)
|
||||
await caught.load()
|
||||
rejectFirst(new Error('stale offline'))
|
||||
await staleRejection
|
||||
expect(caught.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' })
|
||||
await mirror.load()
|
||||
expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
|
||||
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
@@ -22,6 +23,7 @@ async function bench() {
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry }
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ const CHILD_SPECS = {
|
||||
|
||||
describe('ui-settings apply', () => {
|
||||
it('declares only the slot registry (a pure composition face, no locale)', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers the shell and declares every child slot, before or after the declaration', async () => {
|
||||
|
||||
Reference in new issue
Block a user