├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── manifest.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── main.ts ├── palette-modal-adapters │ ├── command-adapter.ts │ ├── file-adapter.ts │ ├── index.ts │ └── tag-adapter.ts ├── palette.ts ├── settings.ts ├── styles.scss ├── types │ ├── types.d.ts │ └── worker.d.ts ├── utils │ ├── constants.ts │ ├── index.ts │ ├── macro.ts │ ├── ordered-set.ts │ ├── palette-match.ts │ ├── settings-command-suggest-modal.ts │ ├── suggest-modal-adapter.ts │ └── utils.ts └── web-workers │ └── suggestions-worker.ts ├── test └── e2e │ ├── test-utils.ts │ ├── test.ts │ └── tests │ ├── index.ts │ ├── initialize-test-env.ts │ ├── tear-down-test-env.ts │ ├── test-command-palette.ts │ ├── test-file-palette.ts │ └── test-tag-palette.ts ├── tools └── generate-test-files.js ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = tab 8 | indent_size = 4 9 | tab_width = 4 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | globals: { 5 | window: true, 6 | }, 7 | env: { 8 | node: true, 9 | }, 10 | plugins: ['@typescript-eslint'], 11 | extends: ['airbnb-base'], 12 | ignorePatterns: ['**/*.scss'], 13 | rules: { 14 | indent: ['error', 4], 15 | 'import/extensions': 0, 16 | 'class-methods-use-this': 'off', 17 | }, 18 | overrides: [ 19 | { 20 | files: ['*.ts'], // Your TypeScript files extension 21 | // As mentioned in the comments, you should extend TypeScript plugins here, 22 | // instead of extending them outside the `overrides`. 23 | // If you don't want to extend any rules, you don't need an `extends` attribute. 24 | extends: ['airbnb-typescript/base'], 25 | 26 | parserOptions: { 27 | sourceType: 'module', 28 | project: './tsconfig.json', 29 | tsconfigRootDir: __dirname, 30 | }, 31 | 32 | rules: { 33 | '@typescript-eslint/indent': ['error', 4], 34 | }, 35 | }, 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-better-command-palette 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "14.x" 21 | 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp dist/main.js dist/manifest.json dist/styles.css ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Upload zip file 46 | id: upload-zip 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 53 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 54 | asset_content_type: application/zip 55 | 56 | - name: Upload main.js 57 | id: upload-main 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | asset_path: ./dist/main.js 64 | asset_name: main.js 65 | asset_content_type: text/javascript 66 | 67 | - name: Upload manifest.json 68 | id: upload-manifest 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./dist/manifest.json 75 | asset_name: manifest.json 76 | asset_content_type: application/json 77 | 78 | - name: Upload styles.css 79 | id: upload-css 80 | uses: actions/upload-release-asset@v1 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | with: 84 | upload_url: ${{ steps.create_release.outputs.upload_url }} 85 | asset_path: ./dist/styles.css 86 | asset_name: styles.css 87 | asset_content_type: text/css -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | styles.css 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | 22 | # build folder 23 | dist 24 | 25 | # dev build folder 26 | test-vault 27 | 28 | # Mac 29 | .DS_Store -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Better Command Palette 2 | A plugin for Obsidian that adds a command palatte that is more user friendly and more feature rich. Use `cmd+shift+p` to to open the palette. 3 | 4 | Quick Feature List: 5 | 1. Use backspace to close the palette 6 | 2. Recent choices bubble to the top 7 | 3. Built in quick switcher by typing `/` or using the hotkey 8 | 4. Built in tag search by typing `#` or using the hotkey 9 | 5. Search files with specific tags 10 | 6. Macro commands 11 | 7. Hide less useful Commands, Files, and Tag, but quickly see them again with `cmd+i` 12 | 13 | Coming Soon: 14 | 1. Populate the input with recent queries automatically 15 | 2. Search files via unstructured frontmatter content 16 | 17 | ## Features 18 | ### Backspace to close 19 | When the palette has no text entered into the input and you press backspace, then the palette will close. This can be turned off in the settings. 20 | 21 | ### Recent Choices 22 | Choices that have been recently used will bubble up to the top of the command list. 23 | 24 | ### Pinned Commands 25 | Commands that have been pinned in the default `Command Palette` will be pinned here as well. 26 | 27 | ### File Opening 28 | Better Command Palette allows you to open files from the same input without needing to run a command or press `cmd+o first`. Once the palette is open just type `/` (This can be changed in the settings) and you will be searching files to open. Press `enter` to open the file. 29 | 30 | ### File Creation 31 | If after searching for files to open there are no results you may press `cmd+enter` to create a file with the same name as you have entered. You may specify directories. If the directory path does not exist it will create it. 32 | 33 | ### File Searching using Tags 34 | Better Command Palette allows you to find and open files that contain the tags you search for. 35 | Type `#` (configurable in the settings) to begin searching all of the tags in your vault. Press enter to use that tag to filter the file search. 36 | 37 | ### Macro Commands 38 | Macros can be created in the settings tab for Better Command Palette. Each Macro must be give a name, delay, and at least one command. If any of these are not set the macro will not show up in the command palette. 39 | 40 | The delay is the number of milliseconds the macro will wait between each command. This can be useful for commands that take some time to complete. 41 | 42 | Any command can be added including other macro commands. Each command is run in sequence. At each step the macro will check if the next command can be run. Certain commands require certain conditions to be met. A an error message will be shown if a command could not be run. The macro will only be shown in the command palette if the first command can be run at that time. 43 | 44 | Hotkeys can be assigned to the macro in the normal hotkey tab after the macro has been created. 45 | 46 | ### Hidden Items 47 | All items that are shown in the palette (Commands, Files, and Tags) can be hidden. Click the `X` next to the item to hide it from both current and future search results. If you want to be able to selec that item again briefly you can click the `Show hidden items` message under the search input or use `cmd+I` to reveal hidden items in the palette. These will be highlighted to better distinguish them. If you decide you want to unhide an item simply make sure hidden items are being shown, search for the item, and click the plus button next to it. 48 | 49 | ## Development 50 | ### Project Setup 51 | 1. Clone the repo 52 | 2. Run `npm install` 53 | 54 | ### Development Build 55 | Run `npm run dev` 56 | 57 | This will create a directory named `test-vault` in your repo (automatically ignored by git). You can point obsidian to this directory and use it as a testing environment. Files are automatically watched and the dev server will restart when they are changed. 58 | 59 | ### Local Build 60 | Run `npm run build-local` 61 | 62 | This builds the plugin in production mode and copies the needed files to the root of the repo (automatically ignored by git). This is to allow people who wish to manually install the plugin on their machines to easily do so by copying the plugin to their plugin directory and running the command. 63 | 64 | ### Production Build 65 | Run `npm run build` 66 | 67 | Builds the plugin for production and puts all neccessary files into the `dist` directory. Pretty much only used by github actions for releases. -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-better-command-palette", 3 | "name": "Better Command Palette", 4 | "version": "0.17.1", 5 | "minAppVersion": "0.12.0", 6 | "description": "A command palette that does all of the things you want it to do.", 7 | "author": "Alex Bieg", 8 | "authorUrl": "www.github.com/AlexBieg", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-better-command-palette", 3 | "version": "0.17.1", 4 | "description": "A command palette that does all of the things you want it to do.", 5 | "main": "main.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "rollup --config rollup.config.js -w", 9 | "build": "NODE_ENV=production rollup --config rollup.config.js", 10 | "build-local": "NODE_ENV=production DEST=local rollup --config rollup.config.js", 11 | "version": "node version-bump.mjs && git add manifest.json versions.json", 12 | "test:lint": "eslint .", 13 | "test:e2e": "TYPE=test rollup --config rollup.config.js -w", 14 | "tool:gen-files": "node ./tools/generate-test-files.js" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@rollup/plugin-commonjs": "^21.0.1", 21 | "@rollup/plugin-eslint": "^8.0.1", 22 | "@rollup/plugin-node-resolve": "^13.1.3", 23 | "@types/node": "^16.11.6", 24 | "@typescript-eslint/eslint-plugin": "^5.2.0", 25 | "@typescript-eslint/parser": "^5.2.0", 26 | "builtin-modules": "^3.2.0", 27 | "eslint": "^8.8.0", 28 | "eslint-config-airbnb-base": "^15.0.0", 29 | "eslint-config-airbnb-typescript": "^16.1.0", 30 | "eslint-plugin-import": "^2.25.4", 31 | "obsidian": "^0.12.17", 32 | "rollup": "^2.66.1", 33 | "rollup-plugin-copy": "^3.4.0", 34 | "rollup-plugin-root-import": "^1.0.0", 35 | "rollup-plugin-scss": "^3.0.0", 36 | "rollup-plugin-terser": "^7.0.2", 37 | "rollup-plugin-typescript2": "^0.31.2", 38 | "rollup-plugin-web-worker-loader": "^1.6.1", 39 | "sass": "^1.49.7", 40 | "tslib": "2.3.1", 41 | "typescript": "4.4.4", 42 | "yargs": "^17.3.1" 43 | }, 44 | "dependencies": { 45 | "fuzzysort": "^1.1.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import nodeResolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import webWorkerLoader from 'rollup-plugin-web-worker-loader'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import copy from 'rollup-plugin-copy'; 7 | import eslint from '@rollup/plugin-eslint'; 8 | import scss from 'rollup-plugin-scss'; 9 | 10 | const isProduction = process.env.NODE_ENV === 'production'; 11 | const isLocal = process.env.DEST === 'local'; 12 | const includeTestFiles = process.env.TYPE === 'test'; 13 | 14 | let outputLocation = './test-vault/.obsidian/plugins/obsidian-better-command-palette'; 15 | 16 | if (isProduction) { 17 | outputLocation = './dist'; 18 | } 19 | 20 | if (isLocal) { 21 | outputLocation = '.'; 22 | } 23 | 24 | export default { 25 | input: includeTestFiles ? 'test/e2e/test.ts' : 'src/main.ts', 26 | output: { 27 | file: `${outputLocation}/main.js`, 28 | sourcemap: isProduction ? null : 'inline', 29 | format: 'cjs', 30 | exports: 'default', 31 | }, 32 | external: ['obsidian'], 33 | plugins: [ 34 | nodeResolve({ 35 | browser: true, 36 | extensions: ['.ts', '.js', '.d.ts'], 37 | }), 38 | commonjs(), 39 | scss({ 40 | output: `${outputLocation}/styles.css`, 41 | }), 42 | // eslint(), 43 | copy({ 44 | targets: [ 45 | ...(!isLocal 46 | ? [{ src: 'manifest.json', dest: outputLocation }] 47 | : []), 48 | ], 49 | }), 50 | webWorkerLoader({ 51 | targetPlatform: 'browser', 52 | preserveSource: !isProduction, 53 | sourcemap: !isProduction, 54 | inline: true, 55 | forceInline: true, 56 | extensions: ['.ts'], 57 | }), 58 | typescript(), 59 | isProduction && terser(), 60 | ], 61 | }; 62 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian'; 2 | 3 | import SuggestionsWorker from 'web-worker:./web-workers/suggestions-worker'; 4 | import { OrderedSet, MacroCommand } from 'src/utils'; 5 | import BetterCommandPaletteModal from 'src/palette'; 6 | import { Match, UnsafeAppInterface } from 'src/types/types'; 7 | import { BetterCommandPalettePluginSettings, BetterCommandPaletteSettingTab, DEFAULT_SETTINGS } from 'src/settings'; 8 | import { MACRO_COMMAND_ID_PREFIX } from './utils/constants'; 9 | import './styles.scss'; 10 | 11 | export default class BetterCommandPalettePlugin extends Plugin { 12 | app: UnsafeAppInterface; 13 | 14 | settings: BetterCommandPalettePluginSettings; 15 | 16 | prevCommands: OrderedSet; 17 | 18 | prevTags: OrderedSet; 19 | 20 | suggestionsWorker: Worker; 21 | 22 | async onload() { 23 | // eslint-disable-next-line no-console 24 | console.log('Loading plugin: Better Command Palette'); 25 | 26 | await this.loadSettings(); 27 | 28 | this.prevCommands = new OrderedSet(); 29 | this.prevTags = new OrderedSet(); 30 | this.suggestionsWorker = new SuggestionsWorker({}); 31 | 32 | this.addCommand({ 33 | id: 'open-better-commmand-palette', 34 | name: 'Open better command palette', 35 | // Generally I would not set a hotkey, but since it is a 36 | // command palette I think it makes sense 37 | // Can still be overwritten in the hotkey settings 38 | hotkeys: [{ modifiers: ['Mod', 'Shift'], key: 'p' }], 39 | callback: () => { 40 | new BetterCommandPaletteModal( 41 | this.app, 42 | this.prevCommands, 43 | this.prevTags, 44 | this, 45 | this.suggestionsWorker, 46 | ).open(); 47 | }, 48 | }); 49 | 50 | this.addCommand({ 51 | id: 'open-better-commmand-palette-file-search', 52 | name: 'Open better command palette: File Search', 53 | hotkeys: [], 54 | callback: () => { 55 | new BetterCommandPaletteModal( 56 | this.app, 57 | this.prevCommands, 58 | this.prevTags, 59 | this, 60 | this.suggestionsWorker, 61 | this.settings.fileSearchPrefix, 62 | ).open(); 63 | }, 64 | }); 65 | 66 | this.addCommand({ 67 | id: 'open-better-commmand-palette-tag-search', 68 | name: 'Open better command palette: Tag Search', 69 | hotkeys: [], 70 | callback: () => { 71 | new BetterCommandPaletteModal( 72 | this.app, 73 | this.prevCommands, 74 | this.prevTags, 75 | this, 76 | this.suggestionsWorker, 77 | this.settings.tagSearchPrefix, 78 | ).open(); 79 | }, 80 | }); 81 | 82 | this.addSettingTab(new BetterCommandPaletteSettingTab(this.app, this)); 83 | } 84 | 85 | onunload(): void { 86 | this.suggestionsWorker.terminate(); 87 | } 88 | 89 | loadMacroCommands() { 90 | this.settings.macros.forEach((macroData, index) => { 91 | if (!macroData.name || !macroData.commandIds.length) { 92 | return; 93 | } 94 | 95 | const macro = new MacroCommand( 96 | this.app, 97 | `${MACRO_COMMAND_ID_PREFIX}${index}`, 98 | macroData.name, 99 | macroData.commandIds, 100 | macroData.delay, 101 | ); 102 | 103 | this.addCommand(macro); 104 | 105 | if (this.prevCommands) { 106 | this.prevCommands = this.prevCommands.values().reduce((acc, match) => { 107 | if (match.id === macro.id && match.text !== macro.name) return acc; 108 | 109 | acc.add(match); 110 | 111 | return acc; 112 | }, new OrderedSet()); 113 | } 114 | }); 115 | } 116 | 117 | deleteMacroCommands() { 118 | const macroCommandIds = Object.keys(this.app.commands.commands) 119 | .filter((id) => id.includes(MACRO_COMMAND_ID_PREFIX)); 120 | 121 | macroCommandIds.forEach((id) => { 122 | this.app.commands.removeCommand(id); 123 | }); 124 | } 125 | 126 | async loadSettings() { 127 | this.settings = { ...DEFAULT_SETTINGS, ...await this.loadData() }; 128 | this.loadMacroCommands(); 129 | } 130 | 131 | async saveSettings() { 132 | this.deleteMacroCommands(); 133 | await this.saveData(this.settings); 134 | this.loadMacroCommands(); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/palette-modal-adapters/command-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Command, Instruction, setIcon } from 'obsidian'; 2 | import { 3 | generateHotKeyText, PaletteMatch, SuggestModalAdapter, 4 | } from 'src/utils'; 5 | import { Match, UnsafeAppInterface } from 'src/types/types'; 6 | import { ActionType } from 'src/utils/constants'; 7 | 8 | export default class BetterCommandPaletteCommandAdapter extends SuggestModalAdapter { 9 | titleText: string; 10 | 11 | emptyStateText: string; 12 | 13 | COMMAND_PLUGIN_NAME_SEPARATOR = ': '; 14 | 15 | // Unsafe Interfaces 16 | app: UnsafeAppInterface; 17 | 18 | allItems: Match[]; 19 | 20 | pinnedItems: Match[]; 21 | 22 | initialize() { 23 | super.initialize(); 24 | 25 | this.titleText = 'Better Command Palette: Commands'; 26 | this.emptyStateText = 'No matching commands.'; 27 | 28 | this.hiddenIds = this.plugin.settings.hiddenCommands; 29 | this.hiddenIdsSettingsKey = 'hiddenCommands'; 30 | 31 | this.allItems = this.app.commands.listCommands() 32 | .sort((a: Command, b: Command) => b.name.localeCompare(a.name)) 33 | .map((c: Command): Match => new PaletteMatch(c.id, c.name)); 34 | 35 | const pinnedCommands = this.app.internalPlugins.getPluginById('command-palette').instance.options.pinned || []; 36 | this.pinnedItems = pinnedCommands.reduce( 37 | (acc: Match[], id: string): Match[] => { 38 | const command = this.app.commands.findCommand(id); 39 | 40 | // If a command was pinned and then the plugin removed we won't have a command here 41 | if (command) { 42 | acc.push(new PaletteMatch(id, command.name)); 43 | } 44 | 45 | return acc; 46 | }, 47 | [], 48 | ).reverse(); 49 | } 50 | 51 | mount(): void { 52 | this.keymapHandlers = [ 53 | this.palette.scope.register(['Mod'], this.plugin.settings.fileSearchHotkey, () => this.palette.changeActionType(ActionType.Files)), 54 | this.palette.scope.register(['Mod'], this.plugin.settings.tagSearchHotkey, () => this.palette.changeActionType(ActionType.Tags)), 55 | ]; 56 | } 57 | 58 | getInstructions(): Instruction[] { 59 | return [ 60 | { command: generateHotKeyText({ modifiers: [], key: 'ENTER' }, this.plugin.settings), purpose: 'Run command' }, 61 | { command: generateHotKeyText({ modifiers: ['Mod'], key: this.plugin.settings.fileSearchHotkey }, this.plugin.settings), purpose: 'Search Files' }, 62 | { command: generateHotKeyText({ modifiers: ['Mod'], key: this.plugin.settings.tagSearchHotkey }, this.plugin.settings), purpose: 'Search Tags' }, 63 | ]; 64 | } 65 | 66 | renderSuggestion(match: Match, content: HTMLElement, aux: HTMLElement): void { 67 | const command = this.app.commands.findCommand(match.id); 68 | const customHotkeys = this.app.hotkeyManager.getHotkeys(command.id); 69 | const defaultHotkeys = this.app.hotkeyManager.getDefaultHotkeys(command.id); 70 | 71 | // If hotkeys have been customized in some way (add new, deleted default) 72 | // customHotkeys will be an array, otherwise undefined 73 | // If there is a default hotkey defaultHotkeys will be an array 74 | // (does not check any customization), otherwise undefined. 75 | const hotkeys = customHotkeys || defaultHotkeys || []; 76 | 77 | if (this.getPinnedItems().find((i) => i.id === match.id)) { 78 | const flairContainer = aux.querySelector('.suggestion-flair') as HTMLElement; 79 | setIcon(flairContainer, 'filled-pin', 13); 80 | flairContainer.ariaLabel = 'Pinned'; 81 | flairContainer.onClickEvent(() => {}); 82 | } 83 | 84 | let { text } = match; 85 | let prefix = ''; 86 | 87 | // Has a plugin name prefix 88 | if (text.includes(this.COMMAND_PLUGIN_NAME_SEPARATOR)) { 89 | // Wish there was an easy way to get the plugin name without string manipulation 90 | // Seems like this is how the acutal command palette does it though 91 | const split = text.split(this.COMMAND_PLUGIN_NAME_SEPARATOR); 92 | // Get first element 93 | prefix = split.shift(); 94 | text = split.join(this.COMMAND_PLUGIN_NAME_SEPARATOR); 95 | } 96 | 97 | content.createEl('span', { 98 | cls: 'suggestion-title', 99 | text, 100 | }); 101 | 102 | if (prefix && this.plugin.settings.showPluginName) { 103 | content.createEl('span', { 104 | cls: 'suggestion-note', 105 | text: prefix, 106 | }); 107 | } 108 | 109 | hotkeys.forEach((hotkey) => { 110 | aux.createEl('kbd', { 111 | cls: 'suggestion-hotkey', 112 | text: generateHotKeyText(hotkey, this.plugin.settings), 113 | }); 114 | }); 115 | } 116 | 117 | async onChooseSuggestion(match: Match) { 118 | this.getPrevItems().add(match); 119 | this.app.commands.executeCommandById(match.id); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/palette-modal-adapters/file-adapter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Instruction, setIcon, 3 | } from 'obsidian'; 4 | import { 5 | generateHotKeyText, 6 | getOrCreateFile, 7 | openFileWithEventKeys, 8 | OrderedSet, 9 | PaletteMatch, SuggestModalAdapter, 10 | createPaletteMatchesFromFilePath, 11 | } from 'src/utils'; 12 | import { Match, UnsafeAppInterface } from 'src/types/types'; 13 | import { ActionType } from 'src/utils/constants'; 14 | 15 | export default class BetterCommandPaletteFileAdapter extends SuggestModalAdapter { 16 | titleText: string; 17 | 18 | emptyStateText: string; 19 | 20 | // Unsafe interface 21 | app: UnsafeAppInterface; 22 | 23 | allItems: Match[]; 24 | 25 | unresolvedItems: OrderedSet; 26 | 27 | fileSearchPrefix: string; 28 | 29 | initialize () { 30 | super.initialize(); 31 | 32 | this.titleText = 'Better Command Palette: Files'; 33 | this.emptyStateText = 'No matching files.'; 34 | this.fileSearchPrefix = this.plugin.settings.fileSearchPrefix; 35 | 36 | this.hiddenIds = this.plugin.settings.hiddenFiles; 37 | this.hiddenIdsSettingsKey = 'hiddenFiles'; 38 | 39 | this.allItems = []; 40 | 41 | this.unresolvedItems = new OrderedSet(); 42 | 43 | // Actually returns all files in the cache even if there are no unresolved links 44 | this.app.metadataCache.getCachedFiles() 45 | .forEach((filePath: string) => { 46 | const badfileType = this.plugin.settings.fileTypeExclusion.some((suf) => filePath.endsWith(`.${suf}`)); 47 | 48 | // If we shouldn't show the file type just return right now 49 | if (badfileType) return; 50 | 51 | const matches = createPaletteMatchesFromFilePath(this.app.metadataCache, filePath); 52 | this.allItems = this.allItems.concat(matches); 53 | 54 | // Add any unresolved links to the set 55 | Object.keys(this.app.metadataCache.unresolvedLinks[filePath] || {}).forEach( 56 | (p) => this.unresolvedItems.add(new PaletteMatch(p, p)), 57 | ); 58 | }); 59 | 60 | // Add the deduped links to all items 61 | this.allItems = this.allItems.concat(Array.from(this.unresolvedItems.values())).reverse(); 62 | 63 | // Use obsidian's last open files as the previous items 64 | [...this.app.workspace.getLastOpenFiles()].reverse().forEach((filePath) => { 65 | const matches = createPaletteMatchesFromFilePath(this.app.metadataCache, filePath); 66 | 67 | // For previous items we only want the actual file, not any aliases 68 | if (matches[0]) { 69 | this.prevItems.add(matches[0]); 70 | } 71 | }); 72 | } 73 | 74 | mount (): void { 75 | this.keymapHandlers = [ 76 | this.palette.scope.register(['Mod'], this.plugin.settings.commandSearchHotkey, () => this.palette.changeActionType(ActionType.Commands)), 77 | this.palette.scope.register(['Mod'], this.plugin.settings.tagSearchHotkey, () => this.palette.changeActionType(ActionType.Tags)), 78 | ]; 79 | } 80 | 81 | getInstructions (): Instruction[] { 82 | const { openInNewTabMod, createNewFileMod } = this.plugin.settings; 83 | return [ 84 | { command: generateHotKeyText({ modifiers: [], key: 'ENTER' }, this.plugin.settings), purpose: 'Open file' }, 85 | { command: generateHotKeyText({ modifiers: [openInNewTabMod], key: 'ENTER' }, this.plugin.settings), purpose: 'Open file in new pane' }, 86 | { command: generateHotKeyText({ modifiers: [createNewFileMod], key: 'ENTER' }, this.plugin.settings), purpose: 'Create file' }, 87 | { command: generateHotKeyText({ modifiers: ['Mod'], key: this.plugin.settings.commandSearchHotkey }, this.plugin.settings), purpose: 'Search Commands' }, 88 | { command: generateHotKeyText({ modifiers: ['Mod'], key: this.plugin.settings.tagSearchHotkey }, this.plugin.settings), purpose: 'Search Tags' }, 89 | ]; 90 | } 91 | 92 | cleanQuery (query: string): string { 93 | const newQuery = query.replace(this.fileSearchPrefix, ''); 94 | return newQuery; 95 | } 96 | 97 | renderSuggestion (match: Match, content: HTMLElement): void { 98 | let noteName = match.text; 99 | 100 | // Build the displayed note name without its full path if required in settings 101 | if (this.plugin.settings.displayOnlyNotesNames) { 102 | noteName = match.text.split("/").pop(); 103 | } 104 | 105 | // Build the displayed note name without its Markdown extension if required in settings 106 | if (this.plugin.settings.hideMdExtension && noteName.endsWith(".md")) { 107 | noteName = noteName.slice(0, -3); 108 | } 109 | 110 | const suggestionEl = content.createEl('div', { 111 | cls: 'suggestion-title', 112 | text: noteName 113 | }); 114 | 115 | if (this.unresolvedItems.has(match)) { 116 | suggestionEl.addClass('unresolved'); 117 | } 118 | 119 | if (match.id.includes(':')) { 120 | // Set Icon will destroy the first element in a node. So we need to add one back 121 | suggestionEl.createEl('span', { 122 | cls: 'suggestion-name', 123 | text: match.text, 124 | }).ariaLabel = 'Alias'; 125 | 126 | setIcon(suggestionEl, 'right-arrow-with-tail'); 127 | 128 | const [, path] = match.id.split(':'); 129 | suggestionEl.createEl('span', { 130 | cls: 'suggestion-note', 131 | text: path, 132 | }); 133 | } 134 | 135 | content.createEl('div', { 136 | cls: 'suggestion-note', 137 | text: `${match.tags.join(' ')}`, 138 | }); 139 | } 140 | 141 | async onChooseSuggestion (match: Match, event: MouseEvent | KeyboardEvent) { 142 | let path = match && match.id; 143 | 144 | // No match means we are trying to create new file 145 | if (!match) { 146 | const el = event.target as HTMLInputElement; 147 | path = el.value.replace(this.fileSearchPrefix, ''); 148 | } else if (path.includes(':')) { 149 | // If the path is an aliase, remove the alias prefix 150 | [, path] = path.split(':'); 151 | } 152 | 153 | const file = await getOrCreateFile(this.app, path); 154 | 155 | // We might not have a file if only a directory was specified 156 | if (file) { 157 | this.getPrevItems().add(match || new PaletteMatch(file.path, file.path)); 158 | } 159 | 160 | openFileWithEventKeys(this.app, this.plugin.settings, file, event); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/palette-modal-adapters/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BetterCommandPaletteCommandAdapter } from './command-adapter'; 2 | export { default as BetterCommandPaletteFileAdapter } from './file-adapter'; 3 | export { default as BetterCommandPaletteTagAdapter } from './tag-adapter'; 4 | -------------------------------------------------------------------------------- /src/palette-modal-adapters/tag-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Instruction } from 'obsidian'; 2 | import { 3 | generateHotKeyText, 4 | PaletteMatch, SuggestModalAdapter, 5 | } 6 | from 'src/utils'; 7 | import { ActionType, QUERY_TAG } from 'src/utils/constants'; 8 | import { Match, UnsafeAppInterface } from '../types/types'; 9 | 10 | export default class BetterCommandPaletteTagAdapter extends SuggestModalAdapter { 11 | titleText: string; 12 | 13 | emptyStateText: string; 14 | 15 | // Unsafe interface 16 | app: UnsafeAppInterface; 17 | 18 | allItems: Match[]; 19 | 20 | tagSearchPrefix: string; 21 | 22 | initialize(): void { 23 | super.initialize(); 24 | 25 | this.hiddenIds = this.plugin.settings.hiddenTags; 26 | this.hiddenIdsSettingsKey = 'hiddenTags'; 27 | 28 | this.tagSearchPrefix = this.plugin.settings.tagSearchPrefix; 29 | this.titleText = 'Better Command Palette: Tags'; 30 | this.emptyStateText = 'No matching tags.'; 31 | 32 | this.allItems = Object.entries(this.app.metadataCache.getTags()) 33 | .map(([tag, count]) => new PaletteMatch(tag, tag, [count])); 34 | } 35 | 36 | mount(): void { 37 | this.keymapHandlers = [ 38 | this.palette.scope.register(['Mod'], this.plugin.settings.commandSearchHotkey, () => this.palette.changeActionType(ActionType.Commands)), 39 | this.palette.scope.register(['Mod'], this.plugin.settings.fileSearchHotkey, () => this.palette.changeActionType(ActionType.Files)), 40 | ]; 41 | } 42 | 43 | getInstructions(): Instruction[] { 44 | return [ 45 | { command: generateHotKeyText({ modifiers: [], key: 'ENTER' }, this.plugin.settings), purpose: 'See file usage' }, 46 | { command: generateHotKeyText({ modifiers: ['Mod'], key: this.plugin.settings.commandSearchHotkey }, this.plugin.settings), purpose: 'Search Commands' }, 47 | { command: generateHotKeyText({ modifiers: ['Mod'], key: this.plugin.settings.fileSearchHotkey }, this.plugin.settings), purpose: 'Search Files' }, 48 | ]; 49 | } 50 | 51 | cleanQuery(query: string): string { 52 | return query.replace(this.tagSearchPrefix, ''); 53 | } 54 | 55 | renderSuggestion(match: Match, content: HTMLElement, aux: HTMLElement): void { 56 | content.createEl('span', { 57 | cls: 'suggestion-content', 58 | text: match.text, 59 | }); 60 | 61 | const count = parseInt(match.tags[0], 10); 62 | 63 | aux.createEl('span', { 64 | cls: 'suggestion-flair', 65 | text: `Found in ${count} file${count === 1 ? '' : 's'}`, 66 | }); 67 | } 68 | 69 | async onChooseSuggestion(match: Match) { 70 | this.getPrevItems().add(match); 71 | this.palette.open(); 72 | this.palette.setQuery(`${this.plugin.settings.fileSearchPrefix}${QUERY_TAG}${match.text}`, 1); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/palette.ts: -------------------------------------------------------------------------------- 1 | import { App, setIcon, SuggestModal } from 'obsidian'; 2 | import { 3 | generateHotKeyText, 4 | OrderedSet, PaletteMatch, renderPrevItems, SuggestModalAdapter, 5 | } from 'src/utils'; 6 | import { Match, UnsafeSuggestModalInterface } from 'src/types/types'; 7 | import { 8 | BetterCommandPaletteCommandAdapter, 9 | BetterCommandPaletteFileAdapter, 10 | BetterCommandPaletteTagAdapter, 11 | } from 'src/palette-modal-adapters'; 12 | import BetterCommandPalettePlugin from 'src/main'; 13 | import { ActionType } from './utils/constants'; 14 | 15 | class BetterCommandPaletteModal extends SuggestModal implements UnsafeSuggestModalInterface { 16 | // Unsafe interface 17 | chooser: UnsafeSuggestModalInterface['chooser']; 18 | 19 | updateSuggestions: UnsafeSuggestModalInterface['updateSuggestions']; 20 | 21 | plugin: BetterCommandPalettePlugin; 22 | 23 | actionType: ActionType; 24 | 25 | fileSearchPrefix: string; 26 | 27 | tagSearchPrefix: string; 28 | 29 | suggestionsWorker: Worker; 30 | 31 | currentSuggestions: Match[]; 32 | 33 | lastQuery: string; 34 | 35 | modalTitleEl: HTMLElement; 36 | 37 | hiddenItemsHeaderEl: HTMLElement; 38 | 39 | showHiddenItems: boolean; 40 | 41 | initialInputValue: string; 42 | 43 | commandAdapter: BetterCommandPaletteCommandAdapter; 44 | 45 | fileAdapter: BetterCommandPaletteFileAdapter; 46 | 47 | tagAdapter: BetterCommandPaletteTagAdapter; 48 | 49 | currentAdapter: SuggestModalAdapter; 50 | 51 | suggestionLimit: number; 52 | 53 | constructor ( 54 | app: App, 55 | prevCommands: OrderedSet, 56 | prevTags: OrderedSet, 57 | plugin: BetterCommandPalettePlugin, 58 | suggestionsWorker: Worker, 59 | initialInputValue = '', 60 | ) { 61 | super(app); 62 | 63 | // General instance variables 64 | this.fileSearchPrefix = plugin.settings.fileSearchPrefix; 65 | this.tagSearchPrefix = plugin.settings.tagSearchPrefix; 66 | this.suggestionLimit = plugin.settings.suggestionLimit; 67 | this.initialInputValue = initialInputValue; 68 | 69 | this.plugin = plugin; 70 | 71 | this.modalEl.addClass('better-command-palette'); 72 | 73 | // The only time the input will be empty will be when we are searching commands 74 | this.setPlaceholder('Select a command'); 75 | 76 | // Set up all of our different adapters 77 | this.commandAdapter = new BetterCommandPaletteCommandAdapter( 78 | app, 79 | prevCommands, 80 | plugin, 81 | this, 82 | ); 83 | this.fileAdapter = new BetterCommandPaletteFileAdapter( 84 | app, 85 | new OrderedSet(), 86 | plugin, 87 | this, 88 | ); 89 | this.tagAdapter = new BetterCommandPaletteTagAdapter( 90 | app, 91 | prevTags, 92 | plugin, 93 | this, 94 | ); 95 | 96 | // Lets us do the suggestion fuzzy search in a different thread 97 | this.suggestionsWorker = suggestionsWorker; 98 | this.suggestionsWorker.onmessage = (msg: MessageEvent) => this.receivedSuggestions(msg); 99 | 100 | // Add our custom title element 101 | this.modalTitleEl = createEl('p', { 102 | cls: 'better-command-palette-title', 103 | }); 104 | 105 | // Update our action type before adding in our title element so the text is correct 106 | this.updateActionType(); 107 | 108 | // Add in the title element 109 | this.modalEl.insertBefore(this.modalTitleEl, this.modalEl.firstChild); 110 | 111 | this.hiddenItemsHeaderEl = createEl('p', 'hidden-items-header'); 112 | this.showHiddenItems = false; 113 | 114 | this.hiddenItemsHeaderEl.onClickEvent(this.toggleHiddenItems); 115 | 116 | this.modalEl.insertBefore(this.hiddenItemsHeaderEl, this.resultContainerEl); 117 | 118 | // Set our scopes for the modal 119 | this.setScopes(plugin); 120 | } 121 | 122 | close (evt?: KeyboardEvent) { 123 | super.close(); 124 | 125 | if (evt) { 126 | evt.preventDefault(); 127 | } 128 | } 129 | 130 | setScopes (plugin: BetterCommandPalettePlugin) { 131 | const closeModal = (event: KeyboardEvent) => { 132 | // Have to cast this to access `value` 133 | const el = event.target as HTMLInputElement; 134 | 135 | if (plugin.settings.closeWithBackspace && el.value === '') { 136 | this.close(event); 137 | } 138 | }; 139 | 140 | const { openInNewTabMod, createNewFileMod } = plugin.settings; 141 | 142 | this.scope.register([], 'Backspace', (event: KeyboardEvent) => { 143 | closeModal(event); 144 | }); 145 | 146 | this.scope.register(['Mod'], 'Backspace', (event: KeyboardEvent) => { 147 | closeModal(event); 148 | }); 149 | 150 | this.scope.register([createNewFileMod], 'Enter', (event: KeyboardEvent) => { 151 | if (this.actionType === ActionType.Files) { 152 | this.currentAdapter.onChooseSuggestion(null, event); 153 | this.close(event); 154 | } 155 | }); 156 | 157 | this.scope.register([createNewFileMod, openInNewTabMod], 'Enter', (event: KeyboardEvent) => { 158 | if (this.actionType === ActionType.Files) { 159 | this.currentAdapter.onChooseSuggestion(null, event); 160 | this.close(event); 161 | } 162 | }); 163 | 164 | this.scope.register([openInNewTabMod], 'Enter', (event: KeyboardEvent) => { 165 | if (this.actionType === ActionType.Files && this.currentSuggestions.length) { 166 | const promptResults = document.querySelector(".better-command-palette .prompt-results"); 167 | const selected = document.querySelector(".better-command-palette .is-selected"); 168 | const selectedIndex = Array.from(promptResults.children).indexOf(selected); 169 | this.currentAdapter.onChooseSuggestion(this.currentSuggestions[selectedIndex], event); 170 | this.close(event); 171 | } 172 | }); 173 | 174 | this.scope.register(['Mod'], 'I', this.toggleHiddenItems); 175 | } 176 | 177 | toggleHiddenItems = () => { 178 | this.showHiddenItems = !this.showHiddenItems; 179 | this.updateSuggestions(); 180 | }; 181 | 182 | onOpen () { 183 | super.onOpen(); 184 | 185 | // Add the initial value to the input 186 | // TODO: Figure out if there is a way to bypass the first seach 187 | // result flickering before this is set 188 | // As far as I can tell onOpen resets the value of the input so this is the first place 189 | if (this.initialInputValue) { 190 | this.setQuery(this.initialInputValue); 191 | } 192 | } 193 | 194 | changeActionType (actionType: ActionType) { 195 | let prefix = ''; 196 | if (actionType === ActionType.Files) { 197 | prefix = this.plugin.settings.fileSearchPrefix; 198 | } else if (actionType === ActionType.Tags) { 199 | prefix = this.plugin.settings.tagSearchPrefix; 200 | 201 | } 202 | const currentQuery: string = this.inputEl.value; 203 | const cleanQuery = this.currentAdapter.cleanQuery(currentQuery); 204 | 205 | this.inputEl.value = prefix + cleanQuery; 206 | this.updateSuggestions(); 207 | } 208 | 209 | setQuery ( 210 | newQuery: string, 211 | cursorPosition: number = -1, 212 | ) { 213 | this.inputEl.value = newQuery; 214 | 215 | if (cursorPosition > -1) { 216 | this.inputEl.setSelectionRange(cursorPosition, cursorPosition); 217 | } 218 | 219 | this.updateSuggestions(); 220 | } 221 | 222 | updateActionType (): boolean { 223 | const text: string = this.inputEl.value; 224 | let nextAdapter; 225 | let type; 226 | 227 | if (text.startsWith(this.fileSearchPrefix)) { 228 | type = ActionType.Files; 229 | nextAdapter = this.fileAdapter; 230 | this.modalEl.setAttribute("palette-mode", "files"); 231 | } else if (text.startsWith(this.tagSearchPrefix)) { 232 | type = ActionType.Tags; 233 | nextAdapter = this.tagAdapter; 234 | this.modalEl.setAttribute("palette-mode", "tags"); 235 | } else { 236 | type = ActionType.Commands; 237 | nextAdapter = this.commandAdapter; 238 | this.modalEl.setAttribute("palette-mode", "commands"); 239 | } 240 | 241 | if (type !== this.actionType) { 242 | this.currentAdapter?.unmount(); 243 | this.currentAdapter = nextAdapter; 244 | this.currentAdapter.mount(); 245 | } 246 | 247 | if (!this.currentAdapter.initialized) { 248 | this.currentAdapter.initialize(); 249 | } 250 | 251 | const wasUpdated = type !== this.actionType; 252 | this.actionType = type; 253 | 254 | if (wasUpdated) { 255 | this.updateEmptyStateText(); 256 | this.updateTitleText(); 257 | this.updateInstructions(); 258 | this.currentSuggestions = this.currentAdapter 259 | .getSortedItems() 260 | .slice(0, this.suggestionLimit); 261 | } 262 | 263 | return wasUpdated; 264 | } 265 | 266 | updateTitleText () { 267 | if (this.plugin.settings.showPluginName) { 268 | this.modalTitleEl.setText(this.currentAdapter.getTitleText()); 269 | } else { 270 | this.modalTitleEl.setText(''); 271 | } 272 | } 273 | 274 | updateEmptyStateText () { 275 | this.emptyStateText = this.currentAdapter.getEmptyStateText(); 276 | } 277 | 278 | updateInstructions () { 279 | Array.from(this.modalEl.getElementsByClassName('prompt-instructions')) 280 | .forEach((instruction) => { 281 | this.modalEl.removeChild(instruction); 282 | }); 283 | 284 | this.setInstructions([ 285 | ...this.currentAdapter.getInstructions(), 286 | { command: generateHotKeyText({ modifiers: [], key: 'ESC' }, this.plugin.settings), purpose: 'Close palette' }, 287 | { command: generateHotKeyText({ modifiers: ['Mod'], key: 'I' }, this.plugin.settings), purpose: 'Toggle Hidden Items' }, 288 | ]); 289 | } 290 | 291 | getItems (): Match[] { 292 | return this.currentAdapter.getSortedItems(); 293 | } 294 | 295 | receivedSuggestions (msg: MessageEvent) { 296 | const results = []; 297 | let hiddenCount = 0; 298 | 299 | for ( 300 | let i = 0; 301 | i < msg.data.length && results.length < this.suggestionLimit + hiddenCount; 302 | i += 1 303 | ) { 304 | results.push(msg.data[i]); 305 | 306 | if (this.currentAdapter.hiddenIds.includes(msg.data[i].id)) { 307 | hiddenCount += 1; 308 | } 309 | } 310 | 311 | const matches = results.map((r: Match) => new PaletteMatch(r.id, r.text, r.tags)); 312 | 313 | // Sort the suggestions so that previously searched items are first 314 | const prevItems = this.currentAdapter.getPrevItems(); 315 | matches.sort((a, b) => (+prevItems.has(b)) - (+prevItems.has(a))); 316 | 317 | this.currentSuggestions = matches; 318 | this.limit = this.currentSuggestions.length; 319 | this.updateSuggestions(); 320 | } 321 | 322 | getSuggestionsAsync (query: string) { 323 | const items = this.getItems(); 324 | this.suggestionsWorker.postMessage({ 325 | query, 326 | items, 327 | }); 328 | } 329 | 330 | getSuggestions (query: string): Match[] { 331 | // The action type might have changed 332 | this.updateActionType(); 333 | 334 | const getNewSuggestions = query !== this.lastQuery; 335 | this.lastQuery = query; 336 | const fixedQuery = this.currentAdapter.cleanQuery(query.trim()); 337 | 338 | if (getNewSuggestions) { 339 | // Load suggestions in another thread 340 | this.getSuggestionsAsync(fixedQuery); 341 | } 342 | 343 | const visibleItems = this.currentSuggestions.filter( 344 | (match) => !this.currentAdapter.hiddenIds.includes(match.id), 345 | ); 346 | 347 | const hiddenItemCount = this.currentSuggestions.length - visibleItems.length; 348 | 349 | this.updateHiddenItemCountHeader(hiddenItemCount); 350 | 351 | // For now return what we currently have. We'll populate results later if we need to 352 | return this.showHiddenItems ? this.currentSuggestions : visibleItems; 353 | } 354 | 355 | updateHiddenItemCountHeader (hiddenItemCount: number) { 356 | this.hiddenItemsHeaderEl.empty(); 357 | 358 | if (hiddenItemCount !== 0) { 359 | const text = `${this.showHiddenItems ? 'Hide' : 'Show'} hidden items (${hiddenItemCount})`; 360 | this.hiddenItemsHeaderEl.setText(text); 361 | } 362 | } 363 | 364 | renderSuggestion (match: Match, el: HTMLElement) { 365 | el.addClass('mod-complex'); 366 | 367 | const isHidden = this.currentAdapter.hiddenIds.includes(match.id); 368 | 369 | if (isHidden) { 370 | el.addClass('hidden'); 371 | } 372 | 373 | const icon = 'cross'; 374 | 375 | const suggestionContent = el.createEl('span', 'suggestion-content'); 376 | const suggestionAux = el.createEl('span', 'suggestion-aux'); 377 | 378 | const flairContainer = suggestionAux.createEl('span', 'suggestion-flair'); 379 | renderPrevItems(this.plugin.settings, match, suggestionContent, this.currentAdapter.getPrevItems()); 380 | 381 | setIcon(flairContainer, icon, 13); 382 | flairContainer.ariaLabel = isHidden ? 'Click to Unhide' : 'Click to Hide'; 383 | flairContainer.setAttr('data-id', match.id); 384 | 385 | flairContainer.onClickEvent((event) => { 386 | event.preventDefault(); 387 | event.stopPropagation(); 388 | 389 | const hideEl = event.target as HTMLElement; 390 | 391 | this.currentAdapter.toggleHideId(hideEl.getAttr('data-id')); 392 | }); 393 | 394 | this.currentAdapter.renderSuggestion(match, suggestionContent, suggestionAux); 395 | } 396 | 397 | async onChooseSuggestion (item: Match, event: MouseEvent | KeyboardEvent) { 398 | this.currentAdapter.onChooseSuggestion(item, event); 399 | } 400 | } 401 | 402 | export default BetterCommandPaletteModal; 403 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, Command, Modifier, PluginSettingTab, setIcon, Setting, 3 | } from 'obsidian'; 4 | import BetterCommandPalettePlugin from 'src/main'; 5 | import { HotkeyStyleType, MacroCommandInterface, UnsafeAppInterface } from './types/types'; 6 | import { SettingsCommandSuggestModal } from './utils'; 7 | 8 | export interface BetterCommandPalettePluginSettings { 9 | closeWithBackspace: boolean, 10 | showPluginName: boolean, 11 | fileSearchPrefix: string, 12 | tagSearchPrefix: string, 13 | commandSearchHotkey: string, 14 | fileSearchHotkey: string, 15 | tagSearchHotkey: string, 16 | suggestionLimit: number, 17 | recentAbovePinned: boolean, 18 | hyperKeyOverride: boolean, 19 | displayOnlyNotesNames: boolean, 20 | hideMdExtension: boolean, 21 | recentlyUsedText: string, 22 | macros: MacroCommandInterface[], 23 | hotkeyStyle: HotkeyStyleType; 24 | createNewFileMod: Modifier, 25 | openInNewTabMod: Modifier, 26 | hiddenCommands: string[], 27 | hiddenFiles: string[], 28 | hiddenTags: string[], 29 | fileTypeExclusion: string[], 30 | } 31 | 32 | export const DEFAULT_SETTINGS: BetterCommandPalettePluginSettings = { 33 | closeWithBackspace: true, 34 | showPluginName: true, 35 | fileSearchPrefix: '/', 36 | tagSearchPrefix: '#', 37 | commandSearchHotkey: 'p', 38 | fileSearchHotkey: 'o', 39 | tagSearchHotkey: 't', 40 | suggestionLimit: 50, 41 | recentAbovePinned: false, 42 | hyperKeyOverride: false, 43 | displayOnlyNotesNames: false, 44 | hideMdExtension: false, 45 | recentlyUsedText: '(recently used)', 46 | macros: [], 47 | hotkeyStyle: 'auto', 48 | createNewFileMod: 'Mod', 49 | openInNewTabMod: 'Shift', 50 | hiddenCommands: [], 51 | hiddenFiles: [], 52 | hiddenTags: [], 53 | fileTypeExclusion: [], 54 | }; 55 | 56 | export class BetterCommandPaletteSettingTab extends PluginSettingTab { 57 | plugin: BetterCommandPalettePlugin; 58 | 59 | app: UnsafeAppInterface; 60 | 61 | constructor (app: App, plugin: BetterCommandPalettePlugin) { 62 | super(app, plugin); 63 | this.plugin = plugin; 64 | } 65 | 66 | display (): void { 67 | this.containerEl.empty(); 68 | this.displayBasicSettings(); 69 | this.displayMacroSettings(); 70 | } 71 | 72 | displayBasicSettings (): void { 73 | const { containerEl } = this; 74 | const { settings } = this.plugin; 75 | 76 | containerEl.empty(); 77 | 78 | containerEl.createEl('h2', { text: 'Better Command Palette Settings' }); 79 | new Setting(containerEl) 80 | .setName('Close on Backspace') 81 | .setDesc('Close the palette when there is no text and backspace is pressed') 82 | .addToggle((t) => t.setValue(settings.closeWithBackspace).onChange(async (val) => { 83 | settings.closeWithBackspace = val; 84 | await this.plugin.saveSettings(); 85 | })); 86 | 87 | new Setting(containerEl) 88 | .setName('Show Plugin Name') 89 | .setDesc('Show the plugin name in the command palette') 90 | .addToggle((t) => t.setValue(settings.showPluginName).onChange(async (val) => { 91 | settings.showPluginName = val; 92 | await this.plugin.saveSettings(); 93 | })); 94 | 95 | new Setting(containerEl) 96 | .setName('Recent above Pinned') 97 | .setDesc('Sorts the suggestion so that the recently used items show before pinned items.') 98 | .addToggle((t) => t.setValue(settings.recentAbovePinned).onChange(async (val) => { 99 | settings.recentAbovePinned = val; 100 | await this.plugin.saveSettings(); 101 | })); 102 | 103 | new Setting(containerEl) 104 | .setName('Caps Lock Hyper Key Hotkey Override') 105 | .setDesc('For those users who have use a "Hyper Key", enabling this maps the icons "⌥ ^ ⌘ ⇧" to the caps lock icon "⇪" ') 106 | .addToggle((t) => t.setValue(settings.hyperKeyOverride).onChange(async (val) => { 107 | settings.hyperKeyOverride = val; 108 | await this.plugin.saveSettings(); 109 | })); 110 | 111 | new Setting(containerEl) 112 | .setName('Use shift to create files and cmd/CTRL to open in new tab') 113 | .setDesc('By default cmd/ctrl is used to create files and shift is used to open in new tab. This setting reverses that to mimic the behavior of the standard quick switcher.') 114 | .addToggle((t) => t.setValue(settings.createNewFileMod === 'Shift').onChange(async (val) => { 115 | settings.createNewFileMod = val ? 'Shift' : 'Mod'; 116 | settings.openInNewTabMod = val ? 'Mod' : 'Shift'; 117 | await this.plugin.saveSettings(); 118 | })); 119 | 120 | new Setting(containerEl) 121 | .setName("Display only notes' names") 122 | .setDesc("If enabled, only notes names will be displayed in Quick Switcher mode instead of their full path.") 123 | .addToggle((t) => t.setValue(settings.displayOnlyNotesNames).onChange(async (val) => { 124 | settings.displayOnlyNotesNames = val; 125 | await this.plugin.saveSettings(); 126 | })); 127 | 128 | new Setting(containerEl) 129 | .setName("Hide .md extensions") 130 | .setDesc("If enabled, Markdown notes will be displayed without their .md extension in Quick Switcher mode") 131 | .addToggle((t) => t.setValue(settings.hideMdExtension).onChange(async (val) => { 132 | settings.hideMdExtension = val; 133 | await this.plugin.saveSettings(); 134 | })); 135 | 136 | 137 | new Setting(containerEl) 138 | .setName('Recently used text') 139 | .setDesc('This text will be displayed next to recently used items') 140 | .addText((t) => t.setValue(settings.recentlyUsedText).onChange(async (val) => { 141 | settings.recentlyUsedText = val; 142 | await this.plugin.saveSettings(); 143 | })); 144 | 145 | new Setting(containerEl) 146 | .setName('File Type Exclusions') 147 | .setDesc('A comma separated list of file extensions (ex: "pdf,jpg,png") that should not be shown when searching files.') 148 | .addText((t) => t.setValue(settings.fileTypeExclusion.join(',')).onChange(async (val) => { 149 | const list = val.split(',').map((e) => e.trim()); 150 | settings.fileTypeExclusion = list; 151 | await this.plugin.saveSettings(); 152 | })); 153 | 154 | new Setting(containerEl) 155 | .setName('File Search Prefix') 156 | .setDesc('The prefix used to tell the palette you want to search files') 157 | .addText((t) => t.setValue(settings.fileSearchPrefix).onChange(async (val) => { 158 | settings.fileSearchPrefix = val; 159 | await this.plugin.saveSettings(); 160 | })); 161 | 162 | new Setting(containerEl) 163 | .setName('Tag Search Prefix') 164 | .setDesc('The prefix used to tell the palette you want to search tags') 165 | .addText((t) => t.setValue(settings.tagSearchPrefix).onChange(async (val) => { 166 | settings.tagSearchPrefix = val; 167 | await this.plugin.saveSettings(); 168 | })); 169 | 170 | new Setting(containerEl) 171 | .setName('Command Search Hotkey') 172 | .setDesc('The hotkey used to switch to command search while using the command palette.') 173 | .addText((t) => t.setValue(settings.commandSearchHotkey).onChange(async (val) => { 174 | settings.commandSearchHotkey = val; 175 | await this.plugin.saveSettings(); 176 | })); 177 | 178 | new Setting(containerEl) 179 | .setName('File Search Hotkey') 180 | .setDesc('The hotkey used to switch to file search while using the command palette.') 181 | .addText((t) => t.setValue(settings.fileSearchHotkey).onChange(async (val) => { 182 | settings.fileSearchHotkey = val; 183 | await this.plugin.saveSettings(); 184 | })); 185 | 186 | new Setting(containerEl) 187 | .setName('Tag Search Hotkey') 188 | .setDesc('The hotkey used to switch to tag search while using the command palette.') 189 | .addText((t) => t.setValue(settings.tagSearchHotkey).onChange(async (val) => { 190 | settings.tagSearchHotkey = val; 191 | await this.plugin.saveSettings(); 192 | })); 193 | 194 | const dropdownOptions = { 195 | 10: '10', 196 | 20: '20', 197 | 50: '50', 198 | 100: '100', 199 | 200: '200', 200 | 500: '500', 201 | 1000: '1000', 202 | }; 203 | new Setting(containerEl) 204 | .setName('Suggestion Limit') 205 | .setDesc('The number of items that will be in the suggestion list of the palette. Really high numbers can affect performance') 206 | .addDropdown((d) => d.addOptions(dropdownOptions) 207 | .setValue(settings.suggestionLimit.toString()) 208 | .onChange(async (v) => { 209 | settings.suggestionLimit = parseInt(v, 10); 210 | await this.plugin.saveSettings(); 211 | })); 212 | 213 | new Setting(containerEl) 214 | .setName('Hotkey Modifier Style') 215 | .setDesc('Allows autodetecting of hotkey modifier or forcing to Mac or Windows') 216 | .addDropdown((d) => d.addOptions({ 217 | auto: 'Auto Detect', 218 | mac: 'Force Mac Hotkeys', 219 | windows: 'Force Windows Hotkeys', 220 | }).setValue(settings.hotkeyStyle) 221 | .onChange(async (v) => { 222 | settings.hotkeyStyle = v as HotkeyStyleType; 223 | await this.plugin.saveSettings(); 224 | })); 225 | 226 | new Setting(containerEl) 227 | .setName('Add new macro') 228 | .setDesc('Create a new grouping of commands that can be run together') 229 | .addButton((button) => button 230 | .setButtonText('+') 231 | .onClick(async () => { 232 | settings.macros.push({ 233 | name: `Macro ${settings.macros.length + 1}`, 234 | commandIds: [], 235 | delay: 200, 236 | }); 237 | await this.plugin.saveSettings(); 238 | this.display(); 239 | })); 240 | } 241 | 242 | displayMacroSettings (): void { 243 | const { containerEl } = this; 244 | const { settings } = this.plugin; 245 | 246 | settings.macros.forEach((macro, index) => { 247 | const topLevelSetting = new Setting(containerEl) 248 | .setClass('macro-setting') 249 | .setName(`Macro #${index + 1}`) 250 | .addButton((button) => button 251 | .setButtonText('Delete Macro') 252 | .onClick(async () => { 253 | settings.macros.splice(index, 1); 254 | await this.plugin.saveSettings(); 255 | this.display(); 256 | })); 257 | 258 | const mainSettingsEl = topLevelSetting.settingEl.createEl('div', 'macro-main-settings'); 259 | 260 | mainSettingsEl.createEl('label', { text: 'Macro Name' }); 261 | mainSettingsEl.createEl('input', { 262 | cls: 'name-input', 263 | type: 'text', 264 | value: macro.name, 265 | }).on('change', '.name-input', async (evt: Event) => { 266 | const target = evt.target as HTMLInputElement; 267 | settings.macros[index] = { ...macro, name: target.value }; 268 | await this.plugin.saveSettings(); 269 | }); 270 | 271 | mainSettingsEl.createEl('label', { text: 'Delay (ms)' }); 272 | mainSettingsEl.createEl('input', { 273 | cls: 'delay-input', 274 | type: 'number', 275 | value: macro.delay.toString(), 276 | }).on('change', '.delay-input', async (evt: Event) => { 277 | const target = evt.target as HTMLInputElement; 278 | const delayStr = target.value; 279 | settings.macros[index].delay = parseInt(delayStr, 10); 280 | await this.plugin.saveSettings(); 281 | }); 282 | 283 | mainSettingsEl.createEl('label', { text: 'Add a new Command to the macro' }); 284 | mainSettingsEl.createEl('button', { text: 'Add Command' }).onClickEvent(async () => { 285 | const suggestModal = new SettingsCommandSuggestModal( 286 | this.app, 287 | async (item: Command) => { 288 | settings.macros[index].commandIds.push(item.id); 289 | await this.plugin.saveSettings(); 290 | this.display(); 291 | }, 292 | ); 293 | suggestModal.open(); 294 | }); 295 | 296 | macro.commandIds.forEach((id, cIndex) => { 297 | const command = this.app.commands.findCommand(id); 298 | const commandEl = topLevelSetting.settingEl.createEl('div', 'macro-command'); 299 | 300 | const buttonEl = commandEl.createEl('button', `delete-command-${cIndex}`); 301 | 302 | commandEl.createEl('p', { text: `${cIndex + 1}: ${command.name}`, cls: 'command' }); 303 | 304 | setIcon(buttonEl, 'trash'); 305 | buttonEl.onClickEvent(async () => { 306 | settings.macros[index].commandIds.splice(cIndex, 1); 307 | await this.plugin.saveSettings(); 308 | this.display(); 309 | }); 310 | }); 311 | }); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | .better-command-palette{ 2 | .better-command-palette-title { 3 | color: var(--text-accent); 4 | margin: 5px; 5 | } 6 | 7 | .hidden-items-header { 8 | margin: 0px; 9 | margin-left: 13px; 10 | margin-top: 10px; 11 | font-size: 15px; 12 | color: var(--text-faint); 13 | 14 | &:hover { 15 | color: var(--text-muted); 16 | cursor: pointer; 17 | } 18 | } 19 | 20 | .suggestion-item { 21 | &.hidden { 22 | color: var(--text-accent); 23 | 24 | .suggestion-flair { 25 | transform: rotate(45deg); 26 | } 27 | } 28 | 29 | .suggestion-flair { 30 | color: var(--text-faint); 31 | margin-right: 10px; 32 | } 33 | 34 | .suggestion-hotkey { 35 | white-space: nowrap; 36 | margin-left: 10px; 37 | padding: 0px 10px; 38 | } 39 | 40 | .suggestion-content { 41 | svg { 42 | margin: 0px 5px; 43 | color: var(--text-muted); 44 | } 45 | } 46 | 47 | .suggestion-aux { 48 | flex-direction: row-reverse; 49 | } 50 | 51 | .suggestion-title { 52 | display: flex; 53 | align-items: center; 54 | } 55 | 56 | .suggestion-note { 57 | flex: 1; 58 | } 59 | 60 | .unresolved { 61 | color: var(--text-muted); 62 | 63 | &::after { 64 | content: '(Unresolved link)'; 65 | color: var(--text-faint); 66 | margin-left: 10px; 67 | } 68 | } 69 | } 70 | } 71 | 72 | /* Settings Styles */ 73 | .macro-setting { 74 | flex-wrap: wrap; 75 | .setting-item-name { 76 | font-weight: bold; 77 | } 78 | 79 | .macro-main-settings { 80 | width: 100%; 81 | display: grid; 82 | grid-template-columns: 80% 20%; 83 | border-top: solid 1px var(--background-modifier-border); 84 | margin-top: 10px; 85 | 86 | * { 87 | margin-top: 10px; 88 | } 89 | } 90 | 91 | .macro-command { 92 | display: flex; 93 | align-items: center; 94 | width: 100%; 95 | 96 | button { 97 | margin-left: 30px; 98 | margin-right: 20px; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/types/types.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PluginSettingTab, Plugin, Command, Hotkey, MetadataCache, App, SuggestModal, 3 | } from 'obsidian'; 4 | import { OrderedSet } from 'src/utils'; 5 | 6 | interface MacroCommandInterface { 7 | commandIds: string[], 8 | name: string, 9 | delay: number, 10 | } 11 | 12 | export interface BetterCommandPaletteInterface extends Plugin { 13 | settings: PluginSettingTab; 14 | 15 | prevCommands: OrderedSet; 16 | 17 | prevFiles: OrderedSet; 18 | 19 | prevTags: OrderedSet; 20 | 21 | suggestionsWorker: Worker; 22 | } 23 | 24 | export interface Comparable { 25 | value: () => string; 26 | } 27 | 28 | export interface Match extends Comparable { 29 | text: string, 30 | id: string, 31 | tags: string[], 32 | } 33 | 34 | // Unsafe Interfaces 35 | // Ideally we would not have to use these, but as far as I can tell 36 | // they are the only way for certain functionality. 37 | // Copied this pattern from Another Quick Switcher: https://github.com/tadashi-aikawa/obsidian-another-quick-switcher/blob/master/src/ui/AnotherQuickSwitcherModal.ts#L109 38 | 39 | export interface UnsafeSuggestModalInterface extends SuggestModal { 40 | chooser: { 41 | useSelectedItem(ev: Partial): void; 42 | } 43 | updateSuggestions(): void; 44 | } 45 | 46 | interface UnsafeMetadataCacheInterface extends MetadataCache { 47 | getCachedFiles(): string[], 48 | getTags(): Record; 49 | } 50 | 51 | export interface UnsafeAppInterface extends App { 52 | commands: { 53 | listCommands(): Command[], 54 | findCommand(id: string): Command, 55 | removeCommand(id: string): void, 56 | executeCommandById(id: string): void, 57 | commands: Record, 58 | }, 59 | hotkeyManager: { 60 | getHotkeys(id: string): Hotkey[], 61 | getDefaultHotkeys(id: string): Hotkey[], 62 | }, 63 | metadataCache: UnsafeMetadataCacheInterface, 64 | internalPlugins: { 65 | getPluginById(id: string): { instance: { options: { pinned: [] } } }, 66 | } 67 | } 68 | 69 | type HotkeyStyleType = 'auto' | 'mac' | 'windows'; 70 | 71 | type Message = { 72 | data: { 73 | query: string, 74 | items: Match[], 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /src/types/worker.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'web-worker:*' { 2 | const WorkerFactory: new (options: any) => Worker; 3 | export default WorkerFactory; 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const QUERY_OR = '||'; 2 | export const QUERY_TAG = '@'; 3 | 4 | export const HYPER_KEY_MODIFIERS_SET = new Set(['Alt', 'Ctrl', 'Mod', 'Shift']); 5 | 6 | export const BASIC_MODIFIER_ICONS = { 7 | Mod: 'Ctrl +', 8 | Ctrl: 'Ctrl +', 9 | Meta: 'Win +', 10 | Alt: 'Alt +', 11 | Shift: 'Shift +', 12 | Hyper: 'Caps +', 13 | }; 14 | 15 | export const MAC_MODIFIER_ICONS = { 16 | Mod: '⌘', 17 | Ctrl: '^', 18 | Meta: '⌘', 19 | Alt: '⌥', 20 | Shift: '⇧', 21 | Hyper: '⇪', 22 | }; 23 | 24 | export const SPECIAL_KEYS: Record = { 25 | TAB: '↹', 26 | ENTER: '↵', 27 | ARROWLEFT: '←', 28 | ARROWRIGHT: '→', 29 | ARROWUP: '↑', 30 | ARROWDOWN: '↓', 31 | BACKSPACE: '⌫', 32 | ESC: 'Esc', 33 | }; 34 | 35 | export const MACRO_COMMAND_ID_PREFIX = 'obsidian-better-command-palette-macro-'; 36 | 37 | export enum ActionType { 38 | Commands, 39 | Files, 40 | Tags, 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OrderedSet } from './ordered-set'; 2 | export { default as PaletteMatch } from './palette-match'; 3 | export { default as SuggestModalAdapter } from './suggest-modal-adapter'; 4 | export { default as MacroCommand } from './macro'; 5 | export { default as SettingsCommandSuggestModal } from './settings-command-suggest-modal'; 6 | export * from './utils'; 7 | -------------------------------------------------------------------------------- /src/utils/macro.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, Command, Notice, 3 | } from 'obsidian'; 4 | import { MacroCommandInterface, UnsafeAppInterface } from 'src/types/types'; 5 | 6 | export default class MacroCommand implements Command, MacroCommandInterface { 7 | app: UnsafeAppInterface; 8 | 9 | id: string; 10 | 11 | name: string; 12 | 13 | commandIds: string[]; 14 | 15 | delay: number; 16 | 17 | constructor( 18 | app: App, 19 | id: string, 20 | name: string, 21 | commandIds: string[] = [], 22 | delay: number = 200, 23 | ) { 24 | this.app = app as UnsafeAppInterface; 25 | this.id = id; 26 | this.name = name; 27 | this.commandIds = commandIds; 28 | this.delay = delay; 29 | } 30 | 31 | addCommand(commandId: string) { 32 | this.commandIds.push(commandId); 33 | } 34 | 35 | removeCommand(commandId: string) { 36 | this.commandIds = this.commandIds.filter((c) => c === commandId); 37 | } 38 | 39 | commandIsAvailable(id:string) { 40 | const command = this.app.commands.findCommand(id); 41 | return !command.checkCallback || command.checkCallback(true); 42 | } 43 | 44 | async callAllCommands() { 45 | const notice = new Notice(`Running "${this.name}"...`, 10000); 46 | 47 | for (let i = 0; i < this.commandIds.length; i += 1) { 48 | const commandId = this.commandIds[i]; 49 | const command = this.app.commands.findCommand(commandId); 50 | 51 | if (!this.commandIsAvailable(commandId)) { 52 | notice.setMessage(`Error: "${command.name}" cannot be used in this context. The macro is stopping.`); 53 | break; 54 | } 55 | 56 | notice.setMessage(`Running "${command.name}"`); 57 | 58 | this.app.commands.executeCommandById(commandId); 59 | 60 | // Give our commands some time to complete 61 | // eslint-disable-next-line no-await-in-loop 62 | await new Promise((resolve) => { 63 | setTimeout(resolve, this.delay); 64 | }); 65 | } 66 | 67 | notice.hide(); 68 | // eslint-disable-next-line no-new 69 | new Notice(`Successfully ran "${this.name}"`); 70 | 71 | return true; 72 | } 73 | 74 | checkCallback(checking: boolean): boolean | void { 75 | if (checking) { 76 | return this.commandIsAvailable(this.commandIds[0]); 77 | } 78 | 79 | this.callAllCommands(); 80 | return null; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/utils/ordered-set.ts: -------------------------------------------------------------------------------- 1 | import { Comparable } from 'src/types/types'; 2 | 3 | /** 4 | * A utility set that keeps track of the last time an item was added to the 5 | * set even if it was already in the set. 6 | */ 7 | export default class OrderedSet { 8 | private map: Map; 9 | 10 | constructor(values: T[] = []) { 11 | this.map = new Map(); 12 | values.forEach((v) => this.map.set(v.value(), v)); 13 | } 14 | 15 | has(item: T): boolean { 16 | return this.map.has(item.value()); 17 | } 18 | 19 | add(item: T) { 20 | this.delete(item); 21 | 22 | return this.map.set(item.value(), item); 23 | } 24 | 25 | addAll(items: T[]) { 26 | items.forEach((item) => this.add(item)); 27 | } 28 | 29 | delete(item: T) { 30 | this.map.delete(item.value()); 31 | } 32 | 33 | values(): T[] { 34 | return Array.from(this.map.values()); 35 | } 36 | 37 | valuesByLastAdd(): T[] { 38 | return Array.from(this.map.values()).reverse(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/palette-match.ts: -------------------------------------------------------------------------------- 1 | import { Match } from 'src/types/types'; 2 | 3 | export default class PaletteMatch implements Match { 4 | id: string; 5 | 6 | text: string; 7 | 8 | tags: string[]; 9 | 10 | constructor(id: string, text: string, tags: string[] = []) { 11 | this.id = id; 12 | this.text = text; 13 | this.tags = tags; 14 | } 15 | 16 | value(): string { 17 | return this.id; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/settings-command-suggest-modal.ts: -------------------------------------------------------------------------------- 1 | import { Command, FuzzySuggestModal } from 'obsidian'; 2 | import { UnsafeAppInterface } from 'src/types/types'; 3 | import { getCommandText } from './utils'; 4 | 5 | export default class CommandSuggestModal extends FuzzySuggestModal { 6 | app: UnsafeAppInterface; 7 | 8 | callback: (item: Command) => void; 9 | 10 | getItemText = getCommandText; 11 | 12 | constructor(app: UnsafeAppInterface, callback: (item: Command) => void) { 13 | super(app); 14 | this.callback = callback; 15 | } 16 | 17 | getItems(): Command[] { 18 | return Object.values(this.app.commands.commands); 19 | } 20 | 21 | onChooseItem(item: Command): void { 22 | this.callback(item); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/suggest-modal-adapter.ts: -------------------------------------------------------------------------------- 1 | import { App, Instruction, KeymapEventHandler } from 'obsidian'; 2 | import BetterCommandPalettePlugin from 'src/main'; 3 | import BetterCommandPaletteModal from 'src/palette'; 4 | import { Match } from 'src/types/types'; 5 | import OrderedSet from 'src/utils/ordered-set'; 6 | 7 | /** 8 | * A class that can be used by the palette modal to abstact away item specific logic between: 9 | * Commands, Files, and Tags 10 | */ 11 | export default abstract class SuggestModalAdapter { 12 | app: App; 13 | 14 | plugin: BetterCommandPalettePlugin; 15 | 16 | palette: BetterCommandPaletteModal; 17 | 18 | prevItems: OrderedSet; 19 | 20 | recentAbovePinned: boolean; 21 | 22 | pinnedItems: Match[]; 23 | 24 | initialized: boolean; 25 | 26 | allItems: Match[]; 27 | 28 | hiddenIds: string[]; 29 | 30 | hiddenIdsSettingsKey: 'hiddenCommands' | 'hiddenFiles' | 'hiddenTags'; 31 | 32 | keymapHandlers: KeymapEventHandler[]; 33 | 34 | abstract titleText: string; 35 | 36 | abstract emptyStateText: string; 37 | 38 | abstract renderSuggestion(match: Match, content: HTMLElement, aux: HTMLElement): void; 39 | abstract onChooseSuggestion(match: Match, event: MouseEvent | KeyboardEvent): void; 40 | 41 | constructor( 42 | app: App, 43 | prevItems: OrderedSet, 44 | plugin: BetterCommandPalettePlugin, 45 | palette: BetterCommandPaletteModal, 46 | ) { 47 | this.app = app; 48 | this.prevItems = prevItems; 49 | this.recentAbovePinned = plugin.settings.recentAbovePinned; 50 | this.plugin = plugin; 51 | this.palette = palette; 52 | this.allItems = []; 53 | this.pinnedItems = []; 54 | this.initialized = false; 55 | this.hiddenIds = []; 56 | this.keymapHandlers = []; 57 | } 58 | 59 | getTitleText(): string { 60 | return this.titleText; 61 | } 62 | 63 | getEmptyStateText(): string { 64 | return this.emptyStateText; 65 | } 66 | 67 | getInstructions(): Instruction[] { 68 | return []; 69 | } 70 | 71 | checkInitialized() { 72 | if (!this.initialized) { 73 | throw new Error('This adapter has not been initialized'); 74 | } 75 | } 76 | 77 | initialize() { 78 | this.initialized = true; 79 | } 80 | 81 | mount() {} 82 | 83 | unmount() { 84 | this.keymapHandlers.forEach((kh) => this.palette.scope.unregister(kh)); 85 | this.keymapHandlers = []; 86 | } 87 | 88 | cleanQuery(query: string) { 89 | return query; 90 | } 91 | 92 | getPinnedItems(): Match[] { 93 | this.checkInitialized(); 94 | return this.pinnedItems; 95 | } 96 | 97 | getItems(): Match[] { 98 | this.checkInitialized(); 99 | return this.allItems; 100 | } 101 | 102 | getPrevItems(): OrderedSet { 103 | return this.prevItems; 104 | } 105 | 106 | getSortedItems(): Match[] { 107 | const allItems = new OrderedSet(this.getItems()); 108 | 109 | // TODO: Clean up this logic. If we ever have more than two things this will not work. 110 | const firstItems = this.recentAbovePinned 111 | ? this.getPrevItems().values() : this.getPinnedItems(); 112 | const secondItems = !this.recentAbovePinned 113 | ? this.getPrevItems().values() : this.getPinnedItems(); 114 | 115 | const itemsToAdd = [secondItems, firstItems]; 116 | 117 | itemsToAdd.forEach((toAdd) => { 118 | toAdd.forEach((item) => { 119 | if (allItems.has(item)) { 120 | // Bring it to the top 121 | allItems.add(item); 122 | } 123 | }); 124 | }); 125 | 126 | return allItems.valuesByLastAdd(); 127 | } 128 | 129 | async toggleHideId(id: string) { 130 | if (this.hiddenIds.includes(id)) { 131 | this.hiddenIds = this.hiddenIds.filter((idToCheck) => id !== idToCheck); 132 | } else { 133 | this.hiddenIds.push(id); 134 | } 135 | 136 | this.plugin.settings[this.hiddenIdsSettingsKey] = this.hiddenIds; 137 | 138 | await this.plugin.saveSettings(); 139 | await this.plugin.loadSettings(); 140 | 141 | this.palette.updateSuggestions(); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, Command, Hotkey, Modifier, normalizePath, parseFrontMatterAliases, 3 | parseFrontMatterTags, Platform, TFile, 4 | } from 'obsidian'; 5 | import { BetterCommandPalettePluginSettings } from 'src/settings'; 6 | import { Match, UnsafeMetadataCacheInterface } from 'src/types/types'; 7 | import PaletteMatch from './palette-match'; 8 | import OrderedSet from './ordered-set'; 9 | import { 10 | BASIC_MODIFIER_ICONS, HYPER_KEY_MODIFIERS_SET, MAC_MODIFIER_ICONS, SPECIAL_KEYS, 11 | } from './constants'; 12 | 13 | /** 14 | * Determines if the modifiers of a hotkey could be a hyper key command. 15 | * @param {Modifier[]} modifiers An array of modifiers 16 | * @returns {boolean} Do the modifiers make up a hyper key command 17 | */ 18 | function isHyperKey (modifiers: Modifier[]): boolean { 19 | if (modifiers.length !== 4) { 20 | return false; 21 | } 22 | 23 | return modifiers.every((m) => HYPER_KEY_MODIFIERS_SET.has(m)); 24 | } 25 | 26 | /** 27 | * A utility that generates the text of a Hotkey for UIs 28 | * @param {Hotkey} hotkey The hotkey to generate text for 29 | * @returns {string} The hotkey text 30 | */ 31 | export function generateHotKeyText ( 32 | hotkey: Hotkey, 33 | settings: BetterCommandPalettePluginSettings, 34 | ): string { 35 | let modifierIcons = Platform.isMacOS ? MAC_MODIFIER_ICONS : BASIC_MODIFIER_ICONS; 36 | 37 | if (settings.hotkeyStyle === 'mac') { 38 | modifierIcons = MAC_MODIFIER_ICONS; 39 | } else if (settings.hotkeyStyle === 'windows') { 40 | modifierIcons = BASIC_MODIFIER_ICONS; 41 | } 42 | 43 | const hotKeyStrings: string[] = []; 44 | 45 | if (settings.hyperKeyOverride && isHyperKey(hotkey.modifiers)) { 46 | hotKeyStrings.push(modifierIcons.Hyper); 47 | } else { 48 | hotkey.modifiers.forEach((mod: Modifier) => { 49 | hotKeyStrings.push(modifierIcons[mod]); 50 | }); 51 | } 52 | 53 | const key = hotkey.key.toUpperCase(); 54 | hotKeyStrings.push(SPECIAL_KEYS[key] || key); 55 | 56 | return hotKeyStrings.join(' '); 57 | } 58 | 59 | export function renderPrevItems (settings: BetterCommandPalettePluginSettings, match: Match, el: HTMLElement, prevItems: OrderedSet) { 60 | if (prevItems.has(match)) { 61 | el.addClass('recent'); 62 | el.createEl('span', { 63 | cls: 'suggestion-note', 64 | text: settings.recentlyUsedText, 65 | }); 66 | } 67 | } 68 | 69 | export function getCommandText (item: Command): string { 70 | return item.name; 71 | } 72 | 73 | export async function getOrCreateFile (app: App, path: string): Promise { 74 | let file = app.metadataCache.getFirstLinkpathDest(path, ''); 75 | 76 | if (!file) { 77 | const normalizedPath = normalizePath(`${path}.md`); 78 | const dirOnlyPath = normalizedPath.split('/').slice(0, -1).join('/'); 79 | 80 | try { 81 | await app.vault.createFolder(dirOnlyPath); 82 | } catch (e) { 83 | // An error just means the folder path already exists 84 | } 85 | 86 | file = await app.vault.create(normalizedPath, ''); 87 | } 88 | 89 | return file; 90 | } 91 | 92 | export function openFileWithEventKeys ( 93 | app: App, 94 | settings: BetterCommandPalettePluginSettings, 95 | file: TFile, 96 | event: MouseEvent | KeyboardEvent, 97 | ) { 98 | // Figure if the file should be opened in a new tab 99 | const openInNewTab = settings.openInNewTabMod === 'Shift' ? event.shiftKey : event.metaKey || event.ctrlKey; 100 | 101 | // Open the file 102 | app.workspace.openLinkText(file.path, file.path, openInNewTab); 103 | } 104 | 105 | export function matchTag (tags: string[], tagQueries: string[]): boolean { 106 | for (let i = 0; i < tagQueries.length; i += 1) { 107 | const tagSearch = tagQueries[i]; 108 | 109 | for (let ii = 0; ii < tags.length; ii += 1) { 110 | const tag = tags[ii]; 111 | 112 | // If they are equal we have matched it 113 | if (tag === tagSearch) return true; 114 | 115 | // Check if the query could be a prefix for a nested tag 116 | const prefixQuery = `${tagSearch}/`; 117 | if (tag.startsWith(prefixQuery)) return true; 118 | } 119 | } 120 | return false; 121 | } 122 | 123 | export function createPaletteMatchesFromFilePath ( 124 | metadataCache: UnsafeMetadataCacheInterface, 125 | filePath: string, 126 | ): PaletteMatch[] { 127 | // Get the cache item for the file so that we can extract its tags 128 | const fileCache = metadataCache.getCache(filePath); 129 | 130 | // Sometimes the cache keeps files that have been deleted 131 | if (!fileCache) return []; 132 | 133 | const cacheTags = (fileCache.tags || []).map((tc) => tc.tag); 134 | const frontmatterTags = parseFrontMatterTags(fileCache.frontmatter) || []; 135 | const tags = cacheTags.concat(frontmatterTags); 136 | 137 | const aliases = parseFrontMatterAliases(fileCache.frontmatter) || []; 138 | 139 | // Make the palette match 140 | return [ 141 | new PaletteMatch( 142 | filePath, 143 | filePath, // Concat our aliases and path to make searching easy 144 | tags, 145 | ), 146 | ...aliases.map((alias: string) => new PaletteMatch( 147 | `${alias}:${filePath}`, 148 | alias, 149 | tags, 150 | )), 151 | ]; 152 | } 153 | -------------------------------------------------------------------------------- /src/web-workers/suggestions-worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | // We need to accesss self in our web worker 3 | 4 | import * as fuzzySearch from 'fuzzysort'; 5 | import { Message } from 'src/types/types'; 6 | import { matchTag } from 'src/utils'; 7 | import { QUERY_OR, QUERY_TAG } from 'src/utils/constants'; 8 | 9 | self.onmessage = (msg: Message) => { 10 | const { query, items } = msg.data; 11 | 12 | const [mainQuery, ...tagQueries] = query.split(QUERY_TAG); 13 | 14 | let results = items; 15 | 16 | if (mainQuery.includes(QUERY_OR)) { 17 | const subqueries = mainQuery.split(QUERY_OR).map((q) => q.trim()); 18 | results = items.filter((item) => subqueries.some((sq) => item.text.includes(sq))); 19 | } else if (mainQuery !== '') { 20 | results = fuzzySearch 21 | .go(mainQuery, items, { key: 'text' }) 22 | .map((r) => r.obj); 23 | } 24 | 25 | if (tagQueries.length) { 26 | results = results.filter((r) => matchTag(r.tags, tagQueries)); 27 | } 28 | 29 | return self.postMessage(results); 30 | }; 31 | -------------------------------------------------------------------------------- /test/e2e/test-utils.ts: -------------------------------------------------------------------------------- 1 | const specialKeyCodes: Record = { 2 | Enter: 13, 3 | Esc: 27, 4 | Backspace: 8, 5 | LeftArrow: 37, 6 | UpArrow: 38, 7 | RightArrow: 39, 8 | DownArrow: 40, 9 | }; 10 | 11 | // Test interfaces 12 | interface PressKeyOptions { 13 | shiftKey?: boolean, 14 | metaKey?: boolean, 15 | selector?: string, 16 | } 17 | 18 | // Test Utils 19 | 20 | export function wait(ms: number = 100): Promise { 21 | // eslint-disable-next-line 22 | return new Promise((resolve) => setTimeout(resolve, ms)); 23 | } 24 | 25 | type TestCommand = (...any: any[]) => any | void; 26 | 27 | export class TestCase { 28 | tests: [string, { (self: TestCase): void }][]; 29 | 30 | name: string; 31 | 32 | constructor(testCaseName: string) { 33 | this.name = testCaseName; 34 | this.tests = []; 35 | } 36 | 37 | async run(): Promise { 38 | for (let i = 0; i < this.tests.length; i += 1) { 39 | const [testName, test] = this.tests[i]; 40 | // eslint-disable-next-line no-console 41 | console.log('\t- Running:', testName); 42 | try { 43 | // eslint-disable-next-line no-await-in-loop 44 | await test(this); 45 | // eslint-disable-next-line no-console 46 | console.log('%c\t\tTest Passed', 'color: green'); 47 | } catch (e) { 48 | // eslint-disable-next-line no-console 49 | console.log('%c\t\tTest Failed', 'color: red'); 50 | // eslint-disable-next-line no-console 51 | console.error(e); 52 | } 53 | // eslint-disable-next-line no-await-in-loop 54 | await wait(); 55 | } 56 | } 57 | 58 | addTest(testName: string, testFunc: () => void) { 59 | this.tests.push([testName, testFunc]); 60 | } 61 | 62 | async runCommandWithRetries( 63 | command: TestCommand, 64 | { retryCount = 10, waitMs = 200 } = {}, 65 | ): Promise { 66 | let result: any; 67 | 68 | for (let i = 1; i <= retryCount; i += 1) { 69 | // eslint-disable-next-line no-await-in-loop 70 | await wait(waitMs); 71 | try { 72 | // eslint-disable-next-line no-await-in-loop 73 | result = await command(); 74 | break; 75 | } catch (e) { 76 | if (i === retryCount) { 77 | throw e; 78 | } 79 | } 80 | } 81 | 82 | return result; 83 | } 84 | 85 | // Internal funcs 86 | private findAllElsInternal = (selector: string, { text = '' } = {}) => (): Element[] => Array.from(document.querySelectorAll(selector)).filter((e) => e.innerHTML.includes(text)); 87 | 88 | private findElInternal = (selector: string, { 89 | text = '', 90 | index = 0, 91 | }: { 92 | text?: string, 93 | index?: number, 94 | } = {}) => (): Element => { 95 | const el = this.findAllElsInternal(selector, { text })()[index]; 96 | 97 | if (!el || !el.innerHTML.includes(text)) { 98 | throw new Error(`Could not find element with class "${selector}" and text "${text}"`); 99 | } 100 | 101 | return el; 102 | }; 103 | 104 | private typeInternal = ( 105 | selector: string, 106 | text: string, 107 | clearCurrentText: boolean = false, 108 | ) => () => { 109 | const el = this.findElInternal(selector)() as HTMLInputElement; 110 | 111 | if (el.isContentEditable) { 112 | const sel = window.getSelection(); 113 | if (sel.getRangeAt && sel.rangeCount) { 114 | const selection = window.getSelection(); 115 | const range = document.createRange(); 116 | range.selectNodeContents(el); 117 | selection.removeAllRanges(); 118 | selection.addRange(range); 119 | 120 | if (clearCurrentText) { 121 | range.deleteContents(); 122 | } 123 | 124 | range.collapse(false); 125 | range.insertNode(document.createTextNode(text)); 126 | range.collapse(false); 127 | } 128 | } else { 129 | if (clearCurrentText) { 130 | el.value = ''; 131 | } 132 | 133 | el.value += text; 134 | } 135 | 136 | el.dispatchEvent(new InputEvent('input')); 137 | el.dispatchEvent(new InputEvent('change')); 138 | }; 139 | 140 | private clickElInternal = (selector: string, { 141 | rightClick = false, 142 | text = '', 143 | index = 0, 144 | } = {}) => () => { 145 | const el = this.findElInternal(selector, { index, text })(); 146 | 147 | if (rightClick) { 148 | el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); 149 | } 150 | el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); 151 | el.dispatchEvent(new MouseEvent('click', { bubbles: true })); 152 | el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); 153 | }; 154 | 155 | private pressKeyInternal = (key: string, { 156 | metaKey = false, 157 | shiftKey = false, 158 | selector = 'body', 159 | }: PressKeyOptions = {}) => () => { 160 | const el = this.findElInternal(selector)(); 161 | el.dispatchEvent(new KeyboardEvent('keydown', { 162 | metaKey, 163 | shiftKey, 164 | key: key.toUpperCase(), 165 | keyCode: specialKeyCodes[key] || key.toUpperCase().charCodeAt(0), 166 | })); 167 | el.dispatchEvent(new KeyboardEvent('keyup', { 168 | metaKey, 169 | shiftKey, 170 | key: key.toUpperCase(), 171 | keyCode: specialKeyCodes[key] || key.toUpperCase().charCodeAt(0), 172 | })); 173 | el.dispatchEvent(new Event('change')); 174 | el.dispatchEvent(new KeyboardEvent('input')); 175 | }; 176 | 177 | private assertEqualInternal = (selector: string, count: number, options = {}) => () => { 178 | const els = this.findAllElsInternal(selector, options)(); 179 | 180 | if (els.length !== count) { 181 | throw new Error(`Found ${els.length} not ${count}`); 182 | } 183 | }; 184 | 185 | private assertExistsInternal = (val: any) => () => { 186 | if (!val) { 187 | throw new Error('Value does not exist'); 188 | } 189 | }; 190 | 191 | // External functions 192 | async findAllEls(selector: string): Promise { 193 | const result = await this.runCommandWithRetries(this.findAllElsInternal(selector)); 194 | return result; 195 | } 196 | 197 | async findEl(selector: string, options: Object = {}): Promise { 198 | return this.runCommandWithRetries(this.findElInternal(selector, options)); 199 | } 200 | 201 | async typeInEl(selector: string, text: string): Promise { 202 | return this.runCommandWithRetries(this.typeInternal(selector, text)); 203 | } 204 | 205 | async clickEl(selector: string, options: Object = {}) { 206 | return this.runCommandWithRetries(this.clickElInternal(selector, options)); 207 | } 208 | 209 | async pressKey(key: string, options: PressKeyOptions = {}): Promise { 210 | return this.runCommandWithRetries(this.pressKeyInternal(key, options)); 211 | } 212 | 213 | async assertElCount(selector: string, count: number, options: Object = {}) { 214 | return this.runCommandWithRetries(this.assertEqualInternal(selector, count, options)); 215 | } 216 | 217 | async assertExists(val: any) { 218 | return this.runCommandWithRetries(this.assertExistsInternal(val)); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /test/e2e/test.ts: -------------------------------------------------------------------------------- 1 | import plugin from 'src/main'; 2 | import { UnsafeAppInterface } from 'src/types/types'; 3 | import tests from './tests'; 4 | 5 | const badWindow = window as any; 6 | const app = badWindow.app as UnsafeAppInterface; 7 | 8 | app.workspace.onLayoutReady(async () => { 9 | // eslint-disable-next-line no-console 10 | console.log('the layout is ready for testing'); 11 | for (let i = 0; i < tests.length; i += 1) { 12 | // eslint-disable-next-line no-console 13 | console.log('Running test suite:', tests[i].name); 14 | // eslint-disable-next-line no-await-in-loop 15 | await tests[i].run(); 16 | } 17 | }); 18 | 19 | export default plugin; 20 | -------------------------------------------------------------------------------- /test/e2e/tests/index.ts: -------------------------------------------------------------------------------- 1 | import testCommandPalette from './test-command-palette'; 2 | import testFilePalette from './test-file-palette'; 3 | import testTagPalette from './test-tag-palette'; 4 | import init from './initialize-test-env'; 5 | import tearDown from './tear-down-test-env'; 6 | 7 | export default [ 8 | init, 9 | testCommandPalette, 10 | testFilePalette, 11 | testTagPalette, 12 | tearDown, 13 | ]; 14 | -------------------------------------------------------------------------------- /test/e2e/tests/initialize-test-env.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TestCase, 3 | } from '../test-utils'; 4 | 5 | const testCase = new TestCase('Initialize Test Environment'); 6 | 7 | testCase.addTest('Create test folder', async () => { 8 | await testCase.pressKey('O', { metaKey: true }); 9 | await testCase.typeInEl('.prompt-input', 'e2e-test-folder/'); 10 | await testCase.pressKey('Enter', { metaKey: true, selector: '.prompt-input' }); 11 | await testCase.findEl('.nav-folder-title-content', { text: 'e2e-test-folder' }); 12 | }); 13 | 14 | testCase.addTest('Create test file', async () => { 15 | await testCase.pressKey('O', { metaKey: true }); 16 | await testCase.typeInEl('.prompt-input', 'e2e-test-folder/e2e-test-file'); 17 | await testCase.pressKey('Enter', { metaKey: true, selector: '.prompt-input' }); 18 | await testCase.clickEl('.nav-folder-title-content', { text: 'e2e-test-folder' }); 19 | await testCase.findEl('.nav-file-title-content', { text: 'e2e-test-file' }); 20 | }); 21 | 22 | testCase.addTest('Add Frontmatter', async () => { 23 | await testCase.clickEl('.cm-content'); 24 | await testCase.typeInEl('.cm-content', '---\nalias: [e2e-alias-1]\ntags: [e2e-frontmatter-tag]\n---'); 25 | }); 26 | 27 | testCase.addTest('Add Links', async () => { 28 | await testCase.clickEl('.cm-content'); 29 | await testCase.typeInEl('.cm-content', '\n[[e2e-test-folder/e2e-unresolved-link]]'); 30 | }); 31 | 32 | testCase.addTest('Add tags', async () => { 33 | await testCase.typeInEl('.cm-content', '\n#e2e-tag '); 34 | await testCase.typeInEl('.cm-content', '\n#e2e-tag/e2e-nested-tag '); 35 | }); 36 | 37 | export default testCase; 38 | -------------------------------------------------------------------------------- /test/e2e/tests/tear-down-test-env.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TestCase, 3 | } from '../test-utils'; 4 | 5 | const testCase = new TestCase('Tear down test environment'); 6 | 7 | testCase.addTest('Delete test folder', async () => { 8 | await testCase.clickEl('.nav-folder-title-content', { text: 'e2e-test-folder', rightClick: true }); 9 | await testCase.clickEl('.menu-item-title', { text: 'Delete' }); 10 | }); 11 | 12 | export default testCase; 13 | -------------------------------------------------------------------------------- /test/e2e/tests/test-command-palette.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TestCase, 3 | } from '../test-utils'; 4 | 5 | const testCase = new TestCase('Test Command Palette'); 6 | 7 | // Test open, search, and close 8 | testCase.addTest('Open, search, and close', async () => { 9 | await testCase.pressKey('P', { metaKey: true }); 10 | await testCase.assertElCount('.suggestion-item', 20); 11 | 12 | await testCase.typeInEl('.prompt-input', 'Toggle pin'); 13 | await testCase.findEl('.suggestion-item', { text: 'Toggle pin' }); 14 | 15 | await testCase.pressKey('Esc'); 16 | await testCase.assertElCount('.better-command-palette', 0); 17 | }); 18 | 19 | testCase.addTest('Close with backspace', async () => { 20 | await testCase.pressKey('P', { metaKey: true }); 21 | await testCase.findEl('.better-command-palette'); 22 | 23 | await testCase.pressKey('Backspace', { selector: '.prompt-input' }); 24 | await testCase.assertElCount('.better-command-palette', 0); 25 | }); 26 | 27 | // Test recent commands 28 | testCase.addTest('Recent command bubbling', async () => { 29 | await testCase.pressKey('P', { metaKey: true }); 30 | await testCase.typeInEl('.prompt-input', 'Toggle pin'); 31 | await testCase.clickEl('.suggestion-item', { text: 'Toggle pin' }); 32 | await testCase.pressKey('Enter'); 33 | 34 | await testCase.pressKey('P', { metaKey: true }); 35 | await testCase.findEl('.recent', { text: 'Toggle pin' }); 36 | await testCase.pressKey('Esc'); 37 | }); 38 | 39 | testCase.addTest('Command fuzzy search', async () => { 40 | await testCase.pressKey('P', { metaKey: true }); 41 | await testCase.typeInEl('.prompt-input', 'Toggle'); 42 | await testCase.assertElCount('.suggestion-item', 19); 43 | await testCase.pressKey('Esc'); 44 | }); 45 | 46 | testCase.addTest('Command hotkeys', async () => { 47 | await testCase.pressKey('P', { metaKey: true }); 48 | await testCase.typeInEl('.prompt-input', 'Toggle pin'); 49 | await testCase.findEl('.suggestion-item', { text: '⌥ ^ ⌘ I' }); 50 | await testCase.pressKey('Esc'); 51 | }); 52 | 53 | testCase.addTest('Empty Hidden Command Header', async () => { 54 | await testCase.pressKey('P', { metaKey: true }); 55 | await testCase.assertElCount('.hidden-items-header', 1, { text: '' }); 56 | await testCase.pressKey('Esc'); 57 | }); 58 | 59 | testCase.addTest('Hide Command', async () => { 60 | await testCase.pressKey('P', { metaKey: true }); 61 | await testCase.typeInEl('.prompt-input', 'Tag Search'); 62 | await testCase.clickEl('.suggestion-flair'); 63 | 64 | await testCase.assertElCount('.hidden-items-header', 1, { text: 'Show hidden items (1)' }); 65 | await testCase.assertElCount('.suggestion-item', 0); 66 | await testCase.pressKey('Esc'); 67 | }); 68 | 69 | testCase.addTest('Show/Hide Hidden Command', async () => { 70 | await testCase.pressKey('P', { metaKey: true }); 71 | await testCase.typeInEl('.prompt-input', 'Tag Search'); 72 | 73 | await testCase.assertElCount('.hidden-items-header', 1, { text: 'Show hidden items (1)' }); 74 | await testCase.assertElCount('.suggestion-item', 0); 75 | 76 | await testCase.clickEl('.hidden-items-header'); 77 | await testCase.findEl('.suggestion-item.hidden', { text: 'Tag Search' }); 78 | await testCase.findEl('.hidden-items-header', { text: 'Hide hidden items (1)' }); 79 | 80 | await testCase.clickEl('.hidden-items-header'); 81 | await testCase.assertElCount('.suggestion-item', 0); 82 | await testCase.pressKey('Esc'); 83 | }); 84 | 85 | testCase.addTest('Unhide Command', async () => { 86 | await testCase.pressKey('P', { metaKey: true }); 87 | await testCase.typeInEl('.prompt-input', 'Tag Search'); 88 | await testCase.clickEl('.hidden-items-header'); 89 | await testCase.clickEl('.suggestion-flair'); 90 | 91 | await testCase.assertElCount('.hidden-items-header', 1, { text: '' }); 92 | await testCase.assertElCount('.suggestion-item', 1); 93 | await testCase.pressKey('Esc'); 94 | }); 95 | 96 | export default testCase; 97 | -------------------------------------------------------------------------------- /test/e2e/tests/test-file-palette.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TestCase, 3 | } from '../test-utils'; 4 | 5 | const testCase = new TestCase('Test File Palette'); 6 | 7 | testCase.addTest('Open, search, and close', async () => { 8 | await testCase.pressKey('O', { metaKey: true }); 9 | await testCase.assertElCount('.suggestion-item', 20); 10 | 11 | await testCase.typeInEl('.prompt-input', 'e2e-test-file'); 12 | await testCase.findEl('.suggestion-item', { text: 'e2e-test-file' }); 13 | 14 | await testCase.pressKey('Esc'); 15 | await testCase.assertElCount('.better-command-palette', 0); 16 | }); 17 | 18 | testCase.addTest('Find unresolved links', async () => { 19 | await testCase.pressKey('O', { metaKey: true }); 20 | await testCase.assertElCount('.suggestion-item', 20); 21 | 22 | await testCase.typeInEl('.prompt-input', 'e2e-unresolved-link'); 23 | await testCase.findEl('.suggestion-item', { text: 'e2e-unresolved-link' }); 24 | 25 | await testCase.pressKey('Esc'); 26 | await testCase.assertElCount('.better-command-palette', 0); 27 | }); 28 | 29 | testCase.addTest('Create file from unresolved link', async () => { 30 | await testCase.pressKey('O', { metaKey: true }); 31 | await testCase.assertElCount('.suggestion-item', 20); 32 | 33 | await testCase.typeInEl('.prompt-input', 'e2e-unresolved-link'); 34 | await testCase.findEl('.suggestion-item', { text: 'e2e-unresolved-link' }); 35 | 36 | await testCase.pressKey('Enter'); 37 | await testCase.assertElCount('.better-command-palette', 0); 38 | 39 | await testCase.findEl('.view-header-title', { text: 'e2e-unresolved-link' }); 40 | }); 41 | 42 | testCase.addTest('Add alias to file', async () => { 43 | await testCase.clickEl('.cm-content'); 44 | await testCase.typeInEl('.cm-content', '---\naliases: [e2e-alias-2]\n---'); 45 | }); 46 | 47 | testCase.addTest('Recent files bubble up', async () => { 48 | await testCase.pressKey('O', { metaKey: true }); 49 | 50 | await testCase.assertElCount('.suggestion-item', 20); 51 | await testCase.findEl('.suggestion-item:first-child', { text: 'e2e-unresolved-link' }); 52 | await testCase.pressKey('Esc'); 53 | }); 54 | 55 | testCase.addTest('Open file in new pane', async () => { 56 | await testCase.pressKey('O', { metaKey: true }); 57 | await testCase.assertElCount('.suggestion-item', 20); 58 | 59 | await testCase.typeInEl('.prompt-input', 'e2e-test-file'); 60 | await testCase.pressKey('Enter', { shiftKey: true, selector: '.prompt-input' }); 61 | 62 | await testCase.assertElCount('.view-header-title', 2); 63 | await testCase.findEl('.view-header-title', { text: 'e2e-unresolved-link' }); 64 | await testCase.findEl('.view-header-title', { text: 'e2e-test-file' }); 65 | 66 | await testCase.clickEl('.mod-close-leaf'); 67 | await testCase.clickEl('.mod-close-leaf'); 68 | }); 69 | 70 | testCase.addTest('Create arbitrary file', async () => { 71 | await testCase.pressKey('O', { metaKey: true }); 72 | await testCase.assertElCount('.suggestion-item', 20); 73 | 74 | await testCase.typeInEl('.prompt-input', 'e2e-test-folder/e2e-test-file-2'); 75 | await testCase.pressKey('Enter', { metaKey: true, selector: '.prompt-input' }); 76 | 77 | await testCase.assertElCount('.view-header-title', 1); 78 | await testCase.findEl('.view-header-title', { text: 'e2e-test-file-2' }); 79 | }); 80 | 81 | testCase.addTest('Create arbitrary file in new pane', async () => { 82 | await testCase.pressKey('O', { metaKey: true }); 83 | await testCase.assertElCount('.suggestion-item', 20); 84 | 85 | await testCase.typeInEl('.prompt-input', 'e2e-test-folder/e2e-test-file-3'); 86 | await testCase.pressKey('Enter', { shiftKey: true, metaKey: true, selector: '.prompt-input' }); 87 | 88 | await testCase.assertElCount('.view-header-title', 2); 89 | await testCase.findEl('.view-header-title', { text: 'e2e-test-file-2' }); 90 | await testCase.findEl('.view-header-title', { text: 'e2e-test-file-3' }); 91 | 92 | await testCase.clickEl('.mod-close-leaf'); 93 | await testCase.clickEl('.mod-close-leaf'); 94 | }); 95 | 96 | testCase.addTest('Search normal tag', async () => { 97 | await testCase.pressKey('O', { metaKey: true }); 98 | await testCase.assertElCount('.suggestion-item', 20); 99 | 100 | await testCase.typeInEl('.prompt-input', '@#e2e-tag'); 101 | await testCase.assertElCount('.suggestion-item', 2); 102 | await testCase.findEl('.suggestion-item', { text: 'e2e-test-file' }); 103 | 104 | await testCase.pressKey('Enter', { selector: '.prompt-input' }); 105 | }); 106 | 107 | testCase.addTest('Search nested tag', async () => { 108 | await testCase.pressKey('O', { metaKey: true }); 109 | await testCase.assertElCount('.suggestion-item', 20); 110 | 111 | await testCase.typeInEl('.prompt-input', '@#e2e-tag/e2e-nested-tag'); 112 | await testCase.assertElCount('.suggestion-item', 2); 113 | await testCase.findEl('.suggestion-item', { text: 'e2e-test-file' }); 114 | 115 | await testCase.pressKey('Enter', { selector: '.prompt-input' }); 116 | }); 117 | 118 | testCase.addTest('Search frontmatter tag', async () => { 119 | await testCase.pressKey('O', { metaKey: true }); 120 | await testCase.assertElCount('.suggestion-item', 20); 121 | 122 | await testCase.typeInEl('.prompt-input', '@#e2e-frontmatter-tag'); 123 | await testCase.assertElCount('.suggestion-item', 2); 124 | await testCase.findEl('.suggestion-item', { text: 'e2e-test-file' }); 125 | 126 | await testCase.pressKey('Enter', { selector: '.prompt-input' }); 127 | }); 128 | 129 | testCase.addTest('Search alias (singular)', async () => { 130 | await testCase.pressKey('O', { metaKey: true }); 131 | await testCase.assertElCount('.suggestion-item', 20); 132 | 133 | await testCase.typeInEl('.prompt-input', 'e2e-alias-1'); 134 | await testCase.assertElCount('.suggestion-item', 1); 135 | await testCase.findEl('.suggestion-item', { text: 'e2e-alias-1' }); 136 | await testCase.findEl('.suggestion-item', { text: 'e2e-test-file' }); 137 | 138 | await testCase.pressKey('Esc'); 139 | }); 140 | 141 | testCase.addTest('Search aliases (plural)', async () => { 142 | await testCase.pressKey('O', { metaKey: true }); 143 | await testCase.assertElCount('.suggestion-item', 20); 144 | 145 | await testCase.typeInEl('.prompt-input', 'e2e-alias-2'); 146 | await testCase.assertElCount('.suggestion-item', 1); 147 | await testCase.findEl('.suggestion-item', { text: 'e2e-alias-2' }); 148 | await testCase.findEl('.suggestion-item', { text: 'e2e-unresolved-link' }); 149 | 150 | await testCase.pressKey('Esc'); 151 | }); 152 | 153 | testCase.addTest('Switch new pane and file creation modifiers', async () => { 154 | await testCase.pressKey(',', { metaKey: true }); 155 | await testCase.clickEl('.vertical-tab-nav-item', { text: 'Better Command Palette' }); 156 | 157 | try { 158 | await testCase.findEl('.setting-item:nth-child(5) .checkbox-container.is-enabled'); 159 | } catch (e) { 160 | await testCase.clickEl('.setting-item:nth-child(5) .checkbox-container'); 161 | } 162 | 163 | await testCase.pressKey('Esc'); 164 | }); 165 | 166 | testCase.addTest('Open file in new pane', async () => { 167 | await testCase.pressKey('O', { metaKey: true }); 168 | 169 | await testCase.typeInEl('.prompt-input', 'e2e-test-file'); 170 | await testCase.pressKey('Enter', { metaKey: true, selector: '.prompt-input' }); 171 | 172 | await testCase.assertElCount('.view-header-title', 2); 173 | 174 | await testCase.clickEl('.mod-close-leaf'); 175 | await testCase.clickEl('.mod-close-leaf'); 176 | }); 177 | 178 | testCase.addTest('Create arbitrary file', async () => { 179 | await testCase.pressKey('O', { metaKey: true }); 180 | 181 | await testCase.typeInEl('.prompt-input', 'e2e-test-folder/e2e-test-file-4'); 182 | await testCase.pressKey('Enter', { shiftKey: true, selector: '.prompt-input' }); 183 | 184 | await testCase.assertElCount('.view-header-title', 1); 185 | await testCase.findEl('.view-header-title', { text: 'e2e-test-file-4' }); 186 | }); 187 | 188 | testCase.addTest('Create arbitrary file in new pane', async () => { 189 | await testCase.pressKey('O', { metaKey: true }); 190 | 191 | await testCase.typeInEl('.prompt-input', 'e2e-test-folder/e2e-test-file-5'); 192 | await testCase.pressKey('Enter', { shiftKey: true, metaKey: true, selector: '.prompt-input' }); 193 | 194 | await testCase.assertElCount('.view-header-title', 2); 195 | await testCase.findEl('.view-header-title', { text: 'e2e-test-file-4' }); 196 | await testCase.findEl('.view-header-title', { text: 'e2e-test-file-5' }); 197 | 198 | await testCase.clickEl('.mod-close-leaf'); 199 | await testCase.clickEl('.mod-close-leaf'); 200 | }); 201 | 202 | testCase.addTest('Switch new pane and file creation modifiers back to default', async () => { 203 | await testCase.pressKey(',', { metaKey: true }); 204 | await testCase.clickEl('.vertical-tab-nav-item', { text: 'Better Command Palette' }); 205 | 206 | try { 207 | await testCase.findEl('.setting-item:nth-child(5) .checkbox-container:not(.is-enabled)'); 208 | } catch (e) { 209 | await testCase.clickEl('.setting-item:nth-child(5) .checkbox-container'); 210 | } 211 | 212 | await testCase.pressKey('Esc'); 213 | }); 214 | 215 | testCase.addTest('Empty Hidden File Header', async () => { 216 | await testCase.pressKey('O', { metaKey: true }); 217 | await testCase.assertElCount('.hidden-items-header', 1, { text: '' }); 218 | await testCase.pressKey('Esc'); 219 | }); 220 | 221 | testCase.addTest('Hide File', async () => { 222 | await testCase.pressKey('O', { metaKey: true }); 223 | await testCase.typeInEl('.prompt-input', 'e2e-unresolved-link'); 224 | await testCase.clickEl('.suggestion-flair'); 225 | 226 | await testCase.assertElCount('.hidden-items-header', 1, { text: 'Show hidden items (1)' }); 227 | await testCase.assertElCount('.suggestion-item', 0); 228 | await testCase.pressKey('Esc'); 229 | }); 230 | 231 | testCase.addTest('Show/Hide Hidden File', async () => { 232 | await testCase.pressKey('O', { metaKey: true }); 233 | await testCase.typeInEl('.prompt-input', 'e2e-unresolved-link'); 234 | 235 | await testCase.assertElCount('.hidden-items-header', 1, { text: 'Show hidden items (1)' }); 236 | await testCase.assertElCount('.suggestion-item', 0); 237 | 238 | await testCase.clickEl('.hidden-items-header'); 239 | await testCase.findEl('.suggestion-item.hidden', { text: 'e2e-unresolved-link' }); 240 | await testCase.findEl('.hidden-items-header', { text: 'Hide hidden items (1)' }); 241 | 242 | await testCase.clickEl('.hidden-items-header'); 243 | await testCase.assertElCount('.suggestion-item', 0); 244 | await testCase.pressKey('Esc'); 245 | }); 246 | 247 | testCase.addTest('Unhide File', async () => { 248 | await testCase.pressKey('O', { metaKey: true }); 249 | await testCase.typeInEl('.prompt-input', 'e2e-unresolved-link'); 250 | await testCase.clickEl('.hidden-items-header'); 251 | await testCase.clickEl('.suggestion-flair'); 252 | 253 | await testCase.assertElCount('.hidden-items-header', 1, { text: '' }); 254 | await testCase.assertElCount('.suggestion-item', 1); 255 | await testCase.pressKey('Esc'); 256 | }); 257 | 258 | export default testCase; 259 | -------------------------------------------------------------------------------- /test/e2e/tests/test-tag-palette.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TestCase, 3 | } from '../test-utils'; 4 | 5 | const testCase = new TestCase('Test Tag Palette'); 6 | 7 | testCase.addTest('Open, search, and close', async () => { 8 | await testCase.pressKey('T', { metaKey: true }); 9 | await testCase.assertElCount('.suggestion-item', 20); 10 | 11 | await testCase.typeInEl('.prompt-input', 'e2e-tag'); 12 | await testCase.assertElCount('.suggestion-item', 3); 13 | 14 | await testCase.pressKey('Esc'); 15 | await testCase.assertElCount('.better-command-palette', 0); 16 | }); 17 | 18 | testCase.addTest('Finds frontmatter tags', async () => { 19 | await testCase.pressKey('T', { metaKey: true }); 20 | 21 | await testCase.typeInEl('.prompt-input', 'e2e-frontmatter-tag'); 22 | await testCase.assertElCount('.suggestion-item', 1); 23 | 24 | await testCase.pressKey('Esc'); 25 | }); 26 | 27 | testCase.addTest('Finds nested tags', async () => { 28 | await testCase.pressKey('T', { metaKey: true }); 29 | 30 | await testCase.typeInEl('.prompt-input', 'e2e-nested-tag'); 31 | await testCase.assertElCount('.suggestion-item', 1); 32 | 33 | await testCase.pressKey('Esc'); 34 | }); 35 | 36 | testCase.addTest('Tag selection opens file search', async () => { 37 | await testCase.pressKey('T', { metaKey: true }); 38 | await testCase.typeInEl('.prompt-input', 'e2e-tag'); 39 | 40 | await testCase.pressKey('Enter'); 41 | await testCase.assertElCount('.better-command-palette', 1); 42 | await testCase.findEl('.better-command-palette-title', { text: 'Files' }); 43 | await testCase.assertElCount('.suggestion-item', 2); 44 | await testCase.pressKey('Esc'); 45 | }); 46 | 47 | testCase.addTest('Empty Hidden Tag Header', async () => { 48 | await testCase.pressKey('T', { metaKey: true }); 49 | await testCase.assertElCount('.hidden-items-header', 1, { text: '' }); 50 | await testCase.pressKey('Esc'); 51 | }); 52 | 53 | testCase.addTest('Hide Tag', async () => { 54 | await testCase.pressKey('T', { metaKey: true }); 55 | await testCase.typeInEl('.prompt-input', 'e2e-tag/e2e-nested-tag'); 56 | await testCase.clickEl('.suggestion-flair'); 57 | 58 | await testCase.assertElCount('.hidden-items-header', 1, { text: 'Show hidden items (1)' }); 59 | await testCase.assertElCount('.suggestion-item', 0); 60 | await testCase.pressKey('Esc'); 61 | }); 62 | 63 | testCase.addTest('Show/Hide Hidden Tag', async () => { 64 | await testCase.pressKey('T', { metaKey: true }); 65 | await testCase.typeInEl('.prompt-input', 'e2e-tag/e2e-nested-tag'); 66 | 67 | await testCase.assertElCount('.hidden-items-header', 1, { text: 'Show hidden items (1)' }); 68 | await testCase.assertElCount('.suggestion-item', 0); 69 | 70 | await testCase.clickEl('.hidden-items-header'); 71 | await testCase.findEl('.suggestion-item.hidden', { text: 'e2e-tag/e2e-nested-tag' }); 72 | await testCase.findEl('.hidden-items-header', { text: 'Hide hidden items (1)' }); 73 | 74 | await testCase.clickEl('.hidden-items-header'); 75 | await testCase.assertElCount('.suggestion-item', 0); 76 | await testCase.pressKey('Esc'); 77 | }); 78 | 79 | testCase.addTest('Unhide Tag', async () => { 80 | await testCase.pressKey('T', { metaKey: true }); 81 | await testCase.typeInEl('.prompt-input', 'e2e-tag/e2e-nested-tag'); 82 | await testCase.clickEl('.hidden-items-header'); 83 | await testCase.clickEl('.suggestion-flair'); 84 | 85 | await testCase.assertElCount('.hidden-items-header', 1, { text: '' }); 86 | await testCase.assertElCount('.suggestion-item', 1); 87 | await testCase.pressKey('Esc'); 88 | }); 89 | 90 | export default testCase; 91 | -------------------------------------------------------------------------------- /tools/generate-test-files.js: -------------------------------------------------------------------------------- 1 | import { writeFile, mkdirSync } from 'fs'; 2 | import yargs from 'yargs'; // eslint-disable-line import/no-extraneous-dependencies 3 | import { hideBin } from 'yargs/helpers'; // eslint-disable-line import/no-extraneous-dependencies 4 | 5 | const { argv } = yargs(hideBin(process.argv)) 6 | .option('count', { 7 | alias: 'c', 8 | describe: 'The number of files to create', 9 | }) 10 | .option('path', { 11 | alias: 'p', 12 | describe: 'The path to create the files at', 13 | default: './test-vault/', 14 | }) 15 | .option('ext', { 16 | alias: 'e', 17 | describe: 'The extension of the files to be created', 18 | default: 'md', 19 | }) 20 | .option('name', { 21 | alias: 'n', 22 | describe: 'The base name of the files to be created', 23 | default: 'test-file', 24 | }) 25 | .option('text', { 26 | alias: 't', 27 | describe: 'The text to add to the files. Use {c} to have the current count inserted into the text.', 28 | default: '', 29 | }) 30 | .demandOption(['count']); 31 | 32 | mkdirSync(argv.path, { recursive: true }); 33 | 34 | for (let i = 0; i < argv.count; i += 1) { 35 | const fileName = `${argv.path}${argv.name}${i}.${argv.ext}`; 36 | writeFile(fileName, argv.text.replaceAll('{c}', i), () => {}); 37 | } 38 | 39 | // eslint-disable-next-line no-console 40 | console.log(`Created ${argv.count} at ${argv.path}`); 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "baseUrl": ".", 5 | "sourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts", 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.12.0", 3 | "0.2.0": "0.12.0", 4 | "0.2.1": "0.12.0", 5 | "0.3.0": "0.12.0", 6 | "0.4.0": "0.12.0", 7 | "0.5.0": "0.12.0", 8 | "0.5.1": "0.12.0", 9 | "0.5.2": "0.12.0", 10 | "0.6.0": "0.12.0", 11 | "0.6.1": "0.12.0", 12 | "0.6.2": "0.12.0", 13 | "0.7.0": "0.12.0", 14 | "0.7.1": "0.12.0", 15 | "0.8.0": "0.12.0", 16 | "0.9.0": "0.12.0", 17 | "0.9.1": "0.12.0", 18 | "0.10.0": "0.12.0", 19 | "0.11.0": "0.12.0", 20 | "0.11.1": "0.12.0", 21 | "0.11.2": "0.12.0", 22 | "0.11.3": "0.12.0", 23 | "0.11.4": "0.12.0", 24 | "0.11.5": "0.12.0", 25 | "0.12.0": "0.12.0", 26 | "0.12.1": "0.12.0", 27 | "0.12.2": "0.12.0", 28 | "0.13.0": "0.12.0", 29 | "0.13.1": "0.12.0", 30 | "0.14.0": "0.12.0", 31 | "0.15.0": "0.12.0", 32 | "0.16.0": "0.12.0", 33 | "0.17.0": "0.12.0", 34 | "0.17.1": "0.12.0" 35 | } --------------------------------------------------------------------------------