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..05ca5e6 --- /dev/null +++ b/src/features/settings/settings-modal.ts @@ -0,0 +1,30 @@ +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.mount(this.contentEl); + this.settingsTab = settingsTab; + } + + onClose(): void { + this.settingsTab?.hide(); + this.settingsTab = null; + this.contentEl.empty(); + } +} 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/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..94d9d3d --- /dev/null +++ b/tests/unit/features/settings/settings-modal.test.ts @@ -0,0 +1,74 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { QoderianSettingsModal } from '@/features/settings/settings-modal'; + +let lastModalInstance: any; +const mockSettingsTab = { + mount: 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.mount.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.mount).toHaveBeenCalledWith(lastModalInstance.contentEl); + }); + + 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); + }); +});