rspace-online/shared/components/rstack-history-panel.ts

797 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <rstack-history-panel> — 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<any> | 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<any>) {
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<string, GroupedEntry[]> = {};
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<string, number> = {};
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
? '<div class="empty-state">No history available yet. Make some changes to see them here.</div>'
: timeGroups.map(tg => `
<div class="time-group">
<div class="time-group__label">${tg.label}</div>
${tg.groups.map(g => `
<div class="activity-group">
<div class="activity-group__header">
<div class="activity-group__avatar">${this._initial(g.username)}</div>
<div class="activity-group__info">
<span class="activity-group__user">${this._esc(g.username)}</span>
<span class="activity-group__time">${this._relativeTime(g.timeRange.end)}</span>
</div>
</div>
<div class="activity-group__summary">${this._esc(g.summary)}</div>
<div class="activity-group__details">
${g.entries.slice(0, 8).map(e => {
const h = this._humanizeMessage(e.message);
const t = e.time ? this._shortTime(e.time) : "";
return `
<div class="activity-entry">
<span class="activity-entry__icon">${h.icon}</span>
<span class="activity-entry__msg">${this._esc(h.text)}</span>
<span class="activity-entry__time">${t}</span>
</div>`;
}).join("")}
${g.entries.length > 8 ? `<div class="activity-entry activity-entry--more">+${g.entries.length - 8} more</div>` : ""}
</div>
</div>
`).join("")}
</div>
`).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
? '<div class="empty-state">No history to scrub. Make some changes first.</div>'
: `
<div class="time-machine">
<div class="time-machine__scrubber">
<label class="time-machine__label">Change ${scrubberVal + 1} of ${totalChanges}</label>
<input type="range" class="time-machine__range" min="0" max="${scrubberMax}" value="${scrubberVal}" id="tm-scrubber" />
</div>
<div class="time-machine__details" id="tm-details">
<div class="empty-state">Drag the slider to view document state at any point in time.</div>
</div>
</div>
`;
this.shadowRoot.innerHTML = `
<style>${PANEL_CSS}</style>
<div class="panel">
<div class="panel-header">
<h2>History</h2>
<button class="close-btn" id="close-btn">&times;</button>
</div>
<div class="tabs">
<button class="tab ${this._tab === "activity" ? "active" : ""}" data-tab="activity">Activity</button>
<button class="tab ${this._tab === "timemachine" ? "active" : ""}" data-tab="timemachine">Time Machine</button>
</div>
<div class="panel-content">
${this._tab === "activity" ? activityHTML : timeMachineHTML}
</div>
${this._entries.length > this._visibleCount && this._tab === "activity" ? `
<div class="load-more">
<button class="load-more__btn" id="load-more-btn">Load more</button>
</div>
` : ""}
</div>
`;
this._bindEvents();
}
private _renderTimeMachineDetails() {
const details = this.shadowRoot?.getElementById("tm-details");
if (!details) return;
const entry = this._entries[this._timeMachineIndex];
if (!entry) {
details.innerHTML = '<div class="empty-state">Select a point in history.</div>';
return;
}
const parsed = parseChangeMessage(entry.message);
// Show snapshot info
const shapeCount = this._timeMachineSnapshot?.shapes
? Object.keys(this._timeMachineSnapshot.shapes).length
: 0;
details.innerHTML = `
<div class="tm-info">
<div class="tm-info__row">
<span class="tm-info__label">Time</span>
<span class="tm-info__value">${entry.time ? new Date(entry.time).toLocaleString() : "Unknown"}</span>
</div>
<div class="tm-info__row">
<span class="tm-info__label">Author</span>
<span class="tm-info__value">${this._esc(entry.username)}</span>
</div>
<div class="tm-info__row">
<span class="tm-info__label">Message</span>
<span class="tm-info__value">${this._esc(entry.message)}</span>
</div>
<div class="tm-info__row">
<span class="tm-info__label">Operations</span>
<span class="tm-info__value">${entry.opsCount}</span>
</div>
${this._timeMachineSnapshot ? `
<div class="tm-info__row">
<span class="tm-info__label">Shapes at this point</span>
<span class="tm-info__value">${shapeCount}</span>
</div>
` : ""}
</div>
<button class="revert-btn" id="revert-btn">Revert to this point</button>
`;
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
// ── 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);
}
`;