diff --git a/src/background/index.ts b/src/background/index.ts index eb84a1d..7c81b3e 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -28,6 +28,18 @@ import { sameDocument } from "../model/url"; const MAIN_FRAME = 0; +// Recording can be paused from the viewer header. The flag lives in +// storage.local (not session) so a pause survives a browser restart — you have +// to deliberately turn recording back on. While paused we record nothing and +// don't move any HEAD pointer; on resume the next navigation simply continues +// from wherever each tab was last recorded (the current/db-fallback parentage), +// so the edge spans the untracked gap rather than starting a fresh root. +const PAUSED_KEY = "hg:paused"; +async function isPaused(): Promise { + const o = await chrome.storage.local.get(PAUSED_KEY); + return o[PAUSED_KEY] === true; +} + // Browser-internal and our-own pages are noise in a browsing graph: the viewer // itself, blank tabs, and the new-tab page. Skip capturing them. const SELF_PREFIX = `chrome-extension://${chrome.runtime.id}`; @@ -43,6 +55,9 @@ function isIgnorable(url: string): boolean { // A link/form that opens a NEW tab fires this BEFORE the new tab commits. // Stash the source visit so the new tab's first commit can attach to it. chrome.webNavigation.onCreatedNavigationTarget.addListener(async (details) => { + // Don't stash an opener while paused: no commit will record during the pause, + // so the single-use slot would linger and mis-attach a much later navigation. + if (await isPaused()) return; const sourceVisit = await getCurrentVisit(details.sourceTabId); if (sourceVisit) { await setPendingOpener(details.tabId, sourceVisit.id); @@ -125,6 +140,9 @@ async function recordNavigation( ): Promise { const { tabId, url } = details; if (isIgnorable(url)) return; + // Paused: record nothing and leave every HEAD pointer where it is. The single + // choke point — covers commits, History-API updates, redirects, back/forward. + if (await isPaused()) return; const qualifiers = details.transitionQualifiers ?? []; // Back/forward: don't fork a new node. Move HEAD onto the existing node the diff --git a/src/query/subtree.ts b/src/query/subtree.ts new file mode 100644 index 0000000..50709ed --- /dev/null +++ b/src/query/subtree.ts @@ -0,0 +1,33 @@ +import type { Visit } from "../model/types"; + +/** + * The id of `rootId` plus every descendant, walking the parentId edges. Pure (no + * storage, no React) so the "delete this branch" math is unit-testable in plain + * node and reusable by the storage delete and any UI confirm-count. The + * visited-set guard keeps a corrupt cycle from spinning forever (mirrors + * findAncestorVisitByUrl's guard in db.ts). + * + * Lives in its own file rather than getForest.ts to avoid a circular import: + * db.ts uses this, and getForest.ts already imports db.ts. + */ +export function collectSubtreeIds(visits: Visit[], rootId: string): Set { + const childrenByParent = new Map(); + for (const v of visits) { + if (v.parentId == null) continue; + const list = childrenByParent.get(v.parentId); + if (list) list.push(v.id); + else childrenByParent.set(v.parentId, [v.id]); + } + const out = new Set([rootId]); + const queue = [rootId]; + while (queue.length > 0) { + const id = queue.shift()!; + for (const child of childrenByParent.get(id) ?? []) { + if (!out.has(child)) { + out.add(child); + queue.push(child); + } + } + } + return out; +} diff --git a/src/storage/db.ts b/src/storage/db.ts index 6e0b547..6e32b99 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -1,5 +1,6 @@ import type { Visit } from "../model/types"; import { sameDocument } from "../model/url"; +import { collectSubtreeIds } from "../query/subtree"; /** * IndexedDB persistence for visits. @@ -63,6 +64,57 @@ export async function patchVisit(id: string, patch: Partial): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const req = tx(db, "readonly").index("parentId").getAll(IDBKeyRange.only(id)); + req.onsuccess = () => resolve((req.result as Visit[] | undefined) ?? []); + req.onerror = () => reject(req.error); + }); +} + +/** + * Delete a single visit, reparenting its children onto its parent (the deleted + * node's grandparent; null → they become roots). Removes one mis-recorded stop + * without losing the branch underneath it. Done in one readwrite transaction so + * a child is never left pointing at a parent that's already gone. + */ +export async function deleteVisitReparenting(id: string): Promise { + const db = await openDb(); + const node = await getVisit(id); + if (!node) return; + const children = await getChildren(id); + await new Promise((resolve, reject) => { + const store = tx(db, "readwrite"); + for (const child of children) store.put({ ...child, parentId: node.parentId }); + store.delete(id); + store.transaction.oncomplete = () => resolve(); + store.transaction.onerror = () => reject(store.transaction.error); + }); +} + +/** + * Delete a visit and its entire subtree. Collects the node + all descendants by + * walking the parentId index, then deletes them in one transaction. The + * visited-set in collectSubtreeIds guards against a corrupt cycle. + */ +export async function deleteSubtree(id: string): Promise { + const db = await openDb(); + const all = await new Promise((resolve, reject) => { + const req = tx(db, "readonly").getAll(); + req.onsuccess = () => resolve((req.result as Visit[] | undefined) ?? []); + req.onerror = () => reject(req.error); + }); + const ids = collectSubtreeIds(all, id); + await new Promise((resolve, reject) => { + const store = tx(db, "readwrite"); + for (const victim of ids) store.delete(victim); + store.transaction.oncomplete = () => resolve(); + store.transaction.onerror = () => reject(store.transaction.error); + }); +} + /** Wipe every stored visit. Used by the viewer's "Clear" button. */ export async function clearAllVisits(): Promise { const db = await openDb(); diff --git a/src/viewer/App.tsx b/src/viewer/App.tsx index 1e28379..774231c 100644 --- a/src/viewer/App.tsx +++ b/src/viewer/App.tsx @@ -6,7 +6,7 @@ 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 { clearAllVisits, deleteSubtree, deleteVisitReparenting } from "../storage/db"; import { getCurrentVisit, getTabKey, CURRENT_KEY, TABKEY_KEY } from "../storage/sessionMap"; type Mode = "graph" | "tree" | "list"; @@ -30,6 +30,10 @@ export function App() { const [newestFirst, setNewestFirst] = useState(true); const [selectedId, setSelectedId] = useState(null); const [headId, setHeadId] = useState(null); + // Recording pause lives in storage.local (survives restarts), shared by the + // capture worker and every open viewer. We mirror it here and treat storage as + // the single source of truth: the toggle writes storage, onChanged updates us. + const [paused, setPaused] = useState(false); const { forest, reload } = useForest(rangeDays, search); @@ -114,6 +118,26 @@ export function App() { }; }, [panel, activeTabId]); + // Mirror the paused flag from storage.local and follow changes from anywhere + // (the capture worker reads the same key; another open viewer may toggle it). + useEffect(() => { + const PAUSED_KEY = "hg:paused"; + void chrome.storage.local + .get(PAUSED_KEY) + .then((o) => setPaused(o[PAUSED_KEY] === true)); + const onChanged = ( + changes: Record, + area: string, + ) => { + if (area === "local" && changes[PAUSED_KEY]) + setPaused(changes[PAUSED_KEY].newValue === true); + }; + chrome.storage.onChanged.addListener(onChanged); + return () => chrome.storage.onChanged.removeListener(onChanged); + }, []); + const togglePaused = () => + void chrome.storage.local.set({ "hg:paused": !paused }); + const openFullView = () => chrome.tabs.create({ url: chrome.runtime.getURL("viewer.html") }); @@ -203,8 +227,14 @@ export function App() { await chrome.tabs.update(tab.id, { url }); }, select: (id) => setSelectedId(id), + deleteNode: async (id, subtree) => { + if (subtree) await deleteSubtree(id); + else await deleteVisitReparenting(id); + setSelectedId(null); + reload(); + }, }), - [], + [reload], ); const layout = useMemo( @@ -300,6 +330,14 @@ export function App() { ⤢ )} + diff --git a/src/viewer/styles.css b/src/viewer/styles.css index bfd58ed..b3bf794 100644 --- a/src/viewer/styles.css +++ b/src/viewer/styles.css @@ -156,6 +156,20 @@ button { background: light-dark(#fdeeee, #271a1a); } +/* Recording on/off toggle. Green dot while capturing, amber "paused" state. */ +.record-toggle { + display: inline-flex; + align-items: center; + gap: 0.3rem; + white-space: nowrap; + color: light-dark(#15803d, #4ade80); + border-color: color-mix(in srgb, currentColor 35%, var(--line)); +} +.record-toggle.is-paused { + color: light-dark(#b45309, #fbbf24); + background: color-mix(in srgb, currentColor 12%, transparent); +} + main { padding: 0.75rem 1.1rem 4rem; } @@ -290,6 +304,33 @@ main { border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); } +/* Hover-revealed per-node delete (both views). Plain click prunes the stop; + ⌥/⇧-click deletes the branch. Mirrors .open-fresh's reveal-on-hover. */ +.node-delete { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.3rem; + height: 1.3rem; + font-size: 0.78rem; + line-height: 1; + border-radius: 6px; + border: 1px solid var(--line); + color: var(--muted); + background: var(--surface); + opacity: 0; + transition: opacity 0.1s ease; +} +.row:hover .node-delete, +.node-delete:focus-visible { + opacity: 1; +} +.node-delete:hover { + color: light-dark(#b91c1c, #f4847a); + border-color: light-dark(#e7bcbc, #5a2b2b); +} + .meta { margin-left: auto; display: inline-flex; diff --git a/src/viewer/views/NodeContent.tsx b/src/viewer/views/NodeContent.tsx index 49649ab..88f0ac2 100644 --- a/src/viewer/views/NodeContent.tsx +++ b/src/viewer/views/NodeContent.tsx @@ -92,10 +92,46 @@ export function NodeContent({ ↗ )} + + {/* Hover-revealed delete. Plain click prunes just this stop (children + reparent onto its parent); ⌥/⇧-click deletes the whole branch (with a + confirm naming the count, since that's destructive). stopPropagation so + it never triggers the row's navigate/select. */} + ); } +/** Number of nodes below this one (excludes the node itself). */ +function countDescendants(node: VisitNode): number { + let n = 0; + for (const child of node.children) n += 1 + countDescendants(child); + return n; +} + /** * A compact icon that reveals the tabs this node links to (the tab it was * opened from, and the tabs it opened). Each entry switches the browser's diff --git a/src/viewer/views/types.ts b/src/viewer/views/types.ts index 8a10dc9..783a78b 100644 --- a/src/viewer/views/types.ts +++ b/src/viewer/views/types.ts @@ -20,6 +20,13 @@ export interface ViewActions { reopenFrom(url: string, id: string, tabGroup: string): void; /** Mark a node as the highlighted/selected one. */ select(id: string): void; + /** + * Delete a node from history. By default (subtree=false) only this node is + * removed and its children reparent onto its parent — pruning one mis-recorded + * stop without losing the branch. With subtree=true the node and all of its + * descendants are removed. + */ + deleteNode(id: string, subtree: boolean): void; } export interface ViewProps { diff --git a/tests/logic.test.ts b/tests/logic.test.ts index 624ff8c..8241c48 100644 --- a/tests/logic.test.ts +++ b/tests/logic.test.ts @@ -1,6 +1,7 @@ import { strict as assert } from "node:assert"; import { sameDocument, stripHash } from "../src/model/url.ts"; import { buildForest } from "../src/query/getForest.ts"; +import { collectSubtreeIds } from "../src/query/subtree.ts"; import type { Visit } from "../src/model/types.ts"; let passed = 0; @@ -63,4 +64,35 @@ check("a same-URL parent/child pair is still rendered as nested (data, not viz, assert.equal(f.roots[0].children[0].id, "c"); }); +console.log("\nSubtree collection (delete-branch math):"); +check("collects the node plus all transitive descendants", () => { + // a → b → {c, d}, b → e; plus an unrelated root x. Deleting b's branch should + // take b, c, d, e but never a or x. + const visits = [ + visit({ id: "a", url: "u/a", ts: 1 }), + visit({ id: "b", url: "u/b", ts: 2, parentId: "a" }), + visit({ id: "c", url: "u/c", ts: 3, parentId: "b" }), + visit({ id: "d", url: "u/d", ts: 4, parentId: "b" }), + visit({ id: "e", url: "u/e", ts: 5, parentId: "c" }), + visit({ id: "x", url: "u/x", ts: 6 }), + ]; + const ids = collectSubtreeIds(visits, "b"); + assert.deepEqual([...ids].sort(), ["b", "c", "d", "e"]); +}); +check("a leaf collects only itself", () => { + const visits = [ + visit({ id: "a", url: "u/a", ts: 1 }), + visit({ id: "b", url: "u/b", ts: 2, parentId: "a" }), + ]; + assert.deepEqual([...collectSubtreeIds(visits, "b")], ["b"]); +}); +check("a corrupt parent cycle terminates", () => { + // b ↔ c point at each other; the visited-set must stop the walk. + const visits = [ + visit({ id: "b", url: "u/b", ts: 1, parentId: "c" }), + visit({ id: "c", url: "u/c", ts: 2, parentId: "b" }), + ]; + assert.deepEqual([...collectSubtreeIds(visits, "b")].sort(), ["b", "c"]); +}); + console.log(`\n${passed} checks passed.`);