Skip to content

Commit 88623f5

Browse files
luoxuanzaoQoder-AI
andauthored
feat(chat): open the settings window from the view header (#65)
Add a gear button beside the feedback button in the view header that opens the Qoderian settings page in its own window, so reaching a setting no longer means diving into Obsidian's settings pane and the sidebar/chat stay in place. The window renders through a new mount(containerEl) entry point sharing the existing imperative builders with the pre-1.13 display() fallback, so the PluginSettingTab.display() call deprecated in Obsidian 1.13.0 is gone while the declarative getSettingDefinitions() path for 1.13+ stays intact. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 80c1a12 commit 88623f5

7 files changed

Lines changed: 144 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1313

1414
### Added
1515

16+
- A settings button in the view header opens Qoderian's settings window in
17+
place, instead of navigating into Obsidian's settings.
1618
- Show the added external context path after dropping a folder or file, surface
1719
rejected additions, and show the context count even for a single path.
1820
- Drag files and folders from your operating system (e.g. macOS Finder) onto

‎src/features/chat/chat-view.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { setButtonTooltip } from '../../shared/dom/tooltip';
1515
import { createIconSvg, QODER_ICON,QODERIAN_ICON_ID } from '../../shared/icons';
1616
import { openFeedbackModal } from '../feedback/ui/feedback-modal';
17+
import { QoderianSettingsModal } from '../settings/settings-modal';
1718
import type { HistoryConversationStatus } from './controllers/conversation-controller';
1819
import {
1920
sendTabInputMessageFromExplicitEnterShortcut,
@@ -230,6 +231,11 @@ export class QoderianView extends ItemView {
230231
setIcon(feedbackBtn, 'message-circle-question');
231232
setButtonTooltip(feedbackBtn, t('commands.submitFeedback'));
232233
feedbackBtn.addEventListener('click', () => this.openFeedback());
234+
235+
const settingsBtn = headerActions.createDiv({ cls: 'qoderian-header-btn' });
236+
setIcon(settingsBtn, 'settings');
237+
setButtonTooltip(settingsBtn, t('settings.title'));
238+
settingsBtn.addEventListener('click', () => this.openSettings());
233239
}
234240

235241
/**
@@ -307,6 +313,10 @@ export class QoderianView extends ItemView {
307313
});
308314
}
309315

316+
private openSettings(): void {
317+
new QoderianSettingsModal(this.plugin).open();
318+
}
319+
310320
private buildInputFooter(): void {
311321
if (!this.viewContainerEl) return;
312322

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Modal } from 'obsidian';
2+
3+
import { t } from '../../i18n/i18n';
4+
import type QoderianPlugin from '../../main';
5+
import { QoderianSettingTab } from './settings-tab';
6+
7+
/** Qoderian-owned settings window that does not navigate into Obsidian settings. */
8+
export class QoderianSettingsModal extends Modal {
9+
private settingsTab: QoderianSettingTab | null = null;
10+
11+
constructor(private readonly plugin: QoderianPlugin) {
12+
super(plugin.app);
13+
}
14+
15+
onOpen(): void {
16+
this.modalEl.addClass('qoderian-settings-modal');
17+
this.contentEl.addClass('qoderian-settings-modal-content');
18+
this.titleEl.setText(t('settings.title'));
19+
20+
const settingsTab = new QoderianSettingTab(this.app, this.plugin);
21+
settingsTab.mount(this.contentEl);
22+
this.settingsTab = settingsTab;
23+
}
24+
25+
onClose(): void {
26+
this.settingsTab?.hide();
27+
this.settingsTab = null;
28+
this.contentEl.empty();
29+
}
30+
}

‎src/features/settings/settings-tab.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,16 @@ export class QoderianSettingTab extends PluginSettingTab {
502502
}
503503
}
504504

505+
/**
506+
* Imperative renderer for hosts outside Obsidian's settings pane, such as
507+
* the header settings window. Shares the same builders as the pre-1.13
508+
* display() fallback, which Obsidian owns for older versions.
509+
*/
510+
mount(containerEl: HTMLElement): void {
511+
this.containerEl = containerEl;
512+
this.renderLegacySettings();
513+
}
514+
505515
/**
506516
* Imperative entry point for Obsidian versions older than 1.13.0, which
507517
* never consult getSettingDefinitions(). Kept as the documented fallback

‎src/style/settings/base.css‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
/* Settings page - remove separator lines from setting items */
2+
.qoderian-settings-modal {
3+
width: min(920px, calc(100vw - 48px));
4+
max-width: 920px;
5+
height: min(86vh, 900px);
6+
}
7+
8+
.qoderian-settings-modal-content {
9+
overflow-y: auto;
10+
overscroll-behavior: contain;
11+
padding-inline-end: 8px;
12+
}
13+
214
.qoderian-settings .setting-item {
315
border-top: none;
416
}

‎tests/__mocks__/obsidian.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ export class Modal {
208208
empty: jest.fn(),
209209
addClass: jest.fn(),
210210
};
211+
modalEl: any = {
212+
addClass: jest.fn(),
213+
};
214+
titleEl: any = {
215+
setText: jest.fn(),
216+
};
211217

212218
constructor(app: any) {
213219
this.app = app;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { createMockEl } from '@test/helpers/mock-element';
2+
3+
import { QoderianSettingsModal } from '@/features/settings/settings-modal';
4+
5+
let lastModalInstance: any;
6+
const mockSettingsTab = {
7+
mount: jest.fn(),
8+
hide: jest.fn(),
9+
};
10+
11+
jest.mock('obsidian', () => {
12+
const actual = jest.requireActual('obsidian');
13+
14+
class MockModal {
15+
app: any;
16+
modalEl: any = { addClass: jest.fn() };
17+
titleEl: any = { setText: jest.fn() };
18+
contentEl: any = createMockEl();
19+
20+
constructor(app: any) {
21+
this.app = app;
22+
// eslint-disable-next-line @typescript-eslint/no-this-alias
23+
lastModalInstance = this;
24+
}
25+
26+
open() {
27+
this.onOpen();
28+
}
29+
30+
close() {
31+
this.onClose();
32+
}
33+
34+
onOpen() {
35+
// Overridden by subclass
36+
}
37+
38+
onClose() {
39+
// Overridden by subclass
40+
}
41+
}
42+
43+
return { ...actual, Modal: MockModal };
44+
});
45+
46+
jest.mock('@/features/settings/settings-tab', () => ({
47+
QoderianSettingTab: jest.fn().mockImplementation(() => mockSettingsTab),
48+
}));
49+
50+
describe('QoderianSettingsModal', () => {
51+
beforeEach(() => {
52+
mockSettingsTab.mount.mockClear();
53+
mockSettingsTab.hide.mockClear();
54+
});
55+
56+
it('renders the settings tab inside the settings window', () => {
57+
new QoderianSettingsModal({ app: {} } as any).open();
58+
59+
expect(lastModalInstance.modalEl.addClass).toHaveBeenCalledWith('qoderian-settings-modal');
60+
expect(lastModalInstance.titleEl.setText).toHaveBeenCalledWith('Qoderian Settings');
61+
expect(mockSettingsTab.mount).toHaveBeenCalledWith(lastModalInstance.contentEl);
62+
});
63+
64+
it('detaches the settings tab when the window closes', () => {
65+
const modal = new QoderianSettingsModal({ app: {} } as any);
66+
modal.open();
67+
const emptySpy = jest.spyOn(modal.contentEl, 'empty');
68+
69+
modal.close();
70+
71+
expect(mockSettingsTab.hide).toHaveBeenCalledTimes(1);
72+
expect(emptySpy).toHaveBeenCalledTimes(1);
73+
});
74+
});

0 commit comments

Comments
 (0)