Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions lib/editor/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,75 @@ describe("agentnoteLinks decorations", () => {
view.destroy();
});
});

describe("agentnoteLinks first paint", () => {
const LINK = "[Deploy checklist](/n/abc-mnop-xyz)";
/**
* Over `Work.InitViewport` (3000 chars), so the language field's initial parse
* cannot finish. Its tree snapshot then stops short of the final paragraph —
* which is exactly where the link lives.
*/
const LONG_NOTE = `${"a paragraph of ordinary prose\n\n".repeat(200)}see ${LINK} here`;
const LABEL_FROM = LONG_NOTE.indexOf(LINK) + 1;
const LABEL_TO = LABEL_FROM + "Deploy checklist".length;

let view: EditorView | null = null;

function mount(doc: string) {
const parent = document.createElement("div");
document.body.appendChild(parent);
view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [markdown(), agentnoteLinks()],
}),
});
return view;
}

/**
* Decorations read out of state rather than the DOM: jsdom has no layout, so
* a link below the fold is never rendered even when correctly decorated.
*/
function decorations(v: EditorView) {
const labels: { from: number; to: number }[] = [];
const hidden: { from: number; to: number }[] = [];
for (const source of v.state.facet(EditorView.decorations)) {
const set = typeof source === "function" ? source(v) : source;
for (const iter = set.iter(); iter.value; iter.next()) {
const className = (iter.value.spec as { class?: string }).class ?? "";
const into = className.includes("cm-md-link") ? labels : hidden;
into.push({ from: iter.from, to: iter.to });
}
}
return { labels, hidden };
}

afterEach(() => {
view?.destroy();
view = null;
document.body.replaceChildren();
});

// The bug: `[label](/n/id)` painted raw until the first keystroke rolled it up.
it("rolls up a link past the initially parsed region without any edit", () => {
const { labels, hidden } = decorations(mount(LONG_NOTE));

expect(labels).toContainEqual({ from: LABEL_FROM, to: LABEL_TO });
// `](` and the `/n/…` destination are replaced away, not left as raw text.
expect(
hidden.some((range) => range.from === LABEL_TO && range.to > LABEL_TO),
).toBe(true);
});

it("makes that link clickable on first paint too", () => {
const v = mount(LONG_NOTE);
expect(hrefAtPos(v.state, LABEL_FROM + 2)).toBe("/n/abc-mnop-xyz");
});

it("still decorates a link inside the initially parsed region", () => {
const v = mount(`see ${LINK} here\n\n${"filler\n\n".repeat(400)}`);
expect(decorations(v).labels).toContainEqual({ from: 5, to: 21 });
});
});
46 changes: 42 additions & 4 deletions lib/editor/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
RangeSetBuilder,
StateField,
type Extension,
type Transaction,
} from "@codemirror/state";
import {
Decoration,
Expand Down Expand Up @@ -76,18 +77,41 @@ const FILE_LIKE_TLDS = new Set([

export type LinkHit = { from: number; to: number; url: string };

/** Milliseconds granted to finish parsing before falling back to a partial tree. */
const PARSE_BUDGET_MS = 50;

/**
* The most complete syntax tree available for the whole document.
*
* `ensureSyntaxTree` advances the language field's *shared, mutable* parse
* context and returns the resulting tree, but `syntaxTree(state)` reads the
* snapshot taken when the `LanguageState` was constructed — which is still the
* pre-parse one. Reading that snapshot is why a note opened with a link past the
* initially parsed region painted raw `[label](/n/id)` until the first edit
* produced a `LanguageState` carrying the advanced tree.
*
* The fallback covers a parse that ran out of budget: a partial tree still
* decorates everything it does cover, and the fields below rebuild when the
* background parser gets further.
*/
function documentTree(state: EditorState) {
return (
ensureSyntaxTree(state, state.doc.length, PARSE_BUDGET_MS) ??
syntaxTree(state)
);
}

function collectMarkdownLinks(state: EditorState): {
/** Clickable span = visible link text only (label / autolink URL). */
hits: LinkHit[];
hide: { from: number; to: number }[];
labels: { from: number; to: number }[];
} {
ensureSyntaxTree(state, state.doc.length, 50);
const hits: LinkHit[] = [];
const hide: { from: number; to: number }[] = [];
const labels: { from: number; to: number }[] = [];

syntaxTree(state).iterate({
documentTree(state).iterate({
enter(node) {
if (node.name !== "Link" && node.name !== "Autolink") return;

Expand Down Expand Up @@ -237,10 +261,24 @@ function buildLabelDecorations(state: EditorState): DecorationSet {
return builder.finish();
}

/**
* True when this transaction changed the document or moved the parse forward.
*
* Rebuilding on `docChanged` alone is why a long note kept raw link markup after
* the first edit too: the background parser reports progress through a
* `Language.setState` transaction that changes no text, so the decorations never
* caught up with the region it had just parsed. Comparing the tree covers both,
* and `tr.state` is safe to read here because the language field is installed
* before these fields, so its value is already computed.
*/
function needsRebuild(tr: Transaction): boolean {
return tr.docChanged || syntaxTree(tr.startState) !== syntaxTree(tr.state);
}

const hiddenLinkMarks = StateField.define<DecorationSet>({
create: buildHideDecorations,
update(deco, tr) {
if (tr.docChanged) return buildHideDecorations(tr.state);
if (needsRebuild(tr)) return buildHideDecorations(tr.state);
return deco.map(tr.changes);
},
provide: (field) => [
Expand All @@ -252,7 +290,7 @@ const hiddenLinkMarks = StateField.define<DecorationSet>({
const visibleLinkMarks = StateField.define<DecorationSet>({
create: buildLabelDecorations,
update(deco, tr) {
if (tr.docChanged) return buildLabelDecorations(tr.state);
if (needsRebuild(tr)) return buildLabelDecorations(tr.state);
return deco.map(tr.changes);
},
provide: (field) => EditorView.decorations.from(field),
Expand Down