diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx new file mode 100644 index 000000000..4735ea142 --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -0,0 +1,472 @@ +import { + App, + Component, + MarkdownRenderer, + Modal, + Notice, + renderResults, + TFile, + type SearchResult, +} from "obsidian"; +import { + StrictMode, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactElement, +} from "react"; +import { createRoot, Root } from "react-dom/client"; +import type DiscourseGraphPlugin from "~/index"; +import { + QueryEngine, + rankDiscourseNodesByTitle, + type DiscourseNodeCandidate, + type RankedDiscourseNode, +} from "~/services/QueryEngine"; +import { + getNodeTypeBadge, + getFallbackNodeTypeBadge, + type NodeTypeBadge, +} from "~/utils/nodeTypeBadge"; +import { fetchUserNames } from "~/utils/importNodes"; +import { getLoggedInClient } from "~/utils/supabaseContext"; + +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 LOCAL_AUTHOR_NAME = "You"; +const UNRESOLVED_AUTHOR_NAME = "Unknown"; + +/** Frontmatter is untyped, so the raw value is narrowed by each caller. */ +const getFrontmatterAuthorId = (app: App, file: TFile): unknown => { + const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter as + | Record + | undefined; + return frontmatter?.authorId; +}; + +/** + * "You" belongs only to a note with no `authorId` at all — every note in an + * unsynced vault. An id that is present but unresolvable stays "Unknown" rather + * than claiming local authorship. `useAuthorNames` has already cached the + * names, so this stays synchronous. + */ +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; +}; + +/** + * `fetchUserNames` returns every person in the vault's spaces in one query, so + * this refreshes once per open when a name is missing rather than querying per + * author. + */ +const useAuthorNames = ({ + app, + plugin, + candidateState, +}: { + app: App; + plugin: DiscourseGraphPlugin; + candidateState: CandidateState; +}): Record => { + const [userNames, setUserNames] = useState(plugin.settings.userNames ?? {}); + + useEffect(() => { + if (candidateState.status !== "ready") 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 (!candidateState.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, candidateState]); + + return userNames; +}; + +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 ( +
+ Select a result to preview it. +
+ ); + } + + return ( +
+
+
{result.title}
+
+ {`Created ${formatTimestamp(file.stat.ctime)} · Modified ${formatTimestamp( + file.stat.mtime, + )} · ${authorName}`} +
+
+
+
+ ); +}; + +const HighlightedTitle = ({ + title, + match, +}: { + title: string; + match: SearchResult; +}): ReactElement => { + const titleRef = useRef(null); + + useEffect(() => { + const container = titleRef.current; + if (!container) return; + container.empty(); + renderResults(container, title, match); + return () => container.empty(); + }, [title, match]); + + return ( +
+ ); +}; + +const ResultList = ({ + results, + activeIndex, + onActivate, +}: { + results: SearchResultRow[]; + activeIndex: number; + onActivate: (index: number) => void; +}): ReactElement => { + const listRef = useRef(null); + const pointerMovedRef = useRef(false); + + useEffect(() => { + const active = listRef.current?.children[activeIndex]; + active?.scrollIntoView({ block: "nearest" }); + // Scrolling drags rows under a stationary cursor, and the mouseenter that + // fires is not a choice. Ignore hover until the pointer actually moves. + pointerMovedRef.current = false; + }, [activeIndex]); + + return ( +
(pointerMovedRef.current = true)} + className="flex-1 overflow-y-auto" + > + {results.map((result, index) => ( +
pointerMovedRef.current && 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, +}: { + plugin: DiscourseGraphPlugin; +}): ReactElement => { + const { app } = plugin; + const [candidateState, setCandidateState] = useState({ + status: "loading", + }); + const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const userNames = useAuthorNames({ app, plugin, candidateState }); + + 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; when + // F12 makes this 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]); + + const results = useMemo(() => { + if (candidateState.status !== "ready") return []; + return rankDiscourseNodesByTitle({ + candidates: candidateState.candidates, + query: debouncedQuery, + }) + .slice(0, MAX_VISIBLE_RESULTS) + .map((result) => ({ + ...result, + nodeType: nodeTypesById.get(result.nodeTypeId) ?? { + name: "Unknown type", + badge: getFallbackNodeTypeBadge(result.title), + }, + })); + }, [candidateState, debouncedQuery, nodeTypesById]); + + // 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; + }); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + // Otherwise the caret jumps to the start or end of the query. + event.preventDefault(); + moveActiveIndex(event.key === "ArrowDown" ? 1 : -1); + }; + + 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. +
+ setQuery(event.target.value)} + className="w-full" + /> +
+
+ {candidateState.status === "loading" && ( +
Loading discourse nodes…
+ )} + {candidateState.status === "error" && ( +
+ Could not load discourse nodes. {candidateState.message} +
+ )} + {candidateState.status === "ready" && results.length === 0 && ( +
No results
+ )} + {candidateState.status === "ready" && results.length > 0 && ( + + )} +
+ +
+
+ ); +}; + +export class NodeSearchModal extends Modal { + private plugin: DiscourseGraphPlugin; + private root: Root | null = null; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen() { + const { contentEl, modalEl } = this; + modalEl.addClass("dg-node-search-modal"); + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render( + + + , + ); + } + + onClose() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + this.contentEl.empty(); + } +} diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 66e243fe6..63949fec5 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3898,3 +3898,26 @@ kbd.tlui-kbd { background-color: var(--background-secondary); } } + +/* The default modal is too narrow for a result list beside a preview pane. + Responsive layout is an explicit non-goal, so this is a desktop-only size. */ +.dg-node-search-modal { + width: 900px; + max-width: 90vw; + height: 600px; + max-height: 80vh; +} + +.dg-node-search-modal .modal-content { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.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; +} 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/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",