refactor(ui-permission-presets): permission row derives from the describe mirror

This commit is contained in:
Yichen Jiang committed 2026-08-17 17:19:10 +08:00
1 parent 232e4beeae
commit e3f484f62f
5 files changed
+167 -175

No files matched your search

@@ -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<void> => controller.load()
const select = (preset: string): Promise<void> => 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',
@@ -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<PermissionSettingsState> = 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<IApiClient, 'settings'>) {}
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<IApiClient, 'settings'>,
) {}
/**
* Begin following the mirror (idempotent) and reflect its current answer.
* @returns settlement once the snapshot reflects the mirror.
*/
async load(): Promise<void> {
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<void> {
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()
}
@@ -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) {
@@ -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({
@@ -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<T>(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<ReturnType<typeof ok<{
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
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<ReturnType<typeof ok<{
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
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<ReturnType<typeof ok<{
writable: boolean
namespaces: SettingsNamespaceView[]
}>>>()
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<ReturnType<typeof ok<SettingsNamespaceView>>>()
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<ReturnType<typeof ok<SettingsNamespaceView>>>()
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')