diff --git a/scripts/checkout-probe.mjs b/scripts/checkout-probe.mjs new file mode 100644 index 0000000..e405d68 --- /dev/null +++ b/scripts/checkout-probe.mjs @@ -0,0 +1,97 @@ +// Verify the "check out an old commit" model: +// 1. building a chain Istanbul → Turkish_language → Turkey, +// 2. checking out Istanbul (the panel's action) and navigating to it → +// must NOT duplicate Istanbul, +// 3. navigating onward (→ Bosphorus) → must branch from Istanbul. +// +// node scripts/checkout-probe.mjs (after `npm run build`) + +import { chromium } from "playwright"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const DIST = resolve(dirname(fileURLToPath(import.meta.url)), "../dist"); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const clickLink = (page, href) => + page.evaluate((h) => { + const a = document.createElement("a"); + a.href = h; + document.body.prepend(a); + a.click(); + }, href); + +const context = await chromium.launchPersistentContext(mkdtempSync(resolve(tmpdir(), "hg-")), { + headless: false, + args: [`--disable-extensions-except=${DIST}`, `--load-extension=${DIST}`], +}); + +const readVisits = (pg) => + pg.evaluate(async () => { + const db = await new Promise((res, rej) => { + const q = indexedDB.open("history-graph"); + q.onsuccess = () => res(q.result); + q.onerror = () => rej(q.error); + }); + return await new Promise((res, rej) => { + const q = db.transaction("visits").objectStore("visits").getAll(); + q.onsuccess = () => res(q.result); + q.onerror = () => rej(q.error); + }); + }); + +try { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent("serviceworker", { timeout: 10_000 }); + const extId = new URL(sw.url()).host; + sw.on("console", (m) => { + const t = m.text(); + if (t.includes("history-graph")) console.log(" [SW]", t.split(" {")[0]); + }); + + const page = await context.newPage(); + await page.goto("https://en.wikipedia.org/wiki/Main_Page", { waitUntil: "load" }); + await sleep(1500); + await page.goto("https://en.wikipedia.org/wiki/Istanbul", { waitUntil: "load" }); + await sleep(1500); + await clickLink(page, "/wiki/Turkish_language"); + await page.waitForLoadState("load"); + await sleep(1500); + await clickLink(page, "/wiki/Turkey"); + await page.waitForLoadState("load"); + await sleep(1500); + + // Viewer reads the data + drives the checkout (as the panel would). + const viewer = await context.newPage(); + await viewer.goto(`chrome-extension://${extId}/viewer.html`, { waitUntil: "load" }); + const before = await readVisits(viewer); + const istanbul = before.find((v) => v.url.endsWith("/Istanbul")); + const webTab = await viewer.evaluate(async (u) => { + const tabs = await chrome.tabs.query({}); + return tabs.find((t) => t.url === u)?.id ?? null; + }, page.url()); + await viewer.evaluate( + (m) => chrome.runtime.sendMessage(m), + { type: "hg:checkout", tabId: webTab, id: istanbul.id, url: istanbul.url }, + ); + await sleep(300); + + // Navigate the web tab TO the checked-out commit (should be skipped). + await page.goto(istanbul.url, { waitUntil: "load" }); + await sleep(1500); + // Then onward to a new page (should branch from Istanbul). + await clickLink(page, "/wiki/Bosphorus"); + await page.waitForLoadState("load"); + await sleep(1800); + + const after = await readVisits(viewer); + const byId = new Map(after.map((v) => [v.id, v])); + const istanbulCount = after.filter((v) => v.url.endsWith("/Istanbul")).length; + const bosphorus = after.find((v) => v.url.includes("Bospor")); + const short = (u) => (u ? u.replace("https://en.wikipedia.org/wiki/", "") : "(root)"); + console.log(`\nIstanbul nodes: ${istanbulCount} (want 1 — no duplicate from checkout)`); + console.log(`Bosphorus parent: ${short(byId.get(bosphorus?.parentId)?.url)} (want Istanbul)`); +} finally { + await context.close(); +} diff --git a/scripts/layout-shot.mjs b/scripts/layout-shot.mjs index 1ccfb15..3384652 100644 --- a/scripts/layout-shot.mjs +++ b/scripts/layout-shot.mjs @@ -88,6 +88,17 @@ try { })); console.log(`wrote ${OUT}/5-forbidden.png`); console.log(`horizontal: scrollWidth=${ov.sw} clientWidth=${ov.cw} → ${ov.sw > ov.cw ? "OVERFLOW" : "ok"}`); + + // Click an OLDER commit (not the newest) and confirm the highlight moves to it. + await viewer.getByText("The Walt Disney Company").click({ modifiers: ["Meta"] }); + await sleep(300); + const selected = await viewer + .locator(".row.is-selected .label") + .first() + .innerText() + .catch(() => "(none)"); + await viewer.screenshot({ path: `${OUT}/7-selected.png` }); + console.log(`clicked an older commit → highlighted: "${selected}"`); } finally { await context.close(); } diff --git a/src/background/index.ts b/src/background/index.ts index ccd7e60..45570a9 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -64,15 +64,36 @@ const REDIRECT_WINDOW_MS = 1500; type NavSource = "commit" | "history"; // Tell open viewers (esp. the side panel) that the data changed, so they can -// refresh live. Debounced; the .catch swallows "no receiver" when nothing's open. +// refresh live. Debounced; the .catch swallows "no receiver" when nothing's +// open. `navigated` marks a real navigation (new commit / redirect) vs a mere +// title/favicon patch, so the viewer knows when to reset its selection. let notifyTimer: ReturnType | undefined; -function notifyChanged(): void { +let pendingNavigated = false; +function notifyChanged(navigated: boolean): void { + if (navigated) pendingNavigated = true; clearTimeout(notifyTimer); notifyTimer = setTimeout(() => { - chrome.runtime.sendMessage({ type: "hg:changed" }).catch(() => {}); + const nav = pendingNavigated; + pendingNavigated = false; + chrome.runtime.sendMessage({ type: "hg:changed", navigated: nav }).catch(() => {}); }, 250); } +// The viewer asks to "check out" a commit before navigating to it: set the +// tab's current pointer to that existing node so the ensuing same-URL +// navigation is skipped (no duplicate) and onward navigation branches from it. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === "hg:checkout" && typeof msg.tabId === "number") { + void setCurrentVisit(msg.tabId, { + id: msg.id, + url: msg.url, + committedAt: Date.now(), + }).then(() => sendResponse(true)); + return true; // respond asynchronously + } + return undefined; +}); + async function recordNavigation( details: chrome.webNavigation.WebNavigationTransitionCallbackDetails, via: NavSource, @@ -113,7 +134,7 @@ async function recordNavigation( await patchVisit(current.id, { url }); await setCurrentVisit(tabId, { id: current.id, url, committedAt: current.committedAt }); console.log(`[history-graph] redirect ⇒ ${url}`); - notifyChanged(); + notifyChanged(true); return; } @@ -139,7 +160,7 @@ async function recordNavigation( `${parentId ? "" : " (root)"} ← ${visit.url}`, visit, ); - notifyChanged(); + notifyChanged(true); } // Fill in the page title/favicon once the tab reports them. @@ -164,7 +185,7 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { const current = await getCurrentVisit(tabId); if (current && sameDocument(current.url, url)) { await patchVisit(current.id, patch); - notifyChanged(); + notifyChanged(false); return; } @@ -173,7 +194,7 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { const match = await getLatestVisitForTabUrl(tabId, url); if (match) { await patchVisit(match.id, patch); - notifyChanged(); + notifyChanged(false); return; } @@ -197,7 +218,7 @@ chrome.webNavigation.onCompleted.addListener(async (details) => { }; if (patch.title || patch.faviconUrl) { await patchVisit(visit.id, patch); - notifyChanged(); + notifyChanged(false); } }); diff --git a/src/viewer/App.tsx b/src/viewer/App.tsx index 216e62a..7ee18f0 100644 --- a/src/viewer/App.tsx +++ b/src/viewer/App.tsx @@ -23,6 +23,7 @@ export function App() { const [soloTab, setSoloTab] = useState(null); const [collapsed, setCollapsed] = useState>(new Set()); const [newestFirst, setNewestFirst] = useState(true); + const [selectedId, setSelectedId] = useState(null); const { forest, reload } = useForest(rangeDays, search); @@ -34,8 +35,12 @@ export function App() { // Live refresh: the capture worker pings us after every change. useEffect(() => { - const onMsg = (msg: { type?: string }) => { - if (msg?.type === "hg:changed") reload(); + const onMsg = (msg: { type?: string; navigated?: boolean }) => { + if (msg?.type !== "hg:changed") return; + reload(); + // A real navigation returns the highlight to the newest commit; a mere + // title/favicon update leaves a manual selection alone. + if (msg.navigated) setSelectedId(null); }; chrome.runtime.onMessage.addListener(onMsg); return () => chrome.runtime.onMessage.removeListener(onMsg); @@ -112,10 +117,14 @@ export function App() { }), soloTab: (t) => setSoloTab(t), clearSolo: () => setSoloTab(null), - openUrl: async (url) => { + openUrl: async (url, id) => { const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); - if (tab?.id != null) await chrome.tabs.update(tab.id, { url }); + if (tab?.id == null) return; + // "Check out" this commit so navigating to it doesn't create a duplicate. + await chrome.runtime.sendMessage({ type: "hg:checkout", tabId: tab.id, id, url }).catch(() => {}); + await chrome.tabs.update(tab.id, { url }); }, + select: (id) => setSelectedId(id), }), [], ); @@ -137,7 +146,7 @@ export function App() { reload(); } - const viewProps = { model, actions, tabLabel, soloed: soloTab != null, panel }; + const viewProps = { model, actions, tabLabel, soloed: soloTab != null, panel, selectedId }; return (
diff --git a/src/viewer/styles.css b/src/viewer/styles.css index bf94602..47b55c2 100644 --- a/src/viewer/styles.css +++ b/src/viewer/styles.css @@ -220,6 +220,10 @@ main { background: color-mix(in srgb, var(--fg) 6%, transparent); } +.row.is-selected .node-content { + background: color-mix(in srgb, var(--accent) 16%, transparent); +} + .favicon { flex: none; width: 15px; diff --git a/src/viewer/views/GraphCanvas.tsx b/src/viewer/views/GraphCanvas.tsx index 176e9b1..083e67d 100644 --- a/src/viewer/views/GraphCanvas.tsx +++ b/src/viewer/views/GraphCanvas.tsx @@ -43,10 +43,19 @@ export function GraphCanvas({ tabLabel, soloed, panel, + selectedId, }: ViewProps & { layout: GraphLayout }) { const gutterW = PAD_X * 2 + layout.cols * COL_W; const height = Math.max(layout.rows * ROW_H, ROW_H); - const headTs = Math.max(...layout.cells.map((c) => c.node.ts), 0); + + // Highlight the explicitly selected node; if none (or it's not in view), fall + // back to the newest visit. + const newestId = layout.cells.reduce( + (best, c) => (!best || c.node.ts > best.node.ts ? c : best), + undefined, + )?.id; + const selectedPresent = selectedId != null && layout.cells.some((c) => c.id === selectedId); + const highlightId = selectedPresent ? selectedId : newestId; return (
actions.toggleCollapse(c.id) : undefined} style={{ cursor: isFork ? "pointer" : "default", color: lane(c.col) }} > - {c.node.ts === headTs && } + {c.id === highlightId && } {isFork ? ( <> @@ -102,7 +111,11 @@ export function GraphCanvas({ .map((c) => { const ann = model.annotations.get(c.id)!; return ( -
+
{ - if (e.metaKey || e.ctrlKey || e.button !== 0) return; - e.preventDefault(); - actions.openUrl(node.url); - } - : undefined - } + onClick={(e) => { + actions.select(node.id); + // In the panel a plain click navigates the active tab; modified + // clicks (and the full-tab view) fall through to the anchor. + if (panel && e.button === 0 && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + actions.openUrl(node.url, node.id); + } + }} > {label} diff --git a/src/viewer/views/types.ts b/src/viewer/views/types.ts index 93a7b2d..c4286ba 100644 --- a/src/viewer/views/types.ts +++ b/src/viewer/views/types.ts @@ -4,8 +4,13 @@ export interface ViewActions { toggleCollapse(id: string): void; soloTab(tabId: number): void; clearSolo(): void; - /** Open a URL in the active tab (used by the panel instead of a new tab). */ - openUrl(url: string): void; + /** + * Navigate the active tab to an existing commit (panel). Checks the commit + * out first so it isn't duplicated; onward navigation branches from it. + */ + openUrl(url: string, id: string): void; + /** Mark a node as the highlighted/selected one. */ + select(id: string): void; } export interface ViewProps { @@ -17,4 +22,6 @@ export interface ViewProps { soloed: boolean; /** Running in the side panel: clicking a row navigates the active tab. */ panel: boolean; + /** Explicitly selected node; when null the newest visit is highlighted. */ + selectedId: string | null; }