Apply detected changes in place instead of full page reload

This commit is contained in:
Zef Hemel
2026-08-05 10:56:46 +02:00
parent d70e7ff0c8
commit 8d08b058bb
7 changed files with 929 additions and 46 deletions
+406
View File
@@ -0,0 +1,406 @@
import { describe, expect, test, vi } from "vitest";
import { EditorState, type TransactionSpec } from "@codemirror/state";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import type { Client } from "./client.ts";
// content_manager.ts imports codemirror/editor_state.ts for createEditorState
// and externalUpdate. That module (transitively, via lua_widget.ts ->
// widget_sandbox_iframe.ts) calls document.createElement at module scope, so
// it can't load under this project's Node-environment vitest config (no
// jsdom/happy-dom, and adding one is out of scope). Replace it with a
// minimal stand-in exposing the same two names -- content_manager.ts itself,
// the actual subject of these tests, is imported for real, unmocked.
vi.mock("./codemirror/editor_state.ts", async () => {
const { Annotation, EditorState: RealEditorState } = await import(
"@codemirror/state"
);
return {
createEditorState: (
_client: unknown,
_pageName: string,
text: string,
_readOnly: boolean,
) => RealEditorState.create({ doc: text }),
externalUpdate: Annotation.define<boolean>(),
};
});
const { ContentManager } = await import("./content_manager.ts");
// content_manager.ts's enriched-meta refresh (loadPage and, after this
// change, reloadPageContent) reads/writes document.body directly for
// frontmatter-derived page-decoration classes. No jsdom in this Node vitest
// config (see the module mock above), so provide the minimal shape used.
(globalThis as unknown as { document: { body: unknown } }).document = {
body: {
className: "",
removeAttribute(this: { className: string }, name: string) {
if (name === "class") this.className = "";
},
},
};
// Minimal stand-in for EditorView: real EditorState + real transaction
// application (state.update), so ChangeSet/annotation semantics are
// genuine -- just no DOM rendering.
function makeEditorViewStub(initialDoc: string) {
let state = EditorState.create({ doc: initialDoc });
const dispatched: TransactionSpec[] = [];
return {
get state() {
return state;
},
setState(newState: EditorState) {
state = newState;
},
dispatch(spec: TransactionSpec) {
dispatched.push(spec);
state = state.update(spec).state;
},
dispatched,
};
}
type ReadPageResult = { text: string; meta: PageMeta };
function makeClientStub(opts: {
initialDoc: string;
readPage: () => Promise<ReadPageResult>;
hasFullIndexCompleted?: () => Promise<boolean>;
getObjectByRef?: () => Promise<PageMeta | undefined>;
}) {
const editorView = makeEditorViewStub(opts.initialDoc);
const viewState: {
current?: { path: string; meta?: PageMeta };
unsavedChanges: boolean;
} = {
current: undefined,
unsavedChanges: false,
};
let currentPathValue = "";
const dispatchedEvents: { name: string; args: unknown[] }[] = [];
const viewDispatched: { type: string; [key: string]: unknown }[] = [];
const client = {
editorView,
viewState,
set currentPathValue(v: string) {
currentPathValue = v;
},
ui: {
viewState,
viewDispatch: (action: {
type: string;
path?: string;
meta?: PageMeta;
}) => {
viewDispatched.push(action);
if (action.type === "page-loaded" && action.path) {
viewState.current = { path: action.path, meta: action.meta };
}
if (action.type === "update-current-page-meta" && viewState.current) {
viewState.current = { ...viewState.current, meta: action.meta };
}
},
},
space: {
readPage: opts.readPage,
unwatchFile: () => {},
watchFile: () => {},
},
objectIndex: {
hasFullIndexCompleted: opts.hasFullIndexCompleted ?? (async () => false),
getObjectByRef: opts.getObjectByRef ?? (async () => undefined),
},
widgetCache: { clearPrewarm: () => {} },
pageMetaAugmenter: { setAugmentation: async () => {} },
eventHook: {
dispatchEvent: async (name: string, ...args: unknown[]) => {
dispatchedEvents.push({ name, args });
return [];
},
},
isReadOnlyMode: () => false,
dispatchAppEvent: async () => [],
currentPath: () => currentPathValue,
currentName: () => currentPathValue.replace(/\.md$/, ""),
dispatchedEvents,
viewDispatched,
};
return client;
}
function pageMeta(lastModified: string): PageMeta {
return {
name: "index",
tags: ["page"],
created: "",
lastModified,
perm: "rw",
} as PageMeta;
}
describe("ContentManager.loadPage base tracking (regression)", () => {
test("same-page reload merges against the previous disk text, not the newly-fetched one", async () => {
let diskText = "hello world\n";
let diskModified = "2026-01-01T00:00:00.000";
const client = makeClientStub({
initialDoc: "",
readPage: async () => ({ text: diskText, meta: pageMeta(diskModified) }),
});
client.currentPathValue = "index.md";
const cm = new ContentManager(client as unknown as Client);
// Fresh load establishes the base.
await cm.loadPage({ path: "index.md" }, false);
expect(client.editorView.state.sliceDoc()).toBe("hello world\n");
// An unsaved local edit sits in the editor -- stale relative to the disk
// change below, exactly the situation "Editor: Reload" or the
// first-sync-complete auto-reload can hit.
client.editorView.dispatch({
changes: { from: client.editorView.state.doc.length, insert: "LOCAL" },
});
expect(client.editorView.state.sliceDoc()).toBe("hello world\nLOCAL");
// Disk changes externally while that edit is still unsaved.
diskText = "hello world\nExternal line\n";
diskModified = "2026-01-01T00:00:05.000";
// Same-page reload (loadingDifferentPath stays false: previousPath ===
// newPath, both "index.md").
await cm.loadPage({ path: "index.md" }, false);
// Must not no-op: if lastKnownDiskText was clobbered to the new disk
// text before this merge runs, base === disk and the diff -- and thus
// this assertion -- would be empty regardless of what changed on disk.
expect(client.editorView.state.sliceDoc()).toContain("External line");
});
});
describe("ContentManager.reloadPageContent stale-navigation guard (regression)", () => {
test("bails out if the current page changed while the read was in flight", async () => {
let resolveReadPage!: (doc: ReadPageResult) => void;
const pending = new Promise<ReadPageResult>((resolve) => {
resolveReadPage = resolve;
});
const client = makeClientStub({
initialDoc: "page A content\n",
readPage: () => pending,
});
client.currentPathValue = "pageA.md";
const cm = new ContentManager(client as unknown as Client);
const reloadPromise = cm.reloadPageContent();
// User navigates to a different page while the fetch above is still in
// flight.
client.currentPathValue = "pageB.md";
client.editorView.setState(EditorState.create({ doc: "page B content\n" }));
// The stale fetch for page A finally resolves, with page-A-derived
// external content.
resolveReadPage({
text: "page A content\nEXTERNAL EDIT\n",
meta: pageMeta("2026-01-01T00:00:00.000"),
});
await reloadPromise;
// Page B's content must be untouched by page A's stale patch.
expect(client.editorView.state.sliceDoc()).toBe("page B content\n");
});
test("applies normally when the page hasn't changed during the fetch", async () => {
// initialDoc "" matches ContentManager's default lastKnownDiskText, so
// the merge is a clean append -- this test only guards against the
// stale-navigation check above being overzealous, not the merge itself
// (covered by external_merge.test.ts and the loadPage test above).
const client = makeClientStub({
initialDoc: "",
readPage: async () => ({
text: "hello\nexternal\n",
meta: pageMeta("2026-01-01T00:00:00.000"),
}),
});
client.currentPathValue = "index.md";
const cm = new ContentManager(client as unknown as Client);
await cm.reloadPageContent();
expect(client.editorView.state.sliceDoc()).toBe("hello\nexternal\n");
});
});
describe("ContentManager.applyExternalPatches monotonicity guard (regression)", () => {
test("drops a stale apply when an older in-flight read resolves after a newer one", async () => {
let resolveOlder!: (r: ReadPageResult) => void;
let resolveNewer!: (r: ReadPageResult) => void;
const olderPromise = new Promise<ReadPageResult>((resolve) => {
resolveOlder = resolve;
});
const newerPromise = new Promise<ReadPageResult>((resolve) => {
resolveNewer = resolve;
});
let call = 0;
const client = makeClientStub({
initialDoc: "",
readPage: () => {
call++;
if (call === 1) {
return Promise.resolve({
text: "base\n",
meta: pageMeta("2026-01-01T00:00:00.000"),
});
}
return call === 2 ? olderPromise : newerPromise;
},
});
client.currentPathValue = "index.md";
const cm = new ContentManager(client as unknown as Client);
// Seed the base (lastKnownDiskText/lastKnownDiskModified) via a fresh load.
await cm.loadPage({ path: "index.md" }, false);
expect(client.editorView.state.sliceDoc()).toBe("base\n");
// Two reloads for the same page, in flight concurrently (e.g. a direct
// reloadEditor racing an SSE-triggered reloadPageContent).
const olderReload = cm.reloadPageContent();
const newerReload = cm.reloadPageContent();
// The one reading the newer disk state resolves first.
resolveNewer({
text: "base\nAGENT V2\n",
meta: pageMeta("2026-01-01T00:00:10.000"),
});
await newerReload;
expect(client.editorView.state.sliceDoc()).toBe("base\nAGENT V2\n");
// The stale (older) read resolves after -- must not revert the editor.
resolveOlder({
text: "base\nAGENT V1\n",
meta: pageMeta("2026-01-01T00:00:05.000"),
});
await olderReload;
expect(client.editorView.state.sliceDoc()).toBe("base\nAGENT V2\n");
});
});
describe("ContentManager.reloadPageContent editor:pageReloaded notification", () => {
test("dispatches editor:pageReloaded after applying a real external patch", async () => {
let diskText = "hello world\n";
const client = makeClientStub({
initialDoc: "",
readPage: async () => ({
text: diskText,
meta: pageMeta("2026-01-01T00:00:00.000"),
}),
});
client.currentPathValue = "index.md";
const cm = new ContentManager(client as unknown as Client);
await cm.loadPage({ path: "index.md" }, false);
client.dispatchedEvents.length = 0;
diskText = "hello world\nExternal line\n";
await cm.reloadPageContent();
const reloaded = client.dispatchedEvents.filter(
(e) => e.name === "editor:pageReloaded",
);
expect(reloaded).toEqual([
{ name: "editor:pageReloaded", args: ["index", "index"] },
]);
});
test("does not dispatch editor:pageReloaded for a stale patch dropped by the monotonicity guard", async () => {
let resolveOlder!: (r: ReadPageResult) => void;
let resolveNewer!: (r: ReadPageResult) => void;
const olderPromise = new Promise<ReadPageResult>((resolve) => {
resolveOlder = resolve;
});
const newerPromise = new Promise<ReadPageResult>((resolve) => {
resolveNewer = resolve;
});
let call = 0;
const client = makeClientStub({
initialDoc: "",
readPage: () => {
call++;
if (call === 1) {
return Promise.resolve({
text: "base\n",
meta: pageMeta("2026-01-01T00:00:00.000"),
});
}
return call === 2 ? olderPromise : newerPromise;
},
});
client.currentPathValue = "index.md";
const cm = new ContentManager(client as unknown as Client);
await cm.loadPage({ path: "index.md" }, false);
const olderReload = cm.reloadPageContent();
const newerReload = cm.reloadPageContent();
resolveNewer({
text: "base\nV2\n",
meta: pageMeta("2026-01-01T00:00:10.000"),
});
await newerReload;
client.dispatchedEvents.length = 0;
// Resolves after the newer one, with older content -- dropped by the
// monotonicity guard in applyExternalPatches. Nothing changed in the
// editor, so no notification should fire either.
resolveOlder({
text: "base\nV1\n",
meta: pageMeta("2026-01-01T00:00:05.000"),
});
await olderReload;
expect(
client.dispatchedEvents.some((e) => e.name === "editor:pageReloaded"),
).toBe(false);
});
test("refreshes viewState.current.meta and body page-decoration classes from the object index", async () => {
// Frontmatter (and thus the indexed page decoration) only appears after
// the external edit below -- this is what the initial loadPage sees vs.
// what the live path must pick up without requiring re-navigation.
let diskText = "hello world\n";
const metaBeforeEdit = pageMeta("2026-01-01T00:00:00.000");
const metaAfterEdit = {
...pageMeta("2026-01-01T00:00:05.000"),
pageDecoration: { cssClasses: ["journal-page"] },
} as PageMeta;
let enrichedMeta = metaBeforeEdit;
const client = makeClientStub({
initialDoc: "",
readPage: async () => ({
text: diskText,
meta: pageMeta("2026-01-01T00:00:00.000"),
}),
hasFullIndexCompleted: async () => true,
getObjectByRef: async () => enrichedMeta,
});
client.currentPathValue = "index.md";
const cm = new ContentManager(client as unknown as Client);
await cm.loadPage({ path: "index.md" }, false);
expect(client.viewState.current?.meta).toEqual(metaBeforeEdit);
diskText = "hello world\nExternal line\n";
enrichedMeta = metaAfterEdit;
await cm.reloadPageContent();
// Without navigating away and back, viewState.current.meta must already
// reflect the post-edit frontmatter, and the body decoration classes
// derived from it.
expect(client.viewState.current?.meta).toEqual(metaAfterEdit);
expect(
(globalThis as unknown as { document: { body: { className: string } } })
.document.body.className,
).toBe("journal-page");
});
});
+161 -46
View File
@@ -22,7 +22,10 @@ import {
createEditorState,
externalUpdate,
} from "./codemirror/editor_state.ts";
import { externalSource } from "./codemirror/external_presence.ts";
import { diffAndPrepareChanges } from "./codemirror/cm_util.ts";
import { computeExternalChanges } from "./external_merge.ts";
import { parsePageMetaLastModified } from "./lib/page_meta.ts";
import { DocumentEditor } from "./document_editor.ts";
import { fsEndpoint } from "./spaces/constants.ts";
import { parseMarkdown } from "./markdown_parser/parser.ts";
@@ -41,6 +44,14 @@ export class ContentManager {
documentEditor: DocumentEditor | null = null;
saveTimeout?: ReturnType<typeof setTimeout>;
private scrollRestoreCleanup?: () => void;
// Last content known to be on disk (base for 3-way external merges)
private lastKnownDiskText = "";
// lastModified backing lastKnownDiskText, used to reject an apply whose
// fetched content is older than what's already applied (out-of-order
// resolution of overlapping reloads for the same page)
private lastKnownDiskModified?: number;
// lastModified of our own most recent write, for echo suppression
lastSavedHash?: number;
debouncedUpdateEvent = throttle(() => {
this.client.eventHook
.dispatchEvent("editor:updated")
@@ -95,12 +106,15 @@ export class ContentManager {
"editor:pageSaving",
this.client.currentName(),
);
const text = this.client.editorView.state.sliceDoc(0);
this.client.space
.writePage(
this.client.currentName(),
this.client.editorView.state.sliceDoc(0),
)
.writePage(this.client.currentName(), text)
.then(async (meta) => {
this.lastKnownDiskText = text;
this.lastSavedHash = parsePageMetaLastModified(
meta.lastModified,
);
this.lastKnownDiskModified = this.lastSavedHash;
this.client.ui.viewDispatch({ type: "page-saved" });
await this.client.dispatchAppEvent(
"editor:pageSaved",
@@ -337,6 +351,8 @@ export class ContentManager {
}
}
this.lastSavedHash = parsePageMetaLastModified(doc.meta.lastModified);
// This could create an invalid editor state, but that doesn't matter, we'll update it later
this.switchToPageEditor();
@@ -355,42 +371,15 @@ export class ContentManager {
path: path,
});
// Fetch the meta which includes the possibly indexed stuff, like page
// decorations
if (await this.client.objectIndex.hasFullIndexCompleted()) {
try {
const enrichedMeta =
(await this.client.objectIndex.getObjectByRef(
pageName,
"page",
pageName,
)) ?? doc.meta;
const body = document.body;
body.removeAttribute("class");
if (enrichedMeta.pageDecoration?.cssClasses) {
body.className = enrichedMeta.pageDecoration.cssClasses
.join(" ")
.replaceAll(/[^a-zA-Z0-9-_ ]/g, "");
}
this.client.ui.viewDispatch({
type: "update-current-page-meta",
meta: enrichedMeta,
});
// Trigger editor re-render to update Lua widgets with the new metadata
this.client.editorView.dispatch({});
} catch (e: any) {
console.log(
`There was an error trying to fetch enriched metadata: ${e.message}`,
);
}
}
await this.refreshCurrentPageMeta(pageName, doc.meta);
// When loading a different page OR if the page is read-only (in which case we don't want to apply local patches, because there's no point)
if (loadingDifferentPath || doc.meta.perm === "ro") {
// Fresh state, nothing to diff against yet: doc.text *is* the new base.
this.lastKnownDiskText = doc.text;
this.lastKnownDiskModified = parsePageMetaLastModified(
doc.meta.lastModified,
);
const editorState = createEditorState(
this.client,
pageName,
@@ -399,8 +388,13 @@ export class ContentManager {
);
this.client.editorView.setState(editorState);
} else {
// Just apply minimal patches so that the cursor is preserved
this.applyExternalPatches(doc.text);
// Same-page reload: applyExternalPatches diffs against the base from
// the *previous* load (this.lastKnownDiskText, untouched above) and
// updates it to doc.text itself.
this.applyExternalPatches(
doc.text,
parsePageMetaLastModified(doc.meta.lastModified),
);
}
this.client.space.watchFile(path);
@@ -510,16 +504,137 @@ export class ContentManager {
});
}
// Like setEditorText, but marks the transaction as an external update (e.g.
// a page re-fetch from storage) so the save-on-change handler skips it and
// we avoid an immediate re-save loop.
private applyExternalPatches(newText: string) {
// Applies an external (storage-side) content change as a minimal,
// cursor-preserving transaction: a 3-way merge against the last known
// on-disk text so unsaved local edits survive. The transaction is marked
// externalUpdate (skips the save-on-change handler) and isolated in undo
// history, so a single undo reverts exactly this external change.
// Returns whether a change was actually dispatched, so callers can skip
// follow-up work (meta refresh, notifications) for a dropped/no-op apply.
private applyExternalPatches(
newText: string,
modified: number | undefined,
source = "external",
): boolean {
if (
modified !== undefined &&
this.lastKnownDiskModified !== undefined &&
modified < this.lastKnownDiskModified
) {
// Two reloads for this page were in flight and this older one resolved
// last (e.g. a direct reloadEditor racing an SSE-triggered
// reloadPageContent). Applying it would revert already-applied,
// newer content.
console.log(
"Dropping stale external patch, older than already-applied content",
{ source, modified, lastKnownDiskModified: this.lastKnownDiskModified },
);
return false;
}
const currentText = this.client.editorView.state.sliceDoc();
const allChanges = diffAndPrepareChanges(currentText, newText);
const changes = computeExternalChanges(
this.lastKnownDiskText,
newText,
currentText,
);
this.lastKnownDiskText = newText;
this.lastKnownDiskModified = modified;
if (changes.empty) {
return false;
}
this.client.editorView.dispatch({
changes: allChanges,
annotations: [isolateHistory.of("full"), externalUpdate.of(true)],
changes,
annotations: [
isolateHistory.of("full"),
externalUpdate.of(true),
externalSource.of(source),
],
});
return true;
}
// Fetch the current page's content from storage and apply it as an
// external patch -- the live-edit path (no full loadPage, no editor state
// rebuild). Still refreshes enriched page meta and dispatches
// editor:pageReloaded when a real change was applied, so plugs and
// frontmatter-derived UI (page decorations) stay in sync with the file
// that changed on disk, per the documented contract of that event.
async reloadPageContent(source = "external"): Promise<void> {
const path = this.client.currentPath();
// Defensive, not currently reachable from the file:changed listener
// (which already gates on isMarkdownPath before calling in): kept so
// this method stays safe to call directly with an unchecked path, which
// future callers (e.g. an SSE push handler) may do.
if (!isMarkdownPath(path)) {
return this.reloadEditor();
}
const doc = await this.client.space.readPage(getNameFromPath(path));
// The user may have navigated to a different page while this fetch was
// in flight. Applying now would diff/merge page-A content against
// whatever page is currently open -- bail out rather than corrupt it.
if (this.client.currentPath() !== path) {
return;
}
const pageName = getNameFromPath(path);
const applied = this.applyExternalPatches(
doc.text,
parsePageMetaLastModified(doc.meta.lastModified),
source,
);
if (!applied) {
return;
}
await this.refreshCurrentPageMeta(pageName, doc.meta);
// Note: dispatched asynchronously deliberately (not waiting for
// results), matching the full-reload path in loadPage.
this.client.eventHook
.dispatchEvent("editor:pageReloaded", pageName, pageName)
.catch(console.error);
}
// Re-fetches enriched (indexed) page meta -- e.g. frontmatter-derived page
// decorations -- and pushes it into viewState.current.meta and the body's
// decoration classes. Shared by loadPage and the live reloadPageContent
// path so an external edit's frontmatter changes show up without
// requiring navigation away and back.
private async refreshCurrentPageMeta(
pageName: string,
fallbackMeta: PageMeta,
): Promise<void> {
if (!(await this.client.objectIndex.hasFullIndexCompleted())) {
return;
}
try {
const enrichedMeta =
(await this.client.objectIndex.getObjectByRef(
pageName,
"page",
pageName,
)) ?? fallbackMeta;
const body = document.body;
body.removeAttribute("class");
if (enrichedMeta.pageDecoration?.cssClasses) {
body.className = enrichedMeta.pageDecoration.cssClasses
.join(" ")
.replaceAll(/[^a-zA-Z0-9-_ ]/g, "");
}
this.client.ui.viewDispatch({
type: "update-current-page-meta",
meta: enrichedMeta,
});
// Trigger editor re-render to update Lua widgets with the new metadata
this.client.editorView.dispatch({});
} catch (e: any) {
console.log(
`There was an error trying to fetch enriched metadata: ${e.message}`,
);
}
}
private navigateWithinPage(pageState: LocationState) {
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import { computeExternalChanges } from "./external_merge.ts";
function apply(
current: string,
cs: ReturnType<typeof computeExternalChanges>,
): string {
return cs.apply(Text.of(current.split("\n"))).toString();
}
describe("computeExternalChanges", () => {
it("applies external change to a pristine doc", () => {
const base = "Hello world\n";
const disk = "Hello world\nExternal line\n";
const cs = computeExternalChanges(base, disk, base);
expect(apply(base, cs)).toBe(disk);
});
it("returns an empty set when disk matches current (echo)", () => {
const base = "One\n";
const disk = "One\nTwo\n";
const cs = computeExternalChanges(base, disk, disk);
expect(cs.empty).toBe(true);
expect(apply(disk, cs)).toBe(disk);
});
it("preserves non-overlapping local edits", () => {
const base = "alpha\nbeta\ngamma\n";
const disk = "alpha\nbeta\ngamma\nexternal\n"; // external append
const current = "ALPHA\nbeta\ngamma\n"; // local edit at top
const cs = computeExternalChanges(base, disk, current);
expect(apply(current, cs)).toBe("ALPHA\nbeta\ngamma\nexternal\n");
});
it("keeps local insertion when external edit touches an adjacent region", () => {
const base = "one two three";
const disk = "one 2 three"; // external replaced "two"
const current = "one two three four"; // local appended
const cs = computeExternalChanges(base, disk, current);
expect(apply(current, cs)).toBe("one 2 three four");
});
it("survives external and local edits to the same region without crashing", () => {
const base = "shared text here";
const disk = "shared TEXT here"; // external
const current = "shared texts here"; // local, overlapping
const cs = computeExternalChanges(base, disk, current);
const merged = apply(current, cs);
// Exact outcome of a direct overlap is defined by ChangeSet.map; the
// invariants we require: no throw, local "texts" influence not silently
// reverted to base, and the result contains the unmodified suffix.
expect(merged).toContain(" here");
});
it("handles disk == base with local edits (nothing external to do)", () => {
const base = "stable\n";
const current = "stable\nlocal\n";
const cs = computeExternalChanges(base, base, current);
expect(cs.empty).toBe(true);
expect(apply(current, cs)).toBe(current);
});
it("keeps a local insertion right at the edge of an external replacement", () => {
const base = "shared text here";
const disk = "shared TEXT here"; // external uppercases "text"
const current = "shared texty here"; // local insertion of "y" right after "text"
const cs = computeExternalChanges(base, disk, current);
expect(apply(current, cs)).toBe("shared TEXTy here");
});
it("does not silently drop local content when a local edit fully replaces an externally-touched word", () => {
const base = "shared text here";
const disk = "shared TEXT here"; // external uppercases "text"
const current = "shared banana here"; // local replaces "text" with "banana"
const cs = computeExternalChanges(base, disk, current);
const merged = apply(current, cs);
// Direct overlaps of this kind are inherently ambiguous for a diff-based
// 3-way merge (see design note); we only require that neither side's
// content is silently discarded and the surrounding text is intact.
expect(merged).toContain("banana");
expect(merged).toContain("shared ");
expect(merged).toContain(" here");
});
it("applies a clean external change on top of an unrelated prior local edit", () => {
const base = "line1\nline2\nline3\n";
const disk = "line1\nline2\nline3\nline4\n"; // external append
const current = "line1\nLINE2\nline3\n"; // unrelated local edit in the middle
const cs = computeExternalChanges(base, disk, current);
expect(apply(current, cs)).toBe("line1\nLINE2\nline3\nline4\n");
});
it("applies multiple sequential external changes correctly (base tracking)", () => {
const base1 = "start\n";
const disk1 = "start\nmid\n";
const current1 = base1;
const cs1 = computeExternalChanges(base1, disk1, current1);
const afterFirst = apply(current1, cs1);
expect(afterFirst).toBe(disk1);
// Second external change is diffed against the *new* base (disk1), not
// the original base1 - simulating ContentManager updating its tracked base.
const disk2 = "start\nmid\nend\n";
const cs2 = computeExternalChanges(disk1, disk2, afterFirst);
expect(apply(afterFirst, cs2)).toBe(disk2);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { ChangeSet } from "@codemirror/state";
import { diffAndPrepareChanges } from "./codemirror/cm_util.ts";
/**
* Three-way merge for externally-changed page content.
*
* `base` is the last known on-disk text (tracked by ContentManager), `disk`
* is the new on-disk text, and `current` is the editor doc (possibly holding
* unsaved local edits relative to base). Both diffs are taken against base;
* the external change set is mapped through the local one so local edits are
* preserved. When an external and a local edit touch the same range, neither
* is discarded: both fragments land in the result, concatenated in position
* order (see the "does not silently drop local content..." test in
* external_merge.test.ts for the exact shape) rather than one overwriting
* the other. The returned ChangeSet applies to `current`.
*/
export function computeExternalChanges(
base: string,
disk: string,
current: string,
): ChangeSet {
if (disk === current) {
return ChangeSet.empty(current.length);
}
const external = ChangeSet.of(diffAndPrepareChanges(base, disk), base.length);
if (current === base) {
return external;
}
const local = ChangeSet.of(diffAndPrepareChanges(base, current), base.length);
// before: true keeps a local insertion on the user's side of the merge
// when it sits at the same position as an external edit.
return external.map(local, true);
}
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, test } from "vitest";
import { history, isolateHistory, redo, undo } from "@codemirror/commands";
import { EditorState, Transaction } from "@codemirror/state";
import { computeExternalChanges } from "./external_merge.ts";
import {
externalSource,
externalUndoField,
} from "./codemirror/external_presence.ts";
// content_manager.ts itself pulls in the full editor extension chain
// (codemirror/editor_state.ts -> lua_widget.ts -> widget_sandbox_iframe.ts),
// which touches `document` at module scope and can't load under Node/vitest.
// These tests instead exercise the same CodeMirror transaction shape that
// ContentManager.applyExternalPatches dispatches -- isolateHistory.of("full")
// plus a changeset from computeExternalChanges -- against a headless
// EditorState, to verify the undo-isolation guarantee the feature promises.
describe("external patch transaction shape (headless EditorState)", () => {
function dispatchExternalPatch(
state: EditorState,
base: string,
disk: string,
): EditorState {
const current = state.sliceDoc();
const changes = computeExternalChanges(base, disk, current);
return state.update({
changes,
annotations: [isolateHistory.of("full")],
}).state;
}
test("undo inverts exactly the external change, even when adjacent to local typing", () => {
// Without isolateHistory, CodeMirror's history groups adjacent,
// userEvent-less transactions issued within its group-delay window into
// a single undo step (see @codemirror/commands HistoryState.addChanges /
// isAdjacent) -- exactly what would happen here, since the external
// append lands right at the end of what the user just typed. This is
// the scenario isolateHistory.of("full") exists to prevent.
let state = EditorState.create({
doc: "Hello",
extensions: [history()],
});
// User types locally (goes into normal history)
state = state.update({
changes: { from: 5, insert: " typed" },
}).state;
expect(state.sliceDoc()).toBe("Hello typed");
// An external write appends "!" right where the local insertion starts --
// computeExternalChanges' `before: true` mapping keeps the local
// insertion on the user's side, landing the external insert immediately
// adjacent to it once mapped onto the current doc.
state = dispatchExternalPatch(state, "Hello", "Hello!");
expect(state.sliceDoc()).toBe("Hello! typed");
// A command's "target" only needs { state, dispatch }, so we can drive
// undo() headlessly by threading `state` through a small local stub.
const target = {
get state() {
return state;
},
dispatch: (tr: { state: EditorState }) => {
state = tr.state;
},
};
// One undo should revert only the external change...
expect(undo(target as any)).toBe(true);
expect(state.sliceDoc()).toBe("Hello typed");
// ...and a second undo reverts the user's own typing
expect(undo(target as any)).toBe(true);
expect(state.sliceDoc()).toBe("Hello");
});
test("echo (disk matches current) yields an empty changeset, nothing to dispatch", () => {
const base = "Hello world\n";
const disk = "Hello world\nExternal line\n";
// Simulate our own write already having landed in the editor (echo)
const current = disk;
const changes = computeExternalChanges(base, disk, current);
expect(changes.empty).toBe(true);
});
});
describe("undo/redo of an external edit preserves the user's cursor (regression)", () => {
// Mirrors ContentManager.applyExternalPatches's actual transaction shape
// exactly, including externalSource, which externalUndoField needs in
// order to track the edit.
function dispatchExternalPatch(
state: EditorState,
base: string,
disk: string,
): EditorState {
const current = state.sliceDoc();
const changes = computeExternalChanges(base, disk, current);
return state.update({
changes,
annotations: [isolateHistory.of("full"), externalSource.of("external")],
}).state;
}
// Simulates client/codemirror/external_presence.ts's externalUndoCursorFix:
// after a transaction lands, if the field flagged a correction, apply it
// as an immediate follow-up transaction, exactly like the production
// EditorView.updateListener does.
function applyPendingCorrection(state: EditorState): EditorState {
const { correction } = state.field(externalUndoField);
if (!correction) return state;
return state.update({
selection: correction,
annotations: Transaction.addToHistory.of(false),
}).state;
}
test("undo after the cursor has moved keeps the cursor where the user left it, not where it was before the external edit", () => {
let state = EditorState.create({
doc: "Hello world\n",
extensions: [history(), externalUndoField],
});
// Page just loaded: cursor sits at 0, untouched.
expect(state.selection.main.head).toBe(0);
// External write lands while the cursor is still at 0.
state = dispatchExternalPatch(
state,
"Hello world\n",
"Hello world\nExternal line\n",
);
expect(state.sliceDoc()).toBe("Hello world\nExternal line\n");
expect(state.selection.main.head).toBe(0);
// *Then* the user moves the cursor -- the step a naive regression test
// (or the existing e2e's click-right-before-undo) skips, which is
// exactly why it missed this bug.
state = state.update({ selection: { anchor: 6 } }).state;
expect(state.selection.main.head).toBe(6);
const target = {
get state() {
return state;
},
dispatch: (tr: { state: EditorState }) => {
state = tr.state;
state = applyPendingCorrection(state);
},
};
expect(undo(target as any)).toBe(true);
expect(state.sliceDoc()).toBe("Hello world\n");
// The bug: CodeMirror's default undo would restore the selection
// recorded before the external edit landed, i.e. position 0.
expect(state.selection.main.head).toBe(6);
// Redo brings the edit back and, symmetrically, must leave the cursor
// wherever the user has it now rather than jumping elsewhere.
state = state.update({ selection: { anchor: 3 } }).state;
expect(redo(target as any)).toBe(true);
expect(state.sliceDoc()).toBe("Hello world\nExternal line\n");
expect(state.selection.main.head).toBe(3);
});
test("undo of the user's own edit still restores the pre-edit selection", () => {
let state = EditorState.create({
doc: "Hello",
extensions: [history(), externalUndoField],
});
state = state.update({ changes: { from: 5, insert: " typed" } }).state;
expect(state.sliceDoc()).toBe("Hello typed");
// Cursor moves away after typing, before undo -- same shape as the
// external-edit scenario above, but here the "jump back to where you
// were before the edit" behaviour is correct and must survive.
state = state.update({ selection: { anchor: 8 } }).state;
const target = {
get state() {
return state;
},
dispatch: (tr: { state: EditorState }) => {
state = tr.state;
state = applyPendingCorrection(state);
},
};
expect(undo(target as any)).toBe(true);
expect(state.sliceDoc()).toBe("Hello");
expect(state.selection.main.head).toBe(0);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import { localDateString } from "@silverbulletmd/silverbullet/lib/dates";
import { parsePageMetaLastModified } from "./page_meta.ts";
describe("parsePageMetaLastModified", () => {
test("round-trips a PageMeta.lastModified string back to the original epoch ms", () => {
// PageMeta.lastModified is built from a numeric FileMeta.lastModified via
// localDateString (see space.ts:fileMetaToPageMeta) -- this is the same
// conversion echo suppression depends on to compare against the numeric
// hash carried by the file:changed event.
const epochMs = Date.parse("2026-08-04T10:20:30.456");
const asStoredString = localDateString(new Date(epochMs));
expect(parsePageMetaLastModified(asStoredString)).toBe(epochMs);
});
test("returns undefined for an empty string (new/unsaved page)", () => {
expect(parsePageMetaLastModified("")).toBeUndefined();
});
});
+10
View File
@@ -0,0 +1,10 @@
// PageMeta.lastModified is indexed as a local-time string (see
// space.ts:fileMetaToPageMeta / localDateString), not the epoch number the
// wire-level FileMeta and file:changed event hashes use. Parsing it back
// recovers a comparable number; a Date-Time string without a zone offset is
// parsed as local time per the ES spec, which is exactly how it was built.
export function parsePageMetaLastModified(
lastModified: string,
): number | undefined {
return lastModified ? Date.parse(lastModified) || undefined : undefined;
}