Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224/** * 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<string, WikiLinkIndex>;
type ContextValue = { indexes: IndexMap; providers: readonly FileSystemProvider[]; onOpenEntry: (request: OpenDocRequest) => void;};
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: (request: OpenDocRequest) => void; 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; 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 };}