/** * 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, WikiLinkVaultView } from '../editors/types'; import { buildWikiLinkIndex, makeWikiLinkVaultView, type WikiLinkIndex, } from './wikiLinkIndex'; import { baseName, dirName, relPathFrom } from './paths'; import { initialNoteContent } from './noteTemplate'; type IndexMap = ReadonlyMap; type ContextValue = { indexes: IndexMap; providers: readonly FileSystemProvider[]; onOpenEntry: (request: OpenDocRequest) => void; }; 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: (request: OpenDocRequest) => void; 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; 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 openWikiLink = useCallback( (activation: WikiLinkActivation) => { if (!providerId || !onOpenEntry) return; const provider = providers?.find((p) => p.id === providerId); if (!provider) return; if (activation.exists) { onOpenEntry({ providerId, entryId: activation.entryId, title: activation.title, }); 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, }); } 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, }); } finally { // Refresh now so the link stops rendering unresolved immediately // rather than after the next poll. void queryClient.invalidateQueries({ queryKey: treeQueryKey(providerId), }); } })(); }, [providerId, providers, onOpenEntry, index, queryClient], ); return { vault, openWikiLink }; }