/** * — Document history slide-out panel. * * Activity feed shows Automerge change history with author attribution, * grouped by author within time windows. Time Machine tab provides a * scrubber over the change timeline to view document state at any point. */ import * as Automerge from "@automerge/automerge"; import { parseChangeMessage, type ParsedChangeMessage } from "../local-first/change-message"; // ── Types ── export interface HistoryEntry { hash: string; actor: string; username: string; time: number; message: string; opsCount: number; seq: number; } export interface GroupedEntry { username: string; entries: HistoryEntry[]; timeRange: { start: number; end: number }; summary: string; } interface TimeGroup { label: string; groups: GroupedEntry[]; } // ── Component ── export class RStackHistoryPanel extends HTMLElement { private _open = false; private _tab: "activity" | "timemachine" = "activity"; private _doc: Automerge.Doc | null = null; private _entries: HistoryEntry[] = []; private _visibleCount = 50; private _timeMachineIndex = -1; private _timeMachineSnapshot: any = null; private _docChangeHandler: (() => void) | null = null; static define() { if (!customElements.get("rstack-history-panel")) { customElements.define("rstack-history-panel", RStackHistoryPanel); } } private _clickOutsideHandler = (e: MouseEvent) => { const path = e.composedPath(); if (!path.includes(this) && !path.includes(document.getElementById("history-btn")!)) { this.close(); } }; connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: "open" }); this._render(); } disconnectedCallback() { document.removeEventListener("click", this._clickOutsideHandler, true); } open() { this._open = true; this._refreshHistory(); this._render(); this._positionPanel(); document.getElementById("history-btn")?.classList.add("active"); document.addEventListener("click", this._clickOutsideHandler, true); } close() { this._open = false; this._timeMachineSnapshot = null; this._render(); document.getElementById("history-btn")?.classList.remove("active"); document.removeEventListener("click", this._clickOutsideHandler, true); } toggle() { if (this._open) this.close(); else this.open(); } /** Position the panel below the history button, right-aligned to button. */ private _positionPanel() { const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null; const btn = document.getElementById("history-btn"); if (!panel || !btn) return; const rect = btn.getBoundingClientRect(); panel.style.top = `${rect.bottom + 6}px`; // Right-align to button's right edge, but clamp so left edge stays on screen const rightOffset = window.innerWidth - rect.right; panel.style.right = `${rightOffset}px`; panel.style.left = "auto"; // After layout, check if panel overflows left edge requestAnimationFrame(() => { const panelRect = panel.getBoundingClientRect(); if (panelRect.left < 8) { panel.style.right = "auto"; panel.style.left = "8px"; } }); } setDoc(doc: Automerge.Doc) { this._doc = doc; if (this._open) { this._refreshHistory(); this._render(); } } // ── History extraction ── private _refreshHistory() { if (!this._doc) { this._entries = []; return; } try { const changes = Automerge.getHistory(this._doc); this._entries = changes.map((entry, i) => { const change = entry.change; const parsed = parseChangeMessage(change.message || null); return { hash: change.hash || `change-${i}`, actor: change.actor || "unknown", username: parsed.user || "Unknown", time: parsed.ts || (change.time ? change.time * 1000 : 0), message: parsed.text, opsCount: change.ops?.length || 0, seq: change.seq || i, }; }); } catch (e) { console.warn("[HistoryPanel] Failed to extract history:", e); this._entries = []; } } // ── Grouping ── private _groupEntries(): TimeGroup[] { const entries = this._entries.slice(-this._visibleCount).reverse(); if (entries.length === 0) return []; // Group by same author within 5-minute windows const grouped: GroupedEntry[] = []; let current: GroupedEntry | null = null; const WINDOW = 5 * 60 * 1000; for (const entry of entries) { if ( current && current.username === entry.username && entry.time - current.timeRange.end < WINDOW ) { current.entries.push(entry); current.timeRange.start = Math.min(current.timeRange.start, entry.time); current.timeRange.end = Math.max(current.timeRange.end, entry.time); } else { current = { username: entry.username, entries: [entry], timeRange: { start: entry.time, end: entry.time }, summary: "", }; grouped.push(current); } } // Build summaries for (const g of grouped) { g.summary = this._buildSummary(g.entries); } // Nest into time groups const now = Date.now(); const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const yesterdayStart = new Date(todayStart); yesterdayStart.setDate(yesterdayStart.getDate() - 1); const weekStart = new Date(todayStart); weekStart.setDate(weekStart.getDate() - 7); const timeGroups: TimeGroup[] = []; const buckets: Record = {}; for (const g of grouped) { const t = g.timeRange.end; let label: string; if (t >= todayStart.getTime()) label = "Today"; else if (t >= yesterdayStart.getTime()) label = "Yesterday"; else if (t >= weekStart.getTime()) label = "This Week"; else label = "Older"; if (!buckets[label]) buckets[label] = []; buckets[label].push(g); } for (const label of ["Today", "Yesterday", "This Week", "Older"]) { if (buckets[label]?.length) { timeGroups.push({ label, groups: buckets[label] }); } } return timeGroups; } private _buildSummary(entries: HistoryEntry[]): string { const actions: Record = {}; for (const e of entries) { const msg = e.message.toLowerCase(); if (msg.startsWith("update shape")) actions["moved/edited shapes"] = (actions["moved/edited shapes"] || 0) + 1; else if (msg.startsWith("update layer")) actions["updated layers"] = (actions["updated layers"] || 0) + 1; else if (msg.startsWith("update")) actions["updated items"] = (actions["updated items"] || 0) + 1; else if (msg.startsWith("add shape") || msg.startsWith("add ")) actions["added"] = (actions["added"] || 0) + 1; else if (msg.startsWith("delete") || msg.startsWith("remove")) actions["removed"] = (actions["removed"] || 0) + 1; else if (msg.startsWith("forget")) actions["forgot"] = (actions["forgot"] || 0) + 1; else if (msg.startsWith("remember")) actions["restored"] = (actions["restored"] || 0) + 1; else if (msg.startsWith("undo")) actions["undid"] = (actions["undid"] || 0) + 1; else if (msg.startsWith("redo")) actions["redid"] = (actions["redid"] || 0) + 1; else actions["changed"] = (actions["changed"] || 0) + 1; } return Object.entries(actions) .map(([action, count]) => `${action}: ${count}`) .join(" · "); } /** Turn raw Automerge change messages into human-readable text with icons. */ private _humanizeMessage(msg: string): { icon: string; text: string } { // Strip shape/layer/flow UUIDs for readability const short = msg.replace(/\s+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "") .replace(/\s+[a-z]+-[a-z0-9]{6,}/gi, ""); // short IDs like folk-abc123 const m = msg.toLowerCase(); if (m.startsWith("update shape")) return { icon: "✏️", text: "Edited shape" }; if (m.startsWith("add shape")) return { icon: "➕", text: "Added shape" }; if (m.startsWith("delete shape")) return { icon: "🗑", text: "Deleted shape" }; if (m.startsWith("forget shape")) return { icon: "👻", text: "Forgot shape" }; if (m.startsWith("remember shape")) return { icon: "💡", text: "Restored shape" }; if (m.startsWith("undo delete")) return { icon: "↩️", text: "Undid delete" }; if (m.startsWith("undo")) return { icon: "↩️", text: "Undo" }; if (m.startsWith("redo")) return { icon: "↪️", text: "Redo" }; if (m.startsWith("add layer")) return { icon: "📑", text: "Added layer" }; if (m.startsWith("remove layer")) return { icon: "📑", text: "Removed layer" }; if (m.startsWith("update layer")) return { icon: "📑", text: "Updated layer" }; if (m.startsWith("add flow")) return { icon: "🔀", text: "Added flow" }; if (m.startsWith("remove flow")) return { icon: "🔀", text: "Removed flow" }; if (m.startsWith("add connection")) return { icon: "🔗", text: "Added connection" }; if (m.startsWith("remove connection")) return { icon: "🔗", text: "Removed connection" }; if (m.startsWith("event:")) return { icon: "⚡", text: short.trim() }; if (m.startsWith("subscribe")) return { icon: "📡", text: "Subscribed shape" }; if (m.startsWith("set view mode")) return { icon: "👁", text: short.trim() }; if (m.startsWith("initialize")) return { icon: "🏗", text: "Initialized space" }; return { icon: "·", text: short.trim() || msg }; } // ── Time Machine ── private _onScrub(index: number) { if (!this._doc || this._entries.length === 0) return; this._timeMachineIndex = index; try { const changes = Automerge.getHistory(this._doc); if (index >= 0 && index < changes.length) { // Get heads at this point in history const hash = changes[index].change.hash; if (hash) { this._timeMachineSnapshot = Automerge.view(this._doc, [hash]); } } } catch (e) { console.warn("[HistoryPanel] Time machine error:", e); } this._renderTimeMachineDetails(); } // ── Rendering ── private _render() { if (!this.shadowRoot) return; if (!this._open) { this.shadowRoot.innerHTML = ""; return; } const timeGroups = this._groupEntries(); const activityHTML = timeGroups.length === 0 ? '
No history available yet. Make some changes to see them here.
' : timeGroups.map(tg => `
${tg.label}
${tg.groups.map(g => `
${this._initial(g.username)}
${this._esc(g.username)} ${this._relativeTime(g.timeRange.end)}
${this._esc(g.summary)}
${g.entries.slice(0, 8).map(e => { const h = this._humanizeMessage(e.message); const t = e.time ? this._shortTime(e.time) : ""; return `
${h.icon} ${this._esc(h.text)} ${t}
`; }).join("")} ${g.entries.length > 8 ? `
+${g.entries.length - 8} more
` : ""}
`).join("")}
`).join(""); const totalChanges = this._entries.length; const scrubberMax = Math.max(0, totalChanges - 1); const scrubberVal = this._timeMachineIndex >= 0 ? this._timeMachineIndex : scrubberMax; const timeMachineHTML = totalChanges === 0 ? '
No history to scrub. Make some changes first.
' : `
Drag the slider to view document state at any point in time.
`; this.shadowRoot.innerHTML = `

History

${this._tab === "activity" ? activityHTML : timeMachineHTML}
${this._entries.length > this._visibleCount && this._tab === "activity" ? `
` : ""}
`; this._bindEvents(); } private _renderTimeMachineDetails() { const details = this.shadowRoot?.getElementById("tm-details"); if (!details) return; const entry = this._entries[this._timeMachineIndex]; if (!entry) { details.innerHTML = '
Select a point in history.
'; return; } const parsed = parseChangeMessage(entry.message); // Show snapshot info const shapeCount = this._timeMachineSnapshot?.shapes ? Object.keys(this._timeMachineSnapshot.shapes).length : 0; details.innerHTML = `
Time ${entry.time ? new Date(entry.time).toLocaleString() : "Unknown"}
Author ${this._esc(entry.username)}
Message ${this._esc(entry.message)}
Operations ${entry.opsCount}
${this._timeMachineSnapshot ? `
Shapes at this point ${shapeCount}
` : ""}
`; // Update the label const label = this.shadowRoot?.querySelector(".time-machine__label"); if (label) label.textContent = `Change ${this._timeMachineIndex + 1} of ${this._entries.length}`; } private _bindEvents() { const sr = this.shadowRoot!; sr.getElementById("close-btn")?.addEventListener("click", () => this.close()); // Tab switching sr.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => { this._tab = (btn as HTMLElement).dataset.tab as "activity" | "timemachine"; this._render(); }); }); // Load more sr.getElementById("load-more-btn")?.addEventListener("click", () => { this._visibleCount += 50; this._render(); }); // Time machine scrubber const scrubber = sr.getElementById("tm-scrubber") as HTMLInputElement; if (scrubber) { scrubber.addEventListener("input", () => { this._onScrub(parseInt(scrubber.value, 10)); }); } // Revert to history point sr.getElementById("revert-btn")?.addEventListener("click", () => { const entry = this._entries[this._timeMachineIndex]; if (!entry?.hash) return; if (!confirm(`Revert to change from ${new Date(entry.time).toLocaleString()} by ${entry.username}?\n\nThis creates a new change that restores the document to that state.`)) return; this.dispatchEvent(new CustomEvent("revert-requested", { bubbles: true, composed: true, detail: { hash: entry.hash, index: this._timeMachineIndex }, })); this.close(); }); } // ── Helpers ── private _initial(name: string): string { return (name.charAt(0) || "?").toUpperCase(); } private _relativeTime(ts: number): string { if (!ts) return ""; const diff = Date.now() - ts; if (diff < 60_000) return "just now"; if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; return new Date(ts).toLocaleDateString(); } private _shortTime(ts: number): string { if (!ts) return ""; const d = new Date(ts); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } private _esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } } // ── CSS ── const PANEL_CSS = ` :host { display: contents; } .panel { position: fixed; width: min(420px, 92vw); max-height: calc(100vh - 72px); background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, #334155); border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); z-index: 200001; display: flex; flex-direction: column; animation: dropDown 0.2s ease; color: var(--rs-text-primary, #e2e8f0); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } @keyframes dropDown { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } } .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--rs-btn-secondary-bg, #334155); flex-shrink: 0; } .panel-header h2 { margin: 0; font-size: 1.1rem; font-weight: 600; background: linear-gradient(135deg, #14b8a6, #22d3ee); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .close-btn { background: none; border: none; color: var(--rs-text-muted, #64748b); font-size: 1.5rem; cursor: pointer; padding: 4px 8px; border-radius: 4px; line-height: 1; } .close-btn:hover { color: var(--rs-text-primary); background: var(--rs-btn-secondary-bg); } /* Tabs */ .tabs { display: flex; border-bottom: 1px solid var(--rs-btn-secondary-bg, #334155); flex-shrink: 0; } .tab { flex: 1; padding: 10px 16px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--rs-text-secondary, #94a3b8); font-size: 0.82rem; font-weight: 600; cursor: pointer; transition: color 0.15s, border-color 0.15s; } .tab:hover { color: var(--rs-text-primary); } .tab.active { color: #14b8a6; border-bottom-color: #14b8a6; } .panel-content { flex: 1; overflow-y: auto; padding: 0; } .empty-state { padding: 32px 20px; text-align: center; color: var(--rs-text-muted, #64748b); font-size: 0.85rem; line-height: 1.5; } /* Time groups */ .time-group { padding: 0; } .time-group__label { position: sticky; top: 0; padding: 8px 20px; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--rs-text-muted, #64748b); background: var(--rs-bg-surface, #1e293b); border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); z-index: 1; } /* Activity groups */ .activity-group { padding: 12px 20px; border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); transition: background 0.15s; } .activity-group:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.03)); } .activity-group__header { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } .activity-group__avatar { width: 28px; height: 28px; border-radius: 50%; background: rgba(20,184,166,0.15); color: #14b8a6; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.75rem; flex-shrink: 0; } .activity-group__info { display: flex; align-items: baseline; gap: 8px; flex: 1; min-width: 0; } .activity-group__user { font-size: 0.82rem; font-weight: 600; color: var(--rs-text-primary); } .activity-group__time { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); } .activity-group__summary { font-size: 0.78rem; color: var(--rs-text-secondary, #94a3b8); margin-bottom: 6px; padding-left: 38px; } .activity-group__details { padding-left: 38px; } .activity-entry { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 0.75rem; color: var(--rs-text-secondary, #94a3b8); } .activity-entry__icon { flex-shrink: 0; width: 18px; text-align: center; font-size: 0.7rem; } .activity-entry__msg { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .activity-entry__time { flex-shrink: 0; font-size: 0.65rem; color: var(--rs-text-muted, #64748b); opacity: 0.7; } .activity-entry--more { color: #14b8a6; font-style: italic; } /* Time Machine */ .time-machine { padding: 20px; } .time-machine__scrubber { margin-bottom: 20px; } .time-machine__label { display: block; font-size: 0.78rem; font-weight: 600; color: var(--rs-text-secondary); margin-bottom: 8px; } .time-machine__range { width: 100%; accent-color: #14b8a6; cursor: pointer; } .time-machine__details { min-height: 100px; } .tm-info { display: flex; flex-direction: column; gap: 8px; } .tm-info__row { display: flex; align-items: flex-start; gap: 12px; } .tm-info__label { flex: 0 0 90px; font-size: 0.72rem; font-weight: 600; color: var(--rs-text-muted, #64748b); text-transform: uppercase; letter-spacing: 0.04em; } .tm-info__value { flex: 1; font-size: 0.82rem; color: var(--rs-text-primary); word-break: break-word; } .revert-btn { width: 100%; margin-top: 16px; padding: 10px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); color: #ef4444; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: background 0.15s; } .revert-btn:hover { background: rgba(239, 68, 68, 0.2); } /* Load more */ .load-more { padding: 12px 20px; border-top: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); flex-shrink: 0; } .load-more__btn { width: 100%; padding: 8px; background: rgba(20,184,166,0.1); border: 1px solid rgba(20,184,166,0.2); color: #14b8a6; border-radius: 8px; font-size: 0.78rem; font-weight: 600; cursor: pointer; transition: background 0.15s; } .load-more__btn:hover { background: rgba(20,184,166,0.2); } `;