Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 22 additions & 18 deletions src/components/QuickSwitcherDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -165,16 +159,17 @@ const FooterActionButton = ({
onClick,
showEnter,
}: FooterActionButtonProps): React.ReactElement => (
<button
className={`bp3-button bp3-minimal rm-find-or-create-footer__action${
disabled ? "bp3-disabled" : ""
}${className ? ` ${className}` : ""}`}
<Button
className={`rm-find-or-create-footer__action${
className ? ` ${className}` : ""
}`}
disabled={disabled}
minimal
onClick={onClick}
type="button"
>
{renderFooterActionContent({ hotkeys, label, showEnter })}
</button>
</Button>
);

const QuickSwitcherDialog = ({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<HTMLInputElement>): void => {
searchRequestRef.current += 1;
setEntryInput(event.target.value);
setSuggestions([]);
setSelectedSuggestionIndex(0);
},
[],
);

const addSuggestion = useCallback(
({ suggestion }: { suggestion: QuickSwitcherEntrySuggestion }): boolean => {
const key = getSuggestionTargetKey({ suggestion });
Expand Down Expand Up @@ -620,6 +627,7 @@ const QuickSwitcherDialog = ({
if (event.key === "Escape") {
if (entryInput || suggestions.length) {
event.preventDefault();
event.stopPropagation();
clearEntryInput();
return;
}
Expand Down Expand Up @@ -681,7 +689,7 @@ const QuickSwitcherDialog = ({
leftIcon="search"
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => {
if (mode === "manage") {
setEntryInput(event.target.value);
onManageEntryInputChange(event);
return;
}
setQuery(event.target.value);
Expand Down Expand Up @@ -831,10 +839,10 @@ const QuickSwitcherDialog = ({
<Menu className="rm-find-or-create-modal-body__list">
{visibleBookmarks.map((bookmark, index) => (
<MenuItem
active={selectedIndex === index}
key={bookmark.id}
onClick={(event): void => onBookmarkRowClick({ bookmark, event })}
onMouseEnter={(): void => setSelectedIndex(index)}
style={selectedIndex === index ? SELECTED_ROW_STYLE : undefined}
multiline
text={renderRowContent({
breadcrumbs: getBookmarkBreadcrumbs({ bookmark }),
Expand Down Expand Up @@ -868,16 +876,12 @@ const QuickSwitcherDialog = ({
<Menu className="rm-find-or-create-modal-body__list">
{suggestions.map((suggestion, index) => (
<MenuItem
active={selectedSuggestionIndex === index}
key={getSuggestionTargetKey({ suggestion })}
onClick={(): void => {
addSuggestion({ suggestion });
}}
onMouseEnter={(): void => setSelectedSuggestionIndex(index)}
style={
selectedSuggestionIndex === index
? SELECTED_ROW_STYLE
: undefined
}
text={renderRowContent({
breadcrumbs:
suggestion.targetType === "block"
Expand Down
127 changes: 74 additions & 53 deletions src/utils/quickSwitcherEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,43 @@ export type QuickSwitcherEntrySuggestion = {

type PulledRoamBlock = Record<string, unknown>;

type RoamSearchResult = Record<string, unknown>;

type RoamSearchOptions = {
"hide-code-blocks": boolean;
limit: number;
pull: string;
"search-blocks": boolean;
"search-pages": boolean;
"search-str": string;
};

type RoamSearchApi = (
options: RoamSearchOptions,
) => Promise<RoamSearchResult[]>;

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();

Expand Down Expand Up @@ -217,29 +239,28 @@ const addBreadcrumbsToBlockSuggestions = async ({
export const searchEntries = async ({
query,
savedTargetKeys,
searchApi,
}: {
query: string;
savedTargetKeys: Set<string>;
searchApi?: RoamSearchApi;
}): Promise<QuickSwitcherEntrySuggestion[]> => {
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<string>();
const addSuggestion = ({
Expand All @@ -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 });
};

Expand Down
76 changes: 76 additions & 0 deletions tests/quickSwitcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createBookmarkFromSuggestion,
getBlockBreadcrumbsFromPull,
getSavedTargetKeys,
searchEntries,
} from "../src/utils/quickSwitcherEntries";

test("parses a page uid from roam page urls", () => {
Expand Down Expand Up @@ -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<string, unknown> = {};
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({
Expand Down
Loading