feat(web): open the local settings file

This commit is contained in:
Yichen Jiang
2026-08-04 16:33:35 +08:00
parent 828cb2ae20
commit e31b7221e7
61 changed files with 939 additions and 98 deletions

View File

@@ -0,0 +1,16 @@
.action {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.error {
max-width: 180px;
overflow: hidden;
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,50 @@
/** Optional settings-header action for opening a file-backed Host document. */
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SettingsDocumentState, SettingsDocumentStore } from './settings-document-store.ts'
import css from './SettingsDocumentAction.module.css'
/** Registrant-owned dependencies of {@link SettingsDocumentAction}. */
export interface SettingsDocumentActionInjected {
/** Provider metadata and action state owner. */
controller: SettingsDocumentStore
/** Bound selector hook for the controller snapshot. */
useSnapshot: SnapshotSelectorHook<SettingsDocumentState>
}
/** Header-action owner share, localized copy, and the registrant's state face. */
export type SettingsDocumentActionProps =
PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected
/**
* Render the open-document action only after Host metadata confirms a local file.
* @param props - header owner props, localized copy, and injected document state.
* @returns the action, or null while unavailable or unresolved.
*/
export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsDocumentActionProps): ReactNode {
const state = useSnapshot(snapshot => snapshot)
useEffect(() => {
if (state.status === 'idle') void controller.load()
}, [controller, state.status])
if (state.status !== 'ready') return null
return (
<div className={css.action}>
{state.error === null ? null : <span className={css.error} role="alert">{t('openDocument.error')}</span>}
<Button
variant="outline"
size="sm"
disabled={state.opening}
onClick={() => { void controller.open() }}
>
{t('openDocument')}
</Button>
</div>
)
}

View File

@@ -1,8 +1,8 @@
/**
* Settings ownerless-copy plugin, browser half: registers everything on the
* Settings surface that belongs to no single feature — the trigger/header
* chrome content, the General section, and the `settings` dictionaries.
* Feature-owned rows and sections stay with their features.
* chrome content, local-document action, General section, and `settings`
* dictionaries. Feature-owned rows and sections stay with their features.
* Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -15,6 +15,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
import { SettingsDocumentStore } from './settings-document-store.ts'
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
import { WelcomeNotice } from './WelcomeNotice.tsx'
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
@@ -27,6 +30,9 @@ export type {
export type {
GeneralSectionComponentProps,
} from './GeneralSection.tsx'
export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx'
export type { SettingsDocumentState } from './settings-document-store.ts'
export { SettingsDocumentStore } from './settings-document-store.ts'
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
export type { WelcomeNoticeState } from './welcome-store.ts'
export type { SettingsKey } from './locales.ts'
@@ -61,6 +67,15 @@ export function apply(ctx: ClientContext): void {
// locale/change re-registration wiring.
const t = ctx.locale.bind(NS)
const connection = ctx.get('connection') as ConnectionHandle
const documentController = connection.isLoopback
? new SettingsDocumentStore(connection.api)
: undefined
const documentInjected = documentController === undefined
? undefined
: (() => {
const useSnapshot = bindSnapshotSelector(documentController.store)
return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot })
})()
const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory')
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
const welcomeInjected = (): WelcomeNoticeInjected => ({
@@ -84,6 +99,16 @@ export function apply(ctx: ClientContext): void {
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () =>
ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent))
const action = documentInjected === undefined
? undefined
: deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () =>
ctx.slots.register({
name: 'settings.action',
id: 'open-document',
order: 0,
locale: NS,
inject: documentInjected,
}, SettingsDocumentAction))
const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () =>
ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel))
const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
@@ -106,9 +131,10 @@ export function apply(ctx: ClientContext): void {
return () => {
trigger.dispose()
header.dispose()
action?.dispose()
close.dispose()
general.dispose()
welcome.dispose()
}
}, 'ui-settings-general: chrome, section, and onboarding registrations')
}, 'ui-settings-general: chrome, action, section, and onboarding registrations')
}

View File

@@ -6,6 +6,8 @@ export const zh = {
'trigger': '设置',
'title': '设置',
'close': '关闭',
'openDocument': '打开配置文件',
'openDocument.error': '无法打开配置文件',
'general.nav': '通用设置',
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
@@ -24,6 +26,8 @@ export const en = {
'trigger': 'Settings',
'title': 'Settings',
'close': 'Close',
'openDocument': 'Open configuration file',
'openDocument.error': 'Could not open configuration file',
'general.nav': 'General',
'welcome.title': WELCOME_NOTICE_COPY.en.title,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],

View File

@@ -0,0 +1,87 @@
/** State owner for the optional local settings-document action. */
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Browser state of the Host-owned settings document. */
export interface SettingsDocumentState {
/** Metadata-loading phase; unavailable means the provider has no local file or the read failed. */
status: 'idle' | 'loading' | 'ready' | 'unavailable'
/** Whether one native-open request is in flight. */
opening: boolean
/** Last metadata/native-open diagnostic; UI exposes only localized copy. */
error: string | null
}
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. */
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
/**
* @param api - loopback settings wire face that reports and opens the provider document.
*/
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/**
* Load the current provider's optional local document path.
* @returns after the latest metadata response updates the store.
*/
async load(): Promise<void> {
const generation = ++this.generation
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.documentPath === undefined ? 'unavailable' : 'ready'
state.error = null
})
} catch (error) {
if (generation !== this.generation) return
this.store.update((state) => {
state.status = 'unavailable'
state.error = messageOf(error)
})
}
}
/**
* Open the loaded document once; concurrent gestures collapse behind the in-flight action.
* @returns after the native-open request settles, or immediately when unavailable/already opening.
*/
async open(): Promise<void> {
const current = this.store.getSnapshot()
if (current.status !== 'ready' || current.opening) return
this.store.update((state) => {
state.opening = true
state.error = null
})
try {
const response = await this.api.settings.openDocument({})
if (!response.result.ok) throw new Error(response.result.error.message)
} catch (error) {
this.store.update((state) => { state.error = messageOf(error) })
} finally {
this.store.update((state) => { state.opening = false })
}
}
}

View File

@@ -16,8 +16,9 @@ export const inject = ['invariants']
/**
* No runtime invariant: the settings seam validates and publishes the durable
* welcome section, while slot conflicts fail loud in the slot core; this
* package owns no additional event/data relationship between those systems.
* welcome section, while slot conflicts fail loud in the slot core. The local
* document action is browser state over typed RPC responses and is covered by
* store/component tests rather than a Cordis runtime relationship.
*/
const install: InvariantInstaller = () => {}