From 76c0a5a9074354de6ffd9513b092d4ea29915fdc Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 20 Jun 2026 11:35:24 +0900 Subject: [PATCH] Group settings rows with Obsidian setting groups --- src/settings/archived-section.tsx | 69 +++++++++++++++++------------ src/settings/helper-section.tsx | 44 +++++++++--------- src/settings/hook-section.tsx | 22 +++++---- src/settings/setting-components.tsx | 19 ++++++++ src/settings/tab.tsx | 30 +++++++++---- src/styles/60-settings.css | 49 +++----------------- tests/settings/settings-tab.test.ts | 64 +++++++++++++++++++++++--- 7 files changed, 178 insertions(+), 119 deletions(-) diff --git a/src/settings/archived-section.tsx b/src/settings/archived-section.tsx index 60298107..caaf7d74 100644 --- a/src/settings/archived-section.tsx +++ b/src/settings/archived-section.tsx @@ -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 ( -
- - - {state.contentAvailable && state.threads.length === 0 ? ( -

No archived threads.

- ) : state.contentAvailable ? ( - - ) : !state.loading && state.status ? ( -

{state.status}

- ) : null} -
+ <> + + + + + + + {state.contentAvailable ? ( + + ) : !state.loading && state.status ? ( +

{state.status}

+ ) : null} +
+ ); } function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }): UiNode { return ( - <> + - + ); } function ArchivedThreadList({ state }: { state: ArchivedThreadSectionState }): UiNode { return ( - <> -

- Restore archived threads, or permanently delete archived threads you no longer need. -

-
- {state.threads.map((thread) => ( - - ))} -
- + + {state.threads.length === 0 ? ( + No archived threads. + ) : ( + state.threads.map((thread) => ) + )} + ); } diff --git a/src/settings/helper-section.tsx b/src/settings/helper-section.tsx index 528b8a6d..71e70505 100644 --- a/src/settings/helper-section.tsx +++ b/src/settings/helper-section.tsx @@ -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 ( -
+ - - + + + + {state.modelLoadFailed ?

{state.modelStatus}

: null} -
+ ); } diff --git a/src/settings/hook-section.tsx b/src/settings/hook-section.tsx index d396ba5a..36251a0d 100644 --- a/src/settings/hook-section.tsx +++ b/src/settings/hook-section.tsx @@ -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 ( -
+ {state.contentAvailable ? ( ) : !state.loading && state.status ? (

{state.status}

) : null} -
+ ); } function Hooks({ state }: { state: HookSectionState }): UiNode { return ( <> - {state.hooks.length === 0 ? ( -

No hooks found for this vault root.

- ) : ( -
- {state.hooks.map((hook) => ( - - ))} -
- )} + + {state.hooks.length === 0 ? ( + No hooks found for this vault root. + ) : ( + state.hooks.map((hook) => ) + )} + {state.warnings.map((warning) => (

{warning} diff --git a/src/settings/setting-components.tsx b/src/settings/setting-components.tsx index fa6741e9..15704e7d 100644 --- a/src/settings/setting-components.tsx +++ b/src/settings/setting-components.tsx @@ -10,6 +10,25 @@ import { export type SelectControlOption = ObsidianDropdownOption; +export function SettingsGroup({ className, children }: { className: string; children: UiNode }): UiNode { + return

{children}
; +} + +export function SettingsItems({ className = "", children }: { className?: string; children: UiNode }): UiNode { + return
{children}
; +} + +export function SettingsStatusRow({ children }: { children: UiNode }): UiNode { + return ( +
+
+
{children}
+
+
+
+ ); +} + export function SettingsHeading({ name, desc, dynamic = false }: { name: string; desc?: string; dynamic?: boolean }): UiNode { return (
{ @@ -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"; diff --git a/src/styles/60-settings.css b/src/styles/60-settings.css index d50a8f73..2314bab6 100644 --- a/src/styles/60-settings.css +++ b/src/styles/60-settings.css @@ -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 { diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 5a993ac6..e8b72db9 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -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 { 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")) {