diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx
new file mode 100644
index 000000000..2778c840f
--- /dev/null
+++ b/apps/obsidian/src/components/NodeSearchFooter.tsx
@@ -0,0 +1,74 @@
+import { type ReactElement } from "react";
+import { getHintKeys, type HintKey } from "~/utils/keyboardHints";
+
+type NodeSearchFooterProps = {
+ canAct: boolean;
+ onClose: () => void;
+ onOpenInNewTab: () => void;
+ onOpenInSplit: () => void;
+};
+
+type FooterActionProps = {
+ disabled?: boolean;
+ keys: HintKey[];
+ label: string;
+ onClick: () => void;
+};
+
+const KeyHints = ({ keys }: { keys: HintKey[] }): ReactElement => (
+ <>
+ {getHintKeys(keys).map((symbol) => (
+
+ {symbol}
+
+ ))}
+ >
+);
+
+const FooterAction = ({
+ disabled = false,
+ keys,
+ label,
+ onClick,
+}: FooterActionProps): ReactElement => (
+
+);
+
+// Sits in Obsidian's `prompt-instructions` container for its type and spacing.
+// Obsidian centres that row for the narrow quick switcher; this footer spans a
+// full-width result list, so the actions start at its left edge instead.
+export const NodeSearchFooter = ({
+ canAct,
+ onClose,
+ onOpenInNewTab,
+ onOpenInSplit,
+}: NodeSearchFooterProps): ReactElement => (
+
+
+
+ {/* The Escape key itself is handled by Obsidian's modal scope; this button
+ is the pointer equivalent, so every footer item responds to a click. */}
+
+
+);
diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx
new file mode 100644
index 000000000..f71142cbb
--- /dev/null
+++ b/apps/obsidian/src/components/NodeSearchModal.tsx
@@ -0,0 +1,558 @@
+import {
+ App,
+ Component,
+ MarkdownRenderer,
+ Modal,
+ Notice,
+ renderResults,
+ TFile,
+ type SearchResult,
+} from "obsidian";
+import {
+ StrictMode,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type MouseEvent,
+ type ReactElement,
+} from "react";
+import { createRoot, Root } from "react-dom/client";
+import type DiscourseGraphPlugin from "~/index";
+import { NodeSearchFooter } from "~/components/NodeSearchFooter";
+import { NodeSortMenu } from "~/components/NodeSortMenu";
+import { NodeTypeChipsSearchInput } from "~/components/NodeTypeChipsSearchInput";
+import { NodeTypeFilterMenu } from "~/components/NodeTypeFilterMenu";
+import type { SearchDropdownId } from "~/components/SearchDropdown";
+import {
+ openFileInNewLeaf,
+ openFileInNewTab,
+} from "~/components/canvas/utils/openFileUtils";
+import {
+ QueryEngine,
+ rankDiscourseNodesByTitle,
+ type DiscourseNodeCandidate,
+ type RankedDiscourseNode,
+} from "~/services/QueryEngine";
+import {
+ getNodeTypeBadge,
+ getFallbackNodeTypeBadge,
+ type NodeTypeBadge,
+} from "~/utils/nodeTypeBadge";
+import {
+ buildAuthorNameByPath,
+ resolveAuthorName,
+ useAuthorNames,
+} from "~/utils/discourseNodeAuthor";
+import {
+ DEFAULT_SORT_DIRECTION,
+ DEFAULT_SORT_KEY,
+ sortSearchResults,
+ type SortDirection,
+ type SortKey,
+} from "~/utils/discourseNodeSort";
+
+const MAX_VISIBLE_RESULTS = 50;
+const SEARCH_DEBOUNCE_MS = 250;
+
+type CandidateState =
+ | { status: "loading" }
+ | { status: "ready"; candidates: DiscourseNodeCandidate[] }
+ | { status: "error"; message: string };
+
+type NodeTypeDisplay = {
+ name: string;
+ /** Null when neither the config nor the title says what type this is. */
+ badge: NodeTypeBadge | null;
+};
+
+type SearchResultRow = RankedDiscourseNode & {
+ nodeType: NodeTypeDisplay;
+};
+
+const formatTimestamp = (epochMs: number): string =>
+ new Date(epochMs).toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+
+const PreviewPane = ({
+ app,
+ result,
+ authorName,
+}: {
+ app: App;
+ result: SearchResultRow | undefined;
+ authorName: string;
+}): ReactElement => {
+ const containerRef = useRef(null);
+ // Paired with its file so an in-flight read can't put one note's body under
+ // another note's title.
+ const [loaded, setLoaded] = useState<{ file: TFile; text: string } | null>(
+ null,
+ );
+
+ const file = result?.file;
+
+ useEffect(() => {
+ if (!file) {
+ setLoaded(null);
+ return;
+ }
+ let cancelled = false;
+ void app.vault.cachedRead(file).then((text) => {
+ if (!cancelled) setLoaded({ file, text });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [app, file]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container || !file || loaded?.file !== file) return;
+
+ container.empty();
+ const component = new Component();
+ void MarkdownRenderer.render(
+ app,
+ loaded.text.trim() || "This note is empty.",
+ container,
+ file.path,
+ component,
+ );
+
+ return () => {
+ component.unload();
+ container.empty();
+ };
+ }, [app, file, loaded]);
+
+ if (!result || !file) {
+ return (
+
hasPointerMoved(event) && onActivate(index)}
+ onClick={() => onActivate(index)}
+ // Keeps focus in the search input, so the keyboard path stays live
+ // after a click.
+ onMouseDown={(event) => event.preventDefault()}
+ className={`border-modifier-border flex cursor-pointer items-center gap-2 border-b px-3 py-2 ${
+ index === activeIndex ? "bg-modifier-hover" : ""
+ }`}
+ >
+ {result.nodeType.badge && (
+
+ {result.nodeType.badge.text}
+
+ )}
+
+
+ ))}
+
+ );
+};
+
+const NodeSearch = ({
+ plugin,
+ onClose,
+}: {
+ plugin: DiscourseGraphPlugin;
+ onClose: () => void;
+}): ReactElement => {
+ const { app } = plugin;
+ const [candidateState, setCandidateState] = useState({
+ status: "loading",
+ });
+ const [query, setQuery] = useState("");
+ const [debouncedQuery, setDebouncedQuery] = useState("");
+ const [activeIndex, setActiveIndex] = useState(0);
+ // The single source of truth for active type filters: F6's chips will read and
+ // write this same state, so either surface can manage them.
+ const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]);
+ // One value per toolbar, so two panels can never be open at once.
+ const [openDropdown, setOpenDropdown] = useState(null);
+ const [sortKey, setSortKey] = useState(DEFAULT_SORT_KEY);
+ const [sortDirection, setSortDirection] = useState(
+ DEFAULT_SORT_DIRECTION,
+ );
+ // An editable span, so the query shares its line boxes with the filter chips.
+ const inputRef = useRef(null);
+ const userNames = useAuthorNames({
+ app,
+ plugin,
+ candidates:
+ candidateState.status === "ready" ? candidateState.candidates : null,
+ });
+
+ const nodeTypesById = useMemo(() => {
+ const byId = new Map();
+ plugin.settings.nodeTypes.forEach((nodeType, nodeIndex) => {
+ byId.set(nodeType.id, {
+ name: nodeType.name,
+ badge: getNodeTypeBadge({ nodeType, nodeIndex }),
+ });
+ });
+ return byId;
+ }, [plugin.settings.nodeTypes]);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ // The fetch is synchronous today, so there is nothing to await or cancel yet.
+ // Effects run after paint, so the loading state still renders for a frame; if
+ // this ever becomes a network call, only this body changes.
+ useEffect(() => {
+ try {
+ const candidates = new QueryEngine(app).getDiscourseNodeCandidates();
+ setCandidateState({ status: "ready", candidates });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Unexpected error";
+ new Notice(`Could not load discourse nodes: ${message}`);
+ setCandidateState({ status: "error", message });
+ }
+ }, [app]);
+
+ useEffect(() => {
+ const timeout = window.setTimeout(
+ () => setDebouncedQuery(query),
+ SEARCH_DEBOUNCE_MS,
+ );
+ return () => window.clearTimeout(timeout);
+ }, [query]);
+
+ // Sort before truncating, so a date or alphabetical sort covers every match.
+ const results = useMemo(() => {
+ if (candidateState.status !== "ready") return [];
+ const ranked = rankDiscourseNodesByTitle({
+ candidates: candidateState.candidates,
+ query: debouncedQuery,
+ nodeTypeIds: selectedNodeTypeIds,
+ });
+ const authorNameByPath =
+ sortKey === "author"
+ ? buildAuthorNameByPath({
+ app,
+ files: ranked.map((result) => result.file),
+ userNames,
+ })
+ : undefined;
+ return sortSearchResults({
+ results: ranked,
+ sortKey,
+ direction: sortDirection,
+ authorNameByPath,
+ })
+ .slice(0, MAX_VISIBLE_RESULTS)
+ .map((result) => ({
+ ...result,
+ nodeType: nodeTypesById.get(result.nodeTypeId) ?? {
+ name: "Unknown type",
+ badge: getFallbackNodeTypeBadge(result.title),
+ },
+ }));
+ }, [
+ app,
+ candidateState,
+ debouncedQuery,
+ nodeTypesById,
+ selectedNodeTypeIds,
+ sortDirection,
+ sortKey,
+ userNames,
+ ]);
+
+ // A narrowing query rebuilds `results` before the effect below can reset the
+ // state, so the old index can point past the new list for one render. Clamping
+ // here keeps the preview and the highlighted row from blanking for that frame.
+ const activeIndexInRange = activeIndex < results.length ? activeIndex : 0;
+ const activeResult = results[activeIndexInRange];
+
+ // Only the preview shows an author, so resolve the selection, not all 50 rows.
+ const authorName = useMemo(
+ () =>
+ activeResult
+ ? resolveAuthorName({ app, file: activeResult.file, userNames })
+ : "",
+ [app, activeResult, userNames],
+ );
+
+ useEffect(() => {
+ setActiveIndex(0);
+ }, [results]);
+
+ const moveActiveIndex = (delta: number) => {
+ if (!results.length) return;
+ setActiveIndex((current) => {
+ const next = current + delta;
+ if (next < 0) return 0;
+ if (next > results.length - 1) return results.length - 1;
+ return next;
+ });
+ };
+
+ // Closes before opening: `close()` unmounts this React root, so the file and
+ // app are read first and nothing touches state afterwards.
+ const openActiveResult = (
+ open: (app: App, file: TFile) => Promise,
+ ): void => {
+ if (!activeResult) return;
+ const { file } = activeResult;
+ onClose();
+ void open(app, file).catch((error: unknown) => {
+ const message = error instanceof Error ? error.message : String(error);
+ new Notice(`Could not open ${file.basename}: ${message}`);
+ });
+ };
+
+ const handleDropdownOpenChange = ({
+ id,
+ isOpen,
+ }: {
+ id: NonNullable;
+ isOpen: boolean;
+ }): void => {
+ setOpenDropdown(isOpen ? id : null);
+ // Returns the keyboard path to the results the moment the panel closes.
+ if (!isOpen) inputRef.current?.focus();
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ // Otherwise the caret jumps to the start or end of the query.
+ event.preventDefault();
+ moveActiveIndex(event.key === "ArrowDown" ? 1 : -1);
+ return;
+ }
+
+ if (event.key !== "Enter") return;
+ // Enter also commits an IME candidate, which must not open a file.
+ if (event.nativeEvent.isComposing) return;
+ // Mod+Enter and Alt+Enter are left alone for the insert and dock actions.
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
+ // A footer button reached by Tab runs its own action on Enter. Preventing the
+ // default here would suppress that click and open a new tab instead.
+ if (
+ event.target instanceof HTMLElement &&
+ event.target.closest("button") !== null
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ openActiveResult(event.shiftKey ? openFileInNewLeaf : openFileInNewTab);
+ };
+
+ return (
+ // Bound here rather than on the input so navigation survives focus moving
+ // elsewhere in the modal, and so result actions have one place to live.
+
+ {/* Padded so a trigger's count badge is not clipped by the modal's overflow-hidden content. */}
+ {/* Top-aligned: the field grows downwards, so the triggers stay on its first line. */}
+
{
+ if (!isOpen) return;
+ // Panel keystrokes must not reach the modal's Enter and arrow result navigation; Escape never arrives here at all.
+ event.stopPropagation();
+ }}
+ >
+
+ {isOpen && (
+
+ {children}
+
+ )}
+
+ );
+};
diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css
index 66e243fe6..3cf9c64ce 100644
--- a/apps/obsidian/src/styles/style.css
+++ b/apps/obsidian/src/styles/style.css
@@ -3898,3 +3898,52 @@ kbd.tlui-kbd {
background-color: var(--background-secondary);
}
}
+
+.dg-node-search-modal .dg-search-result-title span {
+ background-color: var(--text-highlight-bg);
+ color: inherit;
+ border-radius: var(--radius-s);
+ padding: 0 1px;
+}
+
+/* Only the properties Tailwind utilities cannot win here; the rest of this
+ footer's layout is utilities on the elements themselves. Obsidian sets
+ `color`, `background-color`, and `box-shadow` in `button:not(.clickable-icon)`
+ and `button:hover` — both (0,1,1), which outrank a single utility class — so a
+ utility would leave the label in `--text-normal` on an interactive-grey pill.
+ `font-size` has no inherit utility, and without it the button takes
+ `--font-ui-small` rather than the smaller type of the row it sits in. */
+.dg-node-search-modal .dg-search-footer-action,
+.dg-node-search-modal .dg-search-footer-action:hover {
+ color: inherit;
+ background-color: transparent;
+ box-shadow: none;
+ font-size: inherit;
+}
+
+/* Each key is a bordered cap, so `esc` reads as one of the set rather than as
+ emphasised text. Kept in CSS for the inherited font and em-based sizing. */
+.dg-node-search-modal .dg-search-footer-key {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.5em;
+ padding: 0 var(--size-2-1);
+ border: 1px solid var(--background-modifier-border);
+ border-radius: var(--radius-s);
+ background-color: var(--background-primary);
+ color: var(--text-muted);
+ font-family: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+ line-height: 1.6;
+}
+
+.dg-node-search-modal .dg-search-footer-action:hover:not(:disabled) {
+ color: var(--text-normal);
+}
+
+.dg-node-search-modal .dg-search-footer-action:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
diff --git a/apps/obsidian/src/utils/discourseNodeAuthor.ts b/apps/obsidian/src/utils/discourseNodeAuthor.ts
new file mode 100644
index 000000000..57efa0fb9
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseNodeAuthor.ts
@@ -0,0 +1,93 @@
+import { App, TFile } from "obsidian";
+import { useEffect, useState } from "react";
+import type DiscourseGraphPlugin from "~/index";
+import type { DiscourseNodeCandidate } from "~/services/QueryEngine";
+import { fetchUserNames } from "~/utils/importNodes";
+import { getLoggedInClient } from "~/utils/supabaseContext";
+
+/** Single source for a note's author name. Obsidian exposes no Sync user API, so names come from `authorId` frontmatter. */
+
+export const LOCAL_AUTHOR_NAME = "You";
+export const UNRESOLVED_AUTHOR_NAME = "Unknown";
+
+/** Frontmatter is untyped, so the raw value is narrowed by each caller. */
+const getFrontmatterAuthorId = (app: App, file: TFile): unknown => {
+ // Annotated rather than asserted: `FrontMatterCache` indexes to `any`, so a cast is a no-op.
+ const frontmatter: Record | undefined =
+ app.metadataCache.getFileCache(file)?.frontmatter;
+ return frontmatter?.authorId;
+};
+
+/** "You" only when there is no `authorId`; a present but unresolvable id stays "Unknown". */
+export const resolveAuthorName = ({
+ app,
+ file,
+ userNames,
+}: {
+ app: App;
+ file: TFile;
+ userNames: Record;
+}): string => {
+ const authorId = getFrontmatterAuthorId(app, file);
+ if (authorId === undefined || authorId === null) return LOCAL_AUTHOR_NAME;
+ if (typeof authorId !== "number") return UNRESOLVED_AUTHOR_NAME;
+ return userNames[authorId] ?? UNRESOLVED_AUTHOR_NAME;
+};
+
+export const isUnattributedAuthorName = (authorName: string): boolean =>
+ authorName === UNRESOLVED_AUTHOR_NAME;
+
+export const buildAuthorNameByPath = ({
+ app,
+ files,
+ userNames,
+}: {
+ app: App;
+ files: TFile[];
+ userNames: Record;
+}): Map => {
+ const byPath = new Map();
+ files.forEach((file) => {
+ byPath.set(file.path, resolveAuthorName({ app, file, userNames }));
+ });
+ return byPath;
+};
+
+/** One query returns every person, so this refreshes once per open when a name is missing. */
+export const useAuthorNames = ({
+ app,
+ plugin,
+ candidates,
+}: {
+ app: App;
+ plugin: DiscourseGraphPlugin;
+ candidates: DiscourseNodeCandidate[] | null;
+}): Record => {
+ const [userNames, setUserNames] = useState(plugin.settings.userNames ?? {});
+
+ useEffect(() => {
+ if (!candidates) return;
+ if (!plugin.settings.syncModeEnabled) return;
+
+ const isMissingName = (candidate: DiscourseNodeCandidate): boolean => {
+ const authorId = getFrontmatterAuthorId(app, candidate.file);
+ return (
+ typeof authorId === "number" && !plugin.settings.userNames?.[authorId]
+ );
+ };
+ if (!candidates.some(isMissingName)) return;
+
+ let cancelled = false;
+ void (async () => {
+ const client = await getLoggedInClient(plugin);
+ if (!client || cancelled) return;
+ await fetchUserNames(plugin, client);
+ if (!cancelled) setUserNames(plugin.settings.userNames ?? {});
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [app, plugin, candidates]);
+
+ return userNames;
+};
diff --git a/apps/obsidian/src/utils/discourseNodeSort.ts b/apps/obsidian/src/utils/discourseNodeSort.ts
new file mode 100644
index 000000000..cd1b9b737
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseNodeSort.ts
@@ -0,0 +1,140 @@
+import { TFile } from "obsidian";
+import { isUnattributedAuthorName } from "~/utils/discourseNodeAuthor";
+
+/** Client-side result ordering, over the full ranked list before display truncation. Mirrors Roam's advanced search. */
+
+export type SortKey =
+ | "relevance"
+ | "title"
+ | "dateCreated"
+ | "dateModified"
+ | "author";
+
+export type SortDirection = "asc" | "desc";
+
+export const SORT_OPTIONS: { key: SortKey; label: string }[] = [
+ { key: "relevance", label: "Relevance" },
+ { key: "title", label: "Alphabetical" },
+ { key: "dateCreated", label: "Date created" },
+ { key: "dateModified", label: "Date modified" },
+ { key: "author", label: "Author" },
+];
+
+export const DEFAULT_SORT_KEY: SortKey = "relevance";
+export const DEFAULT_SORT_DIRECTION: SortDirection = "desc";
+
+/** Structural, so the sort runs on ranked results or decorated rows alike. */
+export type SortableSearchResult = {
+ file: TFile;
+ title: string;
+ match: { score: number };
+};
+
+const DIRECTION_LABELS: Record> = {
+ relevance: { desc: "Best match first", asc: "Worst match first" },
+ title: { asc: "A to Z", desc: "Z to A" },
+ dateCreated: { desc: "Newest first", asc: "Oldest first" },
+ dateModified: { desc: "Newest first", asc: "Oldest first" },
+ author: { asc: "A to Z", desc: "Z to A" },
+};
+
+export const getSortDirectionLabel = ({
+ sortKey,
+ direction,
+}: {
+ sortKey: SortKey;
+ direction: SortDirection;
+}): string => DIRECTION_LABELS[sortKey][direction];
+
+/** "Descending" means something different per dimension, so switching resets it. */
+export const getDefaultDirectionForKey = (sortKey: SortKey): SortDirection =>
+ sortKey === "title" || sortKey === "author" ? "asc" : "desc";
+
+export const isDefaultSort = ({
+ sortKey,
+ direction,
+}: {
+ sortKey: SortKey;
+ direction: SortDirection;
+}): boolean =>
+ sortKey === DEFAULT_SORT_KEY && direction === DEFAULT_SORT_DIRECTION;
+
+export const getSortOptionLabel = (sortKey: SortKey): string =>
+ SORT_OPTIONS.find((option) => option.key === sortKey)?.label ?? "";
+
+const getAuthorName = ({
+ result,
+ authorNameByPath,
+}: {
+ result: SortableSearchResult;
+ authorNameByPath: Map | undefined;
+}): string => authorNameByPath?.get(result.file.path) ?? "";
+
+/** Unattributed notes sit after every named author, in both directions. */
+const compareUnattributedLast = ({
+ a,
+ b,
+ authorNameByPath,
+}: {
+ a: SortableSearchResult;
+ b: SortableSearchResult;
+ authorNameByPath: Map | undefined;
+}): number => {
+ const isAUnattributed = isUnattributedAuthorName(
+ getAuthorName({ result: a, authorNameByPath }),
+ );
+ const isBUnattributed = isUnattributedAuthorName(
+ getAuthorName({ result: b, authorNameByPath }),
+ );
+ if (isAUnattributed === isBUnattributed) return 0;
+ return isAUnattributed ? 1 : -1;
+};
+
+const compareAscending = ({
+ a,
+ b,
+ sortKey,
+ authorNameByPath,
+}: {
+ a: SortableSearchResult;
+ b: SortableSearchResult;
+ sortKey: SortKey;
+ authorNameByPath: Map | undefined;
+}): number => {
+ if (sortKey === "relevance") return a.match.score - b.match.score;
+ if (sortKey === "title") return a.title.localeCompare(b.title);
+ if (sortKey === "dateCreated") return a.file.stat.ctime - b.file.stat.ctime;
+ if (sortKey === "dateModified") return a.file.stat.mtime - b.file.stat.mtime;
+
+ const authorDelta = getAuthorName({
+ result: a,
+ authorNameByPath,
+ }).localeCompare(getAuthorName({ result: b, authorNameByPath }));
+ return authorDelta !== 0 ? authorDelta : a.title.localeCompare(b.title);
+};
+
+/** `authorNameByPath` is only needed for the author sort. */
+export const sortSearchResults = ({
+ results,
+ sortKey,
+ direction,
+ authorNameByPath,
+}: {
+ results: T[];
+ sortKey: SortKey;
+ direction: SortDirection;
+ authorNameByPath?: Map;
+}): T[] =>
+ [...results].sort((a, b) => {
+ if (sortKey === "author") {
+ // Outside the direction flip on purpose: this partition is not reversible.
+ const unattributedDelta = compareUnattributedLast({
+ a,
+ b,
+ authorNameByPath,
+ });
+ if (unattributedDelta !== 0) return unattributedDelta;
+ }
+ const delta = compareAscending({ a, b, sortKey, authorNameByPath });
+ return direction === "asc" ? delta : -delta;
+ });
diff --git a/apps/obsidian/src/utils/discourseNodeTypeFilter.ts b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts
new file mode 100644
index 000000000..07c9a7be9
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseNodeTypeFilter.ts
@@ -0,0 +1,64 @@
+import { DiscourseNode } from "~/types";
+
+/**
+ * Node type filtering for the search modal. An empty `selectedTypeIds` means no
+ * filter, matching `filterCandidatesByNodeTypeIds` in QueryEngine — so "nothing
+ * selected" and "every type selected" are the same state and both show all nodes.
+ * Ported from Roam's advanced search so both apps filter alike.
+ */
+
+/** Below this many types the list is short enough to scan without a search box. */
+export const NODE_TYPE_FILTER_SEARCH_THRESHOLD = 7;
+
+export const hasActiveTypeFilter = ({
+ selectedTypeIds,
+ allTypeIds,
+}: {
+ selectedTypeIds: string[];
+ allTypeIds: string[];
+}): boolean =>
+ selectedTypeIds.length > 0 && selectedTypeIds.length < allTypeIds.length;
+
+/**
+ * The stored empty set means "no filter", which the panel shows as every row
+ * checked — otherwise an unfiltered search would render as an empty checklist.
+ */
+export const toPanelSelectedIds = ({
+ selectedTypeIds,
+ allTypeIds,
+}: {
+ selectedTypeIds: string[];
+ allTypeIds: string[];
+}): string[] => (selectedTypeIds.length === 0 ? allTypeIds : selectedTypeIds);
+
+/**
+ * Collapses both "all checked" and "none checked" back to the empty set, so the
+ * count badge and the search agree that neither is a filter.
+ */
+export const fromPanelSelectedIds = ({
+ panelSelectedIds,
+ allTypeIds,
+}: {
+ panelSelectedIds: string[];
+ allTypeIds: string[];
+}): string[] => {
+ if (
+ panelSelectedIds.length === 0 ||
+ panelSelectedIds.length === allTypeIds.length
+ ) {
+ return [];
+ }
+ return panelSelectedIds;
+};
+
+export const filterNodeTypesByQuery = (
+ nodeTypes: DiscourseNode[],
+ query: string,
+): DiscourseNode[] => {
+ const trimmedQuery = query.trim().toLowerCase();
+ if (!trimmedQuery) return nodeTypes;
+
+ return nodeTypes.filter((nodeType) =>
+ nodeType.name.toLowerCase().includes(trimmedQuery),
+ );
+};
diff --git a/apps/obsidian/src/utils/keyboardHints.ts b/apps/obsidian/src/utils/keyboardHints.ts
new file mode 100644
index 000000000..ec38800a8
--- /dev/null
+++ b/apps/obsidian/src/utils/keyboardHints.ts
@@ -0,0 +1,35 @@
+import { Platform } from "obsidian";
+
+export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape" | "Tab";
+
+// Obsidian shows glyphs on macOS and spelled-out words everywhere else.
+const MAC_SYMBOLS: Record = {
+ Mod: "⌘",
+ Alt: "⌥",
+ Shift: "⇧",
+ Enter: "↵",
+ Escape: "esc",
+ Tab: "⇥",
+};
+
+const NON_MAC_SYMBOLS: Record = {
+ Mod: "Ctrl",
+ Alt: "Alt",
+ Shift: "Shift",
+ Enter: "Enter",
+ Escape: "Esc",
+ Tab: "Tab",
+};
+
+/** Takes `isMacOS` so the non-mac branch can be checked without that platform. */
+export const formatHintKeys = ({
+ keys,
+ isMacOS,
+}: {
+ keys: HintKey[];
+ isMacOS: boolean;
+}): string[] =>
+ keys.map((key) => (isMacOS ? MAC_SYMBOLS : NON_MAC_SYMBOLS)[key]);
+
+export const getHintKeys = (keys: HintKey[]): string[] =>
+ formatHintKeys({ keys, isMacOS: Platform.isMacOS });
diff --git a/apps/obsidian/src/utils/nodeTypeBadge.ts b/apps/obsidian/src/utils/nodeTypeBadge.ts
new file mode 100644
index 000000000..a4ad3f258
--- /dev/null
+++ b/apps/obsidian/src/utils/nodeTypeBadge.ts
@@ -0,0 +1,40 @@
+import { DiscourseNode } from "~/types";
+import { getNodeTagColors } from "./colorUtils";
+
+const BADGE_TEXT_LENGTH = 3;
+
+export type NodeTypeBadge = {
+ text: string;
+ backgroundColor: string;
+ textColor: string;
+};
+
+export const formatNodeTypeBadgeText = (source: string): string =>
+ source.replace(/^#+/, "").trim().slice(0, BADGE_TEXT_LENGTH).toUpperCase();
+
+export const getNodeTypeBadge = ({
+ nodeType,
+ nodeIndex,
+}: {
+ nodeType: DiscourseNode;
+ nodeIndex: number;
+}): NodeTypeBadge => ({
+ text: formatNodeTypeBadgeText(nodeType.tag?.trim() || nodeType.name),
+ ...getNodeTagColors(nodeType, nodeIndex),
+});
+
+export const getFallbackNodeTypeBadge = (
+ title: string,
+): NodeTypeBadge | null => {
+ const [prefix, ...rest] = title.split(" - ");
+ if (!rest.length || !prefix) return null;
+
+ const text = formatNodeTypeBadgeText(prefix);
+ if (!text) return null;
+
+ return {
+ text,
+ backgroundColor: "var(--background-modifier-hover)",
+ textColor: "var(--text-muted)",
+ };
+};
diff --git a/apps/obsidian/src/utils/nodeTypeChipCompletion.ts b/apps/obsidian/src/utils/nodeTypeChipCompletion.ts
new file mode 100644
index 000000000..1d0151a00
--- /dev/null
+++ b/apps/obsidian/src/utils/nodeTypeChipCompletion.ts
@@ -0,0 +1,45 @@
+import { DiscourseNode } from "~/types";
+
+/** Ghost completion for the search modal's chips; prefix-only, as a substring match has no suffix to draw. */
+
+/** Exact match beats the first partial, so a name that prefixes another stays reachable. */
+export const getBestPrefixMatch = ({
+ nodeTypes,
+ query,
+ selectedTypeIds,
+}: {
+ nodeTypes: DiscourseNode[];
+ query: string;
+ selectedTypeIds: string[];
+}): DiscourseNode | null => {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) return null;
+
+ const selectedTypeIdSet = new Set(selectedTypeIds);
+ const matches = nodeTypes.filter(
+ (nodeType) =>
+ !selectedTypeIdSet.has(nodeType.id) &&
+ nodeType.name.toLowerCase().startsWith(normalizedQuery),
+ );
+ if (!matches.length) return null;
+
+ const exactMatch = matches.find(
+ (nodeType) => nodeType.name.toLowerCase() === normalizedQuery,
+ );
+ return exactMatch ?? matches[0] ?? null;
+};
+
+/** What is left to type; empty once the name is fully spelled, though still committable. */
+export const getCompletionSuffix = ({
+ bestPrefixMatch,
+ query,
+}: {
+ bestPrefixMatch: DiscourseNode | null;
+ query: string;
+}): string => {
+ if (!bestPrefixMatch) return "";
+ const trimmedQuery = query.trim();
+ const { name } = bestPrefixMatch;
+ if (name.toLowerCase() === trimmedQuery.toLowerCase()) return "";
+ return name.slice(trimmedQuery.length);
+};
diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts
index f72544360..de3e2eae6 100644
--- a/apps/obsidian/src/utils/registerCommands.ts
+++ b/apps/obsidian/src/utils/registerCommands.ts
@@ -3,6 +3,7 @@ import type DiscourseGraphPlugin from "~/index";
import { NodeTypeModal } from "~/components/NodeTypeModal";
import ModifyNodeModal from "~/components/ModifyNodeModal";
import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal";
+import { NodeSearchModal } from "~/components/NodeSearchModal";
import { ImportNodesModal } from "~/components/ImportNodesModal";
import { FeedbackModal } from "~/components/FeedbackModal";
import { convertPageToDiscourseNode, createDiscourseNode } from "./createNode";
@@ -137,6 +138,15 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => {
},
});
+ plugin.addCommand({
+ id: "open-node-search",
+ name: "Open node search",
+ hotkeys: [],
+ callback: () => {
+ new NodeSearchModal(plugin.app, plugin).open();
+ },
+ });
+
plugin.addCommand({
id: "import-nodes-from-another-space",
name: "Import nodes from another space",