From 660e3082be21f30a648b7cd4d99918167d0dd944 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Tue, 30 Jun 2026 17:34:17 -0600 Subject: [PATCH] Add saved entry command palette commands --- README.md | 1 + src/components/QuickSwitcherSettings.tsx | 87 +++++++++++++++ src/index.ts | 2 + src/quickSwitcher.tsx | 129 +++++++++++++++++++++++ src/types/quickSwitcher.ts | 5 + src/utils/quickSwitcher.ts | 45 ++++++++ tests/quickSwitcher.test.ts | 52 +++++++++ 7 files changed, 321 insertions(+) diff --git a/README.md b/README.md index 20d98b4..7b420be 100644 --- a/README.md +++ b/README.md @@ -13,4 +13,5 @@ - Save frequently used pages and blocks as Quick Switcher entries. - Open saved pages and blocks from a focused searchable dialog. - Assign direct keyboard shortcuts for one-step switching. +- Optionally expose saved entries as command palette commands with a custom prefix. - Reorder and remove saved entries from extension settings. diff --git a/src/components/QuickSwitcherSettings.tsx b/src/components/QuickSwitcherSettings.tsx index fa517a1..14217e3 100644 --- a/src/components/QuickSwitcherSettings.tsx +++ b/src/components/QuickSwitcherSettings.tsx @@ -15,6 +15,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import type { QuickSwitcherBookmark, + QuickSwitcherCommandPaletteSettings, QuickSwitcherQuerySource, } from "~/types/quickSwitcher"; import { @@ -28,6 +29,7 @@ import { getBookmarkTargetUid, keyboardEventToShortcut, moveBookmarkByOffset, + normalizeCommandPaletteSettings, normalizeShortcut, parsePageUidFromUrl, shortcutHasModifier, @@ -35,9 +37,13 @@ import { type QuickSwitcherSettingsDependencies = { initialBookmarks: QuickSwitcherBookmark[]; + initialCommandPaletteSettings: QuickSwitcherCommandPaletteSettings; initialQuerySource: QuickSwitcherQuerySource; isMac: boolean; onBookmarksChange: (bookmarks: QuickSwitcherBookmark[]) => void; + onCommandPaletteSettingsChange: ( + settings: QuickSwitcherCommandPaletteSettings, + ) => void; onQuerySourceChange: (querySource: QuickSwitcherQuerySource) => void; }; @@ -99,14 +105,26 @@ const getBlockByUid = ({ export const createQuickSwitcherSettingsComponent = ({ initialBookmarks, + initialCommandPaletteSettings, initialQuerySource, isMac, onBookmarksChange, + onCommandPaletteSettingsChange, onQuerySourceChange, }: QuickSwitcherSettingsDependencies): React.FC => { const QuickSwitcherSettings = (): React.ReactElement => { const [bookmarks, setBookmarks] = useState(initialBookmarks); + const [savedCommandPaletteSettings, setSavedCommandPaletteSettings] = + useState( + initialCommandPaletteSettings, + ); + const [commandPaletteEnabled, setCommandPaletteEnabled] = useState( + initialCommandPaletteSettings.enabled, + ); + const [commandPalettePrefix, setCommandPalettePrefix] = useState( + initialCommandPaletteSettings.prefix, + ); const [savedQuerySource, setSavedQuerySource] = useState(initialQuerySource); const [querySourceEnabled, setQuerySourceEnabled] = useState( @@ -134,6 +152,9 @@ export const createQuickSwitcherSettingsComponent = ({ const isQuerySourceDirty = querySourceEnabled !== savedQuerySource.enabled || querySourceRef.trim() !== savedQuerySource.queryRef; + const isCommandPaletteDirty = + commandPaletteEnabled !== savedCommandPaletteSettings.enabled || + commandPalettePrefix !== savedCommandPaletteSettings.prefix; const setAndPersistBookmarks = ({ nextBookmarks, @@ -159,10 +180,16 @@ export const createQuickSwitcherSettingsComponent = ({ setQuerySourceRef(savedQuerySource.queryRef); }; + const resetCommandPaletteSettings = (): void => { + setCommandPaletteEnabled(savedCommandPaletteSettings.enabled); + setCommandPalettePrefix(savedCommandPaletteSettings.prefix); + }; + const closeManageDialog = (): void => { setIsManageDialogOpen(false); clearForm(); clearBulkPages(); + resetCommandPaletteSettings(); resetQuerySource(); }; @@ -451,6 +478,30 @@ export const createQuickSwitcherSettingsComponent = ({ }); }; + const saveCommandPaletteSettings = (): void => { + if (commandPaletteEnabled && !commandPalettePrefix.trim()) { + showToast({ + content: "Add a command palette prefix first", + intent: "warning", + }); + return; + } + + const nextCommandPaletteSettings = normalizeCommandPaletteSettings({ + settings: { + enabled: commandPaletteEnabled, + prefix: commandPalettePrefix, + }, + }); + setSavedCommandPaletteSettings(nextCommandPaletteSettings); + setCommandPalettePrefix(nextCommandPaletteSettings.prefix); + onCommandPaletteSettingsChange(nextCommandPaletteSettings); + showToast({ + content: "Command palette settings saved", + intent: "success", + }); + }; + return (
@@ -606,6 +657,42 @@ export const createQuickSwitcherSettingsComponent = ({
+
+ ): void => + setCommandPaletteEnabled(event.target.checked) + } + /> + + , + ): void => setCommandPalettePrefix(event.target.value)} + placeholder="Q S - " + value={commandPalettePrefix} + /> + +
+
+
+
{bookmarks.length ? ( bookmarks.map((bookmark, index) => ( diff --git a/src/index.ts b/src/index.ts index c1c2ff6..59bf99e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export default runExtension(async ({ extensionAPI }) => { const quickSwitcher = initializeQuickSwitcher({ extensionAPI }); const settingsComponent = createQuickSwitcherSettingsComponent({ initialBookmarks: quickSwitcher.getBookmarks(), + initialCommandPaletteSettings: quickSwitcher.getCommandPaletteSettings(), initialQuerySource: quickSwitcher.getQuerySource(), isMac: /mac|iphone|ipad|ipod/i.test( typeof navigator === "undefined" @@ -14,6 +15,7 @@ export default runExtension(async ({ extensionAPI }) => { : `${navigator.platform} ${navigator.userAgent}`, ), onBookmarksChange: quickSwitcher.setBookmarks, + onCommandPaletteSettingsChange: quickSwitcher.setCommandPaletteSettings, onQuerySourceChange: quickSwitcher.setQuerySource, }); diff --git a/src/quickSwitcher.tsx b/src/quickSwitcher.tsx index b0dc84e..cb2c80c 100644 --- a/src/quickSwitcher.tsx +++ b/src/quickSwitcher.tsx @@ -8,17 +8,22 @@ import type { Result as QueryBuilderResult } from "roamjs-components/types/query import QuickSwitcherDialog from "~/components/QuickSwitcherDialog"; import type { QuickSwitcherBookmark, + QuickSwitcherCommandPaletteSettings, QuickSwitcherQuerySource, } from "~/types/quickSwitcher"; import { buildRoamPageUrl, extractBlockRefUid, extractQueryBlockLabel, + getBookmarkTargetLabel, getBookmarkTargetType, getBookmarkTargetUid, + getCommandPaletteCommandLabel, + normalizeCommandPaletteSettings, keyboardEventToShortcut, normalizeQuerySource, normalizeShortcut, + parseStoredCommandPaletteSettings, parsePageUidFromUrl, parseStoredQuerySource, parseStoredBookmarks, @@ -27,6 +32,7 @@ import { const BOOKMARKS_SETTING_KEY = "quickSwitcherBookmarks"; const QUERY_SOURCE_SETTING_KEY = "quickSwitcherQuerySource"; +const COMMAND_PALETTE_SETTING_KEY = "quickSwitcherCommandPalette"; const OPEN_QUICK_SWITCHER_COMMAND = "Quick Switcher: Open"; type ExtensionApi = OnloadArgs["extensionAPI"]; @@ -35,9 +41,13 @@ type ToastIntent = "none" | "primary" | "success" | "warning" | "danger"; export type QuickSwitcherController = { getBookmarks: () => QuickSwitcherBookmark[]; + getCommandPaletteSettings: () => QuickSwitcherCommandPaletteSettings; getQuerySource: () => QuickSwitcherQuerySource; open: () => void; setBookmarks: (bookmarks: QuickSwitcherBookmark[]) => void; + setCommandPaletteSettings: ( + settings: QuickSwitcherCommandPaletteSettings, + ) => void; setQuerySource: (querySource: QuickSwitcherQuerySource) => void; unload: () => void; }; @@ -184,6 +194,32 @@ const getBookmarkKey = ({ bookmark }: { bookmark: QuickSwitcherBookmark }) => { return targetUid ? `${targetType}:${targetUid}` : `url:${bookmark.url}`; }; +const getUniqueCommandLabel = ({ + bookmark, + settings, + usedLabels, +}: { + bookmark: QuickSwitcherBookmark; + settings: QuickSwitcherCommandPaletteSettings; + usedLabels: Set; +}): string => { + const baseLabel = getCommandPaletteCommandLabel({ bookmark, settings }); + if (!usedLabels.has(baseLabel)) { + return baseLabel; + } + + const typedLabel = `${baseLabel} (${getBookmarkTargetLabel({ bookmark })})`; + if (!usedLabels.has(typedLabel)) { + return typedLabel; + } + + let index = 2; + while (usedLabels.has(`${typedLabel} ${index}`)) { + index += 1; + } + return `${typedLabel} ${index}`; +}; + const mergeBookmarks = ({ savedBookmarks, dynamicBookmarks, @@ -377,10 +413,16 @@ const initializeQuickSwitcher = ({ value: extensionAPI.settings.get(BOOKMARKS_SETTING_KEY), }), }); + let commandPaletteSettings = parseStoredCommandPaletteSettings({ + value: extensionAPI.settings.get(COMMAND_PALETTE_SETTING_KEY), + }); let querySource = parseStoredQuerySource({ value: extensionAPI.settings.get(QUERY_SOURCE_SETTING_KEY), }); let dynamicBookmarks: QuickSwitcherBookmark[] = []; + let registeredBookmarkCommandLabels = new Set(); + let bookmarkCommandSyncQueue = Promise.resolve(); + let bookmarkCommandSyncId = 0; let isDialogOpen = false; let hasRenderedDialog = false; let isUnloaded = false; @@ -390,6 +432,13 @@ const initializeQuickSwitcher = ({ void extensionAPI.settings.set(BOOKMARKS_SETTING_KEY, bookmarks); }; + const persistCommandPaletteSettings = (): void => { + void extensionAPI.settings.set( + COMMAND_PALETTE_SETTING_KEY, + commandPaletteSettings, + ); + }; + const persistQuerySource = (): void => { void extensionAPI.settings.set(QUERY_SOURCE_SETTING_KEY, querySource); }; @@ -517,20 +566,98 @@ const initializeQuickSwitcher = ({ .catch(() => undefined); }; + const syncBookmarkCommands = (): void => { + const syncId = bookmarkCommandSyncId + 1; + bookmarkCommandSyncId = syncId; + bookmarkCommandSyncQueue = bookmarkCommandSyncQueue + .then(async () => { + const labelsToRemove = [...registeredBookmarkCommandLabels]; + registeredBookmarkCommandLabels = new Set(); + await Promise.all( + labelsToRemove.map((label) => + extensionAPI.ui.commandPalette + .removeCommand({ label }) + .catch(() => undefined), + ), + ); + + if ( + isUnloaded || + syncId !== bookmarkCommandSyncId || + !commandPaletteSettings.enabled + ) { + return; + } + + const usedLabels = new Set([OPEN_QUICK_SWITCHER_COMMAND]); + const nextLabels = bookmarks.map((bookmark) => { + const label = getUniqueCommandLabel({ + bookmark, + settings: commandPaletteSettings, + usedLabels, + }); + usedLabels.add(label); + return { bookmark, label }; + }); + + await Promise.all( + nextLabels.map(({ bookmark, label }) => + extensionAPI.ui.commandPalette + .addCommand({ + label, + callback: () => { + void openBookmark({ bookmark }); + }, + }) + .catch(() => undefined), + ), + ); + + if (isUnloaded || syncId !== bookmarkCommandSyncId) { + await Promise.all( + nextLabels.map(({ label }) => + extensionAPI.ui.commandPalette + .removeCommand({ label }) + .catch(() => undefined), + ), + ); + return; + } + + registeredBookmarkCommandLabels = new Set( + nextLabels.map(({ label }) => label), + ); + }) + .catch(() => undefined); + }; + document.addEventListener("keydown", onDocumentKeyDown, true); registerCommand(); + syncBookmarkCommands(); return { getBookmarks: (): QuickSwitcherBookmark[] => bookmarks, + getCommandPaletteSettings: (): QuickSwitcherCommandPaletteSettings => + commandPaletteSettings, getQuerySource: (): QuickSwitcherQuerySource => querySource, open: openDialog, setBookmarks: (nextBookmarks: QuickSwitcherBookmark[]): void => { bookmarks = sanitizeBookmarks({ bookmarks: nextBookmarks }); persistBookmarks(); + syncBookmarkCommands(); if (hasRenderedDialog) { render(); } }, + setCommandPaletteSettings: ( + nextCommandPaletteSettings: QuickSwitcherCommandPaletteSettings, + ): void => { + commandPaletteSettings = normalizeCommandPaletteSettings({ + settings: nextCommandPaletteSettings, + }); + persistCommandPaletteSettings(); + syncBookmarkCommands(); + }, setQuerySource: (nextQuerySource: QuickSwitcherQuerySource): void => { querySource = normalizeQuerySource({ querySource: nextQuerySource }); persistQuerySource(); @@ -539,8 +666,10 @@ const initializeQuickSwitcher = ({ unload: (): void => { isUnloaded = true; refreshQuerySourceId += 1; + bookmarkCommandSyncId += 1; closeDialog(); document.removeEventListener("keydown", onDocumentKeyDown, true); + syncBookmarkCommands(); unregisterCommand(); ReactDOM.unmountComponentAtNode(root); root.remove(); diff --git a/src/types/quickSwitcher.ts b/src/types/quickSwitcher.ts index b886677..6124862 100644 --- a/src/types/quickSwitcher.ts +++ b/src/types/quickSwitcher.ts @@ -16,6 +16,11 @@ export type QuickSwitcherQuerySource = { queryRef: string; }; +export type QuickSwitcherCommandPaletteSettings = { + enabled: boolean; + prefix: string; +}; + export type ShortcutKeyboardEvent = { key: string; ctrlKey: boolean; diff --git a/src/utils/quickSwitcher.ts b/src/utils/quickSwitcher.ts index 01bee08..4e6cc48 100644 --- a/src/utils/quickSwitcher.ts +++ b/src/utils/quickSwitcher.ts @@ -1,5 +1,6 @@ import type { QuickSwitcherBookmark, + QuickSwitcherCommandPaletteSettings, QuickSwitcherQuerySource, QuickSwitcherTargetType, ShortcutKeyboardEvent, @@ -19,6 +20,12 @@ const DEFAULT_QUERY_SOURCE: QuickSwitcherQuerySource = { enabled: false, queryRef: "", }; +const DEFAULT_COMMAND_PALETTE_PREFIX = "Q S - "; + +const DEFAULT_COMMAND_PALETTE_SETTINGS: QuickSwitcherCommandPaletteSettings = { + enabled: false, + prefix: DEFAULT_COMMAND_PALETTE_PREFIX, +}; const normalizeModifierToken = ({ token }: { token: string }): string => { const normalizedToken = token.toLowerCase().trim(); @@ -522,6 +529,44 @@ export const normalizeQuerySource = ({ queryRef: querySource.queryRef.trim(), }); +export const parseStoredCommandPaletteSettings = ({ + value, +}: { + value: unknown; +}): QuickSwitcherCommandPaletteSettings => { + if (!isRecord(value)) { + return DEFAULT_COMMAND_PALETTE_SETTINGS; + } + + const prefix = + typeof value.prefix === "string" && value.prefix.trim() + ? value.prefix + : DEFAULT_COMMAND_PALETTE_PREFIX; + return { + enabled: Boolean(value.enabled), + prefix, + }; +}; + +export const normalizeCommandPaletteSettings = ({ + settings, +}: { + settings: QuickSwitcherCommandPaletteSettings; +}): QuickSwitcherCommandPaletteSettings => ({ + enabled: Boolean(settings.enabled), + prefix: settings.prefix.trim() + ? settings.prefix + : DEFAULT_COMMAND_PALETTE_PREFIX, +}); + +export const getCommandPaletteCommandLabel = ({ + bookmark, + settings, +}: { + bookmark: QuickSwitcherBookmark; + settings: QuickSwitcherCommandPaletteSettings; +}): string => `${settings.prefix}${bookmark.title}`; + export const extractBlockRefUid = ({ value, }: { diff --git a/tests/quickSwitcher.test.ts b/tests/quickSwitcher.test.ts index df211df..db25edb 100644 --- a/tests/quickSwitcher.test.ts +++ b/tests/quickSwitcher.test.ts @@ -7,12 +7,15 @@ import { extractQueryBlockLabel, filterBookmarks, formatShortcutForDisplay, + getCommandPaletteCommandLabel, keyboardEventToShortcut, moveBookmarkByOffset, + normalizeCommandPaletteSettings, normalizeQuerySource, normalizeShortcut, parsePageUidFromUrl, parseStoredBookmarks, + parseStoredCommandPaletteSettings, parseStoredQuerySource, shortcutHasModifier, toAbsoluteUrl, @@ -200,6 +203,55 @@ test("derives block titles from the first few words", () => { expect(deriveBlockTitle({ text: " " })).toBe("Untitled block"); }); +test("parses and normalizes command palette settings", () => { + expect( + parseStoredCommandPaletteSettings({ + value: { + enabled: true, + prefix: "QS: ", + }, + }), + ).toEqual({ + enabled: true, + prefix: "QS: ", + }); + expect(parseStoredCommandPaletteSettings({ value: "invalid" })).toEqual({ + enabled: false, + prefix: "Q S - ", + }); + expect( + normalizeCommandPaletteSettings({ + settings: { + enabled: true, + prefix: "", + }, + }), + ).toEqual({ + enabled: true, + prefix: "Q S - ", + }); +}); + +test("builds command palette labels from prefix and entry title", () => { + expect( + getCommandPaletteCommandLabel({ + bookmark: { + id: "block-1", + title: "Follow up with team", + targetType: "block", + pageUid: null, + blockUid: "block-1", + url: "https://roamresearch.com/#/app/graph/page/block-1", + shortcut: null, + }, + settings: { + enabled: true, + prefix: "QS - ", + }, + }), + ).toBe("QS - Follow up with team"); +}); + test("parses and normalizes query builder source settings", () => { expect( parseStoredQuerySource({