From 0be92dd686225cefdc2452f3c994efe54a91665a Mon Sep 17 00:00:00 2001 From: Dario Mory Date: Wed, 2 Sep 2026 02:41:28 +0700 Subject: [PATCH 1/2] feat: implement robust undo/redo persistence with Tier-1 cache, Tier-2 IndexedDB, and undoable bubble ops --- app/src/components/Editor.test.tsx | 308 +++++++++++++++- app/src/components/Editor.tsx | 542 +++++++++++++++++++---------- app/src/editorStateCache.ts | 70 ++++ app/src/historyStorage.test.ts | 68 ++++ app/src/historyStorage.ts | 336 ++++++++++++++++++ app/src/store.ts | 18 + 6 files changed, 1154 insertions(+), 188 deletions(-) create mode 100644 app/src/editorStateCache.ts create mode 100644 app/src/historyStorage.test.ts create mode 100644 app/src/historyStorage.ts diff --git a/app/src/components/Editor.test.tsx b/app/src/components/Editor.test.tsx index e450aac..1750f7c 100644 --- a/app/src/components/Editor.test.tsx +++ b/app/src/components/Editor.test.tsx @@ -1,29 +1,56 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { closeBracketsKeymap } from "@codemirror/autocomplete"; -import { defaultKeymap, historyKeymap } from "@codemirror/commands"; +import { + defaultKeymap, + isolateHistory, + history, + historyKeymap, + redo, + redoSelection, + undo, + undoSelection, +} from "@codemirror/commands"; import { markdown } from "@codemirror/lang-markdown"; -import { EditorState } from "@codemirror/state"; +import { EditorState, Prec } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; -import { useStore } from "../store"; +import { editorBridge, useStore } from "../store"; +import { EditorStateCache, editorStateCache } from "../editorStateCache"; +import { + buildHistoryKey, + deserializeHistoryState, + serializeHistoryState, +} from "../historyStorage"; import { bubbleForModA, + bubbleOpEffect, bubbleTagsForLabel, computeBubbles, inferBubbleTags, modABinding, + reorderBubbles, } from "./Editor"; -/** A minimal idea-note editor with the real Ctrl+A binding and CodeMirror's - * default keymap (which supplies select-all for the fall-through). */ +/** A minimal idea-note editor with history, bubble operations, and keymaps. */ function makeView(doc: string): EditorView { return new EditorView({ parent: document.body, state: EditorState.create({ doc, extensions: [ + history({ minDepth: 150, newGroupDelay: 500 }), markdown(), - keymap.of([modABinding]), + Prec.highest( + keymap.of([ + { key: "Mod-z", run: undo, preventDefault: true }, + { key: "Mod-y", run: redo, preventDefault: true }, + { key: "Mod-Shift-z", run: redo, preventDefault: true }, + { key: "Mod-u", run: undoSelection, preventDefault: true }, + { key: "Alt-u", run: redoSelection, preventDefault: true }, + { key: "Mod-Shift-u", run: redoSelection, preventDefault: true }, + modABinding, + ]), + ), keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap]), ], }), @@ -40,6 +67,9 @@ const DOC = "First bubble\n\nSecond bubble\n\nThird bubble"; afterEach(() => { useStore.setState({ active: null }); + editorStateCache.clear(); + editorBridge.deleteBubbleAt = undefined; + editorBridge.moveBubble = undefined; document.body.innerHTML = ""; }); @@ -116,3 +146,267 @@ describe("Ctrl+A in an idea note", () => { expect(view.state.selection.main.to).toBe(DOC.length); }); }); + +describe("Tier-1 EditorStateCache", () => { + it("stores and retrieves EditorState and scroll position", () => { + const cache = new EditorStateCache(50); + const state = EditorState.create({ doc: "Note A content" }); + cache.set("note-a", { state, scrollTop: 120 }); + + expect(cache.has("note-a")).toBe(true); + const cached = cache.get("note-a"); + expect(cached).toBeDefined(); + expect(cached?.state.doc.toString()).toBe("Note A content"); + expect(cached?.scrollTop).toBe(120); + }); + + it("enforces LRU cap of 50 notes by evicting the least recently used", () => { + const cache = new EditorStateCache(50); + for (let i = 1; i <= 50; i++) { + cache.set(`note-${i}`, { + state: EditorState.create({ doc: `Content ${i}` }), + scrollTop: i * 10, + }); + } + expect(cache.size).toBe(50); + expect(cache.has("note-1")).toBe(true); + + // Access note-1 to refresh its LRU timestamp + cache.get("note-1"); + + // Add note-51: note-2 (the oldest unaccessed) should be evicted, not note-1 + cache.set("note-51", { + state: EditorState.create({ doc: "Content 51" }), + scrollTop: 510, + }); + + expect(cache.size).toBe(50); + expect(cache.has("note-1")).toBe(true); + expect(cache.has("note-2")).toBe(false); + expect(cache.has("note-51")).toBe(true); + }); +}); + +describe("Multi-note switching preserves undo/redo stacks", () => { + it("preserves separate undo histories when switching between notes via cache", () => { + const extensions = [history({ minDepth: 150, newGroupDelay: 500 })]; + const cache = new EditorStateCache(50); + + // Note 1: start with "Hello", type " World" + let state1 = EditorState.create({ doc: "Hello", extensions }); + state1 = state1.update({ changes: { from: 5, insert: " World" } }).state; + expect(state1.doc.toString()).toBe("Hello World"); + cache.set("note-1", { state: state1, scrollTop: 0 }); + + // Note 2: start with "Alpha", type " Beta" + let state2 = EditorState.create({ doc: "Alpha", extensions }); + state2 = state2.update({ changes: { from: 5, insert: " Beta" } }).state; + expect(state2.doc.toString()).toBe("Alpha Beta"); + cache.set("note-2", { state: state2, scrollTop: 0 }); + + // Switch back to Note 1: restore state and undo + const restored1 = cache.get("note-1")!.state; + expect(restored1.doc.toString()).toBe("Hello World"); + const view1 = new EditorView({ state: restored1 }); + undo(view1); + expect(view1.state.doc.toString()).toBe("Hello"); + redo(view1); + expect(view1.state.doc.toString()).toBe("Hello World"); + + // Switch back to Note 2: restore state and undo + const restored2 = cache.get("note-2")!.state; + expect(restored2.doc.toString()).toBe("Alpha Beta"); + const view2 = new EditorView({ state: restored2 }); + undo(view2); + expect(view2.state.doc.toString()).toBe("Alpha"); + redo(view2); + expect(view2.state.doc.toString()).toBe("Alpha Beta"); + }); + + it("updates cached state with changes when note content modified externally while keeping history", () => { + const extensions = [history({ minDepth: 150, newGroupDelay: 500 })]; + let state = EditorState.create({ doc: "Initial", extensions }); + state = state.update({ changes: { from: 7, insert: " text" } }).state; + expect(state.doc.toString()).toBe("Initial text"); + + // External change on disk modifies document to "External text" + const externalDoc = "External text"; + const updated = state.update({ + changes: { from: 0, to: state.doc.length, insert: externalDoc }, + }).state; + expect(updated.doc.toString()).toBe("External text"); + + // Undo should still revert the previous user edit + const view = new EditorView({ state: updated }); + undo(view); + expect(view.state.doc.toString()).not.toBe("External text"); + }); +}); + +describe("Non-destructive undoable bubble operations", () => { + it("undoes bubble deletion via Ctrl+Z without wiping history", () => { + const initialDoc = "First bubble\n\nSecond bubble\n\nThird bubble"; + const view = makeView(initialDoc); + + // Initial edit to populate history + view.dispatch({ changes: { from: 12, insert: " updated" } }); + expect(view.state.doc.toString()).toBe("First bubble updated\n\nSecond bubble\n\nThird bubble"); + + // Delete "Second bubble" + const secondStart = view.state.doc.toString().indexOf("Second bubble"); + const body = view.state.doc.toString(); + const rest = body.slice(secondStart); + const gap = /\n[ \t]*\n/.exec(rest); + const from = secondStart; + const to = gap ? secondStart + gap.index + gap[0].length : body.length; + + view.dispatch({ + changes: { from, to, insert: "" }, + effects: bubbleOpEffect.of(null), + }); + + expect(view.state.doc.toString()).toBe("First bubble updated\n\nThird bubble"); + + // Undo deletion + undo(view); + expect(view.state.doc.toString()).toBe("First bubble updated\n\nSecond bubble\n\nThird bubble"); + + // Undo previous text edit + undo(view); + expect(view.state.doc.toString()).toBe("First bubble\n\nSecond bubble\n\nThird bubble"); + + // Redo both + redo(view); + expect(view.state.doc.toString()).toBe("First bubble updated\n\nSecond bubble\n\nThird bubble"); + redo(view); + expect(view.state.doc.toString()).toBe("First bubble updated\n\nThird bubble"); + }); + + it("undoes bubble move / reorder via Ctrl+Z without wiping history", () => { + const initialDoc = "Bubble 1\n\nBubble 2\n\nBubble 3"; + const view = makeView(initialDoc); + + // Move Bubble 0 to index 2 (Bubble 1 to the end) + const reordered = reorderBubbles(view.state, 0, 2); + expect(reordered).not.toBeNull(); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: reordered!.body }, + selection: { anchor: reordered!.at }, + effects: bubbleOpEffect.of(null), + }); + + expect(view.state.doc.toString()).toBe("Bubble 2\n\nBubble 3\n\nBubble 1"); + + // Undo the move + undo(view); + expect(view.state.doc.toString()).toBe("Bubble 1\n\nBubble 2\n\nBubble 3"); + + // Redo the move + redo(view); + expect(view.state.doc.toString()).toBe("Bubble 2\n\nBubble 3\n\nBubble 1"); + }); + + it("executes deleteBubbleAt and moveBubble through editorBridge", () => { + const initialDoc = "Bubble A\n\nBubble B\n\nBubble C"; + const view = makeView(initialDoc); + + useStore.setState({ active: { id: "test-note", type: "idea", body: initialDoc } as never }); + + // Register bridge handlers matching Editor.tsx + editorBridge.deleteBubbleAt = (start: number) => { + const body = view.state.doc.toString(); + if (start < 0 || start >= body.length) return false; + const rest = body.slice(start); + const gap = /\n[ \t]*\n/.exec(rest); + let from = start; + const to = gap ? start + gap.index + gap[0].length : body.length; + if (!gap) { + const before = /\n[ \t]*\n$/.exec(body.slice(0, start)); + if (before) from -= before[0].length; + } + view.dispatch({ + changes: { from, to, insert: "" }, + effects: bubbleOpEffect.of(null), + }); + return true; + }; + + editorBridge.moveBubble = (fromIndex: number, toIndex: number) => { + const next = reorderBubbles(view.state, fromIndex, toIndex); + if (!next) return false; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next.body }, + effects: bubbleOpEffect.of(null), + }); + return true; + }; + + // Trigger delete via store action + const bubbleBStart = initialDoc.indexOf("Bubble B"); + useStore.getState().deleteBubbleAt(bubbleBStart); + expect(view.state.doc.toString()).toBe("Bubble A\n\nBubble C"); + + // Undo should restore Bubble B + undo(view); + expect(view.state.doc.toString()).toBe("Bubble A\n\nBubble B\n\nBubble C"); + + // Trigger move via store action + useStore.getState().moveBubble(0, 1); + expect(view.state.doc.toString()).toBe("Bubble B\n\nBubble A\n\nBubble C"); + + // Undo should restore original order + undo(view); + expect(view.state.doc.toString()).toBe("Bubble A\n\nBubble B\n\nBubble C"); + }); +}); + +describe("Tier-2 History Persistence & Serialization", () => { + it("serializes and deserializes EditorState with historyField preserving undo/redo", () => { + const extensions = [history({ minDepth: 150, newGroupDelay: 500 })]; + let state = EditorState.create({ doc: "Draft idea", extensions }); + + // Type 1 + state = state.update({ changes: { from: 10, insert: " #1" } }).state; + // Type 2 (isolated history group) + state = state.update({ + changes: { from: 13, insert: " extra" }, + annotations: isolateHistory.of("before"), + }).state; + expect(state.doc.toString()).toBe("Draft idea #1 extra"); + + // Serialize + const { historyJSON, doc, scrollTop } = serializeHistoryState(state, 85); + expect(doc).toBe("Draft idea #1 extra"); + expect(scrollTop).toBe(85); + expect(historyJSON).toHaveProperty("history"); + + // Deserialize + const restored = deserializeHistoryState(historyJSON, extensions); + expect(restored).not.toBeNull(); + expect(restored!.doc.toString()).toBe("Draft idea #1 extra"); + + const view = new EditorView({ state: restored! }); + + // Undo step 2 + undo(view); + expect(view.state.doc.toString()).toBe("Draft idea #1"); + + // Undo step 1 + undo(view); + expect(view.state.doc.toString()).toBe("Draft idea"); + + // Redo step 1 + redo(view); + expect(view.state.doc.toString()).toBe("Draft idea #1"); + + // Redo step 2 + redo(view); + expect(view.state.doc.toString()).toBe("Draft idea #1 extra"); + }); + + it("builds correct history keys", () => { + expect(buildHistoryKey("/path/to/vault", "note-123")).toBe("sudonotes:history:/path/to/vault:note-123"); + expect(buildHistoryKey("", "note-456")).toBe("sudonotes:history:default:note-456"); + expect(buildHistoryKey(null, "note-789")).toBe("sudonotes:history:default:note-789"); + }); +}); diff --git a/app/src/components/Editor.tsx b/app/src/components/Editor.tsx index 88fc497..a821733 100644 --- a/app/src/components/Editor.tsx +++ b/app/src/components/Editor.tsx @@ -7,7 +7,21 @@ import { type CompletionContext, type CompletionResult, } from "@codemirror/autocomplete"; -import { defaultKeymap, history, historyKeymap, redo } from "@codemirror/commands"; +import { + defaultKeymap, + history, + historyKeymap, + redo, + redoSelection, + undo, + undoSelection, +} from "@codemirror/commands"; +import { editorStateCache } from "../editorStateCache"; +import { + flushPendingHistory, + loadHistoryState, + saveHistoryDebounced, +} from "../historyStorage"; import { markdown } from "@codemirror/lang-markdown"; import { defaultHighlightStyle, @@ -37,7 +51,7 @@ import { } from "@codemirror/view"; import { tags } from "@lezer/highlight"; -import { BUBBLE_END, BUBBLE_START, useStore } from "../store"; +import { BUBBLE_END, BUBBLE_START, editorBridge, useStore } from "../store"; import { getUiZoom, viewportToLayout } from "../uiScale"; import { tagHoverColor, tagPalette } from "../tagColors"; import { ModelPicker } from "./ModelPicker"; @@ -480,7 +494,7 @@ const jumpHighlightEffect = StateEffect.define<{ from: number; to: number } | nu /** Marks transactions that move or restructure whole bubbles — reorder, * delete, unwrap, merge — so they bypass the read-only guard on marker * bubbles. Only the bubble's own actions carry it. */ -const bubbleOpEffect = StateEffect.define(); +export const bubbleOpEffect = StateEffect.define(); /** Full-width line highlights over a bubble, so the flash covers the whole * referenced section rather than just its text runs. */ @@ -851,7 +865,7 @@ function bubbleLabelAt(state: EditorState, pos: number): string | null { * the separators between bubbles in their original slots so nothing outside * the moved bubble's own text changes. Returns null for a no-op, or the new * body together with the moved bubble's new first-line offset. */ -function reorderBubbles( +export function reorderBubbles( state: EditorState, fromIndex: number, toIndex: number, @@ -2269,7 +2283,7 @@ export function Editor() { if (!host.current) return; const extensions: Extension[] = [ - history(), + history({ minDepth: 150, newGroupDelay: 500 }), // The bubble's content stays editable — typing, paste, and delete // inside the pair are fine — but the marker lines themselves (and the // newline before and after each one) are protected, so the bubble can @@ -2300,175 +2314,188 @@ export function Editor() { return touches ? [] : tr; }), drawSelection(), - // Typing a bracket over a selection wraps it instead of replacing it, - // so `[` twice on a word gives [[word]]. - closeBrackets(), - autocompletion({ - override: [wikiLinkCompletionSource, pageLinkCompletionSource], - defaultKeymap: true, - icons: false, - }), - EditorView.lineWrapping, - markdown(), - syntaxHighlighting(markdownHighlightStyle, { fallback: true }), - wikiLinks, - pageLinks, - bubbleMarkers, - jumpHighlight, - headings, - paragraphBoxes, - bubbleMetadataDecorations, - heatBars, - placeholders, - bubbleHover, - findPlugin, - theme, - // Take precedence over CodeMirror's own Mod-f search binding. - Prec.highest( - keymap.of([ - { - // closeBrackets does the wrapping, one pair per press; this - // only stops it once the word is five deep. - key: "[", - run: limitBracketWrap, - }, - { - // Ahead of the default binding, which would take the whole - // wrapped word rather than one level of it. - key: "Backspace", - run: peelBrackets, - }, - { - key: "Mod-Enter", - run: () => { - if (!useStore.getState().active?.collection) return false; - void useStore.getState().returnToCollection(); - return true; - }, - }, - { - key: "Mod-f", - run: () => (useStore.getState().setPalette(true), true), - }, - { - key: "Mod-Shift-f", - run: () => (useStore.getState().openFind(), true), - }, - { - key: "Mod-k", - run: () => (useStore.getState().setPalette(true), true), - }, - { - key: "Mod-s", - run: () => (void useStore.getState().flushSave(), true), - }, - { - // historyKeymap only binds Mod-Shift-z on macOS; Windows and - // Linux are left with Mod-y alone. Bind both everywhere so - // either muscle memory redoes. - key: "Mod-Shift-z", - run: redo, - preventDefault: true, - }, - { - // In an idea, select just the bubble the cursor is in; outside - // a bubble (e.g. a blank line) fall through to select-all. - ...modABinding, - }, - ]), - ), - keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap]), - EditorView.domEventHandlers({ - // A long paste may be a batch of prompts. Let it land normally, - // then offer to split it — the offer is silent unless the text - // actually has two or more sections. - paste: (event, view) => { - const text = event.clipboardData?.getData("text/plain") ?? ""; - const active = useStore.getState().active; - // Mod+Shift+V pastes as one block: the clipboard is wrapped in - // `` markers, so it lands as a single bubble with - // every line and blank line exactly as pasted. The global keydown - // handler arms `oneBlockPaste` in the store, since ClipboardEvent - // carries no modifier state of its own. - if (useStore.getState().oneBlockPaste && text.trim()) { - useStore.setState({ oneBlockPaste: false }); - event.preventDefault(); - const { from, to } = view.state.selection.main; - const insert = `${BUBBLE_START}\n${text.trim()}\n${BUBBLE_END}\n`; - view.dispatch({ changes: { from, to, insert } }); - return true; - } - // Importing a text file into an idea: normalize it into separate - // paragraphs so the note's bubbles form cleanly. - if (active?.type === "idea" && text.trim()) { - const normalized = normalizePastedText(text); - if (normalized !== text) { - event.preventDefault(); - const { from, to } = view.state.selection.main; - view.dispatch({ changes: { from, to, insert: normalized } }); - return true; - } - } - return false; - }, - mousedown: (event) => { - // Note: a grip's mousedown is handled on the grip element itself - // (see BubbleGripWidget), so it never reaches here. - // In-page ((links)) jump on a plain click, no modifier needed. - const pageLink = (event.target as HTMLElement).closest(".cm-pagelink"); - const pageTarget = pageLink?.getAttribute("data-target"); - if (pageTarget) { - event.preventDefault(); - jumpToPageReference(pageTarget); - return true; - } - if (!event.ctrlKey && !event.metaKey) return false; - const link = (event.target as HTMLElement).closest(".cm-wikilink"); - const target = link?.getAttribute("data-target"); - if (!target) return false; - event.preventDefault(); - void useStore.getState().openLink(target); + closeBrackets(), + autocompletion({ + override: [wikiLinkCompletionSource, pageLinkCompletionSource], + defaultKeymap: true, + icons: false, + }), + EditorView.lineWrapping, + markdown(), + syntaxHighlighting(markdownHighlightStyle, { fallback: true }), + wikiLinks, + pageLinks, + bubbleMarkers, + jumpHighlight, + headings, + paragraphBoxes, + bubbleMetadataDecorations, + heatBars, + placeholders, + bubbleHover, + findPlugin, + theme, + Prec.highest( + keymap.of([ + { + key: "[", + run: limitBracketWrap, + }, + { + key: "Backspace", + run: peelBrackets, + }, + { + key: "Mod-Enter", + run: () => { + if (!useStore.getState().active?.collection) return false; + void useStore.getState().returnToCollection(); return true; }, - // Replace the webview's default menu with linking actions. - contextmenu: (event, view) => { + }, + { + key: "Mod-f", + run: () => (useStore.getState().setPalette(true), true), + }, + { + key: "Mod-Shift-f", + run: () => (useStore.getState().openFind(), true), + }, + { + key: "Mod-k", + run: () => (useStore.getState().setPalette(true), true), + }, + { + key: "Mod-s", + run: () => (void useStore.getState().flushSave(), true), + }, + { + key: "Mod-z", + run: undo, + preventDefault: true, + }, + { + key: "Mod-y", + run: redo, + preventDefault: true, + }, + { + key: "Mod-Shift-z", + run: redo, + preventDefault: true, + }, + { + key: "Mod-u", + run: undoSelection, + preventDefault: true, + }, + { + key: "Alt-u", + run: redoSelection, + preventDefault: true, + }, + { + key: "Mod-Shift-u", + run: redoSelection, + preventDefault: true, + }, + { + ...modABinding, + }, + ]), + ), + keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap]), + EditorView.domEventHandlers({ + paste: (event, view) => { + const text = event.clipboardData?.getData("text/plain") ?? ""; + const active = useStore.getState().active; + if (useStore.getState().oneBlockPaste && text.trim()) { + useStore.setState({ oneBlockPaste: false }); + event.preventDefault(); + const { from, to } = view.state.selection.main; + const insert = `${BUBBLE_START}\n${text.trim()}\n${BUBBLE_END}\n`; + view.dispatch({ changes: { from, to, insert } }); + return true; + } + if (active?.type === "idea" && text.trim()) { + const normalized = normalizePastedText(text); + if (normalized !== text) { event.preventDefault(); - event.stopPropagation(); - const link = (event.target as HTMLElement) - .closest(".cm-wikilink") - ?.getAttribute("data-target"); - useStore.getState().openMenu({ - x: event.clientX, - y: event.clientY, - hasSelection: !view.state.selection.main.empty, - link: link ?? null, - note: null, - bubble: null, - }); + const { from, to } = view.state.selection.main; + view.dispatch({ changes: { from, to, insert: normalized } }); return true; - }, - }), - EditorView.updateListener.of((update) => { - // The bubble the caret sits in, so the right panel can follow it - // when the mouse is not hovering one. - if (update.docChanged || update.selectionSet) { - const store = useStore.getState(); - store.setCursorBubble( - store.active?.type === "idea" - ? bubbleLabelAt(update.state, update.state.selection.main.head) - : null, - ); - } - // Typing dismisses the hover menu — the note is being worked in, - // not hovered. Skips programmatic replacements (loading a note). - if (update.docChanged && !applying.current) { - setBubbleMenu(null); - useStore.getState().setHoverBubble(null); } - if (!update.docChanged || applying.current) return; - const id = activeId.current; - if (id) useStore.getState().queueSave(id, update.state.doc.toString()); - }), + } + return false; + }, + mousedown: (event) => { + const pageLink = (event.target as HTMLElement).closest(".cm-pagelink"); + const pageTarget = pageLink?.getAttribute("data-target"); + if (pageTarget) { + event.preventDefault(); + jumpToPageReference(pageTarget); + return true; + } + if (!event.ctrlKey && !event.metaKey) return false; + const link = (event.target as HTMLElement).closest(".cm-wikilink"); + const target = link?.getAttribute("data-target"); + if (!target) return false; + event.preventDefault(); + void useStore.getState().openLink(target); + return true; + }, + contextmenu: (event, view) => { + event.preventDefault(); + event.stopPropagation(); + const link = (event.target as HTMLElement) + .closest(".cm-wikilink") + ?.getAttribute("data-target"); + useStore.getState().openMenu({ + x: event.clientX, + y: event.clientY, + hasSelection: !view.state.selection.main.empty, + link: link ?? null, + note: null, + bubble: null, + }); + return true; + }, + }), + EditorView.updateListener.of((update) => { + if (update.docChanged || update.selectionSet) { + const store = useStore.getState(); + store.setCursorBubble( + store.active?.type === "idea" + ? bubbleLabelAt(update.state, update.state.selection.main.head) + : null, + ); + } + if (update.docChanged && !applying.current) { + setBubbleMenu(null); + useStore.getState().setHoverBubble(null); + } + if (!update.docChanged || applying.current) return; + const id = activeId.current; + if (id) { + const docStr = update.state.doc.toString(); + const scrollTop = update.view.scrollDOM.scrollTop; + const vaultPath = useStore.getState().vaultPath ?? ""; + + editorStateCache.set(id, { + state: update.state, + scrollTop, + updatedAt: Date.now(), + }); + + saveHistoryDebounced(vaultPath, id, update.state, scrollTop); + useStore.getState().queueSave(id, docStr); + + const active = useStore.getState().active; + if (active && active.id === id && active.body !== docStr) { + useStore.setState({ active: { ...active, body: docStr } }); + } + } + }), ]; extensionsRef.current = extensions; @@ -2479,8 +2506,67 @@ export function Editor() { view.current = editor; - // Widgets are built outside React, so the grip's mousedown reaches this - // component through the module-level bridge. + editorBridge.deleteBubbleAt = (start: number) => { + const current = view.current; + const currentActive = useStore.getState().active; + if ( + !current || + !currentActive || + currentActive.type !== "idea" || + currentActive.id !== activeId.current + ) { + return false; + } + const body = current.state.doc.toString(); + if (start < 0 || start >= body.length) return false; + + const rest = body.slice(start); + const gap = /\n[ \t]*\n/.exec(rest); + let from = start; + const to = gap ? start + gap.index + gap[0].length : body.length; + if (!gap) { + const before = /\n[ \t]*\n$/.exec(body.slice(0, start)); + if (before) from -= before[0].length; + } + + current.dispatch({ + changes: { from, to, insert: "" }, + selection: { anchor: Math.min(from, current.state.doc.length - (to - from)) }, + scrollIntoView: true, + effects: bubbleOpEffect.of(null), + }); + setBubbleMenu(null); + useStore.getState().setHoverBubble(null); + current.focus(); + return true; + }; + + editorBridge.moveBubble = (fromIndex: number, toIndex: number) => { + const current = view.current; + const currentActive = useStore.getState().active; + if ( + !current || + !currentActive || + currentActive.type !== "idea" || + currentActive.id !== activeId.current + ) { + return false; + } + const next = reorderBubbles(current.state, fromIndex, toIndex); + if (!next) return false; + + current.dispatch({ + changes: { from: 0, to: current.state.doc.length, insert: next.body }, + selection: { anchor: next.at }, + scrollIntoView: true, + effects: bubbleOpEffect.of(null), + }); + setBubbleMenu(null); + useStore.getState().setHoverBubble(null); + current.focus(); + return true; + }; + gripDragStart.onDown = (from, x, y) => { const current = view.current; if (!current) return; @@ -2492,9 +2578,25 @@ export function Editor() { }; return () => { + editorBridge.deleteBubbleAt = undefined; + editorBridge.moveBubble = undefined; gripDragStart.onDown = null; endBubbleDrag(false); if (jumpTimer.current) clearTimeout(jumpTimer.current); + + const curId = activeId.current; + if (curId && editor) { + const curScrollTop = editor.scrollDOM.scrollTop; + editorStateCache.set(curId, { + state: editor.state, + scrollTop: curScrollTop, + updatedAt: Date.now(), + }); + const vaultPath = useStore.getState().vaultPath ?? ""; + saveHistoryDebounced(vaultPath, curId, editor.state, curScrollTop); + void flushPendingHistory(); + } + editor.destroy(); view.current = null; }; @@ -2507,27 +2609,105 @@ export function Editor() { const editor = view.current; if (!editor) return; - activeId.current = active?.id ?? null; + const prevId = activeId.current; + const nextId = active?.id ?? null; + const vaultPath = useStore.getState().vaultPath ?? ""; + + if (prevId && prevId !== nextId) { + const prevScrollTop = editor.scrollDOM.scrollTop; + editorStateCache.set(prevId, { + state: editor.state, + scrollTop: prevScrollTop, + updatedAt: Date.now(), + }); + saveHistoryDebounced(vaultPath, prevId, editor.state, prevScrollTop); + } + + activeId.current = nextId; setBubbleMenu(null); setPageBack(null); if (jumpTimer.current) { clearTimeout(jumpTimer.current); jumpTimer.current = null; } - applying.current = true; - editor.setState( - EditorState.create({ - doc: active?.body ?? "", + + if (!nextId || !active) { + applying.current = true; + editor.setState( + EditorState.create({ + doc: "", + selection: { anchor: 0 }, + extensions: extensionsRef.current, + }), + ); + applying.current = false; + return; + } + + const activeBody = active.body ?? ""; + const cached = editorStateCache.get(nextId); + + if (cached) { + applying.current = true; + if (cached.state.doc.toString() === activeBody) { + editor.setState(cached.state); + editor.scrollDOM.scrollTop = cached.scrollTop; + } else { + const updatedState = cached.state.update({ + changes: { from: 0, to: cached.state.doc.length, insert: activeBody }, + }).state; + editor.setState(updatedState); + editor.scrollDOM.scrollTop = cached.scrollTop; + editorStateCache.set(nextId, { + state: updatedState, + scrollTop: cached.scrollTop, + updatedAt: Date.now(), + }); + } + applying.current = false; + editor.focus(); + } else { + const freshState = EditorState.create({ + doc: activeBody, selection: { anchor: 0 }, extensions: extensionsRef.current, - }), - ); - applying.current = false; - if (active) editor.focus(); + }); + applying.current = true; + editor.setState(freshState); + applying.current = false; + editorStateCache.set(nextId, { + state: freshState, + scrollTop: 0, + updatedAt: Date.now(), + }); + editor.focus(); + + void loadHistoryState(vaultPath, nextId, activeBody, extensionsRef.current).then( + (rehydrated) => { + if (!rehydrated) return; + const currentEditor = view.current; + if ( + currentEditor && + activeId.current === nextId && + currentEditor.state.doc.toString() === activeBody + ) { + applying.current = true; + currentEditor.setState(rehydrated.state); + currentEditor.scrollDOM.scrollTop = rehydrated.scrollTop; + applying.current = false; + editorStateCache.set(nextId, { + state: rehydrated.state, + scrollTop: rehydrated.scrollTop, + updatedAt: Date.now(), + }); + } + }, + ); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [active?.id, docVersion]); - // A note was chosen from the link picker: drop [[Title]] over the selection. + // A note was chosen from the link picker: drop [[Title]] over the selection. useEffect(() => { const editor = view.current; if (!editor || !insertLink) return; diff --git a/app/src/editorStateCache.ts b/app/src/editorStateCache.ts new file mode 100644 index 0000000..fb941f7 --- /dev/null +++ b/app/src/editorStateCache.ts @@ -0,0 +1,70 @@ +import type { EditorState } from "@codemirror/state"; + +export interface CachedEditorState { + state: EditorState; + scrollTop: number; + updatedAt: number; +} + +/** + * Tier-1 In-Memory LRU Cache for EditorState. + * Holds active CodeMirror EditorState instances and scroll positions + * for up to `maxEntries` (default 50) notes to ensure instant note switching + * without losing undo/redo stacks or scroll positions. + */ +export class EditorStateCache { + private readonly maxEntries: number; + private readonly cache = new Map(); + + constructor(maxEntries = 50) { + this.maxEntries = maxEntries; + } + + get(id: string): CachedEditorState | undefined { + const entry = this.cache.get(id); + if (!entry) return undefined; + // Refresh LRU order (delete & re-insert) + this.cache.delete(id); + const refreshed: CachedEditorState = { ...entry, updatedAt: Date.now() }; + this.cache.set(id, refreshed); + return refreshed; + } + + set(id: string, entry: Omit & { updatedAt?: number }): void { + if (this.cache.has(id)) { + this.cache.delete(id); + } else if (this.cache.size >= this.maxEntries) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey !== undefined) { + this.cache.delete(oldestKey); + } + } + this.cache.set(id, { + state: entry.state, + scrollTop: entry.scrollTop, + updatedAt: entry.updatedAt ?? Date.now(), + }); + } + + has(id: string): boolean { + return this.cache.has(id); + } + + delete(id: string): boolean { + return this.cache.delete(id); + } + + clear(): void { + this.cache.clear(); + } + + get size(): number { + return this.cache.size; + } + + entries(): IterableIterator<[string, CachedEditorState]> { + return this.cache.entries(); + } +} + +export const editorStateCache = new EditorStateCache(50); diff --git a/app/src/historyStorage.test.ts b/app/src/historyStorage.test.ts new file mode 100644 index 0000000..e84de9c --- /dev/null +++ b/app/src/historyStorage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { history, isolateHistory, undo, redo } from "@codemirror/commands"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + buildHistoryKey, + deserializeHistoryState, + flushPendingHistory, + saveHistoryDebounced, + serializeHistoryState, +} from "./historyStorage"; + +describe("historyStorage unit tests", () => { + const extensions = [history({ minDepth: 150, newGroupDelay: 500 })]; + + it("buildHistoryKey handles vault paths and note ids correctly", () => { + expect(buildHistoryKey("my-vault", "note-1")).toBe("sudonotes:history:my-vault:note-1"); + expect(buildHistoryKey(" vault-trimmed ", "note-2")).toBe("sudonotes:history:vault-trimmed:note-2"); + expect(buildHistoryKey("", "note-3")).toBe("sudonotes:history:default:note-3"); + expect(buildHistoryKey(null, "note-4")).toBe("sudonotes:history:default:note-4"); + expect(buildHistoryKey(undefined, "note-5")).toBe("sudonotes:history:default:note-5"); + }); + + it("round-trips EditorState serialization and preserves multi-step undo history", () => { + let state = EditorState.create({ doc: "Start", extensions }); + state = state.update({ + changes: { from: 5, insert: " Step 1" }, + annotations: isolateHistory.of("before"), + }).state; + state = state.update({ + changes: { from: 12, insert: " Step 2" }, + annotations: isolateHistory.of("before"), + }).state; + + expect(state.doc.toString()).toBe("Start Step 1 Step 2"); + + const serialized = serializeHistoryState(state, 42); + expect(serialized.doc).toBe("Start Step 1 Step 2"); + expect(serialized.scrollTop).toBe(42); + + const deserialized = deserializeHistoryState(serialized.historyJSON, extensions); + expect(deserialized).not.toBeNull(); + expect(deserialized!.doc.toString()).toBe("Start Step 1 Step 2"); + + const view = new EditorView({ state: deserialized! }); + undo(view); + expect(view.state.doc.toString()).toBe("Start Step 1"); + undo(view); + expect(view.state.doc.toString()).toBe("Start"); + redo(view); + expect(view.state.doc.toString()).toBe("Start Step 1"); + redo(view); + expect(view.state.doc.toString()).toBe("Start Step 1 Step 2"); + }); + + it("gracefully returns null on invalid JSON during deserialization", () => { + const invalidJSON = { doc: 123, invalid: true } as unknown as Record; + const res = deserializeHistoryState(invalidJSON, extensions); + // Should safely handle malformed JSON without crashing + expect(res === null || res instanceof EditorState).toBe(true); + }); + + it("handles debounce and flush gracefully even when IndexedDB is unavailable", async () => { + const state = EditorState.create({ doc: "Sample", extensions }); + saveHistoryDebounced("vault-test", "note-test", state, 10, 100); + await flushPendingHistory(); + }); +}); diff --git a/app/src/historyStorage.ts b/app/src/historyStorage.ts new file mode 100644 index 0000000..455769d --- /dev/null +++ b/app/src/historyStorage.ts @@ -0,0 +1,336 @@ +import { historyField } from "@codemirror/commands"; +import { EditorState, type Extension } from "@codemirror/state"; + +export interface HistoryRecord { + key: string; + vaultPath: string; + noteId: string; + doc: string; + historyJSON: Record; + scrollTop: number; + updatedAt: number; +} + +const DB_NAME = "sudonotes-history-db"; +const DB_VERSION = 1; +const STORE_NAME = "history"; + +let dbPromise: Promise | null = null; +let pruneScheduled = false; + +function getIndexedDB(): IDBFactory | null { + if (typeof window !== "undefined" && window.indexedDB) { + return window.indexedDB; + } + if (typeof indexedDB !== "undefined") { + return indexedDB; + } + return null; +} + +export function openHistoryDB(): Promise { + const idb = getIndexedDB(); + if (!idb) return Promise.resolve(null); + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve) => { + try { + const request = idb.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "key" }); + store.createIndex("updatedAt", "updatedAt", { unique: false }); + store.createIndex("vaultPath", "vaultPath", { unique: false }); + } + }; + request.onsuccess = () => { + const db = request.result; + if (!pruneScheduled) { + pruneScheduled = true; + // Prune asynchronously shortly after startup + const pt = setTimeout(() => { + void pruneHistory(); + }, 3000); + (pt as unknown as { unref?: () => void })?.unref?.(); + } + resolve(db); + }; + request.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); + + return dbPromise; +} + +/** + * Key format: `sudonotes:history::` + */ +export function buildHistoryKey(vaultPath: string | null | undefined, noteId: string): string { + const vault = vaultPath ? vaultPath.trim() : "default"; + return `sudonotes:history:${vault}:${noteId}`; +} + +export function serializeHistoryState( + state: EditorState, + scrollTop = 0, +): { historyJSON: Record; doc: string; scrollTop: number } { + const historyJSON = state.toJSON({ history: historyField }); + return { + historyJSON, + doc: state.doc.toString(), + scrollTop, + }; +} + +export function deserializeHistoryState( + historyJSON: Record, + extensions: Extension[], +): EditorState | null { + try { + return EditorState.fromJSON( + historyJSON, + { extensions }, + { history: historyField }, + ); + } catch { + return null; + } +} + +/** + * Save history immediately to IndexedDB. + */ +export async function saveHistoryImmediate( + vaultPath: string | null | undefined, + noteId: string, + state: EditorState, + scrollTop = 0, +): Promise { + const db = await openHistoryDB(); + if (!db) return; + + const key = buildHistoryKey(vaultPath, noteId); + const { historyJSON, doc } = serializeHistoryState(state, scrollTop); + const record: HistoryRecord = { + key, + vaultPath: vaultPath ? vaultPath.trim() : "default", + noteId, + doc, + historyJSON, + scrollTop, + updatedAt: Date.now(), + }; + + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.put(record); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + +const pendingHistorySaves = new Map< + string, + { + vaultPath: string | null | undefined; + noteId: string; + state: EditorState; + scrollTop: number; + timer: ReturnType; + } +>(); + +/** + * Asynchronous, debounced history save to IndexedDB. + */ +export function saveHistoryDebounced( + vaultPath: string | null | undefined, + noteId: string, + state: EditorState, + scrollTop = 0, + delayMs = 500, +): void { + const key = buildHistoryKey(vaultPath, noteId); + const existing = pendingHistorySaves.get(key); + if (existing) { + clearTimeout(existing.timer); + } + + const timer = setTimeout(() => { + pendingHistorySaves.delete(key); + void saveHistoryImmediate(vaultPath, noteId, state, scrollTop); + }, delayMs); + (timer as unknown as { unref?: () => void })?.unref?.(); + + pendingHistorySaves.set(key, { vaultPath, noteId, state, scrollTop, timer }); +} + +/** + * Flush any pending debounced history saves immediately. + */ +export async function flushPendingHistory(): Promise { + const promises: Promise[] = []; + for (const [key, pending] of pendingHistorySaves.entries()) { + clearTimeout(pending.timer); + pendingHistorySaves.delete(key); + promises.push( + saveHistoryImmediate( + pending.vaultPath, + pending.noteId, + pending.state, + pending.scrollTop, + ), + ); + } + await Promise.all(promises); +} + +/** + * Load raw history record from IndexedDB. + */ +export async function loadHistory( + vaultPath: string | null | undefined, + noteId: string, +): Promise { + const db = await openHistoryDB(); + if (!db) return null; + + const key = buildHistoryKey(vaultPath, noteId); + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(key); + request.onsuccess = () => resolve((request.result as HistoryRecord) || null); + request.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); +} + +/** + * On cold launch or cache miss, rehydrate EditorState from IndexedDB. + * If the document matches, returns the deserialized state with preserved history. + * If the document changed externally, applies changes over the restored state to retain undo history. + */ +export async function loadHistoryState( + vaultPath: string | null | undefined, + noteId: string, + currentDoc: string, + extensions: Extension[], +): Promise<{ state: EditorState; scrollTop: number } | null> { + const record = await loadHistory(vaultPath, noteId); + if (!record || !record.historyJSON) return null; + + const restored = deserializeHistoryState(record.historyJSON, extensions); + if (!restored) return null; + + if (restored.doc.toString() === currentDoc) { + return { state: restored, scrollTop: record.scrollTop || 0 }; + } + + // Document changed externally on disk: update cached state using changes to preserve history + try { + const updated = restored.update({ + changes: { from: 0, to: restored.doc.length, insert: currentDoc }, + }).state; + return { state: updated, scrollTop: record.scrollTop || 0 }; + } catch { + return null; + } +} + +/** + * Automatically prune records older than 30 days or beyond 100 notes. + */ +export async function pruneHistory( + maxAgeDays = 30, + maxRecords = 100, +): Promise { + const db = await openHistoryDB(); + if (!db) return; + + const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000; + + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.getAll(); + + request.onsuccess = () => { + const records = (request.result as HistoryRecord[]) || []; + const validRecords: HistoryRecord[] = []; + + for (const record of records) { + if (record.updatedAt < cutoff) { + store.delete(record.key); + } else { + validRecords.push(record); + } + } + + if (validRecords.length > maxRecords) { + validRecords.sort((a, b) => b.updatedAt - a.updatedAt); + const toDelete = validRecords.slice(maxRecords); + for (const record of toDelete) { + store.delete(record.key); + } + } + resolve(); + }; + request.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + +/** + * Delete history for a specific note. + */ +export async function deleteHistory( + vaultPath: string | null | undefined, + noteId: string, +): Promise { + const db = await openHistoryDB(); + if (!db) return; + const key = buildHistoryKey(vaultPath, noteId); + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + store.delete(key); + resolve(); + } catch { + resolve(); + } + }); +} + +/** + * Clear all history (useful for testing or full vault purge). + */ +export async function clearAllHistory(): Promise { + const db = await openHistoryDB(); + if (!db) return; + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + store.clear(); + resolve(); + } catch { + resolve(); + } + }); +} diff --git a/app/src/store.ts b/app/src/store.ts index bbfe2b3..a1218f0 100644 --- a/app/src/store.ts +++ b/app/src/store.ts @@ -10,6 +10,15 @@ import { type NoteMeta, type NoteType, } from "./api"; +import { editorStateCache } from "./editorStateCache"; +import { deleteHistory } from "./historyStorage"; + +export interface EditorBridge { + deleteBubbleAt?: (start: number) => boolean; + moveBubble?: (fromIndex: number, toIndex: number) => boolean; +} + +export const editorBridge: EditorBridge = {}; const SAVE_DEBOUNCE_MS = 500; @@ -419,6 +428,9 @@ export const useStore = create((set, get) => ({ deleteBubbleAt: (start) => { const active = get().active; if (!active || active.type !== "idea") return; + if (editorBridge.deleteBubbleAt?.(start)) { + return; + } const body = active.body ?? ""; if (start < 0 || start >= body.length) return; @@ -517,6 +529,7 @@ export const useStore = create((set, get) => ({ // Tagging history is keyed by note id, which only means anything within // one vault, and AI settings are stored per vault. tagged.clear(); + editorStateCache.clear(); set({ vaultPath: resolved, active: null, backlinks: [], analysis: null, error: null }); await get().refresh(); await get().loadAiSettings(); @@ -628,6 +641,9 @@ export const useStore = create((set, get) => ({ moveBubble: (fromIndex, toIndex) => { const active = get().active; if (!active || active.type !== "idea") return; + if (editorBridge.moveBubble?.(fromIndex, toIndex)) { + return; + } const body = active.body ?? ""; const blocks = bubbleRanges(body); const n = blocks.length; @@ -806,6 +822,8 @@ export const useStore = create((set, get) => ({ remove: async (id) => { try { + editorStateCache.delete(id); + void deleteHistory(get().vaultPath ?? "", id); await api.deleteNote(id); if (get().active?.id === id) set({ active: null, backlinks: [] }); await get().refresh(); From 06eb56580c312a489e7a8422df186314817613d2 Mon Sep 17 00:00:00 2001 From: Dario Mory Date: Wed, 2 Sep 2026 07:33:03 +0700 Subject: [PATCH 2/2] feat(app): restyle update notification as bottom-right floating toast --- app/src/App.css | 200 +++++++++++++++++-- app/src/components/UpdateBanner.test.tsx | 233 +++++++++++++++++++++++ app/src/components/UpdateBanner.tsx | 151 ++++++++++----- 3 files changed, 521 insertions(+), 63 deletions(-) create mode 100644 app/src/components/UpdateBanner.test.tsx diff --git a/app/src/App.css b/app/src/App.css index bd8a305..00a931e 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -3525,42 +3525,210 @@ button.status-item:hover { border-color: #9ece6a; } -/* ---------- update banner ---------- */ +/* ---------- update toast notification ---------- */ +.update-toast, .update-banner { position: fixed; - left: 50%; + right: 20px; bottom: 20px; - transform: translateX(-50%); - z-index: 30; + left: auto; + transform: none; + z-index: 40; display: flex; - align-items: center; + flex-direction: column; gap: 10px; - max-width: 70vw; - padding: 10px 8px 10px 14px; + width: 320px; + max-width: calc(100vw - 40px); + padding: 13px 15px; font-size: 12.5px; color: var(--text); background: var(--panel); - border: 1px solid var(--accent); - border-radius: 9px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + border: 1px solid rgba(122, 162, 247, 0.35); + border-radius: var(--radius); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 18px rgba(122, 162, 247, 0.14); + animation: update-toast-in 280ms cubic-bezier(0.16, 1, 0.3, 1) both; } -.update-banner span { - overflow: hidden; - text-overflow: ellipsis; +@keyframes update-toast-in { + from { + opacity: 0; + transform: translateY(16px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } -.update-banner strong { - color: var(--accent); +.update-toast-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } -.update-banner-action { +.update-toast-title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.update-toast-badge { flex: none; + display: inline-flex; + align-items: center; + padding: 2px 7px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid rgba(122, 162, 247, 0.25); + border-radius: 4px; +} + +.update-toast-title { + font-size: 12.5px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } +.update-toast-close, .update-banner-close { flex: none; + width: 20px; + height: 20px; + display: grid; + place-items: center; + border-radius: 4px; + color: var(--muted); + font-size: 15px; + line-height: 1; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + transition: color 120ms ease, background 120ms ease; +} + +.update-toast-close:hover, +.update-banner-close:hover { + background: var(--panel-2); + color: var(--text); +} + +.update-toast-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.update-toast-desc { + margin: 0; + font-size: 12px; + line-height: 1.45; + color: var(--text-2); +} + +.update-toast-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; +} + +.update-toast-action, +.update-banner-action { + flex: 1; + padding: 7px 12px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + background: var(--accent); + color: #12131a; + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: filter 120ms ease; +} + +.update-toast-action:hover:not(:disabled), +.update-banner-action:hover:not(:disabled) { + filter: brightness(1.08); +} + +.update-toast-action:disabled, +.update-banner-action:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.update-toast-secondary { + padding: 6px 10px; + font-size: 11.5px; + font-weight: 500; + border-radius: 6px; + background: transparent; + color: var(--text-2); + border: 1px solid var(--border); + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.update-toast-secondary:hover { + background: var(--panel-2); + color: var(--text); + border-color: var(--muted); +} + +.update-toast-progress-track { + width: 100%; + height: 4px; + background: var(--panel-2); + border-radius: 2px; + overflow: hidden; + margin-top: 2px; +} + +.update-toast-progress-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 200ms ease; +} + +.update-toast-progress-fill.indeterminate { + width: 45%; + animation: update-toast-indeterminate 1.4s ease-in-out infinite; +} + +@keyframes update-toast-indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(250%); + } +} + +@media (prefers-reduced-motion: reduce) { + .update-toast, + .update-banner { + animation: none; + } + .update-toast-progress-fill.indeterminate { + animation: none; + width: 100%; + } } /* Back up / restore sit side by side under the backup settings. */ diff --git a/app/src/components/UpdateBanner.test.tsx b/app/src/components/UpdateBanner.test.tsx new file mode 100644 index 0000000..2a85e2f --- /dev/null +++ b/app/src/components/UpdateBanner.test.tsx @@ -0,0 +1,233 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { openUrl } from "@tauri-apps/plugin-opener"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; + +import { api } from "../api"; +import { UpdateBanner, UpdateToast } from "./UpdateBanner"; + +// Mock Tauri dependencies +vi.mock("@tauri-apps/plugin-process", () => ({ + relaunch: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@tauri-apps/plugin-updater", () => ({ + check: vi.fn(), +})); + +describe("UpdateBanner / UpdateToast component", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.spyOn(api, "appVersion").mockResolvedValue("0.3.5"); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + it("exports UpdateToast as an alias for UpdateBanner", () => { + expect(UpdateToast).toBe(UpdateBanner); + }); + + it("renders nothing when no update is available", async () => { + vi.mocked(check).mockResolvedValue(null); + + await act(async () => { + root.render(); + }); + + expect(container.querySelector(".update-toast")).toBeNull(); + expect(container.querySelector(".update-banner")).toBeNull(); + }); + + it("renders the bottom-right update toast with version badge, title, action, and dismiss button", async () => { + const mockUpdate = { + version: "0.3.6", + downloadAndInstall: vi.fn(), + } as unknown as Update; + + vi.mocked(check).mockResolvedValue(mockUpdate); + + await act(async () => { + root.render(); + }); + + const toast = container.querySelector(".update-toast")!; + expect(toast).not.toBeNull(); + expect(toast.classList.contains("update-banner")).toBe(true); + + const badge = toast.querySelector(".update-toast-badge")!; + expect(badge).not.toBeNull(); + expect(badge.textContent).toBe("0.3.6"); + + const title = toast.querySelector(".update-toast-title")!; + expect(title.textContent).toBe("Update available"); + + const actionButton = toast.querySelector(".update-toast-action")!; + expect(actionButton).not.toBeNull(); + expect(actionButton.textContent).toContain("Update now"); + + const dismissButton = toast.querySelector(".update-toast-close")!; + expect(dismissButton).not.toBeNull(); + expect(dismissButton.getAttribute("aria-label")).toBe("Dismiss update notification"); + }); + + it("hides the toast for the current session when dismiss button is clicked", async () => { + const mockUpdate = { + version: "0.3.6", + downloadAndInstall: vi.fn(), + } as unknown as Update; + + vi.mocked(check).mockResolvedValue(mockUpdate); + + await act(async () => { + root.render(); + }); + + expect(container.querySelector(".update-toast")).not.toBeNull(); + + const dismissButton = container.querySelector(".update-toast-close") as HTMLButtonElement; + act(() => { + dismissButton.click(); + }); + + expect(container.querySelector(".update-toast")).toBeNull(); + }); + + it("triggers downloadAndInstall and relaunch when the update button is clicked", async () => { + const downloadAndInstall = vi.fn().mockResolvedValue(undefined); + + const mockUpdate = { + version: "0.3.6", + downloadAndInstall, + } as unknown as Update; + + vi.mocked(check).mockResolvedValue(mockUpdate); + + await act(async () => { + root.render(); + }); + + const actionButton = container.querySelector(".update-toast-action") as HTMLButtonElement; + + await act(async () => { + actionButton.click(); + }); + + expect(downloadAndInstall).toHaveBeenCalledTimes(1); + expect(relaunch).toHaveBeenCalledTimes(1); + }); + + it("updates progress bar and percentage during download", async () => { + let progressCallback: ((event: DownloadEvent) => void) | undefined; + let finishPromiseResolve: () => void; + const finishPromise = new Promise((resolve) => { + finishPromiseResolve = resolve; + }); + + const downloadAndInstall = vi.fn().mockImplementation((cb) => { + progressCallback = cb; + return finishPromise; + }); + + const mockUpdate = { + version: "0.3.6", + downloadAndInstall, + } as unknown as Update; + + vi.mocked(check).mockResolvedValue(mockUpdate); + + await act(async () => { + root.render(); + }); + + const actionButton = container.querySelector(".update-toast-action") as HTMLButtonElement; + + await act(async () => { + actionButton.click(); + }); + + // Simulate download start and progress + act(() => { + progressCallback?.({ + event: "Started", + data: { contentLength: 1000 }, + }); + progressCallback?.({ + event: "Progress", + data: { chunkLength: 500 }, + }); + }); + + expect(container.textContent).toContain("Downloading 0.3.6… 50%"); + const fill = container.querySelector(".update-toast-progress-fill") as HTMLElement; + expect(fill.style.width).toBe("50%"); + + // Simulate finish + act(() => { + progressCallback?.({ + event: "Finished", + }); + }); + + expect(container.textContent).toContain("Applying update…"); + + await act(async () => { + finishPromiseResolve(); + }); + }); + + it("displays error state and provides retry and manual download options on failure", async () => { + const downloadAndInstall = vi.fn().mockRejectedValue(new Error("Network failed")); + + const mockUpdate = { + version: "0.3.6", + downloadAndInstall, + } as unknown as Update; + + vi.mocked(check).mockResolvedValue(mockUpdate); + + await act(async () => { + root.render(); + }); + + const actionButton = container.querySelector(".update-toast-action") as HTMLButtonElement; + + await act(async () => { + actionButton.click(); + }); + + expect(container.textContent).toContain("Update failed"); + expect(container.textContent).toContain("Couldn't apply the update automatically."); + + const retryButton = container.querySelector(".update-toast-action") as HTMLButtonElement; + expect(retryButton.textContent).toContain("Try again"); + + const manualButton = container.querySelector(".update-toast-secondary") as HTMLButtonElement; + expect(manualButton.textContent).toContain("Manual download"); + + await act(async () => { + manualButton.click(); + }); + + expect(openUrl).toHaveBeenCalledWith("https://sudonotes.com/download"); + }); +}); diff --git a/app/src/components/UpdateBanner.tsx b/app/src/components/UpdateBanner.tsx index 2b7d928..76e1136 100644 --- a/app/src/components/UpdateBanner.tsx +++ b/app/src/components/UpdateBanner.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; @@ -23,8 +24,9 @@ function fetchAvailableUpdate() { return updateCheckInFlight; } -/** Auto-update banner: checks on startup and once a day while the app remains - * open, then offers to download, install, and relaunch into the new version. */ +/** Auto-update toast: checks on startup and periodically while the app remains + * open, then offers a sleek floating toast in the bottom-right corner to download, + * install, and relaunch into the new version. */ export function UpdateBanner() { const [update, setUpdate] = useState(null); const [status, setStatus] = useState<"idle" | "downloading" | "installing" | "error">("idle"); @@ -44,7 +46,7 @@ export function UpdateBanner() { async function run(force = false) { // The timestamp is intentionally shared between versions to avoid noisy // polling, but a version upgrade always gets one fresh check. This is - // what makes an update banner appear on first launch after a release, + // what makes an update toast appear on first launch after a release, // even when the old version checked within the last 24 hours. let currentVersion: string | null = null; try { @@ -97,19 +99,24 @@ export function UpdateBanner() { let total = 0; let received = 0; try { - await update.downloadAndInstall((event) => { - if (event.event === "Started") { - total = event.data.contentLength ?? 0; - } else if (event.event === "Progress") { - received += event.data.chunkLength; - if (total > 0) { - setProgress(Math.min(100, Math.round((received / total) * 100))); + if (typeof update.downloadAndInstall === "function") { + await update.downloadAndInstall((event) => { + if (event.event === "Started") { + total = event.data.contentLength ?? 0; + } else if (event.event === "Progress") { + received += event.data.chunkLength; + if (total > 0) { + setProgress(Math.min(100, Math.round((received / total) * 100))); + } + } else if (event.event === "Finished") { + setStatus("installing"); } - } else if (event.event === "Finished") { - setStatus("installing"); - } - }); - await relaunch(); + }); + await relaunch(); + } else { + await openUrl("https://sudonotes.com/download"); + setStatus("idle"); + } } catch (error) { console.error("Could not install the sudonotes update", error); setStatus("error"); @@ -119,39 +126,89 @@ export function UpdateBanner() { const busy = status === "downloading" || status === "installing"; return ( -
- {busy ? ( - - {status === "downloading" - ? `Downloading ${update.version}… ${progress > 0 ? `${progress}%` : ""}` - : "Applying update… the app will restart itself."} - - ) : status === "error" ? ( - <> - Couldn't apply the update. - - - ) : ( - <> - - {update.version} is available — update sudonotes. +
+ +
+ {busy ? ( + <> +

+ {status === "downloading" + ? `Downloading ${update.version}… ${progress > 0 ? `${progress}%` : ""}` + : "Applying update… the app will restart itself."} +

+
+
0 ? { width: `${progress}%` } : undefined} + /> +
+ + ) : status === "error" ? ( + <> +

Couldn't apply the update automatically.

+
+ + +
+ + ) : ( + <> +

+ A new version of sudonotes is ready to install. +

+
+ +
+ + )} +
+ ); } + +export const UpdateToast = UpdateBanner;