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
77 changes: 77 additions & 0 deletions src/ui/settings/PodNotesSettingsTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,83 @@ 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, "backward");
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,
field(name).selectionDirection,
]).toEqual([1, 4, "backward"]);
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("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) => {
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();
Expand Down
31 changes: 26 additions & 5 deletions src/ui/settings/PodNotesSettingsTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,10 +477,11 @@ export class PodNotesSettingsTab extends PluginSettingTab {
};
const hidden = () => doc.visibilityState === "hidden" && complete();
const complete = () => {
if (!this.drafts.delete(complete)) return;
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", () => {
Expand Down Expand Up @@ -537,17 +538,37 @@ export class PodNotesSettingsTab extends PluginSettingTab {
}

private textRow(name: string, field: TextField, desc?: string): SettingDefinitionRender {
const active = this.containerEl.querySelector<HTMLInputElement | HTMLTextAreaElement>(
"input:focus,textarea:focus",
);
let draft =
active?.name === name
? {
value: active.value,
start: active.selectionStart,
end: active.selectionEnd,
direction: active.selectionDirection,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Number fields crash on refresh snapshot

Medium Severity

A focused number row snapshots selectionStart, selectionEnd, and selectionDirection during rebuild. Chromium throws InvalidStateError for those getters on type="number", so a sync refresh while Skip back/forward, Timestamp offset, or Latest episodes is focused aborts getSettingDefinitions and the tab never shows the adopted values. Restore already skips a null start; the capture does not.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eca578f. Configure here.

: undefined;
return {
name,
desc,
render: (setting) => {
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,
draft.direction ?? undefined,
);
draft = undefined;
complete = this.bindDraft(
control.inputEl,
(value) => {
Expand Down
77 changes: 76 additions & 1 deletion tests/e2e/settings-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,11 @@ describe("PodNotes settings tab", () => {
synced.note.path = remote;
await plugin.data<PodNotesData>().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(`({
Expand All @@ -287,6 +291,77 @@ describe("PodNotes settings tab", () => {
expect((await plugin.data<PodNotesData>().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<PodNotesData>((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, "backward"), true',
);

for (const remote of ["First sync", "Second sync"]) {
const synced = await plugin.data<PodNotesData>().read();
synced.note = { path: remote, template: remote };
synced.autoQueue = true;
await plugin.data<PodNotesData>().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, document.activeElement.selectionDirection]
})`),
).toEqual({
memory: remote,
field: "Local draft",
focused: true,
selection: [1, 4, "backward"],
});
}

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<PodNotesData>().read()).note?.[key]).toBe("Second sync");
await closeSettings(obsidian);
await openPodNotesSettings(obsidian);
expect(await settingValue(obsidian, name)).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";
Expand Down
3 changes: 2 additions & 1 deletion tests/mocks/obsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
Loading