diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index fc7048b2..b673f36f 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -1,10 +1,7 @@ import type { App } from "obsidian"; import type { AppServerClient } from "./app-server/connection/client"; import type { AppServerClientAccessOptions } from "./app-server/connection/client-access"; -import type { ObservedResultListener } from "./app-server/query/observed-result"; import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"; -import type { ModelMetadata } from "./domain/catalog/metadata"; -import type { Thread } from "./domain/threads/model"; import { CodexExecutionRuntime } from "./execution-runtime"; import type { ChatRuntimeView, @@ -40,7 +37,6 @@ export interface CodexPanelRuntimeOptions { export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRuntimeOwner { private readonly panels: WorkspacePanelCoordinator; private executionRuntime: CodexExecutionRuntime | null = null; - private readonly settingsDynamicData = new SwappableSettingsDynamicData(); private selectionRewriteController: SelectionRewriteCommandController | null = null; constructor(private readonly options: CodexPanelRuntimeOptions) { @@ -55,7 +51,6 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti initialize(): void { if (this.executionRuntime) throw new Error("Codex execution runtime is already initialized."); this.executionRuntime = this.createExecutionRuntime(this.options.settingsRef.settings.codexPath); - this.settingsDynamicData.replace(this.executionRuntime.settingsDynamicData); } reset(): void { @@ -64,7 +59,6 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti const executionRuntime = this.executionRuntime; this.executionRuntime = null; executionRuntime?.dispose(); - this.settingsDynamicData.replace(null); this.panels.reset(); } @@ -145,15 +139,16 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti settingTabHost(): CodexPanelSettingTabHost { return { settings: this.options.settingsRef.settings, - dynamicData: this.settingsDynamicData, + dynamicData: this.currentExecutionRuntime().settingsDynamicData, publishSettings: (settings) => this.publishSettings(settings), }; } - private async publishSettings(settings: CodexPanelSettings): Promise<{ appServerContextReplaced: boolean }> { + private async publishSettings(settings: CodexPanelSettings): Promise<{ replacementDynamicData: SettingsDynamicDataAccess | null }> { const previousSettings = { ...this.options.settingsRef.settings }; await this.options.saveSettings(settings); const appServerContextReplaced = previousSettings.codexPath !== settings.codexPath; + let replacementDynamicData: SettingsDynamicDataAccess | null = null; if (appServerContextReplaced) { const nextRuntime = this.createExecutionRuntime(settings.codexPath); this.selectionRewriteController?.closeAll(); @@ -162,14 +157,14 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti this.executionRuntime = null; const views = previousRuntime?.dispose() ?? { chat: [], threads: [] }; Object.assign(this.options.settingsRef.settings, settings); - this.settingsDynamicData.replace(nextRuntime.settingsDynamicData); nextRuntime.adoptViews(views); this.executionRuntime = nextRuntime; + replacementDynamicData = nextRuntime.settingsDynamicData; } else { Object.assign(this.options.settingsRef.settings, settings); } if (appServerContextReplaced || previousSettings.showToolbar !== settings.showToolbar) this.refreshOpenViews(); - return { appServerContextReplaced }; + return { replacementDynamicData }; } private async openTurnDiff(state: TurnDiffViewState): Promise { @@ -275,98 +270,3 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti }); } } - -interface StableObserver { - readonly listener: ObservedResultListener; - readonly options: { emitCurrent?: boolean } | undefined; - unsubscribe: (() => void) | null; -} - -export class SwappableSettingsDynamicData implements SettingsDynamicDataAccess { - private current: SettingsDynamicDataAccess | null = null; - private readonly modelObservers = new Set>(); - private readonly archivedThreadObservers = new Set>(); - - replace(next: SettingsDynamicDataAccess | null): void { - for (const observer of [...this.modelObservers, ...this.archivedThreadObservers]) { - observer.unsubscribe?.(); - observer.unsubscribe = null; - } - this.current = next; - if (!next) return; - for (const observer of this.modelObservers) { - observer.unsubscribe = next.observeModelsResult(observer.listener, observer.options); - } - for (const observer of this.archivedThreadObservers) { - observer.unsubscribe = next.observeArchivedThreadsResult(observer.listener, observer.options); - } - } - - modelsSnapshot(): readonly ModelMetadata[] | null { - return this.delegate().modelsSnapshot(); - } - - observeModelsResult(listener: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void { - const observer: StableObserver = { listener, options, unsubscribe: null }; - this.modelObservers.add(observer); - observer.unsubscribe = this.delegate().observeModelsResult(listener, options); - return () => { - observer.unsubscribe?.(); - this.modelObservers.delete(observer); - }; - } - - fetchModels(): Promise { - return this.delegate().fetchModels(); - } - - refreshModels(): Promise { - return this.delegate().refreshModels(); - } - - archivedThreadsSnapshot(): readonly Thread[] | null { - return this.delegate().archivedThreadsSnapshot(); - } - - observeArchivedThreadsResult(listener: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void { - const observer: StableObserver = { listener, options, unsubscribe: null }; - this.archivedThreadObservers.add(observer); - observer.unsubscribe = this.delegate().observeArchivedThreadsResult(listener, options); - return () => { - observer.unsubscribe?.(); - this.archivedThreadObservers.delete(observer); - }; - } - - refreshArchivedThreads(): Promise { - return this.delegate().refreshArchivedThreads(); - } - - refreshHooks(): ReturnType { - return this.delegate().refreshHooks(); - } - - trustHook(hook: Parameters[0]): ReturnType { - return this.delegate().trustHook(hook); - } - - setHookEnabled( - hook: Parameters[0], - enabled: boolean, - ): ReturnType { - return this.delegate().setHookEnabled(hook, enabled); - } - - restoreArchivedThread(threadId: string): Promise { - return this.delegate().restoreArchivedThread(threadId); - } - - deleteArchivedThread(threadId: string): Promise { - return this.delegate().deleteArchivedThread(threadId); - } - - private delegate(): SettingsDynamicDataAccess { - if (!this.current) throw new Error("Codex execution runtime is not initialized."); - return this.current; - } -} diff --git a/src/settings/app-server-dynamic-data.ts b/src/settings/app-server-dynamic-data.ts index 234c9bc8..50356f56 100644 --- a/src/settings/app-server-dynamic-data.ts +++ b/src/settings/app-server-dynamic-data.ts @@ -1,13 +1,12 @@ import type { AppServerClient } from "../app-server/connection/client"; import type { AppServerClientAccess } from "../app-server/connection/client-access"; import type { ObservedResultListener } from "../app-server/query/observed-result"; -import { isStaleAppServerResourceContextError } from "../app-server/query/resource-store"; import { listHookCatalog, setHookItemEnabled, trustHookItem } from "../app-server/services/catalog"; import { deleteThread, restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/services/threads"; import type { HookItem, ModelMetadata } from "../domain/catalog/metadata"; import type { ThreadCatalogArchivedReader, ThreadCatalogEventSink } from "../features/threads/catalog/thread-catalog"; import { createKeyedOperationQueue } from "../shared/runtime/keyed-operation-queue"; -import { type SettingsDynamicDataAccess, type SettingsHookCatalog, StaleSettingsDynamicDataContextError } from "./dynamic-data"; +import type { SettingsDynamicDataAccess, SettingsHookCatalog } from "./dynamic-data"; interface SettingsAppServerQueries { modelsSnapshot(): readonly ModelMetadata[] | null; @@ -32,26 +31,24 @@ export function createSettingsAppServerDynamicData(options: SettingsAppServerDyn serverRequests: { kind: "reject", message: "Codex Panel settings does not handle server requests." }, }); const runArchivedThreadMutation = (threadId: string, operation: () => Promise): Promise => { - return archivedThreadMutations.run(threadId, () => mapStaleContextError(operation)); + return archivedThreadMutations.run(threadId, operation); }; const loadHooks = (client: AppServerClient): Promise => loadSettingsHookCatalog(client, options.vaultPath); const mutateHook = (hook: HookItem, mutation: (client: AppServerClient, hook: HookItem) => Promise): Promise => - mapStaleContextError(() => - withSettingsConnection(async (client) => { - await mutation(client, hook); - return loadHooks(client); - }), - ); + withSettingsConnection(async (client) => { + await mutation(client, hook); + return loadHooks(client); + }); return { modelsSnapshot: () => options.appServerQueries.modelsSnapshot(), observeModelsResult: (listener, observeOptions) => options.appServerQueries.observeModelsResult(listener, observeOptions), - fetchModels: () => mapStaleContextError(() => options.appServerQueries.fetchModels()), - refreshModels: () => mapStaleContextError(() => options.appServerQueries.refreshModels()), + fetchModels: () => options.appServerQueries.fetchModels(), + refreshModels: () => options.appServerQueries.refreshModels(), archivedThreadsSnapshot: () => options.threadCatalog.archivedSnapshot(), observeArchivedThreadsResult: (listener, observeOptions) => options.threadCatalog.observeArchived(listener, observeOptions), - refreshArchivedThreads: () => mapStaleContextError(() => options.threadCatalog.refreshArchived()), - refreshHooks: () => mapStaleContextError(() => withSettingsConnection(loadHooks)), + refreshArchivedThreads: () => options.threadCatalog.refreshArchived(), + refreshHooks: () => withSettingsConnection(loadHooks), trustHook: (hook) => mutateHook(hook, trustHookItem), setHookEnabled: (hook, enabled) => mutateHook(hook, (client, item) => setHookItemEnabled(client, item, enabled)), restoreArchivedThread: (threadId) => @@ -76,12 +73,3 @@ async function loadSettingsHookCatalog(client: AppServerClient, vaultPath: strin status: `Loaded ${String(hookCount)} hook${hookCount === 1 ? "" : "s"}.`, }; } - -async function mapStaleContextError(operation: () => Promise): Promise { - try { - return await operation(); - } catch (error) { - if (isStaleAppServerResourceContextError(error)) throw new StaleSettingsDynamicDataContextError(); - throw error; - } -} diff --git a/src/settings/dynamic-data.ts b/src/settings/dynamic-data.ts index 92feb70f..4eec7e94 100644 --- a/src/settings/dynamic-data.ts +++ b/src/settings/dynamic-data.ts @@ -23,14 +23,3 @@ export interface SettingsDynamicDataAccess { restoreArchivedThread(threadId: string): Promise; deleteArchivedThread(threadId: string): Promise; } - -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 80d9ef85..dc731e95 100644 --- a/src/settings/dynamic-sections-controller.ts +++ b/src/settings/dynamic-sections-controller.ts @@ -4,7 +4,7 @@ import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForMo import type { Thread } from "../domain/threads/model"; import { threadArchiveDisplayTitle } from "../domain/threads/title"; import { OwnerLifetime } from "../shared/runtime/owner-lifetime"; -import { isStaleSettingsDynamicDataContextError, type SettingsHookCatalog } from "./dynamic-data"; +import type { SettingsDynamicDataAccess, SettingsHookCatalog } from "./dynamic-data"; import type { SettingsDynamicSectionsHost } from "./host"; interface SettingsDynamicSectionsControllerCallbacks { @@ -50,30 +50,65 @@ export class SettingsDynamicSectionsController { private modelsLifecycle: SettingsDynamicSectionLifecycleState = createSettingsDynamicSectionLifecycle(); private unsubscribeModels: (() => void) | null = null; private unsubscribeArchivedThreads: (() => void) | null = null; + private dynamicData: SettingsDynamicDataAccess; constructor( private readonly host: SettingsDynamicSectionsHost, private readonly callbacks: SettingsDynamicSectionsControllerCallbacks, - ) {} + ) { + this.dynamicData = host.dynamicData; + } activate(): void { if (this.unsubscribeModels) return; this.lifetime.activate(); - this.models = [...(this.host.dynamicData.modelsSnapshot() ?? [])]; - const archivedThreads = this.host.dynamicData.archivedThreadsSnapshot(); + this.loadSnapshots(); + this.subscribe(); + } + + replaceDynamicData(next: SettingsDynamicDataAccess): void { + if (next === this.dynamicData) return; + this.unsubscribe(); + this.modelsOperationToken += 1; + this.archivedThreadsOperationToken += 1; + this.hookMutationOperation = null; + this.dynamicData = next; + this.dynamicSectionsAutoLoadStarted = false; + this.modelsLifecycle = createSettingsDynamicSectionLifecycle(); + this.hooks = []; + this.hookWarnings = []; + this.hookErrors = []; + this.hooksLoaded = false; + this.hooksLifecycle = createSettingsDynamicSectionLifecycle(); + this.archivedThreads = []; + this.archivedThreadsLoaded = false; + this.archivedThreadsLifecycle = createSettingsDynamicSectionLifecycle(); + this.loadSnapshots(); + if (this.lifetime.isActive()) this.subscribe(); + } + + private loadSnapshots(): void { + this.models = [...(this.dynamicData.modelsSnapshot() ?? [])]; + const archivedThreads = this.dynamicData.archivedThreadsSnapshot(); if (archivedThreads) { this.archivedThreads = [...archivedThreads]; this.archivedThreadsLoaded = true; this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(archivedThreadsStatus(archivedThreads.length)); } - this.unsubscribeModels = this.host.dynamicData.observeModelsResult( + } + + private subscribe(): void { + const dynamicData = this.dynamicData; + this.unsubscribeModels = dynamicData.observeModelsResult( (result) => { + if (!this.dynamicDataIsCurrent(dynamicData)) return; this.receiveObservedModelsResult(result); }, { emitCurrent: false }, ); - this.unsubscribeArchivedThreads = this.host.dynamicData.observeArchivedThreadsResult( + this.unsubscribeArchivedThreads = dynamicData.observeArchivedThreadsResult( (result) => { + if (!this.dynamicDataIsCurrent(dynamicData)) return; this.receiveObservedArchivedThreadsResult(result); }, { emitCurrent: false }, @@ -86,23 +121,6 @@ export class SettingsDynamicSectionsController { void this.refreshDynamicSections({ forceModels: false }); } - resetDynamicSectionContext(): void { - this.dynamicSectionsAutoLoadStarted = false; - this.modelsOperationToken += 1; - this.archivedThreadsOperationToken += 1; - this.hookMutationOperation = null; - this.models = [...(this.host.dynamicData.modelsSnapshot() ?? [])]; - this.modelsLifecycle = createSettingsDynamicSectionLifecycle(); - this.hooks = []; - this.hookWarnings = []; - this.hookErrors = []; - this.hooksLoaded = false; - this.hooksLifecycle = createSettingsDynamicSectionLifecycle(); - this.archivedThreads = []; - this.archivedThreadsLoaded = false; - this.archivedThreadsLifecycle = createSettingsDynamicSectionLifecycle(); - } - dispose(): void { this.lifetime.dispose(); this.dynamicSectionsAutoLoadStarted = false; @@ -115,10 +133,7 @@ export class SettingsDynamicSectionsController { } this.modelsOperationToken += 1; this.archivedThreadsOperationToken += 1; - this.unsubscribeModels?.(); - this.unsubscribeModels = null; - this.unsubscribeArchivedThreads?.(); - this.unsubscribeArchivedThreads = null; + this.unsubscribe(); } private receiveObservedModelsResult(result: ObservedResult): void { @@ -152,6 +167,7 @@ export class SettingsDynamicSectionsController { async refreshDynamicSections(options: { forceModels?: boolean } = {}): Promise { const lifetime = this.lifetime.signal(); if (!this.lifetime.isCurrent(lifetime)) return; + const dynamicData = this.dynamicData; this.dynamicSectionsAutoLoadStarted = true; const modelsOperationToken = this.nextModelsOperationToken(); const archivedThreadsOperationToken = this.nextArchivedThreadsOperationToken(); @@ -163,11 +179,11 @@ export class SettingsDynamicSectionsController { let failedCount = 0; try { const [modelsResult, hooksResult, archivedThreadsResult] = await Promise.allSettled([ - options.forceModels === false ? this.host.dynamicData.fetchModels() : this.host.dynamicData.refreshModels(), - this.host.dynamicData.refreshHooks(), - this.host.dynamicData.refreshArchivedThreads(), + options.forceModels === false ? dynamicData.fetchModels() : dynamicData.refreshModels(), + dynamicData.refreshHooks(), + dynamicData.refreshArchivedThreads(), ] as const); - if (!this.lifetime.isCurrent(lifetime)) return; + if (!this.lifetime.isCurrent(lifetime) || !this.dynamicDataIsCurrent(dynamicData)) return; if (this.isStaleModelsOperation(modelsOperationToken)) { // A newer models operation owns this section. @@ -176,8 +192,6 @@ export class SettingsDynamicSectionsController { this.modelsLifecycle = settingsDynamicSectionLoaded( `Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`, ); - } else if (isStaleSettingsDynamicDataContextError(modelsResult.reason)) { - return; } else { failedCount += 1; this.modelsLifecycle = settingsDynamicSectionFailed(`Could not load models: ${errorMessage(modelsResult.reason)}`); @@ -186,8 +200,6 @@ export class SettingsDynamicSectionsController { if (hooksResult.status === "fulfilled") { this.receiveHookCatalog(hooksResult.value); this.hooksLifecycle = settingsDynamicSectionLoaded(hooksResult.value.status); - } else if (isStaleSettingsDynamicDataContextError(hooksResult.reason)) { - return; } else { failedCount += 1; this.hooksLifecycle = settingsDynamicSectionFailed(`Could not load hooks: ${errorMessage(hooksResult.reason)}`); @@ -199,8 +211,6 @@ export class SettingsDynamicSectionsController { this.archivedThreads = [...archivedThreadsResult.value]; this.archivedThreadsLoaded = true; this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(archivedThreadsStatus(archivedThreadsResult.value.length)); - } else if (isStaleSettingsDynamicDataContextError(archivedThreadsResult.reason)) { - return; } else { failedCount += 1; this.archivedThreadsLifecycle = settingsDynamicSectionFailed( @@ -208,7 +218,7 @@ export class SettingsDynamicSectionsController { ); } } catch (error) { - if (!this.lifetime.isCurrent(lifetime)) return; + if (!this.lifetime.isCurrent(lifetime) || !this.dynamicDataIsCurrent(dynamicData)) return; failedCount = 3; const message = errorMessage(error); if (!this.isStaleModelsOperation(modelsOperationToken)) { @@ -219,7 +229,7 @@ export class SettingsDynamicSectionsController { this.archivedThreadsLifecycle = settingsDynamicSectionFailed(`Could not load archived threads: ${message}`); } } finally { - if (this.lifetime.isCurrent(lifetime)) { + if (this.lifetime.isCurrent(lifetime) && this.dynamicDataIsCurrent(dynamicData)) { if (failedCount > 0) { this.callbacks.notify("Could not refresh all Codex details."); } @@ -255,7 +265,7 @@ export class SettingsDynamicSectionsController { failureStatus: (error) => `Could not trust hook: ${errorMessage(error)}`, failureNotice: "Could not trust Codex hook.", successStatus: "Trusted hook definition.", - operation: () => this.host.dynamicData.trustHook(hook), + operation: (dynamicData) => dynamicData.trustHook(hook), }); } @@ -265,7 +275,7 @@ export class SettingsDynamicSectionsController { failureStatus: (error) => `Could not update hook: ${errorMessage(error)}`, failureNotice: "Could not update Codex hook.", successStatus: enabled ? "Enabled hook." : "Disabled hook.", - operation: () => this.host.dynamicData.setHookEnabled(hook, enabled), + operation: (dynamicData) => dynamicData.setHookEnabled(hook, enabled), }); } @@ -274,8 +284,8 @@ export class SettingsDynamicSectionsController { loadingStatus: "Loading archived threads...", failureStatus: (error) => `Could not restore archived thread: ${errorMessage(error)}`, failureNotice: "Could not restore archived Codex thread.", - operation: async (operationToken) => { - const restoredThread = await this.host.dynamicData.restoreArchivedThread(threadId); + operation: async (dynamicData, operationToken) => { + const restoredThread = await dynamicData.restoreArchivedThread(threadId); if (this.isStaleArchivedThreadsOperation(operationToken)) return; this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId); this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(`Restored "${threadArchiveDisplayTitle(restoredThread)}".`); @@ -290,8 +300,8 @@ export class SettingsDynamicSectionsController { loadingStatus: "Loading archived threads...", failureStatus: (error) => `Could not delete archived thread: ${errorMessage(error)}`, failureNotice: "Could not delete archived Codex thread.", - operation: async (operationToken) => { - await this.host.dynamicData.deleteArchivedThread(threadId); + operation: async (dynamicData, operationToken) => { + await dynamicData.deleteArchivedThread(threadId); if (this.isStaleArchivedThreadsOperation(operationToken)) return; this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId); this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(`Deleted "${title}".`); @@ -321,20 +331,21 @@ export class SettingsDynamicSectionsController { failureStatus: (error: unknown) => string; failureNotice: string; successStatus: string; - operation: () => Promise; + operation: (dynamicData: SettingsDynamicDataAccess) => Promise; }): Promise { + const dynamicData = this.dynamicData; const operation = {}; this.hookMutationOperation = operation; - const isCurrent = (): boolean => this.hookMutationOperation === operation; + const isCurrent = (): boolean => this.hookMutationOperation === operation && this.dynamicDataIsCurrent(dynamicData); this.hooksLifecycle = settingsDynamicSectionLoading(options.loadingStatus); this.callbacks.display(); try { - const catalog = await options.operation(); + const catalog = await options.operation(dynamicData); if (!isCurrent()) return; this.receiveHookCatalog(catalog); this.hooksLifecycle = settingsDynamicSectionLoaded(options.successStatus); } catch (error) { - if (!isCurrent() || isStaleSettingsDynamicDataContextError(error)) return; + if (!isCurrent()) return; this.hooksLifecycle = settingsDynamicSectionFailed(options.failureStatus(error)); if (this.lifetime.isActive()) this.callbacks.notify(options.failureNotice); } finally { @@ -349,19 +360,21 @@ export class SettingsDynamicSectionsController { loadingStatus: string; failureStatus: (error: unknown) => string; failureNotice: string; - operation: (operationToken: number) => Promise; + operation: (dynamicData: SettingsDynamicDataAccess, operationToken: number) => Promise; }): Promise { const lifetime = this.lifetime.signal(); if (!this.lifetime.isCurrent(lifetime)) return; + const dynamicData = this.dynamicData; const operationToken = this.nextArchivedThreadsOperationToken(); - const stale = (): boolean => !this.lifetime.isCurrent(lifetime) || this.isStaleArchivedThreadsOperation(operationToken); + const stale = (): boolean => + !this.lifetime.isCurrent(lifetime) || !this.dynamicDataIsCurrent(dynamicData) || this.isStaleArchivedThreadsOperation(operationToken); this.archivedThreadsLifecycle = settingsDynamicSectionLoading(options.loadingStatus); this.callbacks.display(); try { - await options.operation(operationToken); + await options.operation(dynamicData, operationToken); } catch (error) { - if (stale() || isStaleSettingsDynamicDataContextError(error)) return; + if (stale()) return; this.archivedThreadsLifecycle = settingsDynamicSectionFailed(options.failureStatus(error)); this.callbacks.notify(options.failureNotice); } finally { @@ -386,6 +399,17 @@ export class SettingsDynamicSectionsController { private isStaleArchivedThreadsOperation(operationToken: number): boolean { return operationToken !== this.archivedThreadsOperationToken; } + + private dynamicDataIsCurrent(dynamicData: SettingsDynamicDataAccess): boolean { + return this.dynamicData === dynamicData; + } + + private unsubscribe(): void { + this.unsubscribeModels?.(); + this.unsubscribeModels = null; + this.unsubscribeArchivedThreads?.(); + this.unsubscribeArchivedThreads = null; + } } function archivedThreadsStatus(count: number): string { diff --git a/src/settings/host.ts b/src/settings/host.ts index ea20a5c6..ac0df9a4 100644 --- a/src/settings/host.ts +++ b/src/settings/host.ts @@ -3,9 +3,9 @@ import type { CodexPanelSettings } from "./model"; export interface SettingsDynamicSectionsHost { settings: CodexPanelSettings; - dynamicData: SettingsDynamicDataAccess; + readonly dynamicData: SettingsDynamicDataAccess; } export interface CodexPanelSettingTabHost extends SettingsDynamicSectionsHost { - publishSettings(settings: CodexPanelSettings): Promise<{ appServerContextReplaced: boolean }>; + publishSettings(settings: CodexPanelSettings): Promise<{ replacementDynamicData: SettingsDynamicDataAccess | null }>; } diff --git a/src/settings/tab.obsidian.tsx b/src/settings/tab.obsidian.tsx index 242f6f3c..1329ff03 100644 --- a/src/settings/tab.obsidian.tsx +++ b/src/settings/tab.obsidian.tsx @@ -478,18 +478,11 @@ export class CodexPanelSettingTab extends PluginSettingTab { private setCodexPath(value: string): Promise { const codexPath = value.trim() || DEFAULT_CODEX_PATH; - return this.queueSettingsMutation( - (settings) => { - if (codexPath === settings.codexPath) return false; - settings.codexPath = codexPath; - return true; - }, - { - onPublished: ({ appServerContextReplaced }) => { - if (appServerContextReplaced) this.dynamicSections.resetDynamicSectionContext(); - }, - }, - ); + return this.queueSettingsMutation((settings) => { + if (codexPath === settings.codexPath) return false; + settings.codexPath = codexPath; + return true; + }); } private setShowToolbar(value: boolean): Promise { @@ -576,16 +569,13 @@ export class CodexPanelSettingTab extends PluginSettingTab { }); } - private queueSettingsMutation( - mutate: (settings: CodexPanelSettings) => boolean | undefined, - publication: { onPublished?: (result: { appServerContextReplaced: boolean }) => void } = {}, - ): Promise { + private queueSettingsMutation(mutate: (settings: CodexPanelSettings) => boolean | undefined): Promise { const operation = this.settingsMutationQueue.then(async () => { const candidateSettings: CodexPanelSettings = { ...this.plugin.settings }; if (mutate(candidateSettings) === false) return; try { - const result = await this.plugin.publishSettings(candidateSettings); - publication.onPublished?.(result); + const { replacementDynamicData } = await this.plugin.publishSettings(candidateSettings); + if (replacementDynamicData) this.dynamicSections.replaceDynamicData(replacementDynamicData); } catch (error) { this.settingsShellRevision += 1; new Notice(`Failed to save Codex Panel settings: ${error instanceof Error ? error.message : String(error)}`); diff --git a/tests/plugin-runtime.integration.test.ts b/tests/plugin-runtime.integration.test.ts index 21b41f38..d201be98 100644 --- a/tests/plugin-runtime.integration.test.ts +++ b/tests/plugin-runtime.integration.test.ts @@ -6,7 +6,6 @@ import { VIEW_TYPE_CODEX_PANEL } from "../src/constants"; import type { Thread } from "../src/domain/threads/model"; import type { CodexChatView } from "../src/features/chat/host/view.obsidian"; import type CodexPanelPlugin from "../src/main"; -import { SwappableSettingsDynamicData } from "../src/plugin-runtime"; import { deferred } from "./support/async"; import { installObsidianDomShims } from "./support/dom"; import { @@ -408,29 +407,3 @@ describe("CodexPanelPlugin runtime integration", () => { expect(closeAll).toHaveBeenCalledOnce(); }); }); - -describe("SwappableSettingsDynamicData", () => { - it("moves existing observers to the replacement runtime", () => { - const firstUnsubscribe = vi.fn(); - const secondUnsubscribe = vi.fn(); - const first = { - observeModelsResult: vi.fn(() => firstUnsubscribe), - observeArchivedThreadsResult: vi.fn(() => vi.fn()), - }; - const second = { - observeModelsResult: vi.fn(() => secondUnsubscribe), - observeArchivedThreadsResult: vi.fn(() => vi.fn()), - }; - const dynamicData = new SwappableSettingsDynamicData(); - const listener = vi.fn(); - dynamicData.replace(first as never); - const unsubscribe = dynamicData.observeModelsResult(listener, { emitCurrent: false }); - - dynamicData.replace(second as never); - - expect(firstUnsubscribe).toHaveBeenCalledOnce(); - expect(second.observeModelsResult).toHaveBeenCalledWith(listener, { emitCurrent: false }); - unsubscribe(); - expect(secondUnsubscribe).toHaveBeenCalledOnce(); - }); -}); diff --git a/tests/settings/dynamic-sections-controller.test.ts b/tests/settings/dynamic-sections-controller.test.ts index c9871be7..aa59b5e3 100644 --- a/tests/settings/dynamic-sections-controller.test.ts +++ b/tests/settings/dynamic-sections-controller.test.ts @@ -3,9 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog"; import type { ThreadRecord } from "../../src/app-server/protocol/thread"; import type { ObservedResult } from "../../src/app-server/query/observed-result"; +import { StaleAppServerResourceContextError } from "../../src/app-server/query/resource-store"; import type { ModelMetadata } from "../../src/domain/catalog/metadata"; import type { Thread } from "../../src/domain/threads/model"; -import { StaleSettingsDynamicDataContextError } from "../../src/settings/dynamic-data"; import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller"; import { deferred } from "../support/async"; import { @@ -189,8 +189,8 @@ describe("SettingsDynamicSectionsController", () => { const oldMutation = controller.trustHook(hook({ key: "hook-old-context", trustStatus: "untrusted" })); await flushPromises(); - await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" }); - controller.resetDynamicSectionContext(); + const publication = await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" }); + controller.replaceDynamicData(publication.replacementDynamicData as NonNullable); await controller.refreshDynamicSections(); expect(controller.snapshot().hooks).toEqual([expect.objectContaining({ key: "hook-new-context" })]); @@ -338,8 +338,9 @@ describe("SettingsDynamicSectionsController", () => { const staleMutation = host.dynamicData.restoreArchivedThread("thread-shared"); await flushPromises(); - await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" }); - const currentMutation = host.dynamicData.restoreArchivedThread("thread-shared"); + const publication = await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" }); + if (!publication.replacementDynamicData) throw new Error("Expected replacement settings data."); + const currentMutation = publication.replacementDynamicData.restoreArchivedThread("thread-shared"); await expect(currentMutation).resolves.toMatchObject({ id: "thread-shared", preview: "New context" }); expect(newClient.requestHandlers["thread/unarchive"]).toHaveBeenCalledOnce(); @@ -347,7 +348,7 @@ describe("SettingsDynamicSectionsController", () => { oldRestore.resolve({ thread: appServerThread({ id: "thread-shared", preview: "Old context" }), }); - await expect(staleMutation).rejects.toBeInstanceOf(StaleSettingsDynamicDataContextError); + await expect(staleMutation).rejects.toBeInstanceOf(StaleAppServerResourceContextError); }); it("records restored archived threads in the active catalog", async () => { diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 1018528d..f57ab89a 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -329,6 +329,32 @@ describe("settings tab", () => { expect(tab.containerEl.children).toHaveLength(0); }); + it("binds the replacement executable data source when a hidden tab is shown again", async () => { + const save = deferred(); + const observeModels = vi.fn(() => vi.fn()); + useShortLivedClients(settingsClient(), settingsClient()); + const host = settingsTabHost({ + saveSettings: vi.fn(() => save.promise), + observeModels, + }); + const tab = new CodexPanelSettingTab({} as never, {} as never, host); + + tab.display(); + const codexInput = inputForSetting(tab, "Codex executable"); + if (!codexInput) throw new Error("Missing Codex executable input"); + codexInput.value = "/opt/codex-next"; + codexInput.dispatchEvent(new FocusEvent("blur")); + await Promise.resolve(); + tab.hide(); + + save.resolve(undefined); + await flushPromises(); + tab.display(); + + expect(host.settings.codexPath).toBe("/opt/codex-next"); + expect(observeModels).toHaveBeenCalledTimes(2); + }); + it("serializes overlapping settings saves", async () => { const firstSave = deferred(); const saveSettings = vi.fn().mockReturnValueOnce(firstSave.promise).mockResolvedValueOnce(undefined); diff --git a/tests/settings/test-support.ts b/tests/settings/test-support.ts index 025acc16..9dc34a80 100644 --- a/tests/settings/test-support.ts +++ b/tests/settings/test-support.ts @@ -8,7 +8,6 @@ import { StaleAppServerResourceContextError } from "../../src/app-server/query/r import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata"; import type { Thread } from "../../src/domain/threads/model"; import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog"; -import { SwappableSettingsDynamicData } from "../../src/plugin-runtime"; import { createSettingsAppServerDynamicData } from "../../src/settings/app-server-dynamic-data"; import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data"; import type { CodexPanelSettingTabHost } from "../../src/settings/host"; @@ -243,8 +242,6 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane return result; }, }; - const swappableDynamicData = options.dynamicData ? null : new SwappableSettingsDynamicData(); - const dynamicData: SettingsDynamicDataAccess = options.dynamicData ?? (swappableDynamicData as SwappableSettingsDynamicData); const createDynamicData = () => createSettingsAppServerDynamicData({ vaultPath: "/vault", @@ -252,8 +249,8 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane appServerQueries, threadCatalog, }); - swappableDynamicData?.replace(createDynamicData()); - return { + let dynamicData = options.dynamicData ?? createDynamicData(); + const host: CodexPanelSettingTabHost = { settings, dynamicData, publishSettings: async (nextSettings) => { @@ -261,11 +258,14 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane await (options.saveSettings ?? vi.fn().mockResolvedValue(undefined))(nextSettings); const appServerContextReplaced = previousSettings.codexPath !== nextSettings.codexPath; Object.assign(settings, nextSettings); - if (appServerContextReplaced) swappableDynamicData?.replace(createDynamicData()); + if (appServerContextReplaced && !options.dynamicData) { + dynamicData = createDynamicData(); + } if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.(); - return { appServerContextReplaced }; + return { replacementDynamicData: appServerContextReplaced ? dynamicData : null }; }, }; + return host; } export async function flushPromises(): Promise {