diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index aec82bf9d9..ce6ecfc32a 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -33,9 +33,7 @@ import { import { displayPermissionPreset, FULL_ACCESS_PRESET, } from './presentation.ts' -import { - PERMISSION_SETTINGS_NS, PermissionPresetSettingsController, refreshPermissionIfLoaded, -} from './settings-store.ts' +import { PermissionPresetSettingsController } from './settings-store.ts' export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx' export type { @@ -43,7 +41,7 @@ export type { } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote'] +export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope'] const ACCESS_NS = 'permission.access' @@ -113,7 +111,10 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') const connection = ctx.get('connection') as ConnectionHandle - const controller = new PermissionPresetSettingsController(connection.api) + // The row follows the shared describe mirror, whose owning plugin already + // refreshes it on document commits and reconnects. + const controller = new PermissionPresetSettingsController( + ctx.settingsScope.describe(), connection.api) const load = (): Promise => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ @@ -122,20 +123,7 @@ export function apply(ctx: ClientContext): void { select, }) - ctx.effect(() => { - const refresh = (): void => { refreshPermissionIfLoaded(controller) } - const disposers = [ - ctx.remote.$on('settings/document-updated', (ns) => { - if (ns !== PERMISSION_SETTINGS_NS) return - refresh() - }), - ctx.on('connection/reset', () => { refresh() }), - ] - return () => { - controller.dispose() - for (const dispose of disposers) dispose() - } - }, 'ui-permission: settings invalidations') + ctx.effect(() => () => { controller.dispose() }, 'ui-permission: settings row directory') ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 6e7199f1be..5f61ed595a 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -1,7 +1,9 @@ /** - * Permission default-settings controller. The host descriptor supplies the - * current value and the dynamic preset enum; writes target only - * `defaultPreset` and carry the descriptor revision. + * Permission default-settings controller. The permission descriptor comes + * from the shared describe mirror (the dynamic preset enum lives in the + * namespace schema, which per-namespace scopes do not carry); writes target + * only `defaultPreset`, carry the descriptor revision, and fold their answer + * back into the mirror. */ import type { @@ -10,6 +12,7 @@ import type { import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import { nodeAtPath, rehydrateSchema, type SchemaNode, } from '@deepseek-ai/dsh-client-schema-form' @@ -75,7 +78,7 @@ export function permissionDefaultOf(view: SettingsNamespaceView): { return { currentValue: value, options } } -/** Controller joining Settings reads, writes, and pushed invalidations. */ +/** Controller deriving the row from the shared mirror and writing the default through it. */ export class PermissionPresetSettingsController { /** Row snapshot consumed through a bound selector hook. */ readonly store: SnapshotStore = createSnapshotStore({ @@ -87,42 +90,32 @@ export class PermissionPresetSettingsController { revision: 0, }) - private generation = 0 - private view: SettingsNamespaceView | undefined - - /** @param api - Settings wire face. */ - constructor(private readonly api: Pick) {} + private following: (() => void) | undefined + private saving = false + private disposed = false /** - * Refresh the permission descriptor. Latest request wins. - * @returns nothing; {@link store} carries success or failure. + * @param describeFace - the shared mirror's read-only face (descriptor and schema source). + * @param api - settings wire face for the `defaultPreset` write. + */ + constructor( + private readonly describeFace: SettingsDescribeFace, + private readonly api: Pick, + ) {} + + /** + * Begin following the mirror (idempotent) and reflect its current answer. + * @returns settlement once the snapshot reflects the mirror. */ async load(): Promise { - const generation = ++this.generation + if (this.disposed) return + this.following ??= this.describeFace.subscribe(() => { this.derive() }) this.store.update((state) => { state.status = 'loading' state.error = null }) - try { - const response = await this.api.settings.describe({}) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation !== this.generation) return - const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) - if (view === undefined) { - this.view = undefined - this.store.update((state) => { - state.status = 'unavailable' - state.writable = false - state.currentValue = '' - state.options = [] - }) - return - } - this.accept(view, response.result.value.writable) - } catch (error) { - if (generation !== this.generation) return - this.fail(error) - } + await this.describeFace.ensure() + this.derive() } /** @@ -131,10 +124,11 @@ export class PermissionPresetSettingsController { * @returns nothing; {@link store} carries success or failure. */ async select(preset: string): Promise { - const view = this.view const state = this.store.getSnapshot() - if (view === undefined || !state.writable) return - const generation = ++this.generation + const view = this.describeFace.getSnapshot().view?.namespaces + .find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined || !state.writable || this.saving) return + this.saving = true this.store.update((draft) => { draft.status = 'saving' draft.error = null @@ -145,32 +139,59 @@ export class PermissionPresetSettingsController { ops: [{ op: 'set', path: ['defaultPreset'], value: preset }], expectedRevision: view.revision, }) - if (generation !== this.generation) return if (!response.result.ok) throw new Error(response.result.error.message) - this.accept(response.result.value, true) + this.saving = false + if (this.disposed) return + // The mirror publish reaches this row's own subscription, so the fold + // is also what republishes the accepted value here. + this.describeFace.acceptView(response.result.value) } catch (error) { - if (generation !== this.generation) return + this.saving = false + if (this.disposed) return this.fail(error) } } - /** Stop in-flight responses from publishing after plugin disposal. */ + /** Stop following the mirror; later publishes leave the snapshot alone. */ dispose(): void { - this.generation += 1 - this.view = undefined + this.disposed = true + this.following?.() + this.following = undefined } - private accept(view: SettingsNamespaceView, writable: boolean): void { - const resolved = permissionDefaultOf(view) - this.view = view - this.store.update((state) => { - state.status = 'ready' - state.error = null - state.writable = writable - state.currentValue = resolved.currentValue - state.options = resolved.options - state.revision = view.revision - }) + private derive(): void { + if (this.disposed || this.saving) return + const mirrored = this.describeFace.getSnapshot() + if (mirrored.view === undefined) { + // A held failure with no answer is a failed row; without one the read + // is still in flight and the row keeps its loading state. + if (mirrored.error !== null) this.fail(new Error(mirrored.error)) + return + } + const view = mirrored.view.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined) { + this.store.update((state) => { + state.status = 'unavailable' + state.writable = false + state.currentValue = '' + state.options = [] + }) + return + } + try { + const resolved = permissionDefaultOf(view) + const { writable } = mirrored.view + this.store.update((state) => { + state.status = 'ready' + state.error = null + state.writable = writable + state.currentValue = resolved.currentValue + state.options = resolved.options + state.revision = view.revision + }) + } catch (error) { + this.fail(error) + } } private fail(error: unknown): void { @@ -180,12 +201,3 @@ export class PermissionPresetSettingsController { }) } } - -/** - * Refetch only after the row has opened once. - * @param controller - permission settings controller. - */ -export function refreshPermissionIfLoaded(controller: PermissionPresetSettingsController): void { - if (controller.store.getSnapshot().status === 'idle') return - void controller.load() -} diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index e3968cf002..19ae476a37 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest' import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client' import { @@ -58,6 +59,7 @@ async function bench() { }, }, } as never) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() let decoration: CommandDecoration | undefined ctx.provide('commandUi', { decorate(c: CommandDecoration) { diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 9df3920bd5..e4cbedbf6b 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -5,8 +5,15 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { en } from '../src/client/locales.ts' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { PermissionPresetSettingsController } from '../src/client/settings-store.ts' +/** Controller over a real mirror derived from the same fake wire. */ +function derivedController(api: { settings: object }) { + const wire = api as never + return new PermissionPresetSettingsController(new SettingsDescribeMirror(wire), wire) +} + afterEach(cleanup) const SCHEMA = { @@ -58,7 +65,7 @@ function mount(controller: PermissionPresetSettingsController) { describe('PermissionRow', () => { it('loads the descriptor, opens the menu, and selects a new default', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -85,7 +92,7 @@ describe('PermissionRow', () => { it('requires explicit acknowledgement before saving Full access', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -109,7 +116,7 @@ describe('PermissionRow', () => { }) it('hides an unavailable namespace and disables a read-only provider', async () => { - const absent = new PermissionPresetSettingsController({ + const absent = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), mutate: vi.fn(), @@ -119,7 +126,7 @@ describe('PermissionRow', () => { await waitFor(() => { expect(rendered.container.textContent).toBe('') }) rendered.unmount() - const readonly = new PermissionPresetSettingsController({ + const readonly = derivedController({ settings: { describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })), mutate: vi.fn(), @@ -134,7 +141,7 @@ describe('PermissionRow', () => { writable: boolean namespaces: SettingsNamespaceView[] }>>>() - const controller = new PermissionPresetSettingsController({ + const controller = derivedController({ settings: { describe: () => describe.promise, mutate: () => Promise.resolve({ diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index e4e218fe86..b8a4725a5c 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/client' import { - PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, + PermissionPresetSettingsController, permissionDefaultOf, } from '../src/client/settings-store.ts' const SCHEMA = { @@ -30,6 +31,13 @@ function ok(value: T) { return { rpcId: 'test', result: { ok: true as const, value } } } +/** The permission controller over a real mirror and one fake wire. */ +function permissionController(api: object) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire) + return { mirror, controller: new PermissionPresetSettingsController(mirror, wire) } +} + describe('permission settings store', () => { it('derives dynamic options and host labels from the descriptor schema', () => { expect(permissionDefaultOf(view('read-only'))).toEqual({ @@ -92,9 +100,7 @@ describe('permission settings store', () => { namespaces: [view('read-only', 4)], }))) const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate } as never, - }) + const { controller } = permissionController({ describe, mutate }) await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', @@ -113,126 +119,105 @@ describe('permission settings store', () => { currentValue: 'workspace-write', revision: 5, }) + // The write answer folded into the mirror; no re-read followed. + expect(describe).toHaveBeenCalledTimes(1) }) it('hides the row when the namespace is absent and contains write failures', async () => { const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))) - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate: vi.fn() } as never, - }) + const { controller } = permissionController({ describe, mutate: vi.fn() }) await controller.load() expect(controller.store.getSnapshot().status).toBe('unavailable') - const failing = new PermissionPresetSettingsController({ - settings: { - describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), - mutate: () => Promise.resolve({ - rpcId: 'test', - result: { - ok: false as const, - error: { code: 'settings-conflict', message: 'stale', details: {} }, - }, - }), - } as never, - }) + const failing = permissionController({ + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'stale', details: {} }, + }, + }), + }).controller await failing.load() await failing.select('workspace-write') expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' }) }) - it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => { - const first = Promise.withResolvers>>() - const describe = vi.fn() - .mockImplementationOnce(() => first.promise) - .mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] })) + it('contains read failures and no-ops without a writable view', async () => { const mutate = vi.fn() - const controller = new PermissionPresetSettingsController({ - settings: { describe, mutate } as never, - }) - const stale = controller.load() - await controller.load() - first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] })) - await stale - expect(controller.store.getSnapshot()).toMatchObject({ + const readOnly = permissionController({ + describe: () => Promise.resolve(ok({ + writable: false, hasDocument: false, namespaces: [view('read-only', 2)], + })), + mutate, + }).controller + await readOnly.load() + expect(readOnly.store.getSnapshot()).toMatchObject({ currentValue: 'read-only', writable: false, revision: 2, }) - await controller.select('workspace-write') + await readOnly.select('workspace-write') expect(mutate).not.toHaveBeenCalled() - const rejected = new PermissionPresetSettingsController({ - settings: { - describe: () => Promise.resolve({ - rpcId: 'test', - result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, - }), - mutate, - } as never, - }) + const rejected = permissionController({ + describe: () => Promise.resolve({ + rpcId: 'test', + result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, + }), + mutate, + }).controller await rejected.select('workspace-write') await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) + expect(mutate).not.toHaveBeenCalled() - const thrown = new PermissionPresetSettingsController({ - settings: { - // Promise consumers must contain unknown rejection values from a - // transport implementation, including non-Error legacy clients. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors - describe: () => Promise.reject('disconnected'), - mutate, - } as never, - }) + const thrown = permissionController({ + // Promise consumers must contain unknown rejection values from a + // transport implementation, including non-Error legacy clients. + describe: () => Promise.reject('disconnected' as never), + mutate, + }).controller await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) }) - it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => { + it('follows a mirror refresh without an own read once loaded', async () => { + const describe = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('read-only', 1)] })) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 2)] })) + const { mirror, controller } = permissionController({ describe, mutate: vi.fn() }) + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'read-only' }) + + await mirror.load() + + expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'workspace-write', revision: 2 }) + }) + + it('disposal stops deriving and suppresses in-flight writes', async () => { const read = Promise.withResolvers>>() - const describe = vi.fn(() => read.promise) - const idle = new PermissionPresetSettingsController({ settings: { describe, mutate: vi.fn() } as never }) - refreshPermissionIfLoaded(idle) - expect(describe).not.toHaveBeenCalled() + const { mirror, controller: idle } = permissionController({ describe: () => read.promise, mutate: vi.fn() }) const loading = idle.load() idle.dispose() read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })) - await loading + await Promise.all([loading, mirror.load()]) expect(idle.store.getSnapshot().status).toBe('loading') - const rejectedRead = Promise.withResolvers>>() - const disposedRead = new PermissionPresetSettingsController({ - settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, - }) - const reading = disposedRead.load() - disposedRead.dispose() - rejectedRead.reject(new Error('late read')) - await reading - expect(disposedRead.store.getSnapshot().status).toBe('loading') - const mutation = Promise.withResolvers>>() - const activeDescribe = vi.fn(() => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [view('read-only')], - }))) - const active = new PermissionPresetSettingsController({ - settings: { - describe: activeDescribe, - mutate: () => mutation.promise, - } as never, + const { controller: active } = permissionController({ + describe: () => Promise.resolve(ok({ + writable: true, + hasDocument: false, + namespaces: [view('read-only')], + })), + mutate: () => mutation.promise, }) await active.load() - refreshPermissionIfLoaded(active) - await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) }) const saving = active.select('workspace-write') active.dispose() mutation.resolve(ok(view('workspace-write', 1))) @@ -240,11 +225,9 @@ describe('permission settings store', () => { expect(active.store.getSnapshot().status).toBe('saving') const rejectedMutation = Promise.withResolvers>>() - const disposedWrite = new PermissionPresetSettingsController({ - settings: { - describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), - mutate: () => rejectedMutation.promise, - } as never, + const { controller: disposedWrite } = permissionController({ + describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), + mutate: () => rejectedMutation.promise, }) await disposedWrite.load() const writing = disposedWrite.select('workspace-write')