From c559d165a6aa7abe8cbcdcb67dac47cbce96f4d5 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Wed, 16 Sep 2026 08:47:00 +0800 Subject: [PATCH 1/2] feat(chat): open the settings window from the view header Reaching a Qoderian setting meant diving into Obsidian's settings pane. Put a gear button beside the feedback button in the view header that opens the settings page in its own window, so the sidebar and chat stay in place. Co-authored-by: QoderAI --- CHANGELOG.md | 2 + src/features/chat/chat-view.ts | 10 +++ src/features/settings/settings-modal.ts | 31 ++++++++ src/style/settings/base.css | 12 +++ tests/__mocks__/obsidian.ts | 6 ++ .../features/settings/settings-modal.test.ts | 77 +++++++++++++++++++ 6 files changed, 138 insertions(+) create mode 100644 src/features/settings/settings-modal.ts create mode 100644 tests/unit/features/settings/settings-modal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a0655cb..c4a6a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ version with its date and start a fresh empty `[Unreleased]` above it. ### Added +- A settings button in the view header opens Qoderian's settings window in + place, instead of navigating into Obsidian's settings. - Show the added external context path after dropping a folder or file, surface rejected additions, and show the context count even for a single path. - Drag files and folders from your operating system (e.g. macOS Finder) onto diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 22c1355..7807455 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -14,6 +14,7 @@ import { import { setButtonTooltip } from '../../shared/dom/tooltip'; import { createIconSvg, QODER_ICON,QODERIAN_ICON_ID } from '../../shared/icons'; import { openFeedbackModal } from '../feedback/ui/feedback-modal'; +import { QoderianSettingsModal } from '../settings/settings-modal'; import type { HistoryConversationStatus } from './controllers/conversation-controller'; import { sendTabInputMessageFromExplicitEnterShortcut, @@ -230,6 +231,11 @@ export class QoderianView extends ItemView { setIcon(feedbackBtn, 'message-circle-question'); setButtonTooltip(feedbackBtn, t('commands.submitFeedback')); feedbackBtn.addEventListener('click', () => this.openFeedback()); + + const settingsBtn = headerActions.createDiv({ cls: 'qoderian-header-btn' }); + setIcon(settingsBtn, 'settings'); + setButtonTooltip(settingsBtn, t('settings.title')); + settingsBtn.addEventListener('click', () => this.openSettings()); } /** @@ -307,6 +313,10 @@ export class QoderianView extends ItemView { }); } + private openSettings(): void { + new QoderianSettingsModal(this.plugin).open(); + } + private buildInputFooter(): void { if (!this.viewContainerEl) return; diff --git a/src/features/settings/settings-modal.ts b/src/features/settings/settings-modal.ts new file mode 100644 index 0000000..2a00787 --- /dev/null +++ b/src/features/settings/settings-modal.ts @@ -0,0 +1,31 @@ +import { Modal } from 'obsidian'; + +import { t } from '../../i18n/i18n'; +import type QoderianPlugin from '../../main'; +import { QoderianSettingTab } from './settings-tab'; + +/** Qoderian-owned settings window that does not navigate into Obsidian settings. */ +export class QoderianSettingsModal extends Modal { + private settingsTab: QoderianSettingTab | null = null; + + constructor(private readonly plugin: QoderianPlugin) { + super(plugin.app); + } + + onOpen(): void { + this.modalEl.addClass('qoderian-settings-modal'); + this.contentEl.addClass('qoderian-settings-modal-content'); + this.titleEl.setText(t('settings.title')); + + const settingsTab = new QoderianSettingTab(this.app, this.plugin); + settingsTab.containerEl = this.contentEl; + settingsTab.display(); + this.settingsTab = settingsTab; + } + + onClose(): void { + this.settingsTab?.hide(); + this.settingsTab = null; + this.contentEl.empty(); + } +} diff --git a/src/style/settings/base.css b/src/style/settings/base.css index dde82b8..039b2e5 100644 --- a/src/style/settings/base.css +++ b/src/style/settings/base.css @@ -1,4 +1,16 @@ /* Settings page - remove separator lines from setting items */ +.qoderian-settings-modal { + width: min(920px, calc(100vw - 48px)); + max-width: 920px; + height: min(86vh, 900px); +} + +.qoderian-settings-modal-content { + overflow-y: auto; + overscroll-behavior: contain; + padding-inline-end: 8px; +} + .qoderian-settings .setting-item { border-top: none; } diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index df7c678..e27e9b0 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -208,6 +208,12 @@ export class Modal { empty: jest.fn(), addClass: jest.fn(), }; + modalEl: any = { + addClass: jest.fn(), + }; + titleEl: any = { + setText: jest.fn(), + }; constructor(app: any) { this.app = app; diff --git a/tests/unit/features/settings/settings-modal.test.ts b/tests/unit/features/settings/settings-modal.test.ts new file mode 100644 index 0000000..04e9bda --- /dev/null +++ b/tests/unit/features/settings/settings-modal.test.ts @@ -0,0 +1,77 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { QoderianSettingsModal } from '@/features/settings/settings-modal'; + +let lastModalInstance: any; +const mockSettingsTab = { + containerEl: null as any, + display: jest.fn(), + hide: jest.fn(), +}; + +jest.mock('obsidian', () => { + const actual = jest.requireActual('obsidian'); + + class MockModal { + app: any; + modalEl: any = { addClass: jest.fn() }; + titleEl: any = { setText: jest.fn() }; + contentEl: any = createMockEl(); + + constructor(app: any) { + this.app = app; + // eslint-disable-next-line @typescript-eslint/no-this-alias + lastModalInstance = this; + } + + open() { + this.onOpen(); + } + + close() { + this.onClose(); + } + + onOpen() { + // Overridden by subclass + } + + onClose() { + // Overridden by subclass + } + } + + return { ...actual, Modal: MockModal }; +}); + +jest.mock('@/features/settings/settings-tab', () => ({ + QoderianSettingTab: jest.fn().mockImplementation(() => mockSettingsTab), +})); + +describe('QoderianSettingsModal', () => { + beforeEach(() => { + mockSettingsTab.containerEl = null; + mockSettingsTab.display.mockClear(); + mockSettingsTab.hide.mockClear(); + }); + + it('renders the settings tab inside the settings window', () => { + new QoderianSettingsModal({ app: {} } as any).open(); + + expect(lastModalInstance.modalEl.addClass).toHaveBeenCalledWith('qoderian-settings-modal'); + expect(lastModalInstance.titleEl.setText).toHaveBeenCalledWith('Qoderian Settings'); + expect(mockSettingsTab.containerEl).toBe(lastModalInstance.contentEl); + expect(mockSettingsTab.display).toHaveBeenCalledTimes(1); + }); + + it('detaches the settings tab when the window closes', () => { + const modal = new QoderianSettingsModal({ app: {} } as any); + modal.open(); + const emptySpy = jest.spyOn(modal.contentEl, 'empty'); + + modal.close(); + + expect(mockSettingsTab.hide).toHaveBeenCalledTimes(1); + expect(emptySpy).toHaveBeenCalledTimes(1); + }); +}); From ce8648148525828aece9c65c46ff0966dd809dc5 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Wed, 16 Sep 2026 09:04:11 +0800 Subject: [PATCH 2/2] fix(settings): render the settings window without the deprecated display() The header settings window called PluginSettingTab.display(), which is deprecated since Obsidian 1.13.0. Add a mount(container) entry point that shares the existing imperative renderer with the pre-1.13 display() fallback that Obsidian still owns. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/features/settings/settings-modal.ts | 3 +-- src/features/settings/settings-tab.ts | 10 ++++++++++ tests/unit/features/settings/settings-modal.test.ts | 9 +++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/features/settings/settings-modal.ts b/src/features/settings/settings-modal.ts index 2a00787..05ca5e6 100644 --- a/src/features/settings/settings-modal.ts +++ b/src/features/settings/settings-modal.ts @@ -18,8 +18,7 @@ export class QoderianSettingsModal extends Modal { this.titleEl.setText(t('settings.title')); const settingsTab = new QoderianSettingTab(this.app, this.plugin); - settingsTab.containerEl = this.contentEl; - settingsTab.display(); + settingsTab.mount(this.contentEl); this.settingsTab = settingsTab; } diff --git a/src/features/settings/settings-tab.ts b/src/features/settings/settings-tab.ts index f2afd65..b91770f 100644 --- a/src/features/settings/settings-tab.ts +++ b/src/features/settings/settings-tab.ts @@ -502,6 +502,16 @@ export class QoderianSettingTab extends PluginSettingTab { } } + /** + * Imperative renderer for hosts outside Obsidian's settings pane, such as + * the header settings window. Shares the same builders as the pre-1.13 + * display() fallback, which Obsidian owns for older versions. + */ + mount(containerEl: HTMLElement): void { + this.containerEl = containerEl; + this.renderLegacySettings(); + } + /** * Imperative entry point for Obsidian versions older than 1.13.0, which * never consult getSettingDefinitions(). Kept as the documented fallback diff --git a/tests/unit/features/settings/settings-modal.test.ts b/tests/unit/features/settings/settings-modal.test.ts index 04e9bda..94d9d3d 100644 --- a/tests/unit/features/settings/settings-modal.test.ts +++ b/tests/unit/features/settings/settings-modal.test.ts @@ -4,8 +4,7 @@ import { QoderianSettingsModal } from '@/features/settings/settings-modal'; let lastModalInstance: any; const mockSettingsTab = { - containerEl: null as any, - display: jest.fn(), + mount: jest.fn(), hide: jest.fn(), }; @@ -50,8 +49,7 @@ jest.mock('@/features/settings/settings-tab', () => ({ describe('QoderianSettingsModal', () => { beforeEach(() => { - mockSettingsTab.containerEl = null; - mockSettingsTab.display.mockClear(); + mockSettingsTab.mount.mockClear(); mockSettingsTab.hide.mockClear(); }); @@ -60,8 +58,7 @@ describe('QoderianSettingsModal', () => { expect(lastModalInstance.modalEl.addClass).toHaveBeenCalledWith('qoderian-settings-modal'); expect(lastModalInstance.titleEl.setText).toHaveBeenCalledWith('Qoderian Settings'); - expect(mockSettingsTab.containerEl).toBe(lastModalInstance.contentEl); - expect(mockSettingsTab.display).toHaveBeenCalledTimes(1); + expect(mockSettingsTab.mount).toHaveBeenCalledWith(lastModalInstance.contentEl); }); it('detaches the settings tab when the window closes', () => {