Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261/** * 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<string, WikiLinkIndex>;
/** 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<ContextValue | null>(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<IndexMap>(() => 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<ContextValue>( () => ({ indexes, providers, onOpenEntry }), [indexes, providers, onOpenEntry], );
return ( <WikiLinkVaultCtx.Provider value={value}> {providers.map((provider) => ( <ProviderIndexLoader key={provider.id} provider={provider} onIndex={handleIndex} /> ))} {children} </WikiLinkVaultCtx.Provider> );}
/** * 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<WikiLinkHeadingSource | null>(() => { 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 };}