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

@@ -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/settings/settings-local/README.md
README.md: 344300c33879918e836b6e208b172343cc472faa
README.zh.md: 7e4913c0883c48c23de3408a3b0fe0455160984f
README.md: 16d66e3dd876ff58ba1745655b325212acc04ca1
README.zh.md: 75c3316bad3005a9e4e895699187a4b16ae8efef

View File

@@ -26,6 +26,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal.
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
- **Local configuration surfaces receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser never reconstructs `$DSH_HOME` or submits a filesystem target.
## Model Experience

View File

@@ -26,6 +26,7 @@
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
- **本地配置界面会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器绝不重建 `$DSH_HOME`,也不提交文件系统目标。
## Model Experience

View File

@@ -10,7 +10,7 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
@@ -96,6 +96,11 @@ function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Whether an exclusive file create found an existing document. */
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
/** File-backed settings provider (`settings.yaml`/`.json`). */
export class SettingsLocal extends Settings {
static Config: z<Config> = z.object({
@@ -139,6 +144,29 @@ export class SettingsLocal extends Settings {
return true
}
/** The resolved YAML/JSON document path exposed to local configuration surfaces. */
override get documentPath(): string {
return this.spec.filename
}
/** Materialize an absent owner-only document, then return its resolved path. */
override prepareDocument(): Promise<string> {
return this.enqueue(async () => {
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
try {
await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 })
} catch (error) {
if (isEEXIST(error)) return
throw error
}
this.text = ''
if (!this.isClosed()) this.publish({})
})
return this.spec.filename
})
}
protected async load(): Promise<Record<string, unknown>> {
let text: string
try {

View File

@@ -48,12 +48,37 @@ describe('resolveSpec', () => {
describe('boot and reads', () => {
it('resolves defaults over an absent file and reports writable', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
expect(ctx.settings.writable).toBe(true)
expect(ctx.settings.documentPath).toBe(path)
})
it('prepares an absent owner-only document without changing resolved settings', async () => {
const dir = await tempDir()
const path = join(dir, 'nested', 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
expect(await readFile(path, 'utf8')).toBe('')
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('preparing an existing document preserves its contents', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const contents = 'ui-theme:\n theme: light\n'
await writeFile(path, contents)
const ctx = await boot({ path, watch: false })
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
expect(await readFile(path, 'utf8')).toBe(contents)
})
it('reads sections from an existing yaml document', async () => {
@@ -77,6 +102,7 @@ describe('boot and reads', () => {
it('defaults the file location under the configured harness home', async () => {
const dir = await tempDir()
const ctx = await boot({ dshHome: dir, watch: false })
expect(ctx.settings.documentPath).toBe(join(dir, 'settings.yaml'))
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')

View File

@@ -11,6 +11,10 @@ import { SettingsLocal } from '../src/index.ts'
const state = vi.hoisted(() => ({
failTempWrite: false,
failDocumentCreate: false,
holdDocumentCreate: false,
documentCreateStarted: undefined as (() => void) | undefined,
continueDocumentCreate: undefined as Promise<void> | undefined,
}))
vi.mock('node:fs/promises', async (importOriginal) => {
@@ -18,6 +22,15 @@ vi.mock('node:fs/promises', async (importOriginal) => {
return {
...actual,
writeFile: (async (path: unknown, ...rest: never[]) => {
if (state.holdDocumentCreate && String(path).endsWith('settings.yaml')) {
state.holdDocumentCreate = false
state.documentCreateStarted!()
await state.continueDocumentCreate!
}
if (state.failDocumentCreate && String(path).endsWith('settings.yaml')) {
state.failDocumentCreate = false
throw Object.assign(new Error('ENOSPC: injected document create failure'), { code: 'ENOSPC' })
}
if (state.failTempWrite && String(path).endsWith('.tmp')) {
state.failTempWrite = false
throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' })
@@ -33,6 +46,10 @@ const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
state.failTempWrite = false
state.failDocumentCreate = false
state.holdDocumentCreate = false
state.documentCreateStarted = undefined
state.continueDocumentCreate = undefined
while (cleanups.length > 0) await cleanups.pop()!()
})
@@ -51,6 +68,42 @@ async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Pro
}
describe('writer-lock failure cleanup', () => {
it('skips publication when an in-flight document create completes during teardown', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, { path, watch: true, debounceMs: 0 })
cleanups.push(async () => { await fiber.dispose() })
await fiber
const settings = ctx.settings
let markStarted!: () => void
const started = new Promise<void>((resolve) => { markStarted = resolve })
let releaseCreate!: () => void
state.continueDocumentCreate = new Promise<void>((resolve) => { releaseCreate = resolve })
state.documentCreateStarted = markStarted
state.holdDocumentCreate = true
const preparing = settings.prepareDocument()
await started
const disposing = fiber.dispose()
await vi.waitFor(() => {
expect((settings as unknown as { closed: boolean }).closed).toBe(true)
})
releaseCreate()
await expect(preparing).resolves.toBe(path)
await disposing
expect(await readFile(path, 'utf8')).toBe('')
})
it('surfaces an exclusive document-create failure and releases the lock', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
state.failDocumentCreate = true
await expect(ctx.settings.prepareDocument()).rejects.toThrow(/ENOSPC/)
await expect(access(`${path}.lock`)).rejects.toThrow()
})
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')