Skip to content

Commit

Permalink
feat: launch Obsidian global search via hotkey #166
Browse files Browse the repository at this point in the history
Introduces a new hotkey will transfer the search
text from Switcher++ to the builtin Obsidian global
search UI and trigger a vault wide search. For
sourced modes, a "path operator" will be defined
to scope the global search to the sourced file.
  • Loading branch information
darlal committed Jan 20, 2025
1 parent ab93eea commit a6ac4bd
Show file tree
Hide file tree
Showing 10 changed files with 310 additions and 8 deletions.
8 changes: 8 additions & 0 deletions src/settings/__tests__/switcherPlusSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ function getDefaultSettingsData(): SettingsData {
openInDefaultAppKeys: { modifiers: ['Shift', 'Ctrl'], key: 'o' },
excludeFileExtensions: [],
},
fulltextSearch: {
isEnabled: true,
searchKeys: { modifiers: ['Mod', 'Shift'], key: 'f' },
},
};

return data;
Expand Down Expand Up @@ -316,6 +320,10 @@ function getTransientSettingsData(): SettingsData {
},
excludeFileExtensions: [],
},
fulltextSearch: {
isEnabled: chance.bool(),
searchKeys: { modifiers: chance.pickset(['Alt', 'Ctrl'], 1), key: chance.letter() },
},
};

return data;
Expand Down
13 changes: 13 additions & 0 deletions src/settings/switcherPlusSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getFacetMap } from './facetConstants';
import { merge } from 'ts-deepmerge';
import {
FacetSettingsData,
FulltextSearchConfig,
InsertLinkConfig,
MatchPriorityData,
MobileLauncherConfig,
Expand Down Expand Up @@ -160,6 +161,10 @@ export class SwitcherPlusSettings {
openInDefaultAppKeys: { modifiers: ['Shift', 'Ctrl'], key: 'o' },
excludeFileExtensions: [],
},
fulltextSearch: {
isEnabled: true,
searchKeys: { modifiers: ['Mod', 'Shift'], key: 'f' },
},
};
}

Expand Down Expand Up @@ -715,6 +720,14 @@ export class SwitcherPlusSettings {
this.data.openDefaultApp = value;
}

get fulltextSearch(): FulltextSearchConfig {
return this.data.fulltextSearch;
}

set fulltextSearch(value: FulltextSearchConfig) {
this.data.fulltextSearch = value;
}

constructor(private plugin: SwitcherPlusPlugin) {
this.data = SwitcherPlusSettings.defaults;
}
Expand Down
58 changes: 58 additions & 0 deletions src/switcherPlus/__tests__/modeHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,64 @@ describe('modeHandler', () => {
expect(result).toBe(expectedInput);
});

describe('retrieving input text for fulltext search', () => {
const mockKeymap = mock<SwitcherPlusKeymap>();
const mockChooser = mock<Chooser<AnySuggestion>>();
let sut: ModeHandler;

beforeAll(() => {
sut = new ModeHandler(mockApp, mockSettings, mockKeymap);
});

it('should return input text without mode escape char in standard mode', () => {
const input = `${escapeCmdCharTrigger}${editorTrigger}`;
const expectedInput = `${editorTrigger}`;

sut.updateSuggestions(input, mockChooser, null);

expect(sut.inputTextForFulltextSearch()).toMatchObject({
mode: Mode.Standard,
parsedInput: expectedInput,
});
});

test('In custom modes, it should return the filter text without the mode trigger string', () => {
const filterText = chance.word();
const input = `${headingsTrigger}${filterText}`;

sut.updateSuggestions(input, mockChooser, null);

expect(sut.inputTextForFulltextSearch()).toMatchObject({
mode: Mode.HeadingsList,
parsedInput: filterText,
});
});

test('In sourced modes, it should return the filter text along with the associated sourced file', () => {
const filterText = chance.word();
const input = `${symbolTrigger}${filterText}`;
const mockFile = new TFile();

const getActiveSuggestionSpy = jest
.spyOn(ModeHandler, 'getActiveSuggestion')
.mockReturnValueOnce(makeFileSuggestion(mockFile));

// Not necessary for this test
const getSuggestionSpy = jest.spyOn(sut, 'getSuggestions').mockReturnValueOnce();

sut.updateSuggestions(input, mockChooser, null);

expect(sut.inputTextForFulltextSearch()).toMatchObject({
mode: Mode.SymbolList,
parsedInput: filterText,
file: mockFile,
});

getActiveSuggestionSpy.mockRestore();
getSuggestionSpy.mockRestore();
});
});

describe('opening and closing the modal', () => {
const mockKeymap = mock<SwitcherPlusKeymap>();
let sut: ModeHandler;
Expand Down
70 changes: 68 additions & 2 deletions src/switcherPlus/__tests__/switcherPlusKeymap.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Chance } from 'chance';
import { SwitcherPlusSettings } from 'src/settings';
import { CustomKeymapInfo, SwitcherPlusKeymap } from 'src/switcherPlus';
import { generateMarkdownLink } from 'src/utils';
import { generateMarkdownLink, getSystemGlobalSearchInstance } from 'src/utils';
import { CommandHandler, Handler } from 'src/Handlers';
import {
MockProxy,
Expand Down Expand Up @@ -29,6 +29,8 @@ import {
CommandPalettePluginInstance,
renderResults,
Platform,
InternalPlugins,
GlobalSearchPluginInstance,
} from 'obsidian';
import {
AnySuggestion,
Expand All @@ -43,6 +45,7 @@ import {
SymbolType,
QuickOpenConfig,
FileSuggestion,
ModeDispatcher,
} from 'src/types';
import {
makeFileSuggestion,
Expand All @@ -56,6 +59,7 @@ jest.mock('src/utils', () => {
__esModule: true,
...jest.requireActual<typeof import('src/utils')>('src/utils'),
generateMarkdownLink: jest.fn(),
getSystemGlobalSearchInstance: jest.fn(),
};
});

Expand Down Expand Up @@ -87,10 +91,12 @@ describe('SwitcherPlusKeymap', () => {
createDiv: () => createInstructionsContainerElFn(),
});

const mockModal = mock<SwitcherPlus>({ modalEl: mockModalEl });
const mockExMode = mock<ModeDispatcher>();
const mockModal = mock<SwitcherPlus>({ modalEl: mockModalEl, exMode: mockExMode });

const mockApp = mock<App>({
workspace: mockWorkspace,
internalPlugins: mock<InternalPlugins>(),
});

describe('Platform specific properties', () => {
Expand Down Expand Up @@ -289,6 +295,66 @@ describe('SwitcherPlusKeymap', () => {
});
});

describe('Launching fulltext search', () => {
let sut: SwitcherPlusKeymap;
let mockGlobalSearchPluginInstance: MockProxy<GlobalSearchPluginInstance>;

const mockGetGlobalSearchPlugin = jest.mocked<typeof getSystemGlobalSearchInstance>(
getSystemGlobalSearchInstance,
);

beforeAll(() => {
sut = new SwitcherPlusKeymap(mockApp, mockScope, mockChooser, mockModal, config);

mockGlobalSearchPluginInstance = mock<GlobalSearchPluginInstance>();
mockGetGlobalSearchPlugin.mockReturnValue(mockGlobalSearchPluginInstance);
});

it('should register the hotkey to trigger fulltext search', () => {
mockReset(mockScope);

sut.registerFulltextSearchBindings(mockScope, config);

const { searchKeys } = config.fulltextSearch;
expect(mockScope.register).toHaveBeenCalledWith(
expect.arrayContaining(searchKeys.modifiers),
searchKeys.key,
expect.any(Function),
);
});

it('should trigger the system global search with the input text', () => {
const parsedInput = chance.word();
mockExMode.inputTextForFulltextSearch.mockReturnValueOnce({
mode: Mode.Standard,
parsedInput,
});

sut.LaunchSystemGlobalSearch(null, null);

expect(mockGlobalSearchPluginInstance.openGlobalSearch).toHaveBeenCalledWith(
parsedInput,
);
});

it('should trigger the system global search with input text and a file path operator when an associated sourced mode file is available', () => {
const file = new TFile();
const parsedInput = chance.word();
mockExMode.inputTextForFulltextSearch.mockReturnValueOnce({
mode: Mode.SymbolList,
parsedInput,
file,
});

sut.LaunchSystemGlobalSearch(null, null);

const expectedText = `path:"${file.path}" ${parsedInput}`;
expect(mockGlobalSearchPluginInstance.openGlobalSearch).toHaveBeenCalledWith(
expectedText,
);
});
});

describe('registerCloseWhenEmptyBindings', () => {
const config = new SwitcherPlusSettings(null);

Expand Down
9 changes: 7 additions & 2 deletions src/switcherPlus/inputInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export class InputInfo {
};
}

/**
* If it exists, returns a version of inputText that has been stripped of the
* custom mode escape command char. Otherwise, returns raw inputText.
*
* @type {string}
*/
get inputTextSansEscapeChar(): string {
return this._inputTextSansEscapeChar ?? this.inputText;
}
Expand All @@ -68,8 +74,7 @@ export class InputInfo {
this.sessionOpts = sessionOpts ?? {};

const sourcedModes = getSourcedModes();
const parsedCmds = {} as Record<Mode, ParsedCommand>;
this.parsedCommands = parsedCmds;
this.parsedCommands = {} as Record<Mode, ParsedCommand>;

// Initialize .parsedCommands with an object for each mode
getModeNames().forEach((modeName) => {
Expand Down
41 changes: 39 additions & 2 deletions src/switcherPlus/modeHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SwitcherPlusKeymap } from './switcherPlusKeymap';
import { InputInfo } from './inputInfo';
import { InputInfo, SourcedParsedCommand } from './inputInfo';
import { SwitcherPlusSettings } from 'src/settings';
import {
Handler,
Expand Down Expand Up @@ -425,6 +425,15 @@ export class ModeHandler implements ModeDispatcher {
}
}

/**
* Searches inputInfo, if cmdStr is found with a preceding escapeChar, then
* escapeCmdChar is stripped out. The result string is saved to .inputTextSansEscapeChar
*
* @param {InputInfo} inputInfo
* @param {string} escapeCmdChar
* @param {string} cmdStr
* @returns {string} InputText with escapeCmdChar stripped out
*/
removeEscapeCommandCharFromInput(
inputInfo: InputInfo,
escapeCmdChar: string,
Expand Down Expand Up @@ -581,7 +590,7 @@ export class ModeHandler implements ModeDispatcher {
}
}

private static getActiveSuggestion(chooser: Chooser<AnySuggestion>): AnySuggestion {
static getActiveSuggestion(chooser: Chooser<AnySuggestion>): AnySuggestion {
let activeSuggestion: AnySuggestion = null;

if (chooser?.values) {
Expand Down Expand Up @@ -702,6 +711,34 @@ export class ModeHandler implements ModeDispatcher {
return searchText;
}

inputTextForFulltextSearch(): {
mode: Mode;
parsedInput: string;
file?: TFile;
} {
const { inputInfo } = this;
const mode = inputInfo.mode;
let file: TFile = null;

// .inputTextSansEscapeChar holds a version of inputText that is
// suitable for Standard mode. This covers the case when the mode is Standard
// and inputText is needed for global search.
let parsedInput = inputInfo.inputTextSansEscapeChar;

if (mode !== Mode.Standard) {
// Custom modes contain the filtered text that can be retrieved directly
// from the ParsedCommand.
const cmd = inputInfo.parsedCommand();
parsedInput = cmd.parsedInput;

if (getSourcedModes().includes(mode)) {
file = (cmd as SourcedParsedCommand).source?.file;
}
}

return { mode, parsedInput, file };
}

addPropertiesToStandardSuggestions(
suggestions: AnySuggestion[],
options: {
Expand Down
Loading

0 comments on commit a6ac4bd

Please sign in to comment.