mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(settings): bind dynamic data to one runtime lease
This commit is contained in:
parent
aaa04507e0
commit
381646ebfb
10 changed files with 142 additions and 251 deletions
|
|
@ -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<void> {
|
||||
|
|
@ -275,98 +270,3 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface StableObserver<T> {
|
||||
readonly listener: ObservedResultListener<readonly T[]>;
|
||||
readonly options: { emitCurrent?: boolean } | undefined;
|
||||
unsubscribe: (() => void) | null;
|
||||
}
|
||||
|
||||
export class SwappableSettingsDynamicData implements SettingsDynamicDataAccess {
|
||||
private current: SettingsDynamicDataAccess | null = null;
|
||||
private readonly modelObservers = new Set<StableObserver<ModelMetadata>>();
|
||||
private readonly archivedThreadObservers = new Set<StableObserver<Thread>>();
|
||||
|
||||
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<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
const observer: StableObserver<ModelMetadata> = { listener, options, unsubscribe: null };
|
||||
this.modelObservers.add(observer);
|
||||
observer.unsubscribe = this.delegate().observeModelsResult(listener, options);
|
||||
return () => {
|
||||
observer.unsubscribe?.();
|
||||
this.modelObservers.delete(observer);
|
||||
};
|
||||
}
|
||||
|
||||
fetchModels(): Promise<readonly ModelMetadata[]> {
|
||||
return this.delegate().fetchModels();
|
||||
}
|
||||
|
||||
refreshModels(): Promise<readonly ModelMetadata[]> {
|
||||
return this.delegate().refreshModels();
|
||||
}
|
||||
|
||||
archivedThreadsSnapshot(): readonly Thread[] | null {
|
||||
return this.delegate().archivedThreadsSnapshot();
|
||||
}
|
||||
|
||||
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
||||
const observer: StableObserver<Thread> = { listener, options, unsubscribe: null };
|
||||
this.archivedThreadObservers.add(observer);
|
||||
observer.unsubscribe = this.delegate().observeArchivedThreadsResult(listener, options);
|
||||
return () => {
|
||||
observer.unsubscribe?.();
|
||||
this.archivedThreadObservers.delete(observer);
|
||||
};
|
||||
}
|
||||
|
||||
refreshArchivedThreads(): Promise<readonly Thread[]> {
|
||||
return this.delegate().refreshArchivedThreads();
|
||||
}
|
||||
|
||||
refreshHooks(): ReturnType<SettingsDynamicDataAccess["refreshHooks"]> {
|
||||
return this.delegate().refreshHooks();
|
||||
}
|
||||
|
||||
trustHook(hook: Parameters<SettingsDynamicDataAccess["trustHook"]>[0]): ReturnType<SettingsDynamicDataAccess["trustHook"]> {
|
||||
return this.delegate().trustHook(hook);
|
||||
}
|
||||
|
||||
setHookEnabled(
|
||||
hook: Parameters<SettingsDynamicDataAccess["setHookEnabled"]>[0],
|
||||
enabled: boolean,
|
||||
): ReturnType<SettingsDynamicDataAccess["setHookEnabled"]> {
|
||||
return this.delegate().setHookEnabled(hook, enabled);
|
||||
}
|
||||
|
||||
restoreArchivedThread(threadId: string): Promise<Thread> {
|
||||
return this.delegate().restoreArchivedThread(threadId);
|
||||
}
|
||||
|
||||
deleteArchivedThread(threadId: string): Promise<void> {
|
||||
return this.delegate().deleteArchivedThread(threadId);
|
||||
}
|
||||
|
||||
private delegate(): SettingsDynamicDataAccess {
|
||||
if (!this.current) throw new Error("Codex execution runtime is not initialized.");
|
||||
return this.current;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <T>(threadId: string, operation: () => Promise<T>): Promise<T> => {
|
||||
return archivedThreadMutations.run(threadId, () => mapStaleContextError(operation));
|
||||
return archivedThreadMutations.run(threadId, operation);
|
||||
};
|
||||
const loadHooks = (client: AppServerClient): Promise<SettingsHookCatalog> => loadSettingsHookCatalog(client, options.vaultPath);
|
||||
const mutateHook = (hook: HookItem, mutation: (client: AppServerClient, hook: HookItem) => Promise<void>): Promise<SettingsHookCatalog> =>
|
||||
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<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (isStaleAppServerResourceContextError(error)) throw new StaleSettingsDynamicDataContextError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,3 @@ export interface SettingsDynamicDataAccess {
|
|||
restoreArchivedThread(threadId: string): Promise<Thread>;
|
||||
deleteArchivedThread(threadId: string): Promise<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<readonly ModelMetadata[]>): void {
|
||||
|
|
@ -152,6 +167,7 @@ export class SettingsDynamicSectionsController {
|
|||
async refreshDynamicSections(options: { forceModels?: boolean } = {}): Promise<void> {
|
||||
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<SettingsHookCatalog>;
|
||||
operation: (dynamicData: SettingsDynamicDataAccess) => Promise<SettingsHookCatalog>;
|
||||
}): Promise<void> {
|
||||
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<void>;
|
||||
operation: (dynamicData: SettingsDynamicDataAccess, operationToken: number) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -478,18 +478,11 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
|
||||
private setCodexPath(value: string): Promise<void> {
|
||||
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<void> {
|
||||
|
|
@ -576,16 +569,13 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
});
|
||||
}
|
||||
|
||||
private queueSettingsMutation(
|
||||
mutate: (settings: CodexPanelSettings) => boolean | undefined,
|
||||
publication: { onPublished?: (result: { appServerContextReplaced: boolean }) => void } = {},
|
||||
): Promise<void> {
|
||||
private queueSettingsMutation(mutate: (settings: CodexPanelSettings) => boolean | undefined): Promise<void> {
|
||||
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)}`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof publication.replacementDynamicData>);
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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<void>();
|
||||
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<void>();
|
||||
const saveSettings = vi.fn().mockReturnValueOnce(firstSave.promise).mockResolvedValueOnce(undefined);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue