diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b4451..03a98c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Made Manage search fast and current by using Roam's bounded frontend search and clearing stale suggestions as queries change. +- Kept the first Escape press in Manage mode focused on clearing the current search. +- Made selected rows theme-aware and replaced custom footer buttons with Blueprint controls. - Moved saved-entry add and remove workflows into a Manage tab in the Quick Switcher dialog. - Replaced saved-entry settings controls with a Manage Saved Entries button. - Restyled the Quick Switcher dialog to follow Roam's native search layout and colors. diff --git a/src/components/QuickSwitcherDialog.tsx b/src/components/QuickSwitcherDialog.tsx index 3e73856..42466e6 100644 --- a/src/components/QuickSwitcherDialog.tsx +++ b/src/components/QuickSwitcherDialog.tsx @@ -83,12 +83,6 @@ const SEARCH_INPUT_STYLE: React.CSSProperties = { boxShadow: "none", }; -const SELECTED_ROW_STYLE: React.CSSProperties = { - backgroundColor: "#dce4eb", - borderRadius: 4, - color: "inherit", -}; - const showToast = ({ content, intent = "none", @@ -165,16 +159,17 @@ const FooterActionButton = ({ onClick, showEnter, }: FooterActionButtonProps): React.ReactElement => ( - + ); const QuickSwitcherDialog = ({ @@ -319,6 +314,7 @@ const QuickSwitcherDialog = ({ useEffect((): void | (() => void) => { const requestId = searchRequestRef.current + 1; searchRequestRef.current = requestId; + setSuggestions([]); setSelectedSuggestionIndex(0); if (mode !== "manage" || normalizedEntryInput.length < 2) { @@ -365,12 +361,23 @@ const QuickSwitcherDialog = ({ ); const clearEntryInput = useCallback((): void => { + searchRequestRef.current += 1; setEntryInput(""); setSuggestions([]); setSelectedSuggestionIndex(0); setIsSearching(false); }, []); + const onManageEntryInputChange = useCallback( + (event: React.ChangeEvent): void => { + searchRequestRef.current += 1; + setEntryInput(event.target.value); + setSuggestions([]); + setSelectedSuggestionIndex(0); + }, + [], + ); + const addSuggestion = useCallback( ({ suggestion }: { suggestion: QuickSwitcherEntrySuggestion }): boolean => { const key = getSuggestionTargetKey({ suggestion }); @@ -620,6 +627,7 @@ const QuickSwitcherDialog = ({ if (event.key === "Escape") { if (entryInput || suggestions.length) { event.preventDefault(); + event.stopPropagation(); clearEntryInput(); return; } @@ -681,7 +689,7 @@ const QuickSwitcherDialog = ({ leftIcon="search" onChange={(event: React.ChangeEvent): void => { if (mode === "manage") { - setEntryInput(event.target.value); + onManageEntryInputChange(event); return; } setQuery(event.target.value); @@ -831,10 +839,10 @@ const QuickSwitcherDialog = ({ {visibleBookmarks.map((bookmark, index) => ( onBookmarkRowClick({ bookmark, event })} onMouseEnter={(): void => setSelectedIndex(index)} - style={selectedIndex === index ? SELECTED_ROW_STYLE : undefined} multiline text={renderRowContent({ breadcrumbs: getBookmarkBreadcrumbs({ bookmark }), @@ -868,16 +876,12 @@ const QuickSwitcherDialog = ({ {suggestions.map((suggestion, index) => ( { addSuggestion({ suggestion }); }} onMouseEnter={(): void => setSelectedSuggestionIndex(index)} - style={ - selectedSuggestionIndex === index - ? SELECTED_ROW_STYLE - : undefined - } text={renderRowContent({ breadcrumbs: suggestion.targetType === "block" diff --git a/src/utils/quickSwitcherEntries.ts b/src/utils/quickSwitcherEntries.ts index c2974ee..ce493dd 100644 --- a/src/utils/quickSwitcherEntries.ts +++ b/src/utils/quickSwitcherEntries.ts @@ -22,21 +22,43 @@ export type QuickSwitcherEntrySuggestion = { type PulledRoamBlock = Record; +type RoamSearchResult = Record; + +type RoamSearchOptions = { + "hide-code-blocks": boolean; + limit: number; + pull: string; + "search-blocks": boolean; + "search-pages": boolean; + "search-str": string; +}; + +type RoamSearchApi = ( + options: RoamSearchOptions, +) => Promise; + const MAX_PAGE_SUGGESTIONS = 8; const MAX_BLOCK_SUGGESTIONS = 8; +const MAX_SEARCH_RESULTS = 50; +const SEARCH_RESULT_PULL = "[:block/string :node/title :block/uid]"; const toDatalogString = ({ value }: { value: string }): string => JSON.stringify(value); -const escapeRegex = ({ value }: { value: string }): string => - value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -const getSearchRegex = ({ query }: { query: string }): string => - `(?i)${escapeRegex({ value: query.trim() })}`; - const isPulledRoamBlock = (value: unknown): value is PulledRoamBlock => typeof value === "object" && value !== null; +const getSearchResultValue = ({ + key, + result, +}: { + key: ":block/string" | ":block/uid" | ":node/title"; + result: RoamSearchResult; +}): string => { + const value = result[key]; + return typeof value === "string" ? value : ""; +}; + const normalizeBreadcrumbSegment = ({ value }: { value: string }): string => value.replace(/\s+/g, " ").trim(); @@ -217,29 +239,28 @@ const addBreadcrumbsToBlockSuggestions = async ({ export const searchEntries = async ({ query, savedTargetKeys, + searchApi, }: { query: string; savedTargetKeys: Set; + searchApi?: RoamSearchApi; }): Promise => { - const regex = toDatalogString({ value: getSearchRegex({ query }) }); - const [pageRows, blockRows] = await Promise.all([ - window.roamAlphaAPI.data.backend.q( - `[:find ?uid ?title - :where - [?page :node/title ?title] - [?page :block/uid ?uid] - [[re-pattern ${regex}] ?regex] - [[re-find ?regex ?title]]]`, - ) as Promise<[string, string][]>, - window.roamAlphaAPI.data.backend.q( - `[:find ?uid ?text - :where - [?block :block/string ?text] - [?block :block/uid ?uid] - [[re-pattern ${regex}] ?regex] - [[re-find ?regex ?text]]]`, - ) as Promise<[string, string][]>, - ]); + const options: RoamSearchOptions = { + "hide-code-blocks": false, + limit: MAX_SEARCH_RESULTS, + pull: SEARCH_RESULT_PULL, + "search-blocks": true, + "search-pages": true, + "search-str": query.trim(), + }; + const searchResults = searchApi + ? await searchApi(options) + : await ( + window.roamAlphaAPI.data + .async as typeof window.roamAlphaAPI.data.async & { + search: RoamSearchApi; + } + ).search(options); const seen = new Set(); const addSuggestion = ({ @@ -261,40 +282,40 @@ export const searchEntries = async ({ return true; }; - const pages = pageRows - .map(([uid, title]) => - toSuggestion({ - uid, - title, - targetType: "page", - }), - ) - .sort((a, b) => (a?.title || "").localeCompare(b?.title || "")); - const blocks = blockRows - .map(([uid, text]) => - toSuggestion({ - uid, - title: deriveBlockTitle({ text }), - targetType: "block", - }), - ) - .sort((a, b) => (a?.title || "").localeCompare(b?.title || "")); - const suggestions: QuickSwitcherEntrySuggestion[] = []; let pageSuggestionCount = 0; - pages.some((suggestion) => { - if (addSuggestion({ suggestion, result: suggestions })) { - pageSuggestionCount += 1; - } - return pageSuggestionCount >= MAX_PAGE_SUGGESTIONS; - }); let blockSuggestionCount = 0; - blocks.some((suggestion) => { + + searchResults.some((result) => { + const uid = getSearchResultValue({ key: ":block/uid", result }); + const pageTitle = getSearchResultValue({ key: ":node/title", result }); + const blockText = getSearchResultValue({ key: ":block/string", result }); + const targetType = pageTitle ? "page" : "block"; + if ( + (targetType === "page" && pageSuggestionCount >= MAX_PAGE_SUGGESTIONS) || + (targetType === "block" && blockSuggestionCount >= MAX_BLOCK_SUGGESTIONS) + ) { + return false; + } + + const suggestion = toSuggestion({ + uid, + title: pageTitle || deriveBlockTitle({ text: blockText }), + targetType, + }); if (addSuggestion({ suggestion, result: suggestions })) { - blockSuggestionCount += 1; + if (targetType === "page") { + pageSuggestionCount += 1; + } else { + blockSuggestionCount += 1; + } } - return blockSuggestionCount >= MAX_BLOCK_SUGGESTIONS; + return ( + pageSuggestionCount >= MAX_PAGE_SUGGESTIONS && + blockSuggestionCount >= MAX_BLOCK_SUGGESTIONS + ); }); + return addBreadcrumbsToBlockSuggestions({ suggestions }); }; diff --git a/tests/quickSwitcher.test.ts b/tests/quickSwitcher.test.ts index 6241ca7..54da636 100644 --- a/tests/quickSwitcher.test.ts +++ b/tests/quickSwitcher.test.ts @@ -16,6 +16,7 @@ import { createBookmarkFromSuggestion, getBlockBreadcrumbsFromPull, getSavedTargetKeys, + searchEntries, } from "../src/utils/quickSwitcherEntries"; test("parses a page uid from roam page urls", () => { @@ -149,6 +150,81 @@ test("builds saved target keys and bookmarks from entry suggestions", () => { }); }); +test("searches a bounded frontend result set and filters saved targets", async () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + href: "https://roamresearch.com/#/app/test-graph/daily-notes", + origin: "https://roamresearch.com", + }, + roamAlphaAPI: { + data: { + pull: (): null => null, + }, + }, + }, + writable: true, + }); + + try { + let receivedOptions: Record = {}; + const pageResults = Array.from({ length: 10 }, (_, index) => ({ + ":block/uid": `page-${index}`, + ":node/title": `Project page ${index}`, + })); + const blockResults = Array.from({ length: 10 }, (_, index) => ({ + ":block/string": `Project block ${index}`, + ":block/uid": `block-${index}`, + })); + + const suggestions = await searchEntries({ + query: " project ", + savedTargetKeys: new Set(["page:page-0", "block:block-0"]), + searchApi: async (options) => { + receivedOptions = options; + return pageResults.flatMap((page, index) => [ + page, + blockResults[index], + ]); + }, + }); + + expect(receivedOptions).toEqual({ + "hide-code-blocks": false, + limit: 50, + pull: "[:block/string :node/title :block/uid]", + "search-blocks": true, + "search-pages": true, + "search-str": "project", + }); + expect(suggestions).toHaveLength(16); + expect( + suggestions.filter(({ targetType }) => targetType === "page"), + ).toHaveLength(8); + expect( + suggestions.filter(({ targetType }) => targetType === "block"), + ).toHaveLength(8); + expect( + suggestions.some( + ({ targetType, uid }) => `${targetType}:${uid}` === "page:page-0", + ), + ).toBe(false); + expect( + suggestions.some( + ({ targetType, uid }) => `${targetType}:${uid}` === "block:block-0", + ), + ).toBe(false); + } finally { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + writable: true, + }); + } +}); + test("extracts block breadcrumbs from pulled block parents", () => { expect( getBlockBreadcrumbsFromPull({