Group settings rows with Obsidian setting groups

This commit is contained in:
murashit 2026-06-20 11:35:24 +09:00
parent 3c7861fcd1
commit 76c0a5a907
7 changed files with 178 additions and 119 deletions

View file

@ -4,31 +4,47 @@ import type { Thread } from "../domain/threads/model";
import { shortThreadId } from "../utils";
import { archivedThreadDisplayTitle } from "./archived-thread-title";
import type { ArchivedThreadSectionState } from "./section-state";
import { SettingRow, SettingsHeading, SettingsIconButton, TextControl, ToggleControl } from "./setting-components";
import {
SettingRow,
SettingsGroup,
SettingsHeading,
SettingsIconButton,
SettingsItems,
SettingsStatusRow,
TextControl,
ToggleControl,
} from "./setting-components";
export function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionState }): UiNode {
return (
<section className="codex-panel-settings__dynamic-section codex-panel-settings__archived-section">
<SettingsHeading
dynamic
name="Thread archiving"
desc="Choose the default archive behavior and configure saved thread notes. Thread lists offer both archive choices; slash commands use the default."
/>
<ArchiveExportSettings state={state} />
{state.contentAvailable && state.threads.length === 0 ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">No archived threads.</p>
) : state.contentAvailable ? (
<ArchivedThreadList state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null}
</section>
<>
<SettingsGroup className="codex-panel-settings__dynamic-section codex-panel-settings__archived-section">
<SettingsHeading
dynamic
name="Thread archiving"
desc="Choose the default archive behavior and configure saved thread notes. Thread lists offer both archive choices; slash commands use the default."
/>
<ArchiveExportSettings state={state} />
</SettingsGroup>
<SettingsGroup className="codex-panel-settings__dynamic-section codex-panel-settings__archived-threads-section">
<SettingsHeading
dynamic
name="Archived threads"
desc="Restore archived threads, or permanently delete archived threads you no longer need."
/>
{state.contentAvailable ? (
<ArchivedThreadList state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null}
</SettingsGroup>
</>
);
}
function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }): UiNode {
return (
<>
<SettingsItems>
<SettingRow
name="Save note by default"
desc="When on, the default archive action saves a markdown note before archiving. When off, the default archives without saving. If saving fails, the thread stays active. Frontmatter includes title, thread_id, created, and optional tags."
@ -70,22 +86,19 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
}}
/>
</SettingRow>
</>
</SettingsItems>
);
}
function ArchivedThreadList({ state }: { state: ArchivedThreadSectionState }): UiNode {
return (
<>
<p className="setting-item-description codex-panel-settings__dynamic-list-summary">
Restore archived threads, or permanently delete archived threads you no longer need.
</p>
<div className="setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list">
{state.threads.map((thread) => (
<ArchivedThreadRow key={thread.id} thread={thread} state={state} />
))}
</div>
</>
<SettingsItems className="codex-panel-settings__dynamic-list codex-panel-settings__archived-list">
{state.threads.length === 0 ? (
<SettingsStatusRow>No archived threads.</SettingsStatusRow>
) : (
state.threads.map((thread) => <ArchivedThreadRow key={thread.id} thread={thread} state={state} />)
)}
</SettingsItems>
);
}

View file

@ -3,34 +3,36 @@ import type { ComponentChild as UiNode } from "preact";
import type { ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
import { findModelMetadataByIdOrName, supportedEffortsForModelMetadata } from "../domain/catalog/metadata";
import type { HelperSettingsState } from "./section-state";
import { SelectControl, SettingRow, SettingsHeading } from "./setting-components";
import { SelectControl, SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components";
const CODEX_DEFAULT_VALUE = "__codex-default__";
export function HelperSettingsSection({ state }: { state: HelperSettingsState }): UiNode {
return (
<section className="codex-panel-settings__section codex-panel-settings__helper-section">
<SettingsGroup className="codex-panel-settings__section codex-panel-settings__helper-section">
<SettingsHeading name="Codex helpers" />
<ModelEffortSetting
name="Automatic thread naming"
desc="Choose the model and reasoning effort used to suggest thread names."
modelValue={state.threadNamingModel}
effortValue={state.threadNamingEffort}
models={state.models}
onModelChange={state.onThreadNamingModelChange}
onEffortChange={state.onThreadNamingEffortChange}
/>
<ModelEffortSetting
name="Selection rewrite"
desc="Choose the model and reasoning effort used by rewrite selection."
modelValue={state.rewriteSelectionModel}
effortValue={state.rewriteSelectionEffort}
models={state.models}
onModelChange={state.onRewriteSelectionModelChange}
onEffortChange={state.onRewriteSelectionEffortChange}
/>
<SettingsItems>
<ModelEffortSetting
name="Automatic thread naming"
desc="Choose the model and reasoning effort used to suggest thread names."
modelValue={state.threadNamingModel}
effortValue={state.threadNamingEffort}
models={state.models}
onModelChange={state.onThreadNamingModelChange}
onEffortChange={state.onThreadNamingEffortChange}
/>
<ModelEffortSetting
name="Selection rewrite"
desc="Choose the model and reasoning effort used by rewrite selection."
modelValue={state.rewriteSelectionModel}
effortValue={state.rewriteSelectionEffort}
models={state.models}
onModelChange={state.onRewriteSelectionModelChange}
onEffortChange={state.onRewriteSelectionEffortChange}
/>
</SettingsItems>
{state.modelLoadFailed ? <p className="setting-item-description codex-panel-settings__section-status">{state.modelStatus}</p> : null}
</section>
</SettingsGroup>
);
}

View file

@ -3,33 +3,31 @@ import type { ComponentChild as UiNode } from "preact";
import type { HookItem } from "../domain/catalog/metadata";
import { ObsidianButton } from "../shared/ui/components";
import type { HookSectionState } from "./section-state";
import { SettingRow, SettingsHeading } from "./setting-components";
import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems, SettingsStatusRow } from "./setting-components";
export function HookSection({ state }: { state: HookSectionState }): UiNode {
return (
<section className="codex-panel-settings__dynamic-section codex-panel-settings__hook-section">
<SettingsGroup className="codex-panel-settings__dynamic-section codex-panel-settings__hook-section">
<SettingsHeading dynamic name="Hook status" desc="Review discovered hooks, trust changes, and turn hooks on or off." />
{state.contentAvailable ? (
<Hooks state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null}
</section>
</SettingsGroup>
);
}
function Hooks({ state }: { state: HookSectionState }): UiNode {
return (
<>
{state.hooks.length === 0 ? (
<p className="setting-item-description">No hooks found for this vault root.</p>
) : (
<div className="setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list">
{state.hooks.map((hook) => (
<HookRow key={hook.key} hook={hook} state={state} />
))}
</div>
)}
<SettingsItems className="codex-panel-settings__dynamic-list codex-panel-settings__hook-list">
{state.hooks.length === 0 ? (
<SettingsStatusRow>No hooks found for this vault root.</SettingsStatusRow>
) : (
state.hooks.map((hook) => <HookRow key={hook.key} hook={hook} state={state} />)
)}
</SettingsItems>
{state.warnings.map((warning) => (
<p key={`warning:${warning}`} className="setting-item-description codex-panel-settings__hook-warning">
{warning}

View file

@ -10,6 +10,25 @@ import {
export type SelectControlOption = ObsidianDropdownOption;
export function SettingsGroup({ className, children }: { className: string; children: UiNode }): UiNode {
return <section className={`setting-group ${className}`}>{children}</section>;
}
export function SettingsItems({ className = "", children }: { className?: string; children: UiNode }): UiNode {
return <div className={`setting-items ${className}`.trim()}>{children}</div>;
}
export function SettingsStatusRow({ children }: { children: UiNode }): UiNode {
return (
<div className="setting-item codex-panel-settings__status-row">
<div className="setting-item-info">
<div className="setting-item-description">{children}</div>
</div>
<div className="setting-item-control" />
</div>
);
}
export function SettingsHeading({ name, desc, dynamic = false }: { name: string; desc?: string; dynamic?: boolean }): UiNode {
return (
<div

View file

@ -28,6 +28,14 @@ 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 dynamicData: SettingsDynamicDataController;
private archivedDeleteConfirmThreadId: string | null = null;
@ -90,9 +98,10 @@ export class CodexPanelSettingTab extends PluginSettingTab {
}
private renderPanelPreferenceSections(containerEl: HTMLElement): void {
const configSection = containerEl.createDiv({ cls: "codex-panel-settings__section codex-panel-settings__general-section" });
const configSection = createSettingsGroup(containerEl, "codex-panel-settings__section codex-panel-settings__general-section");
const configItems = createSettingsItems(configSection);
new Setting(configSection)
new Setting(configItems)
.setName("Codex executable")
.setDesc("Path used to start `codex app-server`. Use an absolute path if Obsidian cannot find `codex`.")
.addText((text) => {
@ -108,7 +117,7 @@ export class CodexPanelSettingTab extends PluginSettingTab {
text.inputEl.blur();
});
});
new Setting(configSection)
new Setting(configItems)
.setName("Show chat toolbar")
.setDesc("Show the chat panel toolbar. Slash commands, composer status controls, and the threads view remain available when hidden.")
.addToggle((toggle) => {
@ -119,9 +128,10 @@ export class CodexPanelSettingTab extends PluginSettingTab {
});
});
const composerSection = containerEl.createDiv({ cls: "codex-panel-settings__section codex-panel-settings__composer-section" });
const composerSection = createSettingsGroup(containerEl, "codex-panel-settings__section codex-panel-settings__composer-section");
renderSettingsHeading(composerSection, "Composer");
new Setting(composerSection)
const composerItems = createSettingsItems(composerSection);
new Setting(composerItems)
.setName("Send shortcut")
.setDesc(
"Choose how the composer sends messages. Shift+Enter inserts a newline when Enter sends. Obsidian hotkeys may intercept Cmd/Ctrl+Enter.",
@ -135,7 +145,7 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.display();
});
});
new Setting(composerSection)
new Setting(composerItems)
.setName("Scroll thread from composer edges")
.setDesc("When enabled, Up/Ctrl+P on the first composer line and Down/Ctrl+N on the last line scroll the thread.")
.addToggle((toggle) => {
@ -244,12 +254,14 @@ export class CodexPanelSettingTab extends PluginSettingTab {
}
private renderHeaderActions(containerEl: HTMLElement, introText: string): void {
const header = containerEl.createDiv({ cls: "codex-panel-settings__header" });
header.createEl("span", {
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 button = header.createEl("button", {
const control = header.createDiv({ cls: "setting-item-control" });
const button = control.createEl("button", {
cls: "clickable-icon codex-panel-settings__refresh-button",
});
button.type = "button";

View file

@ -3,15 +3,6 @@
margin-top: var(--size-4-6);
}
.codex-panel-settings__header {
display: grid;
grid-template-columns: minmax(0, max-content) auto;
align-items: start;
justify-content: start;
gap: var(--size-4-2);
min-height: var(--input-height);
}
.codex-panel-settings__refresh-button {
display: inline-flex;
align-items: center;
@ -21,41 +12,24 @@
padding: 0;
}
.codex-panel-settings__section-heading.setting-item-heading,
.codex-panel-settings__dynamic-section-heading.setting-item-heading {
margin-bottom: var(--size-4-2);
}
.codex-panel-settings__dynamic-section-status {
padding: 0 var(--size-4-4);
}
.codex-panel-settings__section-status {
padding: 0 var(--size-4-4);
}
.codex-panel-settings__section-intro,
.codex-panel-settings__dynamic-list-summary {
.codex-panel-settings__section-intro {
color: var(--text-muted);
font-size: var(--font-ui-small);
line-height: var(--line-height-normal);
}
.codex-panel-settings__section-intro {
margin: 0;
}
.codex-panel-settings__dynamic-list-summary {
padding: 0 var(--size-4-4);
margin: 0 0 var(--size-2-3);
.codex-panel-settings__dynamic-list {
margin: var(--size-4-4) 0 0;
overflow: auto;
}
.codex-panel-settings__dynamic-list {
margin: var(--size-2-3) 0 0;
overflow: auto;
border: var(--codex-panel-border);
border-radius: var(--codex-panel-card-radius);
background: var(--codex-panel-surface);
.codex-panel-settings__dynamic-section-heading + .codex-panel-settings__dynamic-list {
margin-top: 0;
}
.codex-panel-settings__archived-list {
@ -67,16 +41,7 @@
}
.codex-panel-settings__dynamic-row.setting-item {
margin: 0;
padding: var(--size-4-2) var(--size-4-3);
border: 0;
border-top: var(--codex-panel-border);
border-radius: 0;
background: transparent;
}
.codex-panel-settings__dynamic-row.setting-item:first-child {
border-top: 0;
margin-bottom: 0;
}
.codex-panel-settings__archived-row--delete-confirming {

View file

@ -70,8 +70,34 @@ describe("settings tab", () => {
expect(buttonTexts(tab)).not.toContain("Load models");
expect(buttonTexts(tab)).not.toContain("Load hooks");
expect(buttonTexts(tab)).not.toContain("Load archive list");
expect(
tab.containerEl.querySelector(".codex-panel-settings__header.setting-item-heading .setting-item-description")?.textContent,
).toContain("Codex Panel stores only panel preferences");
expect(tab.containerEl.querySelector(".codex-panel-settings__header button")?.getAttribute("data-icon")).toBe("refresh-cw");
expect(tab.containerEl.querySelector("h2")).toBeNull();
expect(tab.containerEl.querySelector(".codex-panel-settings__general-section.setting-group > .setting-items")?.children).toHaveLength(
2,
);
expect(tab.containerEl.querySelector(".codex-panel-settings__composer-section.setting-group > .setting-items")?.children).toHaveLength(
2,
);
expect(tab.containerEl.querySelector(".codex-panel-settings__helper-section.setting-group > .setting-items")?.children).toHaveLength(2);
expect(
tab.containerEl.querySelector(".codex-panel-settings__archived-section > .setting-item-heading .setting-item-description")
?.textContent,
).toContain("Choose the default archive behavior");
expect(
tab.containerEl.querySelector(".codex-panel-settings__archived-threads-section > .setting-item-heading .setting-item-description")
?.textContent,
).toContain("Restore archived threads");
expect(
tab.containerEl.querySelector(".codex-panel-settings__hook-section > .setting-item-heading .setting-item-description")?.textContent,
).toContain("Review discovered hooks");
expect(
tab.containerEl.querySelector(
".codex-panel-settings__archived-section.setting-group > .setting-items:not(.codex-panel-settings__dynamic-list)",
)?.children,
).toHaveLength(4);
expect(settingNames(tab)).toEqual([
"Codex executable",
"Show chat toolbar",
@ -82,6 +108,11 @@ describe("settings tab", () => {
"Automatic thread naming",
"Selection rewrite",
"Thread archiving",
"Save note by default",
"Saved note folder",
"Saved note filename",
"Saved note tags",
"Archived threads",
"Hook status",
]);
});
@ -611,8 +642,11 @@ describe("settings tab", () => {
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-section .setting-item-heading")?.textContent).toContain(
"Thread archiving",
);
expect(tab.containerEl.querySelectorAll(".codex-panel-settings__hook-list .setting-item")).toHaveLength(1);
expect(tab.containerEl.querySelectorAll(".codex-panel-settings__archived-list .setting-item")).toHaveLength(1);
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).toContain("abc123");
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-list")?.textContent).toContain("Archived thread");
});
@ -749,7 +783,10 @@ describe("settings tab", () => {
await flushPromises();
expect(tab.containerEl.textContent).toContain("No archived threads.");
expect(tab.containerEl.querySelectorAll(".codex-panel-settings__archived-list .setting-item")).toHaveLength(0);
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);
});
});
@ -942,21 +979,34 @@ async function flushPromises(): Promise<void> {
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 Array.from(element.querySelectorAll(":scope > .setting-item")).map((setting) => {
return setting.querySelector(".setting-item-name")?.textContent ?? "";
});
return settingsGroupNames(element);
}
if (element.classList.contains("codex-panel-settings__dynamic-section")) {
return [element.querySelector(":scope > .setting-item-heading .setting-item-name")?.textContent ?? ""];
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).flatMap((element) => {
if (element.classList.contains("codex-panel-settings__preact-sections")) {