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..e9d527e29 --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -0,0 +1,540 @@ +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 { + 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 { 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 pointerPositionRef = useRef<{ x: number; y: number } | null>(null); + + useEffect(() => { + const active = listRef.current?.children[activeIndex]; + active?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + // Scrolling drags rows under a stationary cursor, and the mouseenter that + // fires is not a choice. Compare coordinates rather than resetting a flag on + // every activation, so hovering from row to row still counts as a choice. + const hasPointerMoved = (event: MouseEvent): boolean => { + const previous = pointerPositionRef.current; + pointerPositionRef.current = { x: event.clientX, y: event.clientY }; + return ( + previous === null || + previous.x !== event.clientX || + previous.y !== event.clientY + ); + }; + + return ( + // No `aria-label` here: Obsidian renders one as a hover tooltip, which + // covers the results the moment the pointer enters the list. +
{ + hasPointerMoved(event); + }} + className="flex-1 overflow-y-auto" + > + {results.map((result, index) => ( +
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); + 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; 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]); + + 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; + }); + }; + + // 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 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. +
+ 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 && ( + + )} +
+ +
+ openActiveResult(openFileInNewTab)} + onOpenInSplit={() => openActiveResult(openFileInNewLeaf)} + /> +
+ ); +}; + +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; + // 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. + modalEl.addClasses([ + "dg-node-search-modal", + "h-[600px]", + "max-h-[80vh]", + "w-[900px]", + "max-w-[90vw]", + ]); + contentEl.addClasses(["flex", "h-full", "flex-col", "overflow-hidden"]); + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render( + + this.close()} /> + , + ); + } + + 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..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/keyboardHints.ts b/apps/obsidian/src/utils/keyboardHints.ts new file mode 100644 index 000000000..a14586ab3 --- /dev/null +++ b/apps/obsidian/src/utils/keyboardHints.ts @@ -0,0 +1,33 @@ +import { Platform } from "obsidian"; + +export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape"; + +// Obsidian shows glyphs on macOS and spelled-out words everywhere else. +const MAC_SYMBOLS: Record = { + Mod: "⌘", + Alt: "⌥", + Shift: "⇧", + Enter: "↵", + Escape: "esc", +}; + +const NON_MAC_SYMBOLS: Record = { + Mod: "Ctrl", + Alt: "Alt", + Shift: "Shift", + Enter: "Enter", + Escape: "Esc", +}; + +/** 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/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",