From 1fbb8afb807b5806b2f98fdf6552044ece09b6ea Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Thu, 18 Jun 2026 17:43:15 -0700 Subject: [PATCH] Implement restoring closed tabs --- WIP_TODO | 5 +- docs/restore-closed-tabs-impl.md | 360 +++++++++++++++++++++++++++++++ package.json | 1 + scripts/reopen-probe.mjs | 127 +++++++++++ src/background/capture.ts | 5 +- src/background/index.ts | 38 +++- src/model/types.ts | 8 + src/storage/sessionMap.ts | 54 +++++ src/viewer/App.tsx | 92 +++++--- src/viewer/graph/viewModel.ts | 28 ++- src/viewer/styles.css | 29 +++ src/viewer/views/NodeContent.tsx | 33 ++- src/viewer/views/types.ts | 7 + tests/graph.test.ts | 23 +- 14 files changed, 763 insertions(+), 47 deletions(-) create mode 100644 docs/restore-closed-tabs-impl.md create mode 100644 scripts/reopen-probe.mjs diff --git a/WIP_TODO b/WIP_TODO index 297a5a7..a325eb2 100644 --- a/WIP_TODO +++ b/WIP_TODO @@ -1,5 +1,8 @@ WIP_TODO +- restore whole tabs +- pause recording +- prune +- harden heuristics for figuring out what counts as an entry in the history graph - license and privacy policy - list in extensions stores -- restore whole tabs - tree cursor back/forward diff --git a/docs/restore-closed-tabs-impl.md b/docs/restore-closed-tabs-impl.md new file mode 100644 index 0000000..8307356 --- /dev/null +++ b/docs/restore-closed-tabs-impl.md @@ -0,0 +1,360 @@ +# Implementation plan: restore closed tabs (re-attach to the tab's tree) + +Concrete, file-level build plan derived from `docs/restore-closed-tabs-plan.md` +and the current code. Follows that doc's build order: + +1. **Phase 1 — Manual reopen-from-node** (Trigger A): deterministic, small, + reuses checkout. *Ship this first.* +2. **Phase 2 — `tabKey` identity**: reopened tabs group correctly in solo/labels. +3. **Phase 3 — Auto-detect restore** (Trigger B, `sessions`): convenience layer, + manual path stays as the override. + +The unifying mechanism (from the plan): **parenting flows through the tab's +`current` pointer.** `determineParentId` returns `current.id` as the parent for a +follow-on navigation, and `current?.url === url` skips a reload to the current +node. So re-attaching a new tab is just "seed `current` for the new tabId, then +let the existing capture pipeline take over." This is exactly what the +`hg:checkout` handler already does (`background/index.ts:88-101`). + +--- + +## Phase 1 — Manual reopen-from-node — ✅ implemented + +Probe: `npm run test:reopen` (`scripts/reopen-probe.mjs`) builds a chain, closes +the tab, reopens a leaf via `hg:attach`, navigates onward, and asserts the new +visit continues the subtree with no duplicate node. + +### Goal +A node in the viewer gets a "Reopen here" affordance. Clicking it opens a new tab +at that node's URL and seeds the new tab's `current` pointer to that node, so the +*next* navigation in the new tab becomes a child of it — continuing the subtree +instead of starting a new root. + +### Why a new message instead of reusing `hg:checkout` +`hg:checkout` assumes the tab **already exists** and the caller `chrome.tabs.update`s +it to the URL (a navigation that gets same-URL-skipped). For reopen we +`chrome.tabs.create({ url })` — the create's *own* first commit is what needs to be +absorbed. The new tab has no `current` yet, so we cannot pre-seed before create +and rely on the same-URL skip alone, because of a race: the create's `onCommitted` +may fire before our message handler runs. We need a **pending-attach** slot that +the first commit consumes, mirroring the existing `pendingOpener` pattern. + +### 1.1 `storage/sessionMap.ts` — add a pending-attach slot +Mirror `pendingOpener` exactly (single-use, keyed by tabId): + +```ts +const ATTACH_KEY = "pendingAttachByTab"; + +export interface PendingAttach { + id: string; // visit id to seed as current + url: string; // its url (for the same-url skip + current pointer) + tabKey?: string; // Phase 2: carry the logical tab id across the reopen +} + +export async function setPendingAttach(tabId: number, attach: PendingAttach): Promise; +export async function takePendingAttach(tabId: number): Promise; +``` + +Also extend `clearTab` to delete the `ATTACH_KEY` entry (consistency with the +other per-tab maps), so a never-consumed attach doesn't leak if the tab is closed +before committing. + +### 1.2 `background/index.ts` — `hg:attach` message + consume on first commit + +**New message handler** (next to the `hg:checkout` one). It does *not* navigate +(the viewer's `chrome.tabs.create` already did); it only records intent: + +```ts +if (msg?.type === "hg:attach" && typeof msg.tabId === "number") { + void (async () => { + await setPendingAttach(msg.tabId, { id: msg.id, url: msg.url, tabKey: msg.tabKey }); + sendResponse(true); + })(); + return true; +} +``` + +**Consume at the top of `recordNavigation`**, before the normal parent logic. +Place it *after* the `isIgnorable`/`forward_back` guards but *before* the +`current = await getCurrentVisit(tabId)` read, so the seeded pointer is what the +same-URL skip and `determineParentId` see: + +```ts +// A reopened/closed-tab restore: the viewer asked us to continue an existing +// subtree in this freshly created tab. Seed the current pointer to the target +// node so this very commit (same url) is skipped and the NEXT navigation +// parents under it — same mechanism as hg:checkout, but for a brand-new tab. +const attach = await takePendingAttach(tabId); +if (attach) { + await setCurrentVisit(tabId, { id: attach.id, url: attach.url, committedAt: details.timeStamp }); + await setNavStack(tabId, pushNavigation(emptyStack(), attach.id)); + // fall through: the same-url skip below collapses this commit onto attach.id. +} +``` + +Notes: +- After seeding, control falls through to the existing `current?.url === url` + check (line 158), which skips the create's commit when the committed URL equals + the node's URL. If the restored page client-redirects, the redirect-collapse + path (line 169) handles it as usual. +- Seeding a one-entry nav-stack gives back/forward a sane starting point. (A + fuller rebuild from the archived chain is Phase 3.) +- `emptyStack` import already lives in `navEntries` — add it to the import in + `index.ts` (currently only `pushNavigation, applyBackForward` are imported). + +### 1.3 Viewer — `reopenFrom` action +`views/types.ts`, add to `ViewActions`: + +```ts +/** Reopen this node's URL in a NEW tab and continue its subtree there. */ +reopenFrom(url: string, id: string): void; +``` + +`App.tsx`, implement in the `actions` memo (alongside `openUrl`): + +```ts +reopenFrom: async (url, id) => { + const tab = await chrome.tabs.create({}); // BLANK (new-tab page is ignored) + if (tab.id == null) return; + await chrome.runtime.sendMessage({ type: "hg:attach", tabId: tab.id, id, url }).catch(() => {}); + await chrome.tabs.update(tab.id, { url }); // navigate AFTER attach is registered +}, +``` + +**Race resolution (decided during implementation):** creating the tab directly +with `{ url }` lets the new tab's first commit fire *before* the `hg:attach` +message lands, forking a duplicate root (confirmed via `reopen-probe.mjs`: +"visit link (root) ← Turkey" before "attach →"). The deterministic fix mirrors +`hg:checkout`: create the tab **blank** (the new-tab page is `isIgnorable`, so it +never consumes the attach), register the attach, *then* `tabs.update` to the url. +The url's commit now always sees the seeded `current` and is same-url-skipped. No +background round-trip needed. + +### 1.4 Viewer — reopen as the default row action + +**Final UX (revised from the original "Reopen button" sketch):** reopen-and- +continue is the **default click** on any row *in the full viewer tab* — not a +button gated to closed-tab nodes. The trade-off is that the full tab can no +longer plain-click a row to open it disconnected, so a **hover-revealed `.open-fresh` +button** (`↗`, full viewer tab only) provides that escape hatch (a plain new tab = +new root, via an ``). The panel is unchanged: a plain click +still navigates the active tab (`openUrl`/checkout). + +`NodeContent.tsx` label click: + +```tsx +onClick={(e) => { + actions.select(node.id); + // Plain left click acts on the row; modified/middle clicks fall through to the + // anchor (a plain, disconnected new tab). + if (e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + e.preventDefault(); + if (panel) actions.openUrl(node.url, node.id); // panel: navigate active tab + else actions.reopenFrom(node.url, node.id, tabGroupOf(node)); // full tab: reopen & continue + } +}} +``` + +Hover button (rendered only when `!panel`): + +```tsx + e.stopPropagation()}>↗ +``` + +`.open-fresh` is `opacity: 0` by default, revealed by `.row:hover .open-fresh` / +`:focus-visible`. No closed-tab detection (`isTabLive`/`liveTabs`) is needed — +that plumbing was removed. + +### 1.5 Tests (Phase 1) +- **e2e (`scripts/capture-check.mjs` / `npm run test:e2e`)** is the real proof, + since this is capture-side. Add a scenario: capture a small tree in tab A, + close tab A (`chrome.tabs.remove`), drive the viewer/extension to send + `hg:attach` for a leaf node into a newly created tab, navigate that tab once, + and assert the new visit's `parentId === leaf.id` (continuation, not a new + root). Model it on `scripts/checkout-probe.mjs`, which already exercises the + `hg:checkout` seed-`current` path. +- **No pure-logic test needed** for Phase 1 (it's wiring); the logic lands in + Phase 3's matcher. + +### Phase 1 acceptance +Close a tab, click "Reopen" on one of its nodes, browse onward → the new +navigations hang off that node in the graph (one connected subtree), with no +duplicate node for the reopened URL itself. + +--- + +## Phase 2 — `tabKey` identity (stable per-tab grouping across a reopen) — ✅ implemented + +**Why it was needed immediately:** Phase 1 reconnects the *tree*, but the side +panel solos the active tab **by raw `tabId`**, and a reopened tab has a brand-new +`tabId` with no visits of its own — so the panel showed the empty state. Grouping +solo/labels by a logical key fixes that. + +**Implementation notes (differ slightly from the original sketch below):** +- `tabKey` is stamped **only on reopened tabs** (it carries the *source node's + group key* = `tabKey ?? String(tabId)`). Normal tabs get no `tabKey` and keep + grouping by `tabId`, so nothing else changes — and it works for pre-existing + data, where the carried key is just the old `tabId` string. +- `tabGroupOf(v) = v.tabKey ?? String(v.tabId)` (`viewModel.ts`) is the single + "same tab?" source of truth; solo, labels, and cross-tab link detection all use + it. Cross-tab comparisons (`opensTabs`/`openedFromTab`) compare *groups* but + still emit `tabId` values (the related-tabs menu activates a concrete tab). +- The reopened tab's group key is written to `storage.session` (`tabKeyByTab`) at + **`hg:attach` time** (not on consume), so the panel can re-solo before any + onward navigation. The panel watches `TABKEY_KEY` via `storage.onChanged` and + re-resolves the active tab's group; it tracks `activeTabId` separately from the + solo group for the live HEAD highlight. +- Tests: `tests/graph.test.ts` gains a reopened-tab grouping case; `reopen-probe` + asserts the onward visit's group equals the source group. + +
Original sketch (kept for reference) + +### Phase 2 — `tabKey` identity (stable per-tab grouping across a reopen) + +Phase 1 connects the **tree**, but solo/labels key off raw `chrome.tabId`, which +the reopened tab does not share — so solo-by-tab and tab labels still treat the +restored tab as a different tab. `tabKey` fixes that. + +### 2.1 Model — additive field (no migration) +`model/types.ts`, in the "Room to grow" block: + +```ts +/** Stable logical-tab id, minted per tab and carried across a reopen/restore. + * Falls back to tabId-based grouping when absent (old data). */ +tabKey?: string; +``` + +### 2.2 Capture — mint and stamp `tabKey` +- `sessionMap.ts`: add a `tabId → tabKey` map (`TABKEY_KEY`), with + `getTabKey(tabId)` / `setTabKey(tabId, key)`, and delete it in `clearTab`. +- In `recordNavigation`, resolve the tabKey for the visit: + - If a `pendingAttach` carried a `tabKey` (reopen case), use it. + - Else `getTabKey(tabId)`; if none, mint `crypto.randomUUID()` and store it. + - Stamp `visit.tabKey` in `buildVisit` (thread it through, or patch after + `buildVisit`). Keep `buildVisit` pure by passing tabKey as an argument. +- In `reopenFrom`/`hg:attach`: carry the source node's `tabKey` (read from the + `VisitNode`) into `PendingAttach.tabKey`, so the reopened tab inherits the + original logical tab. + +### 2.3 Viewer — group by `tabKey` when present +The plan calls out the exact sites. Introduce a helper +`tabGroupOf(v) = v.tabKey ?? String(v.tabId)` and use it in: +- `graph/viewModel.ts`: `inScope`, `opensTabs`, `openedFromTab` comparisons + (currently compare `n.tabId` / `c.tabId`). These switch to the group key. +- `App.tsx` `tabLabels` map and `soloTab` semantics. + +**Wrinkle:** `soloTab`, `activateTab`, `tabLabel`, and the panel's +`chrome.tabs.query` all speak raw `tabId` (they must, to talk to the browser). +Keep solo's *identity* on the group key but retain a `tabId` for the live actions +(activate/navigate). Cleanest: solo state becomes the group key; map group key → +the *currently live* tabId (if any) for `activateTab`. Spell this out before +coding — it's the one place Phase 2 is more than a find/replace. Annotations in +`viewModel` should expose both the group key (for grouping) and a representative +live `tabId` (for the related-tabs menu's activate action). + +### 2.4 Tests (Phase 2) +- **Pure logic (`tests/graph.test.ts` or a new `tests/viewModel.test.ts`)**: + build a forest where two `tabId`s share one `tabKey`; assert `buildViewModel` + with `soloTab = thatKey` includes both, and `opensTabs`/`openedFromTab` treat + them as one tab. Also assert the `tabKey ?? tabId` fallback for old data. + +### Phase 2 acceptance +Reopen a closed tab, browse on → soloing that tab in the panel/full view shows the +original subtree *and* the new navigations as one tab; the tab label is stable. + +
+ +--- + +## Phase 3 — Auto-detect a Chrome/Firefox restore (`sessions`) + +Convenience layer for Ctrl+Shift+T / "recently closed". Heuristic by nature; +manual path stays as the deterministic override. + +### 3.1 Archive on close — closed-tab registry +New module `src/background/closedTabs.ts`, written **pure where possible** (inject +the registry store + a clock) so the matching logic unit-tests like +`navEntries`/`navEntries.test.ts`: +- On `tabs.onRemoved`, before/around `clearTab`, archive the tail: + `{ tabKey, lastVisitId, lastUrl, lastTitle, closedAt, visitChain }` where + `visitChain` is the nav-stack entries (for back/forward rehydration). Store in + `storage.local` (durable; survives browser restart) or IndexedDB. +- Keep wiping `storage.session` live state in `clearTab` — only the *linking* info + is preserved, in the new registry. +- **TTL / cap** (open question in the plan): default to keeping entries longer + than Chrome's recently-closed (e.g. last N=50 or 7 days), pruned on write. + +### 3.2 Detect the restore +On `tabs.onCreated` (or the new tab's first `onCommitted`): +- Cross-reference `chrome.sessions.getRecentlyClosed()`: a tab that *was* in the + recently-closed list and just disappeared from it is a strong restore signal. +- Guard: committed URL equals a registry entry's `lastUrl` within a short time + window of `closedAt`/now. +- The **matcher is the pure, testable core**: `matchRestore(registry, candidate, + now) → entry | null`, given the candidate's url/title/time and the recently- + closed diff. Unit-test the misattach cases the plan flags (two closed tabs same + URL → prefer most-recent / require recently-closed corroboration; coincidental + new tab to same URL → time window). + +### 3.3 Rehydrate +On a match, do exactly what Phase 1's `hg:attach` does, plus more: +- Map new tabId → archived `tabKey` (`setTabKey`). +- `setCurrentVisit(newTabId, archived leaf)`. +- Rebuild the nav-stack from `visitChain` via `navEntries` so back/forward works + immediately (Chrome restores the real session history; we mirror it). +- This reuses the Phase-1 consume path; auto-detect just populates `pendingAttach` + (with the full chain) instead of the viewer doing so. + +### 3.4 UX safety (open question → decide) +Given false-positive risk, **surface a toast with Undo** rather than silently +re-attaching: "Reconnected this tab to its history — Undo". Undo restores the new +tab to a fresh root (clear `current`/`tabKey`, drop the seeded stack). Cheap to +get wrong = safe to ship a heuristic. + +### 3.5 Permissions +- Add `"sessions"` to `permissions` in **both** manifests. + - Chrome: `public/manifest.json` (or wherever the base manifest lives — the + version is stamped by `vite.config.ts finalize`; check where permissions are + declared and add it there). + - Firefox: `scripts/firefox-dist.mjs` rewrites the manifest — ensure `sessions` + survives the rewrite; `browser.sessions` is the equivalent API. +- Justify it in the store listings: `docs/publishing-chrome.md` / + `docs/publishing-firefox.md`. +- Re-validate with `npm run lint:firefox`. + +### 3.6 Tests (Phase 3) +- **Pure logic**: `tests/closedTabs.test.ts` — the registry archive/prune and the + `matchRestore` heuristic (inject clock + fake recently-closed list), covering + the brittleness cases above. +- **e2e**: extend `capture-check.mjs` to simulate a restore where feasible + (`chrome.sessions` is hard to drive headlessly; at minimum assert the + `pendingAttach`-driven rehydration path end to end by invoking the same + internal entry the matcher would). + +--- + +## Cross-cutting decisions to lock before coding (from the plan's open questions) +- **Reopen into a NEW tab** (Phase 1/3 default) — simplest, no "continue active + tab" mode for now. +- **Registry lifetime:** N=50 / 7-day prune (Phase 3); revisit if storage grows. +- **Auto re-attach is non-silent:** toast + Undo (Phase 3). +- **Incognito:** out of scope here; defer to the README incognito decision (don't + archive incognito tabs into a durable registry). + +## Touch list (quick reference) +| File | Phase 1 | Phase 2 | Phase 3 | +|---|---|---|---| +| `storage/sessionMap.ts` | pendingAttach + clearTab | tabKey map | — | +| `background/index.ts` | hg:attach handler + consume in recordNavigation | mint/stamp tabKey | onRemoved archive, onCreated detect, rehydrate | +| `background/capture.ts` | — | thread tabKey into buildVisit | — | +| `background/closedTabs.ts` | — | — | **new** (registry + matcher, pure) | +| `model/types.ts` | — | `tabKey?` | — | +| `viewer/views/types.ts` | `reopenFrom` + `isTabLive` | — | — | +| `viewer/App.tsx` | reopenFrom impl, liveTabs | group-by-tabKey | undo toast | +| `viewer/views/NodeContent.tsx` | Reopen affordance | — | — | +| `viewer/graph/viewModel.ts` | — | tabGroupOf in scope/opens/openedFrom | — | +| `styles.css` | `.reopen` | — | toast | +| manifests + publishing docs | — | — | `sessions` permission | +| tests | e2e scenario | viewModel logic | closedTabs logic + e2e | diff --git a/package.json b/package.json index 531b756..e56e052 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --noEmit", "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", + "test:reopen": "npm run build && node scripts/reopen-probe.mjs", "shots": "npm run build && node scripts/viewer-shot.mjs" }, "devDependencies": { diff --git a/scripts/reopen-probe.mjs b/scripts/reopen-probe.mjs new file mode 100644 index 0000000..48e05ec --- /dev/null +++ b/scripts/reopen-probe.mjs @@ -0,0 +1,127 @@ +// Verify "reopen a closed tab" (Phase 1 — manual reopen-from-node): +// 1. build a chain Istanbul → Turkish_language → Turkey in a tab, +// 2. CLOSE the tab (its visits survive; live per-tab state is dropped), +// 3. reopen Turkey in a NEW tab + send hg:attach for the Turkey node (as the +// viewer's "Reopen here" affordance does) → the create's commit must NOT +// duplicate Turkey, +// 4. navigate onward (→ Ankara) → must branch from Turkey (the new tab +// continues the closed tab's subtree, not a fresh root). +// +// node scripts/reopen-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]); + }); + + // 1. Build a chain in a tab we will then close. + const page = await context.newPage(); + 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); + + // Grab the closed-tab leaf (Turkey) before closing. + const viewer = await context.newPage(); + await viewer.goto(`chrome-extension://${extId}/viewer.html`, { waitUntil: "load" }); + const before = await readVisits(viewer); + const turkey = before.find((v) => v.url.endsWith("/Turkey")); + if (!turkey) throw new Error("setup failed: no Turkey node captured"); + + // 2. Close the tab → tabs.onRemoved drops its live current/nav-stack state. + await page.close(); + await sleep(800); + + // 3. Reopen Turkey in a NEW tab and register the attach (what reopenFrom does): + // create the tab BLANK, register the attach, THEN navigate — so the url's + // commit can't race ahead of the seed and fork a duplicate root. + const sourceGroup = turkey.tabKey ?? String(turkey.tabId); + const newTabId = await viewer.evaluate( + async ({ id, url, tabKey }) => { + const tab = await chrome.tabs.create({}); + await chrome.runtime.sendMessage({ type: "hg:attach", tabId: tab.id, id, url, tabKey }); + await chrome.tabs.update(tab.id, { url }); + return tab.id; + }, + { id: turkey.id, url: turkey.url, tabKey: sourceGroup }, + ); + // The page object Playwright opened is a blank tab; the real reopened tab is + // the one chrome.tabs.create made. Find it and drive it onward. + await sleep(2000); + const pages = context.pages(); + const turkeyPage = + pages.find((p) => p.url() === turkey.url) ?? pages[pages.length - 1]; + await turkeyPage.bringToFront(); + await clickLink(turkeyPage, "/wiki/Ankara"); + await turkeyPage.waitForLoadState("load"); + await sleep(1800); + + // 4. Assertions. + const after = await readVisits(viewer); + const byId = new Map(after.map((v) => [v.id, v])); + const short = (u) => (u ? u.replace("https://en.wikipedia.org/wiki/", "") : "(root)"); + const turkeyCount = after.filter((v) => v.url.endsWith("/Turkey")).length; + const ankara = after.find((v) => v.url.endsWith("/Ankara")); + + const ankaraGroup = ankara && (ankara.tabKey ?? String(ankara.tabId)); + console.log(`\nreopened new tab id: ${newTabId}`); + console.log(`Turkey nodes: ${turkeyCount} (want 1 — reopen didn't duplicate)`); + console.log(`Ankara parent: ${short(byId.get(ankara?.parentId)?.url)} (want Turkey)`); + console.log(`Ankara group: ${ankaraGroup} (want ${sourceGroup} — rejoined original tab)`); + + const ok = + turkeyCount === 1 && + ankara && + ankara.parentId === turkey.id && + ankaraGroup === sourceGroup; + console.log(`\n${ok ? "✓" : "✗"} reopened tab continued the closed tab's subtree in its group`); + if (!ok && ankara) { + console.log(` Ankara.parentId=${ankara.parentId} expected ${turkey.id}`); + console.log(` Ankara.group=${ankaraGroup} expected ${sourceGroup}`); + } +} finally { + await context.close(); +} diff --git a/src/background/capture.ts b/src/background/capture.ts index 08e2fc4..b21e053 100644 --- a/src/background/capture.ts +++ b/src/background/capture.ts @@ -53,10 +53,12 @@ function newId(): string { return `${Date.now()}-${counter++}`; } -/** Build a Visit record from a committed navigation + its resolved parent. */ +/** Build a Visit record from a committed navigation + its resolved parent. + * `tabKey` is set only for reopened tabs (carries the original tab's group). */ export function buildVisit( details: chrome.webNavigation.WebNavigationTransitionCallbackDetails, parentId: string | null, + tabKey?: string, ): Visit { return { id: newId(), @@ -66,5 +68,6 @@ export function buildVisit( parentId, transition: details.transitionType, qualifiers: details.transitionQualifiers ?? [], + ...(tabKey ? { tabKey } : {}), }; } diff --git a/src/background/index.ts b/src/background/index.ts index 399b4f2..eb84a1d 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -8,9 +8,13 @@ import { takePendingTitle, getNavStack, setNavStack, + setPendingAttach, + takePendingAttach, + getTabKey, + setTabKey, clearTab, } from "../storage/sessionMap"; -import { pushNavigation, applyBackForward } from "./navEntries"; +import { emptyStack, pushNavigation, applyBackForward } from "./navEntries"; import type { Visit } from "../model/types"; import { sameDocument } from "../model/url"; @@ -97,6 +101,21 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { })(); return true; // respond asynchronously } + // The viewer reopened a closed tab's URL in a NEW tab and wants its onward + // navigation to continue that node's subtree. We can't pre-seed `current` + // before the tab exists, and the create's first commit can race this message, + // so stash a single-use attach that recordNavigation consumes on first commit. + if (msg?.type === "hg:attach" && typeof msg.tabId === "number") { + void (async () => { + await setPendingAttach(msg.tabId, { id: msg.id, url: msg.url }); + // Adopt the source node's logical group so this reopened tab rejoins the + // old subtree's group (solo/labels follow it). Set NOW, not on consume, so + // the panel can re-solo onto the group before any onward navigation. + if (typeof msg.tabKey === "string") await setTabKey(msg.tabId, msg.tabKey); + sendResponse(true); + })(); + return true; // respond asynchronously + } return undefined; }); @@ -150,6 +169,18 @@ async function recordNavigation( // No node for this URL at all — fall through and record it. } + // A reopened/restored tab continuing an existing subtree: seed `current` to + // the target node so this very commit (its own url) is same-url-skipped below + // and the NEXT navigation parents under it — the same `current`-pointer + // mechanism as hg:checkout, but for a freshly created tab with no prior state. + // Seed a one-entry nav stack so back/forward has a sane starting point. + const attach = await takePendingAttach(tabId); + if (attach) { + await setCurrentVisit(tabId, { id: attach.id, url: attach.url, committedAt: details.timeStamp }); + await setNavStack(tabId, pushNavigation(emptyStack(), attach.id)); + console.log(`[history-graph] attach → continue subtree at ${attach.url}`); + } + const current = await getCurrentVisit(tabId); // Same URL as the current node: a replaceState-to-self (common on hydration), @@ -193,7 +224,10 @@ async function recordNavigation( } const parentId = await determineParentId(details); - const visit = buildVisit(details, parentId); + // Reopened tabs carry a group key so their visits rejoin the original + // subtree's group; normal tabs have none and group by tabId. + const tabKey = await getTabKey(details.tabId); + const visit = buildVisit(details, parentId, tabKey); // A title may have arrived (and been stashed) before this row existed — e.g. // a fast page whose parsed while we were still awaiting the write. diff --git a/src/model/types.ts b/src/model/types.ts index bf51a74..ba33f5c 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -31,6 +31,14 @@ export interface Visit { qualifiers: string[]; // --- Room to grow (not populated by the MVP) --- + /** + * Stable logical-tab id, used to group a tab's visits even across a reopen. + * Only stamped on tabs that were *reopened* from a closed tab's node (it + * carries the original tab's group key, so the restored tab rejoins the old + * subtree's group). Absent on normal tabs, which group by `tabId`. Grouping is + * therefore always `tabKey ?? String(tabId)` — see `tabGroupOf`. + */ + tabKey?: string; /** Browsing-session grouping. */ sessionId?: string; /** Dwell time on the page, once we measure it. */ diff --git a/src/storage/sessionMap.ts b/src/storage/sessionMap.ts index 535295e..1f04b86 100644 --- a/src/storage/sessionMap.ts +++ b/src/storage/sessionMap.ts @@ -26,6 +26,13 @@ import { emptyStack, type NavStack } from "../background/navEntries"; export const CURRENT_KEY = "currentVisitByTab"; const OPENER_KEY = "pendingOpenerByTab"; const TITLE_KEY = "pendingTitleByTab"; +// A reopened/restored tab's request to continue an existing subtree: the node to +// seed as `current` on the new tab's first commit (see hg:attach in background). +const ATTACH_KEY = "pendingAttachByTab"; +// A reopened tab's logical group key (the original tab's tabGroup), stamped onto +// the reopened tab's visits so it rejoins the old subtree's group. Only set for +// reopened tabs; normal tabs have no entry and group by tabId. +const TABKEY_KEY = "tabKeyByTab"; // 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"; @@ -43,6 +50,13 @@ export interface PendingTitle { faviconUrl?: string; } +export interface PendingAttach { + /** Visit id to seed as the new tab's `current` pointer. */ + id: string; + /** Its url, for the same-url skip + the current pointer. */ + url: string; +} + async function read<T>(key: string): Promise<Record<number, T>> { const obj = await chrome.storage.session.get(key); return (obj[key] as Record<number, T> | undefined) ?? {}; @@ -72,6 +86,46 @@ export async function clearTab(tabId: number): Promise<void> { const stacks = await read<NavStack>(NAVSTACK_KEY); delete stacks[tabId]; await write(NAVSTACK_KEY, stacks); + const attaches = await read<PendingAttach>(ATTACH_KEY); + delete attaches[tabId]; + await write(ATTACH_KEY, attaches); + const tabKeys = await read<string>(TABKEY_KEY); + delete tabKeys[tabId]; + await write(TABKEY_KEY, tabKeys); +} + +/** storage.session key for the per-tab logical group key; exported so the viewer + * can watch it and re-solo a reopened tab onto its original subtree. */ +export { TABKEY_KEY }; + +/** A reopened tab's logical group key (undefined for normal tabs). */ +export async function getTabKey(tabId: number): Promise<string | undefined> { + return (await read<string>(TABKEY_KEY))[tabId]; +} + +export async function setTabKey(tabId: number, tabKey: string): Promise<void> { + const map = await read<string>(TABKEY_KEY); + map[tabId] = tabKey; + await write(TABKEY_KEY, map); +} + +/** Record that a freshly opened `tabId` should continue an existing subtree: + * its first commit seeds `current` to this node (single-use). */ +export async function setPendingAttach(tabId: number, attach: PendingAttach): Promise<void> { + const map = await read<PendingAttach>(ATTACH_KEY); + map[tabId] = attach; + await write(ATTACH_KEY, map); +} + +/** Consume and clear the pending attach for a tab (single-use). */ +export async function takePendingAttach(tabId: number): Promise<PendingAttach | undefined> { + const map = await read<PendingAttach>(ATTACH_KEY); + const attach = map[tabId]; + if (attach !== undefined) { + delete map[tabId]; + await write(ATTACH_KEY, map); + } + return attach; } /** The tab's mirrored browser session-history stack (empty if unseen). */ diff --git a/src/viewer/App.tsx b/src/viewer/App.tsx index f3a801a..1e28379 100644 --- a/src/viewer/App.tsx +++ b/src/viewer/App.tsx @@ -1,13 +1,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useForest } from "./useForest"; -import { buildViewModel } from "./graph/viewModel"; +import { buildViewModel, tabGroupOf } from "./graph/viewModel"; import { chronologicalLayout, treeLayout } from "./graph/layout"; import { indexNodes } from "./graph/tree"; import { GraphCanvas } from "./views/GraphCanvas"; import { ListView } from "./views/ListView"; import type { ViewActions } from "./views/types"; import { clearAllVisits } from "../storage/db"; -import { getCurrentVisit, CURRENT_KEY } from "../storage/sessionMap"; +import { getCurrentVisit, getTabKey, CURRENT_KEY, TABKEY_KEY } from "../storage/sessionMap"; type Mode = "graph" | "tree" | "list"; @@ -21,7 +21,11 @@ export function App() { const [mode, setMode] = useState<Mode>("graph"); const [rangeDays, setRangeDays] = useState(7); const [search, setSearch] = useState(""); - const [soloTab, setSoloTab] = useState<number | null>(null); + // Solo is by tab GROUP (a tabKey or "tabId" string), so a reopened tab shows + // its original subtree. `activeTabId` is the panel's active browser tab, used + // for the live HEAD highlight (a concrete tabId, not a group). + const [soloKey, setSoloKey] = useState<string | null>(null); + const [activeTabId, setActiveTabId] = useState<number | null>(null); const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set()); const [newestFirst, setNewestFirst] = useState(true); const [selectedId, setSelectedId] = useState<string | null>(null); @@ -48,7 +52,9 @@ export function App() { return () => chrome.runtime.onMessage.removeListener(onMsg); }, [reload]); - // In the panel, solo whichever tab is active and follow tab switches. + // In the panel, solo whichever tab is active and follow tab switches. Solo by + // the active tab's GROUP (its tabKey if reopened, else its tabId) so a restored + // tab shows the original subtree even before it has visits of its own. useEffect(() => { if (!panel) return; const sync = async () => { @@ -56,14 +62,27 @@ export function App() { active: true, lastFocusedWindow: true, }); - if (tab?.id != null) setSoloTab(tab.id); + if (tab?.id == null) return; + setActiveTabId(tab.id); + const key = await getTabKey(tab.id); + setSoloKey(key ?? String(tab.id)); }; void sync(); chrome.tabs.onActivated.addListener(sync); chrome.windows.onFocusChanged.addListener(sync); + // A reopen sets the tab's group key in storage.session asynchronously + // (hg:attach); re-resolve so the panel re-solos onto the original subtree. + const onChanged = ( + changes: Record<string, chrome.storage.StorageChange>, + area: string, + ) => { + if (area === "session" && changes[TABKEY_KEY]) void sync(); + }; + chrome.storage.onChanged.addListener(onChanged); return () => { chrome.tabs.onActivated.removeListener(sync); chrome.windows.onFocusChanged.removeListener(sync); + chrome.storage.onChanged.removeListener(onChanged); }; }, [panel]); @@ -72,13 +91,13 @@ export function App() { // including back/forward and in-app back — so we just mirror it here. Panel // only: in the full tab the viewer itself is the active tab. useEffect(() => { - if (!panel || soloTab == null) { + if (!panel || activeTabId == null) { setHeadId(null); return; } let cancelled = false; const refresh = async () => { - const cur = await getCurrentVisit(soloTab); + const cur = await getCurrentVisit(activeTabId); if (!cancelled) setHeadId(cur?.id ?? null); }; void refresh(); @@ -93,7 +112,7 @@ export function App() { cancelled = true; chrome.storage.onChanged.removeListener(onChanged); }; - }, [panel, soloTab]); + }, [panel, activeTabId]); const openFullView = () => chrome.tabs.create({ url: chrome.runtime.getURL("viewer.html") }); @@ -115,26 +134,30 @@ export function App() { void chrome.storage.local.set({ "hg:prefs": { mode, newestFirst } }); }, [mode, newestFirst]); - // A short label per tab = the title of its most recent visit. - const tabLabels = useMemo(() => { - const best = new Map<number, { title: string; ts: number }>(); + // A short label per tab GROUP = the title of the group's most recent visit, + // plus a tabId→group resolver (a reopened tab and its original share a label). + const { groupLabels, groupByTab } = useMemo(() => { + const best = new Map<string, { title: string; ts: number }>(); + const byTab = new Map<number, string>(); for (const n of indexNodes(forest.roots).values()) { - const cur = best.get(n.tabId); + const g = tabGroupOf(n); + byTab.set(n.tabId, g); + const cur = best.get(g); if (!cur || n.ts > cur.ts) { - best.set(n.tabId, { - title: n.title || hostOf(n.url) || `tab ${n.tabId}`, - ts: n.ts, - }); + best.set(g, { title: n.title || hostOf(n.url) || `tab ${n.tabId}`, ts: n.ts }); } } - return best; + return { groupLabels: best, groupByTab: byTab }; }, [forest]); - const tabLabel = (t: number) => - clip(tabLabels.get(t)?.title ?? `tab ${t}`, 24); + const groupLabel = (g: string) => clip(groupLabels.get(g)?.title ?? g, 24); + const tabLabel = (t: number) => groupLabel(groupByTab.get(t) ?? String(t)); + // Latest tabId→group map for the (memoized) actions to read without re-creating. + const groupByTabRef = useRef(groupByTab); + groupByTabRef.current = groupByTab; const model = useMemo( - () => buildViewModel(forest.roots, { soloTab, collapsed }), - [forest, soloTab, collapsed], + () => buildViewModel(forest.roots, { soloKey, collapsed }), + [forest, soloKey, collapsed], ); const actions: ViewActions = useMemo( @@ -145,8 +168,8 @@ export function App() { next.has(id) ? next.delete(id) : next.add(id); return next; }), - soloTab: (t) => setSoloTab(t), - clearSolo: () => setSoloTab(null), + soloTab: (t) => setSoloKey(groupByTabRef.current.get(t) ?? String(t)), + clearSolo: () => setSoloKey(null), activateTab: async (tabId) => { try { const t = await chrome.tabs.get(tabId); @@ -164,6 +187,21 @@ export function App() { await chrome.runtime.sendMessage({ type: "hg:checkout", tabId: tab.id, id, url }).catch(() => {}); await chrome.tabs.update(tab.id, { url }); }, + reopenFrom: async (url, id, tabGroup) => { + // Open a NEW tab and continue the closed tab's subtree in it. Create it + // BLANK first (the new-tab page is ignored by capture), register the + // attach, THEN navigate — so the url's commit can't race ahead of the + // seed (creating with {url} lets the commit fire before our message + // lands, forking a duplicate root). Same ordering as hg:checkout. + // `tabKey` is the source node's group, so the reopened tab's visits + // rejoin that group and the panel re-solos onto the original subtree. + const tab = await chrome.tabs.create({}); + if (tab.id == null) return; + await chrome.runtime + .sendMessage({ type: "hg:attach", tabId: tab.id, id, url, tabKey: tabGroup }) + .catch(() => {}); + await chrome.tabs.update(tab.id, { url }); + }, select: (id) => setSelectedId(id), }), [], @@ -182,7 +220,7 @@ export function App() { await clearAllVisits(); await chrome.storage.session.clear(); setCollapsed(new Set()); - setSoloTab(null); + setSoloKey(null); reload(); } @@ -200,13 +238,13 @@ export function App() { <div className="controls"> {/* The panel always focuses the active tab, so the scope chip is only useful in the full view (where you can solo/clear manually). */} - {!panel && soloTab != null && ( + {!panel && soloKey != null && ( <button className="scope-chip" onClick={actions.clearSolo} title="Show all tabs" > - Tab: {tabLabel(soloTab)} <span className="x">✕</span> + Tab: {groupLabel(soloKey)} <span className="x">✕</span> </button> )} <input @@ -271,7 +309,7 @@ export function App() { <main> {model.roots.length === 0 ? ( <p className="empty"> - {soloTab != null + {soloKey != null ? "No visits for this tab in range." : "No visits yet. Browse around, then refresh."} </p> diff --git a/src/viewer/graph/viewModel.ts b/src/viewer/graph/viewModel.ts index 82a1269..3cc306d 100644 --- a/src/viewer/graph/viewModel.ts +++ b/src/viewer/graph/viewModel.ts @@ -1,6 +1,16 @@ -import type { VisitNode } from "../../model/types"; +import type { Visit, VisitNode } from "../../model/types"; import { indexNodes, subtreeMaxTs, mainlineChild } from "./tree"; +/** + * The logical-tab key a visit groups under. Reopened tabs carry an explicit + * `tabKey` (the original tab's group), so a restored tab rejoins the old + * subtree; everything else groups by its raw `tabId`. This is the single source + * of "same tab?" truth for solo, labels, and cross-tab link detection. + */ +export function tabGroupOf(v: Pick<Visit, "tabKey" | "tabId">): string { + return v.tabKey ?? String(v.tabId); +} + /** What the renderer needs to know about a node beyond its raw fields. */ export interface NodeAnnotation { /** Has ≥2 visible children in the current scope — i.e. is collapsible. */ @@ -14,8 +24,9 @@ export interface NodeAnnotation { } export interface ViewState { - /** Show only this tab's visits, or all tabs when null. */ - soloTab: number | null; + /** Show only this tab-group's visits (a tabKey or "tabId" string), or all + * tabs when null. See {@link tabGroupOf}. */ + soloKey: string | null; /** Forks (by visit id) whose non-mainline branches are hidden. */ collapsed: ReadonlySet<string>; } @@ -38,16 +49,21 @@ export function buildViewModel(fullRoots: VisitNode[], state: ViewState): ViewMo const maxTs = subtreeMaxTs(fullRoots); const annotations = new Map<string, NodeAnnotation>(); - const inScope = (n: VisitNode) => state.soloTab == null || n.tabId === state.soloTab; + const inScope = (n: VisitNode) => + state.soloKey == null || tabGroupOf(n) === state.soloKey; function build(n: VisitNode): VisitNode { const scopedKids = n.children.filter(inScope); + // Cross-tab links compare by GROUP, so a reopened tab continuing its own + // subtree isn't mistaken for a cross-tab jump. Values stay tabIds (the + // related-tabs menu activates a concrete tab). + const ownGroup = tabGroupOf(n); const opensTabs = [ - ...new Set(n.children.filter((c) => c.tabId !== n.tabId).map((c) => c.tabId)), + ...new Set(n.children.filter((c) => tabGroupOf(c) !== ownGroup).map((c) => c.tabId)), ]; const parent = n.parentId ? index.get(n.parentId) : undefined; - const openedFromTab = parent && parent.tabId !== n.tabId ? parent.tabId : null; + const openedFromTab = parent && tabGroupOf(parent) !== ownGroup ? parent.tabId : null; const collapsed = state.collapsed.has(n.id); const isFork = scopedKids.length >= 2; diff --git a/src/viewer/styles.css b/src/viewer/styles.css index 101f25f..bfd58ed 100644 --- a/src/viewer/styles.css +++ b/src/viewer/styles.css @@ -261,6 +261,35 @@ main { background: var(--surface); } +/* Hover-revealed "open in a fresh tab" escape hatch (full viewer tab only): the + row's default click reopens & continues, so this is the disconnected-new-tab + alternative. Hidden until the row is hovered or the control is focused. */ +.open-fresh { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.3rem; + height: 1.3rem; + font-size: 0.82rem; + line-height: 1; + border-radius: 6px; + border: 1px solid var(--line); + color: var(--muted); + background: var(--surface); + text-decoration: none; + opacity: 0; + transition: opacity 0.1s ease; +} +.row:hover .open-fresh, +.open-fresh:focus-visible { + opacity: 1; +} +.open-fresh:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); +} + .meta { margin-left: auto; display: inline-flex; diff --git a/src/viewer/views/NodeContent.tsx b/src/viewer/views/NodeContent.tsx index c36fd00..49649ab 100644 --- a/src/viewer/views/NodeContent.tsx +++ b/src/viewer/views/NodeContent.tsx @@ -1,6 +1,6 @@ import { useRef } from "react"; import type { VisitNode } from "../../model/types"; -import type { NodeAnnotation } from "../graph/viewModel"; +import { tabGroupOf, type NodeAnnotation } from "../graph/viewModel"; import type { ViewActions } from "./types"; /** The text/controls half of a node row, shared by every view. */ @@ -37,18 +37,20 @@ export function NodeContent({ <a className="label" href={node.url} - // In the panel a plain click navigates the active tab; href is kept so - // middle-click / ⌘-click still open a new tab as expected. + // href is kept so middle-click / ⌘-click still open a plain new tab. target={panel ? undefined : "_blank"} rel="noreferrer" title={node.url} 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) { + // A plain left click acts on the row. In the panel it navigates the + // active tab (checkout); in the full viewer tab it REOPENS the page in + // a new tab and continues its subtree there. Modified/middle clicks + // fall through to the anchor (a plain, disconnected new tab). + if (e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey) { e.preventDefault(); - actions.openUrl(node.url, node.id); + if (panel) actions.openUrl(node.url, node.id); + else actions.reopenFrom(node.url, node.id, tabGroupOf(node)); } }} > @@ -73,6 +75,23 @@ export function NodeContent({ <span className="meta-transition">{node.transition}</span> <time>{formatTime(node.ts)}</time> </span> + + {/* Full viewer tab only: a hover-revealed escape hatch to open the page in + a FRESH tab (a new disconnected root), since the row's default click is + now "reopen & continue". */} + {!panel && ( + <a + className="open-fresh" + href={node.url} + target="_blank" + rel="noreferrer" + title="Open in a new tab (don't continue history)" + aria-label="Open in a new tab" + onClick={(e) => e.stopPropagation()} + > + ↗ + </a> + )} </div> ); } diff --git a/src/viewer/views/types.ts b/src/viewer/views/types.ts index a9bdb43..8a10dc9 100644 --- a/src/viewer/views/types.ts +++ b/src/viewer/views/types.ts @@ -11,6 +11,13 @@ export interface ViewActions { * out first so it isn't duplicated; onward navigation branches from it. */ openUrl(url: string, id: string): void; + /** + * Reopen this node's URL in a NEW tab and continue its subtree there: the new + * tab's onward navigation parents under this node instead of starting a fresh + * root. `tabGroup` is the source node's logical group (so the restored tab + * rejoins it in solo/labels). Used to restore a closed tab's line of browsing. + */ + reopenFrom(url: string, id: string, tabGroup: string): void; /** Mark a node as the highlighted/selected one. */ select(id: string): void; } diff --git a/tests/graph.test.ts b/tests/graph.test.ts index ac06ea3..559fcbd 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -105,7 +105,7 @@ check("subtrees are contiguous and col == depth", () => { console.log("\nviewModel — collapse:"); check("collapsing a fork keeps the most-recently-active branch", () => { - const vm = buildViewModel(fixture(), { soloTab: null, collapsed: new Set(["root"]) }); + const vm = buildViewModel(fixture(), { soloKey: null, collapsed: new Set(["root"]) }); const root = vm.roots.find((r) => r.id === "root")!; const kids = root.children.map((c) => c.id).sort(); // b (mainline) kept; a hidden. c is a different tab but still a child here. @@ -117,16 +117,33 @@ check("collapsing a fork keeps the most-recently-active branch", () => { console.log("\nviewModel — solo a tab:"); check("solo filters to one tab and annotates the cross-tab opener", () => { - const vm = buildViewModel(fixture(), { soloTab: 1, collapsed: new Set() }); + const vm = buildViewModel(fixture(), { soloKey: "1", collapsed: new Set() }); const ids = [...vm.annotations.keys()].sort(); assert.deepEqual(ids, ["a", "b", "b2", "root"]); // tab 2's "c" excluded // root opened tab 2. assert.deepEqual(vm.annotations.get("root")!.opensTabs, [2]); }); check("soloing tab 2 promotes the cross-tab child to a root with openedFrom", () => { - const vm = buildViewModel(fixture(), { soloTab: 2, collapsed: new Set() }); + const vm = buildViewModel(fixture(), { soloKey: "2", collapsed: new Set() }); assert.deepEqual(vm.roots.map((r) => r.id), ["c"]); assert.equal(vm.annotations.get("c")!.openedFromTab, 1); }); +console.log("\nviewModel — tabKey grouping (reopened tab):"); +check("a reopened tab (new tabId, shared tabKey) groups with the original", () => { + // Original tab 1's subtree, then a reopened tab (tabId 9) carrying tab 1's + // group key "1" so its onward visit rejoins the group. + const roots = buildForest([ + v("root", 1, null), // tab 1, group "1" + v("a", 2, "root"), // tab 1 + { ...v("re", 3, "a", 9), tabKey: "1" }, // reopened: new tabId 9, group "1" + ]).roots; + // Solo by the GROUP "1": the reopened visit is included despite tabId 9. + const vm = buildViewModel(roots, { soloKey: "1", collapsed: new Set() }); + assert.deepEqual([...vm.annotations.keys()].sort(), ["a", "re", "root"]); + // The continuation isn't treated as a cross-tab open (same group). + assert.deepEqual(vm.annotations.get("a")!.opensTabs, []); + assert.equal(vm.annotations.get("re")!.openedFromTab, null); +}); + console.log(`\n${passed} checks passed.`); -- 2.51.2