From f026990b04b307c7f9ef2cec2a6e73c3ab0d04bf Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:13:45 +0200 Subject: [PATCH 1/3] test(settings): reproduce focused text loss during host refresh --- src/ui/settings/PodNotesSettingsTab.test.ts | 29 +++++++++++++++++++++ tests/e2e/settings-tab.test.ts | 6 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 1e1ce0ba..b41e8e2b 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -375,6 +375,35 @@ describe("text fields", () => { expect((await disk()).note.path).toBe("Synced/{{title}}.md"); }); + test.each(["Episode note path", "Episode note template"])( + "a settings refresh keeps the focused %s text and selection until completion", + async (name) => { + fakeTypingTime(); + const { tab, field, sync, writes, disk } = open(); + const input = field(name); + input.focus(); + enter(input, "Local"); + input.setSelectionRange(1, 4); + vi.advanceTimersByTime(500); + expect(await writes()).toBe(1); + + await sync({ note: { path: "Remote path", template: "Remote template" } }); + tab.update(); + + expect(field(name).value).toBe("Local"); + expect([field(name).selectionStart, field(name).selectionEnd]).toEqual([1, 4]); + complete(field(name)); + expect(field(name).value).toBe( + name === "Episode note path" ? "Remote path" : "Remote template", + ); + expect((await disk()).note).toEqual({ + path: "Remote path", + template: "Remote template", + }); + expect(await writes()).toBe(1); + }, + ); + test("Enter completes a one-line edit; in a template it is a new line", async () => { fakeTypingTime(); const { field, writes, disk } = open(); diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index 52214fa9..bf309f56 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -268,7 +268,11 @@ describe("PodNotes settings tab", () => { synced.note.path = remote; await plugin.data().write(synced); await obsidian.dev.evalJsonAsync( - "app.plugins.plugins.podnotes.onExternalSettingsChange().then(() => true)", + `(async () => { + await app.plugins.plugins.podnotes.onExternalSettingsChange(); + app.setting.pluginTabs.find(tab => tab.id === "podnotes").update(); + return true; + })()`, ); expect( await obsidian.dev.evalJson(`({ From 85e27b953f3b2ac0141b023e4e8a4846ff60a45b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:17:26 +0200 Subject: [PATCH 2/3] fix(settings): preserve focused text through synced row refreshes --- src/ui/settings/PodNotesSettingsTab.test.ts | 25 ++++++++ src/ui/settings/PodNotesSettingsTab.ts | 14 ++++- tests/e2e/settings-tab.test.ts | 66 +++++++++++++++++++++ tests/mocks/obsidian.ts | 3 +- 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index b41e8e2b..6b4bb538 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -404,6 +404,31 @@ describe("text fields", () => { }, ); + test.each(["Episode note path", "Episode note template"])( + "typing in %s after a synced refresh saves the new edit on completion", + async (name) => { + fakeTypingTime(); + const { tab, field, sync, writes, disk } = open(); + field(name).focus(); + enter(field(name), "Local"); + vi.advanceTimersByTime(500); + expect(await writes()).toBe(1); + await sync({ note: { path: "Remote path", template: "Remote template" } }); + tab.update(); + + enter(field(name), "Continued"); + if (name === "Episode note path") pressEnter(field(name)); + else field(name).dispatchEvent(new Event("blur")); + + expect(await writes()).toBe(2); + expect((await disk()).note).toEqual( + name === "Episode note path" + ? { path: "Continued", template: "Remote template" } + : { path: "Remote path", template: "Continued" }, + ); + }, + ); + test("Enter completes a one-line edit; in a template it is a new line", async () => { fakeTypingTime(); const { field, writes, disk } = open(); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 5896e0e0..68b11d2a 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -477,7 +477,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; const hidden = () => doc.visibilityState === "hidden" && complete(); const complete = () => { - if (!this.drafts.delete(complete)) return; + if (!this.drafts.delete(complete)) return show(); win.removeEventListener("pagehide", complete); doc.removeEventListener("visibilitychange", hidden); if (pendingSave !== undefined) save(); @@ -537,6 +537,13 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private textRow(name: string, field: TextField, desc?: string): SettingDefinitionRender { + const active = this.containerEl.querySelector( + "input:focus,textarea:focus", + ); + const draft = + active?.name === name + ? { value: active.value, start: active.selectionStart, end: active.selectionEnd } + : undefined; return { name, desc, @@ -544,10 +551,13 @@ export class PodNotesSettingsTab extends PluginSettingTab { let complete = () => {}; let stopPreview = () => {}; const bind = (control: TextComponent | TextAreaComponent) => { + control.inputEl.name = name; control - .setValue(field.read()) + .setValue(draft?.value ?? field.read()) .setPlaceholder(field.placeholder ?? "") .onChange((value) => field.preview?.(value, setting)); + if (draft?.start != null) + control.inputEl.setSelectionRange(draft.start, draft.end); complete = this.bindDraft( control.inputEl, (value) => { diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index bf309f56..ea3f227a 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -291,6 +291,72 @@ describe("PodNotes settings tab", () => { expect((await plugin.data().read()).note?.path).toBe(remote); }); + test.each([ + { + name: "Episode note path", + key: "path", + other: "Episode note template", + selector: "input", + }, + { + name: "Episode note template", + key: "template", + other: "Episode note path", + selector: "textarea", + }, + ] as const)( + "native sync preserves focused $name and a click completes it", + async ({ name, key, other, selector }) => { + const { obsidian, plugin } = getContext(); + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + const target = ui.settingControl(name, selector); + await typeInto(obsidian, target, "Local draft", { replace: true }); + await plugin.waitForData((data) => data.note?.[key] === "Local draft", { + timeoutMs: 5_000, + intervalMs: 100, + }); + await obsidian.dev.evalJsonAsync( + "app.plugins.plugins.podnotes.flushSaves().then(() => true)", + ); + await obsidian.dev.evalJson("document.activeElement.setSelectionRange(1, 4), true"); + + for (const remote of ["First sync", "Second sync"]) { + const synced = await plugin.data().read(); + synced.note = { path: remote, template: remote }; + synced.autoQueue = true; + await plugin.data().write(synced); + expect( + await eventually(obsidian, () => settingValue(obsidian, other), remote), + ).toBe(remote); + expect( + await obsidian.dev.evalJson(`({ + memory: app.plugins.plugins.podnotes.settings.note[${jsLiteral(key)}], + field: document.activeElement.value, + focused: (${target.elements}).includes(document.activeElement), + selection: [document.activeElement.selectionStart, document.activeElement.selectionEnd] + })`), + ).toEqual({ + memory: remote, + field: "Local draft", + focused: true, + selection: [1, 4], + }); + } + + await click( + obsidian, + ui.settingControl("Queue episodes I switch away from", ".checkbox-container"), + ); + expect(await settingValue(obsidian, "Queue episodes I switch away from")).toBe(false); + expect(await settingValue(obsidian, name)).toBe("Second sync"); + await obsidian.dev.evalJsonAsync( + "app.plugins.plugins.podnotes.flushSaves().then(() => true)", + ); + expect((await plugin.data().read()).note?.[key]).toBe("Second sync"); + }, + ); + test("leaving a field saves a return to its original text before the debounce", async () => { const { obsidian, plugin } = getContext(); const initial = "Initial/{{title}}.md"; diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 6bdd2e29..70f8352a 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -677,9 +677,10 @@ export class PluginSettingTab { } display(): void { + const definitions = this.getSettingDefinitions(); this.cleanUp(); this.containerEl.empty(); - for (const definition of this.getSettingDefinitions()) { + for (const definition of definitions) { if (definition.heading) new Setting(this.containerEl).setName(definition.heading).setHeading(); for (const row of definition.items ?? [definition]) { From 809d4c78030d467ff21a5f95049e3581077c43d8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:33:20 +0200 Subject: [PATCH 3/3] fix(settings): consume focused snapshots once and retain selection direction --- src/ui/settings/PodNotesSettingsTab.test.ts | 27 +++++++++++++++++++-- src/ui/settings/PodNotesSettingsTab.ts | 25 +++++++++++++------ tests/e2e/settings-tab.test.ts | 11 ++++++--- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 6b4bb538..b58e0e52 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -383,7 +383,7 @@ describe("text fields", () => { const input = field(name); input.focus(); enter(input, "Local"); - input.setSelectionRange(1, 4); + input.setSelectionRange(1, 4, "backward"); vi.advanceTimersByTime(500); expect(await writes()).toBe(1); @@ -391,7 +391,11 @@ describe("text fields", () => { tab.update(); expect(field(name).value).toBe("Local"); - expect([field(name).selectionStart, field(name).selectionEnd]).toEqual([1, 4]); + expect([ + field(name).selectionStart, + field(name).selectionEnd, + field(name).selectionDirection, + ]).toEqual([1, 4, "backward"]); complete(field(name)); expect(field(name).value).toBe( name === "Episode note path" ? "Remote path" : "Remote template", @@ -404,6 +408,25 @@ describe("text fields", () => { }, ); + test("a refresh keeps a focused number without trying to restore a text selection", async () => { + fakeTypingTime(); + const { tab, field, sync, writes, disk } = open(); + const input = field("Skip back (s)"); + input.focus(); + enter(input, "7"); + vi.advanceTimersByTime(500); + expect(await writes()).toBe(1); + await sync({ skipBackwardLength: 15 }); + tab.update(); + + expect(field("Skip back (s)").value).toBe("7"); + expect(field("Skip back (s)").selectionStart).toBeNull(); + complete(field("Skip back (s)")); + expect(field("Skip back (s)").value).toBe("15"); + expect((await disk()).skipBackwardLength).toBe(15); + expect(await writes()).toBe(1); + }); + test.each(["Episode note path", "Episode note template"])( "typing in %s after a synced refresh saves the new edit on completion", async (name) => { diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 68b11d2a..4658bc39 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -477,10 +477,11 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; const hidden = () => doc.visibilityState === "hidden" && complete(); const complete = () => { - if (!this.drafts.delete(complete)) return show(); - win.removeEventListener("pagehide", complete); - doc.removeEventListener("visibilitychange", hidden); - if (pendingSave !== undefined) save(); + if (this.drafts.delete(complete)) { + win.removeEventListener("pagehide", complete); + doc.removeEventListener("visibilitychange", hidden); + if (pendingSave !== undefined) save(); + } show(); }; input.addEventListener("input", () => { @@ -540,9 +541,14 @@ export class PodNotesSettingsTab extends PluginSettingTab { const active = this.containerEl.querySelector( "input:focus,textarea:focus", ); - const draft = + let draft = active?.name === name - ? { value: active.value, start: active.selectionStart, end: active.selectionEnd } + ? { + value: active.value, + start: active.selectionStart, + end: active.selectionEnd, + direction: active.selectionDirection, + } : undefined; return { name, @@ -557,7 +563,12 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setPlaceholder(field.placeholder ?? "") .onChange((value) => field.preview?.(value, setting)); if (draft?.start != null) - control.inputEl.setSelectionRange(draft.start, draft.end); + control.inputEl.setSelectionRange( + draft.start, + draft.end, + draft.direction ?? undefined, + ); + draft = undefined; complete = this.bindDraft( control.inputEl, (value) => { diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index ea3f227a..8d4d8120 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -319,7 +319,9 @@ describe("PodNotes settings tab", () => { await obsidian.dev.evalJsonAsync( "app.plugins.plugins.podnotes.flushSaves().then(() => true)", ); - await obsidian.dev.evalJson("document.activeElement.setSelectionRange(1, 4), true"); + await obsidian.dev.evalJson( + 'document.activeElement.setSelectionRange(1, 4, "backward"), true', + ); for (const remote of ["First sync", "Second sync"]) { const synced = await plugin.data().read(); @@ -334,13 +336,13 @@ describe("PodNotes settings tab", () => { memory: app.plugins.plugins.podnotes.settings.note[${jsLiteral(key)}], field: document.activeElement.value, focused: (${target.elements}).includes(document.activeElement), - selection: [document.activeElement.selectionStart, document.activeElement.selectionEnd] + selection: [document.activeElement.selectionStart, document.activeElement.selectionEnd, document.activeElement.selectionDirection] })`), ).toEqual({ memory: remote, field: "Local draft", focused: true, - selection: [1, 4], + selection: [1, 4, "backward"], }); } @@ -354,6 +356,9 @@ describe("PodNotes settings tab", () => { "app.plugins.plugins.podnotes.flushSaves().then(() => true)", ); expect((await plugin.data().read()).note?.[key]).toBe("Second sync"); + await closeSettings(obsidian); + await openPodNotesSettings(obsidian); + expect(await settingValue(obsidian, name)).toBe("Second sync"); }, );