From 0a71363fc3e9bbcdcb192e80c0957e2bd2254c95 Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Wed, 11 Nov 2026 21:37:18 +1100 Subject: [PATCH] feat(library): add tag and version history controls --- web/src/styles/library.css | 150 ++++++++ web/src/ui/TagsView.tsx | 342 ++++++++++++++++++ web/src/ui/VersionHistory.tsx | 220 +++++++++++ web/src/ui/libraryAction.tsx | 157 ++++++++ .../ui/note-links-library-recovery.test.tsx | 214 +++++++++++ 5 files changed, 1083 insertions(+) create mode 100644 web/src/styles/library.css create mode 100644 web/src/ui/TagsView.tsx create mode 100644 web/src/ui/VersionHistory.tsx create mode 100644 web/src/ui/libraryAction.tsx create mode 100644 web/tests/ui/note-links-library-recovery.test.tsx diff --git a/web/src/styles/library.css b/web/src/styles/library.css new file mode 100644 index 0000000..d883823 --- /dev/null +++ b/web/src/styles/library.css @@ -0,0 +1,150 @@ +.library-view { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + overflow: auto; +} +.tag-table { + padding: 0 28px; +} +.tag-row { + display: flex; + gap: 16px; + align-items: center; + min-height: 56px; + border-bottom: 1px solid var(--line); + flex-wrap: wrap; + padding: 10px 0; +} +.tag-row > button:first-child { + flex: 1; + text-align: left; + min-width: 120px; +} +.tag-swatch { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + margin-right: 9px; + background: var(--t3); +} +.tag-colors { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.tag-colors button[aria-pressed="true"] { + outline: 2px solid currentColor; + outline-offset: 2px; +} +.library-form { + display: flex; + flex-direction: column; + gap: 14px; +} +.library-form label { + display: flex; + flex-direction: column; + gap: 6px; +} +.version-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 20px; + min-height: 280px; +} +.version-list { + display: flex; + flex-direction: column; + gap: 5px; + max-height: 60vh; + overflow: auto; +} +.version-list button { + text-align: left; +} +.version-list button[aria-pressed="true"] { + background: var(--sel); +} +.version-preview { + min-width: 0; + overflow-wrap: anywhere; + max-height: 65vh; + overflow: auto; +} +.version-preview pre { + white-space: pre-wrap; + font: inherit; +} +.note-row > .library-row-actions { + display: flex; + align-items: center; + padding: 0 10px; +} +.library-sort { + display: flex; + gap: 6px; + align-items: center; +} +.library-sort select { + max-width: 110px; +} +.history-dialog { + width: min(860px, calc(100vw - 32px)); +} +@media (max-width: 650px) { + .tag-table { + padding: 0 16px; + } + .version-layout { + grid-template-columns: minmax(0, 1fr); + } + .version-list { + max-height: 150px; + } + .tag-row { + gap: 8px; + } +} + +.note-row > .library-row-actions { + flex: 0 0 auto; +} + +.version-preview [role="toolbar"] { + display: none; +} + +.note-tag-chips { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.note-tag-chips .tag { + display: inline-flex; + gap: 6px; + align-items: center; + overflow-wrap: anywhere; +} +.note-tag-chips button { + padding: 0 3px; + min-width: 22px; + min-height: 22px; + background: transparent; +} +.new-note-tag { + display: flex; + align-items: end; + gap: 8px; + flex-wrap: wrap; +} +.new-note-tag label { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 180px; +} diff --git a/web/src/ui/TagsView.tsx b/web/src/ui/TagsView.tsx new file mode 100644 index 0000000..63318b1 --- /dev/null +++ b/web/src/ui/TagsView.tsx @@ -0,0 +1,342 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import type { + LibraryMutationResult, + LibrarySnapshot, + NotesLibrary, + Tag, + TagColor, +} from "../library/contracts"; +import { registerCloseGuard } from "../desktop/closeGuard"; +import { Dialog } from "./primitives"; +import { Menu } from "./Menu"; +import { LibraryActionFeedback, useLibraryAction } from "./libraryAction"; +import "../styles/library.css"; +const colors: [string, TagColor][] = [ + ["Neutral", null], + ["Blue", "#3b82f6"], + ["Green", "#16a34a"], + ["Purple", "#a855f7"], + ["Orange", "#f97316"], +]; +type Form = { + kind: "create" | "rename" | "color" | "merge" | "delete"; + tag: Tag | null; + name: string; + color: TagColor; + target: string; +}; +export function TagsView({ + library, + snapshot, + onResult, + reload, + onOpenTag, + readOnly = false, +}: { + readOnly?: boolean; + library: NotesLibrary; + snapshot: LibrarySnapshot; + onResult: (result: LibraryMutationResult) => void; + reload: () => Promise; + onOpenTag: (name: string) => void; +}) { + const [form, setForm] = useState
(null); + const [discard, setDiscard] = useState(false); + const dirty = useRef(false); + const action = useLibraryAction(); + const readOnlyRef = useRef(readOnly); + readOnlyRef.current = readOnly; + const feedbackAction = { + ...action, + reloaded: !readOnly && action.reloaded, + retry: () => { + if (!readOnlyRef.current) action.retry(); + }, + }; + useLayoutEffect(() => registerCloseGuard(() => !dirty.current), []); + useLayoutEffect(() => { + const guard = (e: BeforeUnloadEvent) => { + if (dirty.current) e.preventDefault(); + }; + window.addEventListener("beforeunload", guard); + return () => window.removeEventListener("beforeunload", guard); + }, []); + const active = snapshot.tags + .filter((tag) => tag.state === "active") + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + function open(kind: Form["kind"], tag: Tag | null = null) { + if (readOnlyRef.current || action.busy || action.error) return; + dirty.current = false; + setForm({ + kind, + tag, + name: tag?.name ?? "", + color: tag?.color ?? null, + target: "", + }); + } + function close() { + if (action.busy || action.error) return; + if (dirty.current) setDiscard(true); + else setForm(null); + } + function submit() { + if (!form || readOnlyRef.current) return; + const value = { ...form }, + token = { ...snapshot.token }; + action.start( + (id) => { + if (readOnlyRef.current) + throw Error("Repair note links before saving tag changes."); + if (value.kind === "create") + return library.createTag( + { name: value.name, color: value.color }, + token, + id, + ); + if (!value.tag) throw new Error("Choose a tag."); + if (value.kind === "rename") + return library.renameTag(value.tag.id, value.name, token, id); + if (value.kind === "color") + return library.recolorTag(value.tag.id, value.color, token, id); + if (value.kind === "merge") + return library.mergeTag(value.tag.id, value.target, token, id); + return library.deleteTag(value.tag.id, token, id); + }, + (result) => { + dirty.current = false; + setForm(null); + onResult(result); + }, + ); + } + return ( +
+
+
+

Tags

+ {active.length} tags +
+ +
+ {readOnly && ( +

+ Repair note links before saving tag changes. Your unsubmitted changes + are kept here. +

+ )} + +
+ {active.length === 0 && ( +

+ No tags yet. Create a tag to organize your notes. +

+ )} + {active.map((tag) => { + const references = snapshot.notes.filter( + (note) => + !note.trashedAt && + snapshot.metadata.some( + (meta) => + meta.noteId === note.id && meta.tagIds.includes(tag.id), + ), + ); + const last = references.reduce( + (latest, note) => Math.max(latest, Date.parse(note.updatedAt)), + 0, + ); + return ( +
+ + + {references.length} {references.length === 1 ? "note" : "notes"} + + + {last + ? `Last used ${new Date(last).toLocaleDateString()}` + : "Not used yet"} + + ({ + label: { + rename: "Rename tag", + color: "Change color", + merge: "Merge tag", + delete: "Delete tag", + }[kind], + danger: kind === "delete", + disabled: + readOnly || + action.busy || + !!action.error || + (kind === "merge" && active.length < 2), + onSelect: () => open(kind, tag), + }), + )} + /> +
+ ); + })} +
+ {form && ( + +
+ {readOnly && ( +

+ Repair note links before saving tag changes. Your unsubmitted + changes are kept here. +

+ )} + + {(form.kind === "create" || form.kind === "rename") && ( + + )} + {(form.kind === "create" || form.kind === "color") && ( +
+ Color +
+ {colors.map(([name, color]) => ( + + ))} +
+
+ )} + {form.kind === "merge" && ( + <> +

+ Merge {form.tag?.name} into another tag. Notes keep the target + tag; the source tag is removed. +

+ + + )} + {form.kind === "delete" && ( +

+ Remove “{form.tag?.name}” from live notes and Trash? This + removes the tag, not the notes themselves. Historical versions + keep their captured tag labels. +

+ )} +
+ + +
+
+
+ )} + {discard && ( + setDiscard(false)}> +

Your unsubmitted tag changes will be lost.

+ + +
+ )} +
+ ); +} diff --git a/web/src/ui/VersionHistory.tsx b/web/src/ui/VersionHistory.tsx new file mode 100644 index 0000000..cfcbc9c --- /dev/null +++ b/web/src/ui/VersionHistory.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState, useRef } from "react"; +import type { Note } from "../domain/contracts"; +import type { + LibraryMutationResult, + NoteVersion, + NotesLibrary, +} from "../library/contracts"; +import { Dialog } from "./primitives"; +import { RichEditor } from "./RichEditor"; +import { message } from "./model"; +import { LibraryActionFeedback, useLibraryAction } from "./libraryAction"; +import "../styles/library.css"; +import type { EditorNoteLinks } from "./noteLinkViews"; +export function VersionHistory({ + note, + library, + onClose, + onResult, + reload, + readOnly = false, + noteLinks, +}: { + readOnly?: boolean; + noteLinks?: EditorNoteLinks; + note: Note; + library: NotesLibrary; + onClose: () => void; + onResult: (result: LibraryMutationResult) => void; + reload: () => Promise; +}) { + const [versions, setVersions] = useState([]), + [selected, setSelected] = useState(null), + [loading, setLoading] = useState(true), + [error, setError] = useState(""), + [confirm, setConfirm] = useState(false); + const [attempt, setAttempt] = useState(0); + const action = useLibraryAction(); + const readOnlyRef = useRef(readOnly); + readOnlyRef.current = readOnly; + const feedbackAction = { + ...action, + reloaded: !readOnly && action.reloaded, + retry: () => { + if (!readOnlyRef.current) action.retry(); + }, + }; + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + library + .history(note.id) + .then((value) => { + if (active) setVersions(value); + }) + .catch((e) => { + if (active) setError(message(e)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [library, note.id, note.revision, attempt]); + return ( + { + if (!action.busy && !action.error) onClose(); + }} + > +

+ History starts when this feature was enabled. Up to 50 previous versions + per note are retained for up to 30 days, within 8 MiB per note and 32 + MiB across the library. Space limits may remove older versions sooner. +

+ {readOnly && ( +

+ Repair note links before restoring a version. You can still browse + history. +

+ )} + + {error && ( +
+

{error}

+ +
+ )} + {loading ? ( +

Loading versions…

+ ) : ( +
+
+ + {versions.map((version) => ( + + ))} + {!versions.length &&

No previous versions yet.

} +
+
+

{(selected?.title ?? note.title) || "Untitled note"}

+
+ {( + selected?.tagsAtCapture ?? + note.tags.map((name) => ({ id: name, name, color: null })) + ).map((tag) => ( + + {tag.name} + + ))} +
+ {}} + ariaLabel="Version content" + readOnly + /> + {selected && ( + <> +

+ Restores the title and writing only. Current tags and pin stay + unchanged. +

+ + + )} +
+
+ )} + {confirm && selected && ( + { + if (!action.busy) setConfirm(false); + }} + > +

+ The current title and writing will be kept as a previous version. + Current tags and pin stay unchanged. +

+ {readOnly && ( +

Repair note links before restoring a version.

+ )} + + + +
+ )} +
+ ); +} diff --git a/web/src/ui/libraryAction.tsx b/web/src/ui/libraryAction.tsx new file mode 100644 index 0000000..4881f1d --- /dev/null +++ b/web/src/ui/libraryAction.tsx @@ -0,0 +1,157 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { registerCloseGuard, trackOperation } from "../desktop/closeGuard"; +import type { LibraryMutationResult } from "../library/contracts"; +import { message } from "./model"; + +type Intent = { + id: string; + effect: (id: string) => Promise; + done: (value: LibraryMutationResult) => void; +}; +/** A failed action retains its original arguments and receipt ID. Reload never rebases it. */ +export function useLibraryAction(scope?: string) { + const alive = useRef(false); + const generation = useRef(0); + const intent = useRef(null); + const pending = useRef(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [conflict, setConflict] = useState(false); + const [reloaded, setReloaded] = useState(false); + useLayoutEffect(() => { + alive.current = true; + return () => { + alive.current = false; + generation.current++; + }; + }, []); + useLayoutEffect(() => { + generation.current++; + intent.current = null; + pending.current = false; + setBusy(false); + setError(""); + setConflict(false); + setReloaded(false); + }, [scope]); + useLayoutEffect( + () => registerCloseGuard(() => !pending.current && !intent.current), + [], + ); + useLayoutEffect(() => { + const guard = (event: BeforeUnloadEvent) => { + if (intent.current) event.preventDefault(); + }; + window.addEventListener("beforeunload", guard); + return () => window.removeEventListener("beforeunload", guard); + }, []); + async function execute(value: Intent) { + if (pending.current) return; + const invocation = generation.current; + pending.current = true; + setBusy(true); + setError(""); + try { + const result = await trackOperation(() => value.effect(value.id)); + if (!alive.current || invocation !== generation.current) return; + intent.current = null; + value.done(result); + } catch (failure) { + if (!alive.current || invocation !== generation.current) return; + const code = + failure && typeof failure === "object" && "code" in failure + ? String(failure.code) + : ""; + setConflict( + code === "library_conflict" || + code === "library_epoch_changed" || + code === "conflict" || + code === "revision_conflict", + ); + setError(message(failure)); + setReloaded(false); + } finally { + if (alive.current && invocation === generation.current) { + pending.current = false; + setBusy(false); + } + } + } + return { + busy, + error, + conflict, + reloaded, + start(effect: Intent["effect"], done: Intent["done"]) { + if (intent.current || pending.current) return; + const value = { id: crypto.randomUUID(), effect, done }; + intent.current = value; + setConflict(false); + setReloaded(false); + void execute(value); + }, + async reload(read: () => Promise) { + const invocation = generation.current, + action = intent.current; + try { + await read(); + if ( + alive.current && + invocation === generation.current && + action === intent.current + ) + setReloaded(true); + } catch (e) { + if ( + alive.current && + invocation === generation.current && + action === intent.current + ) + setError(message(e)); + } + }, + retry() { + if (intent.current && reloaded && !conflict) void execute(intent.current); + }, + dismiss() { + if (!pending.current) { + intent.current = null; + setError(""); + setConflict(false); + } + }, + }; +} +export function LibraryActionFeedback({ + action, + reload, +}: { + action: ReturnType; + reload: () => Promise; +}) { + if (!action.error) return null; + return ( +
+

{action.error}

+

+ {action.conflict + ? "The saved library changed. Reload, then review your action before submitting it again." + : "The result could not be confirmed. Reload before retrying the same action."} +

+ + {!action.conflict && ( + + )} + +
+ ); +} diff --git a/web/tests/ui/note-links-library-recovery.test.tsx b/web/tests/ui/note-links-library-recovery.test.tsx new file mode 100644 index 0000000..c0d6d8c --- /dev/null +++ b/web/tests/ui/note-links-library-recovery.test.tsx @@ -0,0 +1,214 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { TagsView } from "../../src/ui/TagsView"; +import { VersionHistory } from "../../src/ui/VersionHistory"; +import type { + NotesLibrary, + LibrarySnapshot, + NoteVersion, +} from "../../src/library/contracts"; +import type { Note } from "../../src/domain/contracts"; +import type { NoteLinkService, NoteTarget } from "../../src/links/contracts"; +const target: NoteTarget = { + kind: "note", + accountId: "11111111-1111-4111-8111-111111111111", + libraryEpoch: "22222222-2222-4222-8222-222222222222", + noteId: "exact-target", +}; +const note: Note = { + id: "source", + revision: 2, + title: "Source", + tags: [], + createdAt: "2026-11-11T00:00:00Z", + updatedAt: "2026-11-11T00:00:00Z", + trashedAt: null, + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "noteLink", attrs: { version: 1, target } }], + }, + ], + }, +}; +const version: NoteVersion = { + id: "version-1", + noteId: note.id, + noteRevision: 1, + capturedAt: note.createdAt, + reason: "edit", + title: "Previous source", + document: note.document, + tagsAtCapture: [], +}; +const snapshot: LibrarySnapshot = { + token: { epoch: target.libraryEpoch, revision: 2 }, + notes: [note], + tags: [], + metadata: [], + preferences: { sort: "edited" }, +}; +function library(): NotesLibrary { + return { + read: vi.fn(async () => ({ mode: "ready" as const, snapshot })), + updateWithSourceSteps: vi.fn(), + replaceDocument: vi.fn(), + setPinned: vi.fn(), + setSort: vi.fn(), + createTag: vi.fn(), + renameTag: vi.fn(), + recolorTag: vi.fn(), + mergeTag: vi.fn(), + deleteTag: vi.fn(), + history: vi.fn(async () => [version]), + version: vi.fn(), + restoreVersion: vi.fn(), + changeTrash: vi.fn(), + undoTrash: vi.fn(), + emptyTrash: vi.fn(), + eraseLegacy: vi.fn(), + }; +} +function service(): NoteLinkService { + return { + admission: vi.fn(async () => ({ + mode: "current" as const, + token: snapshot.token, + graph: "ready" as const, + })), + currentToken: vi.fn(), + search: vi.fn(), + reference: vi.fn(), + resolve: vi.fn(async () => ({ + status: "available" as const, + target, + token: snapshot.token, + title: "Current authorized target", + noteRevision: 4, + })), + outgoing: vi.fn(), + backlinks: vi.fn(), + linkedNoteIds: vi.fn(), + verifyGraph: vi.fn(), + cancelVerification: vi.fn(), + prepareRepair: vi.fn(), + commitRepair: vi.fn(), + prepareExport: vi.fn(), + exportFiles: vi.fn(), + assertFreshExport: vi.fn(), + subscribe: () => () => {}, + onInvalidate: () => () => {}, + close: vi.fn(), + }; +} +beforeEach(() => { + window.scrollBy = vi.fn(); + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(1, 1, 1, 16), + }); +}); +it("keeps an open tag draft copyable and disables both UI and handler mutations during index recovery", () => { + const api = library(), + props = { + library: api, + snapshot, + onResult: vi.fn(), + reload: vi.fn(async () => {}), + onOpenTag: vi.fn(), + }; + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "New tag" })); + const name = screen.getByRole("textbox", { name: "Name" }); + fireEvent.change(name, { target: { value: "Unsubmitted exact name" } }); + view.rerender(); + expect(name).toHaveAttribute("readonly"); + expect(name).toHaveValue("Unsubmitted exact name"); + fireEvent.change(name, { target: { value: "Must not replace draft" } }); + expect(name).toHaveValue("Unsubmitted exact name"); + expect(screen.getByRole("button", { name: "Create tag" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Create tag" })); + expect(api.createTag).not.toHaveBeenCalled(); + fireEvent.click( + within(screen.getByRole("dialog", { name: "New tag" })).getByRole( + "button", + { name: "Cancel" }, + ), + ); + fireEvent.click(screen.getByRole("button", { name: "Keep editing" })); + expect(name).toHaveValue("Unsubmitted exact name"); + view.rerender(); + expect(screen.getByRole("button", { name: "Create tag" })).toBeEnabled(); +}); +it("keeps an already-open history confirmation and typed Notes preview while forbidding restore after admission changes", async () => { + const api = library(), + links = service(), + props = { + note, + library: api, + onClose: vi.fn(), + onResult: vi.fn(), + reload: vi.fn(async () => {}), + noteLinks: { service: links, noteId: note.id, onOpen: vi.fn() }, + }; + const view = render(); + await screen.findByRole("button", { + name: "Note reference: Current authorized target", + }); + expect( + screen.getByRole("button", { + name: "Note reference: Current authorized target", + }), + ).toBeDisabled(); + expect( + screen.queryByText(/This content isn’t supported/), + ).not.toBeInTheDocument(); + fireEvent.click(await screen.findByRole("button", { name: /Previous edit/ })); + fireEvent.click( + await screen.findByRole("button", { + name: "Restore this version", + }), + ); + await screen.findByRole("dialog", { name: "Restore this version?" }); + view.rerender(); + const restore = within( + screen.getByRole("dialog", { name: "Restore this version?" }), + ).getByRole("button", { name: "Restore version" }); + expect(restore).toBeDisabled(); + fireEvent.click(restore); + expect(api.restoreVersion).not.toHaveBeenCalled(); + fireEvent.click( + within( + screen.getByRole("dialog", { name: "Restore this version?" }), + ).getByRole("button", { name: "Cancel" }), + ); + expect( + screen.getByRole("heading", { name: "Previous source" }), + ).toBeVisible(); + await waitFor(() => + expect( + screen.getByRole("button", { + name: "Note reference: Current authorized target", + }), + ).toBeDisabled(), + ); + expect(props.noteLinks.onOpen).not.toHaveBeenCalled(); +});