From 6b7e5cef98e377b0db7d7a61fb34cfb44b32b64d Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Mon, 25 May 2026 19:24:10 -0400 Subject: [PATCH 01/18] feat(core): add updateBookmarks (bulk) and restoreBookmark pure mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new exports to @gitmarks/core: `updateBookmarks` for applying patches to multiple bookmarks in a single atomic operation, and `restoreBookmark` which composes `updateBookmark` to clear `deleted_at`. Both are pure functions suitable for use as GitHubClient.update() callbacks. Exposes `BookmarkPatch` interface from the public index. Test count: 65 → 71. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/index.ts | 3 ++ packages/core/src/mutate.ts | 36 ++++++++++++++++++ packages/core/test/mutate.test.ts | 61 +++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 45cc7f1..6d91104 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,7 +20,10 @@ export { normalizeUrl } from "./url.js"; export { addBookmark, updateBookmark, + updateBookmarks, + type BookmarkPatch, softDeleteBookmark, + restoreBookmark, gcTombstones, } from "./mutate.js"; diff --git a/packages/core/src/mutate.ts b/packages/core/src/mutate.ts index 305043e..7454e6c 100644 --- a/packages/core/src/mutate.ts +++ b/packages/core/src/mutate.ts @@ -51,3 +51,39 @@ export function gcTombstones( } return { ...file, updated_at: nowIso, bookmarks: kept }; } + +export interface BookmarkPatch { + id: string; + patch: Partial>; +} + +export function updateBookmarks( + file: BookmarksFile, + patches: BookmarkPatch[], + nowIso: string, +): BookmarksFile { + if (patches.length === 0) { + return { ...file, updated_at: nowIso }; + } + const byId = new Map>>(); + for (const p of patches) byId.set(p.id, p.patch); + const next = file.bookmarks.map((b) => { + const patch = byId.get(b.id); + if (patch === undefined) return b; + byId.delete(b.id); + return { ...b, ...patch, updated_at: nowIso }; + }); + if (byId.size > 0) { + const missing = [...byId.keys()].join(", "); + throw new Error(`bookmark not found: ${missing}`); + } + return { ...file, updated_at: nowIso, bookmarks: next }; +} + +export function restoreBookmark( + file: BookmarksFile, + id: string, + nowIso: string, +): BookmarksFile { + return updateBookmark(file, id, { deleted_at: null }, nowIso); +} diff --git a/packages/core/test/mutate.test.ts b/packages/core/test/mutate.test.ts index d4e9f3d..9a1d74e 100644 --- a/packages/core/test/mutate.test.ts +++ b/packages/core/test/mutate.test.ts @@ -5,6 +5,8 @@ import { updateBookmark, softDeleteBookmark, gcTombstones, + restoreBookmark, + updateBookmarks, } from "../src/mutate.js"; function mkBookmark(overrides: Partial = {}): Bookmark { @@ -107,3 +109,62 @@ describe("gcTombstones", () => { expect(out.bookmarks).toEqual([live]); }); }); + +describe("updateBookmarks (bulk)", () => { + it("applies a patch to every listed id and stamps updated_at", () => { + const file = mkFile([ + mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA" }), + mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB" }), + mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC" }), + ]); + const next = updateBookmarks( + file, + [ + { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", patch: { folder: "Archive" } }, + { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", patch: { tags: ["x"] } }, + ], + "2026-05-25T00:00:00Z", + ); + expect(next.bookmarks[0]!.folder).toBe("Archive"); + expect(next.bookmarks[0]!.updated_at).toBe("2026-05-25T00:00:00Z"); + expect(next.bookmarks[1]!.folder).toBe(file.bookmarks[1]!.folder); + expect(next.bookmarks[2]!.tags).toEqual(["x"]); + expect(next.updated_at).toBe("2026-05-25T00:00:00Z"); + }); + + it("throws when any id is missing", () => { + const file = mkFile([mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA" })]); + expect(() => + updateBookmarks(file, [{ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CZ", patch: {} }], "2026-05-25T00:00:00Z"), + ).toThrow(/not found/); + }); + + it("no-ops on empty patch list but stamps updated_at", () => { + const file = mkFile([mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA" })]); + const next = updateBookmarks(file, [], "2026-05-25T00:00:00Z"); + expect(next.updated_at).toBe("2026-05-25T00:00:00Z"); + expect(next.bookmarks).toEqual(file.bookmarks); + }); + + it("does not mutate the input", () => { + const file = mkFile([mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", folder: "" })]); + updateBookmarks(file, [{ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", patch: { folder: "X" } }], "2026-05-25T00:00:00Z"); + expect(file.bookmarks[0]!.folder).toBe(""); + }); +}); + +describe("restoreBookmark", () => { + it("clears deleted_at and updates updated_at", () => { + const file = mkFile([ + mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", deleted_at: "2026-04-01T00:00:00Z" }), + ]); + const next = restoreBookmark(file, "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "2026-05-25T00:00:00Z"); + expect(next.bookmarks[0]!.deleted_at).toBeNull(); + expect(next.bookmarks[0]!.updated_at).toBe("2026-05-25T00:00:00Z"); + }); + + it("throws when the id is missing", () => { + const file = mkFile([]); + expect(() => restoreBookmark(file, "01HXYZ8K7M9P3RQ2V5W6Z8B0CZ", "2026-05-25T00:00:00Z")).toThrow(/not found/); + }); +}); From 51decc3544c4cf877e9f99463b70cb6eefdf38fd Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Mon, 25 May 2026 19:26:10 -0400 Subject: [PATCH 02/18] feat(web): add writeBookmarks to useGitmarksData hook --- packages/web/src/hooks/useGitmarksData.ts | 14 +++++++++++++ .../web/test/hooks.useGitmarksData.test.ts | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/web/src/hooks/useGitmarksData.ts b/packages/web/src/hooks/useGitmarksData.ts index e008dea..af91962 100644 --- a/packages/web/src/hooks/useGitmarksData.ts +++ b/packages/web/src/hooks/useGitmarksData.ts @@ -30,6 +30,10 @@ export interface UseGitmarksData { loading: boolean; error: string | null; refresh: () => Promise; + writeBookmarks: ( + mutate: (f: BookmarksFile) => BookmarksFile, + message: string, + ) => Promise; writeTags: ( mutate: (f: TagsFile) => TagsFile, message: string, @@ -84,6 +88,15 @@ export function useGitmarksData(client: GitHubClient): UseGitmarksData { } }, [bookmarks, tags, client, loadInitial]); + const writeBookmarks = useCallback( + async (mutate: (f: BookmarksFile) => BookmarksFile, message: string) => { + const result = await client.update("bookmarks.json", mutate, message); + if (!mounted.current) return; + setBookmarks({ data: result.data, etag: result.etag, sha: result.sha }); + }, + [client], + ); + const writeTags = useCallback( async (mutate: (f: TagsFile) => TagsFile, message: string) => { const result = await client.update("tags.json", mutate, message); @@ -107,6 +120,7 @@ export function useGitmarksData(client: GitHubClient): UseGitmarksData { loading, error, refresh, + writeBookmarks, writeTags, }; } diff --git a/packages/web/test/hooks.useGitmarksData.test.ts b/packages/web/test/hooks.useGitmarksData.test.ts index ed69c1e..d79b5a9 100644 --- a/packages/web/test/hooks.useGitmarksData.test.ts +++ b/packages/web/test/hooks.useGitmarksData.test.ts @@ -80,6 +80,26 @@ describe("useGitmarksData", () => { expect(update).toHaveBeenCalledWith("tags.json", mutator, "test commit"); }); + it("writeBookmarks() calls client.update on bookmarks.json with the mutator", async () => { + const updatedFile: BookmarksFile = { + version: 1, + updated_at: "2026-05-25T00:00:00Z", + bookmarks: [], + }; + const update = vi.fn().mockResolvedValue({ data: updatedFile, sha: "b2", etag: '"b2"' }); + const client = fakeClient({ update } as any); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const mutator = (f: BookmarksFile) => f; + await act(async () => { + await result.current.writeBookmarks(mutator, "bulk: move to trash"); + }); + + expect(update).toHaveBeenCalledWith("bookmarks.json", mutator, "bulk: move to trash"); + expect(result.current.bookmarksFile).toEqual(updatedFile); + }); + it("sets error when initial read throws", async () => { const client = fakeClient({ read: vi.fn().mockRejectedValue(new Error("boom")), From c093e94ede7fb9b3522fde09607f994bbdc8b4b6 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Mon, 25 May 2026 19:27:23 -0400 Subject: [PATCH 03/18] feat(web): add useSelection hook for multi-select state Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/hooks/useSelection.ts | 34 +++++++++++++++++ packages/web/test/hooks.useSelection.test.ts | 40 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 packages/web/src/hooks/useSelection.ts create mode 100644 packages/web/test/hooks.useSelection.test.ts diff --git a/packages/web/src/hooks/useSelection.ts b/packages/web/src/hooks/useSelection.ts new file mode 100644 index 0000000..20c4fb7 --- /dev/null +++ b/packages/web/src/hooks/useSelection.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from "react"; + +export interface UseSelection { + selected: ReadonlySet; + isSelected: (id: string) => boolean; + toggle: (id: string) => void; + setAll: (ids: readonly string[]) => void; + clear: () => void; +} + +export function useSelection(): UseSelection { + const [selected, setSelected] = useState>(() => new Set()); + + const toggle = useCallback((id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const setAll = useCallback((ids: readonly string[]) => { + setSelected(new Set(ids)); + }, []); + + const clear = useCallback(() => { + setSelected(new Set()); + }, []); + + const isSelected = useCallback((id: string) => selected.has(id), [selected]); + + return { selected, isSelected, toggle, setAll, clear }; +} diff --git a/packages/web/test/hooks.useSelection.test.ts b/packages/web/test/hooks.useSelection.test.ts new file mode 100644 index 0000000..f7f27ec --- /dev/null +++ b/packages/web/test/hooks.useSelection.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useSelection } from "../src/hooks/useSelection.js"; + +describe("useSelection", () => { + it("starts empty", () => { + const { result } = renderHook(() => useSelection()); + expect(result.current.selected.size).toBe(0); + }); + + it("toggle adds then removes", () => { + const { result } = renderHook(() => useSelection()); + act(() => result.current.toggle("a")); + expect(result.current.selected.has("a")).toBe(true); + act(() => result.current.toggle("a")); + expect(result.current.selected.has("a")).toBe(false); + }); + + it("setAll replaces selection", () => { + const { result } = renderHook(() => useSelection()); + act(() => result.current.setAll(["a", "b", "c"])); + expect(result.current.selected.size).toBe(3); + act(() => result.current.setAll(["d"])); + expect([...result.current.selected]).toEqual(["d"]); + }); + + it("clear empties the selection", () => { + const { result } = renderHook(() => useSelection()); + act(() => result.current.setAll(["a", "b"])); + act(() => result.current.clear()); + expect(result.current.selected.size).toBe(0); + }); + + it("isSelected reflects state", () => { + const { result } = renderHook(() => useSelection()); + act(() => result.current.toggle("x")); + expect(result.current.isSelected("x")).toBe(true); + expect(result.current.isSelected("y")).toBe(false); + }); +}); From 68082d91242662f7b8d32e6f10d0ed1e2f68e5e9 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Mon, 25 May 2026 19:29:12 -0400 Subject: [PATCH 04/18] feat(web): pure bulk-mutation factories built on core's updateBookmarks Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/lib/bulk-mutations.ts | 45 ++++++++ packages/web/test/lib.bulk-mutations.test.ts | 112 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 packages/web/src/lib/bulk-mutations.ts create mode 100644 packages/web/test/lib.bulk-mutations.test.ts diff --git a/packages/web/src/lib/bulk-mutations.ts b/packages/web/src/lib/bulk-mutations.ts new file mode 100644 index 0000000..5aee586 --- /dev/null +++ b/packages/web/src/lib/bulk-mutations.ts @@ -0,0 +1,45 @@ +import type { BookmarksFile } from "@gitmarks/core"; +import { updateBookmarks } from "@gitmarks/core"; + +type Mutator = (file: BookmarksFile) => BookmarksFile; + +export function bulkAddTag(ids: string[], tag: string, nowIso: string): Mutator { + return (file) => + updateBookmarks( + file, + ids.map((id) => { + const existing = file.bookmarks.find((b) => b.id === id); + const tags = existing?.tags ?? []; + const nextTags = tags.includes(tag) ? tags : [...tags, tag]; + return { id, patch: { tags: nextTags } }; + }), + nowIso, + ); +} + +export function bulkRemoveTag(ids: string[], tag: string, nowIso: string): Mutator { + return (file) => + updateBookmarks( + file, + ids.map((id) => { + const existing = file.bookmarks.find((b) => b.id === id); + const tags = existing?.tags ?? []; + return { id, patch: { tags: tags.filter((t) => t !== tag) } }; + }), + nowIso, + ); +} + +export function bulkSetFolder(ids: string[], folder: string, nowIso: string): Mutator { + return (file) => updateBookmarks(file, ids.map((id) => ({ id, patch: { folder } })), nowIso); +} + +export function bulkSoftDelete(ids: string[], nowIso: string): Mutator { + return (file) => + updateBookmarks(file, ids.map((id) => ({ id, patch: { deleted_at: nowIso } })), nowIso); +} + +export function bulkRestore(ids: string[], nowIso: string): Mutator { + return (file) => + updateBookmarks(file, ids.map((id) => ({ id, patch: { deleted_at: null } })), nowIso); +} diff --git a/packages/web/test/lib.bulk-mutations.test.ts b/packages/web/test/lib.bulk-mutations.test.ts new file mode 100644 index 0000000..ffeb805 --- /dev/null +++ b/packages/web/test/lib.bulk-mutations.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import type { Bookmark, BookmarksFile } from "@gitmarks/core"; +import { + bulkAddTag, + bulkRemoveTag, + bulkSetFolder, + bulkSoftDelete, + bulkRestore, +} from "../src/lib/bulk-mutations.js"; + +function mk(over: Partial = {}): Bookmark { + return { + id: "01HXYZ8K7M9P3RQ2V5W6Z8B0C1", + url: "https://example.com/", + title: "Example", + folder: "", + tags: [], + added_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + added_from: "chrome@minerva", + deleted_at: null, + notes: null, + ...over, + }; +} + +const file: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [ + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", tags: ["daily"] }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", tags: ["daily", "to-read"] }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", tags: [] }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CD", deleted_at: "2026-05-20T00:00:00Z" }), + ], +}; + +const now = "2026-05-25T00:00:00Z"; + +describe("bulkAddTag", () => { + it("adds a tag to each selected bookmark without duplicating", () => { + const mutator = bulkAddTag( + ["01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", "01HXYZ8K7M9P3RQ2V5W6Z8B0CC"], + "daily", + now, + ); + const next = mutator(file); + expect(next.bookmarks[0]!.tags).toEqual(["daily"]); + expect(next.bookmarks[1]!.tags).toEqual(["daily", "to-read"]); + expect(next.bookmarks[2]!.tags).toEqual(["daily"]); + }); + + it("returned mutator is pure (same input → same output)", () => { + const mutator = bulkAddTag(["01HXYZ8K7M9P3RQ2V5W6Z8B0CA"], "new", now); + expect(mutator(file)).toEqual(mutator(file)); + }); +}); + +describe("bulkRemoveTag", () => { + it("removes the tag from each selected bookmark; no-op when absent", () => { + const mutator = bulkRemoveTag( + ["01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", "01HXYZ8K7M9P3RQ2V5W6Z8B0CC"], + "daily", + now, + ); + const next = mutator(file); + expect(next.bookmarks[0]!.tags).toEqual([]); + expect(next.bookmarks[1]!.tags).toEqual(["to-read"]); + expect(next.bookmarks[2]!.tags).toEqual([]); + }); +}); + +describe("bulkSetFolder", () => { + it("sets folder on each selected bookmark", () => { + const mutator = bulkSetFolder( + ["01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "01HXYZ8K7M9P3RQ2V5W6Z8B0CB"], + "Archive", + now, + ); + const next = mutator(file); + expect(next.bookmarks[0]!.folder).toBe("Archive"); + expect(next.bookmarks[1]!.folder).toBe("Archive"); + expect(next.bookmarks[2]!.folder).toBe(""); + }); +}); + +describe("bulkSoftDelete", () => { + it("sets deleted_at on each selected bookmark", () => { + const mutator = bulkSoftDelete( + ["01HXYZ8K7M9P3RQ2V5W6Z8B0CA", "01HXYZ8K7M9P3RQ2V5W6Z8B0CB"], + now, + ); + const next = mutator(file); + expect(next.bookmarks[0]!.deleted_at).toBe(now); + expect(next.bookmarks[1]!.deleted_at).toBe(now); + expect(next.bookmarks[2]!.deleted_at).toBeNull(); + }); +}); + +describe("bulkRestore", () => { + it("clears deleted_at on each selected bookmark", () => { + const mutator = bulkRestore(["01HXYZ8K7M9P3RQ2V5W6Z8B0CD"], now); + const next = mutator(file); + expect(next.bookmarks[3]!.deleted_at).toBeNull(); + expect(next.bookmarks[3]!.updated_at).toBe(now); + }); + + it("throws via updateBookmarks when an id is missing", () => { + const mutator = bulkRestore(["01HXYZ8K7M9P3RQ2V5W6Z8B0CZ"], now); + expect(() => mutator(file)).toThrow(/not found/); + }); +}); From 992de4e14cc519561f4f88a71d9abec791dee213 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Mon, 25 May 2026 19:32:33 -0400 Subject: [PATCH 05/18] feat(web): optional selection props on BookmarkRow + select-all header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend BookmarkRow with optional selected/onToggleSelect props and BookmarkList with selected/onToggleSelect/onSetAll props; checkbox UI is only rendered when onToggleSelect is provided, keeping the legacy call-site (no selection props) fully unchanged. 3 new tests → 83 total. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/BookmarkList.tsx | 48 +++++++++++++-- packages/web/src/components/BookmarkRow.tsx | 60 ++++++++++++------- .../web/test/components.BookmarkList.test.tsx | 39 +++++++++++- 3 files changed, 117 insertions(+), 30 deletions(-) diff --git a/packages/web/src/components/BookmarkList.tsx b/packages/web/src/components/BookmarkList.tsx index c59443d..f6c8697 100644 --- a/packages/web/src/components/BookmarkList.tsx +++ b/packages/web/src/components/BookmarkList.tsx @@ -5,9 +5,18 @@ import { visibleBookmarks } from "../lib/data.js"; interface Props { bookmarksFile: BookmarksFile; tagsFile: TagsFile; + selected?: ReadonlySet; + onToggleSelect?: (id: string) => void; + onSetAll?: (ids: string[]) => void; } -export function BookmarkList({ bookmarksFile, tagsFile }: Props) { +export function BookmarkList({ + bookmarksFile, + tagsFile, + selected, + onToggleSelect, + onSetAll, +}: Props) { const items = visibleBookmarks(bookmarksFile); if (items.length === 0) { return ( @@ -16,11 +25,38 @@ export function BookmarkList({ bookmarksFile, tagsFile }: Props) {

); } + const showSelectAll = onToggleSelect !== undefined; + const allSelected = + showSelectAll && selected !== undefined && items.length > 0 && items.every((b) => selected.has(b.id)); return ( -
    - {items.map((b) => ( - - ))} -
+
+ {showSelectAll && ( +
+ { + if (onSetAll !== undefined) { + onSetAll(allSelected ? [] : items.map((b) => b.id)); + } + }} + /> + {selected?.size ?? 0} selected +
+ )} +
    + {items.map((b) => ( + + ))} +
+
); } diff --git a/packages/web/src/components/BookmarkRow.tsx b/packages/web/src/components/BookmarkRow.tsx index 17b644d..d057dcc 100644 --- a/packages/web/src/components/BookmarkRow.tsx +++ b/packages/web/src/components/BookmarkRow.tsx @@ -4,34 +4,48 @@ import { TagChip } from "./TagChip.js"; interface Props { bookmark: Bookmark; tagsFile: TagsFile; + selected?: boolean; + onToggleSelect?: (id: string) => void; } -export function BookmarkRow({ bookmark, tagsFile }: Props) { +export function BookmarkRow({ bookmark, tagsFile, selected, onToggleSelect }: Props) { const folder = bookmark.folder.length > 0 ? bookmark.folder : "(root)"; + const showCheckbox = onToggleSelect !== undefined; return ( -
  • -
    - - {bookmark.title} - - {folder} -
    -
    {bookmark.url}
    - {bookmark.tags.length > 0 && ( -
    - {bookmark.tags.map((t) => ( - - ))} -
    - )} - {bookmark.notes != null && ( -

    {bookmark.notes}

    +
  • + {showCheckbox && ( + onToggleSelect(bookmark.id)} + className="mt-1.5" + /> )} +
    +
    + + {bookmark.title} + + {folder} +
    +
    {bookmark.url}
    + {bookmark.tags.length > 0 && ( +
    + {bookmark.tags.map((t) => ( + + ))} +
    + )} + {bookmark.notes != null && ( +

    {bookmark.notes}

    + )} +
  • ); } diff --git a/packages/web/test/components.BookmarkList.test.tsx b/packages/web/test/components.BookmarkList.test.tsx index 7961d1b..94aff4d 100644 --- a/packages/web/test/components.BookmarkList.test.tsx +++ b/packages/web/test/components.BookmarkList.test.tsx @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { BookmarksFile, TagsFile } from "@gitmarks/core"; import { BookmarkList } from "../src/components/BookmarkList.js"; @@ -64,4 +65,40 @@ describe("BookmarkList", () => { render(); expect(screen.getByText(/no bookmarks yet/i)).toBeInTheDocument(); }); + + it("renders a checkbox per row when onToggleSelect is provided", () => { + const onToggleSelect = vi.fn(); + render( + , + ); + const checkboxes = screen.getAllByRole("checkbox"); + // 1 row checkbox + 1 select-all = 2 + expect(checkboxes.length).toBeGreaterThanOrEqual(2); + }); + + it("calls onToggleSelect with the bookmark id when its checkbox is clicked", async () => { + const user = userEvent.setup(); + const onToggleSelect = vi.fn(); + render( + , + ); + const rowCheckbox = screen.getByLabelText(/select hacker news/i); + await user.click(rowCheckbox); + expect(onToggleSelect).toHaveBeenCalledWith("01HXYZ8K7M9P3RQ2V5W6Z8B0CA"); + }); + + it("renders no checkboxes when onToggleSelect is not provided", () => { + render(); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + }); }); From faf244955a001511530198216f9b97bb833540b5 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Mon, 25 May 2026 19:34:29 -0400 Subject: [PATCH 06/18] feat(web): bulk actions bar (add tag, remove tag, set folder, delete, clear) Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/components/BulkActionsBar.tsx | 119 ++++++++++++++++++ .../test/components.BulkActionsBar.test.tsx | 73 +++++++++++ 2 files changed, 192 insertions(+) create mode 100644 packages/web/src/components/BulkActionsBar.tsx create mode 100644 packages/web/test/components.BulkActionsBar.test.tsx diff --git a/packages/web/src/components/BulkActionsBar.tsx b/packages/web/src/components/BulkActionsBar.tsx new file mode 100644 index 0000000..d1cc4d7 --- /dev/null +++ b/packages/web/src/components/BulkActionsBar.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import type { TagsFile } from "@gitmarks/core"; + +interface Props { + count: number; + tagsFile: TagsFile; + onAddTag: (tag: string) => Promise; + onRemoveTag: (tag: string) => Promise; + onSetFolder: (folder: string) => Promise; + onDelete: () => Promise; + onClear: () => void; +} + +const inputClass = + "px-2 py-1 bg-mist border border-fog rounded text-cyan-soft text-sm focus:border-cyan focus:outline-none"; +const btnClass = + "px-3 py-1 rounded bg-fog text-cyan-soft text-sm hover:bg-cyan hover:text-ink disabled:opacity-40"; +const dangerClass = + "px-3 py-1 rounded border border-magenta text-magenta text-sm hover:bg-magenta hover:text-ink"; + +export function BulkActionsBar({ + count, + tagsFile, + onAddTag, + onRemoveTag, + onSetFolder, + onDelete, + onClear, +}: Props) { + const [tagToAdd, setTagToAdd] = useState(""); + const [tagToRemove, setTagToRemove] = useState(""); + const [folder, setFolder] = useState(""); + const tagOptions = Object.keys(tagsFile.tags).sort(); + + return ( +
    + {count} selected + +
    + setTagToAdd(e.target.value)} + placeholder="tag" + /> + +
    + +
    + + +
    + +
    + setFolder(e.target.value)} + placeholder="folder path" + /> + +
    + + + + +
    + ); +} diff --git a/packages/web/test/components.BulkActionsBar.test.tsx b/packages/web/test/components.BulkActionsBar.test.tsx new file mode 100644 index 0000000..a7d108f --- /dev/null +++ b/packages/web/test/components.BulkActionsBar.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { TagsFile } from "@gitmarks/core"; +import { BulkActionsBar } from "../src/components/BulkActionsBar.js"; + +const tagsFile: TagsFile = { + version: 1, + tags: { + daily: { color: "#00FFFF", description: null }, + reference: { color: "#00FF88", description: null }, + }, +}; + +function noopHandlers() { + return { + onAddTag: vi.fn().mockResolvedValue(undefined), + onRemoveTag: vi.fn().mockResolvedValue(undefined), + onSetFolder: vi.fn().mockResolvedValue(undefined), + onDelete: vi.fn().mockResolvedValue(undefined), + onClear: vi.fn(), + }; +} + +describe("BulkActionsBar", () => { + it("shows the selection count", () => { + render(); + expect(screen.getByText(/3 selected/i)).toBeInTheDocument(); + }); + + it("calls onAddTag with the typed tag", async () => { + const user = userEvent.setup(); + const handlers = noopHandlers(); + render(); + await user.type(screen.getByLabelText(/add tag/i), "weekly"); + await user.click(screen.getByRole("button", { name: /^add$/i })); + expect(handlers.onAddTag).toHaveBeenCalledWith("weekly"); + }); + + it("calls onRemoveTag with the picked tag", async () => { + const user = userEvent.setup(); + const handlers = noopHandlers(); + render(); + await user.selectOptions(screen.getByLabelText(/remove tag/i), "reference"); + await user.click(screen.getByRole("button", { name: /^remove$/i })); + expect(handlers.onRemoveTag).toHaveBeenCalledWith("reference"); + }); + + it("calls onSetFolder with the typed folder", async () => { + const user = userEvent.setup(); + const handlers = noopHandlers(); + render(); + await user.type(screen.getByLabelText(/set folder/i), "Archive"); + await user.click(screen.getByRole("button", { name: /^set$/i })); + expect(handlers.onSetFolder).toHaveBeenCalledWith("Archive"); + }); + + it("calls onDelete when Move to trash is clicked", async () => { + const user = userEvent.setup(); + const handlers = noopHandlers(); + render(); + await user.click(screen.getByRole("button", { name: /move to trash/i })); + expect(handlers.onDelete).toHaveBeenCalled(); + }); + + it("calls onClear when Clear is clicked", async () => { + const user = userEvent.setup(); + const handlers = noopHandlers(); + render(); + await user.click(screen.getByRole("button", { name: /^clear$/i })); + expect(handlers.onClear).toHaveBeenCalled(); + }); +}); From dddafff324657835e9768a83a4d9c42990303665 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Mon, 25 May 2026 19:37:01 -0400 Subject: [PATCH 07/18] feat(web): list page bulk select + bulk actions wired to client.update Wires useSelection + BulkActionsBar into ListPage, passing selected/onToggleSelect/onSetAll to BookmarkList. Adds two integration tests (91 web total). Also fixes BookmarkList select-all header counter text to avoid DOM ambiguity with BulkActionsBar's "N selected" label. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/BookmarkList.tsx | 2 +- packages/web/src/routes/ListPage.tsx | 66 +++++++++++++++++-- .../web/test/ListPage.integration.test.tsx | 36 ++++++++++ 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/web/src/components/BookmarkList.tsx b/packages/web/src/components/BookmarkList.tsx index f6c8697..b73b0e1 100644 --- a/packages/web/src/components/BookmarkList.tsx +++ b/packages/web/src/components/BookmarkList.tsx @@ -42,7 +42,7 @@ export function BookmarkList({ } }} /> - {selected?.size ?? 0} selected + {selected?.size ?? 0} of {items.length} )}