diff --git a/src/editors/DocumentSlotView.tsx b/src/editors/DocumentSlotView.tsx index 8275dcf..1dea4fb 100644 --- a/src/editors/DocumentSlotView.tsx +++ b/src/editors/DocumentSlotView.tsx @@ -58,7 +58,7 @@ export function DocumentSlotView({ // Hooks must run before the early returns below (rules of hooks). Only the // `empty` state lacks a request — and it has no document to link from. const slotRequest = state.kind === 'empty' ? null : state.request; - const { vault, openWikiLink } = useWikiLinkDocument( + const { vault, headings, openWikiLink } = useWikiLinkDocument( slotRequest?.providerId ?? null, slotRequest?.entryId ?? null, ); @@ -106,6 +106,7 @@ export function DocumentSlotView({ mode={mode} onOpenLink={onOpenLink} wikiLinkVault={vault} + wikiLinkHeadings={headings} onOpenWikiLink={openWikiLink} viewSnapshot={viewSnapshot} onViewSnapshot={onViewSnapshot} diff --git a/src/editors/automerge/automergeDocumentEditor.tsx b/src/editors/automerge/automergeDocumentEditor.tsx index daaf73f..23649d3 100644 --- a/src/editors/automerge/automergeDocumentEditor.tsx +++ b/src/editors/automerge/automergeDocumentEditor.tsx @@ -28,7 +28,10 @@ import { textileMarkdown } from './markdownExtensions'; import { readDocContent, updateTextContent } from '../../vault/utils/text-diff'; import { livePreview } from './livePreview'; import { editorModeFacet } from './livePreview/mode'; -import { wikiLinkVaultFacet } from './livePreview/wikiLinkVault'; +import { + wikiLinkHeadingsFacet, + wikiLinkVaultFacet, +} from './livePreview/wikiLinkVault'; import { wikiLinkCompletion } from './livePreview/wikiLinkComplete'; import { listEditingKeymap } from './livePreview/listKeymap'; import { @@ -62,6 +65,7 @@ export function AutomergeDocumentEditor({ mode = 'edit', onOpenLink, wikiLinkVault = null, + wikiLinkHeadings = null, onOpenWikiLink, viewSnapshot = null, onViewSnapshot, @@ -280,7 +284,10 @@ export function AutomergeDocumentEditor({ // by the [mode] effect below. Seeded with the mount-time mode. modeCompartment.current.of(modeExtension(mode)), // Vault index for wikilink resolution, reconfigured by the effect below. - wikiLinkCompartment.current.of(wikiLinkVaultFacet.of(wikiLinkVault)), + wikiLinkCompartment.current.of([ + wikiLinkVaultFacet.of(wikiLinkVault), + wikiLinkHeadingsFacet.of(wikiLinkHeadings), + ]), // Change listener updateListener, ], @@ -337,11 +344,12 @@ export function AutomergeDocumentEditor({ const view = viewRef.current; if (!view) return; view.dispatch({ - effects: wikiLinkCompartment.current.reconfigure( + effects: wikiLinkCompartment.current.reconfigure([ wikiLinkVaultFacet.of(wikiLinkVault), - ), + wikiLinkHeadingsFacet.of(wikiLinkHeadings), + ]), }); - }, [wikiLinkVault]); + }, [wikiLinkVault, wikiLinkHeadings]); // A reveal arriving while this editor is already mounted — following a // wikilink to the note you are already reading, or to one open in another diff --git a/src/editors/automerge/automergeEditor.css b/src/editors/automerge/automergeEditor.css index 46fbd4d..2cc65a7 100644 --- a/src/editors/automerge/automergeEditor.css +++ b/src/editors/automerge/automergeEditor.css @@ -211,6 +211,17 @@ color: var(--semantic-primary-fg); } +/* Heading level badge (`H2`) leading a heading option. Fixed width so the + labels beside them line up regardless of level. */ +.automerge-editor-container .cm-editor .cm-wikilink-level { + display: inline-block; + min-width: 1.9em; + margin-right: 0.35em; + font-size: 0.85em; + font-variant-numeric: tabular-nums; + opacity: 0.5; +} + /* The containing folder, shown after the note name. CM italicises this at (0,2,0), which our un-prefixed selector merely tied; (0,3,0) settles it. */ .automerge-editor-container .cm-editor .cm-completionDetail { diff --git a/src/editors/automerge/livePreview/wikiLinkComplete.test.ts b/src/editors/automerge/livePreview/wikiLinkComplete.test.ts index 3193ae7..8a6b541 100644 --- a/src/editors/automerge/livePreview/wikiLinkComplete.test.ts +++ b/src/editors/automerge/livePreview/wikiLinkComplete.test.ts @@ -1,10 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { CompletionContext } from '@codemirror/autocomplete'; +import { + CompletionContext, + type CompletionResult, +} from '@codemirror/autocomplete'; +import type { Extension } from '@codemirror/state'; import { wikiLinkCompletionSource } from './wikiLinkComplete'; -import { wikiLinkVaultFacet } from './wikiLinkVault'; +import { wikiLinkHeadingsFacet, wikiLinkVaultFacet } from './wikiLinkVault'; import { makeState } from './testSupport'; -import type { WikiLinkVaultView } from '../../types'; +import type { DocHeading } from '../../../wikilinks/headings'; +import type { WikiLinkHeadingSource, WikiLinkVaultView } from '../../types'; const fakeVault = (labels: string[]): WikiLinkVaultView => ({ resolve: () => null, @@ -16,58 +21,211 @@ const fakeVault = (labels: string[]): WikiLinkVaultView => ({ })), }); -/** Complete at the end of `doc`, with an optional vault attached. */ -function completeAt(doc: string, labels?: string[]) { - const extra = labels ? wikiLinkVaultFacet.of(fakeVault(labels)) : []; +/** A vault where every listed name resolves, so anchors can be looked up. */ +const resolvingVault = (existing: string[]): WikiLinkVaultView => ({ + resolve: (target) => ({ + entryId: `/vault/${target}.md`, + title: `${target}.md`, + exists: existing.includes(target), + }), + candidates: () => [], +}); + +const heading = (text: string, level = 2, line = 0): DocHeading => ({ + text, + level, + line, +}); + +/** A heading source backed by an in-memory map, no filesystem. */ +const fakeHeadings = ( + byEntryId: Record, +): WikiLinkHeadingSource => ({ + load: (entryId) => Promise.resolve(byEntryId[entryId] ?? []), +}); + +function completeAt(doc: string, extra: Extension = []) { const state = makeState(doc, doc.length, extra); return wikiLinkCompletionSource( new CompletionContext(state, doc.length, false), ); } -describe('wikiLinkCompletionSource', () => { +/** Assert a result arrived synchronously — the same-document path must not await. */ +function expectSync( + result: ReturnType, +): CompletionResult { + expect(result).not.toBeNull(); + expect(result).not.toBeInstanceOf(Promise); + return result as CompletionResult; +} + +const withVault = (labels: string[]) => + wikiLinkVaultFacet.of(fakeVault(labels)); + +describe('note-name completion', () => { it('fires after `[[` and anchors just past the brackets', () => { - const result = completeAt('see [[', ['Daily']); - expect(result?.from).toBe(6); - expect(result?.options.map((o) => o.label)).toEqual(['Daily']); + const result = expectSync(completeAt('see [[', withVault(['Daily']))); + expect(result.from).toBe(6); + expect(result.options.map((o) => o.label)).toEqual(['Daily']); }); it('keeps offering candidates while a partial name is typed', () => { - const result = completeAt('see [[Dai', ['Daily', 'Notes']); + const result = expectSync( + completeAt('see [[Dai', withVault(['Daily', 'Notes'])), + ); // Filtering is CodeMirror's job; the source returns the full candidate set. - expect(result?.from).toBe(6); - expect(result?.options).toHaveLength(2); + expect(result.from).toBe(6); + expect(result.options).toHaveLength(2); }); it('carries the containing folder as detail', () => { - expect(completeAt('[[', ['Daily'])?.options[0].detail).toBe('/'); + const result = expectSync(completeAt('[[', withVault(['Daily']))); + expect(result.options[0].detail).toBe('/'); }); - it('stays valid for further plain-target keystrokes', () => { - const validFor = completeAt('[[', ['Daily'])?.validFor as RegExp; + it('stays valid for further plain-target keystrokes but not past a `#`', () => { + const result = expectSync(completeAt('[[', withVault(['Daily']))); + const validFor = result.validFor as RegExp; expect(validFor.test('Dai')).toBe(true); expect(validFor.test('Dai#')).toBe(false); }); - it('backs off once the user types `#` or `|`', () => { - expect(completeAt('[[Foo#', ['Daily'])).toBeNull(); - expect(completeAt('[[Foo|', ['Daily'])).toBeNull(); + it('returns null when the index has not loaded, or is empty', () => { + expect(completeAt('see [[')).toBeNull(); + expect(completeAt('see [[', withVault([]))).toBeNull(); + }); + + it('does not fire outside a `[[`, or once the link is closed', () => { + expect(completeAt('just text', withVault(['Daily']))).toBeNull(); + expect(completeAt('[single', withVault(['Daily']))).toBeNull(); + expect(completeAt('[[Foo]]', withVault(['Daily']))).toBeNull(); }); +}); - it('returns null when the vault index has not loaded', () => { - expect(completeAt('see [[')).toBeNull(); +describe('heading completion for the current document', () => { + const doc = '# Alpha\ntext\n## Beta\n\n[[#'; + + it('offers this document’s headings synchronously, with no source attached', () => { + // The whole point of the `[[#` branch: the headings are already in state, + // so the popup must not have to wait on a read. + const result = expectSync(completeAt(doc)); + expect(result.options.map((o) => o.label)).toEqual(['Alpha', 'Beta']); + }); + + it('anchors just past the `#`', () => { + const result = expectSync(completeAt(doc)); + expect(result.from).toBe(doc.length); + }); + + it('tags each option with its heading level', () => { + const result = expectSync(completeAt(doc)); + expect(result.options.map((o) => o.type)).toEqual([ + 'heading-1', + 'heading-2', + ]); + }); + + it('keeps offering the full set while an anchor is typed', () => { + const result = expectSync(completeAt('# Alpha\n## Beta\n[[#Al')); + expect(result.options).toHaveLength(2); }); - it('returns null when the vault is empty', () => { - expect(completeAt('see [[', [])).toBeNull(); + it('stays valid across a further `#` but closes on `|`', () => { + const result = expectSync(completeAt(doc)); + const validFor = result.validFor as RegExp; + expect(validFor.test('A#B')).toBe(true); + expect(validFor.test('A|B')).toBe(false); }); - it('does not fire outside a `[[`', () => { - expect(completeAt('just text', ['Daily'])).toBeNull(); - expect(completeAt('[single', ['Daily'])).toBeNull(); + it('returns null when the document has no headings', () => { + expect(completeAt('no headings here\n[[#')).toBeNull(); }); +}); + +describe('heading completion for another document', () => { + const extra = [ + wikiLinkVaultFacet.of(resolvingVault(['Notes'])), + wikiLinkHeadingsFacet.of( + fakeHeadings({ + '/vault/Notes.md': [heading('Intro', 1), heading('Details', 3)], + }), + ), + ]; + + it('resolves the target and loads its headings', async () => { + const result = await completeAt('[[Notes#', extra); + expect(result?.options.map((o) => o.label)).toEqual(['Intro', 'Details']); + expect(result?.options.map((o) => o.type)).toEqual([ + 'heading-1', + 'heading-3', + ]); + }); + + it('anchors just past the `#`, not the start of the target', async () => { + const doc = '[[Notes#Int'; + const result = await completeAt(doc, extra); + expect(result?.from).toBe(doc.indexOf('#') + 1); + }); + + it('tolerates whitespace around the target', async () => { + const result = await completeAt('[[ Notes #', extra); + expect(result?.options).toHaveLength(2); + }); + + it('returns null for a note that does not exist yet', async () => { + expect(await completeAt('[[Missing#', extra)).toBeNull(); + }); + + it('returns null when no heading source is attached', async () => { + const noSource = wikiLinkVaultFacet.of(resolvingVault(['Notes'])); + expect(await completeAt('[[Notes#', noSource)).toBeNull(); + }); + + it('closes the popup rather than crashing when the file cannot be read', async () => { + const failing = [ + wikiLinkVaultFacet.of(resolvingVault(['Notes'])), + wikiLinkHeadingsFacet.of({ + load: () => Promise.reject(new Error('EACCES')), + }), + ]; + expect(await completeAt('[[Notes#', failing)).toBeNull(); + }); +}); + +describe('heading options that cannot be linked to', () => { + const optionsFor = async (headings: DocHeading[]) => { + const extra = [ + wikiLinkVaultFacet.of(resolvingVault(['N'])), + wikiLinkHeadingsFacet.of(fakeHeadings({ '/vault/N.md': headings })), + ]; + return (await completeAt('[[N#', extra))?.options ?? []; + }; + + it('drops headings containing brackets or a pipe', async () => { + const options = await optionsFor([ + heading('Fine'), + heading('Has [brackets]'), + heading('Has | pipe'), + ]); + expect(options.map((o) => o.label)).toEqual(['Fine']); + }); + + it('drops duplicates, since only the first is reachable', async () => { + // `findHeading` resolves to the first in document order, so offering the + // second would navigate somewhere other than where it points. + const options = await optionsFor([ + heading('Same', 2, 0), + heading(' same ', 3, 5), + heading('Other'), + ]); + expect(options.map((o) => o.label)).toEqual(['Same', 'Other']); + }); +}); - it('does not fire once the link is closed', () => { - expect(completeAt('[[Foo]]', ['Daily'])).toBeNull(); +describe('alias', () => { + it('closes the popup once a `|` is typed', () => { + expect(completeAt('[[Foo|', withVault(['Daily']))).toBeNull(); + expect(completeAt('[[Foo#Bar|x', withVault(['Daily']))).toBeNull(); }); }); diff --git a/src/editors/automerge/livePreview/wikiLinkComplete.ts b/src/editors/automerge/livePreview/wikiLinkComplete.ts index 3bb6245..74c5a6e 100644 --- a/src/editors/automerge/livePreview/wikiLinkComplete.ts +++ b/src/editors/automerge/livePreview/wikiLinkComplete.ts @@ -22,7 +22,9 @@ import { type CompletionResult, } from '@codemirror/autocomplete'; -import { wikiLinkVaultFacet } from './wikiLinkVault'; +import { wikiLinkHeadingsFacet, wikiLinkVaultFacet } from './wikiLinkVault'; +import { headingsIn } from './headingReveal'; +import { normalizeAnchor, type DocHeading } from '../../../wikilinks/headings'; /** `[[` followed by anything that is still a plain target. */ const WIKI_TRIGGER = /\[\[[^[\]\n]*$/; @@ -30,6 +32,13 @@ const WIKI_TRIGGER = /\[\[[^[\]\n]*$/; /** Everything still inside the target while the popup filters client-side. */ const STILL_A_TARGET = /^[^[\]\n#|]*$/; +/** + * Everything still inside an anchor. Further `#`s are allowed — the parser + * splits on the *first* one, so `[[Note#A#B]]` has anchor `A#B` — while `|` + * ends the anchor and closes the popup. + */ +const STILL_AN_ANCHOR = /^[^[\]\n|]*$/; + /** * Insert the completion plus its closing `]]`, reusing a `]]` the user has * already typed rather than leaving `[[Note]]]]` behind. @@ -49,17 +58,84 @@ function applyCandidate( }); } +/** + * Build the heading options for an anchor. `from` is just past the `#`. + * + * Two filters, both about not offering something unreachable: a heading + * containing `[`, `]` or `|` cannot appear inside `[[…]]` at all, and only the + * first of a set of duplicate headings is reachable — `findHeading` resolves to + * the first in document order, so offering the rest would be a lie. + */ +function anchorResult( + headings: readonly DocHeading[], + from: number, +): CompletionResult | null { + const seen = new Set(); + const options: Completion[] = []; + for (const heading of headings) { + if (/[[\]|]/.test(heading.text)) continue; + const key = normalizeAnchor(heading.text); + if (seen.has(key)) continue; + seen.add(key); + options.push({ + label: heading.text, + // `icons: false` means `type` renders nothing on its own; it is a + // type-safe carrier for the level badge added via `addToOptions`. + type: `heading-${heading.level}`, + apply: applyCandidate, + }); + } + if (options.length === 0) return null; + return { from, options, validFor: STILL_AN_ANCHOR }; +} + +/** Complete the `#anchor` part of `[[Target#anchor`. */ +function completeAnchor( + context: CompletionContext, + target: string, + from: number, +): CompletionResult | Promise | null { + // `[[#Heading]]` refers to this very document, whose headings are already in + // hand — no read, no promise, so the popup never blinks for the common case. + if (target === '') return anchorResult(headingsIn(context.state.doc), from); + + const vault = context.state.facet(wikiLinkVaultFacet); + const resolution = vault?.resolve(target); + // A note that does not exist yet has no headings to offer. + if (!resolution?.exists) return null; + + const source = context.state.facet(wikiLinkHeadingsFacet); + if (!source) return null; + + return source + .load(resolution.entryId) + .then((headings) => + // The user kept typing or dismissed the popup while we were reading. + context.aborted ? null : anchorResult(headings, from), + ) + .catch(() => null); // unreadable file: close the popup, don't crash +} + export function wikiLinkCompletionSource( context: CompletionContext, -): CompletionResult | null { +): CompletionResult | Promise | null { const match = context.matchBefore(WIKI_TRIGGER); if (!match) return null; const from = match.from + 2; const typed = context.state.sliceDoc(from, context.pos); - // Anchor and alias completion are follow-ups; once the user types `#` or `|` - // they are past the note name, so get out of the way. - if (typed.includes('#') || typed.includes('|')) return null; + // `|` ends the link part; everything after it is free-form display text. + // Alias completion is a follow-up, so get out of the way. + if (typed.includes('|')) return null; + + const hash = typed.indexOf('#'); + if (hash >= 0) { + return completeAnchor( + context, + typed.slice(0, hash).trim(), + from + hash + 1, + ); + } const vault = context.state.facet(wikiLinkVaultFacet); if (!vault) return null; // index still loading @@ -75,9 +151,26 @@ export function wikiLinkCompletionSource( return { from, options, validFor: STILL_A_TARGET }; } +/** + * A leading `H2`-style badge on heading options. Returns null for note-name + * options, which CodeMirror skips — so one renderer serves both branches. + */ +function renderHeadingLevel(completion: Completion): Node | null { + const match = /^heading-([1-6])$/.exec(completion.type ?? ''); + if (!match) return null; + const el = document.createElement('span'); + el.className = `cm-wikilink-level cm-wikilink-level-${match[1]}`; + el.textContent = `H${match[1]}`; + // The label already announces the heading; the badge is a visual aid. + el.setAttribute('aria-hidden', 'true'); + return el; +} + export const wikiLinkCompletion: Extension = [ autocompletion({ override: [wikiLinkCompletionSource], + // Position 15 puts the level badge ahead of the label (50) and detail (80). + addToOptions: [{ render: renderHeadingLevel, position: 15 }], // Installed at highest precedence below instead, so Enter beats the list // keymap. Leaving this true would register the same bindings twice. defaultKeymap: false, diff --git a/src/editors/automerge/livePreview/wikiLinkVault.ts b/src/editors/automerge/livePreview/wikiLinkVault.ts index 943bc9b..f103da1 100644 --- a/src/editors/automerge/livePreview/wikiLinkVault.ts +++ b/src/editors/automerge/livePreview/wikiLinkVault.ts @@ -12,9 +12,19 @@ */ import { Facet } from '@codemirror/state'; -import type { WikiLinkVaultView } from '../../types'; +import type { WikiLinkHeadingSource, WikiLinkVaultView } from '../../types'; export const wikiLinkVaultFacet = Facet.define< WikiLinkVaultView | null, WikiLinkVaultView | null >({ combine: (values) => values[values.length - 1] ?? null }); + +/** + * Headings of *other* notes, for `[[Note#` completion. Separate from the vault + * facet because it is asynchronous — the file has to be read — while the vault + * view is contractually synchronous. + */ +export const wikiLinkHeadingsFacet = Facet.define< + WikiLinkHeadingSource | null, + WikiLinkHeadingSource | null +>({ combine: (values) => values[values.length - 1] ?? null }); diff --git a/src/editors/types.ts b/src/editors/types.ts index 9e29b13..634bc4f 100644 --- a/src/editors/types.ts +++ b/src/editors/types.ts @@ -4,6 +4,8 @@ */ import type { ComponentType } from 'react'; +import type { DocHeading } from '../wikilinks/headings'; + /** Sync/editor backend id. */ export type EditorKind = 'automerge'; @@ -80,6 +82,18 @@ export type WikiLinkVaultView = { candidates(): readonly WikiLinkCandidate[]; }; +/** + * Headings of notes that are *not* open, for `[[Note#` completion. + * + * Explicitly asynchronous and deliberately separate from `WikiLinkVaultView`, + * whose synchronous contract the decoration builders depend on. Keyed by + * `entryId` rather than by target text, so it duplicates none of the resolution + * logic in `wikiLinkIndex` — the caller resolves first, then asks here. + */ +export type WikiLinkHeadingSource = { + load(entryId: string): Promise; +}; + /** A wikilink the user activated, already resolved against the vault. */ export type WikiLinkActivation = WikiLinkResolution & { /** `#heading` text without the `#`, or null. Carried but not yet acted on. */ @@ -107,6 +121,8 @@ export type EditorProps = { * vault index is still loading. */ wikiLinkVault?: WikiLinkVaultView | null; + /** Headings of other notes, for `[[Note#` completion. Null while unavailable. */ + wikiLinkHeadings?: WikiLinkHeadingSource | null; /** * Called when a wikilink is activated, already resolved. Unlike `onOpenLink` * the editor cannot resolve this itself, so the app both resolves it (via diff --git a/src/filesystem/providers/localFilesystemProvider.ts b/src/filesystem/providers/localFilesystemProvider.ts index 85c02fb..25390a2 100644 --- a/src/filesystem/providers/localFilesystemProvider.ts +++ b/src/filesystem/providers/localFilesystemProvider.ts @@ -198,6 +198,10 @@ export class LocalFilesystemProvider implements FileSystemProvider { return entryId; } + async readFileText(entryId: string): Promise { + return this.fs.readFile(entryId); + } + // --- Local directory scanning (via IPC) --- private async scanTree(dirPath: string): Promise { diff --git a/src/filesystem/types.ts b/src/filesystem/types.ts index bc6a90c..18494a5 100644 --- a/src/filesystem/types.ts +++ b/src/filesystem/types.ts @@ -91,6 +91,17 @@ export type FileSystemProvider = { * Used for "Copy Path" to clipboard. */ getPath(entryId: string): string; + /** + * Read a text file's contents, for cheap read-only inspection of notes that + * are not open — today, offering their headings in the `[[` typeahead. + * + * Deliberately not `documents.openDocument`: for a synced vault that pushes + * the file to the remote and acquires a live session per call, which would be + * catastrophic to do per keystroke. A synced vault mirrors to disk on a ~1s + * debounce, so this can lag edits made elsewhere by about a second — fine for + * a typeahead. + */ + readFileText(entryId: string): Promise; /** * When implemented, called when a listed entry's display name changes * (e.g. habitat doc heading edits). Used to keep the sidebar in sync. diff --git a/src/wikilinks/WikiLinkVaultContext.tsx b/src/wikilinks/WikiLinkVaultContext.tsx index 476ae74..859884e 100644 --- a/src/wikilinks/WikiLinkVaultContext.tsx +++ b/src/wikilinks/WikiLinkVaultContext.tsx @@ -26,7 +26,11 @@ 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 type { + WikiLinkActivation, + WikiLinkHeadingSource, + WikiLinkVaultView, +} from '../editors/types'; import { buildWikiLinkIndex, makeWikiLinkVaultView, @@ -34,6 +38,8 @@ import { } from './wikiLinkIndex'; import { baseName, dirName, relPathFrom } from './paths'; import { initialNoteContent } from './noteTemplate'; +import { extractHeadings } from './headings'; +import { headingsQueryKey } from './headingsQuery'; type IndexMap = ReadonlyMap; @@ -147,6 +153,7 @@ export function useWikiLinkDocument( entryId: string | null, ): { vault: WikiLinkVaultView | null; + headings: WikiLinkHeadingSource | null; openWikiLink: (activation: WikiLinkActivation) => void; } { const ctx = useContext(WikiLinkVaultCtx); @@ -161,12 +168,11 @@ export function useWikiLinkDocument( 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) return; - const provider = providers?.find((p) => p.id === providerId); - if (!provider) return; + if (!providerId || !onOpenEntry || !provider) return; if (activation.exists) { onOpenEntry( @@ -227,8 +233,28 @@ export function useWikiLinkDocument( } })(); }, - [providerId, providers, onOpenEntry, index, queryClient], + [providerId, provider, onOpenEntry, index, queryClient], ); - return { vault, openWikiLink }; + // 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 }; } diff --git a/src/wikilinks/headingsQuery.ts b/src/wikilinks/headingsQuery.ts new file mode 100644 index 0000000..a49754f --- /dev/null +++ b/src/wikilinks/headingsQuery.ts @@ -0,0 +1,11 @@ +/** + * The React Query key for one note's headings. + * + * Shared for the same reason as `treeQuery.ts`: every observer must hit one + * cache entry, so repeatedly typing `#` against the same note reads the cache + * instead of the disk. The *parsed* headings are cached rather than the file + * body, so nothing re-parses and whole files are not held in memory. + */ +export function headingsQueryKey(providerId: string, entryId: string) { + return ['headings', providerId, entryId] as const; +}