mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
feat(settings): support declarative settings search
This commit is contained in:
parent
7dfc023360
commit
ec3bcd0312
7 changed files with 510 additions and 20 deletions
|
|
@ -18,16 +18,20 @@ export function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionS
|
|||
</SettingsGroup>
|
||||
<SettingsGroup className="codex-panel-settings__dynamic-section codex-panel-settings__archived-threads-section">
|
||||
<SettingsHeading dynamic name="Archived threads" />
|
||||
{state.contentAvailable ? (
|
||||
<ArchivedThreadList state={state} />
|
||||
) : !state.loading && state.status ? (
|
||||
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
|
||||
) : null}
|
||||
<ArchivedThreadsContent state={state} />
|
||||
</SettingsGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchivedThreadsContent({ state }: { state: ArchivedThreadSectionState }): UiNode {
|
||||
return state.contentAvailable ? (
|
||||
<ArchivedThreadList state={state} />
|
||||
) : !state.loading && state.status ? (
|
||||
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }): UiNode {
|
||||
return (
|
||||
<SettingsItems>
|
||||
|
|
|
|||
39
src/settings/declarative-settings.compat.ts
Normal file
39
src/settings/declarative-settings.compat.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { Setting, SettingGroup } from "obsidian";
|
||||
|
||||
// Obsidian 1.13 declarative-settings subset used while the plugin continues to
|
||||
// compile against its 1.12 runtime baseline. Remove this compatibility surface
|
||||
// when minAppVersion and the obsidian type package move to 1.13 or later.
|
||||
interface DeclarativeSettingBase {
|
||||
name: string;
|
||||
desc?: string | DocumentFragment;
|
||||
aliases?: string[];
|
||||
searchable?: boolean | (() => boolean);
|
||||
visible?: boolean | (() => boolean);
|
||||
}
|
||||
|
||||
type DeclarativeSettingControl =
|
||||
| { type: "toggle"; key: string; defaultValue?: boolean; disabled?: boolean | (() => boolean) }
|
||||
| {
|
||||
type: "dropdown";
|
||||
key: string;
|
||||
defaultValue?: string;
|
||||
options: Record<string, string>;
|
||||
disabled?: boolean | (() => boolean);
|
||||
};
|
||||
|
||||
export type DeclarativeSettingDefinition = DeclarativeSettingBase &
|
||||
(
|
||||
| { control: DeclarativeSettingControl; render?: never; action?: never }
|
||||
| { render: (setting: Setting, group: SettingGroup) => undefined | (() => void); control?: never; action?: never }
|
||||
| { control?: never; render?: never; action?: never }
|
||||
);
|
||||
|
||||
interface DeclarativeSettingGroup {
|
||||
type: "group";
|
||||
heading?: string;
|
||||
cls?: string;
|
||||
items?: DeclarativeSettingDefinition[];
|
||||
visible?: boolean | (() => boolean);
|
||||
}
|
||||
|
||||
export type DeclarativeSettingDefinitionItem = DeclarativeSettingDefinition | DeclarativeSettingGroup;
|
||||
|
|
@ -13,7 +13,7 @@ export function HelperSettingsSection({ state }: { state: HelperSettingsState })
|
|||
<SettingsGroup className="codex-panel-settings__section codex-panel-settings__helper-section">
|
||||
<SettingsHeading name="Panel helpers" />
|
||||
<SettingsItems>
|
||||
<ModelEffortSetting
|
||||
<ModelEffortControl
|
||||
name="Automatic thread naming"
|
||||
desc="Model and effort used when Codex Panel generates thread names."
|
||||
modelValue={state.threadNamingModel}
|
||||
|
|
@ -22,7 +22,7 @@ export function HelperSettingsSection({ state }: { state: HelperSettingsState })
|
|||
onModelChange={state.onThreadNamingModelChange}
|
||||
onEffortChange={state.onThreadNamingEffortChange}
|
||||
/>
|
||||
<ModelEffortSetting
|
||||
<ModelEffortControl
|
||||
name="Selection rewrite"
|
||||
desc="Model and effort used by Rewrite selection."
|
||||
modelValue={state.rewriteSelectionModel}
|
||||
|
|
@ -37,7 +37,7 @@ export function HelperSettingsSection({ state }: { state: HelperSettingsState })
|
|||
);
|
||||
}
|
||||
|
||||
function ModelEffortSetting({
|
||||
export function ModelEffortControl({
|
||||
name,
|
||||
desc,
|
||||
modelValue,
|
||||
|
|
@ -45,6 +45,7 @@ function ModelEffortSetting({
|
|||
models,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
controlsOnly = false,
|
||||
}: {
|
||||
name: string;
|
||||
desc: string;
|
||||
|
|
@ -53,10 +54,11 @@ function ModelEffortSetting({
|
|||
models: readonly ModelMetadata[];
|
||||
onModelChange: (value: string | null) => void;
|
||||
onEffortChange: (value: ReasoningEffort | null) => void;
|
||||
controlsOnly?: boolean;
|
||||
}): UiNode {
|
||||
const efforts = reasoningEffortsForSelectedModel(models, modelValue);
|
||||
return (
|
||||
<SettingRow name={name} desc={desc}>
|
||||
const controls = (
|
||||
<>
|
||||
<ObsidianDropdown
|
||||
value={modelValue ?? CODEX_DEFAULT_VALUE}
|
||||
onChange={(value) => {
|
||||
|
|
@ -71,6 +73,13 @@ function ModelEffortSetting({
|
|||
}}
|
||||
options={reasoningEffortSelectOptions(efforts, effortValue)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return controlsOnly ? (
|
||||
controls
|
||||
) : (
|
||||
<SettingRow name={name} desc={desc}>
|
||||
{controls}
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,15 +9,19 @@ export function HookSection({ state }: { state: HookSectionState }): UiNode {
|
|||
return (
|
||||
<SettingsGroup className="codex-panel-settings__dynamic-section codex-panel-settings__hook-section">
|
||||
<SettingsHeading dynamic name="Codex hooks" />
|
||||
{state.contentAvailable ? (
|
||||
<Hooks state={state} />
|
||||
) : !state.loading && state.status ? (
|
||||
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
|
||||
) : null}
|
||||
<HooksContent state={state} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function HooksContent({ state }: { state: HookSectionState }): UiNode {
|
||||
return state.contentAvailable ? (
|
||||
<Hooks state={state} />
|
||||
) : !state.loading && state.status ? (
|
||||
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function Hooks({ state }: { state: HookSectionState }): UiNode {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import { type App, Notice, type Plugin, PluginSettingTab } from "obsidian";
|
||||
import { type App, Notice, type Plugin, PluginSettingTab, type Setting } from "obsidian";
|
||||
import type { ComponentChild as UiNode } from "preact";
|
||||
|
||||
import { DEFAULT_CODEX_PATH } from "../constants";
|
||||
import type { ReasoningEffort } from "../domain/catalog/metadata";
|
||||
import { listenDomEvent } from "../shared/dom/events.dom";
|
||||
import { renderUiRoot, unmountUiRoot } from "../shared/dom/preact-root.dom";
|
||||
import { IconButton, ObsidianCommitTextInput } from "../shared/obsidian/components.obsidian";
|
||||
import { ArchivedThreadsContent } from "./archived-section";
|
||||
import type { DeclarativeSettingDefinition, DeclarativeSettingDefinitionItem } from "./declarative-settings.compat";
|
||||
import { SettingsDynamicSectionsController } from "./dynamic-sections-controller";
|
||||
import { ModelEffortControl } from "./helper-section";
|
||||
import { HooksContent } from "./hook-section";
|
||||
import type { CodexPanelSettingTabHost } from "./host";
|
||||
import type { CodexPanelSettings } from "./model";
|
||||
import { DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE, DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE, DEFAULT_ATTACHMENT_FOLDER } from "./model";
|
||||
|
|
@ -12,14 +18,16 @@ 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 ARCHIVE_EXPORT_TAGS_PLACEHOLDER = "codex, archive";
|
||||
|
||||
export class CodexPanelSettingTab extends PluginSettingTab {
|
||||
private readonly dynamicSections: SettingsDynamicSectionsController;
|
||||
private displayed = false;
|
||||
private renderMode: "hidden" | "legacy" | "declarative" = "hidden";
|
||||
private settingsShellRevision = 0;
|
||||
private settingsMutationQueue: Promise<void> = Promise.resolve();
|
||||
private archivedDeleteConfirmThreadId: string | null = null;
|
||||
private disposeOutsidePointer: (() => void) | null = null;
|
||||
private readonly declarativeIslandRefreshers = new Set<() => void>();
|
||||
private readonly cancelArchivedDeleteConfirmOnOutsidePointer = (event: PointerEvent): void => {
|
||||
if (!this.archivedDeleteConfirmThreadId) return;
|
||||
const target = event.target;
|
||||
|
|
@ -49,13 +57,236 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
display(): void {
|
||||
this.displayed = true;
|
||||
this.renderMode = "legacy";
|
||||
this.dynamicSections.activate();
|
||||
this.renderSettingsTab({ autoLoadDynamicSections: true });
|
||||
}
|
||||
|
||||
// Obsidian 1.13+ discovers this method at runtime and skips display(). The
|
||||
// local return type mirrors the 1.13 API while the project retains 1.12 types.
|
||||
getSettingDefinitions(): DeclarativeSettingDefinitionItem[] {
|
||||
return [
|
||||
{
|
||||
name: "Codex details",
|
||||
desc: SETTINGS_INTRO_TEXT,
|
||||
searchable: false,
|
||||
render: (setting) =>
|
||||
this.renderDeclarativeControl(setting, () => (
|
||||
<IconButton
|
||||
icon="refresh-cw"
|
||||
label={this.dynamicSections.isLoading() ? "Refreshing Codex details" : "Refresh Codex details"}
|
||||
className="clickable-icon codex-panel-settings__refresh-button"
|
||||
disabled={this.dynamicSections.isLoading()}
|
||||
onClick={() => void this.dynamicSections.refreshDynamicSections()}
|
||||
/>
|
||||
)),
|
||||
},
|
||||
{
|
||||
name: "Codex executable",
|
||||
desc: "Command used to start `codex app-server`. Use an absolute path when Obsidian cannot find `codex`.",
|
||||
render: (setting) =>
|
||||
this.renderDeclarativeControl(setting, () => (
|
||||
<ObsidianCommitTextInput
|
||||
key={this.settingsShellRevision}
|
||||
value={this.plugin.settings.codexPath}
|
||||
placeholder={DEFAULT_CODEX_PATH}
|
||||
normalizeValue={(value) => value.trim() || DEFAULT_CODEX_PATH}
|
||||
onCommit={(value) => void this.setCodexPath(value)}
|
||||
/>
|
||||
)),
|
||||
},
|
||||
{
|
||||
name: "Show chat toolbar",
|
||||
desc: "Shows the toolbar above chat panels.",
|
||||
control: { type: "toggle", key: "showToolbar" },
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
heading: "Panel helpers",
|
||||
cls: "codex-panel-settings__section",
|
||||
items: [
|
||||
{
|
||||
name: "Automatic thread naming",
|
||||
desc: "Model and effort used when Codex Panel generates thread names.",
|
||||
render: (setting) =>
|
||||
this.renderDeclarativeControl(setting, () => {
|
||||
const helper = this.settingsSectionsState().helper;
|
||||
return (
|
||||
<ModelEffortControl
|
||||
name="Automatic thread naming"
|
||||
desc="Model and effort used when Codex Panel generates thread names."
|
||||
modelValue={helper.threadNamingModel}
|
||||
effortValue={helper.threadNamingEffort}
|
||||
models={helper.models}
|
||||
onModelChange={helper.onThreadNamingModelChange}
|
||||
onEffortChange={helper.onThreadNamingEffortChange}
|
||||
controlsOnly
|
||||
/>
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Selection rewrite",
|
||||
desc: "Model and effort used by Rewrite selection.",
|
||||
render: (setting) =>
|
||||
this.renderDeclarativeControl(setting, () => {
|
||||
const helper = this.settingsSectionsState().helper;
|
||||
setting.setDesc(
|
||||
helper.modelLoadFailed
|
||||
? `Model and effort used by Rewrite selection. ${helper.modelStatus}`
|
||||
: "Model and effort used by Rewrite selection.",
|
||||
);
|
||||
return (
|
||||
<ModelEffortControl
|
||||
name="Selection rewrite"
|
||||
desc="Model and effort used by Rewrite selection."
|
||||
modelValue={helper.rewriteSelectionModel}
|
||||
effortValue={helper.rewriteSelectionEffort}
|
||||
models={helper.models}
|
||||
onModelChange={helper.onRewriteSelectionModelChange}
|
||||
onEffortChange={helper.onRewriteSelectionEffortChange}
|
||||
controlsOnly
|
||||
/>
|
||||
);
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
heading: "Composer",
|
||||
cls: "codex-panel-settings__section",
|
||||
items: [
|
||||
{
|
||||
name: "Send shortcut",
|
||||
desc: "Controls whether Enter or Cmd/Ctrl+Enter sends composer-style inputs. Shift+Enter adds a newline.",
|
||||
control: {
|
||||
type: "dropdown",
|
||||
key: "sendShortcut",
|
||||
defaultValue: "enter",
|
||||
options: { enter: "Enter", "mod-enter": "Cmd/Ctrl+Enter" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Scroll conversation from composer line edges",
|
||||
desc: "Lets Up/Ctrl+P and Down/Ctrl+N scroll the conversation from composer line edges.",
|
||||
control: { type: "toggle", key: "scrollThreadFromComposerEdges" },
|
||||
},
|
||||
{
|
||||
name: "Reference active file on send",
|
||||
desc: "Adds the active file as context on each send without changing the prompt text.",
|
||||
control: { type: "toggle", key: "referenceActiveNoteOnSend" },
|
||||
},
|
||||
{
|
||||
name: "Attachment folder",
|
||||
desc: "Vault-relative folder for files pasted or dropped into composer inputs.",
|
||||
render: (setting) =>
|
||||
this.renderDeclarativeControl(setting, () => (
|
||||
<ObsidianCommitTextInput
|
||||
key={this.settingsShellRevision}
|
||||
value={this.plugin.settings.attachmentFolder}
|
||||
placeholder={DEFAULT_ATTACHMENT_FOLDER}
|
||||
normalizeValue={(value) => value.trim() || DEFAULT_ATTACHMENT_FOLDER}
|
||||
onCommit={(value) => void this.setAttachmentFolder(value)}
|
||||
/>
|
||||
)),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
heading: "Thread archiving",
|
||||
cls: "codex-panel-settings__dynamic-section",
|
||||
items: [
|
||||
{
|
||||
name: "Save note by default",
|
||||
desc: "Makes Save and archive thread the default archive action.",
|
||||
control: { type: "toggle", key: "archiveExportEnabled" },
|
||||
},
|
||||
this.commitTextDefinition({
|
||||
name: "Saved note folder",
|
||||
desc: "Vault-relative folder for archived thread notes.",
|
||||
value: () => this.plugin.settings.archiveExportFolderTemplate,
|
||||
placeholder: DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE,
|
||||
normalizeValue: (value) => value.trim() || DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE,
|
||||
onCommit: (value) => this.setArchiveExportFolderTemplate(value),
|
||||
}),
|
||||
this.commitTextDefinition({
|
||||
name: "Saved note filename",
|
||||
desc: "Filename template. Supports {{date}}, {{time}}, {{title}}, {{id}}, and {{shortId}}.",
|
||||
value: () => this.plugin.settings.archiveExportFilenameTemplate,
|
||||
placeholder: DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE,
|
||||
normalizeValue: (value) => value.trim() || DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE,
|
||||
onCommit: (value) => this.setArchiveExportFilenameTemplate(value),
|
||||
}),
|
||||
this.commitTextDefinition({
|
||||
name: "Saved note tags",
|
||||
desc: "Comma-separated tags added to saved thread notes.",
|
||||
value: () => this.plugin.settings.archiveExportTags,
|
||||
placeholder: ARCHIVE_EXPORT_TAGS_PLACEHOLDER,
|
||||
normalizeValue: (value) => value.trim(),
|
||||
onCommit: (value) => this.setArchiveExportTags(value),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
heading: "Archived threads",
|
||||
cls: "codex-panel-settings__dynamic-section",
|
||||
items: [
|
||||
{
|
||||
name: "Archived threads content",
|
||||
searchable: false,
|
||||
render: (setting) =>
|
||||
this.renderDeclarativeSection(setting, () => <ArchivedThreadsContent state={this.settingsSectionsState().archived} />),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
heading: "Codex hooks",
|
||||
cls: "codex-panel-settings__dynamic-section",
|
||||
items: [
|
||||
{
|
||||
name: "Codex hooks content",
|
||||
searchable: false,
|
||||
render: (setting) => this.renderDeclarativeSection(setting, () => <HooksContent state={this.settingsSectionsState().hooks} />),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getControlValue(key: string): unknown {
|
||||
if (!(key in this.plugin.settings)) return undefined;
|
||||
return this.plugin.settings[key as keyof CodexPanelSettings];
|
||||
}
|
||||
|
||||
async setControlValue(key: string, value: unknown): Promise<void> {
|
||||
switch (key) {
|
||||
case "showToolbar":
|
||||
if (typeof value === "boolean") await this.setShowToolbar(value);
|
||||
return;
|
||||
case "sendShortcut":
|
||||
if (value === "enter" || value === "mod-enter") await this.setSendShortcut(value);
|
||||
return;
|
||||
case "scrollThreadFromComposerEdges":
|
||||
if (typeof value === "boolean") await this.setScrollThreadFromComposerEdges(value);
|
||||
return;
|
||||
case "referenceActiveNoteOnSend":
|
||||
if (typeof value === "boolean") await this.setReferenceActiveNoteOnSend(value);
|
||||
return;
|
||||
case "archiveExportEnabled":
|
||||
if (typeof value === "boolean") await this.setArchiveExportEnabled(value);
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unknown declarative setting key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
override hide(): void {
|
||||
this.displayed = false;
|
||||
this.renderMode = "hidden";
|
||||
this.declarativeIslandRefreshers.clear();
|
||||
this.disposeOutsidePointer?.();
|
||||
this.disposeOutsidePointer = null;
|
||||
this.archivedDeleteConfirmThreadId = null;
|
||||
|
|
@ -75,6 +306,65 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
if (options.autoLoadDynamicSections) this.maybeAutoLoadDynamicSections();
|
||||
}
|
||||
|
||||
private beginDeclarativeDisplay(): void {
|
||||
if (this.renderMode === "declarative") return;
|
||||
this.renderMode = "declarative";
|
||||
this.containerEl.addClass("codex-panel-settings");
|
||||
this.disposeOutsidePointer?.();
|
||||
this.disposeOutsidePointer = listenDomEvent(this.containerEl, "pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
|
||||
this.dynamicSections.activate();
|
||||
queueMicrotask(() => {
|
||||
if (this.renderMode === "declarative") this.maybeAutoLoadDynamicSections();
|
||||
});
|
||||
}
|
||||
|
||||
private renderDeclarativeControl(setting: Setting, renderNode: () => UiNode): () => void {
|
||||
return this.renderDeclarativeIsland(setting.controlEl, renderNode);
|
||||
}
|
||||
|
||||
private renderDeclarativeSection(setting: Setting, renderNode: () => UiNode): () => void {
|
||||
setting.setClass("codex-panel-settings__declarative-section-island");
|
||||
setting.settingEl.empty();
|
||||
return this.renderDeclarativeIsland(setting.settingEl, renderNode);
|
||||
}
|
||||
|
||||
private renderDeclarativeIsland(container: HTMLElement, renderNode: () => UiNode): () => void {
|
||||
this.beginDeclarativeDisplay();
|
||||
const refresh = (): void => {
|
||||
renderUiRoot(container, renderNode());
|
||||
};
|
||||
this.declarativeIslandRefreshers.add(refresh);
|
||||
refresh();
|
||||
return () => {
|
||||
this.declarativeIslandRefreshers.delete(refresh);
|
||||
unmountUiRoot(container);
|
||||
};
|
||||
}
|
||||
|
||||
private commitTextDefinition(options: {
|
||||
name: string;
|
||||
desc: string;
|
||||
value: () => string;
|
||||
placeholder: string;
|
||||
normalizeValue: (value: string) => string;
|
||||
onCommit: (value: string) => Promise<void>;
|
||||
}): DeclarativeSettingDefinition {
|
||||
return {
|
||||
name: options.name,
|
||||
desc: options.desc,
|
||||
render: (setting) =>
|
||||
this.renderDeclarativeControl(setting, () => (
|
||||
<ObsidianCommitTextInput
|
||||
key={this.settingsShellRevision}
|
||||
value={options.value()}
|
||||
placeholder={options.placeholder}
|
||||
normalizeValue={options.normalizeValue}
|
||||
onCommit={(value) => void options.onCommit(value)}
|
||||
/>
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
private renderSettingsShell(): void {
|
||||
renderUiRoot(
|
||||
this.containerEl,
|
||||
|
|
@ -311,6 +601,11 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private requestRender(): void {
|
||||
if (this.displayed) this.renderSettingsShell();
|
||||
if (this.renderMode === "hidden") return;
|
||||
if (this.renderMode === "declarative") {
|
||||
for (const refresh of this.declarativeIslandRefreshers) refresh();
|
||||
return;
|
||||
}
|
||||
this.renderSettingsShell();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
.setting-item.codex-panel-settings__declarative-section-island {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.setting-item.codex-panel-settings__declarative-section-island:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.codex-panel-settings__dynamic-section-heading + .codex-panel-settings__dynamic-list {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { Setting } from "obsidian";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
|
||||
import type { DeclarativeSettingDefinition, DeclarativeSettingDefinitionItem } from "../../src/settings/declarative-settings.compat";
|
||||
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
|
||||
import { DEFAULT_SETTINGS } from "../../src/settings/model";
|
||||
import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian";
|
||||
|
|
@ -40,6 +42,108 @@ describe("settings tab", () => {
|
|||
notices.length = 0;
|
||||
});
|
||||
|
||||
it("exposes every persistent setting to Obsidian 1.13 search without loading dynamic data", () => {
|
||||
const tab = newSettingsTab();
|
||||
|
||||
const definitions = tab.getSettingDefinitions();
|
||||
|
||||
expect(withShortLivedAppServerClientMock).not.toHaveBeenCalled();
|
||||
expect(declarativeDefinitionNames(definitions)).toEqual([
|
||||
"Codex details",
|
||||
"Codex executable",
|
||||
"Show chat toolbar",
|
||||
"Panel helpers",
|
||||
"Automatic thread naming",
|
||||
"Selection rewrite",
|
||||
"Composer",
|
||||
"Send shortcut",
|
||||
"Scroll conversation from composer line edges",
|
||||
"Reference active file on send",
|
||||
"Attachment folder",
|
||||
"Thread archiving",
|
||||
"Save note by default",
|
||||
"Saved note folder",
|
||||
"Saved note filename",
|
||||
"Saved note tags",
|
||||
"Archived threads",
|
||||
"Archived threads content",
|
||||
"Codex hooks",
|
||||
"Codex hooks content",
|
||||
]);
|
||||
expect(declarativeDefinitionByName(definitions, "Codex details")?.searchable).toBe(false);
|
||||
});
|
||||
|
||||
it("routes declarative controls through the existing settings publication boundary", async () => {
|
||||
const saveSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshOpenViews = vi.fn();
|
||||
const tab = newSettingsTab({ saveSettings, refreshOpenViews });
|
||||
|
||||
expect(tab.getControlValue("showToolbar")).toBe(true);
|
||||
await tab.setControlValue("showToolbar", false);
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledOnce();
|
||||
expect(refreshOpenViews).toHaveBeenCalledOnce();
|
||||
expect(tab.getControlValue("showToolbar")).toBe(false);
|
||||
});
|
||||
|
||||
it("starts dynamic loading from declarative rendering instead of definition indexing", async () => {
|
||||
const client = settingsClient();
|
||||
useShortLivedClients(client);
|
||||
const tab = newSettingsTab();
|
||||
const header = declarativeDefinitionByName(tab.getSettingDefinitions(), "Codex details");
|
||||
if (!header?.render) throw new Error("Missing declarative Codex details renderer");
|
||||
const container = document.createElement("div");
|
||||
|
||||
header.render(new Setting(container), {} as never);
|
||||
expect(withShortLivedAppServerClientMock).not.toHaveBeenCalled();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(withShortLivedAppServerClientMock).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector("button")?.ariaLabel).toBe("Refresh Codex details");
|
||||
});
|
||||
|
||||
it("preserves an active declarative text island while dynamic sections refresh", async () => {
|
||||
const client = settingsClient();
|
||||
useShortLivedClients(client);
|
||||
const tab = newSettingsTab();
|
||||
const executable = declarativeDefinitionByName(tab.getSettingDefinitions(), "Codex executable");
|
||||
if (!executable?.render) throw new Error("Missing declarative Codex executable renderer");
|
||||
const container = document.createElement("div");
|
||||
|
||||
executable.render(new Setting(container), {} as never);
|
||||
const input = container.querySelector("input");
|
||||
if (!input) throw new Error("Missing declarative Codex executable input");
|
||||
input.focus();
|
||||
input.value = "/draft/codex";
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(container.querySelector("input")).toBe(input);
|
||||
expect(input.value).toBe("/draft/codex");
|
||||
});
|
||||
|
||||
it("rolls back a declarative text island after publication fails", async () => {
|
||||
const client = settingsClient();
|
||||
useShortLivedClients(client);
|
||||
const tab = newSettingsTab({ saveSettings: vi.fn().mockRejectedValue(new Error("disk full")) });
|
||||
const executable = declarativeDefinitionByName(tab.getSettingDefinitions(), "Codex executable");
|
||||
if (!executable?.render) throw new Error("Missing declarative Codex executable renderer");
|
||||
const container = document.createElement("div");
|
||||
|
||||
executable.render(new Setting(container), {} as never);
|
||||
const input = container.querySelector("input");
|
||||
if (!input) throw new Error("Missing declarative Codex executable input");
|
||||
input.value = "/failed/codex";
|
||||
input.dispatchEvent(new Event("blur"));
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(container.querySelector("input")).not.toBe(input);
|
||||
expect(container.querySelector("input")?.value).toBe(DEFAULT_SETTINGS.codexPath);
|
||||
expect(notices).toContain("Failed to save Codex Panel settings: disk full");
|
||||
});
|
||||
|
||||
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")]));
|
||||
|
|
@ -658,6 +762,30 @@ function newSettingsTab(options: SettingsTabHostOptions = {}): CodexPanelSetting
|
|||
return new CodexPanelSettingTab({} as never, {} as never, settingsTabHost(options));
|
||||
}
|
||||
|
||||
function declarativeDefinitionNames(definitions: DeclarativeSettingDefinitionItem[]): string[] {
|
||||
return definitions.flatMap((definition) => {
|
||||
if ("type" in definition) {
|
||||
return [...(definition.heading ? [definition.heading] : []), ...declarativeDefinitionNames(definition.items ?? [])];
|
||||
}
|
||||
return [definition.name];
|
||||
});
|
||||
}
|
||||
|
||||
function declarativeDefinitionByName(
|
||||
definitions: DeclarativeSettingDefinitionItem[],
|
||||
name: string,
|
||||
): DeclarativeSettingDefinition | undefined {
|
||||
for (const definition of definitions) {
|
||||
if ("type" in definition) {
|
||||
const nested = declarativeDefinitionByName(definition.items ?? [], name);
|
||||
if (nested) return nested;
|
||||
} else if (definition.name === name) {
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function settingNames(tab: CodexPanelSettingTab): string[] {
|
||||
return Array.from(settingsSectionRoots(tab)).flatMap((element) => {
|
||||
if (element.classList.contains("codex-panel-settings__header")) return [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue