Просмотр исходного кода

refactor(ui-settings-general): document action derives from the mirror

Yichen Jiang 1 месяц назад
Родитель
Сommit
8ea21a166c

+ 6 - 6
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

+ 40 - 36
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<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
+    })
+  }
 }

+ 9 - 5
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) })
   })

+ 18 - 9
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(<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,

+ 24 - 24
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<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 })
   })
 })

+ 3 - 1
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 () => {