From 2f0a371056677b27e8c84c851765d4c14d990cb4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 13:54:06 +0200 Subject: [PATCH 1/2] test(capture): capture and Undo after a table cell goes stale Obsidian keeps a Live Preview table cell after its table leaves the note's view, in Source mode or once the tab showed another note. A write through the note then reaches that cell at its old offsets. The journeys toggle Source mode or go Back after another note, change another row on disk, capture, and Undo. On master the other row loses text. A third journey captures, opens a note with a wide header cell in the same tab, edits that cell, goes Back, and clicks Undo. On master the Undo is lost. The unit cases model a cell no longer in the document, once for a capture and once for an Undo. --- src/commands.test.ts | 34 +++++++++- tests/e2e/table-cell-capture.test.ts | 97 ++++++++++++++++++++++++++++ tests/mocks/captureEditor.ts | 19 ++++-- 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/src/commands.test.ts b/src/commands.test.ts index 2ec9a78e..21b781a4 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -308,7 +308,7 @@ describe("capture", () => { text: "00:20:34 \\| a \\| b \\\\\\| c ", created: false, }); - expect(cell.getValue()).toBe("anchor00:20:34 | a | b \\\\| c "); + expect(cell.editor.getValue()).toBe("anchor00:20:34 | a | b \\\\| c "); expect(editor.getValue()).toBe( "| Time | Note |\n| --- | --- |\n| Start | anchor00:20:34 \\| a \\| b \\\\\\| c |\nAfter", ); @@ -319,7 +319,7 @@ describe("capture", () => { run("capture-timestamp"); const first = editor.getValue(); run("capture-segment-10s"); - expect(cell.getValue()).toBe("anchor- 00:20:34- 00:20:24-00:20:34"); + expect(cell.editor.getValue()).toBe("anchor- 00:20:34- 00:20:24-00:20:34"); await undo(); @@ -343,6 +343,36 @@ describe("capture", () => { expect(editor.getValue()).toBe("Notes\n"); }); + it("ends a cell no longer in the document, and writes at the note's caret with an exact Undo", async () => { + const { plugin, editor, cell } = await inCell(); + plugin.settings.timestamp.template = "{{time}} | cut\n"; + cell.containerEl.isConnected = false; + editor.replaceRange("Synced\n", editor.offsetToPos(0)); + editor.setSelection(editor.offsetToPos(editor.getValue().indexOf("anchor") + 6)); + const before = editor.getValue(); + + expect(await plugin.capture.timestamp()).toMatchObject({ text: "00:20:34 \\| cut " }); + + expect(editor.getValue()).toBe( + "Synced\n| Time | Note |\n| --- | --- |\n| Start | anchor00:20:34 \\| cut |\nAfter", + ); + expect(cell.editor.getValue()).toBe("anchor"); + await undo(); + expect(editor.getValue()).toBe(before); + }); + + it("ends a detached cell whose old range covers the capture before Undo puts the note back", async () => { + const { run, openNote, focusPodNotes } = await loadPlugin(playing); + const { editor } = openNote("Journal.md", "Notes\n"); + focusPodNotes(); + run("capture-timestamp"); + editInTableCell(editor, "Notes\n- 00:20:34").containerEl.isConnected = false; + + await undo(); + + expect(editor.getValue()).toBe("Notes\n"); + }); + it("falls back to the note editor when the cell's replaceSelection is not a function", async () => { const { plugin, openNote, focusPodNotes } = await loadPlugin(playing); const { editor } = openNote("Table.md", TABLE, TABLE.indexOf("anchor") + 6); diff --git a/tests/e2e/table-cell-capture.test.ts b/tests/e2e/table-cell-capture.test.ts index 58377afa..63cf6683 100644 --- a/tests/e2e/table-cell-capture.test.ts +++ b/tests/e2e/table-cell-capture.test.ts @@ -368,6 +368,103 @@ describe("Live Preview table captures", () => { ).toEqual([before, before]); }); + test.each(["Source mode", "the tab showed another note"])( + "#473 a capture after %s and a change made elsewhere keeps the table and its Undo", + async (away) => { + const { obsidian, sandbox } = getContext(); + const table = + "# Captures\n\n| Moment | Notes |\n| ------ | ------ |\n| Start | anchor |\n| Later | other |\n\nAfter table\n"; + const path = await setUp({ content: table }); + const view = `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view`; + const changeElsewhere = `app.vault.process(app.vault.getAbstractFileByPath(${jsLiteral(path)}), (text) => text.replace("| other |", "| other, edited elsewhere |"))`; + if (away === "Source mode") { + await obsidian.exec("command", { id: "editor:toggle-source" }); + await obsidian.dev.evalJsonAsync(`${changeElsewhere}.then(() => true)`); + } else { + const elsewhere = sandbox.path("Elsewhere.md"); + await writeVaultFile(obsidian, elsewhere, "Elsewhere\n"); + await obsidian.dev.evalJsonAsync(`(async () => { + await ${view}.leaf.openFile(app.vault.getAbstractFileByPath(${jsLiteral(elsewhere)})); + await ${changeElsewhere}; + return true; + })()`); + await obsidian.exec("command", { id: "app:go-back" }); + } + const before = table.replace("| other |", "| other, edited elsewhere |"); + expect( + await eventually( + obsidian, + () => obsidian.dev.evalJson(`${view}.editor.getValue()`), + before, + 10_000, + ), + ).toBe(before); + expect( + await obsidian.dev.evalJson( + `${view}.editor.editorComponent.tableCell?.containerEl.isConnected ?? null`, + ), + "Obsidian still holds a cell no longer in the document", + ).toBe(false); + + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + + const captured = before.replace("anchor", "anchor00:00:30 \\| cut "); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), captured, 10_000), + ).toBe(captured); + await click(obsidian, lastUndo); + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), before, 10_000), + ).toBe(before); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, + 90_000, + ); + + test("#473 Undo puts the note back after a cell of another note in its tab went stale", async () => { + const { obsidian, sandbox } = getContext(); + const wide = sandbox.path("Wide header.md"); + const header = Array(80).fill("wide").join(" "); + await writeVaultFile(obsidian, wide, `| ${header} |\n| --- |\n| x |\n`); + const path = await setUp(); + const view = (file: string) => + `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(file)})?.view`; + const before = await obsidian.dev.evalJson(`${view(path)}.editor.getValue()`); + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + const captured = await obsidian.dev.evalJson(`${view(path)}.editor.getValue()`); + await obsidian.dev.evalJsonAsync(`(async () => { + await ${view(path)}.leaf.openFile(app.vault.getAbstractFileByPath(${jsLiteral(wide)})); + return true; + })()`); + await click(obsidian, { + name: "wide header cell", + elements: `[...${view(wide)}.containerEl.querySelectorAll(".markdown-source-view th")]`, + }); + await obsidian.exec("command", { id: "app:go-back" }); + expect( + await eventually( + obsidian, + () => obsidian.dev.evalJson(`${view(path)}.editor.getValue()`), + captured, + 5000, + ), + ).toBe(captured); + expect( + await obsidian.dev.evalJson(`(() => { + const cell = ${view(path)}.editor.editorComponent.tableCell; + return { attached: cell.containerEl.isConnected, text: cell.editor.getValue() }; + })()`), + "the tab still holds the other note's header cell", + ).toEqual({ attached: false, text: header }); + + await click(obsidian, lastUndo); + + expect( + await eventually(obsidian, () => readVaultText(obsidian, path), before, 10_000), + ).toBe(before); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }); + test("#445 Source mode still escapes table captures and preserves Undo", async () => { const { obsidian } = getContext(); const path = await setUp({ source: true }); diff --git a/tests/mocks/captureEditor.ts b/tests/mocks/captureEditor.ts index 418770cf..63346b9e 100644 --- a/tests/mocks/captureEditor.ts +++ b/tests/mocks/captureEditor.ts @@ -42,8 +42,9 @@ export function fakeEditor(text: string, caret = text.length) { /** * A Live Preview cell has an editor of its own that holds its pipes unescaped, and Obsidian escapes - * them as it writes the cell to the note. An edit made through the note's editor inside - * the cell reaches the cell only later, so here it never lands. + * them as it writes the cell to the note at the offsets the cell had when it opened. An edit + * made through the note's editor inside the cell reaches the cell only later, so here it + * is dropped until Obsidian ends the cell. */ export function editInTableCell(note: ReturnType, cell: string) { const start = note.getValue().indexOf(cell); @@ -52,7 +53,7 @@ export function editInTableCell(note: ReturnType, cell: strin text.replace(/(\\*)\|/g, (pipe, slashes: string) => slashes.length % 2 ? pipe : `${slashes}\\|`, ); - const write = note.replaceRange; + const { replaceSelection, replaceRange: write } = note; const cellEditor = { text: cell, getValue() { @@ -67,12 +68,20 @@ export function editInTableCell(note: ReturnType, cell: strin const inCell = (from: EditorPosition, to: EditorPosition) => note.posToOffset(from) >= start && note.posToOffset(to) <= start + escape(cellEditor.text).length; + const tableCell = { editor: cellEditor, containerEl: { isConnected: true } }; + const editorComponent = { + tableCell: tableCell as typeof tableCell | null, + destroyTableCell() { + editorComponent.tableCell = null; + Object.assign(note, { replaceSelection, replaceRange: write }); + }, + }; Object.assign(note, { - editorComponent: { tableCell: { editor: cellEditor } }, + editorComponent, replaceSelection: () => {}, replaceRange(text: string, from: EditorPosition, to = from) { if (!inCell(from, to)) write(text, from, to); }, }); - return cellEditor; + return tableCell; } From d3153f50299d00a4455f4eee035d1b168ceeaa5f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 13:54:06 +0200 Subject: [PATCH 2/2] fix(capture): end a detached table cell before writing Source mode and opening another note in the tab replace the note's view without ending its Live Preview table cell. That cell keeps the table's old copy at its old place. A capture through it rewrote the table from that copy, and Obsidian handed the note editor's writes in the cell's old range to it, so a capture or an Undo could be lost or land in the wrong place. Before a capture or an Undo writes, a cell whose container is no longer in the document is ended with Obsidian's destroyTableCell, as Obsidian does when the note's editor takes focus. The capture then goes to the note's caret, escaped as in Source mode. A cell still in the document keeps taking captures, also in a background tab. --- src/commands.test.ts | 10 ++++++++++ src/services/CaptureService.ts | 17 +++++++++++++++++ tests/e2e/table-cell-capture.test.ts | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/commands.test.ts b/src/commands.test.ts index 21b781a4..60fad145 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -391,6 +391,16 @@ describe("capture", () => { "| Time | Note |\n| --- | --- |\n| Start | anchor- 00:20:34 |\nAfter", ); }); + + it("still writes through a cell no longer in the document when Obsidian has no destroyTableCell", async () => { + const { run, editor, cell } = await inCell(); + cell.containerEl.isConnected = false; + Reflect.deleteProperty(Reflect.get(editor, "editorComponent"), "destroyTableCell"); + + run("capture-timestamp"); + + expect(cell.editor.getValue()).toBe("anchor- 00:20:34"); + }); }); it("goes to the episode note once the last editor's tab is closed", async () => { diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index 80c766d5..accae5e7 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -101,6 +101,7 @@ export class CaptureService { cursorLine: editor.getCursor("from").line, }); const before = editor.getValue(); + endDetachedTableCell(editor); const cell = tableCellWriter(editor); if (cell) cell(inserted); else editor.replaceSelection(inserted); @@ -217,6 +218,7 @@ export class CaptureService { */ function revertIn(editor: Editor, { before, after }: Snapshot): boolean { if (editor.getValue() !== after) return false; + endDetachedTableCell(editor); let from = 0; while (from < before.length && before[from] === after[from]) from++; let tail = 0; @@ -254,6 +256,21 @@ function tableCellWriter(editor: Editor): ((escaped: string) => void) | null { : null; } +/** + * Obsidian keeps a table cell's editor once its table leaves the note's view, as in Source + * mode or after the tab showed another note. That cell rewrites the table from its old copy + * at its old place, and takes over the note editor's edits there, so it is ended as Obsidian + * ends it when the note's editor takes focus. + */ +function endDetachedTableCell(editor: Editor): void { + const component = field(editor, "editorComponent"); + const cell = field(component, "tableCell"); + const end = field(component, "destroyTableCell"); + if (cell && !field(field(cell, "containerEl"), "isConnected") && typeof end === "function") { + end.call(component); + } +} + function field(value: unknown, key: string): unknown { return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; } diff --git a/tests/e2e/table-cell-capture.test.ts b/tests/e2e/table-cell-capture.test.ts index 63cf6683..3eee3b8f 100644 --- a/tests/e2e/table-cell-capture.test.ts +++ b/tests/e2e/table-cell-capture.test.ts @@ -421,6 +421,24 @@ describe("Live Preview table captures", () => { 90_000, ); + test("#473 a capture into a cell of a note in a background tab keeps that cell open", async () => { + const { obsidian } = getContext(); + const path = await setUp(); + await obsidian.exec("command", { id: "workspace:new-tab" }); + expect( + await obsidian.dev.evalJson(`(() => { + const cell = app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(path)}).view.editor.editorComponent.tableCell; + return { attached: cell.containerEl.isConnected, shown: cell.containerEl.isShown() }; + })()`), + "the cell is in the document but not on screen", + ).toEqual({ attached: true, shown: false }); + + await obsidian.exec("command", { id: "podnotes:capture-timestamp" }); + + await expectCell(path, "anchor00:00:30 \\| cut "); + expect(await cellText(path)).toBe("anchor00:00:30 | cut "); + }); + test("#473 Undo puts the note back after a cell of another note in its tab went stale", async () => { const { obsidian, sandbox } = getContext(); const wide = sandbox.path("Wide header.md");