merge master and address permission settings review
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md
|
||||
README.md: adb56edfc461d982b436159f025dbd65ae14dd83
|
||||
README.zh.md: c5b7c9efeb0c8688be3704c66baaeeef24831301
|
||||
README.md: 742e82d767152073ab963dc74c0565d6e8f8e5c4
|
||||
README.zh.md: e4b39567e4e39d74fd4d527ed2fcfed8d5318a59
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. A push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session.
|
||||
Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session. Choosing Full access requires an explicit risk acknowledgement before the row writes it.
|
||||
|
||||
The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。
|
||||
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。选择 Full access 时必须先显式确认风险,该行随后才会写入。
|
||||
|
||||
当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -45,7 +45,6 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -5,78 +5,125 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
PropsLocale, PropsRuntime, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
PermissionSettingsController, PermissionSettingsState,
|
||||
} from './settings-store.ts'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
IconChevronDownOutline14, Menu, RiskConfirmation,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PermissionSettingsState } from './settings-store.ts'
|
||||
import type { PermissionSettingsKey } from './locales.ts'
|
||||
import { FULL_ACCESS_PRESET } from './presentation.ts'
|
||||
import css from './PermissionRow.module.css'
|
||||
|
||||
/** Injected controller and hook for the host-backed preference. */
|
||||
/** Registration-side business face for the host-backed preference. */
|
||||
export interface PermissionRowInjected {
|
||||
/** Permission settings controller. */
|
||||
controller: PermissionSettingsController
|
||||
/** Selector hook bound to the controller snapshot. */
|
||||
useSnapshot: SnapshotSelectorHook<PermissionSettingsState>
|
||||
hooks: {
|
||||
/** Permission settings snapshot bound by the renderer as usePermission. */
|
||||
permission: SnapshotStore<PermissionSettingsState>
|
||||
}
|
||||
/** Load the descriptor when the row first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Persist one advertised preset. */
|
||||
select: (preset: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type PermissionRowProps =
|
||||
PropsRuntime<'settings.general.item'> & PropsLocale<'settings.permission'> & PermissionRowInjected
|
||||
PropsRuntime<'settings.general.item'>
|
||||
& PropsLocale<'settings.permission'>
|
||||
& InjectFace<PermissionRowInjected>
|
||||
|
||||
/**
|
||||
* Render the new-session Permission default selector.
|
||||
* @param props - composed slot props.
|
||||
* @returns the row, or null when the host does not expose permission settings.
|
||||
*/
|
||||
export function PermissionRow({ controller, useSnapshot, t }: PermissionRowProps) {
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) {
|
||||
const state = usePermission(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmation, setConfirmation] = useState<string | null>(null)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void controller.load()
|
||||
}, [controller])
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.writable && state.status !== 'unavailable') return
|
||||
setOpen(false)
|
||||
setAcknowledged(false)
|
||||
setConfirmation(null)
|
||||
}, [state.status, state.writable])
|
||||
|
||||
if (state.status === 'unavailable') return null
|
||||
const selected = state.options.find(option => option.id === state.currentValue)
|
||||
const busy = state.status === 'loading' || state.status === 'saving'
|
||||
const busy = state.status === 'loading' || state.status === 'saving' || confirmation !== null
|
||||
const label = selected?.label
|
||||
?? (busy ? t('loading') : t('unavailable'))
|
||||
const description: string = state.error ?? t('description')
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('title')}</div>
|
||||
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
|
||||
<>
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('title')}</div>
|
||||
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map(option => ({ id: option.id, label: option.label }))}
|
||||
selectedId={state.currentValue}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
if (id === state.currentValue) return
|
||||
if (id === FULL_ACCESS_PRESET) {
|
||||
setAcknowledged(false)
|
||||
setConfirmation(id)
|
||||
return
|
||||
}
|
||||
void select(id)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={busy || !state.writable || state.options.length === 0}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{label}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map(option => ({ id: option.id, label: option.label }))}
|
||||
selectedId={state.currentValue}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void controller.select(id)
|
||||
<RiskConfirmation
|
||||
open={confirmation !== null}
|
||||
title={t('confirm.title')}
|
||||
description={t('confirm.description')}
|
||||
acknowledgeLabel={t('confirm.acknowledge')}
|
||||
cancelLabel={t('confirm.cancel')}
|
||||
confirmLabel={t('confirm.enable')}
|
||||
acknowledged={acknowledged}
|
||||
disabled={!state.writable || state.status === 'saving'}
|
||||
onAcknowledgedChange={setAcknowledged}
|
||||
onCancel={() => {
|
||||
setAcknowledged(false)
|
||||
setConfirmation(null)
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (!acknowledged || confirmation === null) return
|
||||
const preset = confirmation
|
||||
setAcknowledged(false)
|
||||
setConfirmation(null)
|
||||
void select(preset)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={busy || !state.writable || state.options.length === 0}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{label}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
/**
|
||||
* Permission plugin, browser half. The General-settings row writes the
|
||||
* default preset for subsequently created sessions through Settings; the
|
||||
* `/permission` popup decoration switches the current session through the
|
||||
* host command and its `permissions` projection.
|
||||
* Permission preset plugin, browser half — a popupSelect DECORATION hung on
|
||||
* the host `/permission` command: one flat list of presets, current value
|
||||
* marked active, a pick executes the switch. The decoration owns only the
|
||||
* bare invocation; the host command keeps its catalog row, the argued path
|
||||
* (`/permission <preset>` still switches directly), and the lifecycle
|
||||
* logging. Options and the active mark read the session's `permissions`
|
||||
* projection (the same host-computed select the composer chip renders); a
|
||||
* pick submits the `/permission <preset>` command line, so both surfaces
|
||||
* write through one path and the pushed projection frame is the one
|
||||
* confirmation. The Full access row carries the same explicit risk gate as
|
||||
* the composer chip; the shared popup shell owns the modal mechanics.
|
||||
* The General-settings row separately writes the default preset for sessions
|
||||
* created later through the host Settings API.
|
||||
*/
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Type-only: pulls the General item slot and locale service contracts.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
import { PermissionRow } from './PermissionRow.tsx'
|
||||
import type { PermissionRowInjected } from './PermissionRow.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { displayPresetName } from './presentation.ts'
|
||||
import {
|
||||
accessEn, accessZh, en, zh,
|
||||
} from './locales.ts'
|
||||
import {
|
||||
displayPermissionPreset, FULL_ACCESS_PRESET,
|
||||
} from './presentation.ts'
|
||||
import {
|
||||
PERMISSION_SETTINGS_NS, PermissionSettingsController, refreshPermissionIfLoaded,
|
||||
} from './settings-store.ts'
|
||||
@@ -29,20 +41,33 @@ export type {
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection']
|
||||
|
||||
const ACCESS_NS = 'permission.access'
|
||||
|
||||
/** Read one session's current permissions projection value (undefined = capability absent). */
|
||||
function selectOf(session: SessionFace | undefined): PermissionSelect | undefined {
|
||||
return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined
|
||||
}
|
||||
|
||||
/** Flatten the projection select into popup rows; `custom` is display state, never a target. */
|
||||
function optionsOf(value: PermissionSelect): SelectOption[] {
|
||||
function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] {
|
||||
return value.options
|
||||
.filter(option => option.value !== 'custom')
|
||||
.map(option => ({
|
||||
id: option.value,
|
||||
label: displayPresetName(option.name),
|
||||
label: displayPermissionPreset(option.value, option.name),
|
||||
...(option.description !== undefined ? { detail: option.description } : {}),
|
||||
...(option.value === value.currentValue ? { active: true } : {}),
|
||||
...(option.value === FULL_ACCESS_PRESET
|
||||
? {
|
||||
confirmation: {
|
||||
title: t('confirm.title'),
|
||||
description: t('confirm.description'),
|
||||
acknowledgeLabel: t('confirm.acknowledge'),
|
||||
cancelLabel: t('confirm.cancel'),
|
||||
confirmLabel: t('confirm.enable'),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -54,6 +79,30 @@ function optionsOf(value: PermissionSelect): SelectOption[] {
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const command = ctx.get('command') as CommandServiceContract
|
||||
const sessions = ctx.sessions
|
||||
// This optional bundle and ui-conversation can load independently, so each
|
||||
// owns the same safety copy under its own locale namespace.
|
||||
/* jscpd:ignore-start */
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(ACCESS_NS, 'zh', {
|
||||
'confirm.title': accessZh['confirm.title'],
|
||||
'confirm.description': accessZh['confirm.description'],
|
||||
'confirm.acknowledge': accessZh['confirm.acknowledge'],
|
||||
'confirm.cancel': accessZh['confirm.cancel'],
|
||||
'confirm.enable': accessZh['confirm.enable'],
|
||||
}),
|
||||
ctx.locale.register(ACCESS_NS, 'en', {
|
||||
'confirm.title': accessEn['confirm.title'],
|
||||
'confirm.description': accessEn['confirm.description'],
|
||||
'confirm.acknowledge': accessEn['confirm.acknowledge'],
|
||||
'confirm.cancel': accessEn['confirm.cancel'],
|
||||
'confirm.enable': accessEn['confirm.enable'],
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-permission: Full access confirmation dictionaries')
|
||||
/* jscpd:ignore-end */
|
||||
const t = ctx.locale.bind(ACCESS_NS)
|
||||
const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
|
||||
sessions.binding(session.sessionId)?.session
|
||||
|
||||
@@ -61,8 +110,13 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new PermissionSettingsController(connection.api)
|
||||
const useSnapshot = bindSnapshotSelector(controller.store)
|
||||
const injected = (): PermissionRowInjected => ({ controller, useSnapshot })
|
||||
const load = (): Promise<void> => controller.load()
|
||||
const select = (preset: string): Promise<void> => controller.select(preset)
|
||||
const injected = (): PermissionRowInjected => ({
|
||||
hooks: { permission: controller.store },
|
||||
load,
|
||||
select,
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
@@ -102,7 +156,7 @@ export function apply(ctx: ClientContext): void {
|
||||
options: (session) => {
|
||||
const value = selectOf(sessionFor(session))
|
||||
if (value === undefined) throw new Error('permission presets are not available on this host')
|
||||
return Promise.resolve(optionsOf(value))
|
||||
return Promise.resolve(optionsOf(value, t))
|
||||
},
|
||||
onSelect: async (option, session) => {
|
||||
const live = sessionFor(session)
|
||||
|
||||
@@ -6,6 +6,11 @@ export const zh = {
|
||||
'description': '选择新会话的默认权限模式',
|
||||
'loading': '加载中',
|
||||
'unavailable': '不可用',
|
||||
'confirm.title': '确认启用 Full access?',
|
||||
'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
|
||||
'confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'confirm.cancel': '取消',
|
||||
'confirm.enable': '启用 Full access',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings.permission namespace key union. */
|
||||
@@ -17,4 +22,30 @@ export const en = {
|
||||
'description': 'Choose the default permission mode for new sessions',
|
||||
'loading': 'Loading',
|
||||
'unavailable': 'Unavailable',
|
||||
'confirm.title': 'Enable Full access?',
|
||||
'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
|
||||
'confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'confirm.cancel': 'Cancel',
|
||||
'confirm.enable': 'Enable Full access',
|
||||
} satisfies Record<PermissionSettingsKey, string>
|
||||
|
||||
/** Simplified Chinese dictionary for the current-session popup gate. */
|
||||
export const accessZh = {
|
||||
'confirm.title': '确认启用 Full access?',
|
||||
'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
||||
'confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'confirm.cancel': '取消',
|
||||
'confirm.enable': '启用 Full access',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Current-session popup-gate key union. */
|
||||
export type PermissionAccessKey = keyof typeof accessZh
|
||||
|
||||
/** English dictionary for the current-session popup gate. */
|
||||
export const accessEn = {
|
||||
'confirm.title': 'Enable Full access?',
|
||||
'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
|
||||
'confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'confirm.cancel': 'Cancel',
|
||||
'confirm.enable': 'Enable Full access',
|
||||
} satisfies Record<PermissionAccessKey, string>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/** Machine value of the preset that requires an explicit GUI risk gate. */
|
||||
export const FULL_ACCESS_PRESET = 'danger-full-access'
|
||||
|
||||
/**
|
||||
* Convert conventional kebab-case preset names into user-facing title case.
|
||||
* @param name - host-supplied preset label or key.
|
||||
@@ -7,3 +10,13 @@ export function displayPresetName(name: string): string {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
|
||||
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a permission preset under its product label.
|
||||
* @param value - preset machine value.
|
||||
* @param name - host-supplied preset name.
|
||||
* @returns the Full access product label or the conventional display name.
|
||||
*/
|
||||
export function displayPermissionPreset(value: string, name: string): string {
|
||||
return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import {
|
||||
nodeAtPath, rehydrateSchema, type SchemaNode,
|
||||
} from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { displayPresetName } from './presentation.ts'
|
||||
import { displayPermissionPreset } from './presentation.ts'
|
||||
|
||||
/** Permission's settings namespace on the host wire. */
|
||||
export const PERMISSION_SETTINGS_NS = 'permission'
|
||||
@@ -65,8 +65,8 @@ export function permissionDefaultOf(view: SettingsNamespaceView): {
|
||||
return [{
|
||||
id: choice.value,
|
||||
label: typeof described === 'string' && described.length > 0
|
||||
? displayPresetName(described)
|
||||
: displayPresetName(choice.value),
|
||||
? displayPermissionPreset(choice.value, described)
|
||||
: displayPermissionPreset(choice.value, choice.value),
|
||||
}]
|
||||
})
|
||||
if (options.length === 0 || !options.some(option => option.id === value)) {
|
||||
|
||||
@@ -14,8 +14,11 @@ import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/cl
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
import { PermissionRow } from '../src/client/PermissionRow.tsx'
|
||||
import {
|
||||
PermissionRow, type PermissionRowInjected,
|
||||
} from '../src/client/PermissionRow.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { accessEn } from '../src/client/locales.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
@@ -32,6 +35,7 @@ async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService)
|
||||
const locale = new LocaleService(ctx)
|
||||
locale.setLocale('en')
|
||||
ctx.provide('locale', locale)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
@@ -96,9 +100,10 @@ describe('ui-permission browser plugin', () => {
|
||||
expect(c.ui.kind).toBe('popupSelect')
|
||||
const row = b.permissionRow()!
|
||||
expect(row.options).toEqual({ id: 'permission', order: -20 })
|
||||
const injected = row.inject?.()
|
||||
expect(injected?.controller).toBeDefined()
|
||||
expect(typeof injected?.useSnapshot).toBe('function')
|
||||
const injected = row.inject?.() as PermissionRowInjected | undefined
|
||||
expect(injected?.hooks.permission).toBeDefined()
|
||||
expect(typeof injected?.load).toBe('function')
|
||||
expect(typeof injected?.select).toBe('function')
|
||||
})
|
||||
|
||||
it('availability follows the projection key; options mark the current value active and exclude custom', async () => {
|
||||
@@ -116,7 +121,14 @@ describe('ui-permission browser plugin', () => {
|
||||
expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
|
||||
expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
|
||||
// Kebab-case names title-case; non-kebab host-configured names pass through.
|
||||
expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access'])
|
||||
expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
|
||||
expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
|
||||
title: 'Enable Full access?',
|
||||
description: accessEn['confirm.description'],
|
||||
acknowledgeLabel: 'I understand the risks and want to continue',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Enable Full access',
|
||||
})
|
||||
b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
|
||||
const passthrough = await c.ui.options(proj, new AbortController().signal)
|
||||
expect(passthrough[0]?.label).toBe('Ask Every Time')
|
||||
|
||||
@@ -10,12 +10,13 @@ import { PermissionSettingsController } from '../src/client/settings-store.ts'
|
||||
afterEach(cleanup)
|
||||
|
||||
const SCHEMA = {
|
||||
uid: 4,
|
||||
uid: 5,
|
||||
refs: {
|
||||
1: { type: 'const', value: 'read-only' },
|
||||
2: { type: 'const', value: 'workspace-write' },
|
||||
3: { type: 'union', list: [1, 2] },
|
||||
4: { type: 'object', dict: { defaultPreset: 3 } },
|
||||
3: { type: 'const', value: 'danger-full-access' },
|
||||
4: { type: 'union', list: [1, 2, 3] },
|
||||
5: { type: 'object', dict: { defaultPreset: 4 } },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,8 +47,9 @@ function mount(controller: PermissionSettingsController) {
|
||||
return render(
|
||||
<PermissionRow
|
||||
{...runtime}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
load={() => controller.load()}
|
||||
select={preset => controller.select(preset)}
|
||||
usePermission={bindSnapshotSelector(controller.store)}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
@@ -78,6 +80,27 @@ describe('PermissionRow', () => {
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('requires explicit acknowledgement before saving Full access', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
|
||||
const controller = new PermissionSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
mount(controller)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' })
|
||||
const enable = screen.getByRole('button', { name: 'Enable Full access' })
|
||||
expect((enable as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(enable)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(dialog.isConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('hides an unavailable namespace and disables a read-only provider', async () => {
|
||||
const absent = new PermissionSettingsController({
|
||||
settings: {
|
||||
|
||||
Reference in New Issue
Block a user