diff --git a/packages/extension-chrome/src/lib/apply-remote.ts b/packages/extension-chrome/src/lib/apply-remote.ts index 9a04a6a..df01396 100644 --- a/packages/extension-chrome/src/lib/apply-remote.ts +++ b/packages/extension-chrome/src/lib/apply-remote.ts @@ -38,7 +38,10 @@ export async function applyRemoteChanges( } if (existingNode != null) { - // Already in the local tree — assume in sync; next reconcile fixes drift. + // Remote bookmark already in the local tree — propagate title/url + // changes so users on Device A see edits from Device B within the + // 5-minute poll window (issue #1). + await applyRemoteEdit(existingNode, bm.url, bm.title); continue; } @@ -63,6 +66,34 @@ export async function applyRemoteChanges( } } +async function applyRemoteEdit( + nodeId: string, + remoteUrl: string, + remoteTitle: string, +): Promise { + let current: chrome.bookmarks.BookmarkTreeNode | undefined; + try { + const found = await chrome.bookmarks.get(nodeId); + current = found[0]; + } catch { + // Node may have been deleted locally between mapping and apply; skip. + return; + } + if (current == null) return; + + const changes: { title?: string; url?: string } = {}; + if (current.title !== remoteTitle) changes.title = remoteTitle; + if (current.url !== remoteUrl) changes.url = remoteUrl; + if (Object.keys(changes).length === 0) return; + + // Suppress both the old URL (in case it's being changed away) and the new + // URL — the resulting onChanged echo will report the new URL. + if (current.url != null && current.url.length > 0) suppress(current.url); + suppress(remoteUrl); + + await chrome.bookmarks.update(nodeId, changes); +} + async function ensureFolderPath( folder: string, bookmarksBarId: string, diff --git a/packages/extension-chrome/test/apply-remote.test.ts b/packages/extension-chrome/test/apply-remote.test.ts index ce114a7..efb2cb5 100644 --- a/packages/extension-chrome/test/apply-remote.test.ts +++ b/packages/extension-chrome/test/apply-remote.test.ts @@ -44,6 +44,69 @@ describe("applyRemoteChanges", () => { expect(isSuppressed("https://example.com/new")).toBe(true); }); + it("propagates a remote title change to the local node (issue #1)", async () => { + const bm = bookmark({ + id: "u1", + url: "https://example.com/", + title: "New title from another device", + }); + const idMap = await IdMap.load(); + idMap.set(asUlid("u1"), asNodeId("node-1")); + + // Current local node has the old title + (chrome.bookmarks.get as any).mockResolvedValueOnce([ + { id: "node-1", parentId: BAR, title: "Old title", url: "https://example.com/" }, + ]); + + await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); + + expect(chrome.bookmarks.update).toHaveBeenCalledWith("node-1", { + title: "New title from another device", + }); + // The URL is unchanged, but we still suppress to avoid an onChanged echo + expect(isSuppressed("https://example.com/")).toBe(true); + }); + + it("propagates a remote URL change to the local node (issue #1)", async () => { + const bm = bookmark({ + id: "u1", + url: "https://example.com/new-path", + title: "Same title", + }); + const idMap = await IdMap.load(); + idMap.set(asUlid("u1"), asNodeId("node-1")); + + (chrome.bookmarks.get as any).mockResolvedValueOnce([ + { id: "node-1", parentId: BAR, title: "Same title", url: "https://example.com/old-path" }, + ]); + + await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); + + expect(chrome.bookmarks.update).toHaveBeenCalledWith("node-1", { + url: "https://example.com/new-path", + }); + // Suppress both old and new URL — the onChanged echo will fire with the new URL + expect(isSuppressed("https://example.com/new-path")).toBe(true); + }); + + it("skips the update call when remote matches local (no spurious onChanged)", async () => { + const bm = bookmark({ + id: "u1", + url: "https://example.com/", + title: "Same", + }); + const idMap = await IdMap.load(); + idMap.set(asUlid("u1"), asNodeId("node-1")); + + (chrome.bookmarks.get as any).mockResolvedValueOnce([ + { id: "node-1", parentId: BAR, title: "Same", url: "https://example.com/" }, + ]); + + await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); + + expect(chrome.bookmarks.update).not.toHaveBeenCalled(); + }); + it("does not create a bookmark already mapped", async () => { const bm = bookmark({ id: "u1", url: "https://example.com/" }); const idMap = await IdMap.load(); diff --git a/packages/extension-chrome/test/setup.ts b/packages/extension-chrome/test/setup.ts index 6d9dcf0..244362e 100644 --- a/packages/extension-chrome/test/setup.ts +++ b/packages/extension-chrome/test/setup.ts @@ -43,6 +43,7 @@ const chromeStub = { update: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), move: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), remove: vi.fn(async () => {}), + get: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), getTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), getSubTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), onCreated: { addListener: vi.fn(), removeListener: vi.fn() },