diff --git a/docs/backfill-plan.md b/docs/backfill-plan.md new file mode 100644 index 0000000..a5d53ac --- /dev/null +++ b/docs/backfill-plan.md @@ -0,0 +1,92 @@ +# Plan: backfill a partial forest from `chrome.history` on install + +Status: **planned** (not yet built). Goal: a non-empty day one. On install/first +launch, reconstruct a *partial* browsing tree from the browser's existing history +so the graph isn't blank until the user navigates. + +## The key idea + +The browser keeps two different structures, and we learned the distinction the +hard way while auditing back/forward (see `src/background/navEntries.ts`): + +- the **linear per-tab session-history stack** (what the back/forward buttons + traverse), and +- the **referral relationship** between visits. + +For reconstructing a *tree*, the referral relationship is the right source. +`chrome.history.getVisits({ url })` returns `VisitItem`s with a +**`referringVisitId`** — that is a parent edge, almost exactly our `parentId`. +So backfill builds the forest from the referral DAG, **not** from chronological +or session-stack ordering. + +This is a separate module from live capture and from `navEntries` — it reuses the +*data model*, not the capture code. + +## What we get from `chrome.history` + +- `chrome.history.search({ text: "", startTime, maxResults })` → `HistoryItem[]` + (`url`, `title`, `lastVisitTime`, `visitCount`). +- `chrome.history.getVisits({ url })` → `VisitItem[]`: + `visitId`, `visitTime`, `referringVisitId`, and `transition` (base + `TransitionType` only). + +## What we DON'T get (the gaps that make it "partial") + +- **No `tabId`.** History has no tab concept. So cross-tab opener edges can't be + reconstructed and per-tab *solo* won't work for historical nodes. Decide: + leave `tabId` undefined (degrade solo for old data) or synthesize a sentinel. +- **No `transitionQualifiers`.** `chrome.history` exposes only the base + `TransitionType`, never `forward_back` / `client_redirect`. So none of the live + dedup heuristics (same-URL skip, redirect collapse, back/forward HEAD move, + the soft-redirect timing window) can run against backfilled data. Reloads, + redirects, and back/forward revisits will appear as distinct history visits. + Backfilled subtrees will look busier than live-captured ones — that's the + honest tradeoff of "partial". +- **No favicons.** `chrome.history` has none; would need a separate + `chrome.favicon` / `_favicon` lookup. Defer. + +## Mechanism + +1. **Permission.** Add `"history"` to `permissions` in `public/manifest.json` + (and the Firefox manifest). New privacy surface — disclose it in onboarding / + the AMO listing. +2. **Pull.** `history.search` for the window (e.g. last N days), then + `history.getVisits` per url to get the referral edges + per-visit timestamps. +3. **ID remap.** History `visitId`/`referringVisitId` are session-scoped integers, + not our uuids. Build `Map`, generate ids, then + resolve each `referringVisitId` through the map. A referrer of `0` or one + outside the pulled set → `parentId: null` (a branch root). This dovetails with + `buildForest`, which already surfaces a node whose parent is absent as a root. +4. **Roots fall out naturally.** History's `typed` / `auto_bookmark` / `generated` + visits have no referrer — the same set as live capture's `ROOT_TRANSITIONS`. +5. **Tag the source.** Add an optional `source?: "history" | "live"` field to + `Visit` (fits the flat, additive model — no migration). Lets us: + - skip backfill if it has already run (don't double-seed), + - let live capture take precedence where the two overlap, + - visually distinguish reconstructed nodes if we want to. +6. **Write + render.** Emit `Visit[]` into the same IndexedDB store via `putVisit`. + Everything downstream — `getForest`, layout, all three views — works unchanged. + This is the flat-model thesis paying off. + +## What this does NOT touch + +- `navEntries.ts` / the live back/forward fix — no session stack to mirror after + the fact, and no qualifiers to resolve against. Conceptually adjacent, zero + code overlap. +- Live capture (`background/index.ts`) — backfill runs once, off the + `chrome.runtime.onInstalled` event, independent of the live listeners. + +## Open questions / decisions before building + +- Default window: all-time vs. last 30/90 days? (Volume + perf.) +- `tabId` policy for tab-less historical nodes (undefined vs. sentinel). +- Cycle safety: `referringVisitId` chains shouldn't loop, but guard like + `findAncestorVisitByUrl` does, just in case. +- Re-run policy: strictly once on install, or an explicit "import history" button? + +## Suggested shape + +`src/background/backfill.ts` (pure-ish: take an injected history reader → +return `Visit[]`, so the remap/forest logic unit-tests in plain node like +`navEntries`), invoked from a thin `chrome.runtime.onInstalled` handler in +`background/index.ts`. diff --git a/docs/tree-cursor-plan.md b/docs/tree-cursor-plan.md new file mode 100644 index 0000000..a24b92c --- /dev/null +++ b/docs/tree-cursor-plan.md @@ -0,0 +1,104 @@ +# Plan: tree-aware Back/Forward (the "lineage cursor") + +Status: **planned** (not yet built). Goal: navigation that walks the *apparent +lineage shown in the panel* — Back goes to a node's tree parent, Forward returns +down the branch — instead of the browser's linear session history. + +## Why we own a cursor instead of commandeering the physical buttons + +We audited this (see `src/background/navEntries.ts`). The browser's Back/Forward +buttons traverse Chrome's **linear per-tab session-history stack**, and +**extensions cannot edit that stack** — the only levers are `tabs.update` (append +one entry, truncating forward), `tabs.goBack/goForward` (±1), and same-origin +`history.pushState`. There is no "set the history list to this sequence" API. + +So the two ways to make the *physical* buttons walk the tree are both unviable as +a default: + +- **Replay the lineage as real navigations** (navigate root→…→node so the stack + mirrors the path). Works cross-origin, but **loads every intermediate page** — + re-firing side effects, unable to replay POST/form navigations, slow, flickery, + and silently diverging when a page now redirects/404s/needs auth. It also forces + us to suppress our own capture during replay. Fragile. +- **`pushState` synthesis** — cheap but same-origin only, and the pushed entries + don't render on Back unless the site handles `popstate`. Broken for real sites. + +Conclusion: stop fighting the platform. Keep `navEntries` as the faithful mirror +for when the user presses Chrome's buttons, and add a **tree cursor** that we +fully control as the panel's primary, WYSIWYG navigation. + +## The model + +A per-tab **lineage cursor** = a path through the tree plus a position: + +``` +{ path: string[]; // visit ids, root → … → current node + index: number; } // where we are along that path +``` + +- **Check out node N** → `path = ancestors(N) ++ [N]` (walk `parentId` up from N, + reverse), `index = path.length - 1`. +- **Back** → `index - 1` (the tree parent). +- **Forward** → `index + 1` (the child we descended through — this is *why* we + store the whole path, not just the current node). +- **Retarget to a node M on another branch** → diff `path` against M's lineage, + keep the longest common prefix (the **merge base**), replace the suffix with + M's. This is the "walk to the merge base, then append the lineage" idea — free + and reliable when applied to our own cursor rather than the browser's stack. + +Structurally this is a sibling of `navEntries`: a small, pure, dependency-free +reducer that unit-tests in plain node. + +## Two sub-modes (decide which, or offer both) + +1. **Highlight-only** — Back/Forward move the *selected/highlighted* node up and + down the lineage without navigating the tab. Zero side effects; good for + inspecting a branch. Cheap. +2. **Navigating** — each Back/Forward also issues **one** `tabs.update` to that + ancestor/descendant URL (the cost of a single link click — fine for a + user-initiated step; this is NOT the multi-load replay above). HEAD follows, + the page content follows, and the highlight walks the lineage you see. + +## Implementation steps + +1. **`src/background/treeCursor.ts`** (pure): `LineageCursor` type + + `checkout(node, index)`, `back(cursor)`, `forward(cursor)`, + `retarget(currentPath, targetLineage)` (merge-base prefix + new suffix). Needs + only `parentId` walking — accept an `ancestors(id) => string[]` lookup so the + module stays storage-free (same injection trick as `applyBackForward`). +2. **`tests/treeCursor.test.ts`** — straight-line back/forward, forward after + back, retarget across a fork (assert the merge base is the common ancestor), + single-node and root edge cases. Wire into the `test` script. +3. **Persist** the cursor per tab in `chrome.storage.session` (alongside the + `navStack`); clear it in `clearTab`. +4. **Actions** in `src/viewer/views/types.ts`: `lineageBack()` / `lineageForward()` + that resolve the next node from the cursor, reuse the existing `openUrl` + checkout path to navigate (navigating sub-mode) or just `select` it + (highlight-only sub-mode), and advance the persisted cursor. +5. **Panel UI** in `App.tsx`: ▲ parent / ▼ child buttons (disabled at the ends of + the lineage), optionally bound to keyboard shortcuts. The highlight already + follows HEAD, so it will track the cursor for free. + +## Interaction with what exists + +- **`navEntries` is untouched.** It remains the faithful mirror of the physical + Chrome buttons. The tree cursor is a *separate, additional* control; the two + coexist (physical buttons = linear/honest, panel buttons = tree-aware). +- **Checkout already exists** (`hg:checkout` + `openUrl`); the cursor just wraps + it with lineage bookkeeping, so steps 4–5 are mostly glue. +- **Documented limitation:** we cannot make Chrome's physical Back/Forward walk + the tree. The panel's buttons are the tree-aware path; the physical buttons stay + linear. Make that legible in the UI so the two don't feel inconsistent. + +## Open questions / decisions before building + +- Highlight-only, navigating, or both (e.g. a modifier key to inspect vs. go)? +- After a **physical** Back/Forward (a `navEntries` move), how do we reconcile the + tree cursor — rebuild it from the new HEAD's lineage, or leave it stale until + the next checkout? (Rebuilding from HEAD each time is simplest and keeps the two + consistent.) +- Cross-tab lineage: `openUrl` navigates the *active* tab, and lineages can span + tabs (new-tab opens). Decide whether lineage steps that cross a tab boundary + switch tabs (`activateTab`) or are disallowed. +- Keybindings: do we want Alt+↑/↓ (or similar) for lineage moves, and is that + worth the conflict surface? diff --git a/package.json b/package.json index 7b98009..531b756 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "submit:firefox": "npm run package && web-ext submit --source-dir=dist-firefox --channel=listed", "dev": "vite build --watch --mode development", "typecheck": "tsc --noEmit", - "test": "tsx tests/logic.test.ts && tsx tests/graph.test.ts", + "test": "tsx tests/logic.test.ts && tsx tests/graph.test.ts && tsx tests/navEntries.test.ts", "test:e2e": "npm run build && node scripts/capture-check.mjs", "shots": "npm run build && node scripts/viewer-shot.mjs" }, diff --git a/scripts/capture-check.mjs b/scripts/capture-check.mjs index bca47bb..fb20c9b 100644 --- a/scripts/capture-check.mjs +++ b/scripts/capture-check.mjs @@ -75,6 +75,24 @@ try { await spa.evaluate(() => history.pushState({}, "", "/wiki/Istanbul")); await sleep(1800); + // 6. Repeated-URL back/forward (the cursor-resolution fix). Visit A, B, then A + // AGAIN as a distinct node, so two nodes share A's url. Going Back twice must + // land HEAD on the FIRST A node (the entry the browser is on), not the most + // recent A node — which the old "latest visit for this url" rule got wrong. + const bf = await context.newPage(); + await bf.goto("https://example.com/", { waitUntil: "load" }); // a1 + await sleep(1200); + await bf.goto("https://example.net/", { waitUntil: "load" }); // b + await sleep(1200); + await bf.goto("https://example.com/", { waitUntil: "load" }); // a2 (same url as a1) + await sleep(1200); + await bf.goBack(); // → example.net + await bf.waitForLoadState("load"); + await sleep(1200); + await bf.goBack(); // → example.com; must resolve a1 (cursor), not a2 + await bf.waitForLoadState("load"); + await sleep(1200); + // Read the extension's IndexedDB from a page on the extension origin. const viewer = await context.newPage(); await viewer.goto(`chrome-extension://${extId}/viewer.html`, { waitUntil: "load" }); @@ -144,6 +162,20 @@ try { report(istanbul.length === 1, `in-app back didn't fork (Istanbul nodes: ${istanbul.length})`); const headIstanbulOk = heads.some((h) => h.url === "https://en.wikipedia.org/wiki/Istanbul"); report(headIstanbulOk, "HEAD followed in-app back to Istanbul"); + + // Improvement 3 (cursor resolution): two distinct nodes share example.com's + // url; after Back-Back, the example.com HEAD must point at the EARLIER node + // (the entry the browser actually returned to), not the most recent one. + const exampleNodes = visits + .filter((v) => v.url === "https://example.com/") + .sort((a, b) => a.ts - b.ts); + report(exampleNodes.length === 2, `repeated url made two nodes (example.com nodes: ${exampleNodes.length})`); + const exampleHead = heads.find((h) => h.url === "https://example.com/"); + const resolvedFirst = exampleHead && exampleNodes.length === 2 && exampleHead.id === exampleNodes[0].id; + report(resolvedFirst, "back/forward resolved the correct repeated-url node by cursor (not latest ts)"); + if (exampleHead && exampleNodes.length === 2 && !resolvedFirst) { + console.log(` HEAD landed on ${exampleHead.id}; expected ${exampleNodes[0].id} (got the latest node — the old bug)`); + } } finally { await context.close(); } diff --git a/src/background/index.ts b/src/background/index.ts index ce8be4c..399b4f2 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,13 +1,16 @@ import { buildVisit, determineParentId } from "./capture"; -import { putVisit, patchVisit, getLatestVisitForTabUrl, findAncestorVisitByUrl } from "../storage/db"; +import { putVisit, patchVisit, getVisit, getLatestVisitForTabUrl, findAncestorVisitByUrl } from "../storage/db"; import { setCurrentVisit, setPendingOpener, getCurrentVisit, setPendingTitle, takePendingTitle, + getNavStack, + setNavStack, clearTab, } from "../storage/sessionMap"; +import { pushNavigation, applyBackForward } from "./navEntries"; import type { Visit } from "../model/types"; import { sameDocument } from "../model/url"; @@ -84,11 +87,14 @@ function notifyChanged(navigated: boolean): void { // 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)); + void (async () => { + await setCurrentVisit(msg.tabId, { id: msg.id, url: msg.url, committedAt: Date.now() }); + // The viewer is about to chrome.tabs.update() to this url — a NEW browser + // navigation. The ensuing commit is same-url-skipped (no node created), so + // record the history entry here instead, pointing at the existing node. + await setNavStack(msg.tabId, pushNavigation(await getNavStack(msg.tabId), msg.id)); + sendResponse(true); + })(); return true; // respond asynchronously } return undefined; @@ -102,18 +108,46 @@ async function recordNavigation( if (isIgnorable(url)) return; const qualifiers = details.transitionQualifiers ?? []; - // Back/forward: don't fork a new node. Move the "current" pointer back onto - // the existing node for this URL — like git's HEAD moving to a prior commit. + // Back/forward: don't fork a new node. Move HEAD onto the existing node the + // browser actually navigated to — like git's HEAD moving to a prior commit. + // + // Resolve by the tab's mirrored session-history CURSOR, not by "latest visit + // for this URL": when a URL repeats (loops, revisits, two branches through the + // same page) the latter lands HEAD on the wrong node. See navEntries.ts. if (qualifiers.includes("forward_back")) { + const stack = await getNavStack(tabId); + // applyBackForward needs each entry's url; prefetch (session history is short). + const urlById = new Map(); + for (const id of stack.entries) { + const v = await getVisit(id); + if (v) urlById.set(id, v.url); + } + const moved = applyBackForward(stack, url, (id, u) => { + const vurl = urlById.get(id); + return vurl != null && sameDocument(vurl, u); + }); + if (moved) { + await setNavStack(tabId, moved.stack); + await setCurrentVisit(tabId, { + id: moved.resolvedId, + url: urlById.get(moved.resolvedId) ?? url, + committedAt: details.timeStamp, + }); + console.log(`[history-graph] back/forward → ${url} (entry ${moved.stack.cursor})`); + notifyChanged(true); + return; + } + // The stack doesn't know this entry (history predates our capture, or the + // browser was restarted so storage.session was cleared). Fall back to the + // best-effort URL match before giving up entirely. const existing = await getLatestVisitForTabUrl(tabId, url); if (existing) { await setCurrentVisit(tabId, { id: existing.id, url: existing.url, committedAt: existing.ts }); - console.log(`[history-graph] back/forward → ${url}`); - // Move HEAD without forking; tell the viewer so its highlight follows. + console.log(`[history-graph] back/forward (fallback) → ${url}`); notifyChanged(true); return; } - // No node for this URL (history predates our capture) — fall through. + // No node for this URL at all — fall through and record it. } const current = await getCurrentVisit(tabId); @@ -149,6 +183,9 @@ async function recordNavigation( const ancestor = await findAncestorVisitByUrl(current.id, url); if (ancestor) { await setCurrentVisit(tabId, { id: ancestor.id, url: ancestor.url, committedAt: ancestor.ts }); + // pushState adds a real browser history entry (pointing at an existing + // node), so the session stack grows too — keep it in sync with the browser. + await setNavStack(tabId, pushNavigation(await getNavStack(tabId), ancestor.id)); console.log(`[history-graph] in-app back ↩ ${url}`); notifyChanged(true); return; @@ -170,6 +207,9 @@ async function recordNavigation( url: visit.url, committedAt: details.timeStamp, }); + // A fresh navigation: the browser truncates forward entries and appends, so + // mirror that in the tab's session stack. + await setNavStack(details.tabId, pushNavigation(await getNavStack(details.tabId), visit.id)); // Milestone 2 visibility: watch these in the service worker console. console.log( diff --git a/src/background/navEntries.ts b/src/background/navEntries.ts new file mode 100644 index 0000000..843514e --- /dev/null +++ b/src/background/navEntries.ts @@ -0,0 +1,83 @@ +/** + * A per-tab model of the BROWSER'S native session-history stack — the list the + * back/forward buttons actually traverse. + * + * This is deliberately separate from our browsing *tree*. The browser keeps a + * single LINEAR path per tab and DISCARDS the forward branch the moment you + * navigate somewhere new after going back. Our tree keeps every branch forever. + * That mismatch is the whole reason back/forward doesn't "walk the tree" — the + * browser has thrown the other branch out of its own stack. + * + * Why model it here: a chrome.webNavigation `forward_back` event only tells us + * the destination URL, not which history entry it is. Resolving that by "the + * latest visit for this URL" lands HEAD on the wrong node whenever a URL repeats + * (loops, revisits, two branches through the same page). Tracking the cursor + * lets us resolve a forward_back to the EXACT visit the browser moved to. + * + * Pure and dependency-free on purpose, so it unit-tests in plain node and can be + * persisted (per tab) in chrome.storage.session by the capture layer. + */ + +export interface NavStack { + /** Visit ids in browser history order (index 0 = oldest). */ + entries: string[]; + /** Index of the current entry (the page now showing); -1 when empty. */ + cursor: number; +} + +export function emptyStack(): NavStack { + return { entries: [], cursor: -1 }; +} + +/** The current visit id, or undefined when the stack is empty. */ +export function currentId(s: NavStack): string | undefined { + return s.cursor >= 0 ? s.entries[s.cursor] : undefined; +} + +/** + * A brand-new navigation committed to `visitId`: a fresh node, a typed URL, a + * clicked link — or a "checkout" that re-navigates to an existing node's URL + * (in which case `visitId` is that existing node's id, since no new node is + * created). The browser truncates any forward entries and appends; so do we. + * + * Consequence worth noting: after going back and then navigating, the forward + * branch is gone from this stack — exactly mirroring the browser, and exactly + * why the discarded tree branch is no longer reachable by the Forward button. + */ +export function pushNavigation(s: NavStack, visitId: string): NavStack { + const entries = s.entries.slice(0, s.cursor + 1); + entries.push(visitId); + return { entries, cursor: entries.length - 1 }; +} + +/** + * The browser went back/forward and committed `url`. Move the cursor to the + * nearest neighbouring entry whose visit is the same document as `url`, + * searching outward in BOTH directions because a long-press can jump several + * entries at once. `matches(visitId, url)` reports whether the visit stored at + * an entry is the same document as the committed url (inject sameDocument + a + * visit lookup here, keeping this module storage-free). + * + * At equal distance we prefer the Back direction — the common case, and the + * only sensible tie-break when the same URL flanks the cursor on both sides. + * + * Returns the updated stack and the resolved visit id, or null when no entry + * matches (the destination predates our capture, e.g. history from before the + * extension was installed) — the caller then falls back to its old behavior. + */ +export function applyBackForward( + s: NavStack, + url: string, + matches: (visitId: string, url: string) => boolean, +): { stack: NavStack; resolvedId: string } | null { + for (let dist = 1; dist < s.entries.length; dist++) { + for (const dir of [-1, 1] as const) { + const i = s.cursor + dir * dist; + if (i < 0 || i >= s.entries.length) continue; + if (matches(s.entries[i], url)) { + return { stack: { entries: s.entries, cursor: i }, resolvedId: s.entries[i] }; + } + } + } + return null; +} diff --git a/src/storage/sessionMap.ts b/src/storage/sessionMap.ts index a989b42..535295e 100644 --- a/src/storage/sessionMap.ts +++ b/src/storage/sessionMap.ts @@ -19,12 +19,16 @@ */ import { stripHash } from "../model/url"; +import { emptyStack, type NavStack } from "../background/navEntries"; /** storage.session key for the per-tab HEAD pointer; exported so the viewer can * watch it via chrome.storage.onChanged and follow the active tab live. */ export const CURRENT_KEY = "currentVisitByTab"; const OPENER_KEY = "pendingOpenerByTab"; const TITLE_KEY = "pendingTitleByTab"; +// Mirror of each tab's native browser session-history stack (see navEntries), +// so a forward_back event resolves to the exact entry by cursor, not a URL guess. +const NAVSTACK_KEY = "navStackByTab"; export interface CurrentVisit { id: string; @@ -65,6 +69,20 @@ export async function clearTab(tabId: number): Promise { const titles = await read(TITLE_KEY); delete titles[tabId]; await write(TITLE_KEY, titles); + const stacks = await read(NAVSTACK_KEY); + delete stacks[tabId]; + await write(NAVSTACK_KEY, stacks); +} + +/** The tab's mirrored browser session-history stack (empty if unseen). */ +export async function getNavStack(tabId: number): Promise { + return (await read(NAVSTACK_KEY))[tabId] ?? emptyStack(); +} + +export async function setNavStack(tabId: number, stack: NavStack): Promise { + const map = await read(NAVSTACK_KEY); + map[tabId] = stack; + await write(NAVSTACK_KEY, map); } /** Record that `newTabId` was opened from `sourceVisitId`. */ diff --git a/tests/navEntries.test.ts b/tests/navEntries.test.ts new file mode 100644 index 0000000..c5ad65c --- /dev/null +++ b/tests/navEntries.test.ts @@ -0,0 +1,181 @@ +import { strict as assert } from "node:assert"; +import { + emptyStack, + currentId, + pushNavigation, + applyBackForward, + type NavStack, +} from "../src/background/navEntries.ts"; +import { sameDocument } from "../src/model/url.ts"; + +let passed = 0; +function check(name: string, fn: () => void) { + fn(); + passed++; + console.log(` ✓ ${name}`); +} + +// --- Test fixtures --------------------------------------------------------- +// A tiny visit table: visit id → url. Stands in for IndexedDB so we can inject +// a `matches` that behaves like the real one (getLatestVisitForTabUrl + sameDocument). +type Table = Record; +const matcher = (table: Table) => (visitId: string, url: string) => + sameDocument(table[visitId] ?? "", url); + +// The CURRENT production rule, replicated for comparison: forward_back resolves +// to the most-recently-created visit whose URL matches (getLatestVisitForTabUrl, +// db.ts). `order` is creation order (proxy for ts). This is what we're replacing. +function latestTsRule(table: Table, order: string[], url: string): string | undefined { + for (let i = order.length - 1; i >= 0; i--) { + if (sameDocument(table[order[i]], url)) return order[i]; + } + return undefined; +} + +// Drive a back/forward and assert it lands on the expected entry. +function back(s: NavStack, table: Table, url: string) { + const r = applyBackForward(s, url, matcher(table)); + assert.ok(r, `expected a matching history entry for ${url}`); + return r!; +} + +console.log("navEntries — linear back/forward:"); +check("cursor walks back then forward along the stack", () => { + const table: Table = { h: "/home", a: "/a", b: "/b" }; + let s = emptyStack(); + s = pushNavigation(s, "h"); + s = pushNavigation(s, "a"); + s = pushNavigation(s, "b"); + assert.equal(currentId(s), "b"); + + ({ stack: s } = back(s, table, "/a")); + assert.equal(currentId(s), "a"); + ({ stack: s } = back(s, table, "/home")); + assert.equal(currentId(s), "h"); + // ...and forward again + ({ stack: s } = back(s, table, "/a")); + assert.equal(currentId(s), "a"); +}); + +console.log("\nnavEntries — repeated URL (the wrong-node bug):"); +check("back to a repeated URL resolves the OLDER entry by cursor, not latest ts", () => { + // Visit /x, then /y, then /x AGAIN as a fresh node. Two distinct nodes (x1,x2) + // share the url /x. Browser order: [x1, y, x2], now at x2. + const table: Table = { x1: "/x", y: "/y", x2: "/x" }; + const order = ["x1", "y", "x2"]; + let s = emptyStack(); + s = pushNavigation(s, "x1"); + s = pushNavigation(s, "y"); + s = pushNavigation(s, "x2"); + + // Back → /y → y, Back → /x → must be x1 (the entry the browser is on), + // NOT x2 (the most recent /x node). + ({ stack: s } = back(s, table, "/y")); + assert.equal(currentId(s), "y"); + ({ stack: s } = back(s, table, "/x")); + assert.equal(currentId(s), "x1"); + + // Demonstrate the divergence: the old latest-ts rule would have picked x2 — + // HEAD would highlight the wrong node while the browser sits on x1. + assert.equal(latestTsRule(table, order, "/x"), "x2"); + assert.notEqual(currentId(s), latestTsRule(table, order, "/x")); +}); + +check("forward to a repeated URL lands on the nearer forward entry", () => { + // [x1=/x, y=/y, x2=/x], cursor walked all the way back to x1. Going forward to + // /x has only one candidate ahead (no /x behind the cursor), so it's resolved + // unambiguously to x1's forward neighbour y, then x2. + const table: Table = { x1: "/x", y: "/y", x2: "/x" }; + let s = emptyStack(); + s = pushNavigation(s, "x1"); + s = pushNavigation(s, "y"); + s = pushNavigation(s, "x2"); + ({ stack: s } = back(s, table, "/y")); // back to y + ({ stack: s } = back(s, table, "/x")); // back to x1 (cursor 0) + ({ stack: s } = back(s, table, "/y")); // forward to y + assert.equal(currentId(s), "y"); +}); + +check("LIMITATION: the same URL flanking the cursor is undecidable; we pick Back", () => { + // At y with /x at BOTH neighbours (x1 behind, x2 ahead), a forward_back to /x + // is ambiguous — webNavigation reports the destination url but not the + // direction. We resolve to the Back side by convention. Documented so a future + // direction signal (if Chrome ever exposes one) has a target to improve on. + const table: Table = { x1: "/x", y: "/y", x2: "/x" }; + let s = emptyStack(); + s = pushNavigation(s, "x1"); + s = pushNavigation(s, "y"); + s = pushNavigation(s, "x2"); + ({ stack: s } = back(s, table, "/y")); // back to y (cursor 1, /x on both sides) + const r = back(s, table, "/x"); + assert.equal(r.resolvedId, "x1"); // Back wins the tie +}); + +console.log("\nnavEntries — forward branch is discarded (why the tree branch is unreachable):"); +check("navigating after going back truncates the forward entry", () => { + const table: Table = { h: "/home", a: "/a", b: "/b" }; + let s = emptyStack(); + s = pushNavigation(s, "h"); + s = pushNavigation(s, "a"); // [h, a] + ({ stack: s } = back(s, table, "/home")); // back to h + s = pushNavigation(s, "b"); // new nav → 'a' is dropped from the browser stack + assert.deepEqual(s.entries, ["h", "b"]); + assert.equal(currentId(s), "b"); + // Forward now finds nothing — 'a' lives in our tree but not the browser stack. + assert.equal(applyBackForward(s, "/a", matcher(table)), null); +}); + +console.log("\nnavEntries — checkout (clicking an old node):"); +check("checkout is a new navigation; Back returns to the prior page, not the tree parent", () => { + // Browsing: home → a → b (at b). Then the user clicks the OLD node 'home' in + // the viewer; openUrl re-navigates the tab, which is a fresh browser nav that + // references the existing node id 'home'. + const table: Table = { h: "/home", a: "/a", b: "/b" }; + let s = emptyStack(); + s = pushNavigation(s, "h"); + s = pushNavigation(s, "a"); + s = pushNavigation(s, "b"); // [h, a, b] at b + s = pushNavigation(s, "h"); // checkout to home → [h, a, b, h] at h(entry3) + assert.equal(currentId(s), "h"); + + // Press Back: the browser goes to the PREVIOUS entry (b), faithfully — it does + // NOT walk up the tree to home's parent (home has none). This is the behavior + // we can actually deliver; the highlight now matches the real page. + ({ stack: s } = back(s, table, "/b")); + assert.equal(currentId(s), "b"); +}); + +console.log("\nnavEntries — reloads & redirects don't move the cursor:"); +check("a reload (same url, not pushed) leaves the cursor put", () => { + // recordNavigation skips same-url commits, so the stack simply isn't pushed. + const table: Table = { h: "/home", a: "/a" }; + let s = emptyStack(); + s = pushNavigation(s, "h"); + s = pushNavigation(s, "a"); + const before = currentId(s); + // (no push on reload) + assert.equal(currentId(s), before); + assert.equal(before, "a"); +}); + +check("a client redirect updates the entry's url in place; back still resolves it", () => { + // The visit row's url is patched (Juneau → Juneau,_Alaska); same entry/cursor. + const table: Table = { h: "/home", j: "/wiki/Juneau" }; + let s = emptyStack(); + s = pushNavigation(s, "h"); + s = pushNavigation(s, "j"); + table.j = "/wiki/Juneau,_Alaska"; // redirect patched the node in place + ({ stack: s } = back(s, table, "/home")); + ({ stack: s } = back(s, table, "/wiki/Juneau,_Alaska")); + assert.equal(currentId(s), "j"); +}); + +console.log("\nnavEntries — pre-capture history:"); +check("forward_back to an unknown url returns null (caller keeps its fallback)", () => { + const table: Table = { h: "/home" }; + let s = emptyStack(); + s = pushNavigation(s, "h"); + assert.equal(applyBackForward(s, "/never-seen", matcher(table)), null); +}); + +console.log(`\n${passed} checks passed.`);