├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── demo ├── editor-mode.gif ├── edt-command.png ├── headings-command.png ├── headings-mode.gif ├── quick-filters.gif ├── symbol-mode.gif ├── symbol-outline.png └── symbolsearch.png ├── jest.config.js ├── manifest.json ├── package-lock.json ├── package.json ├── release.sh ├── rollup.config.js ├── src ├── Handlers │ ├── __tests__ │ │ ├── bookmarksHandler.test.ts │ │ ├── commandHandler.test.ts │ │ ├── editorHandler.test.ts │ │ ├── handler.test.ts │ │ ├── headingshandler.test.ts │ │ ├── relatedItemsHandler.test.ts │ │ ├── standardExHandler.test.ts │ │ ├── symbolHandler.test.ts │ │ ├── vaultHandler.test.ts │ │ └── workspaceHandler.test.ts │ ├── bookmarksHandler.ts │ ├── commandHandler.ts │ ├── editorHandler.ts │ ├── handler.ts │ ├── headingsHandler.ts │ ├── index.ts │ ├── relatedItemsHandler.ts │ ├── standardExHandler.ts │ ├── symbolHandler.ts │ ├── vaultHandler.ts │ └── workspaceHandler.ts ├── __fixtures__ │ ├── canvasFile.fixture.ts │ ├── editorFilter.fixture.ts │ ├── fileCachedMetadata.fixture.ts │ ├── fixtureUtils.ts │ ├── index.ts │ ├── inputText.fixture.ts │ ├── modeTrigger.fixture.ts │ └── suggestions.fixture.ts ├── __mocks__ │ └── obsidian │ │ ├── index.ts │ │ └── mockSetting.ts ├── main.ts ├── search │ ├── __tests__ │ │ └── searcher.test.ts │ ├── index.ts │ └── searcher.ts ├── settings │ ├── __tests__ │ │ ├── bookmarksSettingsTabSection.test.ts │ │ ├── commandListSettingsTabSection.test.ts │ │ ├── editorSettingsTabSection.test.ts │ │ ├── generalSettingsTabSection.test.ts │ │ ├── headingsSettingsTabSection.test.ts │ │ ├── relatedItemsSettingsTabSection.test.ts │ │ ├── settingsTabSection.test.ts │ │ ├── switcherPlusSettingTab.test.ts │ │ ├── switcherPlusSettings.test.ts │ │ ├── symbolSettingsTabSection.test.ts │ │ ├── vaultListSettingsTabSection.test.ts │ │ └── workspaceSettingsTabSection.test.ts │ ├── bookmarksSettingsTabSection.ts │ ├── commandListSettingsTabSection.ts │ ├── editorSettingsTabSection.ts │ ├── facetConstants.ts │ ├── generalSettingsTabSection.ts │ ├── headingsSettingsTabSection.ts │ ├── index.ts │ ├── relatedItemsSettingsTabSection.ts │ ├── settingsTabSection.ts │ ├── switcherPlusSettingTab.ts │ ├── switcherPlusSettings.ts │ ├── symbolSettingsTabSection.ts │ ├── vaultListSettingsTabSection.ts │ └── workspaceSettingsTabSection.ts ├── switcherPlus │ ├── __tests__ │ │ ├── mobileLauncher.test.ts │ │ ├── modeHandler.test.ts │ │ ├── switcherPlus.test.ts │ │ └── switcherPlusKeymap.test.ts │ ├── index.ts │ ├── inputInfo.ts │ ├── mobileLauncher.ts │ ├── modeHandler.ts │ ├── switcherPlus.ts │ └── switcherPlusKeymap.ts ├── types │ ├── index.ts │ ├── obsidian │ │ ├── canvas.d.ts │ │ └── index.d.ts │ └── sharedTypes.ts └── utils │ ├── __tests__ │ ├── frontMatterParser.test.ts │ └── utils.test.ts │ ├── componentManager.ts │ ├── frontMatterParser.ts │ ├── index.ts │ └── utils.ts ├── styles.css ├── tsconfig.json ├── tsconfig.prod.json └── versions.json /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | demo/ 3 | dist/ 4 | node_modules 5 | support/demo_template/sample.js 6 | benchmark/extra/ 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | }, 5 | parser: '@typescript-eslint/parser', 6 | parserOptions: { 7 | ecmaVersion: 2020, 8 | sourceType: 'module', 9 | project: './tsconfig.json', 10 | }, 11 | ignorePatterns: ['*.config.js', '.eslintrc.js', '.prettierrc.js'], 12 | plugins: ['@typescript-eslint', 'prettier'], 13 | extends: [ 14 | 'eslint:recommended', 15 | 'plugin:jest/recommended', 16 | 'plugin:@typescript-eslint/eslint-recommended', 17 | 'plugin:@typescript-eslint/recommended', 18 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 19 | 'plugin:prettier/recommended', 20 | ], 21 | overrides: [ 22 | { 23 | files: ['src/**/*.test.ts'], 24 | plugins: ['jest'], 25 | rules: { 26 | // you should turn the original rule off *only* for test files 27 | '@typescript-eslint/unbound-method': 'off', 28 | 'jest/unbound-method': 'error', 29 | }, 30 | }, 31 | ], 32 | rules: { 33 | '@typescript-eslint/unbound-method': 'error', 34 | 'no-unused-vars': 'off', 35 | '@typescript-eslint/no-unused-vars': [ 36 | 'error', 37 | { 38 | argsIgnorePattern: '^_', 39 | }, 40 | ], 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://paypal.me/darla', 'https://www.buymeacoffee.com/darlal'] 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | PLUGIN_NAME: darlal-switcher-plus 10 | 11 | jobs: 12 | build-publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout source 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 18 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Build 27 | run: npm run ci 28 | 29 | - name: Create zip distro 30 | run: | 31 | pushd ./dist > /dev/null 32 | zip -r dist.zip ${{ env.PLUGIN_NAME }} 33 | popd > /dev/null 34 | 35 | - name: Create release 36 | # https://github.com/ncipollo/release-action 37 | uses: ncipollo/release-action@v1 38 | with: 39 | token: ${{ secrets.GITHUB_TOKEN }} 40 | artifactErrorsFailBuild: true 41 | artifacts: "dist/dist.zip,dist/${{ env.PLUGIN_NAME }}/*.*" 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run checks 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request_target: 6 | types: [opened, reopened, synchronize] 7 | workflow_dispatch: 8 | inputs: 9 | pr_number: 10 | description: 'PR # from public fork' 11 | required: true 12 | type: number 13 | 14 | env: 15 | BUILD: production 16 | 17 | jobs: 18 | build_test: 19 | name: Build and test 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: write 24 | steps: 25 | - name: Checkout source 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | ref: | 30 | ${{ 31 | github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', inputs.pr_number) 32 | || github.event.pull_request.head.sha 33 | || github.ref 34 | }} 35 | 36 | - name: Setup node 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: 18 40 | cache: 'npm' 41 | 42 | - name: Install dependencies 43 | run: npm ci 44 | 45 | - name: Lint 46 | run: npm run lint 47 | 48 | - name: Bundle 49 | run: npm run bundle 50 | 51 | - name: Package 52 | run: npm run package-plugin 53 | 54 | - name: Test 55 | run: npm run test 56 | 57 | - name: Diff PR test coverage vs default branch 58 | if: github.event_name == 'pull_request_target' 59 | uses: anuraag016/Jest-Coverage-Diff@V1.4 60 | with: 61 | fullCoverageDiff: true 62 | accessToken: ${{ secrets.DIFF_ACTION_TOKEN }} 63 | afterSwitchCommand: "npm ci" 64 | delta: 0.5 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | dist/ 4 | coverage/ 5 | 6 | ### VisualStudioCode ### 7 | .vscode/* 8 | *.code-workspace 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 90, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /demo/editor-mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/369a9c6d4da5df4bd8cca260b86c315291d05565/demo/editor-mode.gif -------------------------------------------------------------------------------- /demo/edt-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/369a9c6d4da5df4bd8cca260b86c315291d05565/demo/edt-command.png -------------------------------------------------------------------------------- /demo/headings-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/369a9c6d4da5df4bd8cca260b86c315291d05565/demo/headings-command.png -------------------------------------------------------------------------------- /demo/headings-mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/369a9c6d4da5df4bd8cca260b86c315291d05565/demo/headings-mode.gif -------------------------------------------------------------------------------- /demo/quick-filters.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/369a9c6d4da5df4bd8cca260b86c315291d05565/demo/quick-filters.gif -------------------------------------------------------------------------------- /demo/symbol-mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/369a9c6d4da5df4bd8cca260b86c315291d05565/demo/symbol-mode.gif -------------------------------------------------------------------------------- /demo/symbol-outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/369a9c6d4da5df4bd8cca260b86c315291d05565/demo/symbol-outline.png -------------------------------------------------------------------------------- /demo/symbolsearch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darlal/obsidian-switcher-plus/369a9c6d4da5df4bd8cca260b86c315291d05565/demo/symbolsearch.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest'); 2 | const { compilerOptions } = require('./tsconfig'); 3 | 4 | module.exports = { 5 | preset: 'ts-jest', 6 | testEnvironment: 'node', 7 | globals: { 8 | window: {}, 9 | }, 10 | moduleFileExtensions: ['ts', 'js', 'jsx', 'tsx', 'json', 'node'], 11 | roots: ['/src/', 'node_modules'], 12 | modulePaths: ['', 'node_modules'], 13 | moduleDirectories: ['src', 'node_modules'], 14 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths), 15 | transform: { 16 | '^.+\\.js$': 'babel-jest', 17 | '^.+\\.(ts|tsx)$': 'ts-jest', 18 | }, 19 | transformIgnorePatterns: ['\\.pnp\\.[^\\/]+$'], 20 | collectCoverage: true, 21 | coverageThreshold: { 22 | global: { 23 | branches: 99, 24 | functions: 99, 25 | lines: 99, 26 | statements: 99, 27 | }, 28 | }, 29 | coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], 30 | modulePathIgnorePatterns: ['/dist/'], 31 | }; 32 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "darlal-switcher-plus", 3 | "name": "Quick Switcher++", 4 | "version": "5.1.0", 5 | "minAppVersion": "1.8.9", 6 | "description": "Enhanced Quick Switcher, search open panels, and symbols.", 7 | "author": "darlal", 8 | "authorUrl": "https://github.com/darlal/obsidian-switcher-plus", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-switcher-plus", 3 | "version": "5.1.0", 4 | "description": "Enhanced Quick Switcher plugin for Obsidian.md.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/darlal/obsidian-switcher-plus" 8 | }, 9 | "main": "./dist/darlal-switcher-plus/main.js", 10 | "scripts": { 11 | "bundle": "rollup --bundleConfigAsCjs -c rollup.config.js", 12 | "package-plugin": "copyfiles ./styles.css ./manifest.json ./versions.json ./dist/darlal-switcher-plus", 13 | "build": "run-s lint build:fast", 14 | "build:fast": "run-s bundle package-plugin", 15 | "build:watch": "onchange 'src/**/*.ts' -- npm run build:fast", 16 | "ci": "BUILD=production run-s lint test bundle package-plugin", 17 | "lint": "eslint \"**/*.{js,ts}\"", 18 | "test": "jest", 19 | "test:watch": "jest --watchAll", 20 | "test:cov": "serve coverage/lcov-report", 21 | "dev": "onchange 'src/**/*.ts' -- run-s test lint bundle package-plugin" 22 | }, 23 | "keywords": [ 24 | "obsidian", 25 | "obsidian-md", 26 | "obsidian-md-plugin" 27 | ], 28 | "author": "darlal", 29 | "license": "GPL-3.0-only", 30 | "devDependencies": { 31 | "@rollup/plugin-commonjs": "^28.0.1", 32 | "@rollup/plugin-node-resolve": "^15.3.0", 33 | "@rollup/plugin-terser": "^0.4.4", 34 | "@rollup/plugin-typescript": "^12.1.1", 35 | "@types/chance": "^1.1.6", 36 | "@types/jest": "^29.5.14", 37 | "@types/node": "^18.19.34", 38 | "@typescript-eslint/eslint-plugin": "^7.12.0", 39 | "@typescript-eslint/parser": "^7.12.0", 40 | "chance": "^1.1.12", 41 | "copyfiles": "^2.4.1", 42 | "electron": "^30.1.0", 43 | "eslint": "^8.56.0", 44 | "eslint-config-prettier": "^9.1.0", 45 | "eslint-plugin-import": "^2.29.1", 46 | "eslint-plugin-jest": "^28.6.0", 47 | "eslint-plugin-prettier": "^5.1.3", 48 | "jest": "^29.7.0", 49 | "jest-mock-extended": "^3.0.7", 50 | "npm-run-all": "^4.1.5", 51 | "obsidian": "^1.7.2", 52 | "onchange": "^7.1.0", 53 | "prettier": "^3.3.3", 54 | "rollup": "^4.24.0", 55 | "serve": "^14.2.4", 56 | "ts-essentials": "^10.0.2", 57 | "ts-jest": "^29.2.5", 58 | "tslib": "^2.6.3", 59 | "typescript": "^5.4.5" 60 | }, 61 | "dependencies": { 62 | "ts-deepmerge": "^7.0.1" 63 | } 64 | } -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | if [ "$#" -ne 2 ]; then 6 | echo "Must provide exactly two arguments." 7 | echo "First one must be the new version number." 8 | echo "Second one must be the minimum obsidian version for this release." 9 | echo "" 10 | echo "Example usage:" 11 | echo "./release.sh 0.3.0 0.11.13" 12 | echo "Exiting." 13 | 14 | exit 1 15 | fi 16 | 17 | if [[ $(git status --porcelain) ]]; then 18 | echo "Changes in the git repo." 19 | echo "Exiting." 20 | 21 | exit 1 22 | fi 23 | 24 | NEW_VERSION=$1 25 | MINIMUM_OBSIDIAN_VERSION=$2 26 | 27 | echo "Updating to version ${NEW_VERSION} with minimum obsidian version ${MINIMUM_OBSIDIAN_VERSION}" 28 | 29 | read -p "Continue? [y/N] " -n 1 -r 30 | echo 31 | if [[ $REPLY =~ ^[Yy]$ ]] 32 | then 33 | echo "Updating package.json" 34 | TEMP_FILE=$(mktemp) 35 | jq ".version |= \"${NEW_VERSION}\"" package.json > "$TEMP_FILE" || exit 1 36 | mv "$TEMP_FILE" package.json 37 | 38 | echo "Updating manifest.json" 39 | TEMP_FILE=$(mktemp) 40 | jq ".version |= \"${NEW_VERSION}\" | .minAppVersion |= \"${MINIMUM_OBSIDIAN_VERSION}\"" manifest.json > "$TEMP_FILE" || exit 1 41 | mv "$TEMP_FILE" manifest.json 42 | 43 | echo "Updating versions.json" 44 | TEMP_FILE=$(mktemp) 45 | jq ". += {\"${NEW_VERSION}\": \"${MINIMUM_OBSIDIAN_VERSION}\"}" versions.json > "$TEMP_FILE" || exit 1 46 | mv "$TEMP_FILE" versions.json 47 | 48 | read -p "Create git commit, tag, and push? [y/N] " -n 1 -r 49 | echo 50 | if [[ $REPLY =~ ^[Yy]$ ]] 51 | then 52 | 53 | git add -A . 54 | git commit -m"build: version bump ${NEW_VERSION}" 55 | git tag "${NEW_VERSION}" 56 | git push 57 | git push --tags 58 | fi 59 | else 60 | echo "Exiting." 61 | exit 1 62 | fi 63 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import terser from '@rollup/plugin-terser'; 4 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 5 | 6 | const isProd = process.env.BUILD === 'production'; 7 | const tsconfig = isProd ? './tsconfig.prod.json' : './tsconfig.json' 8 | 9 | export default { 10 | input: 'src/main.ts', 11 | treeshake: false, 12 | output: { 13 | dir: 'dist/darlal-switcher-plus', 14 | sourcemap: isProd ? false : 'inline', 15 | sourcemapExcludeSources: isProd, 16 | format: 'cjs', 17 | exports: 'default', 18 | }, 19 | external: ['obsidian', 'electron'], 20 | plugins: [ 21 | typescript({ tsconfig }), 22 | nodeResolve(), 23 | commonjs(), 24 | isProd && terser({ 25 | format: { 26 | comments: false 27 | }, 28 | sourceMap: false, 29 | compress: true 30 | }) 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /src/Handlers/__tests__/standardExHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { BookmarksItemInfo, MatchType } from 'src/types'; 2 | import { InputInfo } from 'src/switcherPlus'; 3 | import { SwitcherPlusSettings } from 'src/settings'; 4 | import { Handler, StandardExHandler } from 'src/Handlers'; 5 | import { mock, MockProxy } from 'jest-mock-extended'; 6 | import { App, TFile } from 'obsidian'; 7 | import { makeAliasSuggestion, makeFileSuggestion } from '@fixtures'; 8 | import { Chance } from 'chance'; 9 | 10 | const chance = new Chance(); 11 | 12 | describe('standardExHandler', () => { 13 | let settings: SwitcherPlusSettings; 14 | let mockApp: MockProxy; 15 | let sut: StandardExHandler; 16 | 17 | beforeAll(() => { 18 | mockApp = mock(); 19 | settings = new SwitcherPlusSettings(null); 20 | sut = new StandardExHandler(mockApp, settings); 21 | }); 22 | 23 | describe('getCommandString', () => { 24 | it('should return empty string', () => { 25 | expect(sut.getCommandString()).toBe(''); 26 | }); 27 | }); 28 | 29 | test('validateCommand should throw', () => { 30 | expect(() => sut.validateCommand(null, 0, '', null, null)).toThrow( 31 | 'Method not implemented.', 32 | ); 33 | }); 34 | 35 | test('getSuggestions should throw', () => { 36 | expect(() => sut.getSuggestions(null)).toThrow('Method not implemented.'); 37 | }); 38 | 39 | describe('renderSuggestion', () => { 40 | it('should not throw an error with a null suggestion', () => { 41 | expect(() => sut.renderSuggestion(null, null)).not.toThrow(); 42 | }); 43 | 44 | it('should render a suggestion with match offsets', () => { 45 | const mockFile = new TFile(); 46 | const sugg = makeFileSuggestion(mockFile); 47 | const mockParentEl = mock(); 48 | const renderAsFileInfoPanelSpy = jest 49 | .spyOn(Handler.prototype, 'renderAsFileInfoPanel') 50 | .mockReturnValueOnce(null); 51 | 52 | sut.renderSuggestion(sugg, mockParentEl); 53 | 54 | expect(renderAsFileInfoPanelSpy).toHaveBeenCalledWith( 55 | mockParentEl, 56 | ['qsp-suggestion-file'], 57 | null, 58 | sugg.file, 59 | sugg.matchType, 60 | sugg.match, 61 | ); 62 | 63 | renderAsFileInfoPanelSpy.mockRestore(); 64 | }); 65 | 66 | it('should add a class for downranked suggestions', () => { 67 | const mockFile = new TFile(); 68 | const alias = chance.word(); 69 | const sugg = makeAliasSuggestion(mockFile, alias); 70 | sugg.downranked = true; 71 | 72 | const mockParentEl = mock(); 73 | const renderAsFileInfoPanelSpy = jest 74 | .spyOn(Handler.prototype, 'renderAsFileInfoPanel') 75 | .mockReturnValueOnce(null); 76 | 77 | sut.renderSuggestion(sugg, mockParentEl); 78 | 79 | expect(mockParentEl.addClass).toHaveBeenCalledWith('mod-downranked'); 80 | expect(renderAsFileInfoPanelSpy).toHaveBeenCalledWith( 81 | mockParentEl, 82 | ['qsp-suggestion-alias'], 83 | alias, 84 | sugg.file, 85 | sugg.matchType, 86 | sugg.match, 87 | false, 88 | ); 89 | 90 | renderAsFileInfoPanelSpy.mockRestore(); 91 | }); 92 | }); 93 | 94 | describe('onChooseSuggestion', () => { 95 | it('should not throw an error with a null suggestion', () => { 96 | expect(() => sut.onChooseSuggestion(null, null)).not.toThrow(); 97 | }); 98 | 99 | it('should navigate to the target file', () => { 100 | const mockEvt = mock(); 101 | const navigateToLeafOrOpenFileSpy = jest 102 | .spyOn(Handler.prototype, 'navigateToLeafOrOpenFile') 103 | .mockImplementation(); 104 | 105 | const sugg = makeFileSuggestion(); 106 | 107 | sut.onChooseSuggestion(sugg, mockEvt); 108 | 109 | expect(navigateToLeafOrOpenFileSpy).toHaveBeenCalledWith( 110 | mockEvt, 111 | sugg.file, 112 | expect.any(String), 113 | ); 114 | 115 | navigateToLeafOrOpenFileSpy.mockRestore(); 116 | }); 117 | }); 118 | 119 | describe('addPropertiesToStandardSuggestions', () => { 120 | const mockFile = new TFile(); 121 | const inputInfo = new InputInfo(); 122 | inputInfo.currentWorkspaceEnvList.openWorkspaceFiles = new Set([mockFile]); 123 | inputInfo.currentWorkspaceEnvList.mostRecentFiles = new Set([mockFile]); 124 | inputInfo.currentWorkspaceEnvList.fileBookmarks = new Map( 125 | [[mockFile, []]], 126 | ); 127 | 128 | it('should set extra properties on alias suggestions', () => { 129 | const sugg = makeAliasSuggestion(mockFile); 130 | 131 | sut.addPropertiesToStandardSuggestions(inputInfo.currentWorkspaceEnvList, sugg); 132 | 133 | expect(sugg).toMatchObject({ 134 | ...sugg, 135 | matchType: MatchType.Primary, 136 | matchText: sugg.alias, 137 | isOpenInEditor: true, 138 | isRecent: true, 139 | isBookmarked: true, 140 | }); 141 | }); 142 | 143 | it('should set extra properties on file suggestions', () => { 144 | const sugg = makeFileSuggestion(mockFile); 145 | 146 | sut.addPropertiesToStandardSuggestions(inputInfo.currentWorkspaceEnvList, sugg); 147 | 148 | expect(sugg).toMatchObject({ 149 | ...sugg, 150 | matchType: MatchType.Path, 151 | matchText: mockFile.path, 152 | isOpenInEditor: true, 153 | isRecent: true, 154 | isBookmarked: true, 155 | }); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/Handlers/__tests__/workspaceHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { Mode, WorkspaceSuggestion, SuggestionType, SearchQuery } from 'src/types'; 2 | import { InputInfo } from 'src/switcherPlus'; 3 | import { Handler, WorkspaceHandler, WORKSPACE_PLUGIN_ID } from 'src/Handlers'; 4 | import { SwitcherPlusSettings } from 'src/settings/switcherPlusSettings'; 5 | import { 6 | App, 7 | InstalledPlugin, 8 | InternalPlugins, 9 | WorkspacesPluginInstance, 10 | } from 'obsidian'; 11 | import { makeFuzzyMatch, workspaceTrigger, makeWorkspaceSuggestion } from '@fixtures'; 12 | import { mock, MockProxy } from 'jest-mock-extended'; 13 | import { Searcher } from 'src/search'; 14 | 15 | function makeWorkspacesPluginInstall(): MockProxy { 16 | const mockInstance = mock({ 17 | id: WORKSPACE_PLUGIN_ID, 18 | workspaces: { 19 | 'first workspace': {}, 20 | 'second workspace': {}, 21 | }, 22 | }); 23 | 24 | return mock({ 25 | enabled: true, 26 | instance: mockInstance, 27 | }); 28 | } 29 | 30 | function makeInternalPluginList( 31 | workspacePlugin: MockProxy, 32 | ): MockProxy { 33 | const mockPlugins = mock>({ 34 | workspaces: workspacePlugin, 35 | }); 36 | 37 | const mockInternalPlugins = mock({ plugins: mockPlugins }); 38 | 39 | mockInternalPlugins.getEnabledPluginById 40 | .calledWith(WORKSPACE_PLUGIN_ID) 41 | .mockReturnValue(mockPlugins[WORKSPACE_PLUGIN_ID].instance); 42 | 43 | return mockInternalPlugins; 44 | } 45 | 46 | describe('workspaceHandler', () => { 47 | let settings: SwitcherPlusSettings; 48 | let mockApp: MockProxy; 49 | let mockInternalPlugins: MockProxy; 50 | let mockWsPluginInstance: MockProxy; 51 | let sut: WorkspaceHandler; 52 | let expectedWorkspaceIds: string[]; 53 | let suggestionInstance: WorkspaceSuggestion; 54 | 55 | beforeAll(() => { 56 | const workspacePluginInstall = makeWorkspacesPluginInstall(); 57 | mockWsPluginInstance = 58 | workspacePluginInstall.instance as MockProxy; 59 | 60 | mockInternalPlugins = makeInternalPluginList(workspacePluginInstall); 61 | mockApp = mock({ 62 | internalPlugins: mockInternalPlugins, 63 | }); 64 | 65 | settings = new SwitcherPlusSettings(null); 66 | jest.spyOn(settings, 'workspaceListCommand', 'get').mockReturnValue(workspaceTrigger); 67 | 68 | expectedWorkspaceIds = Object.keys(mockWsPluginInstance.workspaces); 69 | suggestionInstance = makeWorkspaceSuggestion(expectedWorkspaceIds[0]); 70 | 71 | sut = new WorkspaceHandler(mockApp, settings); 72 | }); 73 | 74 | it('should create a new workspace when the no result action is triggered', () => { 75 | const mockEvt = mock(); 76 | const name = 'new workspace'; 77 | const inputInfo = new InputInfo(name, Mode.WorkspaceList); 78 | inputInfo.parsedCommand(Mode.WorkspaceList).parsedInput = name; 79 | 80 | sut.onNoResultsCreateAction(inputInfo, mockEvt); 81 | 82 | expect(mockWsPluginInstance.saveWorkspace).toHaveBeenCalledWith(name); 83 | expect(mockWsPluginInstance.setActiveWorkspace).toHaveBeenCalledWith(name); 84 | }); 85 | 86 | describe('getCommandString', () => { 87 | it('should return workspaceListCommand trigger', () => { 88 | expect(sut.getCommandString()).toBe(workspaceTrigger); 89 | }); 90 | }); 91 | 92 | describe('validateCommand', () => { 93 | let inputText: string; 94 | let startIndex: number; 95 | const filterText = 'foo'; 96 | 97 | beforeAll(() => { 98 | inputText = `${workspaceTrigger}${filterText}`; 99 | startIndex = workspaceTrigger.length; 100 | }); 101 | 102 | it('should validate parsed input with workspace plugin enabled', () => { 103 | const inputInfo = new InputInfo(inputText); 104 | 105 | sut.validateCommand(inputInfo, startIndex, filterText, null, null); 106 | expect(inputInfo.mode).toBe(Mode.WorkspaceList); 107 | 108 | const workspaceCmd = inputInfo.parsedCommand(); 109 | expect(workspaceCmd.parsedInput).toBe(filterText); 110 | expect(workspaceCmd.isValidated).toBe(true); 111 | expect(mockApp.internalPlugins.getEnabledPluginById).toHaveBeenCalledWith( 112 | WORKSPACE_PLUGIN_ID, 113 | ); 114 | }); 115 | 116 | it('should not validate parsed input with workspace plugin disabled', () => { 117 | mockInternalPlugins.getEnabledPluginById.mockReturnValueOnce(null); 118 | 119 | const inputInfo = new InputInfo(inputText); 120 | 121 | sut.validateCommand(inputInfo, startIndex, filterText, null, null); 122 | expect(inputInfo.mode).toBe(Mode.Standard); 123 | 124 | const workspaceCmd = inputInfo.parsedCommand(); 125 | expect(workspaceCmd.parsedInput).toBe(null); 126 | expect(workspaceCmd.isValidated).toBe(false); 127 | expect(mockInternalPlugins.getEnabledPluginById).toHaveBeenCalledWith( 128 | WORKSPACE_PLUGIN_ID, 129 | ); 130 | }); 131 | }); 132 | 133 | describe('getSuggestions', () => { 134 | test('with falsy input, it should return an empty array', () => { 135 | const results = sut.getSuggestions(null); 136 | 137 | expect(results).not.toBeNull(); 138 | expect(results).toBeInstanceOf(Array); 139 | expect(results).toHaveLength(0); 140 | }); 141 | 142 | test('with default settings, it should return suggestions for workspace mode', () => { 143 | const inputInfo = new InputInfo(workspaceTrigger); 144 | const results = sut.getSuggestions(inputInfo); 145 | 146 | expect(results).not.toBeNull(); 147 | expect(results).toBeInstanceOf(Array); 148 | 149 | const resultWorkspaceIds = new Set(results.map((sugg) => sugg.item.id)); 150 | 151 | expect(results).toHaveLength(expectedWorkspaceIds.length); 152 | expect(expectedWorkspaceIds.every((id) => resultWorkspaceIds.has(id))).toBe(true); 153 | expect(results.every((sugg) => sugg.type === SuggestionType.WorkspaceList)).toBe( 154 | true, 155 | ); 156 | expect(mockInternalPlugins.getEnabledPluginById).toHaveBeenCalledWith( 157 | WORKSPACE_PLUGIN_ID, 158 | ); 159 | }); 160 | 161 | test('with filter search term, it should return only matching suggestions for workspace mode', () => { 162 | const filterText = 'first'; 163 | const inputInfo = new InputInfo(null, Mode.CommandList); 164 | const parsedInputQuerySpy = jest 165 | .spyOn(inputInfo, 'parsedInputQuery', 'get') 166 | .mockReturnValue(mock({ hasSearchTerm: true, query: null })); 167 | 168 | const searchSpy = jest 169 | .spyOn(Searcher.prototype, 'executeSearch') 170 | .mockImplementation((text) => { 171 | return text.startsWith(filterText) ? makeFuzzyMatch() : null; 172 | }); 173 | 174 | const results = sut.getSuggestions(inputInfo); 175 | 176 | expect(results).toHaveLength(1); 177 | expect(results[0]).toHaveProperty('type', SuggestionType.WorkspaceList); 178 | expect(results[0].item.id).toBe(expectedWorkspaceIds[0]); 179 | expect(mockInternalPlugins.getEnabledPluginById).toHaveBeenCalled(); 180 | 181 | searchSpy.mockRestore(); 182 | parsedInputQuerySpy.mockRestore(); 183 | }); 184 | }); 185 | 186 | describe('renderSuggestion', () => { 187 | it('should not throw an error with a null suggestion', () => { 188 | expect(() => sut.renderSuggestion(null, null)).not.toThrow(); 189 | }); 190 | 191 | it('should render a suggestion with match offsets', () => { 192 | const renderContentSpy = jest.spyOn(Handler.prototype, 'renderContent'); 193 | const mockParentEl = mock(); 194 | mockParentEl.createDiv.mockReturnValue(mock()); 195 | 196 | sut.renderSuggestion(suggestionInstance, mockParentEl); 197 | 198 | const { 199 | item: { id }, 200 | match, 201 | } = suggestionInstance; 202 | 203 | expect(renderContentSpy).toHaveBeenCalledWith(mockParentEl, id, match); 204 | expect(mockParentEl.addClasses).toHaveBeenCalledWith( 205 | expect.arrayContaining(['mod-complex', 'qsp-suggestion-workspace']), 206 | ); 207 | 208 | renderContentSpy.mockRestore(); 209 | }); 210 | }); 211 | 212 | describe('onChooseSuggestion', () => { 213 | it('should not throw an error with a null suggestion', () => { 214 | expect(() => sut.onChooseSuggestion(null, null)).not.toThrow(); 215 | }); 216 | 217 | it('should tell the workspaces plugin to load the workspace with the chosen ID', () => { 218 | sut.onChooseSuggestion(suggestionInstance, null); 219 | 220 | expect(mockInternalPlugins.getEnabledPluginById).toHaveBeenCalled(); 221 | expect(mockWsPluginInstance.loadWorkspace).toHaveBeenCalledWith( 222 | suggestionInstance.item.id, 223 | ); 224 | }); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /src/Handlers/bookmarksHandler.ts: -------------------------------------------------------------------------------- 1 | import { getInternalEnabledPluginById, isOfType } from 'src/utils'; 2 | import { Searcher } from 'src/search'; 3 | import { InputInfo, ParsedCommand, WorkspaceEnvList } from 'src/switcherPlus'; 4 | import { 5 | AnySuggestion, 6 | BookmarksItemInfo, 7 | BookmarksSuggestion, 8 | MatchType, 9 | Mode, 10 | SearchResultWithFallback, 11 | SessionOpts, 12 | SuggestionType, 13 | TitleSource, 14 | } from 'src/types'; 15 | import { 16 | sortSearchResults, 17 | WorkspaceLeaf, 18 | TFile, 19 | BookmarksPluginInstance, 20 | BookmarksPluginItem, 21 | BookmarksPluginFileItem, 22 | BookmarksPluginGroupItem, 23 | MetadataCache, 24 | } from 'obsidian'; 25 | import { Handler } from './handler'; 26 | import { BOOKMARKS_FACET_ID_MAP, SwitcherPlusSettings } from 'src/settings'; 27 | 28 | export const BOOKMARKS_PLUGIN_ID = 'bookmarks'; 29 | 30 | export class BookmarksHandler extends Handler { 31 | getCommandString(_sessionOpts?: SessionOpts): string { 32 | return this.settings?.bookmarksListCommand; 33 | } 34 | 35 | validateCommand( 36 | inputInfo: InputInfo, 37 | index: number, 38 | filterText: string, 39 | _activeSuggestion: AnySuggestion, 40 | _activeLeaf: WorkspaceLeaf, 41 | ): ParsedCommand { 42 | const cmd = inputInfo.parsedCommand(Mode.BookmarksList); 43 | 44 | if (this.getEnabledBookmarksPluginInstance()) { 45 | inputInfo.mode = Mode.BookmarksList; 46 | 47 | cmd.index = index; 48 | cmd.parsedInput = filterText; 49 | cmd.isValidated = true; 50 | } 51 | 52 | return cmd; 53 | } 54 | 55 | getSuggestions(inputInfo: InputInfo): BookmarksSuggestion[] { 56 | const suggestions: BookmarksSuggestion[] = []; 57 | 58 | if (inputInfo) { 59 | const { query, hasSearchTerm } = inputInfo.parsedInputQuery; 60 | const searcher = Searcher.create(query); 61 | const { allBookmarks } = this.getItems(inputInfo); 62 | 63 | allBookmarks.forEach((info) => { 64 | let shouldPush = true; 65 | let result: SearchResultWithFallback = { matchType: MatchType.None, match: null }; 66 | 67 | if (hasSearchTerm) { 68 | result = searcher.searchWithFallback(info.bookmarkPath); 69 | shouldPush = result.matchType !== MatchType.None; 70 | } 71 | 72 | if (shouldPush) { 73 | suggestions.push( 74 | this.createSuggestion(inputInfo.currentWorkspaceEnvList, info, result), 75 | ); 76 | } 77 | }); 78 | 79 | if (hasSearchTerm) { 80 | sortSearchResults(suggestions); 81 | } 82 | } 83 | 84 | return suggestions; 85 | } 86 | 87 | renderSuggestion(_sugg: BookmarksSuggestion, _parentEl: HTMLElement): boolean { 88 | return false; 89 | } 90 | 91 | onChooseSuggestion( 92 | sugg: BookmarksSuggestion, 93 | evt: MouseEvent | KeyboardEvent, 94 | ): boolean { 95 | let handled = false; 96 | if (BookmarksHandler.isBookmarksPluginFileItem(sugg?.item)) { 97 | const { file } = sugg; 98 | 99 | this.navigateToLeafOrOpenFile( 100 | evt, 101 | file, 102 | `Unable to open file from BookmarkSuggestion ${file?.path}`, 103 | ); 104 | 105 | handled = true; 106 | } 107 | 108 | return handled; 109 | } 110 | 111 | getPreferredTitle( 112 | pluginInstance: BookmarksPluginInstance, 113 | bookmark: BookmarksPluginItem, 114 | file: TFile, 115 | titleSource: TitleSource, 116 | ): string { 117 | let text = pluginInstance.getItemTitle(bookmark); 118 | 119 | if (titleSource === 'H1' && file) { 120 | const h1 = this.getFirstH1(file); 121 | 122 | if (h1) { 123 | // the "#" represents the start of a heading deep link, 124 | // "#^" represents the the start of a deep block link, 125 | // so everything before "#" should represent the filename that 126 | // needs to be replaced with the file title 127 | text = text.replace(/^[^#]*/, h1.heading); 128 | } 129 | } 130 | 131 | return text; 132 | } 133 | 134 | getItems(inputInfo: InputInfo | null): { 135 | allBookmarks: BookmarksItemInfo[]; 136 | fileBookmarks: Map; 137 | nonFileBookmarks: Set; 138 | } { 139 | const allBookmarks: BookmarksItemInfo[] = []; 140 | const fileBookmarks = new Map(); 141 | const nonFileBookmarks = new Set(); 142 | const pluginInstance = this.getEnabledBookmarksPluginInstance(); 143 | 144 | if (pluginInstance) { 145 | // if inputInfo is not supplied, then all items are expected (disregard facets), so use 146 | // and empty facet list 147 | const activeFacetIds = inputInfo 148 | ? this.getActiveFacetIds(inputInfo) 149 | : new Set(); 150 | 151 | const traverseBookmarks = (bookmarks: BookmarksPluginItem[], path: string) => { 152 | bookmarks?.forEach((bookmark) => { 153 | if (BookmarksHandler.isBookmarksPluginGroupItem(bookmark)) { 154 | traverseBookmarks(bookmark.items, `${path}${bookmark.title}/`); 155 | } else if ( 156 | this.isFacetedWith(activeFacetIds, BOOKMARKS_FACET_ID_MAP[bookmark.type]) 157 | ) { 158 | let bookmarkInfo: BookmarksItemInfo; 159 | 160 | if (BookmarksHandler.isBookmarksPluginFileItem(bookmark)) { 161 | const file = this.getTFileByPath(bookmark.path); 162 | 163 | // When a file is bookmarked and then deleted. The bookmark data is still 164 | // retained, this allows for the bookmark to be restore if the file is restored. 165 | // So if the source file for a file bookmark data cannot be found, then it 166 | // should not be added to the bookmarks list. 167 | if (file) { 168 | bookmarkInfo = { item: bookmark, bookmarkPath: null, file }; 169 | 170 | const infoList = fileBookmarks.get(file) ?? []; 171 | infoList.push(bookmarkInfo); 172 | fileBookmarks.set(file, infoList); 173 | } 174 | } else { 175 | bookmarkInfo = { item: bookmark, bookmarkPath: null, file: null }; 176 | nonFileBookmarks.add(bookmarkInfo); 177 | } 178 | 179 | if (bookmarkInfo) { 180 | const title = this.getPreferredTitle( 181 | pluginInstance, 182 | bookmark, 183 | bookmarkInfo.file, 184 | this.settings.preferredSourceForTitle, 185 | ); 186 | 187 | bookmarkInfo.bookmarkPath = path + title; 188 | allBookmarks.push(bookmarkInfo); 189 | } 190 | } 191 | }); 192 | }; 193 | 194 | traverseBookmarks(pluginInstance.items, ''); 195 | } 196 | 197 | return { allBookmarks, fileBookmarks, nonFileBookmarks }; 198 | } 199 | 200 | getEnabledBookmarksPluginInstance(): BookmarksPluginInstance { 201 | return getInternalEnabledPluginById( 202 | this.app, 203 | BOOKMARKS_PLUGIN_ID, 204 | ) as BookmarksPluginInstance; 205 | } 206 | 207 | createSuggestion( 208 | currentWorkspaceEnvList: WorkspaceEnvList, 209 | bookmarkInfo: BookmarksItemInfo, 210 | result: SearchResultWithFallback, 211 | ): BookmarksSuggestion { 212 | return BookmarksHandler.createSuggestion( 213 | currentWorkspaceEnvList, 214 | bookmarkInfo, 215 | this.settings, 216 | this.app.metadataCache, 217 | result, 218 | ); 219 | } 220 | 221 | static createSuggestion( 222 | currentWorkspaceEnvList: WorkspaceEnvList, 223 | bookmarkInfo: BookmarksItemInfo, 224 | settings: SwitcherPlusSettings, 225 | metadataCache: MetadataCache, 226 | result: SearchResultWithFallback, 227 | ): BookmarksSuggestion { 228 | let sugg: BookmarksSuggestion = { 229 | type: SuggestionType.Bookmark, 230 | item: bookmarkInfo.item, 231 | bookmarkPath: bookmarkInfo.bookmarkPath, 232 | file: bookmarkInfo.file, 233 | ...result, 234 | }; 235 | 236 | sugg = Handler.updateWorkspaceEnvListStatus(currentWorkspaceEnvList, sugg); 237 | return Handler.applyMatchPriorityPreferences(sugg, settings, metadataCache); 238 | } 239 | 240 | static isBookmarksPluginFileItem(obj: unknown): obj is BookmarksPluginFileItem { 241 | return isOfType(obj, 'type', 'file'); 242 | } 243 | 244 | static isBookmarksPluginGroupItem(obj: unknown): obj is BookmarksPluginGroupItem { 245 | return isOfType(obj, 'type', 'group'); 246 | } 247 | 248 | /** 249 | * Injects suggestions generated by the core switcher in Standard mode with 250 | * additional properties to enable custom functionality. 251 | * 252 | * @param {WorkspaceEnvList} workspaceEnvList 253 | * @param {BookmarksSuggestion} sugg 254 | */ 255 | addPropertiesToStandardSuggestions( 256 | workspaceEnvList: WorkspaceEnvList, 257 | sugg: BookmarksSuggestion, 258 | ): void { 259 | const { match, item } = sugg; 260 | let matchType = MatchType.None; 261 | let matchText = null; 262 | 263 | if (!sugg.file && BookmarksHandler.isBookmarksPluginFileItem(item)) { 264 | // Materialize file property for file bookmarks because the core switcher does 265 | // not provide the file for Bookmark suggestions (unlike Alias/File suggestions) 266 | const filePath = item.path; 267 | sugg.file = this.getTFileByPath(filePath); 268 | } 269 | 270 | if (match?.matches) { 271 | matchType = MatchType.Primary; 272 | matchText = sugg.bookmarkPath; 273 | } 274 | 275 | sugg.matchType = matchType; 276 | sugg.matchText = matchText; 277 | 278 | // patch with missing properties required for enhanced custom rendering 279 | Handler.updateWorkspaceEnvListStatus(workspaceEnvList, sugg); 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/Handlers/commandHandler.ts: -------------------------------------------------------------------------------- 1 | import { getInternalEnabledPluginById } from 'src/utils'; 2 | import { Searcher } from 'src/search'; 3 | import { InputInfo, ParsedCommand } from 'src/switcherPlus'; 4 | import { CommandListFacetIds } from 'src/settings'; 5 | import { 6 | AnySuggestion, 7 | Mode, 8 | CommandSuggestion, 9 | SuggestionType, 10 | SessionOpts, 11 | } from 'src/types'; 12 | import { Handler } from './handler'; 13 | import { 14 | SearchResult, 15 | sortSearchResults, 16 | WorkspaceLeaf, 17 | CommandPalettePluginInstance, 18 | Command, 19 | App, 20 | } from 'obsidian'; 21 | 22 | export const COMMAND_PALETTE_PLUGIN_ID = 'command-palette'; 23 | export type CommandInfo = { cmd: Command; isPinned: boolean; isRecent: boolean }; 24 | 25 | const RECENTLY_USED_COMMAND_IDS: string[] = []; 26 | 27 | export class CommandHandler extends Handler { 28 | getCommandString(_sessionOpts?: SessionOpts): string { 29 | return this.settings?.commandListCommand; 30 | } 31 | 32 | validateCommand( 33 | inputInfo: InputInfo, 34 | index: number, 35 | filterText: string, 36 | _activeSuggestion: AnySuggestion, 37 | _activeLeaf: WorkspaceLeaf, 38 | ): ParsedCommand { 39 | const cmd = inputInfo.parsedCommand(Mode.CommandList); 40 | 41 | if (this.getEnabledCommandPalettePluginInstance()) { 42 | inputInfo.mode = Mode.CommandList; 43 | 44 | cmd.index = index; 45 | cmd.parsedInput = filterText; 46 | cmd.isValidated = true; 47 | } 48 | 49 | return cmd; 50 | } 51 | 52 | getSuggestions(inputInfo: InputInfo): CommandSuggestion[] { 53 | const suggestions: CommandSuggestion[] = []; 54 | 55 | if (inputInfo) { 56 | const { query, hasSearchTerm } = inputInfo.parsedInputQuery; 57 | const searcher = Searcher.create(query); 58 | const itemsInfo = this.getItems(inputInfo, hasSearchTerm); 59 | 60 | itemsInfo.forEach((info) => { 61 | let shouldPush = true; 62 | let match: SearchResult = null; 63 | 64 | if (hasSearchTerm) { 65 | ({ match } = searcher.searchWithFallback(info.cmd.name)); 66 | shouldPush = !!match; 67 | } 68 | 69 | if (shouldPush) { 70 | suggestions.push(this.createSuggestion(info, match)); 71 | } 72 | }); 73 | 74 | if (hasSearchTerm) { 75 | sortSearchResults(suggestions); 76 | } 77 | } 78 | 79 | return suggestions; 80 | } 81 | 82 | renderSuggestion(sugg: CommandSuggestion, parentEl: HTMLElement): boolean { 83 | let handled = false; 84 | if (sugg) { 85 | const { item, match, isPinned, isRecent } = sugg; 86 | this.addClassesToSuggestionContainer(parentEl, ['qsp-suggestion-command']); 87 | this.renderContent(parentEl, item.name, match); 88 | 89 | const flairContainerEl = this.createFlairContainer(parentEl); 90 | this.renderHotkeyForCommand(item.id, this.app, flairContainerEl); 91 | 92 | if (item.icon) { 93 | this.renderIndicator(flairContainerEl, [], item.icon); 94 | } 95 | 96 | if (isPinned) { 97 | this.renderIndicator(flairContainerEl, [], 'filled-pin'); 98 | } else if (isRecent) { 99 | this.renderOptionalIndicators(parentEl, sugg, flairContainerEl); 100 | } 101 | 102 | handled = true; 103 | } 104 | 105 | return handled; 106 | } 107 | 108 | renderHotkeyForCommand(id: string, app: App, flairContainerEl: HTMLElement): void { 109 | try { 110 | const { hotkeyManager } = app; 111 | 112 | if (hotkeyManager.getHotkeys(id) || hotkeyManager.getDefaultHotkeys(id)) { 113 | const hotkeyStr = hotkeyManager.printHotkeyForCommand(id); 114 | 115 | if (hotkeyStr?.length) { 116 | flairContainerEl.createEl('kbd', { 117 | cls: 'suggestion-hotkey', 118 | text: hotkeyStr, 119 | }); 120 | } 121 | } 122 | } catch (err) { 123 | console.log('Switcher++: error rendering hotkey for command id: ', id, err); 124 | } 125 | } 126 | 127 | onChooseSuggestion(sugg: CommandSuggestion): boolean { 128 | let handled = false; 129 | if (sugg) { 130 | const { item } = sugg; 131 | this.app.commands.executeCommandById(item.id); 132 | this.saveUsageToList(item.id, RECENTLY_USED_COMMAND_IDS); 133 | handled = true; 134 | } 135 | 136 | return handled; 137 | } 138 | 139 | saveUsageToList(commandId: string, recentCommandIds: string[]): void { 140 | if (recentCommandIds) { 141 | const oldIndex = recentCommandIds.indexOf(commandId); 142 | if (oldIndex > -1) { 143 | recentCommandIds.splice(oldIndex, 1); 144 | } 145 | 146 | recentCommandIds.unshift(commandId); 147 | recentCommandIds.splice(25); 148 | } 149 | } 150 | 151 | getItems(inputInfo: InputInfo, includeAllCommands: boolean): CommandInfo[] { 152 | let items: CommandInfo[] = []; 153 | const activeFacetIds = this.getActiveFacetIds(inputInfo); 154 | const hasActiveFacets = !!activeFacetIds.size; 155 | 156 | if (hasActiveFacets) { 157 | items = this.getPinnedAndRecentCommands(activeFacetIds); 158 | } else if (includeAllCommands) { 159 | items = this.getAllCommands(); 160 | } else { 161 | const pinnedAndRecents = this.getPinnedAndRecentCommands(activeFacetIds); 162 | items = pinnedAndRecents.length ? pinnedAndRecents : this.getAllCommands(); 163 | } 164 | 165 | return items; 166 | } 167 | 168 | getPinnedAndRecentCommands(activeFacetIds: Set): CommandInfo[] { 169 | const items: CommandInfo[] = []; 170 | const pinnedIdsSet = this.getPinnedCommandIds(); 171 | const recentIdsSet = this.getRecentCommandIds(); 172 | 173 | const findCommandInfo = (id: string) => { 174 | let cmdInfo: CommandInfo = null; 175 | const cmd = this.app.commands.findCommand(id); 176 | 177 | if (cmd) { 178 | cmdInfo = { 179 | isPinned: pinnedIdsSet.has(id), 180 | isRecent: recentIdsSet.has(id), 181 | cmd, 182 | }; 183 | } 184 | 185 | return cmdInfo; 186 | }; 187 | 188 | const addCommandInfo = (facetId: string, cmdIds: string[]) => { 189 | if (this.isFacetedWith(activeFacetIds, facetId)) { 190 | cmdIds.forEach((id) => { 191 | const cmdInfo = findCommandInfo(id); 192 | 193 | if (cmdInfo) { 194 | items.push(cmdInfo); 195 | } 196 | }); 197 | } 198 | }; 199 | 200 | addCommandInfo(CommandListFacetIds.Pinned, Array.from(pinnedIdsSet)); 201 | 202 | const isPinnedFaceted = this.isFacetedWith( 203 | activeFacetIds, 204 | CommandListFacetIds.Pinned, 205 | ); 206 | 207 | // Remove any recently used ids that are also in the pinned list so they don't 208 | // appear twice in the result list when the pinned facet is enabled 209 | const recentIds = Array.from(recentIdsSet).filter( 210 | // When not pinned faceted then the recent item should be in the result list 211 | // but when it is pinned facted, the recent item should only be in the result list 212 | // when it does not already exist in the pinned list 213 | (id) => !isPinnedFaceted || (isPinnedFaceted && !pinnedIdsSet.has(id)), 214 | ); 215 | 216 | addCommandInfo(CommandListFacetIds.Recent, recentIds); 217 | 218 | return items; 219 | } 220 | 221 | getAllCommands(): CommandInfo[] { 222 | const pinnedIdsSet = this.getPinnedCommandIds(); 223 | const recentIdsSet = this.getRecentCommandIds(); 224 | 225 | return this.app.commands 226 | .listCommands() 227 | ?.sort((a, b) => a.name.localeCompare(b.name)) 228 | .map((cmd) => { 229 | return { 230 | isPinned: pinnedIdsSet.has(cmd.id), 231 | isRecent: recentIdsSet.has(cmd.id), 232 | cmd, 233 | }; 234 | }); 235 | } 236 | 237 | getPinnedCommandIds(): Set { 238 | const ids = this.getEnabledCommandPalettePluginInstance()?.options?.pinned; 239 | return new Set(ids ?? []); 240 | } 241 | 242 | getRecentCommandIds(): Set { 243 | return new Set(RECENTLY_USED_COMMAND_IDS); 244 | } 245 | 246 | createSuggestion(commandInfo: CommandInfo, match: SearchResult): CommandSuggestion { 247 | const { cmd, isPinned, isRecent } = commandInfo; 248 | const sugg: CommandSuggestion = { 249 | type: SuggestionType.CommandList, 250 | item: cmd, 251 | isPinned, 252 | isRecent, 253 | match, 254 | }; 255 | 256 | return this.applyMatchPriorityPreferences(sugg); 257 | } 258 | 259 | getEnabledCommandPalettePluginInstance(): CommandPalettePluginInstance { 260 | return CommandHandler.getEnabledCommandPalettePluginInstance(this.app); 261 | } 262 | 263 | static getEnabledCommandPalettePluginInstance(app: App): CommandPalettePluginInstance { 264 | return getInternalEnabledPluginById( 265 | app, 266 | COMMAND_PALETTE_PLUGIN_ID, 267 | ) as CommandPalettePluginInstance; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/Handlers/editorHandler.ts: -------------------------------------------------------------------------------- 1 | import { SwitcherPlusSettings } from 'src/settings'; 2 | import { 3 | AnySuggestion, 4 | EditorSuggestion, 5 | MatchType, 6 | Mode, 7 | SearchResultWithFallback, 8 | SessionOpts, 9 | SuggestionType, 10 | TitleSource, 11 | } from 'src/types'; 12 | import { InputInfo, ParsedCommand, WorkspaceEnvList } from 'src/switcherPlus'; 13 | import { MetadataCache, sortSearchResults, TFile, WorkspaceLeaf } from 'obsidian'; 14 | import { Handler } from './handler'; 15 | import { Searcher } from 'src/search'; 16 | import { getTFileFromLeaf } from 'src/utils'; 17 | 18 | export class EditorHandler extends Handler { 19 | getCommandString(_sessionOpts?: SessionOpts): string { 20 | return this.settings?.editorListCommand; 21 | } 22 | 23 | validateCommand( 24 | inputInfo: InputInfo, 25 | index: number, 26 | filterText: string, 27 | _activeSuggestion: AnySuggestion, 28 | _activeLeaf: WorkspaceLeaf, 29 | ): ParsedCommand { 30 | inputInfo.mode = Mode.EditorList; 31 | 32 | const cmd = inputInfo.parsedCommand(Mode.EditorList); 33 | cmd.index = index; 34 | cmd.parsedInput = filterText; 35 | cmd.isValidated = true; 36 | 37 | return cmd; 38 | } 39 | 40 | getSuggestions(inputInfo: InputInfo): EditorSuggestion[] { 41 | const suggestions: EditorSuggestion[] = []; 42 | 43 | if (inputInfo) { 44 | const { query, hasSearchTerm } = inputInfo.parsedInputQuery; 45 | const searcher = Searcher.create(query); 46 | const items = this.getItems(); 47 | 48 | items.forEach((item) => { 49 | const file = getTFileFromLeaf(item); 50 | let shouldPush = true; 51 | let result: SearchResultWithFallback = { matchType: MatchType.None, match: null }; 52 | 53 | const preferredTitle = this.getPreferredTitle( 54 | item, 55 | this.settings.preferredSourceForTitle, 56 | ); 57 | 58 | if (hasSearchTerm) { 59 | result = searcher.searchWithFallback(preferredTitle, file); 60 | shouldPush = result.matchType !== MatchType.None; 61 | } 62 | 63 | if (shouldPush) { 64 | suggestions.push( 65 | this.createSuggestion( 66 | inputInfo.currentWorkspaceEnvList, 67 | item, 68 | file, 69 | result, 70 | preferredTitle, 71 | ), 72 | ); 73 | } 74 | }); 75 | 76 | if (hasSearchTerm) { 77 | sortSearchResults(suggestions); 78 | } 79 | } 80 | 81 | return suggestions; 82 | } 83 | 84 | getPreferredTitle(leaf: WorkspaceLeaf, titleSource: TitleSource): string { 85 | return EditorHandler.getPreferredTitle(leaf, titleSource, this.app.metadataCache); 86 | } 87 | 88 | static getPreferredTitle( 89 | leaf: WorkspaceLeaf, 90 | titleSource: TitleSource, 91 | metadataCache: MetadataCache, 92 | ): string { 93 | const { view } = leaf; 94 | const file = view?.file; 95 | let text = leaf.getDisplayText(); 96 | 97 | if (titleSource === 'H1' && file) { 98 | const h1 = EditorHandler.getFirstH1(file, metadataCache); 99 | 100 | if (h1) { 101 | text = text.replace(file.basename, h1.heading); 102 | } 103 | } 104 | 105 | return text; 106 | } 107 | 108 | getItems(): WorkspaceLeaf[] { 109 | const { 110 | excludeViewTypes, 111 | includeSidePanelViewTypes, 112 | orderEditorListByAccessTime: orderByAccessTime, 113 | } = this.settings; 114 | 115 | return this.getOpenLeaves(excludeViewTypes, includeSidePanelViewTypes, { 116 | orderByAccessTime, 117 | }); 118 | } 119 | 120 | renderSuggestion(sugg: EditorSuggestion, parentEl: HTMLElement): boolean { 121 | let handled = false; 122 | if (sugg) { 123 | const { file, matchType, match } = sugg; 124 | const hideBasename = [MatchType.None, MatchType.Primary].includes(matchType); 125 | 126 | this.renderAsFileInfoPanel( 127 | parentEl, 128 | ['qsp-suggestion-editor'], 129 | sugg.preferredTitle, 130 | file, 131 | matchType, 132 | match, 133 | hideBasename, 134 | ); 135 | 136 | this.renderOptionalIndicators(parentEl, sugg); 137 | handled = true; 138 | } 139 | 140 | return handled; 141 | } 142 | 143 | onChooseSuggestion(sugg: EditorSuggestion, evt: MouseEvent | KeyboardEvent): boolean { 144 | let handled = false; 145 | if (sugg) { 146 | this.navigateToLeafOrOpenFile( 147 | evt, 148 | sugg.file, 149 | 'Unable to reopen existing editor in new Leaf.', 150 | null, 151 | sugg.item, 152 | null, 153 | true, 154 | ); 155 | handled = true; 156 | } 157 | 158 | return handled; 159 | } 160 | 161 | createSuggestion( 162 | currentWorkspaceEnvList: WorkspaceEnvList, 163 | leaf: WorkspaceLeaf, 164 | file: TFile, 165 | result: SearchResultWithFallback, 166 | preferredTitle?: string, 167 | ): EditorSuggestion { 168 | return EditorHandler.createSuggestion( 169 | currentWorkspaceEnvList, 170 | leaf, 171 | file, 172 | this.settings, 173 | this.app.metadataCache, 174 | preferredTitle, 175 | result, 176 | ); 177 | } 178 | 179 | static createSuggestion( 180 | currentWorkspaceEnvList: WorkspaceEnvList, 181 | leaf: WorkspaceLeaf, 182 | file: TFile, 183 | settings: SwitcherPlusSettings, 184 | metadataCache: MetadataCache, 185 | preferredTitle?: string, 186 | result?: SearchResultWithFallback, 187 | ): EditorSuggestion { 188 | result = result ?? { matchType: MatchType.None, match: null, matchText: null }; 189 | preferredTitle = preferredTitle ?? null; 190 | 191 | let sugg: EditorSuggestion = { 192 | item: leaf, 193 | file, 194 | preferredTitle, 195 | type: SuggestionType.EditorList, 196 | ...result, 197 | }; 198 | 199 | sugg = Handler.updateWorkspaceEnvListStatus(currentWorkspaceEnvList, sugg); 200 | return Handler.applyMatchPriorityPreferences(sugg, settings, metadataCache); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './workspaceHandler'; 2 | export * from './headingsHandler'; 3 | export * from './editorHandler'; 4 | export * from './symbolHandler'; 5 | export * from './bookmarksHandler'; 6 | export * from './commandHandler'; 7 | export * from './handler'; 8 | export * from './relatedItemsHandler'; 9 | export * from './standardExHandler'; 10 | export * from './vaultHandler'; 11 | -------------------------------------------------------------------------------- /src/Handlers/standardExHandler.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from './handler'; 2 | import { MetadataCache, WorkspaceLeaf } from 'obsidian'; 3 | import { InputInfo, ParsedCommand, WorkspaceEnvList } from 'src/switcherPlus'; 4 | import { isAliasSuggestion, isFileSuggestion } from 'src/utils'; 5 | import { SwitcherPlusSettings } from 'src/settings'; 6 | import { 7 | FileSuggestion, 8 | AliasSuggestion, 9 | AnySuggestion, 10 | MatchType, 11 | SuggestionType, 12 | UnresolvedSuggestion, 13 | SearchResultWithFallback, 14 | SessionOpts, 15 | } from 'src/types'; 16 | 17 | export type SupportedSystemSuggestions = FileSuggestion | AliasSuggestion; 18 | 19 | export class StandardExHandler extends Handler { 20 | getCommandString(_sessionOpts?: SessionOpts): string { 21 | return ''; 22 | } 23 | 24 | validateCommand( 25 | _inputInfo: InputInfo, 26 | _index: number, 27 | _filterText: string, 28 | _activeSuggestion: AnySuggestion, 29 | _activeLeaf: WorkspaceLeaf, 30 | ): ParsedCommand { 31 | throw new Error('Method not implemented.'); 32 | } 33 | 34 | getSuggestions(_inputInfo: InputInfo): SupportedSystemSuggestions[] { 35 | throw new Error('Method not implemented.'); 36 | } 37 | 38 | renderSuggestion(sugg: SupportedSystemSuggestions, parentEl: HTMLElement): boolean { 39 | let handled = false; 40 | if (isFileSuggestion(sugg)) { 41 | handled = this.renderFileSuggestion(sugg, parentEl); 42 | } else if (isAliasSuggestion(sugg)) { 43 | handled = this.renderAliasSuggestion(sugg, parentEl); 44 | } 45 | 46 | if (sugg?.downranked) { 47 | parentEl.addClass('mod-downranked'); 48 | } 49 | 50 | return handled; 51 | } 52 | 53 | onChooseSuggestion( 54 | sugg: SupportedSystemSuggestions, 55 | evt: MouseEvent | KeyboardEvent, 56 | ): boolean { 57 | let handled = false; 58 | if (sugg) { 59 | const { file } = sugg; 60 | 61 | this.navigateToLeafOrOpenFile( 62 | evt, 63 | file, 64 | `Unable to open file from SystemSuggestion ${file.path}`, 65 | ); 66 | 67 | handled = true; 68 | } 69 | 70 | return handled; 71 | } 72 | 73 | renderFileSuggestion(sugg: FileSuggestion, parentEl: HTMLElement): boolean { 74 | let handled = false; 75 | if (sugg) { 76 | const { file, matchType, match } = sugg; 77 | 78 | this.renderAsFileInfoPanel( 79 | parentEl, 80 | ['qsp-suggestion-file'], 81 | null, 82 | file, 83 | matchType, 84 | match, 85 | ); 86 | 87 | this.renderOptionalIndicators(parentEl, sugg); 88 | handled = true; 89 | } 90 | 91 | return handled; 92 | } 93 | 94 | renderAliasSuggestion(sugg: AliasSuggestion, parentEl: HTMLElement): boolean { 95 | let handled = false; 96 | if (sugg) { 97 | const { file, matchType, match } = sugg; 98 | 99 | this.renderAsFileInfoPanel( 100 | parentEl, 101 | ['qsp-suggestion-alias'], 102 | sugg.alias, 103 | file, 104 | matchType, 105 | match, 106 | false, 107 | ); 108 | 109 | const flairContainerEl = this.renderOptionalIndicators(parentEl, sugg); 110 | this.renderIndicator(flairContainerEl, ['qsp-alias-indicator'], 'lucide-forward'); 111 | handled = true; 112 | } 113 | 114 | return handled; 115 | } 116 | 117 | /** 118 | * Injects suggestions generated by the core switcher in Standard mode with 119 | * additional properties to enable custom functionality. 120 | * 121 | * @param {WorkspaceEnvList} workspaceEnvList 122 | * @param {SupportedSystemSuggestions} sugg 123 | */ 124 | addPropertiesToStandardSuggestions( 125 | workspaceEnvList: WorkspaceEnvList, 126 | sugg: SupportedSystemSuggestions, 127 | ): void { 128 | const { match } = sugg; 129 | let matchType = MatchType.None; 130 | let matchText = null; 131 | 132 | if (match?.matches) { 133 | if (isAliasSuggestion(sugg)) { 134 | matchType = MatchType.Primary; 135 | matchText = sugg.alias; 136 | } else if (isFileSuggestion(sugg)) { 137 | matchType = MatchType.Path; 138 | matchText = sugg.file.path; 139 | } 140 | } 141 | 142 | sugg.matchType = matchType; 143 | sugg.matchText = matchText; 144 | 145 | // patch with missing properties required for enhanced custom rendering 146 | Handler.updateWorkspaceEnvListStatus(workspaceEnvList, sugg); 147 | } 148 | 149 | static createUnresolvedSuggestion( 150 | linktext: string, 151 | result: SearchResultWithFallback, 152 | settings: SwitcherPlusSettings, 153 | metadataCache: MetadataCache, 154 | ): UnresolvedSuggestion { 155 | const sugg: UnresolvedSuggestion = { 156 | linktext, 157 | type: SuggestionType.Unresolved, 158 | ...result, 159 | }; 160 | 161 | return Handler.applyMatchPriorityPreferences(sugg, settings, metadataCache); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Handlers/vaultHandler.ts: -------------------------------------------------------------------------------- 1 | import { filenameFromPath } from 'src/utils'; 2 | import { 3 | AnySuggestion, 4 | MatchType, 5 | Mode, 6 | SessionOpts, 7 | SuggestionType, 8 | VaultSuggestion, 9 | } from 'src/types'; 10 | import { 11 | Platform, 12 | renderResults, 13 | setIcon, 14 | sortSearchResults, 15 | WorkspaceLeaf, 16 | } from 'obsidian'; 17 | import { InputInfo, ParsedCommand } from 'src/switcherPlus'; 18 | import { Handler } from './handler'; 19 | import { Searcher } from 'src/search'; 20 | 21 | // 12/8/23: Format of Record is vaultId as key with and object payload 22 | export type VaultData = Record; 23 | 24 | export class VaultHandler extends Handler { 25 | mobileVaultChooserMarker: VaultSuggestion = { 26 | type: SuggestionType.VaultList, 27 | match: null, 28 | item: null, 29 | pathSegments: null, 30 | }; 31 | 32 | getCommandString(_sessionOpts?: SessionOpts): string { 33 | return this.settings?.vaultListCommand; 34 | } 35 | 36 | validateCommand( 37 | inputInfo: InputInfo, 38 | index: number, 39 | filterText: string, 40 | _activeSuggestion: AnySuggestion, 41 | _activeLeaf: WorkspaceLeaf, 42 | ): ParsedCommand { 43 | inputInfo.mode = Mode.VaultList; 44 | 45 | const cmd = inputInfo.parsedCommand(Mode.VaultList); 46 | cmd.index = index; 47 | cmd.parsedInput = filterText; 48 | cmd.isValidated = true; 49 | 50 | return cmd; 51 | } 52 | 53 | getSuggestions(inputInfo: InputInfo): VaultSuggestion[] { 54 | const suggestions: VaultSuggestion[] = []; 55 | 56 | if (inputInfo) { 57 | const { query, hasSearchTerm } = inputInfo.parsedInputQuery; 58 | const searcher = Searcher.create(query); 59 | const items = this.getItems(); 60 | 61 | items.forEach((item) => { 62 | let shouldPush = true; 63 | 64 | if (hasSearchTerm) { 65 | const results = searcher.searchWithFallback(null, item.pathSegments); 66 | Object.assign(item, results); 67 | shouldPush = !!results.match; 68 | } 69 | 70 | if (shouldPush) { 71 | suggestions.push(item); 72 | } 73 | }); 74 | 75 | if (hasSearchTerm) { 76 | sortSearchResults(suggestions); 77 | } 78 | } 79 | 80 | return suggestions; 81 | } 82 | 83 | renderSuggestion(sugg: VaultSuggestion, parentEl: HTMLElement): boolean { 84 | let handled = false; 85 | if (sugg) { 86 | this.addClassesToSuggestionContainer(parentEl, ['qsp-suggestion-vault']); 87 | handled = true; 88 | 89 | if (Platform.isDesktop) { 90 | this.renderVaultSuggestion(sugg, parentEl); 91 | } else if (sugg === this.mobileVaultChooserMarker) { 92 | this.renderMobileHintSuggestion(parentEl); 93 | } 94 | } 95 | 96 | return handled; 97 | } 98 | 99 | renderMobileHintSuggestion(parentEl: HTMLElement): void { 100 | this.renderContent(parentEl, 'Show mobile vault chooser', null); 101 | } 102 | 103 | renderVaultSuggestion(sugg: VaultSuggestion, parentEl: HTMLElement): void { 104 | const { pathSegments, matchType } = sugg; 105 | let { match } = sugg; 106 | let basenameMatch = null; 107 | 108 | if (matchType === MatchType.Basename) { 109 | basenameMatch = match; 110 | match = null; 111 | } 112 | 113 | const contentEl = this.renderContent(parentEl, pathSegments.basename, basenameMatch); 114 | const wrapperEl = contentEl.createDiv({ cls: ['suggestion-note', 'qsp-note'] }); 115 | const iconEl = wrapperEl.createSpan({ cls: ['qsp-path-indicator'] }); 116 | const pathEl = wrapperEl.createSpan({ cls: 'qsp-path' }); 117 | 118 | setIcon(iconEl, 'folder'); 119 | renderResults(pathEl, pathSegments.path, match); 120 | } 121 | 122 | onChooseSuggestion(sugg: VaultSuggestion, _evt: MouseEvent | KeyboardEvent): boolean { 123 | let handled = false; 124 | if (sugg) { 125 | if (Platform.isDesktop) { 126 | this.openVaultOnDesktop(sugg.pathSegments?.path); 127 | handled = true; 128 | } else if (sugg === this.mobileVaultChooserMarker) { 129 | // It's the mobile app context, show the vault chooser 130 | this.app.openVaultChooser(); 131 | handled = true; 132 | } 133 | } 134 | 135 | return handled; 136 | } 137 | 138 | getItems(): VaultSuggestion[] { 139 | const items: VaultSuggestion[] = []; 140 | 141 | if (Platform.isDesktop) { 142 | try { 143 | const vaultData = this.getVaultListDataOnDesktop(); 144 | 145 | for (const [id, { path, open }] of Object.entries(vaultData)) { 146 | const basename = filenameFromPath(path); 147 | const sugg: VaultSuggestion = { 148 | type: SuggestionType.VaultList, 149 | match: null, 150 | item: id, 151 | isOpen: !!open, 152 | pathSegments: { basename, path }, 153 | }; 154 | 155 | items.push(sugg); 156 | } 157 | } catch (err) { 158 | console.log('Switcher++: error parsing vault data. ', err); 159 | } 160 | } else { 161 | items.push(this.mobileVaultChooserMarker); 162 | } 163 | 164 | return items.sort((a, b) => 165 | a.pathSegments.basename.localeCompare(b.pathSegments.basename), 166 | ); 167 | } 168 | 169 | /** 170 | * Instructs Obsidian to open the vault at vaultPath. This should only be called 171 | * Desktop Platforms. 172 | * 173 | * @param {string} vaultPath 174 | */ 175 | openVaultOnDesktop(vaultPath: string): void { 176 | if (!Platform.isDesktop) { 177 | return; 178 | } 179 | 180 | try { 181 | const ipcRenderer = window.require('electron').ipcRenderer; 182 | 183 | // 12/8/23: "vault-open" is the Obsidian defined channel for opening a vault 184 | ipcRenderer.sendSync( 185 | 'vault-open', 186 | vaultPath, 187 | false, // true to create if it doesn't exist 188 | ); 189 | } catch (error) { 190 | console.log(`Switcher++: error opening vault with path: ${vaultPath} `, error); 191 | } 192 | } 193 | 194 | /** 195 | * Retrieves the list of available vaults that can be opened. This should only be 196 | * called on Desktop Platforms. 197 | * 198 | * @returns {VaultData} 199 | */ 200 | getVaultListDataOnDesktop(): VaultData { 201 | let data: VaultData = null; 202 | 203 | if (Platform.isDesktop) { 204 | try { 205 | const ipcRenderer = window.require('electron').ipcRenderer; 206 | data = ipcRenderer.sendSync('vault-list') as VaultData; 207 | } catch (error) { 208 | console.log('Switcher++: error retrieving list of available vaults. ', error); 209 | } 210 | } 211 | 212 | return data; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/Handlers/workspaceHandler.ts: -------------------------------------------------------------------------------- 1 | import { getInternalEnabledPluginById } from 'src/utils'; 2 | import { 3 | AnySuggestion, 4 | Mode, 5 | SessionOpts, 6 | SuggestionType, 7 | WorkspaceInfo, 8 | WorkspaceSuggestion, 9 | } from 'src/types'; 10 | import { InputInfo, ParsedCommand } from 'src/switcherPlus'; 11 | import { 12 | SearchResult, 13 | sortSearchResults, 14 | WorkspaceLeaf, 15 | WorkspacesPluginInstance, 16 | } from 'obsidian'; 17 | import { Handler } from './handler'; 18 | import { Searcher } from 'src/search'; 19 | 20 | export const WORKSPACE_PLUGIN_ID = 'workspaces'; 21 | 22 | export class WorkspaceHandler extends Handler { 23 | getCommandString(_sessionOpts?: SessionOpts): string { 24 | return this.settings?.workspaceListCommand; 25 | } 26 | 27 | validateCommand( 28 | inputInfo: InputInfo, 29 | index: number, 30 | filterText: string, 31 | _activeSuggestion: AnySuggestion, 32 | _activeLeaf: WorkspaceLeaf, 33 | ): ParsedCommand { 34 | const cmd = inputInfo.parsedCommand(Mode.WorkspaceList); 35 | 36 | if (this.getEnabledWorkspacesPluginInstance()) { 37 | inputInfo.mode = Mode.WorkspaceList; 38 | 39 | cmd.index = index; 40 | cmd.parsedInput = filterText; 41 | cmd.isValidated = true; 42 | } 43 | 44 | return cmd; 45 | } 46 | 47 | getSuggestions(inputInfo: InputInfo): WorkspaceSuggestion[] { 48 | const suggestions: WorkspaceSuggestion[] = []; 49 | 50 | if (inputInfo) { 51 | const { query, hasSearchTerm } = inputInfo.parsedInputQuery; 52 | const searcher = Searcher.create(query); 53 | const items = this.getItems(); 54 | 55 | items.forEach((item) => { 56 | let shouldPush = true; 57 | let match: SearchResult = null; 58 | 59 | if (hasSearchTerm) { 60 | ({ match } = searcher.searchWithFallback(item.id)); 61 | shouldPush = !!match; 62 | } 63 | 64 | if (shouldPush) { 65 | suggestions.push({ type: SuggestionType.WorkspaceList, item, match }); 66 | } 67 | }); 68 | 69 | if (hasSearchTerm) { 70 | sortSearchResults(suggestions); 71 | } 72 | } 73 | 74 | return suggestions; 75 | } 76 | 77 | renderSuggestion(sugg: WorkspaceSuggestion, parentEl: HTMLElement): boolean { 78 | let handled = false; 79 | if (sugg) { 80 | this.addClassesToSuggestionContainer(parentEl, ['qsp-suggestion-workspace']); 81 | this.renderContent(parentEl, sugg.item.id, sugg.match); 82 | handled = true; 83 | } 84 | 85 | return handled; 86 | } 87 | 88 | onChooseSuggestion( 89 | sugg: WorkspaceSuggestion, 90 | _evt: MouseEvent | KeyboardEvent, 91 | ): boolean { 92 | let handled = false; 93 | if (sugg) { 94 | const { id } = sugg.item; 95 | const pluginInstance = this.getEnabledWorkspacesPluginInstance(); 96 | 97 | if (pluginInstance) { 98 | pluginInstance.loadWorkspace(id); 99 | } 100 | 101 | handled = true; 102 | } 103 | 104 | return handled; 105 | } 106 | 107 | override onNoResultsCreateAction( 108 | inputInfo: InputInfo, 109 | _evt: MouseEvent | KeyboardEvent, 110 | ): boolean { 111 | const pluginInstance = this.getEnabledWorkspacesPluginInstance(); 112 | 113 | if (pluginInstance) { 114 | const input = inputInfo.parsedCommand(Mode.WorkspaceList)?.parsedInput; 115 | 116 | // create a new workspace and set it active 117 | pluginInstance.saveWorkspace(input); 118 | pluginInstance.setActiveWorkspace(input); 119 | } 120 | 121 | return true; 122 | } 123 | 124 | private getItems(): WorkspaceInfo[] { 125 | const items: WorkspaceInfo[] = []; 126 | const workspaces = this.getEnabledWorkspacesPluginInstance()?.workspaces; 127 | 128 | if (workspaces) { 129 | Object.keys(workspaces).forEach((id) => items.push({ id, type: 'workspaceInfo' })); 130 | } 131 | 132 | return items.sort((a, b) => a.id.localeCompare(b.id)); 133 | } 134 | 135 | getEnabledWorkspacesPluginInstance(): WorkspacesPluginInstance { 136 | return getInternalEnabledPluginById( 137 | this.app, 138 | WORKSPACE_PLUGIN_ID, 139 | ) as WorkspacesPluginInstance; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/__fixtures__/canvasFile.fixture.ts: -------------------------------------------------------------------------------- 1 | export function makeCanvasFileContentString(): string { 2 | return `{ 3 | "nodes":[ 4 | {"type":"group","id":"1c5117d1307305de","x":-860,"y":0,"width":280,"height":260,"label":"Untitled group"}, 5 | {"type":"link","url":"https://www.google.com/","id":"96c6998a8d8f59a2","x":-280,"y":254,"width":361,"height":302}, 6 | {"type":"file","file":"Obsidian Help/Panes/Linked pane.md","subpath":"","id":"2064cbfa833c2832","x":-512,"y":-460,"width":400,"height":300,"color":"1"}, 7 | {"type":"text","text":"third card","id":"af9a85dc5427b35f","x":-880,"y":375,"width":250,"height":60}, 8 | {"type":"file","file":"symbolsearch.png","id":"13051a00708aaedb","x":-880,"y":-540,"width":348,"height":400}, 9 | {"type":"text","text":"secon card","id":"83db5142f3dd314f","x":-800,"y":180,"width":200,"height":60,"color":"2"}, 10 | {"type":"text","text":"first card","id":"66d5f6bd0f80963d","x":-840,"y":40,"width":200,"height":50} 11 | ], 12 | "edges":[ 13 | {"id":"2748f2636825369f","fromNode":"66d5f6bd0f80963d","fromSide":"bottom","toNode":"83db5142f3dd314f","toSide":"left"} 14 | ] 15 | }`; 16 | } 17 | -------------------------------------------------------------------------------- /src/__fixtures__/editorFilter.fixture.ts: -------------------------------------------------------------------------------- 1 | import { SearchMatches, CachedMetadata } from 'obsidian'; 2 | import { editorTrigger } from './modeTrigger.fixture'; 3 | import { getCachedMetadata, getTags } from './fileCachedMetadata.fixture'; 4 | 5 | interface EditorFixtureFilter { 6 | inputText: string; 7 | displayText: string; 8 | cachedMetadata: CachedMetadata; 9 | } 10 | 11 | function makeEditorFilter( 12 | filterText: string, 13 | displayText: string, 14 | matches: SearchMatches, 15 | score: number, 16 | metadata?: CachedMetadata, 17 | ): EditorFixtureFilter { 18 | const cachedMetadata = metadata ?? getCachedMetadata(); 19 | 20 | return { 21 | inputText: `${editorTrigger}${filterText}`, 22 | displayText, 23 | cachedMetadata, 24 | }; 25 | } 26 | 27 | export const rootSplitEditorFixtures = [ 28 | makeEditorFilter('root', 'root split leaf', [[0, 4]], -0.0115), 29 | ]; 30 | 31 | const leftMetadata: CachedMetadata = { tags: getTags() }; 32 | export const leftSplitEditorFixtures = [ 33 | makeEditorFilter('left', 'left split leaf', [[0, 4]], -0.0115, leftMetadata), 34 | ]; 35 | 36 | export const rightSplitEditorFixtures = [ 37 | makeEditorFilter('right', 'right split leaf', [[0, 5]], -0.011600000000000001), 38 | ]; 39 | -------------------------------------------------------------------------------- /src/__fixtures__/fileCachedMetadata.fixture.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CachedMetadata, 3 | EmbedCache, 4 | LinkCache, 5 | TagCache, 6 | HeadingCache, 7 | Loc, 8 | Pos, 9 | ReferenceCache, 10 | SectionCache, 11 | } from 'obsidian'; 12 | 13 | export function makeLoc(line?: number, col?: number, offset?: number): Loc { 14 | return { 15 | line: line ?? 0, 16 | col: col ?? 0, 17 | offset: offset ?? 0, 18 | }; 19 | } 20 | 21 | function makePos(startLoc?: Loc, endLoc?: Loc): Pos { 22 | const start = startLoc ?? makeLoc(); 23 | const end = endLoc ?? makeLoc(); 24 | 25 | return { 26 | start, 27 | end, 28 | }; 29 | } 30 | 31 | export function makeLink( 32 | link: string, 33 | original: string, 34 | displayText?: string, 35 | startLoc?: Loc, 36 | endLoc?: Loc, 37 | ): ReferenceCache { 38 | const position = makePos(startLoc, endLoc); 39 | 40 | const refCache: ReferenceCache = { 41 | position, 42 | link, 43 | original, 44 | }; 45 | 46 | if (displayText) { 47 | refCache.displayText = displayText; 48 | } 49 | 50 | return refCache; 51 | } 52 | 53 | function makeTag(tag: string, startLoc: Loc, endLoc: Loc): TagCache { 54 | return { 55 | position: makePos(startLoc, endLoc), 56 | tag, 57 | }; 58 | } 59 | 60 | export function makeHeading( 61 | heading: string, 62 | level: number, 63 | startLoc?: Loc, 64 | endLoc?: Loc, 65 | ): HeadingCache { 66 | const position = makePos(startLoc, endLoc); 67 | 68 | return { 69 | position, 70 | heading, 71 | level, 72 | }; 73 | } 74 | 75 | export function makeSectionCache( 76 | type: 'yaml' | 'heading' | 'paragraph' | 'callout', 77 | startLoc?: Loc, 78 | endLoc?: Loc, 79 | id?: string | undefined, 80 | ): SectionCache { 81 | const position = makePos(startLoc, endLoc); 82 | 83 | return { 84 | position, 85 | type, 86 | id, 87 | }; 88 | } 89 | 90 | export function getLinks(): LinkCache[] { 91 | const l1 = makeLink( 92 | 'Format your notes#^e476cc', 93 | '[[Format your notes#^e476cc]]', 94 | 'Format your notes > ^e476cc', 95 | makeLoc(12, 70, 172), 96 | makeLoc(12, 99, 201), 97 | ); 98 | 99 | const l2 = makeLink( 100 | 'internal like to no-exist', 101 | '[[internal like to no-exist|with-alt-text]]', 102 | 'with-alt-text', 103 | makeLoc(12, 136, 238), 104 | makeLoc(12, 179, 281), 105 | ); 106 | 107 | return [l1, l2]; 108 | } 109 | 110 | function getEmbeds(): EmbedCache[] { 111 | const e1 = makeLink( 112 | 'google.jpg', 113 | '![[google.jpg]]', 114 | 'google.jpg', 115 | makeLoc(16, 0, 303), 116 | makeLoc(16, 15, 318), 117 | ); 118 | 119 | return [e1]; 120 | } 121 | 122 | export function getTags(): TagCache[] { 123 | return [ 124 | makeTag('#tag1', makeLoc(20, 11, 340), makeLoc(20, 16, 345)), 125 | makeTag('#tag2', makeLoc(20, 21, 350), makeLoc(20, 26, 355)), 126 | ]; 127 | } 128 | 129 | export function getHeadings(): HeadingCache[] { 130 | return [ 131 | makeHeading('Title heading1', 1, makeLoc(8, 0, 65), makeLoc(8, 16, 81)), 132 | makeHeading('another heading1', 1, makeLoc(9, 0, 83), makeLoc(9, 16, 89)), 133 | makeHeading('More headings2', 2, makeLoc(28, 0, 418), makeLoc(28, 17, 435)), 134 | makeHeading('heading3', 3, makeLoc(30, 0, 437), makeLoc(30, 12, 449)), 135 | makeHeading('heading4', 4, makeLoc(32, 0, 451), makeLoc(32, 13, 464)), 136 | makeHeading('heading5', 5, makeLoc(34, 0, 466), makeLoc(34, 14, 480)), 137 | makeHeading('heading6', 6, makeLoc(36, 0, 482), makeLoc(36, 15, 497)), 138 | ]; 139 | } 140 | 141 | export function getCallouts(): SectionCache[] { 142 | return [makeSectionCache('callout', makeLoc(1, 0, 1), makeLoc(2, 18, 43))]; 143 | } 144 | 145 | export function getCachedMetadata(): CachedMetadata { 146 | return { 147 | links: getLinks(), 148 | embeds: getEmbeds(), 149 | tags: getTags(), 150 | headings: getHeadings(), 151 | sections: [...getCallouts()], 152 | }; 153 | } 154 | -------------------------------------------------------------------------------- /src/__fixtures__/fixtureUtils.ts: -------------------------------------------------------------------------------- 1 | import { mock, mockFn, MockProxy } from 'jest-mock-extended'; 2 | import { InputInfo, ParsedCommand, SourcedParsedCommand } from 'src/switcherPlus'; 3 | import { Chance } from 'chance'; 4 | import { Mode, SourceInfo } from 'src/types'; 5 | import { getSourcedModes } from 'src/utils'; 6 | import { 7 | App, 8 | BookmarksPluginFileItem, 9 | BookmarksPluginFolderItem, 10 | BookmarksPluginGroupItem, 11 | BookmarksPluginSearchItem, 12 | Command, 13 | Editor, 14 | MarkdownView, 15 | SearchMatches, 16 | SearchResult, 17 | TFile, 18 | Vault, 19 | View, 20 | ViewState, 21 | WorkspaceLeaf, 22 | } from 'obsidian'; 23 | 24 | const chance = new Chance(); 25 | 26 | export function makeFuzzyMatch( 27 | matches: SearchMatches = [[0, 5]], 28 | score = -0.0115, 29 | ): SearchResult { 30 | return { 31 | matches, 32 | score, 33 | }; 34 | } 35 | 36 | export const defaultOpenViewState = { 37 | active: true, 38 | eState: { active: true, focus: true }, 39 | }; 40 | 41 | export function makeLeaf(sourceFile?: TFile): MockProxy { 42 | const mockView = mock({ 43 | file: sourceFile ?? new TFile(), 44 | editor: mock(), 45 | getViewType: mockFn().mockReturnValue('markdown'), 46 | }); 47 | 48 | return mock({ 49 | view: mockView, 50 | isDeferred: false, 51 | }); 52 | } 53 | 54 | /** 55 | * Returns a WorkspaceLeaf that is marked as deferred along with associated View, and 56 | * ViewState. 57 | * 58 | * @export 59 | * @param {?{ file?: TFile }} [options] 60 | * @returns {MockProxy} 61 | */ 62 | export function makeLeafDeferred(options?: { file?: TFile }): MockProxy { 63 | options = Object.assign({ file: new TFile() }, options); 64 | 65 | const mockGetFileByPath = mockFn() 66 | .calledWith(options.file.path) 67 | .mockReturnValue(options.file); 68 | 69 | const mockDeferredLeaf = mock({ 70 | isDeferred: true, 71 | // Deferred leaves contain ViewState with the path of the underlying file. 72 | getViewState: () => mock({ state: { file: options.file.path } }), 73 | // Deferred views still report their materialized ViewType 74 | view: mock({ getViewType: () => 'markdown' }), 75 | app: mock({ 76 | vault: mock({ 77 | // The file path from ViewState is resolved to a TFile using getFileByPath 78 | getFileByPath: mockGetFileByPath, 79 | }), 80 | }), 81 | }); 82 | 83 | return mockDeferredLeaf; 84 | } 85 | 86 | export function makeBookmarksPluginFileItem( 87 | options?: Partial, 88 | ): BookmarksPluginFileItem { 89 | return { 90 | type: 'file', 91 | title: options?.title ?? '', 92 | path: options?.path ?? `path/to/${chance.word()}.md`, 93 | subpath: options?.subpath, 94 | }; 95 | } 96 | 97 | export function makeBookmarksPluginFolderItem( 98 | options?: Partial, 99 | ): BookmarksPluginFolderItem { 100 | return { 101 | type: 'folder', 102 | title: options?.title ?? '', 103 | path: options?.path ?? `path/to/${chance.word()}`, 104 | }; 105 | } 106 | 107 | export function makeBookmarksPluginSearchItem( 108 | options?: Partial, 109 | ): BookmarksPluginSearchItem { 110 | return { 111 | type: 'search', 112 | title: options?.title ?? '', 113 | query: options?.query ?? `file:(${chance.word()}) ${chance.word()}`, 114 | }; 115 | } 116 | 117 | export function makeBookmarksPluginGroupItem( 118 | options?: Partial, 119 | ): BookmarksPluginGroupItem { 120 | return { 121 | type: 'group', 122 | title: options?.title ?? `BOOKMARK_GROUP_${chance.word()}`, 123 | items: options?.items ?? [makeBookmarksPluginFileItem()], 124 | }; 125 | } 126 | 127 | export function makeCommandItem(options?: { id?: string; name?: string }): Command { 128 | return { 129 | id: options?.id ?? chance.word(), 130 | name: options?.name ?? chance.word(), 131 | }; 132 | } 133 | 134 | /** 135 | * Returns a non-mocked InputInfo object with the specified parameters 136 | * 137 | * @export 138 | * @param {?{ 139 | * inputText?: string; 140 | * mode?: Mode; 141 | * parsedCommand?: Partial; 142 | * sourceInfo?: Partial; 143 | * }} [options] 144 | * @returns {InputInfo} 145 | */ 146 | export function makeInputInfo(options?: { 147 | inputText?: string; 148 | mode?: Mode; 149 | parsedCommand?: Partial; 150 | sourceInfo?: Partial; 151 | }): InputInfo { 152 | options = Object.assign({}, options); 153 | 154 | const mode = options.mode ?? Mode.Standard; 155 | const inputInfo = new InputInfo(options.inputText ?? '', mode); 156 | 157 | // Update the parsedCommand property with passed in params 158 | const parsedCommand = inputInfo.parsedCommand(mode); 159 | Object.assign(parsedCommand, options.parsedCommand); 160 | 161 | // For sourced modes, add the SourcedInfo 162 | if (getSourcedModes().includes(mode)) { 163 | const sourceInfo: SourceInfo = { 164 | file: null, 165 | leaf: null, 166 | suggestion: null, 167 | isValidSource: null, 168 | }; 169 | 170 | (parsedCommand as SourcedParsedCommand).source = Object.assign( 171 | sourceInfo, 172 | options.sourceInfo, 173 | ); 174 | } 175 | 176 | return inputInfo; 177 | } 178 | -------------------------------------------------------------------------------- /src/__fixtures__/index.ts: -------------------------------------------------------------------------------- 1 | export * from './editorFilter.fixture'; 2 | export * from './fileCachedMetadata.fixture'; 3 | export * from './fixtureUtils'; 4 | export * from './inputText.fixture'; 5 | export * from './modeTrigger.fixture'; 6 | export * from './suggestions.fixture'; 7 | export * from './canvasFile.fixture'; 8 | -------------------------------------------------------------------------------- /src/__fixtures__/modeTrigger.fixture.ts: -------------------------------------------------------------------------------- 1 | export const editorTrigger = ''; 2 | export const symbolTrigger = ''; 3 | export const symbolActiveTrigger = ''; 4 | export const workspaceTrigger = ''; 5 | export const headingsTrigger = ''; 6 | export const bookmarksTrigger = ''; 7 | export const commandTrigger = ''; 8 | export const vaultTrigger = ''; 9 | export const relatedItemsTrigger = ''; 10 | export const relatedItemsActiveTrigger = ''; 11 | export const escapeCmdCharTrigger = ''; 12 | -------------------------------------------------------------------------------- /src/__fixtures__/suggestions.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Chance } from 'chance'; 2 | import { 3 | makeBookmarksPluginFileItem, 4 | makeCommandItem, 5 | makeFuzzyMatch, 6 | } from './fixtureUtils'; 7 | import { 8 | BookmarksPluginFileItem, 9 | Command, 10 | HeadingCache, 11 | SearchMatches, 12 | TFile, 13 | WorkspaceLeaf, 14 | } from 'obsidian'; 15 | import { filenameFromPath, stripMDExtensionFromPath } from 'src/utils'; 16 | import { 17 | AliasSuggestion, 18 | CommandSuggestion, 19 | EditorSuggestion, 20 | FileSuggestion, 21 | SuggestionType, 22 | UnresolvedSuggestion, 23 | WorkspaceSuggestion, 24 | RelatedItemsSuggestion, 25 | HeadingSuggestion, 26 | SymbolSuggestion, 27 | AnySymbolInfoPayload, 28 | SymbolType, 29 | MatchType, 30 | SearchResultWithFallback, 31 | RelationType, 32 | RelatedItemsInfo, 33 | BookmarksSuggestion, 34 | PathSegments, 35 | VaultSuggestion, 36 | } from 'src/types'; 37 | 38 | const chance = new Chance(); 39 | 40 | export function makeFileSuggestion( 41 | file?: TFile, 42 | matches?: SearchMatches, 43 | score?: number, 44 | matchType?: MatchType, 45 | matchText?: string, 46 | ): FileSuggestion { 47 | file = file ?? new TFile(); 48 | 49 | return { 50 | type: SuggestionType.File, 51 | ...makeSearchResultWithFallback(matches, matchType, matchText, score), 52 | file, 53 | }; 54 | } 55 | 56 | export function makeUnresolvedSuggestion(linktext?: string): UnresolvedSuggestion { 57 | return { 58 | type: SuggestionType.Unresolved, 59 | linktext, 60 | match: null, 61 | }; 62 | } 63 | 64 | export function makeAliasSuggestion( 65 | file?: TFile, 66 | alias: string = null, 67 | matches?: SearchMatches, 68 | score?: number, 69 | matchType?: MatchType, 70 | matchText?: string, 71 | ): AliasSuggestion { 72 | file = file ?? new TFile(); 73 | 74 | return { 75 | type: SuggestionType.Alias, 76 | alias, 77 | file, 78 | ...makeSearchResultWithFallback(matches, matchType, matchText, score), 79 | }; 80 | } 81 | 82 | export function makeEditorSuggestion( 83 | item: WorkspaceLeaf, 84 | file: TFile = null, 85 | matches?: SearchMatches, 86 | score?: number, 87 | matchType?: MatchType, 88 | matchText?: string, 89 | ): EditorSuggestion { 90 | return { 91 | type: SuggestionType.EditorList, 92 | ...makeSearchResultWithFallback(matches, matchType, matchText, score), 93 | file, 94 | item, 95 | }; 96 | } 97 | 98 | export function makeBookmarkedFileSuggestion( 99 | options?: Partial, 100 | ): BookmarksSuggestion { 101 | const item = options?.item ?? makeBookmarksPluginFileItem(); 102 | 103 | let file = options?.file; 104 | if (!file) { 105 | file = new TFile(); 106 | file.path = (item as BookmarksPluginFileItem).path; 107 | file.basename = filenameFromPath(stripMDExtensionFromPath(file)); 108 | } 109 | 110 | return { 111 | type: SuggestionType.Bookmark, 112 | match: options?.match, 113 | matchText: options?.matchText, 114 | matchType: options?.matchType, 115 | bookmarkPath: options?.bookmarkPath ?? file.path, 116 | item, 117 | file, 118 | }; 119 | } 120 | 121 | export function makeWorkspaceSuggestion( 122 | workspaceId: string, 123 | matches?: SearchMatches, 124 | score?: number, 125 | ): WorkspaceSuggestion { 126 | return { 127 | type: SuggestionType.WorkspaceList, 128 | match: makeFuzzyMatch(matches, score), 129 | item: { 130 | type: 'workspaceInfo', 131 | id: workspaceId, 132 | }, 133 | }; 134 | } 135 | 136 | export function makeCommandSuggestion( 137 | item?: Command, 138 | matches?: SearchMatches, 139 | score?: number, 140 | ): CommandSuggestion { 141 | item = item ?? makeCommandItem(); 142 | 143 | return { 144 | type: SuggestionType.CommandList, 145 | match: makeFuzzyMatch(matches, score), 146 | item, 147 | }; 148 | } 149 | 150 | export function makeVaultSuggestion(options?: { 151 | item?: string; 152 | pathSegments?: PathSegments; 153 | isOpen?: boolean; 154 | matches?: SearchMatches; 155 | score?: number; 156 | matchType?: MatchType; 157 | }): VaultSuggestion { 158 | options = Object.assign( 159 | { 160 | item: chance.word(), 161 | pathSegments: { basename: 'vault', path: 'path/to/vault' }, 162 | isOpen: false, 163 | }, 164 | options, 165 | ); 166 | const { item, isOpen, pathSegments, score, matches, matchType } = options; 167 | 168 | return { 169 | type: SuggestionType.VaultList, 170 | match: makeFuzzyMatch(matches, score), 171 | item, 172 | isOpen, 173 | pathSegments, 174 | matchType, 175 | }; 176 | } 177 | 178 | export function makeRelatedItemsSuggestion( 179 | item?: RelatedItemsInfo, 180 | preferredTitle?: string, 181 | matches?: SearchMatches, 182 | score?: number, 183 | matchType?: MatchType, 184 | matchText?: string, 185 | ): RelatedItemsSuggestion { 186 | if (!item) { 187 | item = { 188 | relationType: RelationType.DiskLocation, 189 | file: new TFile(), 190 | }; 191 | } 192 | 193 | return { 194 | item, 195 | preferredTitle: preferredTitle ?? null, 196 | type: SuggestionType.RelatedItemsList, 197 | ...makeSearchResultWithFallback(matches, matchType, matchText, score), 198 | file: item.file, 199 | }; 200 | } 201 | 202 | export function makeHeadingSuggestion( 203 | item: HeadingCache, 204 | file: TFile = null, 205 | matches?: SearchMatches, 206 | score?: number, 207 | ): HeadingSuggestion { 208 | return { 209 | type: SuggestionType.HeadingsList, 210 | match: makeFuzzyMatch(matches, score), 211 | item, 212 | file, 213 | }; 214 | } 215 | 216 | export function makeSymbolSuggestion( 217 | symbol: AnySymbolInfoPayload, 218 | symbolType: SymbolType, 219 | file: TFile = null, 220 | isSelected = false, 221 | matches?: SearchMatches, 222 | score?: number, 223 | ): SymbolSuggestion { 224 | return { 225 | type: SuggestionType.SymbolList, 226 | match: makeFuzzyMatch(matches, score), 227 | item: { 228 | type: 'symbolInfo', 229 | symbol, 230 | symbolType, 231 | isSelected, 232 | }, 233 | file, 234 | }; 235 | } 236 | 237 | export function makeSearchResultWithFallback( 238 | matches?: SearchMatches, 239 | type?: MatchType, 240 | text?: string, 241 | score?: number, 242 | ): SearchResultWithFallback { 243 | return { 244 | match: makeFuzzyMatch(matches, score), 245 | matchType: type ?? MatchType.None, 246 | matchText: text, 247 | }; 248 | } 249 | -------------------------------------------------------------------------------- /src/__mocks__/obsidian/index.ts: -------------------------------------------------------------------------------- 1 | import { Chance } from 'chance'; 2 | import { mock, mockFn } from 'jest-mock-extended'; 3 | import { 4 | App, 5 | debounce, 6 | Platform, 7 | Plugin, 8 | PluginManifest, 9 | renderResults, 10 | sortSearchResults, 11 | TFile, 12 | Keymap, 13 | Modal, 14 | normalizePath, 15 | setIcon, 16 | parseLinktext, 17 | Component, 18 | MarkdownRenderer, 19 | prepareFuzzySearch, 20 | prepareSimpleSearch, 21 | stripHeadingForLink, 22 | } from 'obsidian'; 23 | import { 24 | MockSetting, 25 | MockTextComponent, 26 | MockToggleComponent, 27 | MockTextAreaComponent, 28 | MockDropdownComponent, 29 | MockPluginSettingTab, 30 | MockExtraButtonComponent, 31 | MockSliderComponent, 32 | } from './mockSetting'; 33 | 34 | const chance = new Chance(); 35 | 36 | const mockKeymap = mock(); 37 | 38 | const mockPlatform = mock({ 39 | isDesktop: true, 40 | isMobile: false, 41 | }); 42 | 43 | const mockMarkdownRenderer = mock(); 44 | 45 | const mockComponent = jest.fn(() => { 46 | return mock(); 47 | }); 48 | 49 | const mockModal = jest.fn((app) => { 50 | return mock({ 51 | app, 52 | titleEl: mock(), 53 | contentEl: mock(), 54 | }); 55 | }); 56 | 57 | const mockPlugin = jest.fn( 58 | (app, _manifest) => { 59 | return mock({ 60 | app, 61 | }); 62 | }, 63 | ); 64 | 65 | const mockTFile = jest.fn( 66 | (basename = chance.word(), extension = 'md') => { 67 | const name = `${basename}.${extension}`; 68 | 69 | return mock({ 70 | path: `path/to/${name}`, 71 | basename, 72 | extension, 73 | name, 74 | }); 75 | }, 76 | ); 77 | 78 | const mockPrepareFuzzySearch = mockFn(); 79 | const mockPrepareSimpleSearch = mockFn(); 80 | 81 | const mockNormalizePath = mockFn().mockImplementation( 82 | (path) => path, 83 | ); 84 | 85 | const mockSortSearchResults = mockFn().mockImplementation(); 86 | const mockRenderResults = mockFn().mockImplementation(); 87 | const mockDebounce = mockFn().mockImplementation(); 88 | const mockSetIcon = mockFn().mockImplementation(); 89 | const mockParseLinktext = mockFn().mockImplementation(); 90 | const mockStripHeadingForLink = mockFn().mockImplementation( 91 | (heading) => heading, 92 | ); 93 | 94 | export { 95 | mockPlatform as Platform, 96 | mockModal as Modal, 97 | mockPlugin as Plugin, 98 | MockPluginSettingTab as PluginSettingTab, 99 | mockTFile as TFile, 100 | mockPrepareFuzzySearch as prepareFuzzySearch, 101 | mockPrepareSimpleSearch as prepareSimpleSearch, 102 | mockSortSearchResults as sortSearchResults, 103 | mockRenderResults as renderResults, 104 | mockDebounce as debounce, 105 | mockNormalizePath as normalizePath, 106 | mockSetIcon as setIcon, 107 | mockParseLinktext as parseLinktext, 108 | mockStripHeadingForLink as stripHeadingForLink, 109 | mockKeymap as Keymap, 110 | mockMarkdownRenderer as MarkdownRenderer, 111 | mockComponent as Component, 112 | MockSetting as Setting, 113 | MockTextComponent as TextComponent, 114 | MockToggleComponent as ToggleComponent, 115 | MockTextAreaComponent as TextAreaComponent, 116 | MockDropdownComponent as DropdownComponent, 117 | MockExtraButtonComponent as ExtraButtonComponent, 118 | MockSliderComponent as SliderComponent, 119 | }; 120 | -------------------------------------------------------------------------------- /src/__mocks__/obsidian/mockSetting.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | import { mock } from 'jest-mock-extended'; 4 | import { 5 | App, 6 | DropdownComponent, 7 | ExtraButtonComponent, 8 | PluginSettingTab, 9 | SliderComponent, 10 | TextAreaComponent, 11 | TextComponent, 12 | ToggleComponent, 13 | } from 'obsidian'; 14 | 15 | export class MockPluginSettingTab implements PluginSettingTab { 16 | app: App; 17 | containerEl: HTMLElement; 18 | 19 | constructor(app: App) { 20 | this.containerEl = mock(); 21 | this.app = app; 22 | } 23 | 24 | hide() { 25 | throw new Error('Method not implemented.'); 26 | } 27 | display() { 28 | throw new Error('Method not implemented.'); 29 | } 30 | } 31 | 32 | export class MockSetting { 33 | private containerEl; 34 | 35 | constructor(containerEl: HTMLElement) { 36 | this.containerEl = containerEl; 37 | } 38 | 39 | setName(name: string): this { 40 | return this; 41 | } 42 | 43 | setDesc(desc: string): this { 44 | return this; 45 | } 46 | 47 | setHeading(): this { 48 | return this; 49 | } 50 | 51 | setClass(name: string): this { 52 | return this; 53 | } 54 | 55 | addText(cb: (component: TextComponent) => any): this { 56 | cb(new MockTextComponent(this.containerEl)); 57 | return this; 58 | } 59 | 60 | addToggle(cb: (component: ToggleComponent) => any): this { 61 | cb(new MockToggleComponent(this.containerEl)); 62 | return this; 63 | } 64 | 65 | addTextArea(cb: (component: TextAreaComponent) => any): this { 66 | cb(new MockTextAreaComponent(this.containerEl)); 67 | return this; 68 | } 69 | 70 | addDropdown(cb: (component: DropdownComponent) => any): this { 71 | cb(new MockDropdownComponent(this.containerEl)); 72 | return this; 73 | } 74 | 75 | addExtraButton(cb: (component: ExtraButtonComponent) => any): this { 76 | cb(new MockExtraButtonComponent(this.containerEl)); 77 | return this; 78 | } 79 | 80 | addSlider(cb: (component: SliderComponent) => any): this { 81 | cb(new MockSliderComponent(this.containerEl)); 82 | return this; 83 | } 84 | } 85 | 86 | export class MockTextComponent implements TextComponent { 87 | inputEl: HTMLInputElement; 88 | disabled: boolean; 89 | onChangeCB: (value: string) => any; 90 | 91 | constructor(public containerEl: HTMLElement) { 92 | this.inputEl = mock(); 93 | } 94 | 95 | getValue(): string { 96 | return this.inputEl.value; 97 | } 98 | 99 | setValue(value: string): this { 100 | this.inputEl.value = value; 101 | 102 | if (this.onChangeCB) { 103 | this.onChangeCB(value); 104 | } 105 | 106 | return this; 107 | } 108 | 109 | setPlaceholder(placeholder: string): this { 110 | return this; 111 | } 112 | 113 | onChange(callback: (value: string) => any): this { 114 | this.onChangeCB = callback; 115 | return this; 116 | } 117 | 118 | setDisabled(disabled: boolean): this { 119 | throw new Error('Method not implemented.'); 120 | } 121 | onChanged(): void { 122 | throw new Error('Method not implemented.'); 123 | } 124 | registerOptionListener( 125 | listeners: Record string>, 126 | key: string, 127 | ): this { 128 | throw new Error('Method not implemented.'); 129 | } 130 | then(cb: (component: this) => any): this { 131 | throw new Error('Method not implemented.'); 132 | } 133 | } 134 | 135 | export class MockToggleComponent implements ToggleComponent { 136 | toggleEl: HTMLElement; 137 | value: boolean; 138 | onChangeCB: (value: boolean) => any; 139 | 140 | constructor(public containerEl: HTMLElement) { 141 | this.toggleEl = mock(); 142 | } 143 | 144 | getValue(): boolean { 145 | return this.value; 146 | } 147 | 148 | setValue(on: boolean): this { 149 | this.value = on; 150 | 151 | if (this.onChangeCB) { 152 | this.onChangeCB(on); 153 | } 154 | 155 | return this; 156 | } 157 | 158 | onChange(callback: (value: boolean) => any): this { 159 | this.onChangeCB = callback; 160 | return this; 161 | } 162 | 163 | setDisabled(disabled: boolean): this { 164 | throw new Error('Method not implemented.'); 165 | } 166 | setTooltip(tooltip: string): this { 167 | throw new Error('Method not implemented.'); 168 | } 169 | onClick(): void { 170 | throw new Error('Method not implemented.'); 171 | } 172 | registerOptionListener( 173 | listeners: Record boolean>, 174 | key: string, 175 | ): this { 176 | throw new Error('Method not implemented.'); 177 | } 178 | disabled: boolean; 179 | then(cb: (component: this) => any): this { 180 | throw new Error('Method not implemented.'); 181 | } 182 | } 183 | 184 | export class MockTextAreaComponent implements TextAreaComponent { 185 | inputEl: HTMLTextAreaElement; 186 | onChangeCB: (value: string) => any; 187 | 188 | constructor(public containerEl: HTMLElement) { 189 | this.inputEl = mock(); 190 | } 191 | 192 | getValue(): string { 193 | return this.inputEl.value; 194 | } 195 | 196 | setValue(value: string): this { 197 | this.inputEl.value = value; 198 | 199 | if (this.onChangeCB) { 200 | this.onChangeCB(value); 201 | } 202 | 203 | return this; 204 | } 205 | 206 | setPlaceholder(placeholder: string): this { 207 | return this; 208 | } 209 | 210 | onChange(callback: (value: string) => any): this { 211 | this.onChangeCB = callback; 212 | return this; 213 | } 214 | 215 | setDisabled(disabled: boolean): this { 216 | throw new Error('Method not implemented.'); 217 | } 218 | onChanged(): void { 219 | throw new Error('Method not implemented.'); 220 | } 221 | registerOptionListener( 222 | listeners: Record string>, 223 | key: string, 224 | ): this { 225 | throw new Error('Method not implemented.'); 226 | } 227 | disabled: boolean; 228 | then(cb: (component: this) => any): this { 229 | throw new Error('Method not implemented.'); 230 | } 231 | } 232 | 233 | export class MockDropdownComponent implements DropdownComponent { 234 | selectEl: HTMLSelectElement; 235 | onChangeCB: (value: string) => any; 236 | options: Record; 237 | 238 | constructor(public containerEl: HTMLElement) { 239 | this.selectEl = mock(); 240 | } 241 | 242 | getValue(): string { 243 | return this.selectEl.value; 244 | } 245 | 246 | setValue(value: string): this { 247 | this.selectEl.value = value; 248 | 249 | if (this.onChangeCB) { 250 | this.onChangeCB(value); 251 | } 252 | 253 | return this; 254 | } 255 | 256 | addOptions(options: Record): this { 257 | this.options = options; 258 | return this; 259 | } 260 | 261 | onChange(callback: (value: string) => any): this { 262 | this.onChangeCB = callback; 263 | return this; 264 | } 265 | 266 | setDisabled(disabled: boolean): this { 267 | throw new Error('Method not implemented.'); 268 | } 269 | addOption(value: string, display: string): this { 270 | throw new Error('Method not implemented.'); 271 | } 272 | registerOptionListener( 273 | listeners: Record string>, 274 | key: string, 275 | ): this { 276 | throw new Error('Method not implemented.'); 277 | } 278 | disabled: boolean; 279 | then(cb: (component: this) => any): this { 280 | throw new Error('Method not implemented.'); 281 | } 282 | } 283 | 284 | export class MockExtraButtonComponent implements ExtraButtonComponent { 285 | extraSettingsEl: HTMLElement; 286 | 287 | constructor(public containerEl: HTMLElement) { 288 | this.extraSettingsEl = mock(); 289 | } 290 | 291 | setDisabled(disabled: boolean): this { 292 | throw new Error('Method not implemented.'); 293 | } 294 | setTooltip(tooltip: string): this { 295 | throw new Error('Method not implemented.'); 296 | } 297 | setIcon(icon: string): this { 298 | throw new Error('Method not implemented.'); 299 | } 300 | onClick(callback: () => any): this { 301 | throw new Error('Method not implemented.'); 302 | } 303 | disabled: boolean; 304 | then(cb: (component: this) => any): this { 305 | throw new Error('Method not implemented.'); 306 | } 307 | } 308 | 309 | export class MockSliderComponent implements SliderComponent { 310 | sliderEl: HTMLInputElement; 311 | onChangeCB: (value: number) => any; 312 | 313 | constructor(public containerEl: HTMLElement) { 314 | this.sliderEl = mock(); 315 | } 316 | 317 | getValue(): number { 318 | return Number(this.sliderEl.value); 319 | } 320 | 321 | setValue(value: number): this { 322 | this.sliderEl.value = value.toString(); 323 | 324 | if (this.onChangeCB) { 325 | this.onChangeCB(value); 326 | } 327 | 328 | return this; 329 | } 330 | 331 | onChange(callback: (value: number) => any): this { 332 | this.onChangeCB = callback; 333 | return this; 334 | } 335 | 336 | setInstant(instant: boolean): this { 337 | throw new Error('Method not implemented.'); 338 | } 339 | setDisabled(disabled: boolean): this { 340 | throw new Error('Method not implemented.'); 341 | } 342 | setLimits(min: number, max: number, step: number | 'any'): this { 343 | throw new Error('Method not implemented.'); 344 | } 345 | getValuePretty(): string { 346 | throw new Error('Method not implemented.'); 347 | } 348 | setDynamicTooltip(): this { 349 | throw new Error('Method not implemented.'); 350 | } 351 | showTooltip(): void { 352 | throw new Error('Method not implemented.'); 353 | } 354 | registerOptionListener( 355 | listeners: Record number>, 356 | key: string, 357 | ): this { 358 | throw new Error('Method not implemented.'); 359 | } 360 | disabled: boolean; 361 | then(cb: (component: this) => any): this { 362 | throw new Error('Method not implemented.'); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian'; 2 | import { SwitcherPlusSettings, SwitcherPlusSettingTab } from 'src/settings'; 3 | import { createSwitcherPlus, MobileLauncher } from 'src/switcherPlus'; 4 | import { Mode, SessionOpts } from 'src/types'; 5 | 6 | type CommandDefinitionOpts = Pick; 7 | type CommandDefinition = { 8 | id: string; 9 | name: string; 10 | mode: Mode; 11 | iconId: string; 12 | ribbonIconEl: HTMLElement; 13 | sessionOpts?: CommandDefinitionOpts; 14 | }; 15 | 16 | const COMMAND_DATA: CommandDefinition[] = [ 17 | { 18 | id: 'switcher-plus:open', 19 | name: 'Open in Standard Mode', 20 | mode: Mode.Standard, 21 | iconId: 'lucide-file-search', 22 | ribbonIconEl: null, 23 | }, 24 | { 25 | id: 'switcher-plus:open-editors', 26 | name: 'Open in Editor Mode', 27 | mode: Mode.EditorList, 28 | iconId: 'lucide-file-edit', 29 | ribbonIconEl: null, 30 | }, 31 | { 32 | id: 'switcher-plus:open-symbols', 33 | name: 'Open Symbols for selected suggestion or editor', 34 | mode: Mode.SymbolList, 35 | iconId: 'lucide-dollar-sign', 36 | ribbonIconEl: null, 37 | }, 38 | { 39 | id: 'switcher-plus:open-symbols-active', 40 | name: 'Open Symbols for the active editor', 41 | mode: Mode.SymbolList, 42 | iconId: 'lucide-dollar-sign', 43 | ribbonIconEl: null, 44 | sessionOpts: { useActiveEditorAsSource: true }, 45 | }, 46 | { 47 | id: 'switcher-plus:open-workspaces', 48 | name: 'Open in Workspaces Mode', 49 | mode: Mode.WorkspaceList, 50 | iconId: 'lucide-album', 51 | ribbonIconEl: null, 52 | }, 53 | { 54 | id: 'switcher-plus:open-headings', 55 | name: 'Open in Headings Mode', 56 | mode: Mode.HeadingsList, 57 | iconId: 'lucide-file-search', 58 | ribbonIconEl: null, 59 | }, 60 | { 61 | // Note: leaving this id with the old starred plugin name so that user 62 | // don't have to update their hotkey mappings when they upgrade 63 | id: 'switcher-plus:open-starred', 64 | name: 'Open in Bookmarks Mode', 65 | mode: Mode.BookmarksList, 66 | iconId: 'lucide-bookmark', 67 | ribbonIconEl: null, 68 | }, 69 | { 70 | id: 'switcher-plus:open-commands', 71 | name: 'Open in Commands Mode', 72 | mode: Mode.CommandList, 73 | iconId: 'run-command', 74 | ribbonIconEl: null, 75 | }, 76 | { 77 | id: 'switcher-plus:open-related-items', 78 | name: 'Open Related Items for selected suggestion or editor', 79 | mode: Mode.RelatedItemsList, 80 | iconId: 'lucide-file-plus-2', 81 | ribbonIconEl: null, 82 | }, 83 | { 84 | id: 'switcher-plus:open-related-items-active', 85 | name: 'Open Related Items for the active editor', 86 | mode: Mode.RelatedItemsList, 87 | iconId: 'lucide-file-plus-2', 88 | ribbonIconEl: null, 89 | sessionOpts: { useActiveEditorAsSource: true }, 90 | }, 91 | { 92 | id: 'switcher-plus:open-vaults', 93 | name: 'Open in Vaults Mode', 94 | mode: Mode.VaultList, 95 | iconId: 'vault', 96 | ribbonIconEl: null, 97 | }, 98 | ]; 99 | 100 | export default class SwitcherPlusPlugin extends Plugin { 101 | public options: SwitcherPlusSettings; 102 | 103 | async onload(): Promise { 104 | const options = new SwitcherPlusSettings(this); 105 | await options.updateDataAndLoadSettings(); 106 | this.options = options; 107 | 108 | this.addSettingTab(new SwitcherPlusSettingTab(this.app, this, options)); 109 | this.registerRibbonCommandIcons(); 110 | this.updateMobileLauncherButtonOverride(options.mobileLauncher.isEnabled); 111 | 112 | COMMAND_DATA.forEach(({ id, name, mode, iconId, sessionOpts }) => { 113 | this.registerCommand(id, name, mode, iconId, sessionOpts); 114 | }); 115 | } 116 | 117 | onunload(): void { 118 | this.updateMobileLauncherButtonOverride(false); 119 | } 120 | 121 | registerCommand( 122 | id: string, 123 | name: string, 124 | mode: Mode, 125 | iconId?: string, 126 | sessionOpts?: CommandDefinitionOpts, 127 | ): void { 128 | this.addCommand({ 129 | id, 130 | name, 131 | icon: iconId, 132 | checkCallback: (checking) => { 133 | return this.createModalAndOpen(mode, checking, sessionOpts); 134 | }, 135 | }); 136 | } 137 | 138 | registerRibbonCommandIcons(): void { 139 | // remove any registered icons 140 | COMMAND_DATA.forEach((data) => { 141 | data.ribbonIconEl?.remove(); 142 | data.ribbonIconEl = null; 143 | }); 144 | 145 | // map to keyed object 146 | const commandDataByMode = COMMAND_DATA.reduce( 147 | (acc, curr) => { 148 | acc[curr.mode] = curr; 149 | return acc; 150 | }, 151 | {} as Record, 152 | ); 153 | 154 | this.options.enabledRibbonCommands.forEach((command) => { 155 | const data = commandDataByMode[Mode[command]]; 156 | 157 | if (data) { 158 | data.ribbonIconEl = this.addRibbonIcon(data.iconId, data.name, () => { 159 | this.createModalAndOpen(data.mode, false); 160 | }); 161 | } 162 | }); 163 | } 164 | 165 | createModalAndOpen( 166 | mode: Mode, 167 | isChecking: boolean, 168 | sessionOpts?: CommandDefinitionOpts, 169 | ): boolean { 170 | if (!isChecking) { 171 | // modal needs to be created dynamically (same as system switcher) 172 | // as system options are evaluated in the modal constructor 173 | const modal = createSwitcherPlus(this.app, this); 174 | if (!modal) { 175 | return false; 176 | } 177 | 178 | modal.openInMode(mode, sessionOpts); 179 | } 180 | 181 | return true; 182 | } 183 | 184 | updateMobileLauncherButtonOverride(isEnabled: boolean): void { 185 | if (isEnabled) { 186 | const onclickListener = () => { 187 | const modeString = this.options.mobileLauncher.modeString as keyof typeof Mode; 188 | const openMode = Mode[modeString]; 189 | 190 | if (openMode) { 191 | this.createModalAndOpen(openMode, false); 192 | } 193 | }; 194 | 195 | MobileLauncher.installMobileLauncherOverride( 196 | this.app, 197 | this.options.mobileLauncher, 198 | onclickListener, 199 | ); 200 | } else { 201 | MobileLauncher.removeMobileLauncherOverride(); 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/search/__tests__/searcher.test.ts: -------------------------------------------------------------------------------- 1 | import { Searcher, SearchDelegate, StringSearcher } from 'src/search'; 2 | import { makeFuzzyMatch } from '@fixtures'; 3 | import { mockFn, mockReset } from 'jest-mock-extended'; 4 | import { prepareFuzzySearch, prepareSimpleSearch, TFile } from 'obsidian'; 5 | import { Chance } from 'chance'; 6 | import { MatchType } from 'src/types'; 7 | 8 | const chance = new Chance(); 9 | 10 | describe('searcher', () => { 11 | const query = 'query'; 12 | let sut: StringSearcher; 13 | 14 | const mockSearchDelegate = mockFn(); 15 | 16 | const mockPrepareFuzzySearch = jest 17 | .mocked(prepareFuzzySearch) 18 | .mockReturnValue(mockSearchDelegate); 19 | 20 | const mockPrepareSimpleSearch = jest 21 | .mocked(prepareSimpleSearch) 22 | .mockReturnValue(mockSearchDelegate); 23 | 24 | afterAll(() => { 25 | mockPrepareFuzzySearch.mockReset(); 26 | mockPrepareSimpleSearch.mockReset(); 27 | }); 28 | 29 | it('should return results using the Fuzzy matcher by default', () => { 30 | const text = chance.word(); 31 | 32 | const match = makeFuzzyMatch(); 33 | mockSearchDelegate.calledWith(text).mockReturnValueOnce(match); 34 | 35 | const result = Searcher.create(query).executeSearch(text); 36 | 37 | expect(result).toBe(match); 38 | expect(mockSearchDelegate).toHaveBeenCalledWith(text); 39 | expect(mockPrepareFuzzySearch).toHaveBeenCalledWith(query); 40 | 41 | mockReset(mockSearchDelegate); 42 | mockPrepareFuzzySearch.mockClear(); 43 | }); 44 | 45 | it('should return results using the simple search matcher when requested', () => { 46 | const text = chance.word(); 47 | const useSimpleSearch = true; 48 | 49 | const match = makeFuzzyMatch(); 50 | mockSearchDelegate.calledWith(text).mockReturnValueOnce(match); 51 | 52 | const result = Searcher.create(query, useSimpleSearch).executeSearch(text); 53 | 54 | expect(result).toBe(match); 55 | expect(mockSearchDelegate).toHaveBeenCalledWith(text); 56 | expect(mockPrepareSimpleSearch).toHaveBeenCalledWith(query); 57 | 58 | mockReset(mockSearchDelegate); 59 | mockPrepareSimpleSearch.mockClear(); 60 | }); 61 | 62 | it('should return null when the SearchDelegate is null', () => { 63 | mockPrepareFuzzySearch.mockReturnValueOnce(null); 64 | 65 | const result = Searcher.create(query).executeSearch(null); 66 | 67 | expect(result).toBe(null); 68 | expect(mockPrepareFuzzySearch).toHaveBeenCalledWith(query); 69 | 70 | mockReset(mockSearchDelegate); 71 | mockPrepareFuzzySearch.mockClear(); 72 | }); 73 | 74 | it('should not throw on null query', () => { 75 | expect(() => Searcher.create(null)).not.toThrow(); 76 | }); 77 | 78 | describe('searchWithFallback', () => { 79 | const text = chance.word(); 80 | const filepath = `path/to/${text}/${text} name.md`; 81 | const match = makeFuzzyMatch(); 82 | 83 | beforeAll(() => { 84 | sut = Searcher.create(query); 85 | mockSearchDelegate.calledWith(text).mockReturnValue(match); 86 | mockSearchDelegate.calledWith(filepath).mockReturnValue(match); 87 | }); 88 | 89 | afterAll(() => { 90 | sut = null; 91 | mockReset(mockSearchDelegate); 92 | }); 93 | 94 | it('should match for primary string', () => { 95 | const mockFile = new TFile(); 96 | mockFile.path = text; 97 | 98 | const result = sut.searchWithFallback(text, mockFile); 99 | 100 | expect(result).toEqual( 101 | expect.objectContaining({ 102 | matchType: MatchType.Primary, 103 | matchText: text, 104 | match, 105 | }), 106 | ); 107 | }); 108 | 109 | it('should match file basename', () => { 110 | const mockFile = new TFile(); 111 | mockFile.basename = text; 112 | 113 | const result = sut.searchWithFallback(null, mockFile); 114 | 115 | expect(result).toEqual( 116 | expect.objectContaining({ 117 | matchType: MatchType.Basename, 118 | matchText: text, 119 | match, 120 | }), 121 | ); 122 | }); 123 | 124 | it('should match file path', () => { 125 | const mockFile = new TFile(); 126 | mockFile.path = text; 127 | 128 | const result = sut.searchWithFallback(null, mockFile); 129 | 130 | expect(result).toEqual( 131 | expect.objectContaining({ 132 | matchType: MatchType.Path, 133 | matchText: mockFile.path, 134 | match, 135 | }), 136 | ); 137 | }); 138 | 139 | it("should partially match filepath and basename segments when there isn't a full basename match", () => { 140 | const mockFile = new TFile(); 141 | mockFile.path = filepath; 142 | 143 | const result = sut.searchWithFallback(null, mockFile); 144 | 145 | expect(result).toEqual( 146 | expect.objectContaining({ 147 | matchType: MatchType.Path, 148 | matchText: mockFile.path, 149 | match, 150 | }), 151 | ); 152 | }); 153 | 154 | it('should return result for secondary string with a downranked score', () => { 155 | const mockFile = new TFile(); 156 | mockFile.path = text; 157 | 158 | const initialScore = match.score; 159 | mockSearchDelegate.calledWith(text).mockReturnValueOnce(match); 160 | 161 | const result = sut.searchWithFallback(null, mockFile); 162 | 163 | expect(result.matchType).toBe(MatchType.Path); 164 | expect(result.match).toBe(match); 165 | expect(result.match.score).toBe(initialScore - 1); 166 | expect(mockSearchDelegate).toHaveBeenCalledWith(text); 167 | 168 | mockReset(mockSearchDelegate); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/search/index.ts: -------------------------------------------------------------------------------- 1 | export * from './searcher'; 2 | -------------------------------------------------------------------------------- /src/search/searcher.ts: -------------------------------------------------------------------------------- 1 | import { prepareFuzzySearch, prepareSimpleSearch, SearchResult } from 'obsidian'; 2 | import { MatchType, PathSegments, SearchResultWithFallback } from 'src/types'; 3 | 4 | export interface StringSearcher { 5 | /** 6 | * The string query to search for. 7 | * 8 | * @readonly 9 | * @type {string} 10 | */ 11 | readonly query: string; 12 | 13 | /** 14 | * True if query contains a search term, otherwise false. 15 | * 16 | * @readonly 17 | * @type {boolean} 18 | */ 19 | readonly hasSearchTerm: boolean; 20 | 21 | /** 22 | * Searches through primaryString, if no match is found and pathSegments is not null, 23 | * it will fallback to searching 1) basename, 2) path 24 | * @param {string} primaryString 25 | * @param {PathSegments} pathSegments? TFile like object containing the basename and full path. 26 | * @returns SearchResultWithFallback 27 | */ 28 | searchWithFallback( 29 | primaryString: string, 30 | pathSegments?: PathSegments, 31 | ): SearchResultWithFallback; 32 | 33 | /** 34 | * Search text for the previously supplied query. 35 | * 36 | * @param {string} text The text in which to find query. 37 | * @returns {(SearchResult | null)} SearchResult if query is found or query is an empty 38 | * string. Otherwise null. 39 | */ 40 | executeSearch(text: string): SearchResult | null; 41 | } 42 | 43 | /** 44 | * Function that performs the string matching of text against the query. 45 | * 46 | * @export 47 | * @typedef {SearchDelegate} 48 | */ 49 | export type SearchDelegate = (text: string) => SearchResult | null; 50 | 51 | export class Searcher implements StringSearcher { 52 | _searchDelegate: SearchDelegate; 53 | 54 | readonly query: string; 55 | readonly hasSearchTerm: boolean; 56 | 57 | constructor( 58 | query: string, 59 | readonly useSimpleSearch: boolean, 60 | ) { 61 | this.query = (query ?? '').trim().toLowerCase(); 62 | this.hasSearchTerm = !!this.query.length; 63 | } 64 | 65 | /** 66 | * Utility function for creating a StringSearcher instance. Prefer this over calling 67 | * the constructor directly. 68 | * 69 | * @static 70 | * @param {string} query the query string that should be searched for. 71 | * @param {boolean} [useSimpleSearch=false] false to use fuzzy search (default). true to 72 | * use simple search which is better seaching against large vaults. 73 | * @returns {StringSearcher} 74 | */ 75 | static create(query: string, useSimpleSearch = false): StringSearcher { 76 | return new Searcher(query, useSimpleSearch); 77 | } 78 | 79 | searchWithFallback( 80 | primaryString: string, 81 | pathSegments?: PathSegments, 82 | ): SearchResultWithFallback { 83 | let matchType = MatchType.None; 84 | let matchText: string; 85 | let match: SearchResult = null; 86 | 87 | let res = this.searchAndDownrankSecondaryMatch(primaryString); 88 | 89 | if (res.match) { 90 | match = res.match; 91 | matchType = MatchType.Primary; 92 | matchText = primaryString; 93 | } else if (pathSegments) { 94 | const { basename, path } = pathSegments; 95 | 96 | // Note: the fallback to path has to search through the entire path 97 | // because search needs to match over the filename/basename boundaries 98 | // e.g. search string "to my" should match "path/to/myfile.md" 99 | // that means MatchType.Basename will always be in the basename, while 100 | // MatchType.ParentPath can span both filename and basename 101 | res = this.searchAndDownrankSecondaryMatch(basename, path); 102 | 103 | if (res.isPrimary) { 104 | matchType = MatchType.Basename; 105 | matchText = basename; 106 | } else if (res.match) { 107 | matchType = MatchType.Path; 108 | matchText = path; 109 | } 110 | 111 | match = res.match; 112 | } 113 | 114 | return { matchType, matchText, match }; 115 | } 116 | 117 | /** 118 | * Searches through primaryText, if not match is found, searches through secondaryText. 119 | * If a match is found in secondaryText, the downrank value is applied. 120 | * @param {string} primaryText 121 | * @param {string} secondaryText? 122 | * @returns { isPrimary: boolean; match?: SearchResult } 123 | */ 124 | searchAndDownrankSecondaryMatch( 125 | primaryText: string, 126 | secondaryText?: string, 127 | ): { isPrimary: boolean; match?: SearchResult } { 128 | let isPrimary = false; 129 | let match: SearchResult = null; 130 | 131 | if (primaryText) { 132 | match = this.executeSearch(primaryText); 133 | isPrimary = !!match; 134 | } 135 | 136 | if (!match && secondaryText) { 137 | match = this.executeSearch(secondaryText); 138 | 139 | if (match) { 140 | match.score -= 1; 141 | } 142 | } 143 | 144 | return { 145 | isPrimary, 146 | match, 147 | }; 148 | } 149 | 150 | executeSearch(text: string): SearchResult | null { 151 | const searchFn = this.getSearchDelegate(); 152 | return searchFn ? searchFn(text) : null; 153 | } 154 | 155 | getSearchDelegate(): SearchDelegate { 156 | if (!this._searchDelegate) { 157 | const { query, useSimpleSearch } = this; 158 | 159 | this._searchDelegate = useSimpleSearch 160 | ? prepareSimpleSearch(query) 161 | : prepareFuzzySearch(query); 162 | } 163 | 164 | return this._searchDelegate; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/settings/__tests__/bookmarksSettingsTabSection.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SettingsTabSection, 3 | BookmarksSettingsTabSection, 4 | SwitcherPlusSettings, 5 | SwitcherPlusSettingTab, 6 | } from 'src/settings'; 7 | import { mock, MockProxy } from 'jest-mock-extended'; 8 | import { App } from 'obsidian'; 9 | 10 | describe('bookmarksSettingsTabSection', () => { 11 | let mockApp: MockProxy; 12 | let mockPluginSettingTab: MockProxy; 13 | let config: SwitcherPlusSettings; 14 | let mockContainerEl: MockProxy; 15 | let sut: BookmarksSettingsTabSection; 16 | 17 | beforeAll(() => { 18 | mockApp = mock(); 19 | mockContainerEl = mock(); 20 | mockPluginSettingTab = mock({ containerEl: mockContainerEl }); 21 | config = new SwitcherPlusSettings(null); 22 | 23 | sut = new BookmarksSettingsTabSection(mockApp, mockPluginSettingTab, config); 24 | }); 25 | 26 | it('should display a header for the section', () => { 27 | const addSectionTitleSpy = jest.spyOn( 28 | SettingsTabSection.prototype, 29 | 'addSectionTitle', 30 | ); 31 | 32 | sut.display(mockContainerEl); 33 | 34 | expect(addSectionTitleSpy).toHaveBeenCalledWith( 35 | mockContainerEl, 36 | 'Bookmarks List Mode Settings', 37 | ); 38 | 39 | addSectionTitleSpy.mockRestore(); 40 | }); 41 | 42 | it('should show the mode trigger setting', () => { 43 | const addTextSettingSpy = jest.spyOn(SettingsTabSection.prototype, 'addTextSetting'); 44 | 45 | sut.display(mockContainerEl); 46 | 47 | expect(addTextSettingSpy).toHaveBeenCalledWith( 48 | mockContainerEl, 49 | 'Bookmarks list mode trigger', 50 | expect.any(String), 51 | config.bookmarksListCommand, 52 | 'bookmarksListCommand', 53 | config.bookmarksListPlaceholderText, 54 | ); 55 | 56 | addTextSettingSpy.mockRestore(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/settings/__tests__/commandListSettingsTabSection.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandListSettingsTabSection, 3 | SettingsTabSection, 4 | SwitcherPlusSettings, 5 | SwitcherPlusSettingTab, 6 | } from 'src/settings'; 7 | import { mock, MockProxy } from 'jest-mock-extended'; 8 | import { App } from 'obsidian'; 9 | 10 | describe('commandListSettingsTabSection', () => { 11 | let mockApp: MockProxy; 12 | let mockPluginSettingTab: MockProxy; 13 | let config: SwitcherPlusSettings; 14 | let mockContainerEl: MockProxy; 15 | let sut: CommandListSettingsTabSection; 16 | 17 | beforeAll(() => { 18 | mockApp = mock(); 19 | mockContainerEl = mock(); 20 | mockPluginSettingTab = mock({ containerEl: mockContainerEl }); 21 | config = new SwitcherPlusSettings(null); 22 | 23 | sut = new CommandListSettingsTabSection(mockApp, mockPluginSettingTab, config); 24 | }); 25 | 26 | it('should display a header for the section', () => { 27 | const addSectionTitleSpy = jest.spyOn( 28 | SettingsTabSection.prototype, 29 | 'addSectionTitle', 30 | ); 31 | 32 | sut.display(mockContainerEl); 33 | 34 | expect(addSectionTitleSpy).toHaveBeenCalledWith( 35 | mockContainerEl, 36 | 'Command List Mode Settings', 37 | ); 38 | 39 | addSectionTitleSpy.mockRestore(); 40 | }); 41 | 42 | it('should show the mode trigger setting', () => { 43 | const addTextSettingSpy = jest.spyOn(SettingsTabSection.prototype, 'addTextSetting'); 44 | 45 | sut.display(mockContainerEl); 46 | 47 | expect(addTextSettingSpy).toHaveBeenCalledWith( 48 | mockContainerEl, 49 | 'Command list mode trigger', 50 | expect.any(String), 51 | config.commandListCommand, 52 | 'commandListCommand', 53 | config.commandListPlaceholderText, 54 | ); 55 | 56 | addTextSettingSpy.mockRestore(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/settings/__tests__/editorSettingsTabSection.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EditorSettingsTabSection, 3 | SettingsTabSection, 4 | SwitcherPlusSettings, 5 | SwitcherPlusSettingTab, 6 | } from 'src/settings'; 7 | import { mock, MockProxy } from 'jest-mock-extended'; 8 | import { App, ViewRegistry } from 'obsidian'; 9 | 10 | describe('editorSettingsTabSection', () => { 11 | let mockApp: MockProxy; 12 | let mockPluginSettingTab: MockProxy; 13 | let config: SwitcherPlusSettings; 14 | let mockContainerEl: MockProxy; 15 | let sut: EditorSettingsTabSection; 16 | 17 | beforeAll(() => { 18 | mockApp = mock({ viewRegistry: mock() }); 19 | mockContainerEl = mock(); 20 | mockPluginSettingTab = mock({ containerEl: mockContainerEl }); 21 | config = new SwitcherPlusSettings(null); 22 | 23 | sut = new EditorSettingsTabSection(mockApp, mockPluginSettingTab, config); 24 | }); 25 | 26 | it('should display a header for the section', () => { 27 | const addSectionTitleSpy = jest.spyOn( 28 | SettingsTabSection.prototype, 29 | 'addSectionTitle', 30 | ); 31 | 32 | sut.display(mockContainerEl); 33 | 34 | expect(addSectionTitleSpy).toHaveBeenCalledWith( 35 | mockContainerEl, 36 | 'Editor List Mode Settings', 37 | ); 38 | 39 | addSectionTitleSpy.mockRestore(); 40 | }); 41 | 42 | it('should show the mode trigger setting', () => { 43 | const addTextSettingSpy = jest.spyOn(SettingsTabSection.prototype, 'addTextSetting'); 44 | 45 | sut.display(mockContainerEl); 46 | 47 | expect(addTextSettingSpy).toHaveBeenCalledWith( 48 | mockContainerEl, 49 | 'Editor list mode trigger', 50 | expect.any(String), 51 | config.editorListCommand, 52 | 'editorListCommand', 53 | config.editorListPlaceholderText, 54 | ); 55 | 56 | addTextSettingSpy.mockRestore(); 57 | }); 58 | 59 | it('should show the includeSidePanelViewTypes setting', () => { 60 | const addTextAreaSettingSpy = jest.spyOn( 61 | SettingsTabSection.prototype, 62 | 'addTextAreaSetting', 63 | ); 64 | 65 | sut.display(mockContainerEl); 66 | 67 | expect(addTextAreaSettingSpy).toHaveBeenCalledWith( 68 | mockContainerEl, 69 | 'Include side panel views', 70 | expect.any(String), 71 | config.includeSidePanelViewTypes.join('\n'), 72 | 'includeSidePanelViewTypes', 73 | config.includeSidePanelViewTypesPlaceholder, 74 | ); 75 | 76 | addTextAreaSettingSpy.mockRestore(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/settings/__tests__/relatedItemsSettingsTabSection.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RelatedItemsSettingsTabSection, 3 | SettingsTabSection, 4 | SwitcherPlusSettings, 5 | SwitcherPlusSettingTab, 6 | } from 'src/settings'; 7 | import { RelationType } from 'src/types'; 8 | import { mock, MockProxy, mockReset } from 'jest-mock-extended'; 9 | import { App, Setting, TextAreaComponent } from 'obsidian'; 10 | 11 | describe('relatedItemsSettingsTabSection', () => { 12 | let mockApp: MockProxy; 13 | let mockPluginSettingTab: MockProxy; 14 | let config: SwitcherPlusSettings; 15 | let mockContainerEl: MockProxy; 16 | let sut: RelatedItemsSettingsTabSection; 17 | 18 | beforeAll(() => { 19 | mockApp = mock(); 20 | mockContainerEl = mock(); 21 | mockPluginSettingTab = mock({ containerEl: mockContainerEl }); 22 | config = new SwitcherPlusSettings(null); 23 | 24 | sut = new RelatedItemsSettingsTabSection(mockApp, mockPluginSettingTab, config); 25 | }); 26 | 27 | it('should display a header for the section', () => { 28 | const addSectionTitleSpy = jest.spyOn( 29 | SettingsTabSection.prototype, 30 | 'addSectionTitle', 31 | ); 32 | 33 | sut.display(mockContainerEl); 34 | 35 | expect(addSectionTitleSpy).toHaveBeenCalledWith( 36 | mockContainerEl, 37 | 'Related Items List Mode Settings', 38 | ); 39 | 40 | addSectionTitleSpy.mockRestore(); 41 | }); 42 | 43 | it('should show the mode trigger setting', () => { 44 | const addTextSettingSpy = jest.spyOn(SettingsTabSection.prototype, 'addTextSetting'); 45 | 46 | sut.display(mockContainerEl); 47 | 48 | expect(addTextSettingSpy).toHaveBeenCalledWith( 49 | mockContainerEl, 50 | 'Related Items list mode trigger', 51 | expect.any(String), 52 | config.relatedItemsListCommand, 53 | 'relatedItemsListCommand', 54 | config.relatedItemsListPlaceholderText, 55 | ); 56 | 57 | addTextSettingSpy.mockRestore(); 58 | }); 59 | 60 | it('should show the excludeOpenRelatedFiles setting', () => { 61 | const addToggleSettingSpy = jest.spyOn( 62 | SettingsTabSection.prototype, 63 | 'addToggleSetting', 64 | ); 65 | 66 | sut.display(mockContainerEl); 67 | 68 | expect(addToggleSettingSpy).toHaveBeenCalledWith( 69 | mockContainerEl, 70 | 'Exclude open files', 71 | expect.any(String), 72 | config.excludeOpenRelatedFiles, 73 | 'excludeOpenRelatedFiles', 74 | ); 75 | 76 | addToggleSettingSpy.mockRestore(); 77 | }); 78 | 79 | it('should show the enabledRelatedItems setting', () => { 80 | const createSettingSpy = jest.spyOn(SettingsTabSection.prototype, 'createSetting'); 81 | 82 | sut.display(mockContainerEl); 83 | 84 | expect(createSettingSpy).toHaveBeenCalledWith( 85 | mockContainerEl, 86 | 'Show related item types', 87 | expect.any(String), 88 | ); 89 | 90 | createSettingSpy.mockRestore(); 91 | }); 92 | 93 | describe('showEnabledRelatedItems', () => { 94 | let mockSetting: MockProxy; 95 | let mockTextComp: MockProxy; 96 | let mockInputEl: MockProxy; 97 | let createSettingSpy: jest.SpyInstance; 98 | 99 | beforeAll(() => { 100 | mockSetting = mock(); 101 | mockInputEl = mock(); 102 | mockTextComp = mock({ 103 | inputEl: mockInputEl, 104 | }); 105 | 106 | createSettingSpy = jest 107 | .spyOn(SettingsTabSection.prototype, 'createSetting') 108 | .mockReturnValue(mockSetting); 109 | 110 | mockSetting.addTextArea.mockImplementation((cb) => { 111 | cb(mockTextComp); 112 | return mockSetting; 113 | }); 114 | }); 115 | 116 | afterAll(() => { 117 | createSettingSpy.mockRestore(); 118 | }); 119 | 120 | afterEach(() => { 121 | mockReset(mockTextComp); 122 | mockReset(mockInputEl); 123 | }); 124 | 125 | it('should show the saved enabledRelatedItems setting', () => { 126 | const { enabledRelatedItems } = config; 127 | 128 | sut.showEnabledRelatedItems(mockContainerEl, config); 129 | 130 | expect(mockTextComp.setValue).toHaveBeenCalledWith(enabledRelatedItems.join('\n')); 131 | }); 132 | 133 | it('should save updated value', () => { 134 | const enabledTypes = RelationType.Backlink; 135 | const saveSpy = jest.spyOn(config, 'save'); 136 | 137 | let focusoutFn: EventListener; 138 | mockInputEl.addEventListener.mockImplementation((evtStr, listener) => { 139 | focusoutFn = listener as EventListener; 140 | }); 141 | 142 | config.enabledRelatedItems = []; // start with no values set 143 | mockTextComp.getValue.mockReturnValue(enabledTypes); 144 | 145 | sut.showEnabledRelatedItems(mockContainerEl, config); 146 | focusoutFn(null); // trigger the callback to save 147 | 148 | expect(mockTextComp.getValue).toHaveBeenCalled(); 149 | expect(saveSpy).toHaveBeenCalled(); 150 | expect(config.enabledRelatedItems).toEqual( 151 | expect.arrayContaining(enabledTypes.split('\n')), 152 | ); 153 | 154 | saveSpy.mockRestore(); 155 | }); 156 | 157 | it('should not save changes when invalid related items types are added', () => { 158 | const enabledTypes = 'invalid type'; 159 | const initialTypes = Object.values(RelationType); 160 | const saveSpy = jest.spyOn(config, 'save'); 161 | 162 | let focusoutFn: EventListener; 163 | mockInputEl.addEventListener.mockImplementation((evtStr, listener) => { 164 | focusoutFn = listener as EventListener; 165 | }); 166 | 167 | config.enabledRelatedItems = initialTypes; 168 | mockTextComp.getValue.mockReturnValue(enabledTypes); 169 | 170 | sut.showEnabledRelatedItems(mockContainerEl, config); 171 | focusoutFn(null); // trigger the callback to save 172 | 173 | expect(mockTextComp.getValue).toHaveBeenCalled(); 174 | expect(config.enabledRelatedItems).toBe(initialTypes); 175 | expect(saveSpy).not.toHaveBeenCalled(); 176 | 177 | saveSpy.mockRestore(); 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /src/settings/__tests__/switcherPlusSettingTab.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SwitcherPlusSettingTab, 3 | SwitcherPlusSettings, 4 | GeneralSettingsTabSection, 5 | SymbolSettingsTabSection, 6 | HeadingsSettingsTabSection, 7 | EditorSettingsTabSection, 8 | RelatedItemsSettingsTabSection, 9 | BookmarksSettingsTabSection, 10 | CommandListSettingsTabSection, 11 | WorkspaceSettingsTabSection, 12 | VaultListSettingsTabSection, 13 | } from 'src/settings'; 14 | import { mock, MockProxy } from 'jest-mock-extended'; 15 | import { App, ViewRegistry } from 'obsidian'; 16 | 17 | describe('SwitcherPlusSettingTab', () => { 18 | let mockApp: MockProxy; 19 | let mockConfig: MockProxy; 20 | let mockContainerEl: MockProxy; 21 | let sut: SwitcherPlusSettingTab; 22 | 23 | beforeAll(() => { 24 | mockApp = mock({ viewRegistry: mock() }); 25 | mockConfig = mock(); 26 | 27 | sut = new SwitcherPlusSettingTab(mockApp, null, mockConfig); 28 | mockContainerEl = sut.containerEl as MockProxy; 29 | }); 30 | 31 | describe('display', () => { 32 | let displayTabSectionSpy: jest.SpyInstance; 33 | 34 | beforeAll(() => { 35 | displayTabSectionSpy = jest.spyOn(sut, 'displayTabSection').mockReturnValue(); 36 | }); 37 | 38 | afterAll(() => { 39 | displayTabSectionSpy.mockRestore(); 40 | }); 41 | 42 | afterEach(() => { 43 | displayTabSectionSpy.mockClear(); 44 | }); 45 | 46 | it('should display a title header', () => { 47 | sut.display(); 48 | 49 | expect(mockContainerEl.createEl).toHaveBeenCalledWith('h2', { 50 | text: 'Quick Switcher++ Settings', 51 | }); 52 | }); 53 | 54 | it('should display all the different setting tab sections', () => { 55 | const expected = [ 56 | [GeneralSettingsTabSection], 57 | [SymbolSettingsTabSection], 58 | [HeadingsSettingsTabSection], 59 | [EditorSettingsTabSection], 60 | [RelatedItemsSettingsTabSection], 61 | [BookmarksSettingsTabSection], 62 | [CommandListSettingsTabSection], 63 | [WorkspaceSettingsTabSection], 64 | [VaultListSettingsTabSection], 65 | ]; 66 | 67 | sut.display(); 68 | 69 | expect(displayTabSectionSpy.mock.calls).toEqual(expected); 70 | }); 71 | }); 72 | 73 | describe('displayTabSection', () => { 74 | it('should display a setting tab section', () => { 75 | const sectionClass = GeneralSettingsTabSection; 76 | const sectionDisplaySpy = jest 77 | .spyOn(sectionClass['prototype'], 'display') 78 | .mockReturnValue(); 79 | 80 | sut.displayTabSection(sectionClass); 81 | 82 | expect(sectionDisplaySpy).toHaveBeenCalledWith(mockContainerEl); 83 | 84 | sectionDisplaySpy.mockRestore(); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/settings/__tests__/vaultListSettingsTabSection.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SettingsTabSection, 3 | VaultListSettingsTabSection, 4 | SwitcherPlusSettings, 5 | SwitcherPlusSettingTab, 6 | } from 'src/settings'; 7 | import { mock, MockProxy } from 'jest-mock-extended'; 8 | import { App, Setting } from 'obsidian'; 9 | 10 | describe('vaultListSettingsTabSection', () => { 11 | let mockApp: MockProxy; 12 | let mockPluginSettingTab: MockProxy; 13 | let config: SwitcherPlusSettings; 14 | let mockContainerEl: MockProxy; 15 | let sut: VaultListSettingsTabSection; 16 | let addSectionTitleSpy: jest.SpyInstance; 17 | 18 | beforeAll(() => { 19 | mockApp = mock(); 20 | mockContainerEl = mock(); 21 | mockPluginSettingTab = mock({ containerEl: mockContainerEl }); 22 | config = new SwitcherPlusSettings(null); 23 | 24 | addSectionTitleSpy = jest 25 | .spyOn(SettingsTabSection.prototype, 'addSectionTitle') 26 | .mockReturnValue(mock({ nameEl: mock() })); 27 | 28 | sut = new VaultListSettingsTabSection(mockApp, mockPluginSettingTab, config); 29 | }); 30 | 31 | afterAll(() => { 32 | addSectionTitleSpy.mockRestore(); 33 | }); 34 | 35 | afterEach(() => { 36 | addSectionTitleSpy.mockClear(); 37 | }); 38 | 39 | it('should display a header for the section', () => { 40 | sut.display(mockContainerEl); 41 | 42 | expect(addSectionTitleSpy).toHaveBeenCalledWith( 43 | mockContainerEl, 44 | 'Vault List Mode Settings', 45 | ); 46 | }); 47 | 48 | it('should show the mode trigger setting', () => { 49 | const addTextSettingSpy = jest.spyOn(SettingsTabSection.prototype, 'addTextSetting'); 50 | 51 | sut.display(mockContainerEl); 52 | 53 | expect(addTextSettingSpy).toHaveBeenCalledWith( 54 | mockContainerEl, 55 | 'Vault list mode trigger', 56 | expect.any(String), 57 | config.vaultListCommand, 58 | 'vaultListCommand', 59 | config.vaultListPlaceholderText, 60 | ); 61 | 62 | addTextSettingSpy.mockRestore(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/settings/__tests__/workspaceSettingsTabSection.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SettingsTabSection, 3 | SwitcherPlusSettings, 4 | SwitcherPlusSettingTab, 5 | WorkspaceSettingsTabSection, 6 | } from 'src/settings'; 7 | import { mock, MockProxy } from 'jest-mock-extended'; 8 | import { App } from 'obsidian'; 9 | 10 | describe('WorkspaceSettingsTabSection', () => { 11 | let mockApp: MockProxy; 12 | let mockPluginSettingTab: MockProxy; 13 | let config: SwitcherPlusSettings; 14 | let mockContainerEl: MockProxy; 15 | let sut: WorkspaceSettingsTabSection; 16 | 17 | beforeAll(() => { 18 | mockApp = mock(); 19 | mockContainerEl = mock(); 20 | mockPluginSettingTab = mock({ containerEl: mockContainerEl }); 21 | config = new SwitcherPlusSettings(null); 22 | 23 | sut = new WorkspaceSettingsTabSection(mockApp, mockPluginSettingTab, config); 24 | }); 25 | 26 | it('should display a header for the section', () => { 27 | const addSectionTitleSpy = jest.spyOn( 28 | SettingsTabSection.prototype, 29 | 'addSectionTitle', 30 | ); 31 | 32 | sut.display(mockContainerEl); 33 | 34 | expect(addSectionTitleSpy).toHaveBeenCalledWith( 35 | mockContainerEl, 36 | 'Workspace List Mode Settings', 37 | ); 38 | 39 | addSectionTitleSpy.mockRestore(); 40 | }); 41 | 42 | it('should show the mode trigger setting', () => { 43 | const addTextSettingSpy = jest.spyOn(SettingsTabSection.prototype, 'addTextSetting'); 44 | 45 | sut.display(mockContainerEl); 46 | 47 | expect(addTextSettingSpy).toHaveBeenCalledWith( 48 | mockContainerEl, 49 | 'Workspace list mode trigger', 50 | expect.any(String), 51 | config.workspaceListCommand, 52 | 'workspaceListCommand', 53 | config.workspaceListPlaceholderText, 54 | ); 55 | 56 | addTextSettingSpy.mockRestore(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/settings/bookmarksSettingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { SettingsTabSection } from './settingsTabSection'; 2 | 3 | export class BookmarksSettingsTabSection extends SettingsTabSection { 4 | display(containerEl: HTMLElement): void { 5 | const { config } = this; 6 | 7 | this.addSectionTitle(containerEl, 'Bookmarks List Mode Settings'); 8 | 9 | this.addTextSetting( 10 | containerEl, 11 | 'Bookmarks list mode trigger', 12 | 'Character that will trigger bookmarks list mode in the switcher', 13 | config.bookmarksListCommand, 14 | 'bookmarksListCommand', 15 | config.bookmarksListPlaceholderText, 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/settings/commandListSettingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { SettingsTabSection } from './settingsTabSection'; 2 | 3 | export class CommandListSettingsTabSection extends SettingsTabSection { 4 | display(containerEl: HTMLElement): void { 5 | const { config } = this; 6 | 7 | this.addSectionTitle(containerEl, 'Command List Mode Settings'); 8 | 9 | this.addTextSetting( 10 | containerEl, 11 | 'Command list mode trigger', 12 | 'Character that will trigger command list mode in the switcher', 13 | config.commandListCommand, 14 | 'commandListCommand', 15 | config.commandListPlaceholderText, 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/settings/editorSettingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { SwitcherPlusSettings } from './switcherPlusSettings'; 2 | import { SettingsTabSection } from './settingsTabSection'; 3 | 4 | export class EditorSettingsTabSection extends SettingsTabSection { 5 | display(containerEl: HTMLElement): void { 6 | const { config } = this; 7 | 8 | this.addSectionTitle(containerEl, 'Editor List Mode Settings'); 9 | 10 | this.addTextSetting( 11 | containerEl, 12 | 'Editor list mode trigger', 13 | 'Character that will trigger editor list mode in the switcher', 14 | config.editorListCommand, 15 | 'editorListCommand', 16 | config.editorListPlaceholderText, 17 | ); 18 | 19 | this.showIncludeSidePanelViews(containerEl, config); 20 | 21 | this.addToggleSetting( 22 | containerEl, 23 | 'Order default editor list by most recently accessed', 24 | 'When there is no search term, order the list of editors by most recent access time.', 25 | config.orderEditorListByAccessTime, 26 | 'orderEditorListByAccessTime', 27 | ); 28 | } 29 | 30 | showIncludeSidePanelViews( 31 | containerEl: HTMLElement, 32 | config: SwitcherPlusSettings, 33 | ): void { 34 | const viewsListing = Object.keys(this.app.viewRegistry.viewByType).sort().join(' '); 35 | const desc = `When in Editor list mode, show the following view types from the side panels. Add one view type per line. Available view types: ${viewsListing}`; 36 | 37 | this.addTextAreaSetting( 38 | containerEl, 39 | 'Include side panel views', 40 | desc, 41 | config.includeSidePanelViewTypes.join('\n'), 42 | 'includeSidePanelViewTypes', 43 | config.includeSidePanelViewTypesPlaceholder, 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/settings/facetConstants.ts: -------------------------------------------------------------------------------- 1 | import { Facet, Mode, RelationType, SymbolType } from 'src/types'; 2 | 3 | // map Canvas node data types to facet id 4 | export const CANVAS_NODE_FACET_ID_MAP: Record = { 5 | file: 'canvas-node-file', 6 | text: 'canvas-node-text', 7 | link: 'canvas-node-link', 8 | group: 'canvas-node-group', 9 | }; 10 | 11 | export const SYMBOL_MODE_FACETS: Facet[] = [ 12 | { 13 | id: SymbolType[SymbolType.Heading], 14 | mode: Mode.SymbolList, 15 | label: 'headings', 16 | isActive: false, 17 | isAvailable: true, 18 | }, 19 | { 20 | id: SymbolType[SymbolType.Tag], 21 | mode: Mode.SymbolList, 22 | label: 'tags', 23 | isActive: false, 24 | isAvailable: true, 25 | }, 26 | { 27 | id: SymbolType[SymbolType.Callout], 28 | mode: Mode.SymbolList, 29 | label: 'callouts', 30 | isActive: false, 31 | isAvailable: true, 32 | }, 33 | { 34 | id: SymbolType[SymbolType.Link], 35 | mode: Mode.SymbolList, 36 | label: 'links', 37 | isActive: false, 38 | isAvailable: true, 39 | }, 40 | { 41 | id: SymbolType[SymbolType.Embed], 42 | mode: Mode.SymbolList, 43 | label: 'embeds', 44 | isActive: false, 45 | isAvailable: true, 46 | }, 47 | { 48 | id: CANVAS_NODE_FACET_ID_MAP.file, 49 | mode: Mode.SymbolList, 50 | label: 'file cards', 51 | isActive: false, 52 | isAvailable: true, 53 | }, 54 | { 55 | id: CANVAS_NODE_FACET_ID_MAP.text, 56 | mode: Mode.SymbolList, 57 | label: 'text cards', 58 | isActive: false, 59 | isAvailable: true, 60 | }, 61 | { 62 | id: CANVAS_NODE_FACET_ID_MAP.link, 63 | mode: Mode.SymbolList, 64 | label: 'link cards', 65 | isActive: false, 66 | isAvailable: true, 67 | }, 68 | { 69 | id: CANVAS_NODE_FACET_ID_MAP.group, 70 | mode: Mode.SymbolList, 71 | label: 'groups', 72 | isActive: false, 73 | isAvailable: true, 74 | }, 75 | ]; 76 | 77 | export const RELATED_ITEMS_MODE_FACETS: Facet[] = [ 78 | { 79 | id: RelationType.Backlink, 80 | mode: Mode.RelatedItemsList, 81 | label: 'backlinks', 82 | isActive: false, 83 | isAvailable: true, 84 | }, 85 | { 86 | id: RelationType.OutgoingLink, 87 | mode: Mode.RelatedItemsList, 88 | label: 'outgoing links', 89 | isActive: false, 90 | isAvailable: true, 91 | }, 92 | { 93 | id: RelationType.DiskLocation, 94 | mode: Mode.RelatedItemsList, 95 | label: 'disk location', 96 | isActive: false, 97 | isAvailable: true, 98 | }, 99 | ]; 100 | 101 | export const BOOKMARKS_FACET_ID_MAP: Record = { 102 | file: 'bookmarks-file', 103 | folder: 'bookmarks-folder', 104 | search: 'bookmarks-search', 105 | group: 'bookmarks-group', 106 | }; 107 | 108 | export const BOOKMARKS_MODE_FACETS: Facet[] = [ 109 | { 110 | id: BOOKMARKS_FACET_ID_MAP.file, 111 | mode: Mode.BookmarksList, 112 | label: 'files', 113 | isActive: false, 114 | isAvailable: true, 115 | }, 116 | { 117 | id: BOOKMARKS_FACET_ID_MAP.folder, 118 | mode: Mode.BookmarksList, 119 | label: 'folders', 120 | isActive: false, 121 | isAvailable: true, 122 | }, 123 | { 124 | id: BOOKMARKS_FACET_ID_MAP.search, 125 | mode: Mode.BookmarksList, 126 | label: 'searches', 127 | isActive: false, 128 | isAvailable: true, 129 | }, 130 | ]; 131 | 132 | export enum CommandListFacetIds { 133 | Pinned = 'pinnedCommands', 134 | Recent = 'recentCommands', 135 | } 136 | 137 | export const COMMAND_MODE_FACETS: Facet[] = [ 138 | { 139 | id: CommandListFacetIds.Pinned, 140 | mode: Mode.CommandList, 141 | label: 'pinned', 142 | isActive: false, 143 | isAvailable: true, 144 | }, 145 | { 146 | id: CommandListFacetIds.Recent, 147 | mode: Mode.CommandList, 148 | label: 'recent', 149 | isActive: false, 150 | isAvailable: true, 151 | }, 152 | ]; 153 | 154 | export enum HeadingsListFacetIds { 155 | RecentFiles = 'recentFilesSearch', 156 | Bookmarks = 'bookmarksSearch', 157 | Filenames = 'filenamesSearch', 158 | Headings = 'headingsSearch', 159 | ExternalFiles = 'externalFilesSearch', 160 | } 161 | 162 | export const HEADINGS_MODE_FACETS: Facet[] = [ 163 | { 164 | id: HeadingsListFacetIds.RecentFiles, 165 | mode: Mode.HeadingsList, 166 | label: 'recent files', 167 | isActive: false, 168 | isAvailable: true, 169 | }, 170 | { 171 | id: HeadingsListFacetIds.Bookmarks, 172 | mode: Mode.HeadingsList, 173 | label: 'bookmarks', 174 | isActive: false, 175 | isAvailable: true, 176 | }, 177 | { 178 | id: HeadingsListFacetIds.Filenames, 179 | mode: Mode.HeadingsList, 180 | label: 'filenames', 181 | isActive: false, 182 | isAvailable: true, 183 | }, 184 | { 185 | id: HeadingsListFacetIds.Headings, 186 | mode: Mode.HeadingsList, 187 | label: 'headings', 188 | isActive: false, 189 | isAvailable: true, 190 | }, 191 | { 192 | id: HeadingsListFacetIds.ExternalFiles, 193 | mode: Mode.HeadingsList, 194 | label: 'external files', 195 | isActive: false, 196 | isAvailable: true, 197 | }, 198 | ]; 199 | 200 | export function getFacetMap(): Record { 201 | const facetMap: Record = {}; 202 | const facetLists = [ 203 | SYMBOL_MODE_FACETS, 204 | RELATED_ITEMS_MODE_FACETS, 205 | BOOKMARKS_MODE_FACETS, 206 | COMMAND_MODE_FACETS, 207 | HEADINGS_MODE_FACETS, 208 | ]; 209 | 210 | facetLists.flat().reduce((facetMap, facet) => { 211 | facetMap[facet.id] = Object.assign({}, facet); 212 | return facetMap; 213 | }, facetMap); 214 | 215 | return facetMap; 216 | } 217 | -------------------------------------------------------------------------------- /src/settings/headingsSettingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { SwitcherPlusSettings } from './switcherPlusSettings'; 2 | import { SettingsTabSection } from './settingsTabSection'; 3 | import { Modal } from 'obsidian'; 4 | 5 | export class HeadingsSettingsTabSection extends SettingsTabSection { 6 | display(containerEl: HTMLElement): void { 7 | const { config } = this; 8 | 9 | this.addSectionTitle(containerEl, 'Headings List Mode Settings'); 10 | 11 | this.addTextSetting( 12 | containerEl, 13 | 'Headings list mode trigger', 14 | 'Character that will trigger headings list mode in the switcher', 15 | config.headingsListCommand, 16 | 'headingsListCommand', 17 | config.headingsListPlaceholderText, 18 | ); 19 | 20 | this.showHeadingSettings(containerEl, config); 21 | 22 | this.addToggleSetting( 23 | containerEl, 24 | 'Search Filenames', 25 | "Enabled, search and show suggestions for filenames. Disabled, Don't search through filenames (except for fallback searches)", 26 | config.shouldSearchFilenames, 27 | 'shouldSearchFilenames', 28 | ); 29 | 30 | this.addToggleSetting( 31 | containerEl, 32 | 'Search Bookmarks', 33 | "Enabled, search and show suggestions for Bookmarks. Disabled, Don't search through Bookmarks", 34 | config.shouldSearchBookmarks, 35 | 'shouldSearchBookmarks', 36 | ); 37 | 38 | this.addSliderSetting( 39 | containerEl, 40 | 'Max recent files to show', 41 | 'The maximum number of recent files to show when there is no search term', 42 | config.maxRecentFileSuggestionsOnInit, 43 | [0, 75, 1], 44 | 'maxRecentFileSuggestionsOnInit', 45 | ); 46 | 47 | this.showExcludeFolders(containerEl, config); 48 | 49 | this.addToggleSetting( 50 | containerEl, 51 | 'Hide Obsidian "Excluded files"', 52 | 'Enabled, do not display suggestions for files that are in Obsidian\'s "Options > Files & Links > Excluded files" list. Disabled, suggestions for those files will be displayed but downranked.', 53 | config.excludeObsidianIgnoredFiles, 54 | 'excludeObsidianIgnoredFiles', 55 | ); 56 | 57 | this.showFileExtAllowList(containerEl, config); 58 | } 59 | 60 | showHeadingSettings(containerEl: HTMLElement, config: SwitcherPlusSettings): void { 61 | const isEnabled = config.shouldSearchHeadings; 62 | 63 | this.addToggleSetting( 64 | containerEl, 65 | 'Search Headings', 66 | "Enabled, search and show suggestions for Headings. Disabled, Don't search through Headings", 67 | isEnabled, 68 | null, 69 | (isEnabled, config) => { 70 | config.shouldSearchHeadings = isEnabled; 71 | 72 | // have to wait for the save here because the call to display() will 73 | // trigger a read of the updated data 74 | config.saveSettings().then( 75 | () => { 76 | // reload the settings panel. This will cause the other option 77 | // controls to be shown/hidden based on isEnabled status 78 | this.mainSettingsTab.display(); 79 | }, 80 | (reason) => 81 | console.log('Switcher++: error saving "Search Headings" setting. ', reason), 82 | ); 83 | }, 84 | ); 85 | 86 | if (isEnabled) { 87 | let setting = this.addToggleSetting( 88 | containerEl, 89 | 'Turn off filename fallback', 90 | 'Enabled, strictly search through only the headings contained in the file. Do not fallback to searching the filename when an H1 match is not found. Disabled, fallback to searching against the filename when there is not a match in the first H1 contained in the file.', 91 | config.strictHeadingsOnly, 92 | 'strictHeadingsOnly', 93 | ); 94 | 95 | setting.setClass('qsp-setting-item-indent'); 96 | 97 | setting = this.addToggleSetting( 98 | containerEl, 99 | 'Search all headings', 100 | 'Enabled, search through all headings contained in each file. Disabled, only search through the first H1 in each file.', 101 | config.searchAllHeadings, 102 | 'searchAllHeadings', 103 | ); 104 | 105 | setting.setClass('qsp-setting-item-indent'); 106 | } 107 | } 108 | 109 | showFileExtAllowList(containerEl: HTMLElement, config: SwitcherPlusSettings): void { 110 | this.createSetting( 111 | containerEl, 112 | 'File extension override', 113 | 'Override the "Show attachments" and the "Show all file types" builtin, system Switcher settings and always search files with the listed extensions. Add one path per line. For example to add ".canvas" file extension, just add "canvas".', 114 | ).addTextArea((textArea) => { 115 | textArea.setValue(config.fileExtAllowList.join('\n')); 116 | textArea.inputEl.addEventListener('focusout', () => { 117 | const allowList = textArea 118 | .getValue() 119 | .split('\n') 120 | .map((v) => v.trim()) 121 | .filter((v) => v.length > 0); 122 | 123 | config.fileExtAllowList = allowList; 124 | config.save(); 125 | }); 126 | }); 127 | } 128 | 129 | showExcludeFolders(containerEl: HTMLElement, config: SwitcherPlusSettings): void { 130 | const settingName = 'Exclude folders'; 131 | 132 | this.createSetting( 133 | containerEl, 134 | settingName, 135 | 'When in Headings list mode, folder path that match any regex listed here will not be searched for suggestions. Path should start from the Vault Root. Add one path per line.', 136 | ).addTextArea((textArea) => { 137 | textArea.setValue(config.excludeFolders.join('\n')); 138 | textArea.inputEl.addEventListener('focusout', () => { 139 | const excludes = textArea 140 | .getValue() 141 | .split('\n') 142 | .filter((v) => v.length > 0); 143 | 144 | if (this.validateExcludeFolderList(settingName, excludes)) { 145 | config.excludeFolders = excludes; 146 | config.save(); 147 | } 148 | }); 149 | }); 150 | } 151 | 152 | validateExcludeFolderList(settingName: string, excludes: string[]) { 153 | let isValid = true; 154 | let failedMsg = ''; 155 | 156 | for (const str of excludes) { 157 | try { 158 | new RegExp(str); 159 | } catch (err) { 160 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 161 | failedMsg += `${str}
${err}

`; 162 | isValid = false; 163 | } 164 | } 165 | 166 | if (!isValid) { 167 | const popup = new Modal(this.app); 168 | popup.titleEl.setText(settingName); 169 | popup.contentEl.innerHTML = `Changes not saved. The following regex contain errors:

${failedMsg}`; 170 | popup.open(); 171 | } 172 | 173 | return isValid; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/settings/index.ts: -------------------------------------------------------------------------------- 1 | export * from './switcherPlusSettings'; 2 | export * from './switcherPlusSettingTab'; 3 | export * from './settingsTabSection'; 4 | export * from './bookmarksSettingsTabSection'; 5 | export * from './relatedItemsSettingsTabSection'; 6 | export * from './commandListSettingsTabSection'; 7 | export * from './generalSettingsTabSection'; 8 | export * from './workspaceSettingsTabSection'; 9 | export * from './editorSettingsTabSection'; 10 | export * from './headingsSettingsTabSection'; 11 | export * from './symbolSettingsTabSection'; 12 | export * from './facetConstants'; 13 | export * from './vaultListSettingsTabSection'; 14 | -------------------------------------------------------------------------------- /src/settings/relatedItemsSettingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { Modal } from 'obsidian'; 2 | import { SwitcherPlusSettings } from 'src/settings'; 3 | import { RelationType } from 'src/types'; 4 | import { SettingsTabSection } from './settingsTabSection'; 5 | 6 | export class RelatedItemsSettingsTabSection extends SettingsTabSection { 7 | display(containerEl: HTMLElement): void { 8 | const { config } = this; 9 | 10 | this.addSectionTitle(containerEl, 'Related Items List Mode Settings'); 11 | 12 | this.addTextSetting( 13 | containerEl, 14 | 'Related Items list mode trigger', 15 | 'Character that will trigger related items list mode in the switcher. This triggers a display of Related Items for the source file of the currently selected (highlighted) suggestion in the switcher. If there is not a suggestion, display results for the active editor.', 16 | config.relatedItemsListCommand, 17 | 'relatedItemsListCommand', 18 | config.relatedItemsListPlaceholderText, 19 | ); 20 | 21 | this.addTextSetting( 22 | containerEl, 23 | 'Related Items list mode trigger - Active editor only', 24 | 'Character that will trigger related items list mode in the switcher. This always triggers a display of Related Items for the active editor only.', 25 | config.relatedItemsListActiveEditorCommand, 26 | 'relatedItemsListActiveEditorCommand', 27 | config.relatedItemsListActiveEditorCommand, 28 | ); 29 | 30 | this.showEnabledRelatedItems(containerEl, config); 31 | 32 | this.addToggleSetting( 33 | containerEl, 34 | 'Exclude open files', 35 | 'Enable, related files which are already open will not be displayed in the list. Disabled, All related files will be displayed in the list.', 36 | config.excludeOpenRelatedFiles, 37 | 'excludeOpenRelatedFiles', 38 | ); 39 | } 40 | 41 | showEnabledRelatedItems(containerEl: HTMLElement, config: SwitcherPlusSettings): void { 42 | const relationTypes = Object.values(RelationType).sort() as string[]; 43 | const relationTypesStr = relationTypes.join(', '); 44 | const desc = `The types of related items to show in the list. Add one type per line. Available types: ${relationTypesStr}`; 45 | 46 | this.createSetting(containerEl, 'Show related item types', desc).addTextArea( 47 | (textArea) => { 48 | textArea.setValue(config.enabledRelatedItems.join('\n')); 49 | 50 | textArea.inputEl.addEventListener('focusout', () => { 51 | const values = textArea 52 | .getValue() 53 | .split('\n') 54 | .map((v) => v.trim()) 55 | .filter((v) => v.length > 0); 56 | 57 | const invalidValues = [...new Set(values)].filter( 58 | (v) => !relationTypes.includes(v), 59 | ); 60 | 61 | if (invalidValues?.length) { 62 | this.showErrorPopup(invalidValues.join('
'), relationTypesStr); 63 | } else { 64 | config.enabledRelatedItems = values as RelationType[]; 65 | config.save(); 66 | } 67 | }); 68 | }, 69 | ); 70 | } 71 | 72 | showErrorPopup(invalidTypes: string, relationTypes: string): void { 73 | const popup = new Modal(this.app); 74 | 75 | popup.titleEl.setText('Invalid related item type'); 76 | popup.contentEl.innerHTML = `Changes not saved. Available relation types are: ${relationTypes}. The following types are invalid:

${invalidTypes}`; 77 | popup.open(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/settings/settingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { SwitcherPlusSettings } from './switcherPlusSettings'; 2 | import { SwitcherPlusSettingTab } from './switcherPlusSettingTab'; 3 | import { App, Setting, SliderComponent } from 'obsidian'; 4 | import { WritableKeysWithValueOfType } from 'src/types'; 5 | import { WritableKeys } from 'ts-essentials'; 6 | 7 | type StringTypedConfigKey = WritableKeysWithValueOfType; 8 | type NumberTypedConfigKey = WritableKeysWithValueOfType; 9 | type BooleanTypedConfigKey = WritableKeysWithValueOfType; 10 | type ListTypedConfigKey = WritableKeysWithValueOfType< 11 | SwitcherPlusSettings, 12 | Array 13 | >; 14 | 15 | export abstract class SettingsTabSection { 16 | constructor( 17 | protected app: App, 18 | protected mainSettingsTab: SwitcherPlusSettingTab, 19 | protected config: SwitcherPlusSettings, 20 | ) {} 21 | 22 | abstract display(containerEl: HTMLElement): void; 23 | 24 | /** 25 | * Creates a new Setting with the given name and description. 26 | * @param {HTMLElement} containerEl 27 | * @param {string} name 28 | * @param {string} desc 29 | * @returns Setting 30 | */ 31 | createSetting(containerEl: HTMLElement, name?: string, desc?: string): Setting { 32 | const setting = new Setting(containerEl); 33 | setting.setName(name); 34 | setting.setDesc(desc); 35 | 36 | return setting; 37 | } 38 | /** 39 | * Create section title elements and divider. 40 | * @param {HTMLElement} containerEl 41 | * @param {string} title 42 | * @param {string} desc? 43 | * @returns Setting 44 | */ 45 | addSectionTitle(containerEl: HTMLElement, title: string, desc = ''): Setting { 46 | const setting = this.createSetting(containerEl, title, desc); 47 | setting.setHeading(); 48 | 49 | return setting; 50 | } 51 | 52 | /** 53 | * Creates a HTMLInput element setting. 54 | * @param {HTMLElement} containerEl The element to attach the setting to. 55 | * @param {string} name 56 | * @param {string} desc 57 | * @param {string} initialValue 58 | * @param {StringTypedConfigKey} configStorageKey The SwitcherPlusSettings key where the value for this setting should be stored. 59 | * @param {string} placeholderText? 60 | * @returns Setting 61 | */ 62 | addTextSetting( 63 | containerEl: HTMLElement, 64 | name: string, 65 | desc: string, 66 | initialValue: string, 67 | configStorageKey: StringTypedConfigKey, 68 | placeholderText?: string, 69 | ): Setting { 70 | const setting = this.createSetting(containerEl, name, desc); 71 | 72 | setting.addText((comp) => { 73 | comp.setPlaceholder(placeholderText); 74 | comp.setValue(initialValue); 75 | 76 | comp.onChange((rawValue) => { 77 | const value = rawValue.length ? rawValue : initialValue; 78 | this.saveChangesToConfig(configStorageKey, value); 79 | }); 80 | }); 81 | 82 | return setting; 83 | } 84 | 85 | /** 86 | * Create a Checkbox element setting. 87 | * @param {HTMLElement} containerEl The element to attach the setting to. 88 | * @param {string} name 89 | * @param {string} desc 90 | * @param {boolean} initialValue 91 | * @param {BooleanTypedConfigKey} configStorageKey The SwitcherPlusSettings key where the value for this setting should be stored. This can safely be set to null if the onChange handler is provided. 92 | * @param {(value:string,config:SwitcherPlusSettings)=>void} onChange? optional callback to invoke instead of using configStorageKey 93 | * @returns Setting 94 | */ 95 | addToggleSetting( 96 | containerEl: HTMLElement, 97 | name: string, 98 | desc: string, 99 | initialValue: boolean, 100 | configStorageKey: BooleanTypedConfigKey, 101 | onChange?: (value: boolean, config: SwitcherPlusSettings) => void, 102 | ): Setting { 103 | const setting = this.createSetting(containerEl, name, desc); 104 | 105 | setting.addToggle((comp) => { 106 | comp.setValue(initialValue); 107 | comp.onChange((value) => { 108 | if (onChange) { 109 | onChange(value, this.config); 110 | } else { 111 | this.saveChangesToConfig(configStorageKey, value); 112 | } 113 | }); 114 | }); 115 | 116 | return setting; 117 | } 118 | 119 | /** 120 | * Create a TextArea element setting. 121 | * @param {HTMLElement} containerEl The element to attach the setting to. 122 | * @param {string} name 123 | * @param {string} desc 124 | * @param {string} initialValue 125 | * @param {ListTypedConfigKey|StringTypedConfigKey} configStorageKey The SwitcherPlusSettings key where the value for this setting should be stored. 126 | * @param {string} placeholderText? 127 | * @returns Setting 128 | */ 129 | addTextAreaSetting( 130 | containerEl: HTMLElement, 131 | name: string, 132 | desc: string, 133 | initialValue: string, 134 | configStorageKey: ListTypedConfigKey | StringTypedConfigKey, 135 | placeholderText?: string, 136 | ): Setting { 137 | const setting = this.createSetting(containerEl, name, desc); 138 | 139 | setting.addTextArea((comp) => { 140 | comp.setPlaceholder(placeholderText); 141 | comp.setValue(initialValue); 142 | 143 | comp.onChange((rawValue) => { 144 | const value = rawValue.length ? rawValue : initialValue; 145 | const isArray = Array.isArray(this.config[configStorageKey]); 146 | this.saveChangesToConfig(configStorageKey, isArray ? value.split('\n') : value); 147 | }); 148 | }); 149 | 150 | return setting; 151 | } 152 | 153 | /** 154 | * Add a dropdown list setting 155 | * @param {HTMLElement} containerEl 156 | * @param {string} name 157 | * @param {string} desc 158 | * @param {string} initialValue option value that is initially selected 159 | * @param {Record} options 160 | * @param {StringTypedConfigKey} configStorageKey The SwitcherPlusSettings key where the value for this setting should be stored. This can safely be set to null if the onChange handler is provided. 161 | * @param {(rawValue:string,config:SwitcherPlusSettings)=>void} onChange? optional callback to invoke instead of using configStorageKey 162 | * @returns Setting 163 | */ 164 | addDropdownSetting( 165 | containerEl: HTMLElement, 166 | name: string, 167 | desc: string, 168 | initialValue: string, 169 | options: Record, 170 | configStorageKey: StringTypedConfigKey, 171 | onChange?: (rawValue: string, config: SwitcherPlusSettings) => void, 172 | ): Setting { 173 | const setting = this.createSetting(containerEl, name, desc); 174 | 175 | setting.addDropdown((comp) => { 176 | comp.addOptions(options); 177 | comp.setValue(initialValue); 178 | 179 | comp.onChange((rawValue) => { 180 | if (onChange) { 181 | onChange(rawValue, this.config); 182 | } else { 183 | this.saveChangesToConfig(configStorageKey, rawValue); 184 | } 185 | }); 186 | }); 187 | 188 | return setting; 189 | } 190 | 191 | addSliderSetting( 192 | containerEl: HTMLElement, 193 | name: string, 194 | desc: string, 195 | initialValue: number, 196 | limits: [min: number, max: number, step: number], 197 | configStorageKey: NumberTypedConfigKey, 198 | onChange?: (value: number, config: SwitcherPlusSettings) => void, 199 | ): Setting { 200 | const setting = this.createSetting(containerEl, name, desc); 201 | 202 | // display a button to reset the slider value 203 | setting.addExtraButton((comp) => { 204 | comp.setIcon('lucide-rotate-ccw'); 205 | comp.setTooltip('Restore default'); 206 | comp.onClick(() => (setting.components[1] as SliderComponent).setValue(0)); 207 | return comp; 208 | }); 209 | 210 | setting.addSlider((comp) => { 211 | comp.setLimits(limits[0], limits[1], limits[2]); 212 | comp.setValue(initialValue); 213 | comp.setDynamicTooltip(); 214 | 215 | comp.onChange((value) => { 216 | if (onChange) { 217 | onChange(value, this.config); 218 | } else { 219 | this.saveChangesToConfig(configStorageKey, value); 220 | } 221 | }); 222 | }); 223 | 224 | return setting; 225 | } 226 | 227 | /** 228 | * Updates the internal SwitcherPlusSettings configStorageKey with value, and writes it to disk. 229 | * @param {K} configStorageKey The SwitcherPlusSettings key where the value for this setting should be stored. 230 | * @param {SwitcherPlusSettings[K]} value 231 | * @returns void 232 | */ 233 | saveChangesToConfig>( 234 | configStorageKey: K, 235 | value: SwitcherPlusSettings[K], 236 | ): void { 237 | if (configStorageKey) { 238 | const { config } = this; 239 | config[configStorageKey] = value; 240 | config.save(); 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/settings/switcherPlusSettingTab.ts: -------------------------------------------------------------------------------- 1 | import { SettingsTabSection } from './settingsTabSection'; 2 | import { BookmarksSettingsTabSection } from './bookmarksSettingsTabSection'; 3 | import { CommandListSettingsTabSection } from './commandListSettingsTabSection'; 4 | import { RelatedItemsSettingsTabSection } from './relatedItemsSettingsTabSection'; 5 | import { GeneralSettingsTabSection } from './generalSettingsTabSection'; 6 | import { WorkspaceSettingsTabSection } from './workspaceSettingsTabSection'; 7 | import { EditorSettingsTabSection } from './editorSettingsTabSection'; 8 | import { HeadingsSettingsTabSection } from './headingsSettingsTabSection'; 9 | import { SymbolSettingsTabSection } from './symbolSettingsTabSection'; 10 | import { VaultListSettingsTabSection } from './vaultListSettingsTabSection'; 11 | import { SwitcherPlusSettings } from './switcherPlusSettings'; 12 | import { App, PluginSettingTab } from 'obsidian'; 13 | import type SwitcherPlusPlugin from '../main'; 14 | 15 | type ConstructableSettingsTabSection = { 16 | new ( 17 | app: App, 18 | mainSettingsTab: SwitcherPlusSettingTab, 19 | config: SwitcherPlusSettings, 20 | ): SettingsTabSection; 21 | }; 22 | 23 | export class SwitcherPlusSettingTab extends PluginSettingTab { 24 | constructor( 25 | app: App, 26 | public plugin: SwitcherPlusPlugin, 27 | private config: SwitcherPlusSettings, 28 | ) { 29 | super(app, plugin); 30 | } 31 | 32 | display(): void { 33 | const { containerEl } = this; 34 | const tabSections = [ 35 | GeneralSettingsTabSection, 36 | SymbolSettingsTabSection, 37 | HeadingsSettingsTabSection, 38 | EditorSettingsTabSection, 39 | RelatedItemsSettingsTabSection, 40 | BookmarksSettingsTabSection, 41 | CommandListSettingsTabSection, 42 | WorkspaceSettingsTabSection, 43 | VaultListSettingsTabSection, 44 | ]; 45 | 46 | containerEl.empty(); 47 | containerEl.createEl('h2', { text: 'Quick Switcher++ Settings' }); 48 | 49 | tabSections.forEach((tabSectionClass) => { 50 | this.displayTabSection(tabSectionClass); 51 | }); 52 | } 53 | 54 | displayTabSection(tabSectionClass: ConstructableSettingsTabSection): void { 55 | const { app, config, containerEl } = this; 56 | const tabSection = new tabSectionClass(app, this, config); 57 | tabSection.display(containerEl); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/settings/symbolSettingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { SwitcherPlusSettings } from './switcherPlusSettings'; 2 | import { SettingsTabSection } from './settingsTabSection'; 3 | import { LinkType, SymbolType } from 'src/types'; 4 | 5 | export class SymbolSettingsTabSection extends SettingsTabSection { 6 | display(containerEl: HTMLElement): void { 7 | const { config } = this; 8 | 9 | this.addSectionTitle(containerEl, 'Symbol List Mode Settings'); 10 | 11 | this.addTextSetting( 12 | containerEl, 13 | 'Symbol list mode trigger', 14 | 'Character that will trigger symbol list mode in the switcher. This triggers a display of Symbols for the source file of the currently selected (highlighted) suggestion in the switcher. If there is not a suggestion, display results for the active editor.', 15 | config.symbolListCommand, 16 | 'symbolListCommand', 17 | config.symbolListPlaceholderText, 18 | ); 19 | 20 | this.addTextSetting( 21 | containerEl, 22 | 'Symbol list mode trigger - Active editor only', 23 | 'Character that will trigger symbol list mode in the switcher. This always triggers a display of Symbols for the active editor only.', 24 | config.symbolListActiveEditorCommand, 25 | 'symbolListActiveEditorCommand', 26 | config.symbolListActiveEditorCommand, 27 | ); 28 | 29 | this.addToggleSetting( 30 | containerEl, 31 | 'List symbols as indented outline', 32 | 'Enabled, symbols will be displayed in the (line) order they appear in the source text, indented under any preceding heading. Disabled, symbols will be grouped by type: Headings, Tags, Links, Embeds.', 33 | config.symbolsInLineOrder, 34 | 'symbolsInLineOrder', 35 | ); 36 | 37 | this.addToggleSetting( 38 | containerEl, 39 | 'Open Symbols in new tab', 40 | 'Enabled, always open a new tab when navigating to Symbols. Disabled, navigate in an already open tab (if one exists).', 41 | config.alwaysNewTabForSymbols, 42 | 'alwaysNewTabForSymbols', 43 | ); 44 | 45 | this.addToggleSetting( 46 | containerEl, 47 | 'Open Symbols in active tab on mobile devices', 48 | 'Enabled, navigate to the target file and symbol in the active editor tab. Disabled, open a new tab when navigating to Symbols, even on mobile devices.', 49 | config.useActiveTabForSymbolsOnMobile, 50 | 'useActiveTabForSymbolsOnMobile', 51 | ); 52 | 53 | this.addToggleSetting( 54 | containerEl, 55 | 'Auto-select nearest heading', 56 | 'Enabled, in an unfiltered symbol list, select the closest preceding Heading to the current cursor position. Disabled, the first symbol in the list is selected.', 57 | config.selectNearestHeading, 58 | 'selectNearestHeading', 59 | ); 60 | 61 | this.showEnableSymbolTypesToggle(containerEl, config); 62 | this.showEnableLinksToggle(containerEl, config); 63 | } 64 | 65 | showEnableSymbolTypesToggle( 66 | containerEl: HTMLElement, 67 | config: SwitcherPlusSettings, 68 | ): void { 69 | const allowedSymbols: [string, SymbolType][] = [ 70 | ['Show Headings', SymbolType.Heading], 71 | ['Show Tags', SymbolType.Tag], 72 | ['Show Embeds', SymbolType.Embed], 73 | ['Show Callouts', SymbolType.Callout], 74 | ]; 75 | 76 | allowedSymbols.forEach(([name, symbolType]) => { 77 | this.addToggleSetting( 78 | containerEl, 79 | name, 80 | '', 81 | config.isSymbolTypeEnabled(symbolType), 82 | null, 83 | (isEnabled) => { 84 | config.setSymbolTypeEnabled(symbolType, isEnabled); 85 | config.save(); 86 | }, 87 | ); 88 | }); 89 | } 90 | 91 | showEnableLinksToggle(containerEl: HTMLElement, config: SwitcherPlusSettings): void { 92 | const isLinksEnabled = config.isSymbolTypeEnabled(SymbolType.Link); 93 | 94 | this.addToggleSetting( 95 | containerEl, 96 | 'Show Links', 97 | '', 98 | isLinksEnabled, 99 | null, 100 | (isEnabled) => { 101 | config.setSymbolTypeEnabled(SymbolType.Link, isEnabled); 102 | 103 | // have to wait for the save here because the call to display() will 104 | // trigger a read of the updated data 105 | config.saveSettings().then( 106 | () => { 107 | // reload the settings panel. This will cause the sublink types toggle 108 | // controls to be shown/hidden based on isLinksEnabled status 109 | this.mainSettingsTab.display(); 110 | }, 111 | (reason) => 112 | console.log('Switcher++: error saving "Show Links" setting. ', reason), 113 | ); 114 | }, 115 | ); 116 | 117 | if (isLinksEnabled) { 118 | const allowedLinkTypes: [string, number][] = [ 119 | ['Links to headings', LinkType.Heading], 120 | ['Links to blocks', LinkType.Block], 121 | ]; 122 | 123 | allowedLinkTypes.forEach(([name, linkType]) => { 124 | const isExcluded = (config.excludeLinkSubTypes & linkType) === linkType; 125 | const setting = this.addToggleSetting( 126 | containerEl, 127 | name, 128 | '', 129 | !isExcluded, 130 | null, 131 | (isEnabled) => this.saveEnableSubLinkChange(linkType, isEnabled), 132 | ); 133 | 134 | setting.setClass('qsp-setting-item-indent'); 135 | }); 136 | } 137 | } 138 | 139 | saveEnableSubLinkChange(linkType: LinkType, isEnabled: boolean): void { 140 | const { config } = this; 141 | let exclusions = config.excludeLinkSubTypes; 142 | 143 | if (isEnabled) { 144 | // remove from exclusion list 145 | exclusions &= ~linkType; 146 | } else { 147 | // add to exclusion list 148 | exclusions |= linkType; 149 | } 150 | 151 | config.excludeLinkSubTypes = exclusions; 152 | config.save(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/settings/vaultListSettingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { SettingsTabSection } from './settingsTabSection'; 2 | 3 | export class VaultListSettingsTabSection extends SettingsTabSection { 4 | display(containerEl: HTMLElement): void { 5 | const { config } = this; 6 | 7 | const titleSetting = this.addSectionTitle(containerEl, 'Vault List Mode Settings'); 8 | titleSetting.nameEl?.createSpan({ 9 | cls: ['qsp-tag', 'qsp-warning'], 10 | text: 'Experimental', 11 | }); 12 | 13 | this.addTextSetting( 14 | containerEl, 15 | 'Vault list mode trigger', 16 | 'Character that will trigger vault list mode in the switcher', 17 | config.vaultListCommand, 18 | 'vaultListCommand', 19 | config.vaultListPlaceholderText, 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/settings/workspaceSettingsTabSection.ts: -------------------------------------------------------------------------------- 1 | import { SettingsTabSection } from './settingsTabSection'; 2 | 3 | export class WorkspaceSettingsTabSection extends SettingsTabSection { 4 | display(containerEl: HTMLElement): void { 5 | const { config } = this; 6 | 7 | this.addSectionTitle(containerEl, 'Workspace List Mode Settings'); 8 | 9 | this.addTextSetting( 10 | containerEl, 11 | 'Workspace list mode trigger', 12 | 'Character that will trigger workspace list mode in the switcher', 13 | config.workspaceListCommand, 14 | 'workspaceListCommand', 15 | config.workspaceListPlaceholderText, 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/switcherPlus/__tests__/mobileLauncher.test.ts: -------------------------------------------------------------------------------- 1 | import { Chance } from 'chance'; 2 | import { MockProxy, mock, mockFn, mockClear } from 'jest-mock-extended'; 3 | import { App, Platform, setIcon } from 'obsidian'; 4 | import { MobileLauncherConfig } from 'src/types'; 5 | import { MobileLauncher } from 'src/switcherPlus'; 6 | import { SwitcherPlusSettings } from 'src/settings'; 7 | 8 | const chance = new Chance(); 9 | 10 | describe('MobileLauncher', () => { 11 | let mockApp: MockProxy; 12 | let mockPlatform: MockProxy; 13 | let mockNavbarContainerEl: MockProxy; 14 | const sut = MobileLauncher; 15 | 16 | beforeAll(() => { 17 | mockNavbarContainerEl = mock(); 18 | mockApp = mock({ 19 | mobileNavbar: { 20 | containerEl: mockNavbarContainerEl, 21 | }, 22 | }); 23 | mockPlatform = jest.mocked(Platform); 24 | }); 25 | 26 | describe('removeMobileLauncherOverride', () => { 27 | let mockCoreButtonEl: MockProxy; 28 | let mockQspButtonEl: MockProxy; 29 | 30 | beforeAll(() => { 31 | mockQspButtonEl = mock(); 32 | mockCoreButtonEl = mock(); 33 | }); 34 | 35 | afterEach(() => { 36 | mockClear(mockCoreButtonEl); 37 | mockClear(mockQspButtonEl); 38 | 39 | sut.coreMobileLauncherButtonEl = null; 40 | sut.qspMobileLauncherButtonEl = null; 41 | }); 42 | 43 | it('should remove the custom launcher from the DOM and add the core launcher so only one is visible at a time', () => { 44 | sut.coreMobileLauncherButtonEl = mockCoreButtonEl; 45 | sut.qspMobileLauncherButtonEl = mockQspButtonEl; 46 | 47 | mockQspButtonEl.insertAdjacentElement.mockReturnValueOnce(mockCoreButtonEl); 48 | 49 | const result = sut.removeMobileLauncherOverride(); 50 | 51 | expect(result).toBe(true); 52 | expect(sut.coreMobileLauncherButtonEl).toBeNull(); 53 | expect(sut.qspMobileLauncherButtonEl).toBeNull(); 54 | expect(mockQspButtonEl.remove).toHaveBeenCalled(); 55 | expect(mockQspButtonEl.insertAdjacentElement).toHaveBeenCalledWith( 56 | 'beforebegin', 57 | mockCoreButtonEl, 58 | ); 59 | 60 | mockQspButtonEl.insertAdjacentElement.mockReset(); 61 | }); 62 | 63 | it("should fail if a reference to the core launcher doesn't exist", () => { 64 | sut.coreMobileLauncherButtonEl = null; 65 | 66 | const result = sut.removeMobileLauncherOverride(); 67 | 68 | expect(result).toBe(false); 69 | }); 70 | }); 71 | 72 | describe('installMobileLauncherOverride', () => { 73 | let launcherConfig: MockProxy; 74 | let mockCoreButtonEl: MockProxy; 75 | let mockQspButtonEl: MockProxy; 76 | 77 | beforeAll(() => { 78 | launcherConfig = mock({ isEnabled: true }); 79 | mockQspButtonEl = mock(); 80 | mockCoreButtonEl = mock(); 81 | 82 | // .cloneNode is how the qsp button is created 83 | mockCoreButtonEl.cloneNode.mockReturnValue(mockQspButtonEl); 84 | 85 | // .insertAdjacentElement is how the qsp button is inserted into the DOM 86 | mockCoreButtonEl.insertAdjacentElement.mockReturnValue(mockQspButtonEl); 87 | 88 | // Used for finding the core button in DOM 89 | launcherConfig.coreLauncherButtonSelector = chance.word(); 90 | mockNavbarContainerEl.querySelector 91 | .calledWith(launcherConfig.coreLauncherButtonSelector) 92 | .mockReturnValue(mockCoreButtonEl); 93 | }); 94 | 95 | afterEach(() => { 96 | mockClear(mockNavbarContainerEl); 97 | mockClear(mockCoreButtonEl); 98 | mockClear(mockQspButtonEl); 99 | 100 | sut.coreMobileLauncherButtonEl = null; 101 | sut.qspMobileLauncherButtonEl = null; 102 | }); 103 | 104 | it('should not install if the Platform is not mobile', () => { 105 | mockPlatform.isMobile = false; 106 | 107 | const results = sut.installMobileLauncherOverride(mockApp, null, null); 108 | 109 | expect(results).toBeNull(); 110 | expect(mockNavbarContainerEl.querySelector).not.toHaveBeenCalled(); 111 | 112 | mockPlatform.isMobile = true; 113 | }); 114 | 115 | it('should not install if the launcher is disabled', () => { 116 | launcherConfig.isEnabled = false; 117 | 118 | const results = sut.installMobileLauncherOverride(mockApp, launcherConfig, null); 119 | 120 | expect(results).toBeNull(); 121 | expect(mockNavbarContainerEl.querySelector).not.toHaveBeenCalled(); 122 | 123 | launcherConfig.isEnabled = true; 124 | }); 125 | 126 | it('should not install if the core button is already overridden', () => { 127 | sut.coreMobileLauncherButtonEl = mock(); 128 | 129 | const results = sut.installMobileLauncherOverride(mockApp, launcherConfig, null); 130 | 131 | expect(results).toBeNull(); 132 | expect(mockNavbarContainerEl.querySelector).not.toHaveBeenCalled(); 133 | 134 | sut.coreMobileLauncherButtonEl = null; 135 | }); 136 | 137 | it('should create a a custom launcher button by cloning the core launcher button', () => { 138 | const clickHandler = mockFn(); 139 | 140 | const result = sut.installMobileLauncherOverride( 141 | mockApp, 142 | launcherConfig, 143 | clickHandler, 144 | ); 145 | 146 | expect(result).toBe(mockQspButtonEl); 147 | expect(mockQspButtonEl.addClass).toHaveBeenCalledWith('qsp-mobile-launcher-button'); 148 | expect(mockQspButtonEl.addEventListener).toHaveBeenCalledWith( 149 | 'click', 150 | clickHandler, 151 | ); 152 | }); 153 | 154 | test('custom launcher button should use a custom icon when iconName is provided', () => { 155 | const mockIconEl = mock(); 156 | const mockSetIcon = jest.mocked(setIcon); 157 | launcherConfig.iconName = chance.word(); 158 | launcherConfig.coreLauncherButtonIconSelector = chance.word(); 159 | 160 | mockQspButtonEl.querySelector 161 | .calledWith(launcherConfig.coreLauncherButtonIconSelector) 162 | .mockReturnValueOnce(mockIconEl); 163 | 164 | const result = sut.installMobileLauncherOverride(mockApp, launcherConfig, null); 165 | 166 | expect(result).toBe(mockQspButtonEl); 167 | expect(mockSetIcon).toHaveBeenCalledWith(mockIconEl, launcherConfig.iconName); 168 | 169 | mockQspButtonEl.querySelector.mockReset(); 170 | launcherConfig.iconName = ''; 171 | launcherConfig.coreLauncherButtonIconSelector = ''; 172 | }); 173 | 174 | it('should insert the custom launcher into the DOM and remove the core launcher so only one is visible at a time', () => { 175 | mockClear(mockCoreButtonEl); 176 | mockClear(mockQspButtonEl); 177 | 178 | sut.installMobileLauncherOverride(mockApp, launcherConfig, null); 179 | 180 | expect(sut.coreMobileLauncherButtonEl).toBe(mockCoreButtonEl); 181 | expect(sut.qspMobileLauncherButtonEl).toBe(mockQspButtonEl); 182 | expect(mockCoreButtonEl.remove).toHaveBeenCalled(); 183 | expect(mockCoreButtonEl.insertAdjacentElement).toHaveBeenCalledWith( 184 | 'beforebegin', 185 | mockQspButtonEl, 186 | ); 187 | }); 188 | 189 | test('when the core launcher button is not found using the default selector, it should try using the selector stored in settings', () => { 190 | const mockContainerEl = mock(); 191 | const defaultSelector = 192 | SwitcherPlusSettings.defaults.mobileLauncher.coreLauncherButtonSelector; 193 | 194 | const localApp = mock({ 195 | mobileNavbar: { 196 | containerEl: mockContainerEl, 197 | }, 198 | }); 199 | 200 | // return null when called using default selector 201 | mockContainerEl.querySelector.calledWith(defaultSelector).mockReturnValue(null); 202 | 203 | // return expected value when called using stored selector 204 | mockContainerEl.querySelector 205 | .calledWith(launcherConfig.coreLauncherButtonSelector) 206 | .mockReturnValue(mockCoreButtonEl); 207 | 208 | const result = sut.installMobileLauncherOverride(localApp, launcherConfig, null); 209 | 210 | expect(result).toBe(mockQspButtonEl); 211 | expect(mockContainerEl.querySelector).toHaveBeenCalledWith(defaultSelector); 212 | expect(mockContainerEl.querySelector).toHaveBeenCalledWith( 213 | launcherConfig.coreLauncherButtonSelector, 214 | ); 215 | }); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /src/switcherPlus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './modeHandler'; 2 | export * from './switcherPlus'; 3 | export * from './switcherPlusKeymap'; 4 | export * from './inputInfo'; 5 | export * from './mobileLauncher'; 6 | -------------------------------------------------------------------------------- /src/switcherPlus/inputInfo.ts: -------------------------------------------------------------------------------- 1 | import { TFile, WorkspaceLeaf } from 'obsidian'; 2 | import { getModeNames, getSourcedModes } from 'src/utils'; 3 | import { Mode, SourceInfo, SearchQuery, SessionOpts, BookmarksItemInfo } from 'src/types'; 4 | 5 | export interface WorkspaceEnvList { 6 | openWorkspaceLeaves: Set; 7 | openWorkspaceFiles: Set; 8 | fileBookmarks: Map; 9 | nonFileBookmarks: Set; 10 | mostRecentFiles: Set; 11 | attachmentFileExtensions: Set; 12 | } 13 | 14 | export interface ParsedCommand { 15 | isValidated: boolean; 16 | index: number; 17 | parsedInput: string; 18 | } 19 | 20 | export interface SourcedParsedCommand extends ParsedCommand { 21 | source: SourceInfo; 22 | } 23 | 24 | export class InputInfo { 25 | private parsedCommands: Record; 26 | private _inputTextSansEscapeChar: string = null; 27 | 28 | static get defaultParsedCommand(): ParsedCommand { 29 | return { 30 | isValidated: false, 31 | index: -1, 32 | parsedInput: null, 33 | }; 34 | } 35 | 36 | sessionOpts: SessionOpts; 37 | readonly currentWorkspaceEnvList: WorkspaceEnvList = { 38 | openWorkspaceLeaves: new Set(), 39 | openWorkspaceFiles: new Set(), 40 | fileBookmarks: new Map(), 41 | nonFileBookmarks: new Set(), 42 | mostRecentFiles: new Set(), 43 | attachmentFileExtensions: new Set(), 44 | }; 45 | 46 | get parsedInputQuery(): SearchQuery { 47 | const query = (this.parsedCommand()?.parsedInput ?? '').trim().toLowerCase(); 48 | 49 | return { 50 | query, 51 | hasSearchTerm: !!query.length, 52 | }; 53 | } 54 | 55 | /** 56 | * If it exists, returns a version of inputText that has been stripped of the 57 | * custom mode escape command char. Otherwise, returns raw inputText. 58 | * 59 | * @type {string} 60 | */ 61 | get inputTextSansEscapeChar(): string { 62 | return this._inputTextSansEscapeChar ?? this.inputText; 63 | } 64 | 65 | set inputTextSansEscapeChar(value: string) { 66 | this._inputTextSansEscapeChar = value; 67 | } 68 | 69 | constructor( 70 | public inputText = '', 71 | public mode = Mode.Standard, 72 | sessionOpts?: SessionOpts, 73 | ) { 74 | this.sessionOpts = sessionOpts ?? {}; 75 | 76 | const sourcedModes = getSourcedModes(); 77 | this.parsedCommands = {} as Record; 78 | 79 | // Initialize .parsedCommands with an object for each mode 80 | getModeNames().forEach((modeName) => { 81 | const modeValue = Mode[modeName]; 82 | 83 | if (sourcedModes.includes(modeValue)) { 84 | // Initialize the additional properties for sourced commands. 85 | (this.parsedCommands[modeValue] as SourcedParsedCommand) = { 86 | ...InputInfo.defaultParsedCommand, 87 | source: null, 88 | }; 89 | } else { 90 | this.parsedCommands[modeValue] = InputInfo.defaultParsedCommand; 91 | } 92 | }); 93 | } 94 | 95 | parsedCommand(mode?: Mode): ParsedCommand { 96 | mode = mode ?? this.mode; 97 | return this.parsedCommands[mode]; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/switcherPlus/mobileLauncher.ts: -------------------------------------------------------------------------------- 1 | import { App, Platform, setIcon } from 'obsidian'; 2 | import { SwitcherPlusSettings } from 'src/settings'; 3 | import { MobileLauncherConfig } from 'src/types'; 4 | 5 | /** 6 | * Creates a custom launcher button element by cloning then modifying coreLauncherButtonEl 7 | * @param {Element} coreLauncherButtonEl the ootb system launcher button element 8 | * @param {MobileLauncherConfig} launcherConfig 9 | * @param {()=>void} onclickListener event handler to attach to the new custom button 10 | * @returns HTMLElement the new custom button element that was created 11 | */ 12 | function createQSPLauncherButton( 13 | coreLauncherButtonEl: Element, 14 | launcherConfig: MobileLauncherConfig, 15 | onclickListener: () => void, 16 | ): HTMLElement { 17 | let qspLauncherButtonEl: HTMLElement = null; 18 | 19 | if (coreLauncherButtonEl) { 20 | // April 2024: cloneNode(true) should perform a deep copy, but does not copy 21 | // any event handlers that were attached using addEventListener(), which 22 | // corePlusButtonEl does use, so it can be safely cloned. 23 | // Additionally, cloneNode() will copy element ID/Name as well which could result 24 | // in duplicates, but corePlusButtonEl does not contain ID/Name so it's also safe 25 | qspLauncherButtonEl = coreLauncherButtonEl.cloneNode(true) as HTMLElement; 26 | 27 | if (qspLauncherButtonEl) { 28 | const { iconName, coreLauncherButtonIconSelector } = launcherConfig; 29 | 30 | qspLauncherButtonEl.addClass('qsp-mobile-launcher-button'); 31 | qspLauncherButtonEl.addEventListener('click', onclickListener); 32 | 33 | if (iconName?.length) { 34 | // Override the core icon, if a custom icon file name is provided 35 | const iconEl = qspLauncherButtonEl.querySelector(coreLauncherButtonIconSelector); 36 | 37 | if (iconEl) { 38 | setIcon(iconEl as HTMLElement, iconName); 39 | } 40 | } 41 | } 42 | } 43 | 44 | return qspLauncherButtonEl; 45 | } 46 | /** 47 | * Remove coreButtonEl from DOM and replaces it with qspButtonEl 48 | * @param {Element} coreButtonEl 49 | * @param {HTMLElement} qspButtonEl 50 | * @returns boolean True if succeeded 51 | */ 52 | function replaceCoreLauncherButtonWithQSPButton( 53 | coreButtonEl: Element, 54 | qspButtonEl: HTMLElement, 55 | ): boolean { 56 | let isSuccessful = false; 57 | 58 | if (coreButtonEl && qspButtonEl) { 59 | // Hide the button before adding to DOM 60 | const initialDisplay = qspButtonEl.style.display; 61 | qspButtonEl.style.display = 'none'; 62 | 63 | if (coreButtonEl.insertAdjacentElement('beforebegin', qspButtonEl)) { 64 | coreButtonEl.remove(); 65 | isSuccessful = true; 66 | } 67 | 68 | qspButtonEl.style.display = initialDisplay; 69 | } 70 | 71 | return isSuccessful; 72 | } 73 | /** 74 | * Finds the "⊕" button element using the default selector. 75 | * If that fails, retries using the selector stored in settings 76 | * @param {App} app 77 | * @param {MobileLauncherConfig} launcherConfig 78 | * @returns Element The button Element 79 | */ 80 | function getCoreLauncherButtonElement( 81 | app: App, 82 | launcherConfig: MobileLauncherConfig, 83 | ): Element { 84 | let coreLauncherButtonEl: Element = null; 85 | const containerEl = app?.mobileNavbar?.containerEl; 86 | 87 | if (containerEl) { 88 | coreLauncherButtonEl = containerEl.querySelector( 89 | SwitcherPlusSettings.defaults.mobileLauncher.coreLauncherButtonSelector, 90 | ); 91 | 92 | if (!coreLauncherButtonEl) { 93 | // Element wasn't found using the default selector, try using the custom selector 94 | coreLauncherButtonEl = containerEl.querySelector( 95 | launcherConfig.coreLauncherButtonSelector, 96 | ); 97 | } 98 | } 99 | 100 | return coreLauncherButtonEl; 101 | } 102 | 103 | export class MobileLauncher { 104 | // Reference to the system switcher launcher button on mobile platforms, 105 | // the "plus" button in the NavBar. 106 | static coreMobileLauncherButtonEl: HTMLElement | null; 107 | 108 | // Reference to the custom launcher button that was created 109 | static qspMobileLauncherButtonEl: HTMLElement | null; 110 | 111 | /** 112 | * Overrides the default functionality of the "⊕" button on mobile platforms 113 | * to launch Switcher++ instead of the default system switcher. 114 | * @param {App} app 115 | * @param {MobileLauncherConfig} launcherConfig 116 | * @param {()=>void} onclickListener event handler to attach to the new custom button 117 | * @returns HTMLElement the new launcher button element if created 118 | */ 119 | static installMobileLauncherOverride( 120 | app: App, 121 | launcherConfig: MobileLauncherConfig, 122 | onclickListener: () => void, 123 | ): HTMLElement { 124 | let qspLauncherButtonEl: HTMLElement = null; 125 | 126 | // If it's not a mobile platform, or the override feature is disabled, or the 127 | // core launcher has already been overridden then do nothing. 128 | if ( 129 | !Platform.isMobile || 130 | !launcherConfig.isEnabled || 131 | MobileLauncher.coreMobileLauncherButtonEl 132 | ) { 133 | return null; 134 | } 135 | 136 | const coreLauncherButtonEl = getCoreLauncherButtonElement(app, launcherConfig); 137 | if (coreLauncherButtonEl) { 138 | const qspButtonEl = createQSPLauncherButton( 139 | coreLauncherButtonEl, 140 | launcherConfig, 141 | onclickListener, 142 | ); 143 | 144 | if (replaceCoreLauncherButtonWithQSPButton(coreLauncherButtonEl, qspButtonEl)) { 145 | MobileLauncher.coreMobileLauncherButtonEl = coreLauncherButtonEl as HTMLElement; 146 | MobileLauncher.qspMobileLauncherButtonEl = qspButtonEl; 147 | qspLauncherButtonEl = qspButtonEl; 148 | } 149 | } 150 | 151 | return qspLauncherButtonEl; 152 | } 153 | /** 154 | * Restores the default functionality of the "⊕" button on mobile platforms and 155 | * removes the custom launcher button. 156 | * @returns boolean true if successful 157 | */ 158 | static removeMobileLauncherOverride(): boolean { 159 | let isSuccessful = false; 160 | 161 | if (!MobileLauncher.coreMobileLauncherButtonEl) { 162 | return isSuccessful; 163 | } 164 | 165 | if (MobileLauncher.qspMobileLauncherButtonEl?.parentElement) { 166 | const qspButtonEl = MobileLauncher.qspMobileLauncherButtonEl; 167 | const coreButtonEl = MobileLauncher.coreMobileLauncherButtonEl; 168 | 169 | const initialDisplay = coreButtonEl.style.display; 170 | coreButtonEl.style.display = 'none'; 171 | 172 | if (qspButtonEl.insertAdjacentElement('beforebegin', coreButtonEl)) { 173 | qspButtonEl.remove(); 174 | 175 | MobileLauncher.qspMobileLauncherButtonEl = null; 176 | MobileLauncher.coreMobileLauncherButtonEl = null; 177 | isSuccessful = true; 178 | } 179 | 180 | coreButtonEl.style.display = initialDisplay; 181 | } 182 | 183 | return isSuccessful; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/switcherPlus/switcherPlus.ts: -------------------------------------------------------------------------------- 1 | import { SwitcherPlusKeymap } from './switcherPlusKeymap'; 2 | import { getSystemSwitcherInstance } from 'src/utils'; 3 | import { ModeHandler } from './modeHandler'; 4 | import SwitcherPlusPlugin from 'src/main'; 5 | import { App, QuickSwitcherOptions } from 'obsidian'; 6 | import { 7 | SystemSwitcher, 8 | SwitcherPlus, 9 | AnySuggestion, 10 | Mode, 11 | SessionOpts, 12 | ModeDispatcher, 13 | } from 'src/types'; 14 | 15 | interface SystemSwitcherConstructor extends SystemSwitcher { 16 | new (app: App, builtInOptions: QuickSwitcherOptions): SystemSwitcher; 17 | } 18 | 19 | export function createSwitcherPlus(app: App, plugin: SwitcherPlusPlugin): SwitcherPlus { 20 | const SystemSwitcherModal = getSystemSwitcherInstance(app) 21 | ?.QuickSwitcherModal as SystemSwitcherConstructor; 22 | 23 | if (!SystemSwitcherModal) { 24 | console.log( 25 | 'Switcher++: unable to extend system switcher. Plugin UI will not be loaded. Use the builtin switcher instead.', 26 | ); 27 | return null; 28 | } 29 | 30 | const SwitcherPlusModal = class extends SystemSwitcherModal implements SwitcherPlus { 31 | private _exMode: ModeDispatcher; 32 | get exMode(): ModeDispatcher { 33 | return this._exMode; 34 | } 35 | 36 | constructor( 37 | app: App, 38 | public plugin: SwitcherPlusPlugin, 39 | ) { 40 | super(app, plugin.options.builtInSystemOptions); 41 | 42 | const { options } = plugin; 43 | options.shouldShowAlias = this.shouldShowAlias; 44 | const exKeymap = new SwitcherPlusKeymap( 45 | app, 46 | this.scope, 47 | this.chooser, 48 | this, 49 | options, 50 | ); 51 | this._exMode = new ModeHandler(app, options, exKeymap); 52 | } 53 | 54 | openInMode(mode: Mode, sessionOpts?: SessionOpts): void { 55 | this.exMode.setSessionOpenMode(mode, this.chooser, sessionOpts); 56 | super.open(); 57 | } 58 | 59 | onOpen(): void { 60 | this.exMode.onOpen(); 61 | super.onOpen(); 62 | } 63 | 64 | onClose() { 65 | super.onClose(); 66 | this.exMode.onClose(); 67 | } 68 | 69 | protected updateSuggestions(): void { 70 | const { exMode, inputEl, chooser } = this; 71 | exMode.insertSessionOpenModeOrLastInputString(inputEl); 72 | 73 | if (!exMode.updateSuggestions(inputEl.value, chooser, this)) { 74 | super.updateSuggestions(); 75 | } 76 | } 77 | 78 | getSuggestions(input: string): AnySuggestion[] { 79 | const { exMode, plugin } = this; 80 | const query = exMode.inputTextForStandardMode(input); 81 | const results = super.getSuggestions(query); 82 | exMode.addPropertiesToStandardSuggestions(results, plugin.options); 83 | return results; 84 | } 85 | 86 | onChooseSuggestion(item: AnySuggestion, evt: MouseEvent | KeyboardEvent) { 87 | if (!this.exMode.onChooseSuggestion(item, evt)) { 88 | super.onChooseSuggestion(item, evt); 89 | } 90 | } 91 | 92 | renderSuggestion(value: AnySuggestion, parentEl: HTMLElement) { 93 | if (!this.exMode.renderSuggestion(value, parentEl)) { 94 | super.renderSuggestion(value, parentEl); 95 | } 96 | } 97 | }; 98 | 99 | return new SwitcherPlusModal(app, plugin); 100 | } 101 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sharedTypes'; 2 | -------------------------------------------------------------------------------- /src/types/obsidian/canvas.d.ts: -------------------------------------------------------------------------------- 1 | export * from 'obsidian/canvas'; 2 | 3 | declare module 'obsidian/canvas' { 4 | // TODO: remove this when the Obsidian module release is updated as this is already define https://github.com/obsidianmd/obsidian-api/blob/master/canvas.d.ts#L51 5 | export interface CanvasGroupData extends CanvasNodeData { 6 | type: 'group'; 7 | // Optional label to display on top of the group. 8 | label?: string; 9 | } 10 | 11 | // TODO: remove this when the Obsidian module release is updated as this is already define https://github.com/obsidianmd/obsidian-api/blob/master/canvas.d.ts#L25 12 | export type AllCanvasNodeData = 13 | | CanvasFileData 14 | | CanvasTextData 15 | | CanvasLinkData 16 | | CanvasGroupData; 17 | } 18 | -------------------------------------------------------------------------------- /src/types/obsidian/index.d.ts: -------------------------------------------------------------------------------- 1 | import { CanvasNodeData } from 'obsidian/canvas'; 2 | 3 | export * from 'obsidian'; 4 | export * from './canvas'; 5 | 6 | declare module 'obsidian' { 7 | export interface PluginInstance { 8 | id: string; 9 | } 10 | 11 | export interface BookmarksPluginItem { 12 | type: string; 13 | title?: string; 14 | } 15 | 16 | export interface BookmarksPluginFileItem extends BookmarksPluginItem { 17 | type: 'file'; 18 | path: string; 19 | subpath: string; 20 | } 21 | 22 | export interface BookmarksPluginFolderItem extends BookmarksPluginItem { 23 | type: 'folder'; 24 | path: string; 25 | } 26 | 27 | export interface BookmarksPluginSearchItem extends BookmarksPluginItem { 28 | type: 'search'; 29 | query: string; 30 | } 31 | 32 | export interface BookmarksPluginGroupItem extends BookmarksPluginItem { 33 | type: 'group'; 34 | items: Array; 35 | } 36 | 37 | export interface BookmarksPluginInstance extends PluginInstance { 38 | items: Array; 39 | getItemTitle(item: BookmarksPluginItem): string; 40 | } 41 | 42 | export interface CommandPalettePluginInstance extends PluginInstance { 43 | plugin: Plugin; 44 | options: { 45 | pinned?: Array; 46 | }; 47 | saveSettings(plugin: Plugin): void; 48 | } 49 | 50 | export interface WorkspacesPluginInstance extends PluginInstance { 51 | workspaces: Record; 52 | loadWorkspace(id: string): void; 53 | saveWorkspace(id: string): void; 54 | setActiveWorkspace(id: string): void; 55 | } 56 | 57 | export interface GlobalSearchPluginInstance extends PluginInstance { 58 | openGlobalSearch(query: string): void; 59 | } 60 | 61 | export interface QuickSwitcherOptions { 62 | showAllFileTypes: boolean; 63 | showAttachments: boolean; 64 | showExistingOnly: boolean; 65 | } 66 | 67 | export interface QuickSwitcherPluginInstance extends PluginInstance { 68 | options: QuickSwitcherOptions; 69 | QuickSwitcherModal: unknown; 70 | } 71 | 72 | export interface InstalledPlugin { 73 | enabled: boolean; 74 | instance: PluginInstance; 75 | } 76 | 77 | export interface InternalPlugins { 78 | plugins: Record; 79 | getPluginById(id: string): InstalledPlugin; 80 | getEnabledPluginById(id: string): PluginInstance; 81 | } 82 | 83 | export interface ViewRegistry { 84 | viewByType: Record; 85 | typeByExtension: Record; 86 | isExtensionRegistered(extension: string): boolean; 87 | } 88 | 89 | export interface MetadataCache { 90 | isUserIgnored(path: string): boolean; 91 | } 92 | 93 | export interface SettingsTab { 94 | id: string; 95 | } 96 | 97 | export class HotkeysSettingTab implements SettingsTab { 98 | id: 'hotkeys'; 99 | setQuery(query: string): void; 100 | } 101 | 102 | export interface App { 103 | internalPlugins: InternalPlugins; 104 | viewRegistry: ViewRegistry; 105 | commands: { 106 | listCommands(): Command[]; 107 | executeCommandById(id: string): boolean; 108 | findCommand(id: string): Command; 109 | }; 110 | hotkeyManager: { 111 | getHotkeys(id: string): Hotkey[]; 112 | getDefaultHotkeys(id: string): Hotkey[]; 113 | printHotkeyForCommand(id: string): string; 114 | }; 115 | mobileNavbar: { 116 | containerEl: HTMLElement; 117 | }; 118 | setting: { 119 | open(): void; 120 | openTabById(id: string): SettingsTab; 121 | }; 122 | openVaultChooser(): void; 123 | openWithDefaultApp(path: string): Promise; 124 | } 125 | 126 | export interface Chooser { 127 | selectedItem: number; 128 | suggestions: HTMLDivElement[]; 129 | values: T[]; 130 | setSelectedItem(index: number, evt: MouseEvent | KeyboardEvent): void; 131 | setSuggestions(suggestions: T[]): void; 132 | useSelectedItem(evt: KeyboardEvent): void; 133 | } 134 | 135 | export interface View { 136 | file?: TFile; 137 | } 138 | 139 | export interface KeymapEventHandler { 140 | func: KeymapEventListener; 141 | } 142 | 143 | export interface Scope { 144 | keys: KeymapEventHandler[]; 145 | } 146 | 147 | export interface WorkspaceLeaf { 148 | app: App; 149 | activeTime: number; 150 | } 151 | 152 | export interface Workspace { 153 | floatingSplit: WorkspaceRoot; 154 | getRecentFiles(options: { 155 | showMarkdown: boolean; 156 | showCanvas: boolean; 157 | showNonImageAttachments: boolean; 158 | showImages: boolean; 159 | maxCount: number; 160 | }): string[]; 161 | } 162 | 163 | export interface CanvasNodeElement extends CanvasNodeData { 164 | containerEl: string; 165 | } 166 | 167 | export interface CanvasFileView extends FileView { 168 | canvas: { 169 | nodes: Map; 170 | selectOnly(node: CanvasNodeElement): void; 171 | zoomToSelection(): void; 172 | }; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/utils/__tests__/frontMatterParser.test.ts: -------------------------------------------------------------------------------- 1 | import { FrontMatterCache } from 'obsidian'; 2 | import { FrontMatterParser } from 'src/utils'; 3 | 4 | describe('FrontMatterParser', () => { 5 | describe('getAliases', () => { 6 | it('should return empty array with falsy input', () => { 7 | const results = FrontMatterParser.getAliases(null); 8 | 9 | expect(results).toBeInstanceOf(Array); 10 | expect(results).toHaveLength(0); 11 | }); 12 | 13 | it('should return empty array with missing key', () => { 14 | const fm: FrontMatterCache = { 15 | position: null, 16 | }; 17 | 18 | const results = FrontMatterParser.getAliases(fm); 19 | 20 | expect(results).toBeInstanceOf(Array); 21 | expect(results).toHaveLength(0); 22 | }); 23 | 24 | it('should parse alias key', () => { 25 | const fm: FrontMatterCache = { 26 | alias: 'foo', 27 | position: null, 28 | }; 29 | 30 | const results = FrontMatterParser.getAliases(fm); 31 | 32 | expect(results).toBeInstanceOf(Array); 33 | expect(results).toHaveLength(1); 34 | expect(results[0]).toBe('foo'); 35 | }); 36 | 37 | it('should parse aliases key', () => { 38 | const fm: FrontMatterCache = { 39 | aliases: 'foo', 40 | position: null, 41 | }; 42 | 43 | const results = FrontMatterParser.getAliases(fm); 44 | 45 | expect(results).toBeInstanceOf(Array); 46 | expect(results).toHaveLength(1); 47 | expect(results[0]).toBe('foo'); 48 | }); 49 | 50 | it('should parse string values', () => { 51 | const fm: FrontMatterCache = { 52 | aliases: 'one, two ,three', 53 | position: null, 54 | }; 55 | 56 | const results = FrontMatterParser.getAliases(fm); 57 | 58 | expect(results).toBeInstanceOf(Array); 59 | expect(results).toHaveLength(3); 60 | expect(results).toEqual((fm.aliases as string).split(',').map((val) => val.trim())); 61 | }); 62 | 63 | it('should parse array values', () => { 64 | const fm: FrontMatterCache = { 65 | aliases: ['one', 'two ', 'three'], 66 | position: null, 67 | }; 68 | 69 | const results = FrontMatterParser.getAliases(fm); 70 | 71 | expect(results).toBeInstanceOf(Array); 72 | expect(results).toHaveLength(3); 73 | expect(results).toEqual((fm.aliases as string[]).map((val) => val.trim())); 74 | }); 75 | 76 | it('should ignore non-string/non-array values', () => { 77 | const fm: FrontMatterCache = { 78 | aliases: {}, 79 | position: null, 80 | }; 81 | 82 | const results = FrontMatterParser.getAliases(fm); 83 | 84 | expect(results).toBeInstanceOf(Array); 85 | expect(results).toHaveLength(0); 86 | }); 87 | 88 | it('should ignore nested non-string values', () => { 89 | const fm: FrontMatterCache = { 90 | aliases: ['one', ['two'], 'three'], 91 | position: null, 92 | }; 93 | 94 | const results = FrontMatterParser.getAliases(fm); 95 | 96 | expect(results).toBeInstanceOf(Array); 97 | expect(results).toHaveLength(2); 98 | expect(results).toEqual( 99 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 100 | (fm.aliases as any[]).filter((val) => typeof val === 'string'), 101 | ); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/utils/componentManager.ts: -------------------------------------------------------------------------------- 1 | import { Component } from 'obsidian'; 2 | 3 | /** 4 | * Provides access to a shared Component in static contexts to ensure that all child 5 | * Components are properly unloaded. Note: this class is not intended to be subclassed, 6 | * the static methods should be used directly. 7 | * 8 | */ 9 | export abstract class ComponentManager { 10 | private static rootComponent: Component; 11 | 12 | /** 13 | * Returns a Component that can be used to manage the lifecycle of other Components. 14 | * This container component will be automatically unloaded when the modal is closed to free any 15 | * associated resources. This is useful for cases like MarkdownRenderer.render() 16 | * 17 | * @static 18 | * @returns {Component} 19 | */ 20 | static getRootComponent(): Component { 21 | if (!this.rootComponent) { 22 | this.rootComponent = new Component(); 23 | } 24 | 25 | return this.rootComponent; 26 | } 27 | 28 | /** 29 | * Unload the tracking component and its children. 30 | * 31 | * @static 32 | */ 33 | static unload(): void { 34 | this.rootComponent?.unload(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/frontMatterParser.ts: -------------------------------------------------------------------------------- 1 | import { FrontMatterCache } from 'obsidian'; 2 | 3 | export class FrontMatterParser { 4 | static getAliases(frontMatter: FrontMatterCache): string[] { 5 | let aliases: string[] = []; 6 | 7 | if (frontMatter) { 8 | aliases = FrontMatterParser.getValueForKey(frontMatter, /^alias(es)?$/i); 9 | } 10 | 11 | return aliases; 12 | } 13 | 14 | private static getValueForKey( 15 | frontMatter: FrontMatterCache, 16 | keyPattern: RegExp, 17 | ): string[] { 18 | const retVal: string[] = []; 19 | const fmKeys = Object.keys(frontMatter); 20 | const key = fmKeys.find((val) => keyPattern.test(val)); 21 | 22 | if (key) { 23 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 24 | let value = frontMatter[key]; 25 | 26 | if (typeof value === 'string') { 27 | value = value.split(','); 28 | } 29 | 30 | if (Array.isArray(value)) { 31 | value.forEach((val) => { 32 | if (typeof val === 'string') { 33 | retVal.push(val.trim()); 34 | } 35 | }); 36 | } 37 | } 38 | 39 | return retVal; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | export * from './frontMatterParser'; 3 | export * from './componentManager'; 4 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --symbol-base-padding: 0px; 3 | --symbol-indent-padding: 12px; 4 | } 5 | 6 | .qsp-filter-active { 7 | color: var(--text-accent); 8 | } 9 | 10 | /* suggestion file path icon */ 11 | .qsp-path-indicator { 12 | margin-right: 4px; 13 | vertical-align: middle; 14 | } 15 | 16 | /* symbol suggestion, symbol type icon */ 17 | .qsp-symbol-indicator { 18 | width: 2em; 19 | text-align: center; 20 | float: left; 21 | font-weight: 800; 22 | } 23 | 24 | .qsp-symbol-indicator.callout { 25 | background-color:inherit; 26 | padding: inherit; 27 | } 28 | 29 | /* warning/error text */ 30 | .qsp-warning { 31 | color: var(--text-error); 32 | } 33 | 34 | .qsp-tag { 35 | background-color: var(--background-modifier-hover); 36 | border-radius: var(--radius-s); 37 | font-size: 9px; 38 | font-weight: var(--font-semibold); 39 | letter-spacing: 0.05em; 40 | line-height: var(--line-height-normal); 41 | margin-left: var(--size-2-3); 42 | padding: 0 var(--size-4-1); 43 | text-transform: uppercase; 44 | align-self: center; 45 | } 46 | 47 | /* settings panel, indent setting to create visual hierarchy */ 48 | .qsp-setting-item-indent { 49 | border: 0px; 50 | padding-left: 36px; 51 | } 52 | 53 | /* symbol suggestion should align to the left */ 54 | .suggestion-item.qsp-suggestion-symbol { 55 | justify-content: left; 56 | } 57 | 58 | /* highlight recently used file suggestions */ 59 | .suggestion-item.qsp-recent-file .qsp-title { 60 | text-decoration: underline dotted var(--text-muted); 61 | } 62 | 63 | /* highlight currently open editor suggestions */ 64 | .suggestion-item.qsp-open-editor .qsp-title { 65 | text-decoration: underline var(--text-accent); 66 | } 67 | 68 | /* highlight the open editor flair icon */ 69 | .suggestion-item.qsp-open-editor .qsp-editor-indicator { 70 | color: var(--text-accent); 71 | } 72 | 73 | /* symbol suggestion display the icon first (on the left side) */ 74 | .qsp-suggestion-symbol > .qsp-aux { 75 | order: -1; 76 | } 77 | 78 | /* symbol suggestion indentation when in outline mode */ 79 | .qsp-symbol-l0 { 80 | padding-left: var(--symbol-base-padding); 81 | } 82 | 83 | .qsp-symbol-l1 { 84 | padding-left: calc(var(--symbol-base-padding) + var(--symbol-indent-padding)); 85 | } 86 | 87 | .qsp-symbol-l2 { 88 | padding-left: calc(var(--symbol-base-padding) + (2 * var(--symbol-indent-padding))); 89 | } 90 | 91 | .qsp-symbol-l3 { 92 | padding-left: calc(var(--symbol-base-padding) + (3 * var(--symbol-indent-padding))); 93 | } 94 | 95 | .qsp-symbol-l4 { 96 | padding-left: calc(var(--symbol-base-padding) + (4 * var(--symbol-indent-padding))); 97 | } 98 | 99 | .qsp-symbol-l5 { 100 | padding-left: calc(var(--symbol-base-padding) + (5 * var(--symbol-indent-padding))); 101 | } 102 | 103 | .qsp-symbol-l6 { 104 | padding-left: calc(var(--symbol-base-padding) + (6 * var(--symbol-indent-padding))); 105 | } 106 | 107 | /* Wrapper container for rendered markdown content using MarkdownRenderer.render() */ 108 | .qsp-rendered-container {} 109 | 110 | /* Override styles for child elements of rendered markdown content */ 111 | .qsp-rendered-container > * { 112 | /* Remove start margin/padding so elements don't take up a bunch of extra 113 | horizontal space */ 114 | margin-block-start: 0px; 115 | margin-inline-start: 0px; 116 | padding-block-start: 0px; 117 | padding-inline-start: 0px; 118 | /* Disable elements from reacting to mouse events, so things like links & tags don't 119 | react to mouseover hover, etc.. */ 120 | pointer-events: none; 121 | } 122 | 123 | /* Rendered markdown content that starts with an Ordered List should keep it's start 124 | margin so that the list number is displayed properly. Obsidian defaults to 40px */ 125 | .qsp-rendered-container > ol { 126 | padding-inline-start: 40px; 127 | margin-block-end: 0px; 128 | } 129 | 130 | /* command suggestion item */ 131 | .qsp-suggestion-command {} 132 | 133 | /* editor suggestion item */ 134 | .qsp-suggestion-editor {} 135 | 136 | /* heading suggestion item */ 137 | .qsp-suggestion-headings {} 138 | 139 | /* related item suggestion */ 140 | .qsp-suggestion-related {} 141 | 142 | /* symbol suggestion item */ 143 | .qsp-suggestion-symbol {} 144 | 145 | /* workspace suggestion item */ 146 | .qsp-suggestion-workspace {} 147 | 148 | /* file suggestion when not in standard mode */ 149 | .qsp-suggestion-file {} 150 | 151 | /* alias suggestion when not in standard mode */ 152 | .qsp-suggestion-alias {} 153 | 154 | /* suggestion primary content container */ 155 | .qsp-content {} 156 | 157 | /* suggestion title element */ 158 | .qsp-title {} 159 | 160 | /* suggestion secondary information element (like file path information) */ 161 | .qsp-note {} 162 | 163 | /* suggestion flair/icon container */ 164 | .qsp-aux {} 165 | 166 | /* suggestion file path element */ 167 | .qsp-path {} 168 | 169 | /* headings suggestion, heading level (H1, H2, etc..) icon */ 170 | .qsp-headings-indicator {} 171 | 172 | /* flair icon for suggestions that represent a recent file */ 173 | .qsp-recent-indicator {} 174 | 175 | /* flair icon for suggestion that represent a file currently opened in an editor */ 176 | .qsp-editor-indicator {} 177 | 178 | /* flair icon for suggestions that represent a related file */ 179 | .qsp-related-indicator {} 180 | 181 | /* flair icon for suggestions that represent an alias */ 182 | .qsp-alias-indicator {} 183 | 184 | /* Quick Open indicator container element */ 185 | .qsp-quick-open-aux { 186 | display: flex; 187 | align-items: center; 188 | align-self: center; 189 | flex-shrink: 0; 190 | } 191 | 192 | /* Quick Open hotkey indicator element */ 193 | .qsp-quick-open-hotkey {} 194 | 195 | /* headings level */ 196 | .qsp-headings-l1 {} 197 | .qsp-headings-l2 {} 198 | .qsp-headings-l3 {} 199 | .qsp-headings-l4 {} 200 | .qsp-headings-l5 {} 201 | .qsp-headings-l6 {} 202 | 203 | /* Usually the "plus" button button in the NavBar on mobile platforms */ 204 | .qsp-mobile-launcher-button {} 205 | 206 | /* Prompt instructions element in custom modes */ 207 | .qsp-prompt-instructions {} 208 | 209 | /* Prompt instructions element for facets in custom modes */ 210 | .qsp-prompt-instructions-facets {} 211 | 212 | /* Prompt instructions element for mode triggers */ 213 | .qsp-prompt-instructions-modes {} 214 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "src": [ 6 | "src/*" 7 | ], 8 | "@fixtures*": [ 9 | "src/__fixtures__", 10 | "src/__fixtures__*" 11 | ] 12 | }, 13 | "typeRoots": [ 14 | "./src/types", 15 | "./node_modules/@types" 16 | ], 17 | "inlineSourceMap": true, 18 | "inlineSources": true, 19 | "module": "ESNext", 20 | "target": "es2020", 21 | "allowJs": true, 22 | "noImplicitAny": true, 23 | "strictBindCallApply": true, 24 | "esModuleInterop": true, 25 | "moduleResolution": "node", 26 | "importHelpers": true, 27 | "lib": [ 28 | "dom", 29 | "scripthost", 30 | "es2020" 31 | ] 32 | }, 33 | "include": [ 34 | "src/**/*.ts" 35 | ], 36 | "exclude": [ 37 | "node_modules/*", 38 | "src/types", 39 | "dist" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "inlineSourceMap": false, 5 | "sourceMap": false, 6 | "inlineSources": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.11": "0.12.4", 3 | "0.0.10": "0.10.7", 4 | "0.0.9": "0.10.6", 5 | "0.0.8": "0.9.20", 6 | "0.0.7": "0.9.17", 7 | "0.0.6": "0.9.10", 8 | "0.0.12": "0.12.5", 9 | "0.0.13": "0.12.9", 10 | "1.0.0": "0.12.11", 11 | "1.0.1": "0.12.11", 12 | "1.0.2": "0.12.13", 13 | "1.0.3": "0.12.13", 14 | "1.0.4": "0.12.19", 15 | "1.0.5": "0.12.19", 16 | "1.0.6": "0.14.6", 17 | "1.0.7": "0.14.6", 18 | "1.0.8": "0.14.6", 19 | "1.0.9": "0.14.6", 20 | "1.0.10": "0.14.6", 21 | "1.0.11": "0.14.15", 22 | "1.0.12": "0.14.15", 23 | "2.0.0": "0.15.3", 24 | "2.0.1": "0.15.4", 25 | "2.0.2": "0.15.5", 26 | "2.0.3": "0.15.5", 27 | "2.0.4": "0.15.5", 28 | "2.0.5": "0.15.5", 29 | "2.0.6": "0.15.9", 30 | "2.0.7": "0.15.9", 31 | "2.1.0": "0.16.2", 32 | "2.1.1": "1.0.0", 33 | "2.2.0": "1.0.0", 34 | "2.2.1": "1.0.0", 35 | "2.3.0": "1.0.0", 36 | "2.3.1": "1.0.0", 37 | "2.3.2": "1.0.0", 38 | "2.3.3": "1.0.0", 39 | "2.3.4": "1.0.0", 40 | "2.3.5": "1.0.0", 41 | "2.3.6": "1.0.0", 42 | "2.3.7": "1.0.0", 43 | "2.3.8": "1.0.0", 44 | "3.0.0": "1.1.16", 45 | "3.0.1": "1.1.16", 46 | "3.1.0": "1.1.16", 47 | "3.2.1": "1.2.7", 48 | "3.3.0": "1.2.8", 49 | "3.3.1": "1.3.4", 50 | "3.3.2": "1.3.4", 51 | "3.3.3": "1.3.5", 52 | "3.3.4": "1.3.5", 53 | "3.3.5": "1.3.5", 54 | "3.3.6": "1.3.7", 55 | "3.3.7": "1.4.5", 56 | "3.3.8": "1.4.16", 57 | "3.3.9": "1.4.16", 58 | "4.0.0": "1.5.3", 59 | "4.1.0": "1.5.12", 60 | "4.2.0": "1.5.12", 61 | "4.2.1": "1.5.12", 62 | "4.2.2": "1.5.12", 63 | "4.3.0": "1.5.12", 64 | "4.4.0": "1.6.5", 65 | "4.5.0": "1.6.7", 66 | "4.5.1": "1.6.7", 67 | "4.6.0": "1.7.3", 68 | "4.6.1": "1.7.4", 69 | "4.6.2": "1.7.4", 70 | "4.6.3": "1.7.7", 71 | "5.0.0": "1.8.4", 72 | "5.1.0": "1.8.9" 73 | } 74 | --------------------------------------------------------------------------------