Skip to content

Commit c3165c5

Browse files
committed
feat(settings): add modal settings interface with vertical tabs
Introduce a new SettingsModal component that provides a full-featured settings interface using Obsidian's vertical tabs layout pattern. The modal includes categorized navigation, deep search functionality, and mobile-responsive design with back navigation. Key changes: - Add SettingsModal with searchable settings, category grouping, and smooth transitions between tabs - Refactor ProjectSettingsTab with comprehensive documentation and clearer section organization - Support boolean metadata values for project detection (uses filename as project name when `project: true`) - Integrate settings modal into FluentTopNavigation - Add corresponding SCSS styles for the modal layout
1 parent d03f938 commit c3165c5

14 files changed

Lines changed: 3399 additions & 1670 deletions

File tree

src/__tests__/ProjectConfigManager.test.ts

Lines changed: 85 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class MockTFile {
2121
constructor(
2222
public path: string,
2323
public name: string,
24-
public parent: MockTFolder | null = null
24+
public parent: MockTFolder | null = null,
2525
) {
2626
this.stat = { mtime: Date.now() };
2727
}
@@ -33,7 +33,7 @@ class MockTFolder {
3333
public path: string,
3434
public name: string,
3535
public parent: MockTFolder | null = null,
36-
public children: (MockTFile | MockTFolder)[] = []
36+
public children: (MockTFile | MockTFolder)[] = [],
3737
) {}
3838
}
3939

@@ -58,6 +58,10 @@ class MockVault {
5858
return this.files.get(path) || null;
5959
}
6060

61+
getFileByPath(path: string): MockTFile | null {
62+
return this.files.get(path) || null;
63+
}
64+
6165
async read(file: MockTFile): Promise<string> {
6266
return this.fileContents.get(file.path) || "";
6367
}
@@ -129,7 +133,7 @@ describe("ProjectConfigManager", () => {
129133
manager.updateOptions({ pathMappings });
130134

131135
const workProject = await manager.determineTgProject(
132-
"Projects/Work/task.md"
136+
"Projects/Work/task.md",
133137
);
134138
expect(workProject).toEqual({
135139
type: "path",
@@ -138,9 +142,8 @@ describe("ProjectConfigManager", () => {
138142
readonly: true,
139143
});
140144

141-
const personalProject = await manager.determineTgProject(
142-
"Personal/notes.md"
143-
);
145+
const personalProject =
146+
await manager.determineTgProject("Personal/notes.md");
144147
expect(personalProject).toEqual({
145148
type: "path",
146149
name: "Personal Project",
@@ -161,7 +164,7 @@ describe("ProjectConfigManager", () => {
161164
manager.updateOptions({ pathMappings });
162165

163166
const project = await manager.determineTgProject(
164-
"Projects/Work/task.md"
167+
"Projects/Work/task.md",
165168
);
166169
expect(project).toBeUndefined();
167170
});
@@ -178,7 +181,7 @@ describe("ProjectConfigManager", () => {
178181
manager.updateOptions({ pathMappings });
179182

180183
const project = await manager.determineTgProject(
181-
"Projects/SomeProject/task.md"
184+
"Projects/SomeProject/task.md",
182185
);
183186
expect(project).toEqual({
184187
type: "path",
@@ -223,6 +226,41 @@ describe("ProjectConfigManager", () => {
223226
const project = await manager.determineTgProject("nonexistent.md");
224227
expect(project).toBeUndefined();
225228
});
229+
230+
it("should use filename as project name when project: true (boolean)", async () => {
231+
vault.addFile("Projects/MyAwesomeProject.md", "# My Project");
232+
metadataCache.setFileMetadata("Projects/MyAwesomeProject.md", {
233+
project: true,
234+
});
235+
236+
const project = await manager.determineTgProject(
237+
"Projects/MyAwesomeProject.md",
238+
);
239+
expect(project).toEqual({
240+
type: "metadata",
241+
name: "MyAwesomeProject",
242+
source: "project (filename)",
243+
readonly: true,
244+
});
245+
});
246+
247+
it("should use filename when custom metadata key has boolean true", async () => {
248+
manager.updateOptions({ metadataKey: "isProject" });
249+
vault.addFile("Tasks/ImportantTask.md", "# Task");
250+
metadataCache.setFileMetadata("Tasks/ImportantTask.md", {
251+
isProject: true,
252+
});
253+
254+
const project = await manager.determineTgProject(
255+
"Tasks/ImportantTask.md",
256+
);
257+
expect(project).toEqual({
258+
type: "metadata",
259+
name: "ImportantTask",
260+
source: "isProject (filename)",
261+
readonly: true,
262+
});
263+
});
226264
});
227265

228266
describe("Config file-based project detection", () => {
@@ -235,14 +273,14 @@ project: Config Project
235273
---
236274
237275
# Project Configuration
238-
`
276+
`,
239277
);
240278

241279
// Mock the folder structure
242280
const file = vault.addFile("Projects/task.md", "- [ ] Test task");
243281
const folder = vault.addFolder("Projects");
244282
const configFile = vault.getAbstractFileByPath(
245-
"Projects/project.md"
283+
"Projects/project.md",
246284
);
247285
if (configFile) {
248286
folder.children.push(configFile);
@@ -254,9 +292,8 @@ project: Config Project
254292
project: "Config Project",
255293
});
256294

257-
const project = await manager.determineTgProject(
258-
"Projects/task.md"
259-
);
295+
const project =
296+
await manager.determineTgProject("Projects/task.md");
260297
expect(project).toEqual({
261298
type: "config",
262299
name: "Config Project",
@@ -278,16 +315,15 @@ description: A project defined in content
278315
const file = vault.addFile("Projects/task.md", "- [ ] Test task");
279316
const folder = vault.addFolder("Projects");
280317
const configFile = vault.getAbstractFileByPath(
281-
"Projects/project.md"
318+
"Projects/project.md",
282319
);
283320
if (configFile) {
284321
folder.children.push(configFile);
285322
file.parent = folder;
286323
}
287324

288-
const project = await manager.determineTgProject(
289-
"Projects/task.md"
290-
);
325+
const project =
326+
await manager.determineTgProject("Projects/task.md");
291327
expect(project).toEqual({
292328
type: "config",
293329
name: "Content Project",
@@ -321,9 +357,8 @@ description: A project defined in content
321357
other: "value",
322358
});
323359

324-
const enhancedMetadata = await manager.getEnhancedMetadata(
325-
"test.md"
326-
);
360+
const enhancedMetadata =
361+
await manager.getEnhancedMetadata("test.md");
327362
expect(enhancedMetadata).toEqual({
328363
proj: "Mapped Project",
329364
due_date: "2024-01-01",
@@ -349,9 +384,8 @@ description: A project defined in content
349384
proj: "Should Not Map",
350385
});
351386

352-
const enhancedMetadata = await manager.getEnhancedMetadata(
353-
"test.md"
354-
);
387+
const enhancedMetadata =
388+
await manager.getEnhancedMetadata("test.md");
355389
expect(enhancedMetadata).toEqual({
356390
proj: "Should Not Map",
357391
});
@@ -370,7 +404,7 @@ description: A project defined in content
370404
manager.updateOptions({ defaultProjectNaming });
371405

372406
const project = await manager.determineTgProject(
373-
"Projects/my-document.md"
407+
"Projects/my-document.md",
374408
);
375409
expect(project).toEqual({
376410
type: "default",
@@ -390,7 +424,7 @@ description: A project defined in content
390424
manager.updateOptions({ defaultProjectNaming });
391425

392426
const project = await manager.determineTgProject(
393-
"Projects/my-document.md"
427+
"Projects/my-document.md",
394428
);
395429
expect(project).toEqual({
396430
type: "default",
@@ -409,7 +443,7 @@ description: A project defined in content
409443
manager.updateOptions({ defaultProjectNaming });
410444

411445
const project = await manager.determineTgProject(
412-
"Projects/WorkFolder/task.md"
446+
"Projects/WorkFolder/task.md",
413447
);
414448
expect(project).toEqual({
415449
type: "default",
@@ -433,9 +467,8 @@ description: A project defined in content
433467

434468
manager.updateOptions({ defaultProjectNaming });
435469

436-
const project = await manager.determineTgProject(
437-
"anywhere/task.md"
438-
);
470+
const project =
471+
await manager.determineTgProject("anywhere/task.md");
439472
expect(project).toEqual({
440473
type: "default",
441474
name: "Global Project",
@@ -454,7 +487,7 @@ description: A project defined in content
454487
manager.updateOptions({ defaultProjectNaming });
455488

456489
const project = await manager.determineTgProject(
457-
"Projects/my-document.md"
490+
"Projects/my-document.md",
458491
);
459492
expect(project).toBeUndefined();
460493
});
@@ -477,9 +510,8 @@ description: A project defined in content
477510
project: "Metadata Project",
478511
});
479512

480-
const project = await manager.determineTgProject(
481-
"Projects/task.md"
482-
);
513+
const project =
514+
await manager.determineTgProject("Projects/task.md");
483515
expect(project).toEqual({
484516
type: "path",
485517
name: "Path Project",
@@ -499,16 +531,15 @@ description: A project defined in content
499531
const file = vault.getAbstractFileByPath("Projects/task.md");
500532
const folder = vault.addFolder("Projects");
501533
const configFile = vault.getAbstractFileByPath(
502-
"Projects/project.md"
534+
"Projects/project.md",
503535
);
504536
if (file && configFile) {
505537
folder.children.push(configFile);
506538
file.parent = folder;
507539
}
508540

509-
const project = await manager.determineTgProject(
510-
"Projects/task.md"
511-
);
541+
const project =
542+
await manager.determineTgProject("Projects/task.md");
512543
expect(project).toEqual({
513544
type: "metadata",
514545
name: "Metadata Project",
@@ -533,16 +564,15 @@ description: A project defined in content
533564
const file = vault.getAbstractFileByPath("Projects/task.md");
534565
const folder = vault.addFolder("Projects");
535566
const configFile = vault.getAbstractFileByPath(
536-
"Projects/project.md"
567+
"Projects/project.md",
537568
);
538569
if (file && configFile) {
539570
folder.children.push(configFile);
540571
file.parent = folder;
541572
}
542573

543-
const project = await manager.determineTgProject(
544-
"Projects/task.md"
545-
);
574+
const project =
575+
await manager.determineTgProject("Projects/task.md");
546576
expect(project).toEqual({
547577
type: "config",
548578
name: "Config Project",
@@ -561,22 +591,20 @@ description: A project defined in content
561591
const file = vault.getAbstractFileByPath("Projects/task.md");
562592
const folder = vault.addFolder("Projects");
563593
const configFile = vault.getAbstractFileByPath(
564-
"Projects/project.md"
594+
"Projects/project.md",
565595
);
566596
if (file && configFile) {
567597
folder.children.push(configFile);
568598
file.parent = folder;
569599
}
570600

571601
// First call should read and cache
572-
const project1 = await manager.determineTgProject(
573-
"Projects/task.md"
574-
);
602+
const project1 =
603+
await manager.determineTgProject("Projects/task.md");
575604

576605
// Second call should use cache
577-
const project2 = await manager.determineTgProject(
578-
"Projects/task.md"
579-
);
606+
const project2 =
607+
await manager.determineTgProject("Projects/task.md");
580608

581609
expect(project1).toEqual(project2);
582610
expect(project1?.name).toBe("Cached Project");
@@ -621,24 +649,23 @@ description: A project defined in content
621649
it("should handle malformed config files gracefully", async () => {
622650
vault.addFile(
623651
"Projects/project.md",
624-
"Invalid content without proper format"
652+
"Invalid content without proper format",
625653
);
626654
vault.addFile("Projects/task.md", "# Test file");
627655

628656
// Mock folder structure
629657
const file = vault.getAbstractFileByPath("Projects/task.md");
630658
const folder = vault.addFolder("Projects");
631659
const configFile = vault.getAbstractFileByPath(
632-
"Projects/project.md"
660+
"Projects/project.md",
633661
);
634662
if (file && configFile) {
635663
folder.children.push(configFile);
636664
file.parent = folder;
637665
}
638666

639-
const project = await manager.determineTgProject(
640-
"Projects/task.md"
641-
);
667+
const project =
668+
await manager.determineTgProject("Projects/task.md");
642669
expect(project).toBeUndefined();
643670
});
644671
});
@@ -659,14 +686,14 @@ description: A project defined in content
659686

660687
// All methods should return null/empty when disabled
661688
expect(
662-
await disabledManager.getProjectConfig("test.md")
689+
await disabledManager.getProjectConfig("test.md"),
663690
).toBeNull();
664691
expect(disabledManager.getFileMetadata("test.md")).toBeNull();
665692
expect(
666-
await disabledManager.determineTgProject("test.md")
693+
await disabledManager.determineTgProject("test.md"),
667694
).toBeUndefined();
668695
expect(
669-
await disabledManager.getEnhancedMetadata("test.md")
696+
await disabledManager.getEnhancedMetadata("test.md"),
670697
).toEqual({});
671698
expect(disabledManager.isEnhancedProjectEnabled()).toBe(false);
672699
});
@@ -727,12 +754,12 @@ description: A project defined in content
727754
// All metadata-related methods should return null/empty when disabled
728755
expect(disabledManager.getFileMetadata("test.md")).toBeNull();
729756
expect(
730-
await disabledManager.getEnhancedMetadata("test.md")
757+
await disabledManager.getEnhancedMetadata("test.md"),
731758
).toEqual({});
732759

733760
// Even if frontmatter exists, it should not be accessible through disabled manager
734761
expect(
735-
await disabledManager.determineTgProject("test.md")
762+
await disabledManager.determineTgProject("test.md"),
736763
).toBeUndefined();
737764
});
738765
});

src/components/features/fluent/components/FluentTopNavigation.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import TaskProgressBarPlugin from "@/index";
1212
import { Task } from "@/types/task";
1313
import { t } from "@/translations/helper";
1414
import { Events, on } from "@/dataflow/events/Events";
15+
import { SettingsModal } from "@/components/features/settings/SettingsModal";
1516

1617
export type ViewMode = "list" | "kanban" | "tree" | "calendar";
1718

@@ -250,9 +251,10 @@ export class TopNavigation extends Component {
250251
cls: "fluent-nav-icon-button",
251252
});
252253
setIcon(settingsBtn, "settings");
253-
this.registerDomEvent(settingsBtn, "click", () =>
254-
this.onSettingsClick(),
255-
);
254+
this.registerDomEvent(settingsBtn, "click", () => {
255+
const modal = new SettingsModal(this.plugin.app, this.plugin);
256+
modal.open();
257+
});
256258
}
257259

258260
private createViewTab(

0 commit comments

Comments
 (0)