diff --git a/src/features/notes/ui/NotesView.test.ts b/src/features/notes/ui/NotesView.test.ts index ef17681e7..636074c00 100644 --- a/src/features/notes/ui/NotesView.test.ts +++ b/src/features/notes/ui/NotesView.test.ts @@ -86,6 +86,124 @@ async function render(projects = recents, cwd = "/work/Edefyn") { ); } +function typeInto(field: HTMLInputElement | HTMLTextAreaElement, value: string) { + const prototype = field instanceof HTMLInputElement + ? HTMLInputElement.prototype + : HTMLTextAreaElement.prototype; + Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(field, value); + field.dispatchEvent(new Event("input", { bubbles: true })); +} + +// https://github.com/hardbeat920/monocode/issues/768 +it.each([ + { title: "Untitled", body: "This is the note body.", fallback: "This is the note body." }, + { title: "Custom title", body: "Intro\n# Generated title\nBody", fallback: "Generated title" }, +])("keeps a cleared $title draft until the title field blurs", async (note) => { + vi.useFakeTimers(); + stored = { ...stored, id: `note-title-${note.title}`, title: note.title, body: note.body }; + await render(); + const title = container.querySelector('[aria-label="Note title"]')!; + act(() => { + title.focus(); + typeInto(title, ""); + }); + await act(async () => vi.advanceTimersByTime(800)); + + expect(document.activeElement).toBe(title); + expect(title.value).toBe(""); + expect(stored.title).toBe(note.title); + + await act(async () => title.blur()); + expect(title.value).toBe(note.fallback); + expect(stored.title).toBe(note.fallback); +}); + +it("autosaves a replacement title without trimming the focused draft", async () => { + vi.useFakeTimers(); + stored = { ...stored, id: "note-title-replacement" }; + await render(); + const title = container.querySelector('[aria-label="Note title"]')!; + act(() => { + title.focus(); + typeInto(title, " Replacement title "); + }); + await act(async () => vi.advanceTimersByTime(400)); + + expect(stored.title).toBe("Replacement title"); + expect(title.value).toBe(" Replacement title "); + await act(async () => title.blur()); + expect(title.value).toBe("Replacement title"); +}); + +it.each(["refocus", "unmount"] as const)( + "retains a cleared title when a body save completes after %s", + async (action) => { + vi.useFakeTimers(); + stored = { ...stored, id: `note-title-body-save-${action}` }; + await render(); + const source = [...container.querySelectorAll('[role="tab"]')] + .find((button) => button.textContent === "Source")!; + await act(async () => source.click()); + const body = container.querySelector("textarea.markdown-source-field")!; + const title = container.querySelector('[aria-label="Note title"]')!; + const save = invoke.getMockImplementation()!; + let finishSave!: () => void; + const saving = new Promise((resolve) => { finishSave = resolve; }); + let holdSave = true; + invoke.mockImplementation(async (command, args) => { + if (command === "notes_upsert" && holdSave) { + holdSave = false; + await saving; + } + return save(command, args); + }); + act(() => { + typeInto(body, "Updated body."); + title.focus(); + typeInto(title, ""); + }); + await act(async () => vi.advanceTimersByTime(400)); + expect(invoke).toHaveBeenCalledWith("notes_upsert", { + note: expect.objectContaining({ body: "Updated body." }), + }); + if (action === "refocus") { + // Blur queues a commit, then a new edit begins before the save finishes. + await act(async () => title.blur()); + act(() => { + title.focus(); + typeInto(title, ""); + }); + } else { + await act(async () => root.unmount()); + root = createRoot(container); + } + await act(async () => finishSave()); + + expect(stored.body).toBe("Updated body."); + if (action === "refocus") { + expect(document.activeElement).toBe(title); + expect(title.value).toBe(""); + await act(async () => title.blur()); + } + expect(stored.title).toBe("Updated body."); + }, +); + +it("commits a cleared title when its focused editor unmounts", async () => { + vi.useFakeTimers(); + stored = { ...stored, id: "note-title-unmount" }; + await render(); + const title = container.querySelector('[aria-label="Note title"]')!; + act(() => { + title.focus(); + typeInto(title, ""); + }); + await act(async () => root.unmount()); + root = createRoot(container); + expect(stored.title).toBe("Keep this text."); + expect(stored.body).toBe("Keep this text."); +}); + it("shows a preloaded note immediately while refreshing in the background", async () => { await loadNotes(); let finish!: (notes: Note[]) => void; diff --git a/src/features/notes/ui/NotesView.tsx b/src/features/notes/ui/NotesView.tsx index 04abe4d15..d5b8b9d59 100644 --- a/src/features/notes/ui/NotesView.tsx +++ b/src/features/notes/ui/NotesView.tsx @@ -579,6 +579,7 @@ function NoteEditor({ const projectChangeRef = useRef(projectChange); const noteRef = useRef(note); const dropZoneRef = useRef(null); + const titleFieldRef = useRef(null); const sourceFieldRef = useRef(null); const lastDropAt = useRef(0); const skipSave = useRef(false); @@ -607,15 +608,25 @@ function NoteEditor({ const current = latest ?? noteRef.current; const changes = editsRef.current; const nextBody = changes.body ?? current.body; + const titleFocused = document.activeElement === titleFieldRef.current; const nextTitle = - (changes.title ?? current.title).trim() || noteTitle(nextBody); + (changes.title ?? current.title).trim() || + (titleFocused ? current.title : noteTitle(nextBody)); const nextTags = changes.tags ?? current.tags; const nextProject = projectChangeRef.current; const acceptSaved = (saved: Note) => { noteRef.current = saved; // A completed save only clears the edits included in that request. const remaining = { ...editsRef.current }; - if (remaining.title === changes.title) delete remaining.title; + // Keep the focused draft, including blanks and spaces, until blur. + // Leave the draft for a queued blur or unmount save to commit as well. + if ( + remaining.title === changes.title && + !titleFocused && + document.activeElement !== titleFieldRef.current + ) { + delete remaining.title; + } if (remaining.body === changes.body) delete remaining.body; if (remaining.tags === changes.tags) delete remaining.tags; editsRef.current = remaining; @@ -820,6 +831,7 @@ function NoteEditor({ /> { editNote({ title: event.target.value });