murashit_codex-panel/tests/settings/settings-tab.test.ts
2026-07-06 16:47:12 +09:00

1214 lines
51 KiB
TypeScript

// @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 { ObservedResult } from "../../src/app-server/query/observed-result";
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 { 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 { DEFAULT_SETTINGS } from "../../src/settings/model";
import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian";
import { notices } from "../mocks/obsidian";
import { deferred } from "../support/async";
import { installObsidianDomShims } from "../support/dom";
installObsidianDomShims();
const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({
withShortLivedAppServerClientMock: vi.fn(),
}));
vi.mock("../../src/app-server/connection/short-lived-client", () => ({
withShortLivedAppServerClient: withShortLivedAppServerClientMock,
}));
describe("settings tab", () => {
beforeEach(() => {
withShortLivedAppServerClientMock.mockReset();
notices.length = 0;
});
it("auto-loads dynamic sections once and keeps one global refresh button", async () => {
const client = settingsClient();
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
useShortLivedClients(client);
const tab = newSettingsTab({ fetchModels });
tab.display();
await flushPromises();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledTimes(1);
expect(fetchModels).toHaveBeenCalledTimes(1);
expect(requestMethods(client)).not.toContain("model/list");
expectRequestTimes(client, "hooks/list", 1);
tab.display();
await flushPromises();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledTimes(1);
expect(buttonLabels(tab)).toContain("Refresh Codex details");
expect(buttonTexts(tab)).not.toContain("Refresh Codex details");
expect(buttonTexts(tab)).not.toContain("Load models");
expect(buttonTexts(tab)).not.toContain("Load hooks");
expect(buttonTexts(tab)).not.toContain("Load archive list");
expect(settingNames(tab)).toEqual([
"Codex executable",
"Show chat toolbar",
"Panel helpers",
"Automatic thread naming",
"Selection rewrite",
"Composer",
"Send shortcut",
"Scroll thread from composer line edges",
"Reference active file on send",
"Attachment folder",
"Web clipping",
"Clipped note folder",
"Clipped note filename",
"Clipped note tags",
"Thread archiving",
"Save note by default",
"Saved note folder",
"Saved note filename",
"Saved note tags",
"Archived threads",
"Codex hooks",
]);
});
it("saves the send shortcut setting and describes newline behavior", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const tab = newSettingsTab({ saveSettings });
tab.display();
const codexPath = inputForSetting(tab, "Codex executable");
expect(codexPath?.getAttribute("aria-label")).toBeNull();
expect(codexPath?.type).toBe("text");
const shortcut = selectForSetting(tab, "Send shortcut");
if (!shortcut) throw new Error("Missing send shortcut dropdown");
expect(shortcut.classList.contains("dropdown")).toBe(true);
shortcut.value = "mod-enter";
shortcut.dispatchEvent(new Event("change"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(settingDesc(tab, "Send shortcut")).toContain("Shift+Enter adds a newline");
expect(tab.containerEl.querySelector(".codex-panel-settings__section-status")?.textContent ?? "").not.toContain("Shift+Enter");
});
it("saves the toolbar visibility setting and refreshes open panels", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const refreshOpenViews = vi.fn();
const tab = newSettingsTab({ saveSettings, refreshOpenViews });
tab.display();
const toggle = inputForSetting(tab, "Show chat toolbar");
if (!toggle) throw new Error("Missing toolbar visibility toggle");
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
toggle.checked = false;
toggle.dispatchEvent(new Event("change"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(refreshOpenViews).toHaveBeenCalledOnce();
expect(settingDesc(tab, "Show chat toolbar")).toContain("toolbar above chat panels");
});
it("saves the composer line edge scroll setting", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const tab = newSettingsTab({ saveSettings });
tab.display();
const toggle = inputForSetting(tab, "Scroll thread from composer line edges");
if (!toggle) throw new Error("Missing composer line edge scroll toggle");
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
toggle.checked = true;
toggle.dispatchEvent(new Event("change"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(settingDesc(tab, "Scroll thread from composer line edges")).toContain("Up/Ctrl+P");
});
it("saves the active file reference setting", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const tab = newSettingsTab({ saveSettings });
tab.display();
const toggle = inputForSetting(tab, "Reference active file on send");
if (!toggle) throw new Error("Missing active file reference toggle");
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
toggle.checked = true;
toggle.dispatchEvent(new Event("change"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(settingDesc(tab, "Reference active file on send")).toContain("active file as context");
});
it("saves the attachment folder setting", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const tab = newSettingsTab({ saveSettings });
tab.display();
const folder = inputForSetting(tab, "Attachment folder");
if (!folder) throw new Error("Missing attachment folder input");
expect(folder.type).toBe("text");
folder.value = "Files/Codex";
folder.dispatchEvent(new Event("blur"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(settingDesc(tab, "Attachment folder")).toContain("pasted or dropped");
});
it("saves web clip settings", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const tab = newSettingsTab({ saveSettings });
tab.display();
const folder = inputForSetting(tab, "Clipped note folder");
const filename = inputForSetting(tab, "Clipped note filename");
const tags = inputForSetting(tab, "Clipped note tags");
if (!folder || !filename || !tags) throw new Error("Missing clip controls");
expect(folder.type).toBe("text");
expect(filename.type).toBe("text");
expect(tags.type).toBe("text");
folder.value = "Web Clips";
folder.dispatchEvent(new Event("blur"));
filename.value = "{{site}} {{title}}.md";
filename.dispatchEvent(new Event("blur"));
tags.value = "web, clipping";
tags.dispatchEvent(new Event("blur"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledTimes(3);
expect(settingDesc(tab, "Web clipping")).toBe("");
expect(settingDesc(tab, "Clipped note filename")).toContain("{{domain}}");
expect(settingDesc(tab, "Clipped note tags")).toContain("Comma-separated");
});
it("saves archive export settings", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const tab = newSettingsTab({ saveSettings });
tab.display();
const toggle = inputForSetting(tab, "Save note by default");
if (!toggle) throw new Error("Missing archive export toggle");
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
toggle.checked = true;
toggle.dispatchEvent(new Event("change"));
await flushPromises();
const folder = inputForSetting(tab, "Saved note folder");
if (!folder) throw new Error("Missing archive export folder input");
expect(folder.getAttribute("aria-label")).toBeNull();
expect(folder.type).toBe("text");
folder.value = "Saved Threads";
folder.dispatchEvent(new Event("blur"));
await flushPromises();
const filename = inputForSetting(tab, "Saved note filename");
if (!filename) throw new Error("Missing archive export filename input");
expect(filename.getAttribute("aria-label")).toBeNull();
expect(filename.type).toBe("text");
filename.value = "{{date}} {{title}}.md";
filename.dispatchEvent(new Event("blur"));
await flushPromises();
const tags = inputForSetting(tab, "Saved note tags");
if (!tags) throw new Error("Missing archive export tags input");
expect(tags.getAttribute("aria-label")).toBeNull();
expect(tags.type).toBe("text");
tags.value = "codex, archive";
tags.dispatchEvent(new Event("blur"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledTimes(4);
expect(settingDesc(tab, "Save note by default")).toContain("default archive action");
expect(settingDesc(tab, "Saved note filename")).toContain("{{shortId}}");
expect(settingDesc(tab, "Saved note tags")).toContain("Comma-separated");
});
it("restores default archive export templates when cleared", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const tab = newSettingsTab({ saveSettings });
tab.display();
const folder = inputForSetting(tab, "Saved note folder");
if (!folder) throw new Error("Missing archive export folder input");
folder.value = " ";
folder.dispatchEvent(new Event("blur"));
await flushPromises();
expect(inputForSetting(tab, "Saved note folder")?.value).toBe(DEFAULT_SETTINGS.archiveExportFolderTemplate);
const filename = inputForSetting(tab, "Saved note filename");
if (!filename) throw new Error("Missing archive export filename input");
filename.value = " ";
filename.dispatchEvent(new Event("blur"));
await flushPromises();
expect(inputForSetting(tab, "Saved note filename")?.value).toBe(DEFAULT_SETTINGS.archiveExportFilenameTemplate);
expect(saveSettings).toHaveBeenCalledTimes(2);
});
it("refreshes models, hooks, and archived threads from the global refresh button", async () => {
const firstClient = settingsClient({ models: [model("gpt-5.4")] });
const secondClient = settingsClient({ models: [model("gpt-5.5")] });
useShortLivedClients(firstClient, secondClient);
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.4")]));
const refreshModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
const refreshArchived = vi
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old", archived: true })])
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New", archived: true })]);
const tab = newSettingsTab({ fetchModels, refreshModels, refreshArchived });
tab.display();
await flushPromises();
clickButtonByLabel(tab, "Refresh Codex details");
await flushPromises();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledTimes(2);
expect(fetchModels).toHaveBeenCalledOnce();
expect(refreshModels).toHaveBeenCalledOnce();
expect(refreshArchived).toHaveBeenCalledTimes(2);
expect(tab.containerEl.textContent).toContain("gpt-5.5");
expect(tab.containerEl.textContent).toContain("New");
expect(tab.containerEl.textContent).not.toContain("Old");
});
it("clears dynamic sections when the Codex executable changes", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const notifyContextChanged = vi.fn();
const refreshOpenViews = vi.fn();
const oldClient = settingsClient({
models: [model("gpt-old")],
hooks: [hook({ key: "hook-old", command: "old hook", currentHash: "oldhash" })],
});
const newClient = settingsClient({
models: [model("gpt-new")],
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
});
useShortLivedClients(oldClient, newClient);
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-old")]));
const refreshModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-new")]));
const refreshArchived = vi
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })])
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]);
const tab = newSettingsTab({ saveSettings, notifyContextChanged, refreshOpenViews, fetchModels, refreshModels, refreshArchived });
tab.display();
await flushPromises();
expect(tab.containerEl.textContent).toContain("gpt-old");
expect(tab.containerEl.textContent).toContain("Old archived");
const codexInput = inputForSetting(tab, "Codex executable");
if (!codexInput) throw new Error("Missing Codex executable input");
codexInput.value = "/opt/codex";
codexInput.dispatchEvent(new InputEvent("input", { bubbles: true }));
await flushPromises();
expect(saveSettings).not.toHaveBeenCalled();
expect(notifyContextChanged).not.toHaveBeenCalled();
expect(refreshOpenViews).not.toHaveBeenCalled();
codexInput.dispatchEvent(new FocusEvent("blur"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(notifyContextChanged).toHaveBeenCalledOnce();
expect(refreshOpenViews).toHaveBeenCalledOnce();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledTimes(1);
expect(tab.containerEl.textContent).not.toContain("gpt-old");
expect(tab.containerEl.textContent).not.toContain("Old archived");
clickButtonByLabel(tab, "Refresh Codex details");
await flushPromises();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledTimes(2);
expect(withShortLivedAppServerClientMock.mock.calls[1]?.[0]).toBe("/opt/codex");
expect(tab.containerEl.textContent).toContain("gpt-new");
expect(tab.containerEl.textContent).toContain("New archived");
});
it("subscribes model updates only while the settings tab is displayed", () => {
useShortLivedClients(settingsClient());
const unsubscribe = vi.fn();
const observeModels = vi.fn(() => unsubscribe);
const tab = newSettingsTab({ observeModels });
expect(observeModels).not.toHaveBeenCalled();
tab.display();
tab.hide();
tab.display();
expect(observeModels).toHaveBeenCalledTimes(2);
expect(unsubscribe).toHaveBeenCalledOnce();
});
it("publishes archived thread catalog updates to the archived settings section", () => {
let emitArchived = (_threads: readonly Thread[]): void => {
throw new Error("Expected archived thread observer");
};
const display = vi.fn();
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
observeArchived: (listener) => {
emitArchived = (threads) => {
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
};
return () => undefined;
},
}),
{ display, notify: vi.fn() },
);
controller.activate();
emitArchived([panelThread({ id: "thread-archived", preview: "Archived elsewhere", archived: true })]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["Archived elsewhere"]);
expect(controller.snapshot().archivedThreadsLifecycle.kind).toBe("loaded");
expect(display).toHaveBeenCalledOnce();
});
it("ignores stale dynamic sections refresh results after a newer refresh completes", async () => {
const firstModels = deferred<ModelMetadata[]>();
const firstClient = settingsClient();
const secondClient = settingsClient({ models: [model("gpt-new")] });
useShortLivedClients(firstClient, secondClient);
const refreshModels = vi
.fn()
.mockReturnValueOnce(firstModels.promise)
.mockResolvedValueOnce(modelMetadataFromCatalogModels([model("gpt-new")]));
const refreshArchived = vi
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old", archived: true })])
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New", archived: true })]);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
display: vi.fn(),
notify: vi.fn(),
});
const firstRefresh = controller.refreshDynamicSections();
await flushPromises();
await controller.refreshDynamicSections();
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
firstModels.resolve(modelMetadataFromCatalogModels([model("gpt-old")]));
await firstRefresh;
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
});
it("ignores stale hook reload results after a newer dynamic operation completes", async () => {
const staleHooks = deferred<{
data: { cwd: string; hooks: CatalogHookMetadata[]; warnings: string[]; errors: unknown[] }[];
}>();
const initialClient = settingsClient({
hooks: [hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })],
});
const trustClient = settingsRequestClient({
"config/batchWrite": vi.fn().mockResolvedValue({}),
});
const staleClient = settingsClient();
staleClient.requestHandlers["hooks/list"]?.mockReturnValue(staleHooks.promise);
const newerClient = settingsClient({
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
});
useShortLivedClients(initialClient, trustClient, staleClient, newerClient);
const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() });
await controller.refreshDynamicSections();
const initialHook = controller.snapshot().hooks[0];
if (!initialHook) throw new Error("Expected initial hook to load");
const staleReload = controller.trustHook(initialHook);
await flushPromises();
await controller.refreshDynamicSections();
expect(controller.snapshot().hooks.map((hook) => hook.currentHash)).toEqual(["newhash"]);
staleHooks.resolve({
data: [{ cwd: "/vault", hooks: [hook({ key: "hook-old", command: "old hook", currentHash: "oldhash" })], warnings: [], errors: [] }],
});
await staleReload;
expect(controller.snapshot().hooks.map((item) => item.currentHash)).toEqual(["newhash"]);
});
it("keeps full refresh models and archived threads current when hook operations overlap", async () => {
const models = deferred<readonly ModelMetadata[]>();
const fullRefreshClient = settingsClient({
hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })],
});
const trustClient = settingsRequestClient({
"config/batchWrite": vi.fn().mockResolvedValue({}),
});
const hookReloadClient = settingsClient({
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
});
useShortLivedClients(fullRefreshClient, trustClient, hookReloadClient);
const refreshModels = vi.fn().mockReturnValue(models.promise);
const refreshArchived = vi.fn().mockResolvedValue([panelThread({ id: "thread-full", preview: "Full archived", archived: true })]);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
display: vi.fn(),
notify: vi.fn(),
});
const fullRefresh = controller.refreshDynamicSections();
await flushPromises();
await controller.trustHook(hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" }));
models.resolve(modelMetadataFromCatalogModels([model("gpt-refreshed")]));
await fullRefresh;
const snapshot = controller.snapshot();
expect(snapshot.models.map((item) => item.model)).toEqual(["gpt-refreshed"]);
expect(snapshot.modelsLifecycle.kind).toBe("loaded");
expect(snapshot.archivedThreads.map((thread) => thread.preview)).toEqual(["Full archived"]);
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
expect(snapshot.hooks.map((item) => item.currentHash)).toEqual(["newhash"]);
});
it("ignores stale archived restore results after a newer dynamic operation completes", async () => {
const staleRestore = deferred<{ thread: ThreadRecord }>();
const applyThreadCatalogEvent = vi.fn();
const initialClient = settingsClient();
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn(() => staleRestore.promise),
});
const newerClient = settingsClient();
useShortLivedClients(initialClient, restoreClient, newerClient);
const refreshArchived = vi
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })])
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ applyThreadCatalogEvent, refreshArchived }), {
display: vi.fn(),
notify: vi.fn(),
});
await controller.refreshDynamicSections();
const restore = controller.restoreArchivedThread("thread-old");
await flushPromises();
await controller.refreshDynamicSections();
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
await restore;
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
});
it("records restored archived threads in the active catalog", async () => {
const notify = vi.fn();
const applyThreadCatalogEvent = vi.fn();
const initialClient = settingsClient();
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
});
useShortLivedClients(initialClient, restoreClient);
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
applyThreadCatalogEvent,
}),
{ display: vi.fn(), notify },
);
await controller.refreshDynamicSections();
await controller.restoreArchivedThread("thread-old");
const snapshot = controller.snapshot();
expect(snapshot.archivedThreads).toEqual([]);
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
type: "thread-restored",
thread: expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }),
});
expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread.");
});
it("displays restored archived thread state after recording the active catalog event", async () => {
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
const initialClient = settingsClient();
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
});
useShortLivedClients(initialClient, restoreClient);
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
let emitArchived = (_threads: readonly Thread[]): void => undefined;
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
observeArchived: (listener) => {
emitArchived = (threads) => {
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
};
return () => undefined;
},
applyThreadCatalogEvent: () => {
emitArchived([]);
},
}),
{
display: () => {
const snapshot = controllerRef.current?.snapshot();
if (snapshot) snapshots.push(snapshot);
},
notify: vi.fn(),
},
);
controllerRef.current = controller;
await controller.refreshDynamicSections();
snapshots.length = 0;
await controller.restoreArchivedThread("thread-old");
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Restored "Restored old".');
});
it("displays deleted archived thread status after recording the catalog event", async () => {
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
const initialClient = settingsClient();
const deleteClient = settingsRequestClient({
"thread/delete": vi.fn().mockResolvedValue({}),
});
useShortLivedClients(initialClient, deleteClient);
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
let emitArchived = (_threads: readonly Thread[]): void => undefined;
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
observeArchived: (listener) => {
emitArchived = (threads) => {
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
};
return () => undefined;
},
applyThreadCatalogEvent: () => {
emitArchived([]);
},
}),
{
display: () => {
const snapshot = controllerRef.current?.snapshot();
if (snapshot) snapshots.push(snapshot);
},
notify: vi.fn(),
},
);
controllerRef.current = controller;
await controller.refreshDynamicSections();
snapshots.length = 0;
await controller.deleteArchivedThread("thread-old");
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Deleted "Old archived".');
});
it("uses cached models initially and publishes refreshed models", async () => {
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
const client = settingsClient({ models: [model("gpt-5.5")] });
useShortLivedClients(client);
const tab = newSettingsTab({ modelsSnapshot: modelMetadataFromCatalogModels([model("gpt-cached")]), fetchModels });
tab.display();
expect(tab.containerEl.textContent).toContain("gpt-cached");
await flushPromises();
expect(fetchModels).toHaveBeenCalledOnce();
expect(tab.containerEl.textContent).toContain("gpt-5.5");
});
it("replaces stale cached model options with an empty successful refresh while preserving saved values", async () => {
const fetchModels = vi.fn().mockResolvedValue([]);
const client = settingsClient({ models: [] });
useShortLivedClients(client);
const tab = newSettingsTab({
modelsSnapshot: modelMetadataFromCatalogModels([model("gpt-cached")]),
fetchModels,
settings: {
threadNamingModel: "gpt-saved",
rewriteSelectionModel: "gpt-cached",
},
});
tab.display();
expect(selectOptions(tab, "Automatic thread naming")).toEqual(["Codex default", "gpt-saved (saved)", "gpt-cached"]);
expect(selectOptions(tab, "Selection rewrite")).toEqual(["Codex default", "gpt-cached"]);
await flushPromises();
expect(fetchModels).toHaveBeenCalledOnce();
expect(selectOptions(tab, "Automatic thread naming")).toEqual(["Codex default", "gpt-saved (saved)"]);
expect(selectOptions(tab, "Selection rewrite")).toEqual(["Codex default", "gpt-cached (saved)"]);
});
it("uses model-provided reasoning efforts in helper settings while preserving saved unknown values", async () => {
const tab = newSettingsTab({
modelsSnapshot: modelMetadataFromCatalogModels([model("gpt-5.5", false, false, ["extreme"])]),
settings: {
threadNamingModel: "gpt-5.5",
threadNamingEffort: "saved-custom-effort",
rewriteSelectionModel: "gpt-5.5",
rewriteSelectionEffort: "extreme",
},
});
tab.display();
expect(selectOptions(tab, "Automatic thread naming", 1)).toEqual(["Codex default", "saved-custom-effort (saved)", "extreme"]);
expect(selectOptions(tab, "Selection rewrite", 1)).toEqual(["Codex default", "extreme"]);
expect(selectForSetting(tab, "Automatic thread naming")?.classList.contains("dropdown")).toBe(true);
expect(selectForSetting(tab, "Selection rewrite")?.classList.contains("dropdown")).toBe(true);
});
it("keeps successful sections when one dynamic sections request fails", async () => {
const client = settingsClient({
models: [model("gpt-5.4")],
hooksError: new Error("hooks unavailable"),
});
useShortLivedClients(client);
const tab = newSettingsTab({ archivedThreads: [panelThread({ preview: "Archived thread", archived: true })] });
tab.display();
await flushPromises();
expect(tab.containerEl.textContent).not.toContain("Loaded 1 model.");
expect(tab.containerEl.textContent).toContain("Could not load hooks: hooks unavailable");
expect(tab.containerEl.querySelector(".codex-panel-settings__refresh-status")).toBeNull();
expect(tab.containerEl.textContent).toContain("Archived thread");
expect(notices).toEqual(["Could not refresh all Codex details."]);
});
it("renders archived threads and hooks as dynamic setting rows", async () => {
const client = settingsClient({
hooks: [hook({ key: "hook-1", command: "node hook.js", currentHash: "abc123", trustStatus: "untrusted" })],
});
useShortLivedClients(client);
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
tab.display();
await flushPromises();
expect(tab.containerEl.textContent).not.toContain("Restore or permanently delete archived Codex threads.");
expect(tab.containerEl.textContent).not.toContain("Trust, enable, or disable discovered Codex hooks.");
expect(tab.containerEl.textContent).not.toContain("Loaded 1 hook from Codex app server.");
expect(tab.containerEl.textContent).not.toContain("Loaded 1 archived thread from Codex app server.");
expect(tab.containerEl.querySelector(".codex-panel-settings__hook-section .setting-item-heading")?.textContent).toContain(
"Codex hooks",
);
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-section .setting-item-heading")?.textContent).toContain(
"Thread archiving",
);
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-threads-section .setting-item-heading")?.textContent).toContain(
"Archived threads",
);
expect(tab.containerEl.querySelectorAll(".codex-panel-settings__hook-list .codex-panel-settings__hook-row")).toHaveLength(1);
expect(tab.containerEl.querySelectorAll(".codex-panel-settings__archived-list .codex-panel-settings__archived-row")).toHaveLength(1);
expect(tab.containerEl.querySelector(".codex-panel-settings__hook-list")?.textContent).not.toContain("abc123");
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-list")?.textContent).toContain("Archived thread");
expect(buttonLabels(tab)).toContain("Restore thread");
expect(buttonLabels(tab)).toContain("Delete thread");
});
it("confirms archived thread deletion inline and cancels from outside clicks", async () => {
const client = settingsClient();
useShortLivedClients(client);
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
tab.display();
await flushPromises();
clickButtonByLabel(tab, "Delete thread");
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).not.toBeNull();
expect(tab.containerEl.textContent).toContain("Permanently delete this archived thread? This cannot be undone.");
tab.containerEl.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).toBeNull();
expect(tab.containerEl.textContent).not.toContain("Permanently delete this archived thread?");
expect(requestMethods(client)).not.toContain("thread/delete");
});
it("keeps the settings shell mounted while dynamic sections update", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const client = settingsClient();
useShortLivedClients(client);
const tab = newSettingsTab({
saveSettings,
archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })],
});
tab.display();
await flushPromises();
const codexInput = inputForSetting(tab, "Codex executable");
const shortcut = selectForSetting(tab, "Send shortcut");
const archiveToggle = inputForSetting(tab, "Save note by default");
if (!codexInput || !shortcut || !archiveToggle) throw new Error("Missing settings controls");
archiveToggle.checked = true;
archiveToggle.dispatchEvent(new Event("change"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(inputForSetting(tab, "Codex executable")).toBe(codexInput);
expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut);
clickButtonByLabel(tab, "Delete thread");
expect(inputForSetting(tab, "Codex executable")).toBe(codexInput);
expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut);
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).not.toBeNull();
});
it("rerenders only the changed dynamic section for archive and hook actions", async () => {
const client = settingsClient({
hooks: [hook({ key: "hook-1", command: "node hook.js", currentHash: "abc123", enabled: true })],
});
useShortLivedClients(client);
const hookUpdate = deferred<undefined>();
client.requestHandlers["config/batchWrite"]?.mockReturnValue(hookUpdate.promise);
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
tab.display();
await flushPromises();
const helperSection = dynamicSection(tab, "codex-panel-settings__helper-section");
const archivedSection = dynamicSection(tab, "codex-panel-settings__archived-section");
const hookSection = dynamicSection(tab, "codex-panel-settings__hook-section");
const archiveToggle = inputForSetting(tab, "Save note by default");
if (!archiveToggle) throw new Error("Missing archive toggle");
archiveToggle.checked = true;
archiveToggle.dispatchEvent(new Event("change"));
await flushPromises();
expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection);
expect(dynamicSection(tab, "codex-panel-settings__hook-section")).toBe(hookSection);
clickButtonByLabel(tab, "Delete thread");
expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection);
expect(dynamicSection(tab, "codex-panel-settings__hook-section")).toBe(hookSection);
clickButtonByText(tab, "Disable");
await flushPromises();
expectRequestTimes(client, "config/batchWrite", 1);
expect(dynamicSection(tab, "codex-panel-settings__hook-section")).not.toBeNull();
expect(tab.containerEl.querySelector(".codex-panel-settings__hook-list")).not.toBeNull();
expect(dynamicSection(tab, "codex-panel-settings__helper-section")).toBe(helperSection);
expect(dynamicSection(tab, "codex-panel-settings__archived-section")).toBe(archivedSection);
hookUpdate.resolve(undefined);
await flushPromises();
});
it("permanently deletes an archived thread from the confirmed settings row", async () => {
const client = settingsClient();
useShortLivedClients(client);
const deleteRequest = deferred<undefined>();
client.requestHandlers["thread/delete"]?.mockReturnValue(deleteRequest.promise);
const tab = newSettingsTab({ archivedThreads: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })] });
tab.display();
await flushPromises();
clickButtonByLabel(tab, "Delete thread");
pointerDownButtonByLabel(tab, "Delete thread");
clickButtonByLabel(tab, "Delete thread");
await flushPromises();
expect(client.request).toHaveBeenCalledWith("thread/delete", { threadId: "thread-archived" }, {});
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-list")).not.toBeNull();
deleteRequest.resolve(undefined);
await flushPromises();
expect(tab.containerEl.textContent).toContain("No archived threads.");
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-list .codex-panel-settings__status-row")?.textContent).toContain(
"No archived threads.",
);
expect(tab.containerEl.querySelectorAll(".codex-panel-settings__archived-list .codex-panel-settings__archived-row")).toHaveLength(0);
});
});
function panelThread(overrides: Partial<Thread> = {}): Thread {
return {
id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
preview: "Preview",
createdAt: 1,
updatedAt: 1,
name: null,
archived: false,
...overrides,
};
}
function appServerThread(overrides: Partial<ThreadRecord> = {}): ThreadRecord {
return {
id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
sessionId: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
forkedFromId: null,
parentThreadId: null,
preview: "Preview",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/tmp",
cliVersion: "codex-cli 0.0.0",
source: "unknown",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
...overrides,
};
}
function model(modelId: string, isDefault = false, hidden = false, efforts: ReasoningEffort[] = ["medium"]): CatalogModel {
return {
id: `${modelId}-id`,
model: modelId,
upgrade: null,
upgradeInfo: null,
availabilityNux: null,
displayName: `Display ${modelId}`,
description: "",
isDefault,
hidden,
supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: reasoningEffort })),
defaultReasoningEffort: "medium",
inputModalities: ["text"],
supportsPersonality: false,
additionalSpeedTiers: [],
serviceTiers: [],
defaultServiceTier: null,
} satisfies CatalogModel;
}
function hook(overrides: Partial<CatalogHookMetadata> = {}): CatalogHookMetadata {
return {
key: "hook-key",
eventName: "postToolUse",
handlerType: "command",
matcher: "apply_patch",
command: "node hook.js",
timeoutSec: 10n,
statusMessage: null,
sourcePath: "/vault/.codex/hooks.json",
source: "project",
pluginId: null,
displayOrder: 0n,
enabled: true,
isManaged: false,
currentHash: "hash",
trustStatus: "trusted",
...overrides,
};
}
function settingsClient(
options: { models?: CatalogModel[]; hooks?: CatalogHookMetadata[]; hooksError?: Error; threads?: ThreadRecord[] } = {},
) {
return settingsRequestClient({
"model/list": vi.fn().mockResolvedValue({ data: options.models ?? [model("gpt-5.4")] }),
"hooks/list": vi.fn().mockImplementation(() => {
if (options.hooksError) return Promise.reject(options.hooksError);
return Promise.resolve({
data: [
{
cwd: "/vault",
hooks: options.hooks ?? [],
warnings: [],
errors: [],
},
],
});
}),
"thread/list": vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }),
"config/batchWrite": vi.fn().mockResolvedValue({}),
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ preview: "Restored" }) }),
"thread/delete": vi.fn().mockResolvedValue({}),
});
}
type SettingsRequestClient = AppServerClient & {
request: ReturnType<typeof vi.fn<(method: string, params?: unknown, options?: unknown) => unknown>>;
requestHandlers: Record<string, ReturnType<typeof vi.fn<(params?: unknown, options?: unknown) => unknown>>>;
};
function useShortLivedClients(...clients: SettingsRequestClient[]): void {
const runWithClient = (client: SettingsRequestClient, operation: (client: AppServerClient) => Promise<unknown>) => operation(client);
if (clients.length === 1) {
const [client] = clients;
if (!client) throw new Error("Expected a short-lived client.");
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise<unknown>) => runWithClient(client, operation),
);
return;
}
for (const client of clients) {
withShortLivedAppServerClientMock.mockImplementationOnce(
(_codexPath: string, _cwd: string, operation: (client: AppServerClient) => Promise<unknown>) => runWithClient(client, operation),
);
}
}
function settingsRequestClient(
handlers: Record<string, ReturnType<typeof vi.fn<(params?: unknown, options?: unknown) => unknown>>>,
): SettingsRequestClient {
return {
requestHandlers: handlers,
request: vi.fn((method: string, params: unknown) => {
const handler = handlers[method];
if (!handler) throw new Error(`Unexpected app-server request: ${method}`);
return handler(params);
}),
} as unknown as SettingsRequestClient;
}
function requestMethods(client: SettingsRequestClient): string[] {
return client.request.mock.calls.map(([method]) => method);
}
function expectRequestTimes(client: SettingsRequestClient, method: string, times: number): void {
expect(requestMethods(client).filter((calledMethod) => calledMethod === method)).toHaveLength(times);
}
function newSettingsTab(
options: {
saveSettings?: () => Promise<void>;
sendShortcut?: "enter" | "mod-enter";
modelsSnapshot?: ModelMetadata[];
fetchModels?: () => Promise<readonly ModelMetadata[]>;
refreshModels?: () => Promise<readonly ModelMetadata[]>;
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
notifyContextChanged?: () => void;
refreshOpenViews?: () => void;
archivedThreads?: Thread[];
archivedSnapshot?: Thread[] | null;
refreshArchived?: () => Promise<readonly Thread[]>;
observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"];
applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void;
dynamicData?: SettingsDynamicDataAccess;
settings?: Partial<{
threadNamingModel: string | null;
threadNamingEffort: string | null;
rewriteSelectionModel: string | null;
rewriteSelectionEffort: string | null;
}>;
} = {},
): CodexPanelSettingTab {
return new CodexPanelSettingTab({} as never, {} as never, settingsTabHost(options));
}
function settingsTabHost(
options: {
saveSettings?: () => Promise<void>;
sendShortcut?: "enter" | "mod-enter";
modelsSnapshot?: ModelMetadata[];
fetchModels?: () => Promise<readonly ModelMetadata[]>;
refreshModels?: () => Promise<readonly ModelMetadata[]>;
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
notifyContextChanged?: () => void;
refreshOpenViews?: () => void;
archivedThreads?: Thread[];
archivedSnapshot?: Thread[] | null;
refreshArchived?: () => Promise<readonly Thread[]>;
observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"];
applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void;
dynamicData?: SettingsDynamicDataAccess;
settings?: Partial<{
threadNamingModel: string | null;
threadNamingEffort: string | null;
rewriteSelectionModel: string | null;
rewriteSelectionEffort: string | null;
}>;
} = {},
): CodexPanelSettingTabHost {
const defaultArchivedThreads = [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })];
const settings = {
...DEFAULT_SETTINGS,
threadNamingModel: options.settings?.threadNamingModel ?? null,
threadNamingEffort: options.settings?.threadNamingEffort ?? null,
rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null,
rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null,
sendShortcut: options.sendShortcut ?? "enter",
};
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,
dynamicData:
options.dynamicData ??
createSettingsAppServerDynamicData({
vaultPath: "/vault",
clientAccess: {
withClient: <T>(operation: (client: AppServerClient) => Promise<T>, clientOptions?: AppServerClientAccessOptions) =>
withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
},
appServerQueries,
threadCatalog,
}),
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
refreshOpenViews: options.refreshOpenViews ?? vi.fn(),
};
}
async function flushPromises(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
function settingNames(tab: CodexPanelSettingTab): string[] {
return Array.from(settingsSectionRoots(tab)).flatMap((element) => {
if (element.classList.contains("codex-panel-settings__header")) return [];
if (element.classList.contains("setting-item")) {
return [element.querySelector(".setting-item-name")?.textContent ?? ""];
}
if (element.classList.contains("codex-panel-settings__section")) {
return settingsGroupNames(element);
}
if (element.classList.contains("codex-panel-settings__dynamic-section")) {
return settingsGroupNames(element);
}
return [];
});
}
function settingsGroupNames(element: Element): string[] {
const names = Array.from(element.querySelectorAll(":scope > .setting-item-heading")).map((setting) => {
return setting.querySelector(".setting-item-name")?.textContent ?? "";
});
names.push(
...Array.from(element.querySelectorAll(":scope > .setting-items:not(.codex-panel-settings__dynamic-list) > .setting-item")).flatMap(
(setting) => {
return [setting.querySelector(".setting-item-name")?.textContent ?? ""];
},
),
);
return names;
}
function settingsSectionRoots(tab: CodexPanelSettingTab): Element[] {
return Array.from(tab.containerEl.children);
}
function settingDesc(tab: CodexPanelSettingTab, name: string): string {
const setting = Array.from(tab.containerEl.querySelectorAll(".setting-item")).find(
(element) => element.querySelector(".setting-item-name")?.textContent === name,
);
return setting?.querySelector(".setting-item-description")?.textContent ?? "";
}
function buttonTexts(tab: CodexPanelSettingTab): string[] {
return Array.from(tab.containerEl.querySelectorAll("button")).map((element) => element.textContent);
}
function buttonLabels(tab: CodexPanelSettingTab): string[] {
return Array.from(tab.containerEl.querySelectorAll<HTMLElement>("button, [aria-label]")).map((element) => element.ariaLabel ?? "");
}
function clickButtonByLabel(tab: CodexPanelSettingTab, label: string): void {
buttonByLabel(tab, label).click();
}
function clickButtonByText(tab: CodexPanelSettingTab, text: string): void {
const button = Array.from(tab.containerEl.querySelectorAll("button")).find((element) => element.textContent === text);
if (!button) throw new Error(`Could not find button text: ${text}`);
button.click();
}
function pointerDownButtonByLabel(tab: CodexPanelSettingTab, label: string): void {
buttonByLabel(tab, label).dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
}
function buttonByLabel(tab: CodexPanelSettingTab, label: string): HTMLElement {
const button = Array.from(tab.containerEl.querySelectorAll<HTMLElement>("button, [aria-label]")).find(
(element) => element.ariaLabel === label,
);
if (!button) throw new Error(`Could not find button: ${label}`);
return button;
}
function inputForSetting(tab: CodexPanelSettingTab, name: string): HTMLInputElement | null {
return settingElement(tab, name)?.querySelector("input") ?? null;
}
function dynamicSection(tab: CodexPanelSettingTab, className: string): Element {
const section = tab.containerEl.querySelector(`.${className}`);
if (!section) throw new Error(`Missing dynamic section: ${className}`);
return section;
}
function settingElement(tab: CodexPanelSettingTab, name: string): Element | null {
return (
Array.from(tab.containerEl.querySelectorAll(".setting-item")).find(
(element) => element.querySelector(".setting-item-name")?.textContent === name,
) ?? null
);
}
function selectForSetting(tab: CodexPanelSettingTab, name: string): HTMLSelectElement | null {
return settingElement(tab, name)?.querySelector("select") ?? null;
}
function selectOptions(tab: CodexPanelSettingTab, name: string, index = 0): string[] {
const select = settingElement(tab, name)?.querySelectorAll<HTMLSelectElement>("select")[index] ?? null;
if (!select) throw new Error(`Missing select: ${name}`);
return Array.from(select.options).map((option) => option.textContent);
}