/** * The React seam that feeds vault knowledge to the editor's wikilink support. * * Holds one `WikiLinkIndex` per filesystem provider, derived from the *same* * React Query cache entry the sidebar uses (so the recursive `getTree()` walk * is fetched once, not once per observer), and hands each open document a * `WikiLinkVaultView` bound to that document plus a handler that opens — or * creates — whatever a wikilink points at. * * Deliberately attached at `DocumentSlotView` rather than drilled through the * tiling tree: link activation ends in the global `onOpenEntry` channel, and * the slot already knows its own `providerId`/`entryId`. That also means Zen * gets wikilinks without any workspace-specific wiring. */ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { FileSystemProvider } from '../filesystem/types'; import { treeQueryKey } from '../filesystem/treeQuery'; import type { OpenDocRequest } from '../workspaces/workspace'; import type { WikiLinkActivation, WikiLinkHeadingSource, WikiLinkVaultView, } from '../editors/types'; import { buildWikiLinkIndex, makeWikiLinkVaultView, type WikiLinkIndex, } from './wikiLinkIndex'; import { baseName, dirName, relPathFrom } from './paths'; import { initialNoteContent } from './noteTemplate'; import { extractHeadings } from './headings'; import { headingsQueryKey } from './headingsQuery'; type IndexMap = ReadonlyMap; /** The shell's open channel; `anchor` scrolls to a heading for this open only. */ type OpenEntry = ( request: OpenDocRequest, options?: { anchor?: string | null }, ) => void; type ContextValue = { indexes: IndexMap; providers: readonly FileSystemProvider[]; onOpenEntry: OpenEntry; }; const WikiLinkVaultCtx = createContext(null); /** * Keeps one provider's index current. A child component per provider because * hooks cannot be called in a loop — the same shape `AppSidebar` uses for its * per-provider sections. */ function ProviderIndexLoader({ provider, onIndex, }: { provider: FileSystemProvider; onIndex: (providerId: string, index: WikiLinkIndex | null) => void; }) { const treeQuery = useQuery({ queryKey: treeQueryKey(provider.id), queryFn: () => provider.getTree(), enabled: provider.isReady(), // Matches the sidebar. Observers of one key share a single fetch at the // shortest interval, so this costs nothing when the sidebar is mounted and // keeps non-selected providers fresh when it is not. refetchInterval: 3_000, }); const tree = treeQuery.data; // React Query's structural sharing keeps `data`'s identity stable across a // deep-equal refetch, so this only recomputes on a real file change — which // is what stops the 3s poll from churning decorations. const index = useMemo( () => (tree ? buildWikiLinkIndex(provider.rootId, tree) : null), [tree, provider.rootId], ); useEffect(() => { onIndex(provider.id, index); }, [onIndex, provider.id, index]); useEffect(() => { return () => onIndex(provider.id, null); }, [onIndex, provider.id]); return null; } export function WikiLinkVaultProvider({ providers, onOpenEntry, children, }: { providers: readonly FileSystemProvider[]; onOpenEntry: OpenEntry; children: ReactNode; }) { const [indexes, setIndexes] = useState(() => new Map()); const handleIndex = useCallback( (providerId: string, index: WikiLinkIndex | null) => { setIndexes((prev) => { if ((prev.get(providerId) ?? null) === index) return prev; const next = new Map(prev); if (index) next.set(providerId, index); else next.delete(providerId); return next; }); }, [], ); const value = useMemo( () => ({ indexes, providers, onOpenEntry }), [indexes, providers, onOpenEntry], ); return ( {providers.map((provider) => ( ))} {children} ); } /** * Vault view + activation handler for one open document. * * Returns a `null` view until that provider's index has loaded, which the * editor renders as "unknown" rather than "unresolved". */ export function useWikiLinkDocument( providerId: string | null, entryId: string | null, ): { vault: WikiLinkVaultView | null; headings: WikiLinkHeadingSource | null; openWikiLink: (activation: WikiLinkActivation) => void; } { const ctx = useContext(WikiLinkVaultCtx); const queryClient = useQueryClient(); const index = providerId ? (ctx?.indexes.get(providerId) ?? null) : null; const vault = useMemo( () => (index && entryId ? makeWikiLinkVaultView(index, entryId) : null), [index, entryId], ); const providers = ctx?.providers; const onOpenEntry = ctx?.onOpenEntry; const provider = providers?.find((p) => p.id === providerId) ?? null; const openWikiLink = useCallback( (activation: WikiLinkActivation) => { if (!providerId || !onOpenEntry || !provider) return; if (activation.exists) { onOpenEntry( { providerId, entryId: activation.entryId, title: activation.title, }, { anchor: activation.anchor }, ); return; } // Obsidian-style create-on-click. Going through `createFile` explicitly // rather than letting the document provider create on open: a missing // file's ENOENT loses its `code` across the Electron IPC boundary, so a // local vault would silently open a buffer backed by nothing on disk and // a synced vault would hard-fail. void (async () => { const dir = dirName(activation.entryId); try { const relDir = relPathFrom(provider.rootId, dir); if (relDir && !index?.dirs.has(relDir)) { await provider.createDirectory(relDir, provider.rootId); } const fileName = baseName(activation.entryId); const entry = await provider.createFile( dir, fileName, initialNoteContent(fileName), ); // Open the id the provider returned, not the one we predicted. onOpenEntry( { providerId, entryId: entry.id, title: entry.name }, { anchor: activation.anchor }, ); } catch (error) { // Most likely a lost race: someone created exactly this file first. console.error( '[wikilink] could not create', activation.entryId, error, ); onOpenEntry( { providerId, entryId: activation.entryId, title: activation.title, }, { anchor: activation.anchor }, ); } finally { // Refresh now so the link stops rendering unresolved immediately // rather than after the next poll. void queryClient.invalidateQueries({ queryKey: treeQueryKey(providerId), }); } })(); }, [providerId, provider, onOpenEntry, index, queryClient], ); // Headings of notes that are NOT open, for `[[Note#` completion. Reads the // file directly rather than opening a document: for a synced vault // `openDocument` pushes to the remote and acquires a session, which would be // ruinous per keystroke. const headings = useMemo(() => { if (!providerId || !provider) return null; return { load: (entryId) => queryClient.fetchQuery({ queryKey: headingsQueryKey(providerId, entryId), queryFn: async () => extractHeadings((await provider.readFileText(entryId)).split('\n')), // A note's headings don't churn per keystroke; this keeps repeated // `#` typing off the IPC path without going noticeably stale. staleTime: 2_000, gcTime: 60_000, }), }; }, [providerId, provider, queryClient]); return { vault, headings, openWikiLink }; }