diff --git a/README.md b/README.md
index 86abcd1..5b2bea5 100644
--- a/README.md
+++ b/README.md
@@ -9,19 +9,13 @@ Find what you're looking for faster, using Quick Switcher++. An [Obsidian.md](ht
## Features
* [Find files by headings instead of filename](#search-headings-instead-of-filename)
- * [Configuration](#search-headings-configuration)
* [Navigate to symbols (Canvas Nodes, Headings, Hashtags, Links, Embeddings) in your notes](#symbol-navigation-canvas-nodes-headings-hashtags-links-embeddings)
- * [Configuration](#symbol-navigation-configuration)
* [Navigate between open editors, and side panels](#navigate-between-open-editors)
- * [Configuration](#editor-navigation-configuration)
* [Switch between configured Workspaces quickly](#switch-workspaces)
- * [Configuration](#workspace-configuration)
* [Navigate between your Starred notes](#navigate-starred-notes)
- * [Configuration](#starred-configuration)
-* [Run commands](#run-commands)
- * [Configuration](#command-configuration)
-* [Navigate to related items](#related-items)
- * [Configuration](#related-items-configuration)
+* [Run Obsidian commands](#run-commands)
+* [Navigate to related files](#related-items)
+* [Quick Filters to narrow down your search results](#quick-filters)
* [General Settings](#general-settings)
* [Global Commands for Hotkeys/Shortcuts](#global-commands-for-hotkeys)
@@ -183,6 +177,15 @@ When the Related Items command is triggered for a selected input suggestion/file
| Show related item types | Specify which relation types are enabled to be displayed in the result list. | `backlink`
`disk-location`
`outgoing-link` |
| Exclude open files | **Enabled**: related files which are already open in an editor will not be displayed in the list.
**Disabled**: All related files will be displayed in the list. | disabled |
+## Quick Filters
+
+Quick Filters enable you to quickly narrow down the types of items that appear in your search results without having to change your query. Each type of results will have a hotkey assigned that can be used to toggle (show/hide) that type from the result list. When active, only results that match the Quick Filter type will be displayed, multiple Quick Filters can be active at the same time.
+
+In the demo below, `Quick Switcher++: Open Symbols for the active editor` global command is triggered for the active file. Notice towards the bottom of the Switcher the hotkeys assigned to each result type. The `headings` Quick Filter is triggered using the `Ctrl+Alt+1` hotkey, this restricts the result list to only display Heading results. Multiple Quick Filters are activated using their corresponding hotkeys, and all Quick Filters can be quickly toggle off using `Ctrl+Alt+0`.
+
+
+
+
## General Settings
| Setting | Description | Default |
diff --git a/demo/quick-filters.gif b/demo/quick-filters.gif
new file mode 100644
index 0000000..ba1c16f
Binary files /dev/null and b/demo/quick-filters.gif differ
diff --git a/src/Handlers/__tests__/handler.test.ts b/src/Handlers/__tests__/handler.test.ts
index 8107b9d..07f1e7b 100644
--- a/src/Handlers/__tests__/handler.test.ts
+++ b/src/Handlers/__tests__/handler.test.ts
@@ -1,3 +1,4 @@
+import { Facet, FacetSettingsData } from './../../types/sharedTypes';
import {
App,
Editor,
@@ -2071,7 +2072,7 @@ describe('Handler', () => {
it('should log any errors to the console while trying to create a new file', async () => {
const filename = chance.word();
- const errorMsg = 'Unit test error';
+ const errorMsg = 'createFile Unit test error';
const rejectedPromise = Promise.reject(errorMsg);
const consoleLogSpy = jest.spyOn(console, 'log').mockReturnValueOnce();
@@ -2101,4 +2102,19 @@ describe('Handler', () => {
consoleLogSpy.mockRestore();
});
});
+
+ describe('activateFacet', () => {
+ test('withshouldResetActiveFacets disabled, it should save changes to active facet status', () => {
+ const finalValue = true;
+ const mockFacet = mock({ isActive: false });
+ mockSettings.quickFilters = mock({
+ shouldResetActiveFacets: false,
+ });
+
+ sut.activateFacet([mockFacet], finalValue);
+
+ expect(mockFacet.isActive).toBe(finalValue);
+ expect(mockSettings.save).toHaveBeenCalledWith();
+ });
+ });
});
diff --git a/src/Handlers/__tests__/symbolHandler.test.ts b/src/Handlers/__tests__/symbolHandler.test.ts
index 4bf2f8a..e18a0ec 100644
--- a/src/Handlers/__tests__/symbolHandler.test.ts
+++ b/src/Handlers/__tests__/symbolHandler.test.ts
@@ -973,7 +973,12 @@ describe('symbolHandler', () => {
const results: SymbolInfo[] = [];
mockVault.cachedRead.mockResolvedValueOnce(fileContentWithCallout);
- await sut.addCalloutsFromSource(mockFile, calloutSectionCache, results);
+ await sut.addCalloutsFromSource(
+ mockFile,
+ calloutSectionCache,
+ results,
+ new Set(),
+ );
expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile);
expect(results).toHaveLength(calloutSectionCache.length);
@@ -986,7 +991,12 @@ describe('symbolHandler', () => {
mockVault.cachedRead.mockRejectedValueOnce(errorMsg);
- await sut.addCalloutsFromSource(mockFile, calloutSectionCache, []);
+ await sut.addCalloutsFromSource(
+ mockFile,
+ calloutSectionCache,
+ [],
+ new Set(),
+ );
expect(consoleLogSpy).toHaveBeenCalledWith(expectedMsg, errorMsg);
expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile);
@@ -1004,7 +1014,7 @@ describe('symbolHandler', () => {
const canvasNodes = (JSON.parse(fileContent) as CanvasData).nodes;
mockVault.cachedRead.mockResolvedValueOnce(fileContent);
- await sut.addCanvasSymbolsFromSource(mockFile, results);
+ await sut.addCanvasSymbolsFromSource(mockFile, results, new Set());
expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile);
expect(results).toHaveLength(canvasNodes.length);
@@ -1017,7 +1027,7 @@ describe('symbolHandler', () => {
mockVault.cachedRead.mockRejectedValueOnce(errorMsg);
- await sut.addCanvasSymbolsFromSource(mockFile, []);
+ await sut.addCanvasSymbolsFromSource(mockFile, [], new Set());
expect(consoleLogSpy).toHaveBeenCalledWith(expectedMsg, errorMsg);
expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile);
diff --git a/src/Handlers/handler.ts b/src/Handlers/handler.ts
index 2b6599c..a869d76 100644
--- a/src/Handlers/handler.ts
+++ b/src/Handlers/handler.ts
@@ -24,6 +24,7 @@ import {
} from 'obsidian';
import {
AnySuggestion,
+ Facet,
MatchType,
Mode,
PathDisplayFormat,
@@ -46,6 +47,7 @@ import {
} from 'src/utils';
export abstract class Handler {
+ facets: Facet[];
get commandString(): string {
return null;
}
@@ -68,6 +70,38 @@ export abstract class Handler {
/* noop */
}
+ getFacets(mode: Mode): Facet[] {
+ if (!this.facets) {
+ this.facets = this.settings.quickFilters.facetList?.filter((v) => v.mode === mode);
+ }
+
+ return this.facets ?? [];
+ }
+ getAvailableFacets(inputInfo: InputInfo): Facet[] {
+ return this.getFacets(inputInfo.mode).filter((v) => v.isAvailable);
+ }
+
+ activateFacet(facets: Facet[], isActive: boolean): void {
+ facets.forEach((v) => (v.isActive = isActive));
+
+ if (!this.settings.quickFilters.shouldResetActiveFacets) {
+ this.settings.save();
+ }
+ }
+
+ getActiveFacetIds(inputInfo: InputInfo): Set {
+ const facetIds = this.getAvailableFacets(inputInfo)
+ .filter((v) => v.isActive)
+ .map((v) => v.id);
+
+ return new Set(facetIds);
+ }
+
+ isFacetedWith(activeFacetIds: Set, facetId: string): boolean {
+ const hasActiveFacets = !!activeFacetIds.size;
+ return (hasActiveFacets && activeFacetIds.has(facetId)) || !hasActiveFacets;
+ }
+
getEditorInfo(leaf: WorkspaceLeaf): SourceInfo {
const { excludeViewTypes } = this.settings;
let file: TFile = null;
diff --git a/src/Handlers/relatedItemsHandler.ts b/src/Handlers/relatedItemsHandler.ts
index 8c4fa7e..07a1afb 100644
--- a/src/Handlers/relatedItemsHandler.ts
+++ b/src/Handlers/relatedItemsHandler.ts
@@ -63,7 +63,7 @@ export class RelatedItemsHandler extends Handler<
const { hasSearchTerm } = inputInfo.searchQuery;
const cmd = inputInfo.parsedCommand(Mode.RelatedItemsList) as SourcedParsedCommand;
- const items = this.getItems(cmd.source);
+ const items = this.getItems(cmd.source, inputInfo);
items.forEach((item) => {
const sugg = this.searchAndCreateSuggestion(inputInfo, item);
@@ -169,29 +169,39 @@ export class RelatedItemsHandler extends Handler<
: this.createSuggestion(currentWorkspaceEnvList, item, result);
}
- getItems(sourceInfo: SourceInfo): RelatedItemsInfo[] {
+ getItems(sourceInfo: SourceInfo, inputInfo: InputInfo): RelatedItemsInfo[] {
const relatedItems: RelatedItemsInfo[] = [];
const { metadataCache } = this.app;
- const { enabledRelatedItems } = this.settings;
const { file, suggestion } = sourceInfo;
+ const enabledRelatedItems = new Set(this.settings.enabledRelatedItems);
+ const activeFacetIds = this.getActiveFacetIds(inputInfo);
- enabledRelatedItems.forEach((relationType) => {
- if (relationType === RelationType.Backlink) {
- let targetPath = file?.path;
- let linkMap = metadataCache.resolvedLinks;
+ const shouldIncludeRelation = (relationType: RelationType) => {
+ return (
+ enabledRelatedItems.has(relationType) &&
+ this.isFacetedWith(activeFacetIds, relationType)
+ );
+ };
- if (isUnresolvedSuggestion(suggestion)) {
- targetPath = suggestion.linktext;
- linkMap = metadataCache.unresolvedLinks;
- }
+ if (shouldIncludeRelation(RelationType.Backlink)) {
+ let targetPath = file?.path;
+ let linkMap = metadataCache.resolvedLinks;
- this.addBacklinks(targetPath, linkMap, relatedItems);
- } else if (relationType === RelationType.DiskLocation) {
- this.addRelatedDiskFiles(file, relatedItems);
- } else {
- this.addOutgoingLinks(file, relatedItems);
+ if (isUnresolvedSuggestion(suggestion)) {
+ targetPath = suggestion.linktext;
+ linkMap = metadataCache.unresolvedLinks;
}
- });
+
+ this.addBacklinks(targetPath, linkMap, relatedItems);
+ }
+
+ if (shouldIncludeRelation(RelationType.DiskLocation)) {
+ this.addRelatedDiskFiles(file, relatedItems);
+ }
+
+ if (shouldIncludeRelation(RelationType.OutgoingLink)) {
+ this.addOutgoingLinks(file, relatedItems);
+ }
return relatedItems;
}
diff --git a/src/Handlers/symbolHandler.ts b/src/Handlers/symbolHandler.ts
index 328f20e..612b5f9 100644
--- a/src/Handlers/symbolHandler.ts
+++ b/src/Handlers/symbolHandler.ts
@@ -33,10 +33,12 @@ import {
SymbolIndicators,
SuggestionType,
CalloutCache,
+ Facet,
} from 'src/types';
import { getLinkType, isCalloutCache, isHeadingCache, isTagCache } from 'src/utils';
import { InputInfo, SourcedParsedCommand } from 'src/switcherPlus';
import { Handler } from './handler';
+import { CANVAS_NODE_FACET_ID_MAP } from 'src/settings';
export type SymbolInfoExcludingCanvasNodes = Omit & {
symbol: Exclude;
@@ -50,7 +52,7 @@ const CANVAS_ICON_MAP: Record = {
};
export class SymbolHandler extends Handler {
- private inputInfo: InputInfo;
+ inputInfo: InputInfo;
override get commandString(): string {
return this.settings?.symbolListCommand;
@@ -177,6 +179,23 @@ export class SymbolHandler extends Handler {
this.inputInfo = null;
}
+ override getAvailableFacets(inputInfo: InputInfo): Facet[] {
+ const cmd = inputInfo.parsedCommand(Mode.SymbolList) as SourcedParsedCommand;
+ const isCanvasFile = SymbolHandler.isCanvasFile(cmd?.source?.file);
+ const facets = this.getFacets(inputInfo.mode);
+ const canvasFacetIds = new Set(Object.values(CANVAS_NODE_FACET_ID_MAP));
+
+ // get only the string values of SymbolType as they are used as the face ids
+ const mdFacetIds = new Set(Object.values(SymbolType).filter((v) => isNaN(Number(v))));
+
+ facets.forEach((facet) => {
+ const { id } = facet;
+ facet.isAvailable = isCanvasFile ? canvasFacetIds.has(id) : mdFacetIds.has(id);
+ });
+
+ return facets.filter((v) => v.isAvailable);
+ }
+
zoomToCanvasNode(view: View, nodeData: CanvasNodeData): void {
if (SymbolHandler.isCanvasView(view)) {
const canvas = view.canvas;
@@ -302,21 +321,22 @@ export class SymbolHandler extends Handler {
): Promise {
const {
app: { metadataCache },
- settings,
+ inputInfo,
} = this;
const ret: SymbolInfo[] = [];
if (sourceInfo?.file) {
const { file } = sourceInfo;
+ const activeFacetIds = this.getActiveFacetIds(inputInfo);
if (SymbolHandler.isCanvasFile(file)) {
- await this.addCanvasSymbolsFromSource(file, ret);
+ await this.addCanvasSymbolsFromSource(file, ret, activeFacetIds);
} else {
const symbolData = metadataCache.getFileCache(file);
if (symbolData) {
const push = (symbols: AnySymbolInfoPayload[] = [], symbolType: SymbolType) => {
- if (settings.isSymbolTypeEnabled(symbolType)) {
+ if (this.shouldIncludeSymbol(symbolType, activeFacetIds)) {
symbols.forEach((symbol) =>
ret.push({ type: 'symbolInfo', symbol, symbolType }),
);
@@ -325,13 +345,14 @@ export class SymbolHandler extends Handler {
push(symbolData.headings, SymbolType.Heading);
push(symbolData.tags, SymbolType.Tag);
- this.addLinksFromSource(symbolData.links, ret);
+ this.addLinksFromSource(symbolData.links, ret, activeFacetIds);
push(symbolData.embeds, SymbolType.Embed);
await this.addCalloutsFromSource(
file,
symbolData.sections?.filter((v) => v.type === 'callout'),
ret,
+ activeFacetIds,
);
if (orderByLineNumber) {
@@ -346,7 +367,28 @@ export class SymbolHandler extends Handler {
return ret;
}
- async addCanvasSymbolsFromSource(file: TFile, symbolList: SymbolInfo[]): Promise {
+ shouldIncludeSymbol(
+ symbolType: SymbolType | string,
+ activeFacetIds: Set,
+ ): boolean {
+ let shouldInclude = false;
+
+ if (typeof symbolType === 'string') {
+ shouldInclude = this.isFacetedWith(activeFacetIds, symbolType);
+ } else {
+ shouldInclude =
+ this.settings.isSymbolTypeEnabled(symbolType) &&
+ this.isFacetedWith(activeFacetIds, SymbolType[symbolType]);
+ }
+
+ return shouldInclude;
+ }
+
+ async addCanvasSymbolsFromSource(
+ file: TFile,
+ symbolList: SymbolInfo[],
+ activeFacetIds: Set,
+ ): Promise {
let canvasNodes: AllCanvasNodeData[];
try {
@@ -361,11 +403,15 @@ export class SymbolHandler extends Handler {
if (Array.isArray(canvasNodes)) {
canvasNodes.forEach((node) => {
- symbolList.push({
- type: 'symbolInfo',
- symbolType: SymbolType.CanvasNode,
- symbol: { ...node },
- });
+ if (
+ this.shouldIncludeSymbol(CANVAS_NODE_FACET_ID_MAP[node.type], activeFacetIds)
+ ) {
+ symbolList.push({
+ type: 'symbolInfo',
+ symbolType: SymbolType.CanvasNode,
+ symbol: { ...node },
+ });
+ }
});
}
}
@@ -374,15 +420,15 @@ export class SymbolHandler extends Handler {
file: TFile,
sectionCache: SectionCache[],
symbolList: SymbolInfo[],
+ activeFacetIds: Set,
): Promise {
const {
app: { vault },
- settings,
} = this;
- const isCalloutEnabled = settings.isSymbolTypeEnabled(SymbolType.Callout);
+ const shouldInclude = this.shouldIncludeSymbol(SymbolType.Callout, activeFacetIds);
- if (isCalloutEnabled && sectionCache?.length && file) {
+ if (shouldInclude && sectionCache?.length && file) {
let fileContent: string = null;
try {
@@ -420,11 +466,15 @@ export class SymbolHandler extends Handler {
}
}
- private addLinksFromSource(linkData: LinkCache[], symbolList: SymbolInfo[]): void {
+ private addLinksFromSource(
+ linkData: LinkCache[],
+ symbolList: SymbolInfo[],
+ activeFacetIds: Set,
+ ): void {
const { settings } = this;
linkData = linkData ?? [];
- if (settings.isSymbolTypeEnabled(SymbolType.Link)) {
+ if (this.shouldIncludeSymbol(SymbolType.Link, activeFacetIds)) {
for (const link of linkData) {
const type = getLinkType(link);
const isExcluded = (settings.excludeLinkSubTypes & type) === type;
@@ -550,7 +600,7 @@ export class SymbolHandler extends Handler {
}
static isCanvasFile(sourceFile: TFile): boolean {
- return sourceFile.extension === 'canvas';
+ return sourceFile?.extension === 'canvas';
}
static isCanvasView(view: View): view is CanvasFileView {
diff --git a/src/settings/__tests__/generalSettingsTabSection.test.ts b/src/settings/__tests__/generalSettingsTabSection.test.ts
index 7d21784..5a24b1e 100644
--- a/src/settings/__tests__/generalSettingsTabSection.test.ts
+++ b/src/settings/__tests__/generalSettingsTabSection.test.ts
@@ -79,15 +79,15 @@ describe('generalSettingsTabSection', () => {
});
it('should show path settings', () => {
- const setPathDisplayFormatSpy = jest
- .spyOn(sut, 'setPathDisplayFormat')
+ const showPathDisplayFormatSpy = jest
+ .spyOn(sut, 'showPathDisplayFormat')
.mockReturnValueOnce();
sut.display(mockContainerEl);
- expect(setPathDisplayFormatSpy).toHaveBeenCalled();
+ expect(showPathDisplayFormatSpy).toHaveBeenCalled();
- setPathDisplayFormatSpy.mockRestore();
+ showPathDisplayFormatSpy.mockRestore();
});
it('should show the hidePathIfRoot setting', () => {
@@ -115,15 +115,15 @@ describe('generalSettingsTabSection', () => {
});
it('should show setting to change ribbon commands', () => {
- const setEnabledRibbonCommandsSpy = jest
- .spyOn(sut, 'setEnabledRibbonCommands')
+ const showEnabledRibbonCommandsSpy = jest
+ .spyOn(sut, 'showEnabledRibbonCommands')
.mockReturnValueOnce();
sut.display(mockContainerEl);
- expect(setEnabledRibbonCommandsSpy).toHaveBeenCalled();
+ expect(showEnabledRibbonCommandsSpy).toHaveBeenCalled();
- setEnabledRibbonCommandsSpy.mockRestore();
+ showEnabledRibbonCommandsSpy.mockRestore();
});
it('should show setting to change match priority adjustments', () => {
@@ -139,22 +139,22 @@ describe('generalSettingsTabSection', () => {
});
});
- describe('setPathDisplayFormat', () => {
+ describe('showPathDisplayFormat', () => {
it('should show the pathDisplayFormat setting', () => {
const addDropdownSettingSpy = jest.spyOn(
SettingsTabSection.prototype,
'addDropdownSetting',
);
- const setPathDisplayFormatSpy = jest.spyOn(sut, 'setPathDisplayFormat');
+ const showPathDisplayFormatSpy = jest.spyOn(sut, 'showPathDisplayFormat');
sut.display(mockContainerEl);
- expect(setPathDisplayFormatSpy).toHaveBeenCalledWith(mockContainerEl, config);
+ expect(showPathDisplayFormatSpy).toHaveBeenCalledWith(mockContainerEl, config);
expect(addDropdownSettingSpy).toHaveBeenCalled();
- setPathDisplayFormatSpy.mockRestore();
- setPathDisplayFormatSpy.mockRestore();
+ showPathDisplayFormatSpy.mockRestore();
+ showPathDisplayFormatSpy.mockRestore();
});
it('should save modified setting', () => {
@@ -171,7 +171,7 @@ describe('generalSettingsTabSection', () => {
const configSaveSpy = jest.spyOn(config, 'save');
- sut.setPathDisplayFormat(mockContainerEl, config);
+ sut.showPathDisplayFormat(mockContainerEl, config);
// trigger the save
onChangeFn(finalValue.toString(), config);
@@ -183,7 +183,7 @@ describe('generalSettingsTabSection', () => {
});
});
- describe('setEnabledRibbonCommands', () => {
+ describe('showEnabledRibbonCommands', () => {
let mockSetting: MockProxy;
let mockTextComp: MockProxy;
let mockInputEl: MockProxy;
@@ -218,7 +218,7 @@ describe('generalSettingsTabSection', () => {
it('should show the enabledRibbonCommands setting', () => {
const { enabledRibbonCommands } = config;
- sut.setEnabledRibbonCommands(mockContainerEl, config);
+ sut.showEnabledRibbonCommands(mockContainerEl, config);
expect(mockTextComp.setValue).toHaveBeenCalledWith(
enabledRibbonCommands.join('\n'),
@@ -237,7 +237,7 @@ describe('generalSettingsTabSection', () => {
config.enabledRibbonCommands = []; // start with no values set
mockTextComp.getValue.mockReturnValue(enabledCommands);
- sut.setEnabledRibbonCommands(mockContainerEl, config);
+ sut.showEnabledRibbonCommands(mockContainerEl, config);
focusoutFn(null); // trigger the callback to save
expect(mockTextComp.getValue).toHaveBeenCalled();
@@ -262,7 +262,7 @@ describe('generalSettingsTabSection', () => {
config.enabledRibbonCommands = initialCommands;
mockTextComp.getValue.mockReturnValue(enabledCommands);
- sut.setEnabledRibbonCommands(mockContainerEl, config);
+ sut.showEnabledRibbonCommands(mockContainerEl, config);
focusoutFn(null); // trigger the callback to save
expect(mockTextComp.getValue).toHaveBeenCalled();
@@ -322,7 +322,7 @@ describe('generalSettingsTabSection', () => {
});
it('should log error to the console when setting cannot be saved', async () => {
- const errorMsg = 'Unit test error';
+ const errorMsg = 'showMatchPriorityAdjustments Unit test error';
const rejectedPromise = Promise.reject(errorMsg);
const consoleLogSpy = jest.spyOn(console, 'log').mockReturnValueOnce();
@@ -393,4 +393,44 @@ describe('generalSettingsTabSection', () => {
saveSpy.mockRestore();
});
});
+
+ describe('showResetFacetEachSession', () => {
+ type addToggleSettingArgs = Parameters;
+ let toggleSettingOnChangeFn: addToggleSettingArgs[5];
+ let saveSettingsSpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ saveSettingsSpy = jest.spyOn(config, 'saveSettings');
+ });
+
+ afterAll(() => {
+ saveSettingsSpy.mockRestore();
+ });
+
+ it('should save changes to the shouldResetActiveFacets setting', () => {
+ const initialValue = false;
+ const finalValue = true;
+
+ config.quickFilters.shouldResetActiveFacets = initialValue;
+ addToggleSettingSpy.mockImplementation((...args: addToggleSettingArgs) => {
+ if (args[1] === 'Reset active Quick Filters') {
+ toggleSettingOnChangeFn = args[5];
+ }
+
+ return mock();
+ });
+
+ sut.showResetFacetEachSession(mockContainerEl, config);
+
+ // trigger the change/save
+ toggleSettingOnChangeFn(finalValue, config);
+
+ expect(saveSettingsSpy).toHaveBeenCalled();
+ expect(config.quickFilters.shouldResetActiveFacets).toBe(finalValue);
+
+ config.quickFilters.shouldResetActiveFacets = false;
+ addToggleSettingSpy.mockReset();
+ mockPluginSettingTab.display.mockClear();
+ });
+ });
});
diff --git a/src/settings/__tests__/switcherPlusSettings.test.ts b/src/settings/__tests__/switcherPlusSettings.test.ts
index 5fafaad..3ec9d4d 100644
--- a/src/settings/__tests__/switcherPlusSettings.test.ts
+++ b/src/settings/__tests__/switcherPlusSettings.test.ts
@@ -7,7 +7,7 @@ import {
SymbolType,
} from 'src/types';
import SwitcherPlusPlugin from 'src/main';
-import { SwitcherPlusSettings } from 'src/settings';
+import { FACETS_ALL, SwitcherPlusSettings } from 'src/settings';
import { Chance } from 'chance';
import {
App,
@@ -19,10 +19,9 @@ import {
import { mock, MockProxy } from 'jest-mock-extended';
const chance = new Chance();
+const sidePanelOptions = ['backlink', 'image', 'markdown', 'pdf'];
-function transientSettingsData(useDefault: boolean): SettingsData {
- const sidePanelOptions = ['backlink', 'image', 'markdown', 'pdf'];
-
+function getDefaultSettingsData(): SettingsData {
const enabledSymbolTypes = {} as Record;
enabledSymbolTypes[SymbolType.Link] = true;
enabledSymbolTypes[SymbolType.Embed] = true;
@@ -76,66 +75,88 @@ function transientSettingsData(useDefault: boolean): SettingsData {
alias: 0,
h1: 0,
},
+ quickFilters: {
+ resetKey: '0',
+ keyList: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+ modifiers: ['Ctrl', 'Alt'],
+ facetList: FACETS_ALL.map((v) => Object.assign({}, v)),
+ shouldResetActiveFacets: false,
+ shouldShowFacetInstructions: true,
+ },
preserveCommandPaletteLastInput: false,
preserveQuickSwitcherLastInput: false,
};
- if (!useDefault) {
- data.onOpenPreferNewTab = chance.bool();
- data.alwaysNewTabForSymbols = chance.bool();
- data.useActiveTabForSymbolsOnMobile = chance.bool();
- data.symbolsInLineOrder = chance.bool();
- data.editorListCommand = chance.word();
- data.symbolListCommand = chance.word();
- data.workspaceListCommand = chance.word();
- data.headingsListCommand = chance.word();
- data.starredListCommand = chance.word();
- data.commandListCommand = chance.word();
- data.strictHeadingsOnly = chance.bool();
- data.searchAllHeadings = chance.bool();
- data.headingsSearchDebounceMilli = chance.millisecond();
- data.limit = chance.integer();
- data.selectNearestHeading = chance.bool();
- data.starredListCommand = chance.word();
- data.relatedItemsListCommand = chance.word();
- data.excludeLinkSubTypes = LinkType.Block;
- data.excludeOpenRelatedFiles = chance.bool();
- data.excludeObsidianIgnoredFiles = chance.bool();
- data.shouldSearchFilenames = chance.bool();
- data.pathDisplayFormat = PathDisplayFormat.Full;
- data.hidePathIfRoot = chance.bool();
- data.enabledRelatedItems = chance.pickset(Object.values(RelationType), 2);
- data.showOptionalIndicatorIcons = chance.bool();
- data.overrideStandardModeBehaviors = chance.bool();
- data.fileExtAllowList = [];
- data.enableMatchPriorityAdjustments = chance.bool();
- data.matchPriorityAdjustments = { h2: 0.5, isOpenInEditor: 0.5 };
-
- const ribbonCommands = Object.values(Mode).filter((v) => isNaN(Number(v))) as Array<
- keyof typeof Mode
- >;
- data.enabledRibbonCommands = chance.pickset(ribbonCommands, 3);
-
- data.includeSidePanelViewTypes = [
- chance.word(),
- chance.word(),
- chance.pickone(sidePanelOptions),
- ];
+ return data;
+}
+
+function getTransientSettingsData(): SettingsData {
+ const enabledSymbolTypes = {} as Record;
+ enabledSymbolTypes[SymbolType.Link] = chance.bool();
+ enabledSymbolTypes[SymbolType.Embed] = chance.bool();
+ enabledSymbolTypes[SymbolType.Tag] = chance.bool();
+ enabledSymbolTypes[SymbolType.Heading] = chance.bool();
+ enabledSymbolTypes[SymbolType.Callout] = chance.bool();
- data.excludeFolders = [
- `path/to/${chance.word()}`,
- `${chance.word()}`,
- `/${chance.word()}`,
- ];
+ const ribbonCommands = Object.values(Mode).filter((v) => isNaN(Number(v))) as Array<
+ keyof typeof Mode
+ >;
+ const enabledRibbonCommands = chance.pickset(ribbonCommands, 3);
- data.excludeRelatedFolders = [`path/to/${chance.word()}`];
+ const data: SettingsData = {
+ enabledSymbolTypes,
- enabledSymbolTypes[SymbolType.Link] = chance.bool();
- enabledSymbolTypes[SymbolType.Embed] = chance.bool();
- enabledSymbolTypes[SymbolType.Tag] = chance.bool();
- enabledSymbolTypes[SymbolType.Heading] = chance.bool();
- enabledSymbolTypes[SymbolType.Callout] = chance.bool();
- }
+ excludeViewTypes: [chance.word(), chance.word()],
+ referenceViews: [chance.word(), chance.word()],
+
+ onOpenPreferNewTab: chance.bool(),
+ alwaysNewTabForSymbols: chance.bool(),
+ useActiveTabForSymbolsOnMobile: chance.bool(),
+ symbolsInLineOrder: chance.bool(),
+ editorListCommand: chance.word(),
+ symbolListCommand: chance.word(),
+ workspaceListCommand: chance.word(),
+ headingsListCommand: chance.word(),
+ starredListCommand: chance.word(),
+ commandListCommand: chance.word(),
+ strictHeadingsOnly: chance.bool(),
+ searchAllHeadings: chance.bool(),
+ headingsSearchDebounceMilli: chance.millisecond(),
+ limit: chance.integer(),
+ selectNearestHeading: chance.bool(),
+ relatedItemsListCommand: chance.word(),
+ excludeLinkSubTypes: LinkType.Block,
+ includeSidePanelViewTypes: [
+ chance.word(),
+ chance.word(),
+ chance.pickone(sidePanelOptions),
+ ],
+ excludeFolders: [`path/to/${chance.word()}`, `${chance.word()}`, `/${chance.word()}`],
+ excludeRelatedFolders: [`path/to/${chance.word()}`],
+ excludeOpenRelatedFiles: chance.bool(),
+ excludeObsidianIgnoredFiles: chance.bool(),
+ shouldSearchFilenames: chance.bool(),
+ pathDisplayFormat: PathDisplayFormat.Full,
+ hidePathIfRoot: chance.bool(),
+ enabledRelatedItems: chance.pickset(Object.values(RelationType), 2),
+ showOptionalIndicatorIcons: chance.bool(),
+ overrideStandardModeBehaviors: chance.bool(),
+ enabledRibbonCommands,
+ fileExtAllowList: [],
+ enableMatchPriorityAdjustments: chance.bool(),
+ matchPriorityAdjustments: { h2: 0.5, isOpenInEditor: 0.5 },
+ quickFilters: {
+ resetKey: chance.letter(),
+ resetModifiers: chance.pickset(['Alt', 'Ctrl', 'Meta', 'Shift'], 2),
+ keyList: [chance.letter()],
+ modifiers: [chance.pickone(['Alt', 'Ctrl', 'Meta'])],
+ facetList: [],
+ shouldResetActiveFacets: chance.bool(),
+ shouldShowFacetInstructions: chance.bool(),
+ },
+ preserveCommandPaletteLastInput: chance.bool(),
+ preserveQuickSwitcherLastInput: chance.bool(),
+ };
return data;
}
@@ -157,7 +178,7 @@ describe('SwitcherPlusSettings', () => {
it('should return default settings', () => {
// extract enabledSymbolTypes to handle separately, because it's not exposed
// on SwitcherPlusSettings directly
- const { enabledSymbolTypes, ...defaults } = transientSettingsData(true);
+ const { enabledSymbolTypes, ...defaults } = getDefaultSettingsData();
expect(sut).toEqual(expect.objectContaining(defaults));
expect(sut.editorListPlaceholderText).toBe(defaults.editorListCommand);
@@ -189,40 +210,20 @@ describe('SwitcherPlusSettings', () => {
});
it('should save modified settings', async () => {
- const settings = transientSettingsData(false);
-
- sut.onOpenPreferNewTab = settings.onOpenPreferNewTab;
- sut.alwaysNewTabForSymbols = settings.alwaysNewTabForSymbols;
- sut.useActiveTabForSymbolsOnMobile = settings.useActiveTabForSymbolsOnMobile;
- sut.symbolsInLineOrder = settings.symbolsInLineOrder;
- sut.editorListCommand = settings.editorListCommand;
- sut.symbolListCommand = settings.symbolListCommand;
- sut.workspaceListCommand = settings.workspaceListCommand;
- sut.headingsListCommand = settings.headingsListCommand;
- sut.starredListCommand = settings.starredListCommand;
- sut.commandListCommand = settings.commandListCommand;
- sut.relatedItemsListCommand = settings.relatedItemsListCommand;
- sut.strictHeadingsOnly = settings.strictHeadingsOnly;
- sut.searchAllHeadings = settings.searchAllHeadings;
- sut.headingsSearchDebounceMilli = settings.headingsSearchDebounceMilli;
- sut.includeSidePanelViewTypes = settings.includeSidePanelViewTypes;
- sut.limit = settings.limit;
- sut.selectNearestHeading = settings.selectNearestHeading;
- sut.excludeFolders = settings.excludeFolders;
- sut.excludeLinkSubTypes = settings.excludeLinkSubTypes;
- sut.excludeRelatedFolders = settings.excludeRelatedFolders;
- sut.excludeOpenRelatedFiles = settings.excludeOpenRelatedFiles;
- sut.excludeObsidianIgnoredFiles = settings.excludeObsidianIgnoredFiles;
- sut.shouldSearchFilenames = settings.shouldSearchFilenames;
- sut.pathDisplayFormat = settings.pathDisplayFormat;
- sut.hidePathIfRoot = settings.hidePathIfRoot;
- sut.enabledRelatedItems = settings.enabledRelatedItems;
- sut.showOptionalIndicatorIcons = settings.showOptionalIndicatorIcons;
- sut.overrideStandardModeBehaviors = settings.overrideStandardModeBehaviors;
- sut.enabledRibbonCommands = settings.enabledRibbonCommands;
- sut.fileExtAllowList = settings.fileExtAllowList;
- sut.enableMatchPriorityAdjustments = settings.enableMatchPriorityAdjustments;
- sut.matchPriorityAdjustments = settings.matchPriorityAdjustments;
+ const settings = getTransientSettingsData();
+
+ const props = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(sut));
+ Object.keys(settings).forEach((key) => {
+ // check if a setter is defined on sut for this key
+ if (props[key]?.set) {
+ type IndexedType = { [key: string]: unknown };
+
+ // copy value to sut since a setter exists
+ (sut as SwitcherPlusSettings & IndexedType)[key] = (
+ settings as SettingsData & IndexedType
+ )[key];
+ }
+ });
sut.setSymbolTypeEnabled(
SymbolType.Heading,
@@ -261,7 +262,7 @@ describe('SwitcherPlusSettings', () => {
});
it('should load saved settings', async () => {
- const settings = transientSettingsData(false);
+ const settings = getTransientSettingsData();
const { enabledSymbolTypes, ...prunedSettings } = settings;
mockPlugin.loadData.mockResolvedValueOnce(settings);
@@ -290,8 +291,8 @@ describe('SwitcherPlusSettings', () => {
});
it('should load saved settings, even with missing data keys', async () => {
- const defaults = transientSettingsData(true);
- const settings = transientSettingsData(false);
+ const defaults = getDefaultSettingsData();
+ const settings = getTransientSettingsData();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { enabledSymbolTypes, ...prunedSettings } = settings;
@@ -322,7 +323,7 @@ describe('SwitcherPlusSettings', () => {
});
it('should use default data if settings cannot be loaded', async () => {
- const { enabledSymbolTypes, ...defaults } = transientSettingsData(true);
+ const { enabledSymbolTypes, ...defaults } = getDefaultSettingsData();
mockPlugin.loadData.mockResolvedValueOnce(null);
await sut.loadSettings();
@@ -385,42 +386,42 @@ describe('SwitcherPlusSettings', () => {
mockInternalPlugins.getPluginById.mockReset();
});
- it('should log errors to console on fire and forget save operation', () => {
- // Promise used to trigger the error condition
- const saveDataPromise = Promise.resolve();
+ test('.loadSettings() should log errors to the console', async () => {
+ const consoleLogSpy = jest.spyOn(console, 'log').mockReturnValueOnce();
- mockPlugin.saveData.mockImplementationOnce((_data: SettingsData) => {
- // throw to simulate saveData() failing. This happens first
- return saveDataPromise.then(() => {
- throw new Error('saveData() unit test mock error');
- });
- });
+ const error = new Error('loadSettings unit test error');
+ mockPlugin.loadData.mockRejectedValueOnce(error);
- // Promise used to track the call to console.log
- let consoleLogPromiseResolveFn: (value: void | PromiseLike) => void;
- const consoleLogPromise = new Promise((resolve, _reject) => {
- consoleLogPromiseResolveFn = resolve;
- });
+ await sut.loadSettings();
- const consoleLogSpy = jest
- .spyOn(console, 'log')
- .mockImplementation((message: string) => {
- if (message.startsWith('Switcher++: error saving changes to settings')) {
- // resolve the consoleLogPromise. This happens second and will allow
- // allPromises to resolve itself
- consoleLogPromiseResolveFn();
- }
- });
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ 'Switcher++: error loading settings, using defaults. ',
+ error,
+ );
+
+ consoleLogSpy.mockRestore();
+ });
+
+ it('should log errors to console on fire and forget save operation', () => {
+ const consoleLogSpy = jest.spyOn(console, 'log');
+
+ const error = new Error('saveData() unit test mock error');
+ mockPlugin.saveData.mockRejectedValueOnce(error);
- // wait for the other promises to resolve before this promise can resolve
- const allPromises = Promise.all([saveDataPromise, consoleLogPromise]);
+ const logPromise = new Promise((resolve, _r) => {
+ consoleLogSpy.mockImplementationOnce(() => {
+ resolve();
+ });
+ });
sut.save();
- // when all the promises are resolved check expectations and clean up
- return allPromises.finally(() => {
+ return logPromise.finally(() => {
expect(mockPlugin.saveData).toHaveBeenCalled();
- expect(consoleLogSpy).toHaveBeenCalled();
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ 'Switcher++: error saving changes to settings',
+ error,
+ );
consoleLogSpy.mockRestore();
});
diff --git a/src/settings/__tests__/symbolSettingsTabSection.test.ts b/src/settings/__tests__/symbolSettingsTabSection.test.ts
index c84629a..34d6d3a 100644
--- a/src/settings/__tests__/symbolSettingsTabSection.test.ts
+++ b/src/settings/__tests__/symbolSettingsTabSection.test.ts
@@ -302,7 +302,7 @@ describe('symbolSettingsTabSection', () => {
it('should log error to the console when setting cannot be saved', async () => {
const initialEnabledValue = false;
const finalEnabledValue = true;
- const errorMsg = 'Unit test error';
+ const errorMsg = 'showEnableLinksToggle Unit test error';
const rejectedPromise = Promise.reject(errorMsg);
const consoleLogSpy = jest.spyOn(console, 'log').mockReturnValueOnce();
diff --git a/src/settings/facetConstants.ts b/src/settings/facetConstants.ts
new file mode 100644
index 0000000..6186722
--- /dev/null
+++ b/src/settings/facetConstants.ts
@@ -0,0 +1,101 @@
+import { Facet, Mode, RelationType, SymbolType } from 'src/types';
+
+// map Canvas node data types to facet id
+export const CANVAS_NODE_FACET_ID_MAP: Record = {
+ file: 'canvas-node-file',
+ text: 'canvas-node-text',
+ link: 'canvas-node-link',
+ group: 'canvas-node-group',
+};
+
+export const SYMBOL_MODE_FACETS: Facet[] = [
+ {
+ id: SymbolType[SymbolType.Heading],
+ mode: Mode.SymbolList,
+ label: 'headings',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: SymbolType[SymbolType.Tag],
+ mode: Mode.SymbolList,
+ label: 'tags',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: SymbolType[SymbolType.Callout],
+ mode: Mode.SymbolList,
+ label: 'callouts',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: SymbolType[SymbolType.Link],
+ mode: Mode.SymbolList,
+ label: 'links',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: SymbolType[SymbolType.Embed],
+ mode: Mode.SymbolList,
+ label: 'embeds',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: CANVAS_NODE_FACET_ID_MAP.file,
+ mode: Mode.SymbolList,
+ label: 'file cards',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: CANVAS_NODE_FACET_ID_MAP.text,
+ mode: Mode.SymbolList,
+ label: 'text cards',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: CANVAS_NODE_FACET_ID_MAP.link,
+ mode: Mode.SymbolList,
+ label: 'link cards',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: CANVAS_NODE_FACET_ID_MAP.group,
+ mode: Mode.SymbolList,
+ label: 'groups',
+ isActive: false,
+ isAvailable: true,
+ },
+];
+
+export const RELATED_ITEMS_MODE_FACETS: Facet[] = [
+ {
+ id: RelationType.Backlink,
+ mode: Mode.RelatedItemsList,
+ label: 'backlinks',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: RelationType.OutgoingLink,
+ mode: Mode.RelatedItemsList,
+ label: 'outgoing links',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: RelationType.DiskLocation,
+ mode: Mode.RelatedItemsList,
+ label: 'disk location',
+ isActive: false,
+ isAvailable: true,
+ },
+];
+
+export const FACETS_ALL: Facet[] = [...SYMBOL_MODE_FACETS, ...RELATED_ITEMS_MODE_FACETS];
diff --git a/src/settings/generalSettingsTabSection.ts b/src/settings/generalSettingsTabSection.ts
index db5fa81..f87e75f 100644
--- a/src/settings/generalSettingsTabSection.ts
+++ b/src/settings/generalSettingsTabSection.ts
@@ -23,14 +23,14 @@ export class GeneralSettingsTabSection extends SettingsTabSection {
const { config } = this;
this.addSectionTitle(containerEl, 'General Settings');
- this.setEnabledRibbonCommands(containerEl, config);
+ this.showEnabledRibbonCommands(containerEl, config);
- this.setPathDisplayFormat(containerEl, config);
+ this.showPathDisplayFormat(containerEl, config);
this.addToggleSetting(
containerEl,
'Hide path for root items',
'When enabled, path information will be hidden for items at the root of the vault.',
- this.config.hidePathIfRoot,
+ config.hidePathIfRoot,
'hidePathIfRoot',
).setClass('qsp-setting-item-indent');
@@ -54,11 +54,12 @@ export class GeneralSettingsTabSection extends SettingsTabSection {
containerEl,
'Show indicator icons',
'Display icons to indicate that an item is recent, starred, etc..',
- this.config.showOptionalIndicatorIcons,
+ config.showOptionalIndicatorIcons,
'showOptionalIndicatorIcons',
);
this.showMatchPriorityAdjustments(containerEl, config);
+
this.addToggleSetting(
containerEl,
'Restore previous input in Command Mode',
@@ -73,9 +74,11 @@ export class GeneralSettingsTabSection extends SettingsTabSection {
config.preserveQuickSwitcherLastInput,
'preserveQuickSwitcherLastInput',
);
+
+ this.showResetFacetEachSession(containerEl, config);
}
- setPathDisplayFormat(containerEl: HTMLElement, config: SwitcherPlusSettings): void {
+ showPathDisplayFormat(containerEl: HTMLElement, config: SwitcherPlusSettings): void {
const options: Record = {};
options[PathDisplayFormat.None.toString()] = 'Hide path';
options[PathDisplayFormat.Full.toString()] = 'Full path';
@@ -98,7 +101,10 @@ export class GeneralSettingsTabSection extends SettingsTabSection {
);
}
- setEnabledRibbonCommands(containerEl: HTMLElement, config: SwitcherPlusSettings) {
+ showEnabledRibbonCommands(
+ containerEl: HTMLElement,
+ config: SwitcherPlusSettings,
+ ): void {
const modeNames = Object.values(Mode)
.filter((v) => isNaN(Number(v)))
.sort();
@@ -193,4 +199,21 @@ export class GeneralSettingsTabSection extends SettingsTabSection {
});
}
}
+
+ showResetFacetEachSession(
+ containerEl: HTMLElement,
+ config: SwitcherPlusSettings,
+ ): void {
+ this.addToggleSetting(
+ containerEl,
+ 'Reset active Quick Filters',
+ 'When enabled, the switcher will reset all Quick Filters back to inactive for each session.',
+ config.quickFilters.shouldResetActiveFacets,
+ null,
+ (value, config) => {
+ config.quickFilters.shouldResetActiveFacets = value;
+ config.save();
+ },
+ );
+ }
}
diff --git a/src/settings/index.ts b/src/settings/index.ts
index bcaf652..22a0b03 100644
--- a/src/settings/index.ts
+++ b/src/settings/index.ts
@@ -9,3 +9,4 @@ export * from './workspaceSettingsTabSection';
export * from './editorSettingsTabSection';
export * from './headingsSettingsTabSection';
export * from './symbolSettingsTabSection';
+export * from './facetConstants';
diff --git a/src/settings/switcherPlusSettings.ts b/src/settings/switcherPlusSettings.ts
index 2aee613..64b85a9 100644
--- a/src/settings/switcherPlusSettings.ts
+++ b/src/settings/switcherPlusSettings.ts
@@ -1,7 +1,9 @@
import { getSystemSwitcherInstance } from 'src/utils';
import type SwitcherPlusPlugin from 'src/main';
import { QuickSwitcherOptions } from 'obsidian';
+import { FACETS_ALL } from './facetConstants';
import {
+ FacetSettingsData,
Mode,
PathDisplayFormat,
RelationType,
@@ -66,6 +68,14 @@ export class SwitcherPlusSettings {
alias: 0,
h1: 0,
},
+ quickFilters: {
+ resetKey: '0',
+ keyList: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+ modifiers: ['Ctrl', 'Alt'],
+ facetList: FACETS_ALL.map((v) => Object.assign({}, v)),
+ shouldResetActiveFacets: false,
+ shouldShowFacetInstructions: true,
+ },
preserveCommandPaletteLastInput: false,
preserveQuickSwitcherLastInput: false,
};
@@ -239,10 +249,18 @@ export class SwitcherPlusSettings {
return this.data.excludeViewTypes;
}
+ set excludeViewTypes(value: Array) {
+ this.data.excludeViewTypes = value;
+ }
+
get referenceViews(): Array {
return this.data.referenceViews;
}
+ set referenceViews(value: Array) {
+ this.data.referenceViews = value;
+ }
+
get limit(): number {
return this.data.limit;
}
@@ -394,7 +412,15 @@ export class SwitcherPlusSettings {
this.data.matchPriorityAdjustments = value;
}
- get preserveCommandPaletteLastInput() {
+ get quickFilters(): FacetSettingsData {
+ return this.data.quickFilters;
+ }
+
+ set quickFilters(value: FacetSettingsData) {
+ this.data.quickFilters = value;
+ }
+
+ get preserveCommandPaletteLastInput(): boolean {
return this.data.preserveCommandPaletteLastInput;
}
@@ -402,7 +428,7 @@ export class SwitcherPlusSettings {
this.data.preserveCommandPaletteLastInput = value;
}
- get preserveQuickSwitcherLastInput() {
+ get preserveQuickSwitcherLastInput(): boolean {
return this.data.preserveQuickSwitcherLastInput;
}
diff --git a/src/switcherPlus/__tests__/modeHandler.test.ts b/src/switcherPlus/__tests__/modeHandler.test.ts
index 080edc4..05a612b 100644
--- a/src/switcherPlus/__tests__/modeHandler.test.ts
+++ b/src/switcherPlus/__tests__/modeHandler.test.ts
@@ -1,6 +1,15 @@
import { mock, mockClear, mockFn, MockProxy, mockReset } from 'jest-mock-extended';
import { SwitcherPlusSettings } from 'src/settings';
-import { AnySuggestion, Mode, SwitcherPlus, SymbolType } from 'src/types';
+import {
+ AnySuggestion,
+ Mode,
+ SwitcherPlus,
+ SymbolType,
+ KeymapConfig,
+ RelationType,
+ Facet,
+ FacetSettingsData,
+} from 'src/types';
import { Chance } from 'chance';
import {
SwitcherPlusKeymap,
@@ -184,6 +193,19 @@ describe('modeHandler', () => {
expect(mockKeymap.isOpen).toBe(true);
});
+ test('with shouldResetActiveFacets enabled, .onOpen() should set all facets to inactive', () => {
+ mockSettings.quickFilters = mock({
+ shouldResetActiveFacets: true,
+ facetList: [mock({ isActive: true }), mock({ isActive: true })],
+ });
+
+ sut.onOpen();
+
+ expect(mockSettings.quickFilters.facetList.every((v) => v.isActive === false)).toBe(
+ true,
+ );
+ });
+
test('onClose() should close the keymap', () => {
mockKeymap.isOpen = true;
@@ -577,7 +599,7 @@ describe('modeHandler', () => {
});
it('should log errors from async handlers to the console', async () => {
- const errorMsg = 'Unit test error';
+ const errorMsg = 'managing suggestions Unit test error';
const rejectedPromise = Promise.reject(errorMsg);
const consoleLogSpy = jest.spyOn(console, 'log').mockReturnValueOnce();
@@ -1003,4 +1025,125 @@ describe('modeHandler', () => {
recentSpy.mockRestore();
});
});
+
+ describe('updatedKeymapForMode', () => {
+ const mockKeymap = mock();
+ const mockModal = mock();
+ const mockChooser = mock>();
+ const mode = Mode.RelatedItemsList;
+ const inputInfo = new InputInfo('', mode);
+ let sut: ModeHandler;
+ let getSuggestionSpy: jest.SpyInstance;
+ const mockSettingsLocal = mock({
+ quickFilters: {
+ facetList: [
+ {
+ id: RelationType.Backlink,
+ mode: mode,
+ label: 'backlinks',
+ isActive: false,
+ isAvailable: true,
+ },
+ {
+ id: RelationType.OutgoingLink,
+ mode: mode,
+ label: 'outgoing links',
+ isActive: false,
+ isAvailable: true,
+ },
+ ],
+ },
+ });
+
+ beforeAll(() => {
+ sut = new ModeHandler(mockApp, mockSettingsLocal, mockKeymap);
+ getSuggestionSpy = jest.spyOn(sut, 'getSuggestions').mockReturnValue();
+ });
+
+ afterEach(() => {
+ getSuggestionSpy.mockClear();
+ mockReset(mockKeymap);
+ });
+
+ afterAll(() => {
+ getSuggestionSpy.mockRestore();
+ });
+
+ test('on facet activation, it should toggle the facet to active (.isActive) and rerun getSuggestions', () => {
+ let keymapConfig: KeymapConfig;
+ mockKeymap.updateKeymapForMode.mockImplementationOnce((config) => {
+ keymapConfig = config;
+ });
+
+ sut.updatedKeymapForMode(
+ inputInfo,
+ mockChooser,
+ mockModal,
+ mockKeymap,
+ mockSettings,
+ );
+
+ // ensure the facet is not active to start with
+ const facet = keymapConfig.facets.facetList[0];
+ facet.isActive = false;
+
+ // trigger the callback
+ keymapConfig.facets.onToggleFacet([facet], false);
+
+ expect(facet.isActive).toBe(true);
+ expect(getSuggestionSpy).toHaveBeenCalledWith(inputInfo, mockChooser, mockModal);
+ });
+
+ test('on reset trigger, if at least 1 facet is active, it should toggle all facets to in-active and rerun getSuggestions', () => {
+ let keymapConfig: KeymapConfig;
+ mockKeymap.updateKeymapForMode.mockImplementationOnce((config) => {
+ keymapConfig = config;
+ });
+
+ sut.updatedKeymapForMode(
+ inputInfo,
+ mockChooser,
+ mockModal,
+ mockKeymap,
+ mockSettings,
+ );
+
+ // start with at least one facet being active
+ const { facetList } = keymapConfig.facets;
+ facetList[0].isActive = true;
+ facetList[1].isActive = false;
+
+ // trigger the callback
+ keymapConfig.facets.onToggleFacet(facetList, true);
+
+ expect(facetList.every((facet) => facet.isActive === false)).toBe(true);
+ expect(getSuggestionSpy).toHaveBeenCalledWith(inputInfo, mockChooser, mockModal);
+ });
+
+ test('on reset trigger, if all facets are in-active, it should toggle all facets to active and rerun getSuggestions', () => {
+ let keymapConfig: KeymapConfig;
+ mockKeymap.updateKeymapForMode.mockImplementationOnce((config) => {
+ keymapConfig = config;
+ });
+
+ sut.updatedKeymapForMode(
+ inputInfo,
+ mockChooser,
+ mockModal,
+ mockKeymap,
+ mockSettings,
+ );
+
+ // start with all facets being inactive
+ const { facetList } = keymapConfig.facets;
+ facetList[0].isActive = false;
+ facetList[1].isActive = false;
+
+ // trigger the callback
+ keymapConfig.facets.onToggleFacet(facetList, true);
+
+ expect(facetList.every((facet) => facet.isActive === true)).toBe(true);
+ expect(getSuggestionSpy).toHaveBeenCalledWith(inputInfo, mockChooser, mockModal);
+ });
+ });
});
diff --git a/src/switcherPlus/__tests__/switcherPlus.test.ts b/src/switcherPlus/__tests__/switcherPlus.test.ts
index b25547d..dc9bd47 100644
--- a/src/switcherPlus/__tests__/switcherPlus.test.ts
+++ b/src/switcherPlus/__tests__/switcherPlus.test.ts
@@ -62,14 +62,7 @@ describe('switcherPlus', () => {
describe('createSwitcherPlus', () => {
it('should log error to the console if the builtin QuickSwitcherModal is not accessible', () => {
- let wasLogged = false;
- const consoleLogSpy = jest
- .spyOn(console, 'log')
- .mockImplementation((message: string) => {
- if (message.startsWith('Switcher++: unable to extend system switcher.')) {
- wasLogged = true;
- }
- });
+ const consoleLogSpy = jest.spyOn(console, 'log').mockReturnValueOnce();
mockGetSystemSwitcherInstance.mockReturnValueOnce(null);
@@ -77,8 +70,11 @@ describe('switcherPlus', () => {
expect(result).toBeNull();
expect(mockGetSystemSwitcherInstance).toHaveBeenCalledWith(mockApp);
- expect(consoleLogSpy).toHaveBeenCalled();
- expect(wasLogged).toBe(true);
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Switcher++: unable to extend system switcher. Plugin UI will not be loaded.',
+ ),
+ );
consoleLogSpy.mockRestore();
});
diff --git a/src/switcherPlus/__tests__/switcherPlusKeymap.test.ts b/src/switcherPlus/__tests__/switcherPlusKeymap.test.ts
index 9851a89..6bab436 100644
--- a/src/switcherPlus/__tests__/switcherPlusKeymap.test.ts
+++ b/src/switcherPlus/__tests__/switcherPlusKeymap.test.ts
@@ -1,4 +1,5 @@
-import { mock, mockClear, mockReset } from 'jest-mock-extended';
+import { Chance } from 'chance';
+import { anyFunction, mock, mockClear, mockFn, mockReset } from 'jest-mock-extended';
import {
Chooser,
KeymapContext,
@@ -8,8 +9,17 @@ import {
Platform,
Scope,
} from 'obsidian';
-import { SwitcherPlusKeymap } from 'src/switcherPlus';
-import { AnySuggestion, Mode, SwitcherPlus } from 'src/types';
+import { CustomKeymapInfo, SwitcherPlusKeymap } from 'src/switcherPlus';
+import {
+ AnySuggestion,
+ KeymapConfig,
+ Mode,
+ SwitcherPlus,
+ Facet,
+ FacetSettingsData,
+} from 'src/types';
+
+const chance = new Chance();
describe('SwitcherPlusKeymap', () => {
const selector = '.prompt-instructions';
@@ -200,13 +210,13 @@ describe('SwitcherPlusKeymap', () => {
});
it('should hide the default prompt instructions in custom modes', () => {
- sut.updateKeymapForMode(Mode.EditorList);
+ sut.updateKeymapForMode({ mode: Mode.EditorList });
expect(mockInstructionsEl.style.display).toBe('none');
});
it('should show the default prompt instructions in standard modes', () => {
- sut.updateKeymapForMode(Mode.Standard);
+ sut.updateKeymapForMode({ mode: Mode.Standard });
expect(mockInstructionsEl.style.display).toBe('');
});
@@ -215,7 +225,7 @@ describe('SwitcherPlusKeymap', () => {
const mode = Mode.EditorList;
const keymaps = sut.customKeysInfo.filter((keymap) => keymap.modes?.includes(mode));
- sut.updateKeymapForMode(mode);
+ sut.updateKeymapForMode({ mode });
expect(mockModal.setInstructions).toHaveBeenCalledWith(keymaps);
});
@@ -227,7 +237,7 @@ describe('SwitcherPlusKeymap', () => {
mockScope.keys = [mockEnter];
- sut.updateKeymapForMode(Mode.EditorList);
+ sut.updateKeymapForMode({ mode: Mode.EditorList });
expect(mockScope.unregister).not.toHaveBeenCalled();
});
@@ -236,11 +246,11 @@ describe('SwitcherPlusKeymap', () => {
mockScope.keys = [mockMetaShiftEnter, mockShiftEnter];
// should first update for a custom mode
- sut.updateKeymapForMode(Mode.HeadingsList);
+ sut.updateKeymapForMode({ mode: Mode.HeadingsList });
mockScope.register.mockReset();
// should restore all standard hotkeys
- sut.updateKeymapForMode(Mode.Standard);
+ sut.updateKeymapForMode({ mode: Mode.Standard });
// convert to [][] so each call can be checked separately
const expected = sut.standardKeysInfo.map((v) => {
@@ -263,7 +273,7 @@ describe('SwitcherPlusKeymap', () => {
.spyOn(sut, 'unregisterKeys')
.mockReturnValue([mock()]);
- sut.updateKeymapForMode(mode);
+ sut.updateKeymapForMode({ mode });
// convert to [][] so each call can be checked separately
const expected = customKeymaps.map((v) => {
@@ -322,4 +332,284 @@ describe('SwitcherPlusKeymap', () => {
expect(mockChooser.useSelectedItem).toHaveBeenCalledWith(mockEvt);
});
});
+
+ describe('registerFacetBinding', () => {
+ let sut: SwitcherPlusKeymap;
+
+ beforeAll(() => {
+ sut = new SwitcherPlusKeymap(mockScope, mockChooser, mockModal);
+ });
+
+ beforeEach(() => {
+ mockClear(mockScope);
+ });
+
+ test('should register a facet binding using default shortcut keys', () => {
+ const key = chance.letter();
+ const modifiers = chance.pickset(['Alt', 'Ctrl', 'Shift', 'Meta'], 3);
+ const facet = mock({
+ modifiers: undefined,
+ key: null,
+ });
+ const mockKeymapConfig = mock({
+ facets: {
+ facetList: [facet],
+ facetSettings: {
+ modifiers,
+ keyList: [key],
+ },
+ },
+ });
+
+ sut.registerFacetBinding(mockScope, mockKeymapConfig);
+
+ expect(mockScope.register).toHaveBeenCalledWith(
+ modifiers,
+ key,
+ expect.any(Function),
+ );
+ });
+
+ test('should register a facet binding using custom shortcut keys', () => {
+ const facet = mock({
+ modifiers: [chance.pickone(['Alt', 'Ctrl', 'Shift'])],
+ key: chance.letter(),
+ });
+ const mockKeymapConfig = mock({
+ facets: {
+ facetList: [facet],
+ },
+ });
+
+ sut.registerFacetBinding(mockScope, mockKeymapConfig);
+
+ expect(mockScope.register).toHaveBeenCalledWith(
+ facet.modifiers,
+ facet.key,
+ expect.any(Function),
+ );
+ });
+
+ test('should log error to the console when the list of default shortcut keys is used up', () => {
+ const consoleLogSpy = jest.spyOn(console, 'log').mockReturnValueOnce();
+ const facet = mock({
+ modifiers: null,
+ key: null,
+ });
+ const mockKeymapConfig = mock({
+ facets: {
+ facetList: [facet],
+ facetSettings: {
+ keyList: [],
+ },
+ },
+ });
+
+ sut.registerFacetBinding(mockScope, mockKeymapConfig);
+
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Switcher++: unable to register hotkey for facet:'),
+ );
+
+ consoleLogSpy.mockRestore();
+ });
+
+ test('should toggle all facets using resetModifiers shortcut key', () => {
+ const resetKey = chance.letter();
+ const mockKeymapConfig = mock({
+ facets: {
+ facetList: [mock()],
+ facetSettings: {
+ resetKey,
+ keyList: [chance.letter()],
+ resetModifiers: chance.pickset(['Alt', 'Ctrl', 'Shift'], 2),
+ },
+ onToggleFacet: mockFn(),
+ },
+ });
+ const { resetModifiers } = mockKeymapConfig.facets.facetSettings;
+
+ let keymapFn: KeymapEventListener;
+ mockScope.register
+ .calledWith(resetModifiers, resetKey, anyFunction())
+ .mockImplementationOnce((_m, _k, evtListener) => {
+ keymapFn = evtListener;
+ return null;
+ });
+
+ // perform registration
+ sut.registerFacetBinding(mockScope, mockKeymapConfig);
+
+ //execute callback
+ keymapFn(null, null);
+
+ expect(mockScope.register).toHaveBeenCalledWith(
+ resetModifiers,
+ resetKey,
+ expect.any(Function),
+ );
+
+ expect(mockKeymapConfig.facets.onToggleFacet).toHaveBeenCalledWith(
+ mockKeymapConfig.facets.facetList,
+ true,
+ );
+ });
+
+ test('should register the toggle all shortcut key using modifiers if resetModifiers is falsy', () => {
+ const resetKey = chance.letter();
+ const modifiers = chance.pickset(['Alt', 'Ctrl', 'Shift'], 2);
+ const mockKeymapConfig = mock({
+ facets: {
+ facetList: [mock()],
+ facetSettings: {
+ resetKey,
+ modifiers,
+ resetModifiers: null,
+ keyList: [chance.letter()],
+ },
+ },
+ });
+
+ sut.registerFacetBinding(mockScope, mockKeymapConfig);
+
+ expect(mockScope.register).toHaveBeenCalledWith(
+ modifiers,
+ resetKey,
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe('renderFacetInstructions', () => {
+ let sut: SwitcherPlusKeymap;
+ let mockInstructionEl: HTMLSpanElement;
+
+ beforeAll(() => {
+ sut = new SwitcherPlusKeymap(mockScope, mockChooser, mockModal);
+
+ mockInstructionEl = mock();
+ mockModal.modalEl = mock({
+ // return the filters container element
+ createDiv: mockFn().mockReturnValue({
+ // return the instructions wrapper element
+ createDiv: mockFn().mockReturnValue(mockInstructionEl),
+ }),
+ });
+ });
+
+ beforeEach(() => {
+ mockClear(mockModal);
+ mockClear(mockInstructionEl);
+ });
+
+ afterAll(() => {
+ mockReset(mockModal);
+ });
+
+ it('should render a facet indicator using default modifiers', () => {
+ const key = chance.letter();
+ const modifiers = chance.pickset(['Alt', 'Ctrl', 'Shift', 'Meta'], 3);
+
+ const facetSettings = mock({
+ modifiers,
+ keyList: [key],
+ });
+
+ const facetKeyInfo = mock({
+ command: key,
+ facet: mock({
+ modifiers: undefined,
+ isActive: false,
+ }),
+ });
+
+ sut.renderFacetInstructions(mockModal, facetSettings, [facetKeyInfo]);
+
+ expect(mockInstructionEl.createSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ cls: 'prompt-instruction-command',
+ text: key,
+ }),
+ );
+ });
+
+ it('should render a facet indicator using custom modifiers', () => {
+ const key = chance.letter();
+ const modifiers = chance.pickset(['Alt', 'Ctrl'], 1);
+
+ const facetSettings = mock({
+ keyList: [key],
+ });
+
+ const facetKeyInfo = mock({
+ command: key,
+ facet: mock({
+ modifiers,
+ isActive: false,
+ }),
+ });
+
+ sut.renderFacetInstructions(mockModal, facetSettings, [facetKeyInfo]);
+
+ const modifierStr = modifiers.toString().replace(',', ' ');
+ expect(mockInstructionEl.createSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ cls: 'prompt-instruction-command',
+ text: `(${modifierStr}) ${key}`,
+ }),
+ );
+ });
+
+ it('should add an additional css class to indicate a facet is active', () => {
+ const key = chance.letter();
+ const modifiers = chance.pickset(['Alt', 'Ctrl'], 1);
+
+ const facetSettings = mock({
+ modifiers,
+ keyList: [key],
+ });
+
+ const facetKeyInfo = mock({
+ command: key,
+ purpose: chance.sentence(),
+ facet: mock({
+ modifiers: undefined,
+ isActive: true,
+ }),
+ });
+
+ sut.renderFacetInstructions(mockModal, facetSettings, [facetKeyInfo]);
+
+ expect(mockInstructionEl.createSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ cls: ['qsp-filter-active'],
+ text: facetKeyInfo.purpose,
+ }),
+ );
+ });
+
+ it('should render the reset toggle indicator', () => {
+ const key = chance.letter();
+ const modifiers = chance.pickset(['Alt', 'Ctrl'], 1);
+
+ const facetSettings = mock({
+ resetKey: key,
+ resetModifiers: modifiers,
+ });
+
+ const facetKeyInfo = mock({
+ facet: null,
+ });
+
+ sut.renderFacetInstructions(mockModal, facetSettings, [facetKeyInfo]);
+
+ const modifierStr = modifiers.toString().replace(',', ' ');
+ expect(mockInstructionEl.createSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ cls: 'prompt-instruction-command',
+ text: `(${modifierStr}) ${key}`,
+ }),
+ );
+ });
+ });
});
diff --git a/src/switcherPlus/modeHandler.ts b/src/switcherPlus/modeHandler.ts
index 8e1a281..da34efd 100644
--- a/src/switcherPlus/modeHandler.ts
+++ b/src/switcherPlus/modeHandler.ts
@@ -25,6 +25,8 @@ import {
SymbolSuggestion,
SuggestionType,
SwitcherPlus,
+ Facet,
+ KeymapConfig,
} from 'src/types';
import { InputInfo } from './inputInfo';
import { SwitcherPlusSettings } from 'src/settings';
@@ -85,7 +87,12 @@ export class ModeHandler {
}
onOpen(): void {
- this.exKeymap.isOpen = true;
+ const { exKeymap, settings } = this;
+ exKeymap.isOpen = true;
+
+ if (settings.quickFilters?.shouldResetActiveFacets) {
+ settings.quickFilters.facetList?.forEach((f) => (f.isActive = false));
+ }
}
onClose() {
@@ -137,10 +144,10 @@ export class ModeHandler {
chooser: Chooser,
modal: SwitcherPlus,
): boolean {
+ const { exKeymap, settings } = this;
let handled = false;
- const { exKeymap } = this;
- // cancel any potentially previously running debounced getsuggestions call
+ // cancel any potentially previously running debounced getSuggestions call
this.debouncedGetSuggestions.cancel();
// get the currently active leaf across all rootSplits
@@ -148,11 +155,12 @@ export class ModeHandler {
const activeSugg = ModeHandler.getActiveSuggestion(chooser);
const inputInfo = this.determineRunMode(query, activeSugg, activeLeaf);
this.inputInfo = inputInfo;
- const { mode } = inputInfo;
- exKeymap.updateKeymapForMode(mode);
+ const { mode } = inputInfo;
lastInputInfoByMode[mode] = inputInfo;
+ this.updatedKeymapForMode(inputInfo, chooser, modal, exKeymap, settings);
+
if (mode !== Mode.Standard) {
if (mode === Mode.HeadingsList && inputInfo.parsedCommand().parsedInput?.length) {
// if headings mode and user is typing a query, delay getting suggestions
@@ -167,6 +175,47 @@ export class ModeHandler {
return handled;
}
+ updatedKeymapForMode(
+ inputInfo: InputInfo,
+ chooser: Chooser,
+ modal: SwitcherPlus,
+ exKeymap: SwitcherPlusKeymap,
+ settings: SwitcherPlusSettings,
+ ): void {
+ const { mode } = inputInfo;
+ const handler = this.getHandler(mode);
+ const facetList = handler?.getAvailableFacets(inputInfo) ?? [];
+
+ const handleFacetKeyEvent = (facets: Facet[], isReset: boolean) => {
+ if (isReset) {
+ // cycle between making all facets active/inactive
+ const hasActive = facets.some((v) => v.isActive === true);
+ handler.activateFacet(facets, !hasActive);
+ } else {
+ // expect facets to contain only one item that needs to be toggled
+ handler.activateFacet(facets, !facets[0].isActive);
+ }
+
+ // refresh the suggestion list after changing the list of active facets
+ this.updatedKeymapForMode(inputInfo, chooser, modal, exKeymap, settings);
+ this.getSuggestions(inputInfo, chooser, modal);
+
+ // prevent default handling of key press afterwards
+ return false;
+ };
+
+ const keymapConfig: KeymapConfig = {
+ mode,
+ facets: {
+ facetList,
+ facetSettings: settings.quickFilters,
+ onToggleFacet: handleFacetKeyEvent.bind(this),
+ },
+ };
+
+ exKeymap.updateKeymapForMode(keymapConfig);
+ }
+
renderSuggestion(sugg: AnySuggestion, parentEl: HTMLElement): boolean {
const {
inputInfo,
diff --git a/src/switcherPlus/switcherPlusKeymap.ts b/src/switcherPlus/switcherPlusKeymap.ts
index b216afb..e21343a 100644
--- a/src/switcherPlus/switcherPlusKeymap.ts
+++ b/src/switcherPlus/switcherPlusKeymap.ts
@@ -1,4 +1,11 @@
-import { AnySuggestion, Mode, SwitcherPlus } from 'src/types';
+import {
+ AnySuggestion,
+ Facet,
+ FacetSettingsData,
+ KeymapConfig,
+ Mode,
+ SwitcherPlus,
+} from 'src/types';
import {
Scope,
KeymapContext,
@@ -10,7 +17,7 @@ import {
Platform,
} from 'obsidian';
-type CustomKeymapInfo = Omit &
+export type CustomKeymapInfo = Omit &
Instruction & { isInstructionOnly?: boolean; modes?: Mode[] };
export class SwitcherPlusKeymap {
@@ -23,7 +30,8 @@ export class SwitcherPlusKeymap {
modKey: Modifier = 'Ctrl';
modKeyText = 'ctrl';
- shiftText = 'shift';
+ shiftKeyText = 'shift';
+ readonly facetKeysInfo: Array = [];
get isOpen(): boolean {
return this._isOpen;
@@ -41,7 +49,7 @@ export class SwitcherPlusKeymap {
if (Platform.isMacOS) {
this.modKey = 'Meta';
this.modKeyText = '⌘';
- this.shiftText = '⇧';
+ this.shiftKeyText = '⇧';
}
this.initKeysInfo();
@@ -96,7 +104,7 @@ export class SwitcherPlusKeymap {
modifiers: `${this.modKey},Shift`,
key: '\\',
func: null,
- command: `${this.modKeyText} ${this.shiftText} \\`,
+ command: `${this.modKeyText} ${this.shiftKeyText} \\`,
purpose: 'open below',
},
{
@@ -145,6 +153,65 @@ export class SwitcherPlusKeymap {
});
}
+ registerFacetBinding(scope: Scope, keymapConfig: KeymapConfig): void {
+ const { mode, facets } = keymapConfig;
+
+ if (facets?.facetList?.length) {
+ const { facetList, facetSettings, onToggleFacet } = facets;
+ const { keyList, modifiers, resetKey, resetModifiers } = facetSettings;
+ let currKeyListIndex = 0;
+ let keyHandler: KeymapEventHandler;
+
+ const registerFn = (
+ modKeys: Modifier[],
+ key: string,
+ facetListLocal: Facet[],
+ isReset: boolean,
+ ) => {
+ return scope.register(modKeys, key, () => onToggleFacet(facetListLocal, isReset));
+ };
+
+ // register each of the facets to a corresponding key
+ for (let i = 0; i < facetList.length; i++) {
+ const facet = facetList[i];
+ const facetModifiers = facet.modifiers ?? modifiers;
+ let key: string;
+
+ if (facet.key?.length) {
+ // has override key defined so use it instead of the default
+ key = facet.key;
+ } else if (currKeyListIndex < keyList.length) {
+ // use up one of the default keys
+ key = keyList[currKeyListIndex];
+ ++currKeyListIndex;
+ } else {
+ // override key is not defined and no default keys left
+ console.log(
+ `Switcher++: unable to register hotkey for facet: ${facet.label} in mode: ${Mode[mode]} because a trigger key is not specified`,
+ );
+ continue;
+ }
+
+ keyHandler = registerFn(facetModifiers, key, [facet], false);
+ this.facetKeysInfo.push({
+ facet,
+ command: key,
+ purpose: facet.label,
+ ...keyHandler,
+ });
+ }
+
+ // register the toggle key
+ keyHandler = registerFn(resetModifiers ?? modifiers, resetKey, facetList, true);
+ this.facetKeysInfo.push({
+ facet: null,
+ command: resetKey,
+ purpose: 'toggle all',
+ ...keyHandler,
+ });
+ }
+ }
+
registerTabBindings(scope: Scope): void {
const keys: [Modifier[], string][] = [
[[this.modKey], '\\'],
@@ -157,15 +224,25 @@ export class SwitcherPlusKeymap {
});
}
- updateKeymapForMode(mode: Mode): void {
- const isStandardMode = mode === Mode.Standard;
- const { modal, scope, savedStandardKeysInfo, standardKeysInfo, customKeysInfo } =
- this;
+ updateKeymapForMode(keymapConfig: KeymapConfig): void {
+ const { mode } = keymapConfig;
+ const {
+ modal,
+ scope,
+ savedStandardKeysInfo,
+ standardKeysInfo,
+ customKeysInfo,
+ facetKeysInfo,
+ } = this;
const customKeymaps = customKeysInfo.filter((v) => !v.isInstructionOnly);
this.unregisterKeys(scope, customKeymaps);
- if (isStandardMode) {
+ // remove facet keys and reset storage array
+ this.unregisterKeys(scope, facetKeysInfo);
+ facetKeysInfo.length = 0;
+
+ if (mode === Mode.Standard) {
this.registerKeys(scope, savedStandardKeysInfo);
savedStandardKeysInfo.length = 0;
@@ -178,8 +255,9 @@ export class SwitcherPlusKeymap {
const customKeysToAdd = customKeymaps.filter((v) => v.modes?.includes(mode));
this.registerKeys(scope, customKeysToAdd);
+ this.registerFacetBinding(scope, keymapConfig);
- this.showCustomInstructions(modal, customKeysInfo, mode);
+ this.showCustomInstructions(modal, keymapConfig, customKeysInfo, facetKeysInfo);
}
}
@@ -247,17 +325,81 @@ export class SwitcherPlusKeymap {
showCustomInstructions(
modal: SwitcherPlus,
+ keymapConfig: KeymapConfig,
keymapInfo: CustomKeymapInfo[],
- mode: Mode,
+ facetKeysInfo: Array,
): void {
+ const { mode, facets } = keymapConfig;
const { containerEl } = modal;
const keymaps = keymapInfo.filter((keymap) => keymap.modes?.includes(mode));
this.toggleStandardInstructions(containerEl, false);
this.clearCustomInstructions(containerEl);
+
+ this.renderFacetInstructions(modal, facets?.facetSettings, facetKeysInfo);
modal.setInstructions(keymaps);
}
+ renderFacetInstructions(
+ modal: SwitcherPlus,
+ facetSettings: FacetSettingsData,
+ facetKeysInfo: Array,
+ ): void {
+ if (facetKeysInfo?.length && facetSettings.shouldShowFacetInstructions) {
+ const modifiersToString = (modifiers: Modifier[]) => {
+ return modifiers?.toString().replace(',', ' ');
+ };
+
+ const containerEl = modal.modalEl.createDiv('prompt-instructions');
+
+ // render the preamble
+ let instructionEl = containerEl.createDiv();
+ instructionEl.createSpan({
+ cls: 'prompt-instruction-command',
+ text: `filters | ${modifiersToString(facetSettings.modifiers)}`,
+ });
+
+ // render each key instruction
+ facetKeysInfo.forEach((facetKeyInfo) => {
+ const { facet, command, purpose } = facetKeyInfo;
+ let modifiers: Modifier[];
+ let key: string;
+ let activeCls: string[] = null;
+
+ if (facet) {
+ // Note: the command only contain the key, the modifiers has to be derived
+ key = command;
+ modifiers = facet.modifiers;
+
+ if (facet.isActive) {
+ activeCls = ['qsp-filter-active'];
+ }
+ } else {
+ // Note: only the reset key is expected to not have an associated facet
+ key = facetSettings.resetKey;
+ modifiers = facetSettings.resetModifiers;
+ }
+
+ // if a modifier is specified for this specific facet, it overrides the
+ // default modifier so display that too. Otherwise, just show the key alone
+ const commandDisplayText = modifiers
+ ? `(${modifiersToString(modifiers)}) ${key}`
+ : `${key}`;
+
+ instructionEl = containerEl.createDiv();
+ instructionEl.createSpan({
+ cls: 'prompt-instruction-command',
+ text: commandDisplayText,
+ });
+
+ instructionEl.createSpan({
+ cls: activeCls,
+ text: purpose,
+ });
+ });
+ }
+ }
+
useSelectedItem(evt: KeyboardEvent, _ctx: KeymapContext): boolean | void {
this.chooser.useSelectedItem(evt);
}
diff --git a/src/types/sharedTypes.ts b/src/types/sharedTypes.ts
index 7f4da61..f6dfc13 100644
--- a/src/types/sharedTypes.ts
+++ b/src/types/sharedTypes.ts
@@ -13,6 +13,7 @@ import {
Command,
SearchResult,
SectionCache,
+ Modifier,
} from 'obsidian';
import type { SuggestModal, StarredPluginItem } from 'obsidian';
import { PickKeys, WritableKeys } from 'ts-essentials';
@@ -234,6 +235,46 @@ export interface SourceInfo {
cursor?: EditorPosition;
}
+export interface Facet {
+ id: string;
+ mode: Mode;
+ label: string;
+ isActive: boolean;
+ isAvailable: boolean;
+ key?: string;
+ modifiers?: Modifier[];
+}
+
+export interface FacetSettingsData {
+ resetKey: string;
+ resetModifiers?: Modifier[];
+ keyList: string[];
+ modifiers: Modifier[];
+ facetList: Facet[];
+ shouldResetActiveFacets: boolean;
+ shouldShowFacetInstructions: boolean;
+}
+
+export interface SearchQuery {
+ hasSearchTerm: boolean;
+ prepQuery: PreparedQuery;
+}
+
+export interface SearchResultWithFallback {
+ matchType: MatchType;
+ match: SearchResult;
+ matchText?: string;
+}
+
+export type KeymapConfig = {
+ mode: Mode;
+ facets?: {
+ facetSettings: FacetSettingsData;
+ facetList: Facet[];
+ onToggleFacet: (facets: Facet[], isReset: boolean) => boolean;
+ };
+};
+
export interface SettingsData {
onOpenPreferNewTab: boolean;
alwaysNewTabForSymbols: boolean;
@@ -272,15 +313,5 @@ export interface SettingsData {
matchPriorityAdjustments: Record;
preserveCommandPaletteLastInput: boolean;
preserveQuickSwitcherLastInput: boolean;
-}
-
-export interface SearchQuery {
- hasSearchTerm: boolean;
- prepQuery: PreparedQuery;
-}
-
-export interface SearchResultWithFallback {
- matchType: MatchType;
- match: SearchResult;
- matchText?: string;
+ quickFilters: FacetSettingsData;
}
diff --git a/src/utils/__tests__/utils.test.ts b/src/utils/__tests__/utils.test.ts
index a9aedd3..e21b779 100644
--- a/src/utils/__tests__/utils.test.ts
+++ b/src/utils/__tests__/utils.test.ts
@@ -98,19 +98,14 @@ describe('utils', () => {
});
it('should log invalid regex strings to the console', () => {
- let wasLogged = false;
- const consoleLogSpy = jest
- .spyOn(console, 'log')
- .mockImplementation((message: string) => {
- if (message.startsWith('Switcher++: error creating RegExp from string')) {
- wasLogged = true;
- }
- });
+ const consoleLogSpy = jest.spyOn(console, 'log').mockReturnValueOnce();
matcherFnForRegExList(['*']); // invalid regex
- expect(consoleLogSpy).toHaveBeenCalled();
- expect(wasLogged).toBe(true);
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Switcher++: error creating RegExp from string:'),
+ expect.any(Error),
+ );
consoleLogSpy.mockRestore();
});
diff --git a/styles.css b/styles.css
index 8fe6e24..2aee966 100644
--- a/styles.css
+++ b/styles.css
@@ -3,6 +3,10 @@
--symbol-indent-padding: 12px;
}
+.qsp-filter-active {
+ color: var(--text-accent);
+}
+
/* suggestion file path icon */
.qsp-path-indicator {
margin-right: 4px;