From 176f46029ac725febc60b9b40638e7b57f2ccc38 Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Sat, 1 Aug 2026 15:45:38 -0400 Subject: [PATCH] Complete headings after `[[Note#` and `[[#` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `#` closed the popup, so anchors had to be typed from memory and exactly right. Now `[[#` offers the current document's headings and `[[Note#` offers that note's, each labelled with its level. Two branches, because they have very different costs: - `[[#` (this document) is synchronous and does no I/O — the headings are already in the editor state. That path must never blink, since same-document anchors are the common case. - `[[Note#` reads the target file. `CompletionSource` may return a promise, so the source awaits it; `context.aborted` drops a response the user has already typed past, and a read failure closes the popup rather than crashing. Reading is a new `FileSystemProvider.readFileText`, one method on `LocalFilesystemProvider` that the synced provider inherits — the IPC already existed end to end. 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 ruinous per keystroke. Parsed headings are cached under a shared React Query key, so repeatedly typing `#` against one note hits the cache. Headings for other notes ride a separate `wikiLinkHeadingsFacet` rather than being folded into `WikiLinkVaultView`, whose documented synchronous contract the decoration builders depend on. It shares the existing compartment, so there is still one compartment and one reconfigure effect. Options that could not be linked to are filtered out: a heading containing `[`, `]` or `|` cannot appear inside `[[…]]` at all, and duplicate headings are deduped because `findHeading` resolves to the first in document order — offering the rest would navigate somewhere other than where they point. The level badge rides on `Completion.type`, which is inert here since `icons: false`, rendered by an `addToOptions` entry at position 15 so it leads the label (50) and detail (80). The renderer returns null for note-name options, which CodeMirror skips, so one renderer serves both branches. `|` closes the popup in both branches; alias completion remains out of scope. Co-Authored-By: Claude Opus 5 --- src/editors/DocumentSlotView.tsx | 3 +- .../automerge/automergeDocumentEditor.tsx | 18 +- src/editors/automerge/automergeEditor.css | 11 + .../livePreview/wikiLinkComplete.test.ts | 214 +++++++++++++++--- .../automerge/livePreview/wikiLinkComplete.ts | 103 ++++++++- .../automerge/livePreview/wikiLinkVault.ts | 12 +- src/editors/types.ts | 16 ++ .../providers/localFilesystemProvider.ts | 4 + src/filesystem/types.ts | 11 + src/wikilinks/WikiLinkVaultContext.tsx | 38 +++- src/wikilinks/headingsQuery.ts | 11 + 11 files changed, 395 insertions(+), 46 deletions(-) create mode 100644 src/wikilinks/headingsQuery.ts 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; +} -- 2.54.0