diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index e1a94934c7..1a12b95722 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -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 diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index dde604d5ae..2e8c8eb192 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -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 = 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) {} + constructor( + private readonly api: Pick, + 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 { - 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 + }) + } } diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index d6c3ffff02..ee65055ff7 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -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) }) }) diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 874bd44630..d4f788383a 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -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( { 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, diff --git a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index b514da85da..a8a42953ee 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts @@ -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>((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) => void - const first = new Promise>((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>((_, 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 }) }) }) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 32c354deda..2b50a33596 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -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 () => {