From a31b6348bde4d2facaec64e77ff64c7d1b993ebf Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Sat, 1 Aug 2026 15:41:48 -0400 Subject: [PATCH] Navigate to a heading when following [[Note#Heading]] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `#Heading` half of a wikilink parsed and resolved the note but did nothing with the anchor, and a bare same-document `[[#Heading]]` bailed out entirely. Following one now scrolls the heading to the top of the viewport and puts the caret on the line *below* it. Below, not on it: `headingDecoration` reveals a heading line's raw `## ` markers while the cursor sits there, so landing on the heading would change its appearance and reflow the line at the moment the scroll settles. A heading near the end of a document lands mid-viewport, since CodeMirror cannot scroll past the end and forcing it would mean permanent blank space under every document. For a document being opened, the heading is resolved BEFORE the view exists and applied through `EditorViewConfig.scrollTo` at construction. Dispatching the scroll straight after `new EditorView(...)` does not work: CodeMirror only applies a pending scroll target once `viewState.editorHeight` is non-zero, which is not guaranteed in the tick the view is created, so the request is silently dropped and the document stays at the top. `resolveHeadingPosition` therefore takes a `Text` rather than an `EditorState`, so it can run before construction. The dispatch path remains for a reveal arriving at an already-mounted editor, where the view has long since been measured. Heading extraction is a line scan, not a Lezer walk. `syntaxTree` is parsed viewport-first and can be truncated on a long document, and forcing it with `ensureSyntaxTree` blocks the thread and may still time out; a scan is O(lines) with no failure mode. It also lets one implementation serve completion for notes that are not open, where there is no `EditorState` at all. Since that makes it a second heading implementation alongside the grammar the editor renders with, a parity test asserts both find headings on the same lines across a corpus — that is what stops the two drifting. The anchor rides on `OpenDocRequest.nav` rather than a separate channel: the workspace already decides which tab an open lands in, and a parallel channel matching on providerId+entryId would scroll *every* tile showing that document. `nav` is excluded from dedup (which compares providerId+entryId) and from persistence, via an explicit allowlist in serializeLayout so a future transient field cannot leak into localStorage silently — a test caught exactly that leaking while writing this. Two things had to be fixed for it to arrive at all: - `useTilingDocumentSlots` early-returned when the document was already open, leaving the tab holding its original request and dropping the newer anchor. New pure `tabsModel.retargetTab` swaps the request while preserving the slot kind and handle, so the editor is not remounted. - A reveal must not replay. The editor records the applied `seq` in the tab's view snapshot, so returning to a tab restores where you left it rather than re-running the jump that first brought you there. `[[#Heading]]` short-circuits inside the editor — no vault lookup, no workspace round trip — and works in view mode, where `readOnly` blocks document changes but not selection or scrolling. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 28 +++- src/editors/DocumentSlotView.tsx | 1 + .../automerge/automergeDocumentEditor.tsx | 98 +++++++++-- .../livePreview/headingReveal.test.ts | 60 +++++++ .../automerge/livePreview/headingReveal.ts | 81 ++++++++++ src/editors/automerge/livePreview/index.ts | 14 +- src/editors/types.ts | 13 ++ src/wikilinks/WikiLinkVaultContext.tsx | 45 ++++-- src/wikilinks/headings.test.ts | 152 ++++++++++++++++++ src/wikilinks/headings.ts | 108 +++++++++++++ src/workspaces/tiling/tabsModel.test.ts | 53 ++++++ src/workspaces/tiling/tabsModel.ts | 26 +++ .../tiling/tilingPersistence.test.ts | 22 +++ src/workspaces/tiling/tilingPersistence.ts | 21 ++- .../tiling/useTilingDocumentSlots.ts | 10 +- src/workspaces/workspace.tsx | 11 ++ 16 files changed, 706 insertions(+), 37 deletions(-) create mode 100644 src/editors/automerge/livePreview/headingReveal.test.ts create mode 100644 src/editors/automerge/livePreview/headingReveal.ts create mode 100644 src/wikilinks/headings.test.ts create mode 100644 src/wikilinks/headings.ts diff --git a/src/App.tsx b/src/App.tsx index 373fd19..8707f2c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -105,6 +105,9 @@ function AppShell() { ); const [openRequest, setOpenRequest] = useState(null); const [openRequestId, setOpenRequestId] = useState(0); + // Mirrors `openRequestId` so `handleOpenEntry` can read the next value + // synchronously and stamp it onto the request's `nav`. + const openSeqRef = useRef(0); const [selectedProviderId, setSelectedProviderId] = useState( loadInitialSelectedVault, ); @@ -303,10 +306,27 @@ function AppShell() { } }, [selectedProviderId]); - const handleOpenEntry = useCallback((request: OpenDocRequest) => { - setOpenRequest(request); - setOpenRequestId((id) => id + 1); - }, []); + /** + * The single "open a document" channel. `options.anchor` carries a wikilink's + * `#heading` for this open only; it is stamped with the same monotonic id the + * workspaces already use, so re-clicking the same anchor counts as a new + * intent rather than an unchanged value. + */ + const handleOpenEntry = useCallback( + (request: OpenDocRequest, options?: { anchor?: string | null }) => { + // Counter in a ref, not derived inside a state updater: the seq has to be + // readable synchronously to stamp onto the request, and updaters can be + // invoked twice under StrictMode. + const seq = ++openSeqRef.current; + setOpenRequestId(seq); + setOpenRequest( + options?.anchor + ? { ...request, nav: { seq, anchor: options.anchor } } + : request, + ); + }, + [], + ); /** * The document the active workspace is showing, reported up by that diff --git a/src/editors/DocumentSlotView.tsx b/src/editors/DocumentSlotView.tsx index f4353c5..8275dcf 100644 --- a/src/editors/DocumentSlotView.tsx +++ b/src/editors/DocumentSlotView.tsx @@ -109,6 +109,7 @@ export function DocumentSlotView({ onOpenWikiLink={openWikiLink} viewSnapshot={viewSnapshot} onViewSnapshot={onViewSnapshot} + reveal={request.nav ?? null} /> ); } diff --git a/src/editors/automerge/automergeDocumentEditor.tsx b/src/editors/automerge/automergeDocumentEditor.tsx index 831a6ef..daaf73f 100644 --- a/src/editors/automerge/automergeDocumentEditor.tsx +++ b/src/editors/automerge/automergeDocumentEditor.tsx @@ -1,5 +1,11 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { EditorState, Compartment } from '@codemirror/state'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { EditorState, Compartment, Text } from '@codemirror/state'; import { EditorView, type EditorViewConfig, @@ -25,6 +31,11 @@ import { editorModeFacet } from './livePreview/mode'; import { wikiLinkVaultFacet } from './livePreview/wikiLinkVault'; import { wikiLinkCompletion } from './livePreview/wikiLinkComplete'; import { listEditingKeymap } from './livePreview/listKeymap'; +import { + resolveHeadingPosition, + revealHeading, + scrollToHeadingEffect, +} from './livePreview/headingReveal'; import './automergeEditor.css'; /** Compartment contents for a mode: read-only in view, plus the mode facet. */ @@ -54,6 +65,7 @@ export function AutomergeDocumentEditor({ onOpenWikiLink, viewSnapshot = null, onViewSnapshot, + reveal = null, }: EditorProps) { const { getDoc, applyChange, subscribeToChanges } = getAutomergePayload(binding); @@ -78,6 +90,26 @@ export function AutomergeDocumentEditor({ // later prop change must not retroactively move the cursor. const initialSnapshotRef = useRef(viewSnapshot); + // A heading jump waiting to be applied. It stays pending until it succeeds, + // because "heading not found" usually just means the document has not + // arrived yet (a synced vault fills in after mount). + const pendingRevealRef = useRef<{ seq: number; anchor: string } | null>(null); + // The reveal already applied in this tab, carried in and back out through the + // snapshot so returning to the tab restores its position instead of replaying. + const revealedSeqRef = useRef(viewSnapshot?.revealedSeq); + // A reveal we have not applied yet overrides the stored position: jumping to + // a heading should win over restoring where the tab used to be. + const revealOnMount = reveal != null && reveal.seq !== revealedSeqRef.current; + + const tryReveal = useCallback(() => { + const view = viewRef.current; + const pending = pendingRevealRef.current; + if (!view || !pending) return; + if (!revealHeading(view, pending.anchor)) return; + pendingRevealRef.current = null; + revealedSeqRef.current = pending.seq; + }, []); + const modeCompartment = useRef(new Compartment()); // The vault index arrives asynchronously and changes as files come and go, // so it lives in its own compartment rather than being baked in at mount. @@ -127,7 +159,10 @@ export function AutomergeDocumentEditor({ }); } contentRef.current = newContent; - }, [docVersion, docContent]); + // Content may have only just arrived, which can be what makes a pending + // heading resolvable. + tryReveal(); + }, [docVersion, docContent, tryReveal]); /** * Record where this tab was, on the way out. @@ -150,6 +185,7 @@ export function AutomergeDocumentEditor({ onViewSnapshotRef.current?.({ selection: { anchor, head }, scroll: view.scrollSnapshot(), + revealedSeq: revealedSeqRef.current, }); }; }, []); @@ -182,18 +218,38 @@ export function AutomergeDocumentEditor({ // Restore the cursor from the last time this tab was shown. Clamped: for a // synced vault the document can still be empty at mount and fill in later, // and an out-of-range selection would throw. - const snapshot = initialSnapshotRef.current; + // A pending reveal supersedes the stored position, so don't restore first + // and then jump — that would scroll twice. + const snapshot = revealOnMount ? null : initialSnapshotRef.current; const clamp = (n: number) => Math.max(0, Math.min(n, initialContent.length)); + // Resolve the heading BEFORE the view exists, so the scroll can be applied + // through `scrollTo` at construction. Dispatching it straight after + // `new EditorView(...)` does not work: CodeMirror only applies a pending + // scroll target once `viewState.editorHeight` is non-zero, which is not + // guaranteed in the tick the view is created — the request is simply + // dropped and the document stays at the top. + const revealPos = + revealOnMount && reveal + ? resolveHeadingPosition( + Text.of(initialContent.split('\n')), + reveal.anchor, + ) + : null; + if (revealPos && reveal) revealedSeqRef.current = reveal.seq; + const state = EditorState.create({ doc: initialContent, - selection: snapshot - ? { - anchor: clamp(snapshot.selection.anchor), - head: clamp(snapshot.selection.head), - } - : undefined, + selection: + revealPos?.cursorPos != null + ? { anchor: revealPos.cursorPos } + : snapshot + ? { + anchor: clamp(snapshot.selection.anchor), + head: clamp(snapshot.selection.head), + } + : undefined, extensions: [ // Core history(), @@ -236,11 +292,20 @@ export function AutomergeDocumentEditor({ // Applying the scroll position at construction (rather than dispatching // it after mount) restores it before the first paint, so returning to a // tab doesn't visibly jump from the top. - scrollTo: snapshot?.scroll as EditorViewConfig['scrollTo'], + scrollTo: revealPos + ? scrollToHeadingEffect(revealPos.headingFrom) + : (snapshot?.scroll as EditorViewConfig['scrollTo']), }); viewRef.current = view; + // Heading not found at mount usually means the content has not arrived yet + // (a synced vault fills in later), so keep it pending for the content + // effect to retry. + if (revealOnMount && reveal && !revealPos) { + pendingRevealRef.current = reveal; + } + return () => { // Flush any pending save before destroying. if (saveTimerRef.current) { @@ -278,6 +343,17 @@ export function AutomergeDocumentEditor({ }); }, [wikiLinkVault]); + // 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 + // tab. Declared LAST on purpose: React runs effects in declaration order, so + // by the time this runs on the first commit the mount effect above has + // created the view. + useEffect(() => { + if (!reveal || reveal.seq === revealedSeqRef.current) return; + pendingRevealRef.current = reveal; + tryReveal(); + }, [reveal, tryReveal]); + return (
{ + it('reads headings straight off the editor state', () => { + const state = makeState('# One\ntext\n## Two'); + expect(headingsIn(state.doc)).toEqual([ + { text: 'One', level: 1, line: 0 }, + { text: 'Two', level: 2, line: 2 }, + ]); + }); +}); + +describe('resolveHeadingPosition', () => { + it('scrolls to the heading line but puts the caret on the line below', () => { + const doc = 'intro\n\n## Target\nbody text'; + const state = makeState(doc); + const pos = resolveHeadingPosition(state.doc, 'Target')!; + + expect(doc.slice(pos.headingFrom)).toMatch(/^## Target/); + expect(doc.slice(pos.cursorPos!)).toBe('body text'); + }); + + it('leaves the caret alone when the heading is the last line', () => { + // Nothing below to land on, and landing ON it would reveal the `## `. + const pos = resolveHeadingPosition(makeState('body\n## Last').doc, 'Last')!; + expect(pos.cursorPos).toBeNull(); + expect(pos.headingFrom).toBe(5); + }); + + it('lands on the next heading when two are consecutive', () => { + const doc = '## A\n## B\nbody'; + const pos = resolveHeadingPosition(makeState(doc).doc, 'A')!; + expect(doc.slice(pos.cursorPos!)).toBe('## B\nbody'); + }); + + it('lands on a blank line when one follows the heading', () => { + const doc = '## A\n\nbody'; + const pos = resolveHeadingPosition(makeState(doc).doc, 'A')!; + expect(doc.slice(pos.cursorPos!)).toBe('\nbody'); + }); + + it('matches case- and whitespace-insensitively', () => { + const state = makeState('# The Big One\nbody'); + expect(resolveHeadingPosition(state.doc, ' the big one ')).not.toBeNull(); + }); + + it('returns null for an unknown anchor and an empty one', () => { + const state = makeState('# One\nbody'); + expect(resolveHeadingPosition(state.doc, 'Missing')).toBeNull(); + expect(resolveHeadingPosition(state.doc, '')).toBeNull(); + }); + + it('ignores a heading inside a fenced code block', () => { + const state = makeState('```\n## Fake\n```\nbody'); + expect(resolveHeadingPosition(state.doc, 'Fake')).toBeNull(); + }); +}); diff --git a/src/editors/automerge/livePreview/headingReveal.ts b/src/editors/automerge/livePreview/headingReveal.ts new file mode 100644 index 0000000..c545f81 --- /dev/null +++ b/src/editors/automerge/livePreview/headingReveal.ts @@ -0,0 +1,81 @@ +/** + * Moving to a heading within a document — the `#Heading` half of a wikilink. + * + * The caret lands on the line *below* the heading rather than on it. That is + * deliberate: `headingDecoration` reveals a heading line's raw `## ` markers + * while the cursor is on it, so landing on the heading would change its + * appearance and reflow the line at the exact moment the scroll settles. + */ +import { type Text } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; + +import { + extractHeadings, + findHeading, + type DocHeading, +} from '../../../wikilinks/headings'; + +export type HeadingPosition = { + /** Offset of the start of the heading line — what gets scrolled into view. */ + headingFrom: number; + /** Where the caret goes; null when the heading is the document's last line. */ + cursorPos: number | null; +}; + +/** Headings of a document. */ +export function headingsIn(doc: Text): DocHeading[] { + return extractHeadings(doc.iterLines()); +} + +/** + * Takes a `Text` rather than an `EditorState` so the position can be resolved + * *before* an `EditorView` exists — the editor needs it at construction time, + * where applying the scroll is reliable (see `revealHeading`). + */ +export function resolveHeadingPosition( + doc: Text, + anchor: string, +): HeadingPosition | null { + const heading = findHeading(headingsIn(doc), anchor); + if (!heading) return null; + + // `DocHeading.line` is 0-based; `Text.line` is 1-based. + const lineNumber = heading.line + 1; + const headingLine = doc.line(lineNumber); + const hasLineBelow = lineNumber < doc.lines; + + return { + headingFrom: headingLine.from, + cursorPos: hasLineBelow ? doc.line(lineNumber + 1).from : null, + }; +} + +/** The scroll effect that puts a heading line at the top of the viewport. */ +export function scrollToHeadingEffect(headingFrom: number) { + // `yMargin` defaults to 5px; the scroller's own padding already gives a gap. + return EditorView.scrollIntoView(headingFrom, { y: 'start', yMargin: 0 }); +} + +/** + * Put the caret below `anchor`'s heading and scroll that heading to the top. + * Returns false when the document has no such heading, leaving the view alone. + * + * A heading near the end of a document lands mid-viewport rather than at the + * top: CodeMirror cannot scroll past the end, and adding trailing padding to + * force it would put permanent blank space under every document. + */ +export function revealHeading(view: EditorView, anchor: string): boolean { + const position = resolveHeadingPosition(view.state.doc, anchor); + if (!position) return false; + + view.dispatch({ + ...(position.cursorPos != null + ? { selection: { anchor: position.cursorPos } } + : {}), + effects: scrollToHeadingEffect(position.headingFrom), + // A transaction carrying a selection would otherwise also request that the + // cursor be scrolled into view, competing with the effect above. + scrollIntoView: false, + }); + return true; +} diff --git a/src/editors/automerge/livePreview/index.ts b/src/editors/automerge/livePreview/index.ts index 215741e..a0a84aa 100644 --- a/src/editors/automerge/livePreview/index.ts +++ b/src/editors/automerge/livePreview/index.ts @@ -20,6 +20,7 @@ import { hrDecoration } from './hrDecoration'; import { listDecoration } from './listDecoration'; import { escapeDecoration } from './escapeDecoration'; import { wikiLinkDecoration, wikiLinkAt } from './wikiLinkDecoration'; +import { revealHeading } from './headingReveal'; import { wikiLinkVaultFacet } from './wikiLinkVault'; import { editorModeFacet } from './mode'; import type { WikiLinkActivation } from '../../types'; @@ -79,10 +80,17 @@ function makeLinkClickHandler(options: LivePreviewOptions): Extension { const wiki = wikiLinkAt(view.state, pos); if (wiki) { + // A target-less `[[#Heading]]` points at this very document, so it + // needs no vault lookup and no trip through the workspace — the editor + // can just move. Works in view mode too: `readOnly` blocks document + // changes, not selection or scrolling. + if (!wiki.target) { + if (!wiki.anchor || !revealHeading(view, wiki.anchor)) return false; + event.preventDefault(); + return true; + } const vault = view.state.facet(wikiLinkVaultFacet); - // No vault index yet, or a same-document `[[#Heading]]` ref (which - // needs the scroll-to-heading follow-up before it can do anything). - if (!vault || !wiki.target) return false; + if (!vault) return false; const resolution = vault.resolve(wiki.target); if (!resolution) return false; event.preventDefault(); diff --git a/src/editors/types.ts b/src/editors/types.ts index 39822a7..9e29b13 100644 --- a/src/editors/types.ts +++ b/src/editors/types.ts @@ -34,6 +34,12 @@ export type EditorBinding = { export type EditorViewSnapshot = { selection: { anchor: number; head: number }; scroll: unknown; + /** + * The `reveal.seq` already applied in this tab. Returning to the tab restores + * the position above rather than re-running that navigation, so a heading + * jump happens once and does not replay every time you come back. + */ + revealedSeq?: number; }; /** Where a `[[wikilink]]` target points, and whether that document exists yet. */ @@ -117,6 +123,13 @@ export type EditorProps = { * The workspace stores it against the tab being left. */ onViewSnapshot?: (snapshot: EditorViewSnapshot) => void; + /** + * Scroll to and place the caret at a heading. `seq` is monotonic, so + * requesting the *same* anchor again still fires; the editor ignores a seq it + * has already applied, which is what stops a stale intent replaying when you + * return to the tab. + */ + reveal?: { seq: number; anchor: string } | null; }; /** diff --git a/src/wikilinks/WikiLinkVaultContext.tsx b/src/wikilinks/WikiLinkVaultContext.tsx index 1c208a3..476ae74 100644 --- a/src/wikilinks/WikiLinkVaultContext.tsx +++ b/src/wikilinks/WikiLinkVaultContext.tsx @@ -37,10 +37,16 @@ import { initialNoteContent } from './noteTemplate'; type IndexMap = ReadonlyMap; +/** 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: (request: OpenDocRequest) => void; + onOpenEntry: OpenEntry; }; const WikiLinkVaultCtx = createContext(null); @@ -93,7 +99,7 @@ export function WikiLinkVaultProvider({ children, }: { providers: readonly FileSystemProvider[]; - onOpenEntry: (request: OpenDocRequest) => void; + onOpenEntry: OpenEntry; children: ReactNode; }) { const [indexes, setIndexes] = useState(() => new Map()); @@ -163,11 +169,14 @@ export function useWikiLinkDocument( if (!provider) return; if (activation.exists) { - onOpenEntry({ - providerId, - entryId: activation.entryId, - title: activation.title, - }); + onOpenEntry( + { + providerId, + entryId: activation.entryId, + title: activation.title, + }, + { anchor: activation.anchor }, + ); return; } @@ -190,11 +199,10 @@ export function useWikiLinkDocument( initialNoteContent(fileName), ); // Open the id the provider returned, not the one we predicted. - onOpenEntry({ - providerId, - entryId: entry.id, - title: entry.name, - }); + 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( @@ -202,11 +210,14 @@ export function useWikiLinkDocument( activation.entryId, error, ); - onOpenEntry({ - providerId, - entryId: activation.entryId, - title: activation.title, - }); + 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. diff --git a/src/wikilinks/headings.test.ts b/src/wikilinks/headings.test.ts new file mode 100644 index 0000000..0a67c56 --- /dev/null +++ b/src/wikilinks/headings.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { syntaxTree } from '@codemirror/language'; + +import { makeState } from '../editors/automerge/livePreview/testSupport'; +import { extractHeadings, findHeading, normalizeAnchor } from './headings'; + +const headings = (doc: string) => extractHeadings(doc.split('\n')); + +describe('extractHeadings', () => { + it('reads all six levels', () => { + const doc = ['# a', '## b', '### c', '#### d', '##### e', '###### f'].join( + '\n', + ); + expect(headings(doc).map((h) => h.level)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('records text and 0-based line index', () => { + expect(headings('intro\n\n## Second bit\nbody')).toEqual([ + { text: 'Second bit', level: 2, line: 2 }, + ]); + }); + + it('requires a space, so `#hashtag` is not a heading', () => { + expect(headings('#hashtag')).toEqual([]); + }); + + it('ignores a heading with no text', () => { + expect(headings('##\n### ')).toEqual([]); + }); + + it('stops at six `#`', () => { + expect(headings('####### seven')).toEqual([]); + }); + + it('strips a closing sequence', () => { + expect(headings('## Title ##')[0].text).toBe('Title'); + expect(headings('## Title #########')[0].text).toBe('Title'); + }); + + it('keeps a `#` that is part of the text', () => { + expect(headings('## C# notes')[0].text).toBe('C# notes'); + }); + + it('allows up to three leading spaces but not four', () => { + expect(headings(' ### indented')[0].text).toBe('indented'); + expect(headings(' ### code block')).toEqual([]); + }); + + it('tolerates CRLF', () => { + expect(headings('# a\r\n## b\r')).toEqual([ + { text: 'a', level: 1, line: 0 }, + { text: 'b', level: 2, line: 1 }, + ]); + }); + + describe('fenced code', () => { + it('ignores headings inside a backtick fence', () => { + expect(headings('```\n# not a heading\n```\n# real')).toEqual([ + { text: 'real', level: 1, line: 3 }, + ]); + }); + + it('ignores headings inside a tilde fence', () => { + expect(headings('~~~\n# nope\n~~~')).toEqual([]); + }); + + it('does not let a tilde fence close a backtick fence', () => { + expect(headings('```\n~~~\n# still inside\n```\n# out')).toEqual([ + { text: 'out', level: 1, line: 4 }, + ]); + }); + + it('requires the closing fence to be at least as long', () => { + expect(headings('````\n```\n# still inside\n````\n# out')).toEqual([ + { text: 'out', level: 1, line: 4 }, + ]); + }); + + it('does not close on a fence carrying an info string', () => { + expect(headings('```\n```ts\n# still inside\n```\n# out')).toEqual([ + { text: 'out', level: 1, line: 4 }, + ]); + }); + + it('handles a fence opened with an info string', () => { + expect(headings('```ts\n# nope\n```\n# out')).toEqual([ + { text: 'out', level: 1, line: 3 }, + ]); + }); + }); +}); + +/** + * The scan is a second heading implementation alongside the Lezer grammar the + * editor renders with. This is the test that stops the two drifting: for a + * broad corpus, both must find headings on exactly the same lines. + */ +describe('parity with the markdown parser the editor uses', () => { + const CORPUS = [ + '# one\ntext\n## two\n### three', + 'intro\n\n#hashtag not a heading\n\n## real one', + '```\n# fenced\n```\n# after', + ' ### three spaces\n #### four spaces', + '## closing ##\n## trailing spaces ', + '# a\n\n> quoted text\n\n## b\n- list\n## c', + '~~~js\n# in tilde fence\n~~~\n# out', + '## C# and **bold**\ntext', + 'para\n# h1\npara\n###### h6\npara', + '````\n```\n# nested fence\n````\n# after nested', + ]; + + const parserHeadingLines = (doc: string): number[] => { + const state = makeState(doc, 0); + const lines: number[] = []; + syntaxTree(state).iterate({ + enter(node) { + if (!/^ATXHeading[1-6]$/.test(node.name)) return; + lines.push(state.doc.lineAt(node.from).number - 1); + }, + }); + return lines; + }; + + for (const doc of CORPUS) { + it(`agrees on: ${JSON.stringify(doc.slice(0, 32))}…`, () => { + expect(headings(doc).map((h) => h.line)).toEqual(parserHeadingLines(doc)); + }); + } +}); + +describe('normalizeAnchor', () => { + it('folds case and collapses whitespace', () => { + expect(normalizeAnchor(' The Big One ')).toBe('the big one'); + }); +}); + +describe('findHeading', () => { + const hs = headings('# Alpha\n## Beta\n### Alpha'); + + it('matches case- and whitespace-insensitively', () => { + expect(findHeading(hs, ' beta ')).toMatchObject({ line: 1 }); + }); + + it('resolves a duplicate to the first in document order', () => { + expect(findHeading(hs, 'Alpha')).toMatchObject({ line: 0, level: 1 }); + }); + + it('returns null for no match and for an empty anchor', () => { + expect(findHeading(hs, 'Gamma')).toBeNull(); + expect(findHeading(hs, ' ')).toBeNull(); + }); +}); diff --git a/src/wikilinks/headings.ts b/src/wikilinks/headings.ts new file mode 100644 index 0000000..6922852 --- /dev/null +++ b/src/wikilinks/headings.ts @@ -0,0 +1,108 @@ +/** + * Markdown heading extraction, for resolving `[[Note#Heading]]` anchors and for + * offering headings in the `[[` typeahead. + * + * A line scan rather than a Lezer walk, deliberately. `syntaxTree(state)` is + * parsed viewport-first and can be truncated on a long document — and forcing + * it with `ensureSyntaxTree` blocks the thread and may still time out. A scan + * is O(lines) with no failure mode. It also means one implementation serves + * every caller, including completion for a note that is *not* open, where there + * is a raw string off disk and no `EditorState` at all. + * + * Two known gaps, both matching what the editor already renders: + * - **Setext headings** (`Title` over `===`) are not recognised, because + * `headingDecoration.ts` does not render them as headings either. Accepting + * them here would let a link resolve to something that displays as plain text. + * - **YAML front matter** is not understood, so a `#` line inside a leading + * `---` block reads as a heading. The app has no front-matter support at all + * yet; this should be revisited when it does. + */ + +/** A heading found in a document. */ +export type DocHeading = { + /** Heading text with markers, any closing `#`s and surrounding space removed. */ + text: string; + /** 1–6. */ + level: number; + /** 0-based line index. */ + line: number; +}; + +/** + * Up to three leading spaces (four would make it an indented code block), 1–6 + * `#`, then either end-of-line or a space before the text. Requiring that space + * is what stops `#hashtag` being read as a heading. + */ +const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/; + +/** A closing run of `#`s, as in `## Title ##`, which is not part of the text. */ +const CLOSING_SEQUENCE = /[ \t]+#+[ \t]*$/; + +/** ``` or ~~~ opening/closing a fenced code block. */ +const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/; + +export function extractHeadings(lines: Iterable): DocHeading[] { + const out: DocHeading[] = []; + let lineNumber = -1; + // The fence currently open: its character and length. A fence closes only on + // the same character, at least as long, and carrying no info string. + let openFence: { char: string; length: number } | null = null; + + for (const rawLine of lines) { + lineNumber++; + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + + const fence = FENCE.exec(line); + if (fence) { + const [, marker, rest] = fence; + const char = marker[0]; + if (!openFence) { + openFence = { char, length: marker.length }; + } else if ( + char === openFence.char && + marker.length >= openFence.length && + rest.trim() === '' + ) { + openFence = null; + } + continue; + } + if (openFence) continue; + + const match = ATX.exec(line); + if (!match) continue; + const text = (match[2] ?? '').replace(CLOSING_SEQUENCE, '').trim(); + // `##` with nothing after it names nothing, so it cannot be linked to. + if (text === '') continue; + + out.push({ text, level: match[1].length, line: lineNumber }); + } + + return out; +} + +/** + * Comparison key for anchors: case- and whitespace-insensitive. + * + * Case-folding mirrors how note names resolve (`wikiLinkIndex`'s + * `byStemLower`); collapsing whitespace forgives `[[Note# Heading]]`. + */ +export function normalizeAnchor(text: string): string { + return text.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +/** + * The heading an anchor refers to, or null. + * + * Duplicates resolve to the first in document order — the same rule the + * typeahead uses when it drops later duplicates from the list, so what you can + * pick is always what you will reach. + */ +export function findHeading( + headings: readonly DocHeading[], + anchor: string, +): DocHeading | null { + const key = normalizeAnchor(anchor); + if (key === '') return null; + return headings.find((h) => normalizeAnchor(h.text) === key) ?? null; +} diff --git a/src/workspaces/tiling/tabsModel.test.ts b/src/workspaces/tiling/tabsModel.test.ts index 6231d6b..256184b 100644 --- a/src/workspaces/tiling/tabsModel.test.ts +++ b/src/workspaces/tiling/tabsModel.test.ts @@ -20,6 +20,7 @@ import { getTabs, moveTab, planOpen, + retargetTab, setActiveTab, setTabSlot, setTabView, @@ -502,3 +503,55 @@ describe('setTabView / getTabView', () => { expect(getTabView(map, 'other', 'tab-a')).toBeNull(); }); }); + +describe('retargetTab', () => { + const withNav = (entryId: string, seq: number, anchor: string) => ({ + ...request(entryId), + nav: { seq, anchor }, + }); + + it('swaps the request so a re-open delivers its new navigation intent', () => { + const map = tileWithDocs('tile-1', ['a']); + const next = retargetTab(map, 'tile-1', 'tab-a', withNav('a', 7, 'Intro')); + expect(getActiveSlot(next, 'tile-1')).toMatchObject({ + request: { nav: { seq: 7, anchor: 'Intro' } }, + }); + }); + + it('preserves slot kind and handle, so the editor is not remounted', () => { + const handle = { id: '/vault/a.md' } as never; + const map: TileTabsMap = { + 'tile-1': { + tabs: [ + { + tabId: 'tab-a', + slot: { kind: 'open', request: request('a'), handle }, + mode: 'edit', + }, + ], + activeTabId: 'tab-a', + }, + }; + const next = retargetTab(map, 'tile-1', 'tab-a', withNav('a', 2, 'X')); + const slot = getActiveSlot(next, 'tile-1'); + expect(slot.kind).toBe('open'); + expect(slot.kind === 'open' && slot.handle).toBe(handle); + }); + + it('leaves an empty tab alone — there is nothing to retarget', () => { + const map = ensureTile({}, 'tile-1', 'tab-1'); + expect(retargetTab(map, 'tile-1', 'tab-1', request('a'))).toBe(map); + }); + + it('is a no-op (same reference) for a missing tile or tab', () => { + const map = tileWithDocs('tile-1', ['a']); + expect(retargetTab(map, 'other', 'tab-a', request('a'))).toBe(map); + expect(retargetTab(map, 'tile-1', 'missing', request('a'))).toBe(map); + }); + + it('does not touch sibling tabs', () => { + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); + const next = retargetTab(map, 'tile-1', 'tab-a', withNav('a', 3, 'H')); + expect(getTabs(next, 'tile-1')[1]).toBe(getTabs(map, 'tile-1')[1]); + }); +}); diff --git a/src/workspaces/tiling/tabsModel.ts b/src/workspaces/tiling/tabsModel.ts index de1f769..76885f8 100644 --- a/src/workspaces/tiling/tabsModel.ts +++ b/src/workspaces/tiling/tabsModel.ts @@ -178,6 +178,32 @@ export function setTabMode( return { ...map, [tileId]: { ...tile, tabs } }; } +/** + * Point an existing tab at a new request for the same document. + * + * Re-opening a document that is already showing focuses its tab and otherwise + * changes nothing, which would leave the tab holding the *original* request — + * and so drop any newer navigation intent (a `#heading` anchor) on the floor. + * The slot's `kind` and `handle` are preserved, so the editor is not remounted. + */ +export function retargetTab( + map: TileTabsMap, + tileId: string, + tabId: string, + request: OpenDocRequest, +): TileTabsMap { + const tile = map[tileId]; + if (!tile) return map; + let changed = false; + const tabs = tile.tabs.map((t) => { + if (t.tabId !== tabId || t.slot.kind === 'empty') return t; + changed = true; + return { ...t, slot: { ...t.slot, request } }; + }); + if (!changed) return map; + return { ...map, [tileId]: { ...tile, tabs } }; +} + /** * Store one tab's last scroll/cursor position. No-op if the tile/tab is * missing — a tab can be closed while its editor is tearing down. diff --git a/src/workspaces/tiling/tilingPersistence.test.ts b/src/workspaces/tiling/tilingPersistence.test.ts index 8dcd997..920e992 100644 --- a/src/workspaces/tiling/tilingPersistence.test.ts +++ b/src/workspaces/tiling/tilingPersistence.test.ts @@ -22,6 +22,28 @@ function splitRow( } describe('serializeLayout', () => { + it('excludes a request’s one-shot navigation intent', () => { + // `nav` is a heading jump for one open, not part of the document's + // identity — persisting it would re-scroll on every app restart. + const layout = serializeLayout({ kind: 'tile', id: 'tile-1' }, 'tile-1', { + 'tile-1': { + tabs: [ + { + tabId: 'tab-a', + slot: { + kind: 'loading', + request: { ...req('a'), nav: { seq: 4, anchor: 'Intro' } }, + }, + mode: 'edit', + }, + ], + activeTabId: 'tab-a', + }, + }); + expect(layout.tiles[0].tabs[0].request).toEqual(req('a')); + expect(JSON.stringify(layout)).not.toContain('anchor'); + }); + it('excludes a tab’s scroll/cursor snapshot from the persisted layout', () => { // `TileTab.view` holds a live CodeMirror object and is in-memory only. // serializeLayout picks fields explicitly, which is what keeps it out — diff --git a/src/workspaces/tiling/tilingPersistence.ts b/src/workspaces/tiling/tilingPersistence.ts index 48dce2d..b5d19f1 100644 --- a/src/workspaces/tiling/tilingPersistence.ts +++ b/src/workspaces/tiling/tilingPersistence.ts @@ -48,6 +48,22 @@ function normalizeTab(tab: PersistedTab | OpenDocRequest): PersistedTab { return { request: tab, mode: DEFAULT_MODE }; } +/** + * Reduce a request to the fields that identify a document. + * + * An allowlist rather than a copy: `OpenDocRequest` also carries one-shot + * navigation intent (`nav`, a heading to jump to), which would otherwise be + * persisted and re-applied on every app restart. Picking explicitly means a + * future transient field is excluded by default instead of leaking silently. + */ +function toPersistedRequest({ + providerId, + entryId, + title, +}: OpenDocRequest): OpenDocRequest { + return { providerId, entryId, title }; +} + /** Serialize the live layout into a persistable snapshot. */ export function serializeLayout( tree: TilingNode, @@ -62,7 +78,10 @@ export function serializeLayout( for (const t of tile.tabs) { if (t.slot.kind === 'empty') continue; if (t.tabId === tile.activeTabId) activeIndex = tabs.length; - tabs.push({ request: t.slot.request, mode: t.mode }); + tabs.push({ + request: toPersistedRequest(t.slot.request), + mode: t.mode, + }); } } return { tileId, tabs, activeIndex }; diff --git a/src/workspaces/tiling/useTilingDocumentSlots.ts b/src/workspaces/tiling/useTilingDocumentSlots.ts index 272e5d8..70b5181 100644 --- a/src/workspaces/tiling/useTilingDocumentSlots.ts +++ b/src/workspaces/tiling/useTilingDocumentSlots.ts @@ -129,7 +129,15 @@ export function useTilingDocumentSlots({ makeTabId, ); setTileTabs(plan.map); - if (plan.alreadyOpen) return; + if (plan.alreadyOpen) { + // The document is already showing, so there is nothing to open — but the + // request may carry a newer navigation intent (a `#heading` anchor) that + // the mounted editor still needs to see. + setTileTabs((prev) => + tabsModel.retargetTab(prev, tileId, plan.tabId, request), + ); + return; + } const tabId = plan.tabId; const setSlot = (slot: DocumentSlotState) => diff --git a/src/workspaces/workspace.tsx b/src/workspaces/workspace.tsx index 9ae4872..e338dc1 100644 --- a/src/workspaces/workspace.tsx +++ b/src/workspaces/workspace.tsx @@ -33,6 +33,17 @@ export type OpenDocRequest = { entryId: string; /** Human-readable label used for tab/title chrome. */ title: string; + /** + * Where to go *within* the document for this particular open — currently a + * `#heading` anchor from a wikilink. + * + * Deliberately not part of the document's identity. Tab dedup compares + * `providerId` + `entryId` only (`slotShowsRequest`), and `serializeLayout` + * picks fields explicitly, so this neither splits tabs nor reaches + * localStorage. `seq` is monotonic, so re-clicking the same anchor is a new + * intent and fires again rather than looking unchanged. + */ + nav?: { seq: number; anchor: string }; }; export type WorkspaceProps = { -- 2.51.2