## 概要 [[📰2025年43週 Weekly Report#Show another command paletteコマンド強化]] の機能を[[🦉Another Quick Switcher]]に追加する。 ## 仕様 ### 機能名 Command palette ### コマンド履歴マッピング 入力したクエリに対して、最後に実行したコマンドが紐づいているマッピング。 `例` ```json { "queryUsedMap": { "relo": "app:reload", "reloa": "app:reload", "silins": "obsidian-silhouette:insert-tasks", "del": "carnelian:carnelian_delete-active-file", "rel": "app:reload", "note": "carnelian:carnelian_insert-note-card" } } ``` ### 最終利用マッピング コマンドごとの最終実行タイムスタンプ。 `例` ```json { "lastUsedMap": { "app:reload": 1762662619, "obsidian-silhouette:insert-tasks": 1762644952, "carnelian:carnelian_delete-active-file": 1762662086, "carnelian:carnelian_insert-note-card": 1762663084, "carnelian:carnelian_create-troubleshooting-notes": 1762653909, "publish:view-changes": 1762657378, "carnelian:carnelian_summarize-description": 1762663021, "various-complements:reload-current-vault": 1762600041 } } ``` ### 設定値 - 最終利用マッピングの保存最大日数: (default: 10日) - 履歴マッピングの相対パス (default: プラグインディレクトリ配下のcommand-history.json) 以下は一旦省略。 - ファジーマッチングスコアのしきい値: (default: 0.25) ### ソート順の優先度 | No | 条件 | | --- | ------------------------------- | | 1 | 入力クエリに一致する[[#コマンド履歴マッピング]]のコマンド | | 2 | [[#最終利用マッピング]]にコマンドが存在する | | 3 | 入力クエリにコマンドが部分一致している | | 4 | [[#最終利用マッピング]]のタイムスタンプが新しい(大きい) | | 5 | ファジーマッチングスコアが大きい | ### [[🦉Carnelian]]の参考コード ```ts import type { Command } from "obsidian"; import { getAvailableCommands } from "src/lib/helpers/commands"; import { AbstractSuggestionModal } from "src/lib/helpers/components/AbstractSuggestionModal"; import { now } from "src/lib/helpers/datetimes"; import { loadJson, saveJson } from "src/lib/helpers/io"; import { copyToClipboard, notify } from "src/lib/helpers/ui"; import type { UApp } from "src/lib/types"; import { maxReducer } from "src/lib/utils/collections"; import { omitBy, sorter } from "src/lib/utils/collections"; import { isPresent } from "src/lib/utils/guard"; import { isMod } from "src/lib/utils/keys"; import { microFuzzy } from "src/lib/utils/strings"; // XXX: 少し気持ち悪い declare let app: UApp; type HistoricalCommand = Command & { lastUsed?: number; /* 最優先で上位に表示するか */ topPriority?: boolean; }; type CommandId = string; interface CommandHistoryMap { lastUsedMap: { [id: CommandId]: number }; queryUsedMap: { [query: string]: CommandId }; } /** * もう1つのコマンドパレットを表示 */ export async function showAnotherCommandPalette(args: { commandHistoryPath: string; }): Promise<void> { const unixNow = now("unixtime"); const { lastUsedMap: _lastUsedMap = {}, queryUsedMap: _queryUsedMap = {} } = (await loadJson<CommandHistoryMap>(args.commandHistoryPath)) ?? {}; // 最終コマンド利用日から10日以上経っているものは履歴から除外 const lastUsedMap = omitBy( _lastUsedMap, (_, lastUpdated: number) => unixNow - lastUpdated > 10 * 24 * 60 * 60, ); const queryUsedMap = omitBy( _queryUsedMap, (_, commandId) => lastUsedMap[commandId] == null, ); const commands: HistoricalCommand[] = getAvailableCommands().map((x) => ({ ...x, lastUsed: lastUsedMap[x.id], })); new CommandQuickSwitcher(app, commands, queryUsedMap, async (item, query) => { lastUsedMap[item.id] = now("unixtime"); queryUsedMap[query] = item.id; await saveJson( args.commandHistoryPath, { lastUsedMap, queryUsedMap }, { overwrite: true, }, ); }).open(); } class CommandQuickSwitcher extends AbstractSuggestionModal<HistoricalCommand> { query = ""; constructor( app: UApp, private commands: HistoricalCommand[], private queryUsedMap: CommandHistoryMap["queryUsedMap"], private handleChooseItem: (item: HistoricalCommand, query: string) => any, ) { super(app); this.registerKeyMap(["Mod"], "enter", async (evt) => { const item = this.getSelectedItem(); if (!item) { return; } if (isMod(evt)) { await copyToClipboard(item.id); notify(`Copied command ID to clipboard: ${item.id}`); this.close(); } }); } toKey(item: HistoricalCommand): string { return item.id; } getSuggestions(query: string): HistoricalCommand[] { this.query = query; return this.commands .map((command) => ({ command: this.queryUsedMap[query] === command.id ? { ...command, topPriority: true } : command, results: query .split(" ") .filter(isPresent) .map((q) => microFuzzy(command.name.toLowerCase(), q.toLowerCase())), })) .filter(({ results }) => results.every((r) => r.type !== "none")) .map(({ command, results }) => ({ command, result: results.reduce(maxReducer((x) => x.score)), })) .filter(({ result }) => result.type !== "none") .filter( ({ result }) => !query || result.type !== "fuzzy" || result.score > 0.25, ) .toSorted(sorter(({ result }) => result.score, "desc")) .toSorted(sorter(({ command }) => command.lastUsed ?? 0, "desc")) .toSorted( sorter( ({ result }) => result.type === "includes" || result.type === "starts-with", "desc", ), ) .toSorted(sorter(({ command }) => command.lastUsed != null, "desc")) .toSorted(sorter(({ command }) => command.topPriority ?? false, "desc")) .map(({ command }) => command); } renderSuggestion(item: HistoricalCommand, el: HTMLElement): void { const recordEl = createDiv({ cls: [ "carnelian-command-palette-item", item.lastUsed ? "carnelian-command-palette-item-lastused" : "", item.topPriority ? "carnelian-command-palette-item-top-priority" : "", ], }); recordEl.appendChild( createDiv({ text: item.name, }), ); recordEl.appendChild( createDiv({ text: app.hotkeyManager.printHotkeyForCommand(item.id), cls: ["carnelian-command-palette-item__key"], }), ); el.appendChild(recordEl); } onChooseSuggestion(item: HistoricalCommand, evt: MouseEvent | KeyboardEvent) { item.callback?.() ?? item.checkCallback?.(false); this.handleChooseItem(item, this.query); } } ```