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`. + +![quick filters gif](https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/master/demo/quick-filters.gif) + + ## 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;