From f068607509c9c0754a996f7743fc031ccfd04d01 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 27 Jun 2026 22:12:00 +0900 Subject: [PATCH] Separate settings app-server dynamic data --- biome.jsonc | 4 + ...-settings-app-server-boundary-imports.grit | 18 +++++ src/plugin-runtime.ts | 11 ++- src/settings/app-server/dynamic-data.ts | 80 +++++++++++++++++++ src/settings/dynamic-data.ts | 41 ++++++++++ src/settings/dynamic-sections-controller.ts | 72 ++++++----------- src/settings/host.ts | 18 +---- src/settings/tab.obsidian.tsx | 2 +- tests/scripts/grit-policy.test.mjs | 39 +++++++++ tests/settings/settings-tab.test.ts | 62 ++++++++------ 10 files changed, 255 insertions(+), 92 deletions(-) create mode 100644 scripts/lint/no-settings-app-server-boundary-imports.grit create mode 100644 src/settings/app-server/dynamic-data.ts create mode 100644 src/settings/dynamic-data.ts diff --git a/biome.jsonc b/biome.jsonc index d999c022..f7b1f7ad 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -43,6 +43,10 @@ "path": "./scripts/lint/no-generated-app-server-boundary-imports.grit", "includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/app-server/connection/**"] }, + { + "path": "./scripts/lint/no-settings-app-server-boundary-imports.grit", + "includes": ["**/src/settings/**/*.ts", "**/src/settings/**/*.tsx", "!**/src/settings/app-server/**"] + }, // Shared source layering boundaries. { diff --git a/scripts/lint/no-settings-app-server-boundary-imports.grit b/scripts/lint/no-settings-app-server-boundary-imports.grit new file mode 100644 index 00000000..f1e82fe4 --- /dev/null +++ b/scripts/lint/no-settings-app-server-boundary-imports.grit @@ -0,0 +1,18 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { + $source <: r"^[\"'](?:(?:\.\./)+app-server|src/app-server|\./app-server|(?:\.\./)+settings/app-server|src/settings/app-server)(?:/.*)?[\"']$" + }, + register_diagnostic(span=$stmt, message="Settings modules must depend on settings-owned dynamic data ports instead of importing app-server modules or settings app-server adapters.", severity="error") +} diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index a1adc436..37814fa0 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -22,6 +22,7 @@ import type { ThreadsViewPanelActivity } from "./features/threads-view/state"; import { CodexThreadsView } from "./features/threads-view/view.obsidian"; import { persistedTurnDiffViewState, type TurnDiffViewState } from "./features/turn-diff/model"; import { CodexTurnDiffView } from "./features/turn-diff/view.obsidian"; +import { createSettingsAppServerDynamicData } from "./settings/app-server/dynamic-data"; import type { CodexPanelSettingTabHost } from "./settings/host"; import type { CodexPanelSettings } from "./settings/model"; import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator"; @@ -175,14 +176,16 @@ export class CodexPanelRuntime implements AppServerClientAccess { settingTabHost(): CodexPanelSettingTabHost { return { settings: this.options.settingsRef.settings, - vaultPath: this.options.settingsRef.vaultPath, - clientAccess: this, + dynamicData: createSettingsAppServerDynamicData({ + vaultPath: this.options.settingsRef.vaultPath, + clientAccess: this, + appServerQueries: this.appServerSharedQueries, + threadCatalog: this.threadCatalog, + }), saveSettings: () => this.options.saveSettings(), refreshOpenViews: () => { this.refreshOpenViews(); }, - appServerQueries: this.appServerSharedQueries, - threadCatalog: this.threadCatalog, }; } diff --git a/src/settings/app-server/dynamic-data.ts b/src/settings/app-server/dynamic-data.ts new file mode 100644 index 00000000..69b1d91d --- /dev/null +++ b/src/settings/app-server/dynamic-data.ts @@ -0,0 +1,80 @@ +import type { AppServerClient } from "../../app-server/connection/client"; +import type { AppServerClientAccess } from "../../app-server/connection/client-access"; +import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries"; +import type { ThreadCatalogArchivedReader, ThreadCatalogEventSink } from "../../app-server/query/thread-catalog"; +import { listHookCatalog, setHookItemEnabled, trustHookItem } from "../../app-server/services/catalog"; +import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../../app-server/services/threads"; +import type { ModelMetadata } from "../../domain/catalog/metadata"; +import type { ObservedResultListener } from "../../shared/query/observed-result"; +import { type SettingsDynamicDataAccess, type SettingsHookCatalog, StaleSettingsDynamicDataContextError } from "../dynamic-data"; + +interface SettingsAppServerQueries { + modelsSnapshot(): readonly ModelMetadata[] | null; + observeModelsResult(listener: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void; + fetchModels(): Promise; + refreshModels(): Promise; + notifyContextChanged(): void; +} + +type SettingsArchivedThreadCatalog = ThreadCatalogArchivedReader & ThreadCatalogEventSink; + +export interface SettingsAppServerDynamicDataOptions { + vaultPath: string; + clientAccess: AppServerClientAccess; + appServerQueries: SettingsAppServerQueries; + threadCatalog: SettingsArchivedThreadCatalog; +} + +export function createSettingsAppServerDynamicData(options: SettingsAppServerDynamicDataOptions): SettingsDynamicDataAccess { + const withSettingsConnection = (operation: (client: AppServerClient) => Promise): Promise => + options.clientAccess.withClient(operation, { + serverRequests: { kind: "reject", message: "Codex Panel settings does not handle server requests." }, + }); + + return { + modelsSnapshot: () => options.appServerQueries.modelsSnapshot(), + observeModelsResult: (listener, observeOptions) => options.appServerQueries.observeModelsResult(listener, observeOptions), + fetchModels: () => mapStaleContextError(() => options.appServerQueries.fetchModels()), + refreshModels: () => mapStaleContextError(() => options.appServerQueries.refreshModels()), + archivedThreadsSnapshot: () => options.threadCatalog.archivedSnapshot(), + observeArchivedThreadsResult: (listener, observeOptions) => options.threadCatalog.observeArchived(listener, observeOptions), + refreshArchivedThreads: () => mapStaleContextError(() => options.threadCatalog.refreshArchived()), + loadHooks: () => withSettingsConnection((client) => loadSettingsHookCatalog(client, options.vaultPath)), + trustHook: (hook) => withSettingsConnection((client) => trustHookItem(client, hook)), + setHookEnabled: (hook, enabled) => withSettingsConnection((client) => setHookItemEnabled(client, hook, enabled)), + restoreArchivedThread: async (threadId, mutationOptions) => { + const thread = await withSettingsConnection((client) => restoreArchivedThreadOnAppServer(client, threadId)); + if (mutationOptions?.shouldPublish?.() ?? true) { + options.threadCatalog.apply({ type: "thread-restored", thread }); + } + return thread; + }, + deleteArchivedThread: async (threadId, mutationOptions) => { + await withSettingsConnection((client) => client.deleteThread(threadId)); + if (mutationOptions?.shouldPublish?.() ?? true) { + options.threadCatalog.apply({ type: "thread-deleted", threadId }); + } + }, + notifyContextChanged: () => { + options.appServerQueries.notifyContextChanged(); + }, + }; +} + +async function loadSettingsHookCatalog(client: AppServerClient, cwd: string): Promise { + const hooks = await listHookCatalog(client, cwd); + const hookCount = hooks.hooks.length; + return { + ...hooks, + status: `Loaded ${String(hookCount)} hook${hookCount === 1 ? "" : "s"}.`, + }; +} + +async function mapStaleContextError(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (isStaleAppServerSharedQueryContextError(error)) throw new StaleSettingsDynamicDataContextError(); + throw error; + } +} diff --git a/src/settings/dynamic-data.ts b/src/settings/dynamic-data.ts new file mode 100644 index 00000000..da63872a --- /dev/null +++ b/src/settings/dynamic-data.ts @@ -0,0 +1,41 @@ +import type { HookItem, ModelMetadata } from "../domain/catalog/metadata"; +import type { Thread } from "../domain/threads/model"; +import type { ObservedResultListener } from "../shared/query/observed-result"; + +export interface SettingsHookCatalog { + hooks: readonly HookItem[]; + warnings: readonly string[]; + errors: readonly string[]; + status: string; +} + +interface SettingsDynamicDataMutationOptions { + shouldPublish?: () => boolean; +} + +export interface SettingsDynamicDataAccess { + modelsSnapshot(): readonly ModelMetadata[] | null; + observeModelsResult(listener: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void; + fetchModels(): Promise; + refreshModels(): Promise; + archivedThreadsSnapshot(): readonly Thread[] | null; + observeArchivedThreadsResult(listener: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void; + refreshArchivedThreads(): Promise; + loadHooks(): Promise; + trustHook(hook: HookItem): Promise; + setHookEnabled(hook: HookItem, enabled: boolean): Promise; + restoreArchivedThread(threadId: string, options?: SettingsDynamicDataMutationOptions): Promise; + deleteArchivedThread(threadId: string, options?: SettingsDynamicDataMutationOptions): Promise; + notifyContextChanged(): void; +} + +export class StaleSettingsDynamicDataContextError extends Error { + constructor() { + super("Settings dynamic data context changed while loading Codex details."); + this.name = "StaleSettingsDynamicDataContextError"; + } +} + +export function isStaleSettingsDynamicDataContextError(error: unknown): error is StaleSettingsDynamicDataContextError { + return error instanceof StaleSettingsDynamicDataContextError; +} diff --git a/src/settings/dynamic-sections-controller.ts b/src/settings/dynamic-sections-controller.ts index 9ea8c574..83cac402 100644 --- a/src/settings/dynamic-sections-controller.ts +++ b/src/settings/dynamic-sections-controller.ts @@ -1,13 +1,10 @@ -import type { AppServerClient } from "../app-server/connection/client"; -import { isStaleAppServerSharedQueryContextError } from "../app-server/query/shared-queries"; -import { type HookCatalog, listHookCatalog, setHookItemEnabled, trustHookItem } from "../app-server/services/catalog"; -import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/services/threads"; import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata"; import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../domain/catalog/metadata"; import type { Thread } from "../domain/threads/model"; import { threadArchiveDisplayTitle } from "../domain/threads/title"; import type { ObservedResult } from "../shared/query/observed-result"; import { observedValue } from "../shared/query/observed-result"; +import { isStaleSettingsDynamicDataContextError } from "./dynamic-data"; import type { SettingsDynamicSectionsHost } from "./host"; import { createSettingsDynamicSectionLifecycle, @@ -15,19 +12,6 @@ import { transitionSettingsDynamicSectionLifecycle, } from "./lifecycle"; -interface LoadedHookCatalog extends HookCatalog { - status: string; -} - -async function loadHookCatalog(client: AppServerClient, cwd: string): Promise { - const hooks = await listHookCatalog(client, cwd); - const hookCount = hooks.hooks.length; - return { - ...hooks, - status: `Loaded ${String(hookCount)} hook${hookCount === 1 ? "" : "s"}.`, - }; -} - interface SettingsDynamicSectionsControllerCallbacks { display(target: SettingsDynamicSectionsDisplayTarget): void; notify(message: string): void; @@ -75,8 +59,8 @@ export class SettingsDynamicSectionsController { activate(): void { if (this.unsubscribeModels) return; - this.models = [...(this.host.appServerQueries.modelsSnapshot() ?? [])]; - const archivedThreads = this.host.threadCatalog.archivedSnapshot(); + this.models = [...(this.host.dynamicData.modelsSnapshot() ?? [])]; + const archivedThreads = this.host.dynamicData.archivedThreadsSnapshot(); if (archivedThreads) { this.archivedThreads = [...archivedThreads]; this.archivedThreadsLoaded = true; @@ -86,13 +70,13 @@ export class SettingsDynamicSectionsController { operationToken: this.archivedThreadsOperationToken, }); } - this.unsubscribeModels = this.host.appServerQueries.observeModelsResult( + this.unsubscribeModels = this.host.dynamicData.observeModelsResult( (result) => { this.receiveObservedModelsResult(result); }, { emitCurrent: false }, ); - this.unsubscribeArchivedThreads = this.host.threadCatalog.observeArchived( + this.unsubscribeArchivedThreads = this.host.dynamicData.observeArchivedThreadsResult( (result) => { this.receiveObservedArchivedThreadsResult(result); }, @@ -112,7 +96,7 @@ export class SettingsDynamicSectionsController { this.modelsOperationToken += 1; this.hooksOperationToken += 1; this.archivedThreadsOperationToken += 1; - this.models = [...(this.host.appServerQueries.modelsSnapshot() ?? [])]; + this.models = [...(this.host.dynamicData.modelsSnapshot() ?? [])]; this.modelsLifecycle = createSettingsDynamicSectionLifecycle(); this.hooks = []; this.hookWarnings = []; @@ -179,9 +163,9 @@ export class SettingsDynamicSectionsController { let failedCount = 0; try { const [modelsResult, hooksResult, archivedThreadsResult] = await Promise.allSettled([ - options.forceModels === false ? this.host.appServerQueries.fetchModels() : this.host.appServerQueries.refreshModels(), - this.withSettingsConnection((client) => loadHookCatalog(client, this.host.vaultPath)), - this.host.threadCatalog.refreshArchived(), + options.forceModels === false ? this.host.dynamicData.fetchModels() : this.host.dynamicData.refreshModels(), + this.host.dynamicData.loadHooks(), + this.host.dynamicData.refreshArchivedThreads(), ] as const); if (this.isStaleDynamicSectionsRefreshOperation(operationToken)) return; @@ -194,7 +178,7 @@ export class SettingsDynamicSectionsController { status: `Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`, operationToken: modelsOperationToken, }); - } else if (isStaleAppServerSharedQueryContextError(modelsResult.reason)) { + } else if (isStaleSettingsDynamicDataContextError(modelsResult.reason)) { return; } else { failedCount += 1; @@ -208,9 +192,9 @@ export class SettingsDynamicSectionsController { if (this.isStaleHooksOperation(hooksOperationToken)) { // A newer hooks operation owns this section. } else if (hooksResult.status === "fulfilled") { - this.hooks = hooksResult.value.hooks; - this.hookWarnings = hooksResult.value.warnings; - this.hookErrors = hooksResult.value.errors; + this.hooks = [...hooksResult.value.hooks]; + this.hookWarnings = [...hooksResult.value.warnings]; + this.hookErrors = [...hooksResult.value.errors]; this.hooksLoaded = true; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "loaded", @@ -236,7 +220,7 @@ export class SettingsDynamicSectionsController { status: archivedThreadsStatus(archivedThreadsResult.value.length), operationToken: archivedThreadsOperationToken, }); - } else if (isStaleAppServerSharedQueryContextError(archivedThreadsResult.reason)) { + } else if (isStaleSettingsDynamicDataContextError(archivedThreadsResult.reason)) { return; } else { failedCount += 1; @@ -310,7 +294,7 @@ export class SettingsDynamicSectionsController { failureStatus: (error) => `Could not trust hook: ${errorMessage(error)}`, failureNotice: "Could not trust Codex hook.", operation: async (operationToken) => { - await this.withSettingsConnection((client) => trustHookItem(client, hook)); + await this.host.dynamicData.trustHook(hook); if (this.isStaleHooksOperation(operationToken)) return; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "loaded", @@ -329,7 +313,7 @@ export class SettingsDynamicSectionsController { failureStatus: (error) => `Could not update hook: ${errorMessage(error)}`, failureNotice: "Could not update Codex hook.", operation: async (operationToken) => { - await this.withSettingsConnection((client) => setHookItemEnabled(client, hook, enabled)); + await this.host.dynamicData.setHookEnabled(hook, enabled); if (this.isStaleHooksOperation(operationToken)) return; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "loaded", @@ -348,10 +332,11 @@ export class SettingsDynamicSectionsController { failureStatus: (error) => `Could not restore archived thread: ${errorMessage(error)}`, failureNotice: "Could not restore archived Codex thread.", operation: async (operationToken) => { - const restoredThread = await this.withSettingsConnection((client) => restoreArchivedThreadOnAppServer(client, threadId)); + const restoredThread = await this.host.dynamicData.restoreArchivedThread(threadId, { + shouldPublish: () => !this.isStaleArchivedThreadsOperation(operationToken), + }); if (this.isStaleArchivedThreadsOperation(operationToken)) return; this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId); - this.host.threadCatalog.apply({ type: "thread-restored", thread: restoredThread }); this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "loaded", status: `Restored "${threadArchiveDisplayTitle(restoredThread)}".`, @@ -370,10 +355,11 @@ export class SettingsDynamicSectionsController { failureStatus: (error) => `Could not delete archived thread: ${errorMessage(error)}`, failureNotice: "Could not delete archived Codex thread.", operation: async (operationToken) => { - await this.withSettingsConnection((client) => client.deleteThread(threadId)); + await this.host.dynamicData.deleteArchivedThread(threadId, { + shouldPublish: () => !this.isStaleArchivedThreadsOperation(operationToken), + }); if (this.isStaleArchivedThreadsOperation(operationToken)) return; this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId); - this.host.threadCatalog.apply({ type: "thread-deleted", threadId }); this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "loaded", status: `Deleted "${title}".`, @@ -407,11 +393,11 @@ export class SettingsDynamicSectionsController { failureStatus: (error) => `Could not load hooks: ${errorMessage(error)}`, failureNotice: "Could not load Codex hooks.", operation: async (operationToken) => { - const hooks = await this.withSettingsConnection((client) => loadHookCatalog(client, this.host.vaultPath)); + const hooks = await this.host.dynamicData.loadHooks(); if (this.isStaleHooksOperation(operationToken)) return; - this.hooks = hooks.hooks; - this.hookWarnings = hooks.warnings; - this.hookErrors = hooks.errors; + this.hooks = [...hooks.hooks]; + this.hookWarnings = [...hooks.warnings]; + this.hookErrors = [...hooks.errors]; this.hooksLoaded = true; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "loaded", @@ -467,12 +453,6 @@ export class SettingsDynamicSectionsController { } } - private async withSettingsConnection(operation: (client: AppServerClient) => Promise): Promise { - return this.host.clientAccess.withClient(operation, { - serverRequests: { kind: "reject", message: "Codex Panel settings does not handle server requests." }, - }); - } - private nextDynamicSectionsRefreshOperationToken(): number { this.dynamicSectionsRefreshOperationToken += 1; return this.dynamicSectionsRefreshOperationToken; diff --git a/src/settings/host.ts b/src/settings/host.ts index 371831e4..1986b6e2 100644 --- a/src/settings/host.ts +++ b/src/settings/host.ts @@ -1,23 +1,9 @@ -import type { AppServerClientAccess } from "../app-server/connection/client-access"; -import type { ThreadCatalogArchivedReader, ThreadCatalogEventSink } from "../app-server/query/thread-catalog"; -import type { ModelMetadata } from "../domain/catalog/metadata"; -import type { ObservedResultListener } from "../shared/query/observed-result"; +import type { SettingsDynamicDataAccess } from "./dynamic-data"; import type { CodexPanelSettings } from "./model"; -interface SettingsAppServerQueries { - modelsSnapshot(): readonly ModelMetadata[] | null; - observeModelsResult(listener: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void; - fetchModels(): Promise; - refreshModels(): Promise; - notifyContextChanged(): void; -} - export interface SettingsDynamicSectionsHost { settings: CodexPanelSettings; - vaultPath: string; - clientAccess: AppServerClientAccess; - appServerQueries: SettingsAppServerQueries; - threadCatalog: ThreadCatalogArchivedReader & ThreadCatalogEventSink; + dynamicData: SettingsDynamicDataAccess; } export interface CodexPanelSettingTabHost extends SettingsDynamicSectionsHost { diff --git a/src/settings/tab.obsidian.tsx b/src/settings/tab.obsidian.tsx index a304ff67..6bf7adaf 100644 --- a/src/settings/tab.obsidian.tsx +++ b/src/settings/tab.obsidian.tsx @@ -176,7 +176,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { this.plugin.settings.codexPath = codexPath; await this.plugin.saveSettings(); this.dynamicSections.resetDynamicSectionContext(); - this.plugin.appServerQueries.notifyContextChanged(); + this.plugin.dynamicData.notifyContextChanged(); this.plugin.refreshOpenViews(); this.renderSettingsShell(); } diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index e8643123..8c893254 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -21,6 +21,8 @@ const APP_SERVER_ROOT_MODULE_IMPORT_MESSAGE = "Import app-server boundary modules from responsibility subfolders, not src/app-server root modules."; const APP_SERVER_SUBFOLDER_ROOT_IMPORT_MESSAGE = "App-server subfolders must not import sibling root modules; move the dependency into a responsibility subfolder."; +const SETTINGS_APP_SERVER_BOUNDARY_MESSAGE = + "Settings modules must depend on settings-owned dynamic data ports instead of importing app-server modules or settings app-server adapters."; const CHAT_APPLICATION_OUTER_LAYER_MESSAGE = "Chat application modules must not import app-server, host, panel, presentation, or UI layers; expose state and workflow contracts instead."; const CHAT_APP_SERVER_OUTER_LAYER_MESSAGE = "Chat app-server adapters must not import chat host, panel, presentation, or UI layers."; @@ -826,6 +828,14 @@ export const value = statusText; ]); }); + it("keeps settings app-server access behind settings app-server adapters", async () => { + const report = await appServerBoundaryPolicyReport(); + + expect(pluginMessages(report, "src/settings/dynamic-sections-controller.ts")).toEqual([SETTINGS_APP_SERVER_BOUNDARY_MESSAGE]); + expect(pluginMessages(report, "src/settings/adapter-leak.ts")).toEqual([SETTINGS_APP_SERVER_BOUNDARY_MESSAGE]); + expect(pluginDiagnostics(report, "src/settings/app-server/dynamic-data.ts")).toEqual([]); + }); + it("keeps app-server root from becoming a boundary escape hatch", async () => { const cwd = await tempBiomeWorkspace([ "no-responsibility-root-module-files.grit", @@ -1056,6 +1066,7 @@ async function createAppServerBoundaryPolicyReport() { "no-lower-level-feature-imports.grit", "no-app-server-connection-boundary-imports.grit", "no-app-server-projection-rpcs.grit", + "no-settings-app-server-boundary-imports.grit", ]); await writeFile( path.join(cwd, "src/features/chat/domain/generated-thread.ts"), @@ -1317,6 +1328,30 @@ export async function read(client: AppServerClient): Promise { import type { AppServerClient } from "../../../app-server/connection/client"; export type AppServerThreadResumeClient = Pick; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/settings/dynamic-sections-controller.ts"), + ` +import { listHookCatalog } from "../app-server/services/catalog"; + +export const load = listHookCatalog; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/settings/adapter-leak.ts"), + ` +import { createSettingsAppServerDynamicData } from "./app-server/dynamic-data"; + +export const create = createSettingsAppServerDynamicData; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/settings/app-server/dynamic-data.ts"), + ` +import { listHookCatalog } from "../../app-server/services/catalog"; + +export const load = listHookCatalog; `.trimStart(), ); @@ -1352,6 +1387,9 @@ export type AppServerThreadResumeClient = Pick; "src/features/chat/application/threads/history.ts", "src/app-server/services/threads.ts", "src/features/chat/host/connection-bundle.ts", + "src/settings/dynamic-sections-controller.ts", + "src/settings/adapter-leak.ts", + "src/settings/app-server/dynamic-data.ts", ], cwd, ); @@ -1377,6 +1415,7 @@ async function tempBiomeWorkspace(plugins) { await mkdir(path.join(cwd, "src/features/threads-view"), { recursive: true }); await mkdir(path.join(cwd, "src/features/turn-diff"), { recursive: true }); await mkdir(path.join(cwd, "src/settings"), { recursive: true }); + await mkdir(path.join(cwd, "src/settings/app-server"), { recursive: true }); await mkdir(path.join(cwd, "src/domain/threads"), { recursive: true }); await mkdir(path.join(cwd, "src/app-server/connection"), { recursive: true }); await mkdir(path.join(cwd, "src/app-server/protocol"), { recursive: true }); diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 0851ade9..d9c8d8ca 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -1,12 +1,16 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AppServerClient } from "../../src/app-server/connection/client"; import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access"; import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog"; import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog"; import type { ThreadRecord } from "../../src/app-server/protocol/thread"; +import type { ThreadCatalogEvent } from "../../src/app-server/query/thread-catalog"; import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata"; import type { Thread } from "../../src/domain/threads/model"; +import { createSettingsAppServerDynamicData } from "../../src/settings/app-server/dynamic-data"; +import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data"; import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller"; import type { CodexPanelSettingTabHost } from "../../src/settings/host"; import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian"; @@ -952,14 +956,15 @@ function newSettingsTab( modelsSnapshot?: ModelMetadata[]; fetchModels?: () => Promise; refreshModels?: () => Promise; - observeModels?: CodexPanelSettingTabHost["appServerQueries"]["observeModelsResult"]; + observeModels?: SettingsDynamicDataAccess["observeModelsResult"]; notifyContextChanged?: () => void; refreshOpenViews?: () => void; archivedThreads?: Thread[]; archivedSnapshot?: Thread[] | null; refreshArchived?: () => Promise; - observeArchived?: CodexPanelSettingTabHost["threadCatalog"]["observeArchived"]; - applyThreadCatalogEvent?: CodexPanelSettingTabHost["threadCatalog"]["apply"]; + observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"]; + applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void; + dynamicData?: SettingsDynamicDataAccess; settings?: Partial<{ threadNamingModel: string | null; threadNamingEffort: string | null; @@ -978,14 +983,15 @@ function settingsTabHost( modelsSnapshot?: ModelMetadata[]; fetchModels?: () => Promise; refreshModels?: () => Promise; - observeModels?: CodexPanelSettingTabHost["appServerQueries"]["observeModelsResult"]; + observeModels?: SettingsDynamicDataAccess["observeModelsResult"]; notifyContextChanged?: () => void; refreshOpenViews?: () => void; archivedThreads?: Thread[]; archivedSnapshot?: Thread[] | null; refreshArchived?: () => Promise; - observeArchived?: CodexPanelSettingTabHost["threadCatalog"]["observeArchived"]; - applyThreadCatalogEvent?: CodexPanelSettingTabHost["threadCatalog"]["apply"]; + observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"]; + applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void; + dynamicData?: SettingsDynamicDataAccess; settings?: Partial<{ threadNamingModel: string | null; threadNamingEffort: string | null; @@ -1009,29 +1015,35 @@ function settingsTabHost( archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md", archiveExportTags: "", }; + const appServerQueries = { + modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []), + fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), + refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), + observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined), + notifyContextChanged: options.notifyContextChanged ?? vi.fn(), + }; + const threadCatalog = { + archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null), + loadArchived: vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads), + refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads), + observeArchived: options.observeArchived ?? vi.fn(() => () => undefined), + apply: options.applyThreadCatalogEvent ?? vi.fn(), + }; return { settings, - vaultPath: "/vault", - clientAccess: { - withClient: (operation: (client: never) => Promise, clientOptions?: AppServerClientAccessOptions) => - withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise, - }, + dynamicData: + options.dynamicData ?? + createSettingsAppServerDynamicData({ + vaultPath: "/vault", + clientAccess: { + withClient: (operation: (client: AppServerClient) => Promise, clientOptions?: AppServerClientAccessOptions) => + withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise, + }, + appServerQueries, + threadCatalog, + }), saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined), refreshOpenViews: options.refreshOpenViews ?? vi.fn(), - appServerQueries: { - modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []), - fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), - refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []), - observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined), - notifyContextChanged: options.notifyContextChanged ?? vi.fn(), - }, - threadCatalog: { - archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null), - loadArchived: vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads), - refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads), - observeArchived: options.observeArchived ?? vi.fn(() => () => undefined), - apply: options.applyThreadCatalogEvent ?? vi.fn(), - }, }; }