From 0b0b9d4c391b6b250eafc05282407040f93bf384 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 12:57:18 +0100 Subject: [PATCH] Add expanded timeline mode to show all branches at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a toggle button (▾/▴) to the timeline bar that expands it vertically, displaying every branch as its own row. The active branch is highlighted, shared ancestors are dimmed on other rows, and clicking any marker on a non-active row switches to that branch. Co-Authored-By: Claude Opus 4.6 --- js/snapshot_manager.js | 227 ++++++++++++++++++++++++++++++++--------- 1 file changed, 180 insertions(+), 47 deletions(-) diff --git a/js/snapshot_manager.js b/js/snapshot_manager.js index e838437..f6e8868 100644 --- a/js/snapshot_manager.js +++ b/js/snapshot_manager.js @@ -45,6 +45,7 @@ let sidebarTooltipEl = null; // tooltip element for sidebar hover previews const lastCapturedIdMap = new Map(); // workflowKey -> id of most recent capture (for parentId chaining) const activeBranchSelections = new Map(); // forkPointId -> selected child index let branchingEnabled = true; +let timelineExpanded = false; const sessionWorkflows = new Map(); // workflowKey -> { firstSeen, lastSeen } // ─── Server API Layer ─────────────────────────────────────────────── @@ -972,6 +973,58 @@ function getAncestorIds(snapshotId, parentOf) { return ancestors; } +function getAllBranches(tree) { + const branches = []; + function walk(nodeId, path) { + const record = tree.byId.get(nodeId); + if (!record) return; + const currentPath = [...path, record]; + const children = tree.childrenOf.get(nodeId); + if (!children || children.length === 0) { + branches.push(currentPath); + } else { + for (const child of children) { + walk(child.id, currentPath); + } + } + } + for (const root of tree.roots) { + walk(root.id, []); + } + return branches; +} + +function selectBranchContaining(snapshotId, tree) { + // Walk from snapshot to root, at each fork set activeBranchSelections + const pathToRoot = []; + const visited = new Set(); + let current = snapshotId; + while (current) { + if (visited.has(current)) break; // cycle detection + visited.add(current); + pathToRoot.push(current); + current = tree.parentOf.get(current) || null; + } + pathToRoot.reverse(); // now root → snapshot + + // Handle multiple roots + if (pathToRoot.length > 0 && tree.roots.length > 1) { + const rootId = pathToRoot[0]; + const rootIdx = tree.roots.findIndex(r => r.id === rootId); + if (rootIdx >= 0) activeBranchSelections.set("__root__", rootIdx); + } + + for (let i = 0; i < pathToRoot.length - 1; i++) { + const parentId = pathToRoot[i]; + const childId = pathToRoot[i + 1]; + const children = tree.childrenOf.get(parentId); + if (children && children.length > 1) { + const idx = children.findIndex(c => c.id === childId); + if (idx >= 0) activeBranchSelections.set(parentId, idx); + } + } +} + // ─── Restore Lock ─────────────────────────────────────────────────── async function withRestoreLock(fn) { @@ -2107,6 +2160,40 @@ const CSS = ` opacity: 1; background: rgba(59, 130, 246, 0.2); } +.snap-timeline-expand-btn { + font-size: 13px; + padding: 2px 6px; + line-height: 1; +} +.snap-timeline-expanded { + height: auto; + align-items: flex-start; + padding: 8px 16px; +} +.snap-timeline-expanded .snap-timeline-track { + flex-direction: column; + gap: 4px; + height: auto; + max-height: 180px; + overflow-y: auto; + align-items: stretch; +} +.snap-timeline-branch-row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 6px; + border-radius: 4px; + min-height: 24px; + border-left: 2px solid transparent; +} +.snap-timeline-branch-row-active { + background: rgba(59, 130, 246, 0.12); + border-left-color: #3b82f6; +} +.snap-timeline-marker-dimmed { + opacity: 0.35; +} .snap-diff-overlay { position: fixed; inset: 0; @@ -3447,15 +3534,68 @@ function buildTimeline() { snapBtn.disabled = false; }); + const expandBtn = document.createElement("button"); + expandBtn.className = "snap-timeline-snap-btn snap-timeline-expand-btn"; + expandBtn.textContent = "\u25BE"; + expandBtn.title = "Expand timeline to show all branches"; + expandBtn.addEventListener("click", () => { + timelineExpanded = !timelineExpanded; + expandBtn.textContent = timelineExpanded ? "\u25B4" : "\u25BE"; + expandBtn.title = timelineExpanded ? "Collapse timeline" : "Expand timeline to show all branches"; + bar.classList.toggle("snap-timeline-expanded", timelineExpanded); + refresh(); + }); + bar.appendChild(track); + bar.appendChild(expandBtn); bar.appendChild(snapBtn); canvasParent.appendChild(bar); timelineEl = bar; + function buildMarker(rec, { dimmed = false, onClickBranch = null } = {}) { + const marker = document.createElement("div"); + marker.className = "snap-timeline-marker"; + + const iconInfo = CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown; + marker.style.setProperty("--snap-marker-color", iconInfo.color); + marker.innerHTML = iconInfo.svg; + + if (rec.source === "node") { + marker.classList.add("snap-timeline-marker-node"); + marker.style.setProperty("--snap-marker-color", "#6d28d9"); + } + if (rec.locked) marker.classList.add("snap-timeline-marker-locked"); + if (rec.id === activeSnapshotId) marker.classList.add("snap-timeline-marker-active"); + if (rec.id === currentSnapshotId) { + marker.classList.add("snap-timeline-marker-current"); + marker.style.setProperty("--snap-marker-color", "#10b981"); + } + if (dimmed) marker.classList.add("snap-timeline-marker-dimmed"); + + let tip = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`; + if (rec.notes) tip += `\n${rec.notes}`; + marker.title = tip; + + marker.addEventListener("click", () => { + if (onClickBranch) onClickBranch(); + swapSnapshot(rec); + }); + + return marker; + } + async function refresh() { if (!showTimeline) return; + // Hide/show expand button based on branching + expandBtn.style.display = branchingEnabled ? "" : "none"; + if (!branchingEnabled && timelineExpanded) { + timelineExpanded = false; + bar.classList.remove("snap-timeline-expanded"); + expandBtn.textContent = "\u25BE"; + } + const allRecords = await db_getAllForWorkflow(getWorkflowKey()); track.innerHTML = ""; @@ -3468,62 +3608,58 @@ function buildTimeline() { return; } - let records; let tree = null; + if (branchingEnabled) { + tree = buildSnapshotTree(allRecords); + } + + // ── Expanded mode: one row per branch ── + if (timelineExpanded && branchingEnabled) { + const allBranches = getAllBranches(tree); + const currentPath = getDisplayPath(tree, activeBranchSelections); + const currentIds = new Set(currentPath.map(r => r.id)); + + // Determine which branch is the active one + const currentLeafId = currentPath.length > 0 ? currentPath[currentPath.length - 1].id : null; + + for (const branch of allBranches) { + const row = document.createElement("div"); + row.className = "snap-timeline-branch-row"; + + const branchLeafId = branch[branch.length - 1].id; + const isActiveBranch = branchLeafId === currentLeafId; + if (isActiveBranch) row.classList.add("snap-timeline-branch-row-active"); + + for (const rec of branch) { + const isSharedAncestor = !isActiveBranch && currentIds.has(rec.id); + const marker = buildMarker(rec, { + dimmed: isSharedAncestor, + onClickBranch: isActiveBranch ? null : () => { + selectBranchContaining(branchLeafId, tree); + }, + }); + row.appendChild(marker); + } + + track.appendChild(row); + } + return; + } + + // ── Collapsed mode (default) ── + let records; let forkPointSet = new Set(); if (branchingEnabled) { - // Show only current branch's markers - tree = buildSnapshotTree(allRecords); records = getDisplayPath(tree, activeBranchSelections); - for (const [parentId, children] of tree.childrenOf) { if (children.length > 1) forkPointSet.add(parentId); } } else { - // Flat: all records in timestamp order records = [...allRecords].sort((a, b) => a.timestamp - b.timestamp); } for (const rec of records) { - const marker = document.createElement("div"); - marker.className = "snap-timeline-marker"; - - // Change-type icon and color - const iconInfo = CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown; - marker.style.setProperty("--snap-marker-color", iconInfo.color); - marker.innerHTML = iconInfo.svg; - - // Node snapshot styling — override color to purple but keep the SVG icon - if (rec.source === "node") { - marker.classList.add("snap-timeline-marker-node"); - marker.style.setProperty("--snap-marker-color", "#6d28d9"); - } - - // Locked snapshot styling - if (rec.locked) { - marker.classList.add("snap-timeline-marker-locked"); - } - - // Active snapshot styling (the one swapped TO) - if (rec.id === activeSnapshotId) { - marker.classList.add("snap-timeline-marker-active"); - } - - // Current snapshot styling (auto-saved "you were here" bookmark) - if (rec.id === currentSnapshotId) { - marker.classList.add("snap-timeline-marker-current"); - marker.style.setProperty("--snap-marker-color", "#10b981"); - } - - // Native tooltip with change-type description - let tip = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`; - if (rec.notes) tip += `\n${rec.notes}`; - marker.title = tip; - - // Click to swap - marker.addEventListener("click", () => { - swapSnapshot(rec); - }); + const marker = buildMarker(rec); // Fork point: vertical stack — up arrow, marker, down arrow if (branchingEnabled && forkPointSet.has(rec.id)) { @@ -3533,10 +3669,8 @@ function buildTimeline() { const group = document.createElement("div"); group.className = "snap-timeline-fork-group"; - // Arrow color matches the marker const arrowColor = marker.style.getPropertyValue("--snap-marker-color") || "#3b82f6"; - // Up arrow (previous branch) const upBtn = document.createElement("button"); upBtn.className = "snap-timeline-branch-btn"; upBtn.textContent = "\u25B2"; @@ -3550,7 +3684,6 @@ function buildTimeline() { if (sidebarRefresh) sidebarRefresh().catch(() => {}); }); - // Down arrow (next branch) const downBtn = document.createElement("button"); downBtn.className = "snap-timeline-branch-btn"; downBtn.textContent = "\u25BC";