diff --git a/src/settings/dynamic-sections-controller.ts b/src/settings/dynamic-sections-controller.ts
index 9f706978..ed54fb69 100644
--- a/src/settings/dynamic-sections-controller.ts
+++ b/src/settings/dynamic-sections-controller.ts
@@ -40,7 +40,7 @@ interface SettingsDynamicSectionsControllerCallbacks {
notify(message: string): void;
}
-export type SettingsDynamicSectionsDisplayTarget = "all" | "helper" | "archived" | "hooks";
+type SettingsDynamicSectionsDisplayTarget = "all" | "helper" | "archived" | "hooks";
export interface SettingsDynamicSectionsSnapshot {
archivedThreads: readonly Thread[];
diff --git a/src/settings/tab-shell.tsx b/src/settings/tab-shell.tsx
new file mode 100644
index 00000000..380a16a6
--- /dev/null
+++ b/src/settings/tab-shell.tsx
@@ -0,0 +1,157 @@
+import type { TargetedEvent, TargetedKeyboardEvent, ComponentChild as UiNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+
+import { DEFAULT_CODEX_PATH } from "../constants";
+import { IconButton } from "../shared/ui/components.obsidian";
+import type { SendShortcut } from "../shared/ui/keyboard";
+import { ArchivedThreadSection } from "./archived-section";
+import { HelperSettingsSection } from "./helper-section";
+import { HookSection } from "./hook-section";
+import type { SettingsSectionsState } from "./section-state";
+import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components";
+
+const SEND_SHORTCUT_LABELS = {
+ enter: "Enter",
+ "mod-enter": "Cmd/Ctrl+Enter",
+} as const;
+
+interface SettingsTabPanelState {
+ codexPath: string;
+ showToolbar: boolean;
+ sendShortcut: SendShortcut;
+ scrollThreadFromComposerEdges: boolean;
+}
+
+interface SettingsTabShellActions {
+ refreshDynamicSections: () => void;
+ setCodexPath: (value: string) => void;
+ setShowToolbar: (value: boolean) => void;
+ setSendShortcut: (value: SendShortcut) => void;
+ setScrollThreadFromComposerEdges: (value: boolean) => void;
+}
+
+interface SettingsTabShellProps {
+ introText: string;
+ dynamicSectionsLoading: boolean;
+ panel: SettingsTabPanelState;
+ sections: SettingsSectionsState;
+ actions: SettingsTabShellActions;
+}
+
+export function SettingsTabShell({ introText, dynamicSectionsLoading, panel, sections, actions }: SettingsTabShellProps): UiNode {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+function SettingsHeader({ introText, loading, onRefresh }: { introText: string; loading: boolean; onRefresh: () => void }): UiNode {
+ return (
+
+ );
+}
+
+function PanelPreferenceSections({ panel, actions }: { panel: SettingsTabPanelState; actions: SettingsTabShellActions }): UiNode {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function CommitTextInput({
+ value,
+ placeholder,
+ onCommit,
+}: {
+ value: string;
+ placeholder: string;
+ onCommit: (value: string) => void;
+}): UiNode {
+ const [draft, setDraft] = useState(value);
+ useEffect(() => {
+ setDraft(value);
+ }, [value]);
+ const commit = (nextValue = draft): void => {
+ onCommit(nextValue);
+ };
+ return (
+ {
+ setDraft(event.currentTarget.value);
+ }}
+ onBlur={(event) => {
+ commit(event.currentTarget.value);
+ }}
+ onKeyDown={(event: TargetedKeyboardEvent) => {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ commit(event.currentTarget.value);
+ }}
+ />
+ );
+}
+
+function SettingsCheckbox({ checked, onChange }: { checked: boolean; onChange: (value: boolean) => void }): UiNode {
+ return (
+ ) => {
+ onChange(event.currentTarget.checked);
+ }}
+ />
+ );
+}
diff --git a/src/settings/tab.obsidian.tsx b/src/settings/tab.obsidian.tsx
index 1c0b0807..a304ff67 100644
--- a/src/settings/tab.obsidian.tsx
+++ b/src/settings/tab.obsidian.tsx
@@ -1,44 +1,20 @@
-import { type App, Notice, type Plugin, PluginSettingTab, Setting, setIcon } from "obsidian";
+import { type App, Notice, type Plugin, PluginSettingTab } from "obsidian";
import { DEFAULT_CODEX_PATH } from "../constants";
import type { ReasoningEffort } from "../domain/catalog/metadata";
+import { listenDomEvent } from "../shared/ui/dom-events.dom";
import { renderUiRoot, unmountUiRoot } from "../shared/ui/ui-root.dom";
-import { ArchivedThreadSection } from "./archived-section";
-import { SettingsDynamicSectionsController, type SettingsDynamicSectionsDisplayTarget } from "./dynamic-sections-controller";
-import { HelperSettingsSection } from "./helper-section";
-import { HookSection } from "./hook-section";
+import { SettingsDynamicSectionsController } from "./dynamic-sections-controller";
import type { CodexPanelSettingTabHost } from "./host";
import type { SettingsSectionsState } from "./section-state";
+import { SettingsTabShell } from "./tab-shell";
const SETTINGS_INTRO_TEXT = "Codex Panel stores panel preferences only. Runtime settings still come from Codex.";
-const SEND_SHORTCUT_LABELS = {
- enter: "Enter",
- "mod-enter": "Cmd/Ctrl+Enter",
-} as const;
-
-interface DynamicSectionRoots {
- container: HTMLElement;
- helper: HTMLElement;
- archived: HTMLElement;
- hooks: HTMLElement;
-}
-
-function renderSettingsHeading(containerEl: HTMLElement, name: string): void {
- new Setting(containerEl).setClass("codex-panel-settings__section-heading").setHeading().setName(name);
-}
-
-function createSettingsGroup(containerEl: HTMLElement, className: string): HTMLElement {
- return containerEl.createDiv({ cls: `setting-group ${className}` });
-}
-
-function createSettingsItems(containerEl: HTMLElement): HTMLElement {
- return containerEl.createDiv({ cls: "setting-items" });
-}
export class CodexPanelSettingTab extends PluginSettingTab {
private readonly dynamicSections: SettingsDynamicSectionsController;
private archivedDeleteConfirmThreadId: string | null = null;
- private dynamicSectionRoots: DynamicSectionRoots | null = null;
+ private disposeOutsidePointer: (() => void) | null = null;
private readonly cancelArchivedDeleteConfirmOnOutsidePointer = (event: PointerEvent): void => {
if (!this.archivedDeleteConfirmThreadId) return;
const target = event.target;
@@ -48,7 +24,7 @@ export class CodexPanelSettingTab extends PluginSettingTab {
if (deleteConfirm && this.containerEl.contains(deleteConfirm)) return;
}
this.archivedDeleteConfirmThreadId = null;
- this.renderDynamicSections("archived");
+ this.renderSettingsShell();
};
constructor(
@@ -58,8 +34,8 @@ export class CodexPanelSettingTab extends PluginSettingTab {
) {
super(app, owner);
this.dynamicSections = new SettingsDynamicSectionsController(plugin, {
- display: (target) => {
- this.renderDynamicSections(target);
+ display: () => {
+ this.renderSettingsShell();
},
notify: (message) => {
new Notice(message);
@@ -73,121 +49,60 @@ export class CodexPanelSettingTab extends PluginSettingTab {
}
override hide(): void {
- this.containerEl.removeEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
+ this.disposeOutsidePointer?.();
+ this.disposeOutsidePointer = null;
this.archivedDeleteConfirmThreadId = null;
this.dynamicSections.dispose();
- this.unmountDynamicSectionRoots();
+ unmountUiRoot(this.containerEl);
super.hide();
}
private renderSettingsTab(options: { autoLoadDynamicSections: boolean }): void {
const { containerEl } = this;
- this.unmountDynamicSectionRoots();
- containerEl.empty();
containerEl.addClass("codex-panel-settings");
- containerEl.removeEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
- containerEl.addEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
+ this.disposeOutsidePointer?.();
+ this.disposeOutsidePointer = listenDomEvent(containerEl, "pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
- this.renderHeaderActions(containerEl, SETTINGS_INTRO_TEXT);
- this.renderPanelPreferenceSections(containerEl);
- this.createDynamicSectionRoots(containerEl);
- this.renderDynamicSections("all");
+ this.renderSettingsShell();
if (options.autoLoadDynamicSections) this.maybeAutoLoadDynamicSections();
}
- private renderPanelPreferenceSections(containerEl: HTMLElement): void {
- const configSection = createSettingsGroup(containerEl, "codex-panel-settings__section codex-panel-settings__general-section");
- const configItems = createSettingsItems(configSection);
-
- new Setting(configItems)
- .setName("Codex executable")
- .setDesc("Command used to start `codex app-server`; use an absolute path if needed.")
- .addText((text) => {
- text.setPlaceholder(DEFAULT_CODEX_PATH).setValue(this.plugin.settings.codexPath);
- const commitCodexPath = () => {
- void this.setCodexPath(text.inputEl.value);
- };
- text.inputEl.addEventListener("blur", commitCodexPath);
- text.inputEl.addEventListener("keydown", (event) => {
- if (event.key !== "Enter") return;
- event.preventDefault();
- commitCodexPath();
- text.inputEl.blur();
- });
- });
- new Setting(configItems)
- .setName("Show chat toolbar")
- .setDesc("Show the toolbar above the chat panel.")
- .addToggle((toggle) => {
- toggle.setValue(this.plugin.settings.showToolbar).onChange(async (value) => {
- this.plugin.settings.showToolbar = value;
- await this.plugin.saveSettings();
- this.plugin.refreshOpenViews();
- });
- });
-
- const composerSection = createSettingsGroup(containerEl, "codex-panel-settings__section codex-panel-settings__composer-section");
- renderSettingsHeading(composerSection, "Composer");
- const composerItems = createSettingsItems(composerSection);
- new Setting(composerItems)
- .setName("Send shortcut")
- .setDesc("Pick Enter or Cmd/Ctrl+Enter. Shift+Enter adds a newline when Enter sends.")
- .addDropdown((dropdown) => {
- dropdown.addOption("enter", SEND_SHORTCUT_LABELS.enter);
- dropdown.addOption("mod-enter", SEND_SHORTCUT_LABELS["mod-enter"]);
- dropdown.setValue(this.plugin.settings.sendShortcut).onChange(async (value) => {
- this.plugin.settings.sendShortcut = value === "mod-enter" ? "mod-enter" : "enter";
- await this.plugin.saveSettings();
- this.display();
- });
- });
- new Setting(composerItems)
- .setName("Scroll thread from composer line edges")
- .setDesc("Use Up/Ctrl+P and Down/Ctrl+N at composer line edges to scroll the thread.")
- .addToggle((toggle) => {
- toggle.setValue(this.plugin.settings.scrollThreadFromComposerEdges).onChange(async (value) => {
- this.plugin.settings.scrollThreadFromComposerEdges = value;
- await this.plugin.saveSettings();
- });
- });
+ private renderSettingsShell(): void {
+ renderUiRoot(
+ this.containerEl,
+ {
+ void this.dynamicSections.refreshDynamicSections();
+ },
+ setCodexPath: (value) => {
+ void this.setCodexPath(value);
+ },
+ setShowToolbar: (value) => {
+ void this.setShowToolbar(value);
+ },
+ setSendShortcut: (value) => {
+ void this.setSendShortcut(value);
+ },
+ setScrollThreadFromComposerEdges: (value) => {
+ void this.setScrollThreadFromComposerEdges(value);
+ },
+ }}
+ />,
+ );
}
- private createDynamicSectionRoots(containerEl: HTMLElement): void {
- const container = containerEl.createDiv({ cls: "codex-panel-settings__preact-sections" });
- this.dynamicSectionRoots = {
- container,
- helper: container.createDiv(),
- archived: container.createDiv(),
- hooks: container.createDiv(),
- };
- }
-
- private unmountDynamicSectionRoots(): void {
- const roots = this.dynamicSectionRoots;
- if (!roots) return;
- unmountUiRoot(roots.helper);
- unmountUiRoot(roots.archived);
- unmountUiRoot(roots.hooks);
- this.dynamicSectionRoots = null;
- }
-
- private renderDynamicSections(target: SettingsDynamicSectionsDisplayTarget): void {
- const roots = this.dynamicSectionRoots;
- if (!roots) return;
- const state = this.dynamicSectionsState();
- if (target === "all" || target === "helper") {
- renderUiRoot(roots.helper, );
- }
- if (target === "all" || target === "archived") {
- renderUiRoot(roots.archived, );
- }
- if (target === "all" || target === "hooks") {
- renderUiRoot(roots.hooks, );
- }
- }
-
- private dynamicSectionsState(): SettingsSectionsState {
+ private settingsSectionsState(): SettingsSectionsState {
const dynamicSections = this.dynamicSections.snapshot();
return {
helper: {
@@ -222,16 +137,16 @@ export class CodexPanelSettingTab extends PluginSettingTab {
onExportTagsChange: (value) => void this.setArchiveExportTags(value),
onRestore: (threadId) => {
this.archivedDeleteConfirmThreadId = null;
- this.renderDynamicSections("archived");
+ this.renderSettingsShell();
void this.dynamicSections.restoreArchivedThread(threadId);
},
onStartDelete: (threadId) => {
this.archivedDeleteConfirmThreadId = threadId;
- this.renderDynamicSections("archived");
+ this.renderSettingsShell();
},
onDelete: (threadId) => {
this.archivedDeleteConfirmThreadId = null;
- this.renderDynamicSections("archived");
+ this.renderSettingsShell();
void this.dynamicSections.deleteArchivedThread(threadId);
},
},
@@ -251,26 +166,6 @@ export class CodexPanelSettingTab extends PluginSettingTab {
};
}
- private renderHeaderActions(containerEl: HTMLElement, introText: string): void {
- const header = containerEl.createDiv({ cls: "setting-item setting-item-heading codex-panel-settings__header" });
- const info = header.createDiv({ cls: "setting-item-info" });
- info.createDiv({
- cls: "setting-item-description codex-panel-settings__section-intro",
- text: introText,
- });
- const control = header.createDiv({ cls: "setting-item-control" });
- const button = control.createEl("button", {
- cls: "clickable-icon codex-panel-settings__refresh-button",
- });
- button.type = "button";
- button.disabled = this.dynamicSections.isLoading();
- button.ariaLabel = this.dynamicSections.isLoading() ? "Refreshing Codex details" : "Refresh Codex details";
- setIcon(button, "refresh-cw");
- button.addEventListener("click", () => {
- void this.dynamicSections.refreshDynamicSections();
- });
- }
-
private maybeAutoLoadDynamicSections(): void {
this.dynamicSections.maybeAutoLoadDynamicSections();
}
@@ -283,13 +178,32 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.dynamicSections.resetDynamicSectionContext();
this.plugin.appServerQueries.notifyContextChanged();
this.plugin.refreshOpenViews();
- this.renderSettingsTab({ autoLoadDynamicSections: false });
+ this.renderSettingsShell();
+ }
+
+ private async setShowToolbar(value: boolean): Promise {
+ this.plugin.settings.showToolbar = value;
+ await this.plugin.saveSettings();
+ this.plugin.refreshOpenViews();
+ this.renderSettingsShell();
+ }
+
+ private async setSendShortcut(value: "enter" | "mod-enter"): Promise {
+ this.plugin.settings.sendShortcut = value;
+ await this.plugin.saveSettings();
+ this.renderSettingsShell();
+ }
+
+ private async setScrollThreadFromComposerEdges(value: boolean): Promise {
+ this.plugin.settings.scrollThreadFromComposerEdges = value;
+ await this.plugin.saveSettings();
+ this.renderSettingsShell();
}
private async setArchiveExportEnabled(enabled: boolean): Promise {
this.plugin.settings.archiveExportEnabled = enabled;
await this.plugin.saveSettings();
- this.renderDynamicSections("archived");
+ this.renderSettingsShell();
}
private async setArchiveExportFolderTemplate(value: string): Promise {
@@ -313,7 +227,7 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.plugin.settings.threadNamingEffort = null;
}
await this.plugin.saveSettings();
- this.renderDynamicSections("helper");
+ this.renderSettingsShell();
}
private async setThreadNamingEffort(value: ReasoningEffort | null): Promise {
@@ -327,7 +241,7 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.plugin.settings.rewriteSelectionEffort = null;
}
await this.plugin.saveSettings();
- this.renderDynamicSections("helper");
+ this.renderSettingsShell();
}
private async setRewriteSelectionEffort(value: ReasoningEffort | null): Promise {
diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts
index 1df2ab75..fdf57f29 100644
--- a/tests/settings/settings-tab.test.ts
+++ b/tests/settings/settings-tab.test.ts
@@ -225,7 +225,7 @@ describe("settings tab", () => {
const codexInput = inputForSetting(tab, "Codex executable");
if (!codexInput) throw new Error("Missing Codex executable input");
codexInput.value = "/opt/codex";
- codexInput.dispatchEvent(new Event("change"));
+ codexInput.dispatchEvent(new InputEvent("input", { bubbles: true }));
await flushPromises();
expect(saveSettings).not.toHaveBeenCalled();
@@ -1071,12 +1071,7 @@ function settingsGroupNames(element: Element): string[] {
}
function settingsSectionRoots(tab: CodexPanelSettingTab): Element[] {
- return Array.from(tab.containerEl.children).flatMap((element) => {
- if (element.classList.contains("codex-panel-settings__preact-sections")) {
- return Array.from(element.children).flatMap((root) => Array.from(root.children));
- }
- return [element];
- });
+ return Array.from(tab.containerEl.children);
}
function settingDesc(tab: CodexPanelSettingTab, name: string): string {