<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>ChronoCode Replay</title>
<script src="https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
background: #0d1117;
color: #c9d1d9;
font-size: 13px;
}
.upload-screen {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0d1117 0%, #161b22 100%);
}
.upload-box {
background: #161b22;
border: 2px dashed #30363d;
border-radius: 12px;
padding: 60px 80px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-box:hover {
border-color: #58a6ff;
background: #1c2128;
}
.upload-box.dragover {
border-color: #58a6ff;
background: rgba(88, 166, 255, 0.1);
}
.upload-box h2 {
color: #58a6ff;
font-size: 1.5em;
margin-bottom: 8px;
font-weight: 500;
}
.upload-box p {
color: #8b949e;
font-size: 0.9em;
}
input[type="file"] {
display: none;
}
.player {
display: none;
height: 100vh;
grid-template-rows: auto auto 1fr;
grid-template-columns: 1fr 320px;
gap: 0;
}
.player.active {
display: grid;
}
.top-bar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #161b22;
border-bottom: 1px solid #30363d;
gap: 16px;
}
.logo {
font-weight: 600;
color: #58a6ff;
font-size: 14px;
white-space: nowrap;
}
.stats-row {
display: flex;
gap: 24px;
align-items: center;
}
.stat {
display: flex;
align-items: center;
gap: 6px;
}
.stat-icon {
font-size: 12px;
}
.stat-value {
font-weight: 600;
font-size: 14px;
}
.stat-label {
color: #8b949e;
font-size: 11px;
}
.stat .stat-value { color: #58a6ff; }
.stat-created .stat-value { color: #3fb950; }
.stat-modified .stat-value { color: #d29922; }
.stat-deleted .stat-value { color: #f85149; }
.controls {
display: flex;
align-items: center;
gap: 8px;
}
.btn {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
display: flex;
align-items: center;
gap: 4px;
transition: background 0.15s;
}
.btn:hover {
background: #30363d;
}
.btn-primary {
background: #238636;
border-color: #238636;
}
.btn-primary:hover {
background: #2ea043;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.step-display {
color: #8b949e;
font-size: 12px;
min-width: 70px;
text-align: center;
}
.speed-select {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.timeline {
grid-column: 1 / -1;
padding: 12px 16px;
background: #161b22;
border-bottom: 1px solid #30363d;
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-controls {
display: flex;
align-items: center;
gap: 12px;
}
.time-label {
font-size: 11px;
color: #8b949e;
min-width: 50px;
}
.time-label.right {
text-align: right;
}
.scrubber-container {
flex: 1;
position: relative;
height: 24px;
display: flex;
align-items: center;
}
.scrubber-track {
width: 100%;
height: 8px;
background: #21262d;
border-radius: 4px;
cursor: pointer;
position: relative;
}
.scrubber-fill {
height: 100%;
background: linear-gradient(90deg, #238636, #3fb950);
border-radius: 4px;
transition: width 0.1s linear;
pointer-events: none;
}
.scrubber-handle {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
background: #58a6ff;
border: 2px solid #0d1117;
border-radius: 50%;
cursor: grab;
z-index: 10;
transition: transform 0.1s;
}
.scrubber-handle:hover {
transform: translate(-50%, -50%) scale(1.2);
}
.scrubber-handle:active {
cursor: grabbing;
}
.event-markers {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
pointer-events: none;
}
.event-marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 4px;
border-radius: 50%;
opacity: 0.7;
}
.event-marker.created { background: #3fb950; }
.event-marker.modified { background: #d29922; }
.event-marker.deleted { background: #f85149; }
.main-content {
overflow: hidden;
display: grid;
grid-template-rows: 1fr 1fr;
background: #0d1117;
}
.main-content.no-preview {
grid-template-rows: 1fr;
}
.main-content.no-preview .content-panel {
display: none;
}
.file-tree {
overflow-y: auto;
padding: 8px 0;
border-bottom: 1px solid #30363d;
}
.main-content.no-preview .file-tree {
border-bottom: none;
}
.file-tree::-webkit-scrollbar {
width: 8px;
}
.file-tree::-webkit-scrollbar-track {
background: transparent;
}
.file-tree::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
.tree-row {
display: flex;
align-items: center;
padding: 3px 16px;
cursor: pointer;
white-space: nowrap;
transition: background 0.3s, opacity 0.3s;
}
.tree-row:hover {
background: #161b22;
}
.tree-row.selected {
background: #1f6feb33;
}
.tree-row.is-new {
animation: flash-green 1s ease-out;
}
.tree-row.is-modified {
animation: flash-yellow 1s ease-out;
}
@keyframes flash-green {
0% { background: rgba(63, 185, 80, 0.6); }
100% { background: transparent; }
}
@keyframes flash-yellow {
0% { background: rgba(210, 153, 34, 0.6); }
100% { background: transparent; }
}
.tree-indent {
display: inline-block;
width: 20px;
color: #484f58;
}
.tree-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: #484f58;
transition: transform 0.15s ease, color 0.15s;
flex-shrink: 0;
cursor: pointer;
border-radius: 3px;
margin-right: 2px;
}
.tree-chevron:hover {
color: #c9d1d9;
background: #30363d;
}
.tree-chevron.expanded {
transform: rotate(90deg);
}
.tree-chevron.collapsed {
transform: rotate(0deg);
}
.tree-chevron-spacer {
display: inline-block;
width: 18px;
flex-shrink: 0;
}
.tree-icon {
width: 20px;
text-align: center;
flex-shrink: 0;
}
.tree-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 8px;
}
.tree-name.is-dir {
color: #58a6ff;
}
.tree-size {
color: #8b949e;
font-size: 11px;
width: 60px;
text-align: right;
margin-right: 8px;
flex-shrink: 0;
}
.tree-loc {
color: #6e7681;
font-size: 11px;
width: 50px;
text-align: right;
margin-right: 8px;
flex-shrink: 0;
}
.tree-status {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
width: 40px;
text-align: center;
flex-shrink: 0;
}
.tree-status.created {
background: rgba(63, 185, 80, 0.2);
color: #3fb950;
}
.tree-status.modified {
background: rgba(210, 153, 34, 0.2);
color: #d29922;
}
.tree-status.deleted {
background: rgba(248, 81, 73, 0.2);
color: #f85149;
}
.content-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d1117;
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #161b22;
border-bottom: 1px solid #30363d;
}
.content-title {
font-size: 12px;
color: #8b949e;
display: flex;
align-items: center;
gap: 8px;
}
.content-title .filename {
color: #58a6ff;
font-weight: 500;
}
.content-tabs {
display: flex;
gap: 4px;
}
.content-tab {
padding: 4px 12px;
font-size: 11px;
background: transparent;
border: 1px solid transparent;
color: #8b949e;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.content-tab:hover {
color: #c9d1d9;
}
.content-tab.active {
background: #21262d;
border-color: #30363d;
color: #c9d1d9;
}
.content-body {
flex: 1;
overflow: auto;
padding: 12px 16px;
}
.content-body::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.content-body::-webkit-scrollbar-track {
background: transparent;
}
.content-body::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
.code-view {
font-family: inherit;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.code-line {
display: flex;
}
.line-number {
color: #484f58;
text-align: right;
padding-right: 16px;
user-select: none;
min-width: 40px;
}
.line-content {
flex: 1;
}
.diff-view .line-added {
background: rgba(63, 185, 80, 0.15);
}
.diff-view .line-added .line-content {
color: #3fb950;
}
.diff-view .line-removed {
background: rgba(248, 81, 73, 0.15);
}
.diff-view .line-removed .line-content {
color: #f85149;
}
.diff-view .line-context {
color: #8b949e;
}
.diff-header {
padding: 8px 0;
margin-bottom: 8px;
border-bottom: 1px solid #30363d;
color: #8b949e;
font-size: 11px;
}
.diff-stats {
display: flex;
gap: 16px;
}
.diff-stats .added { color: #3fb950; }
.diff-stats .removed { color: #f85149; }
.no-content {
color: #484f58;
font-style: italic;
padding: 20px;
text-align: center;
}
.structure-view {
font-size: 12px;
}
.structure-section {
margin-bottom: 16px;
}
.structure-section-title {
color: #8b949e;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid #21262d;
}
.structure-item {
display: flex;
align-items: center;
padding: 6px 8px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.structure-item:hover {
background: #21262d;
}
.structure-item.modified {
background: rgba(210, 153, 34, 0.15);
border-left: 2px solid #d29922;
}
.structure-item.added {
background: rgba(63, 185, 80, 0.15);
border-left: 2px solid #3fb950;
}
.structure-icon {
width: 20px;
margin-right: 8px;
text-align: center;
font-size: 14px;
}
.structure-name {
flex: 1;
color: #c9d1d9;
}
.structure-name .params {
color: #8b949e;
}
.structure-line {
color: #484f58;
font-size: 10px;
margin-left: 8px;
}
.structure-activity {
width: 40px;
height: 8px;
background: #21262d;
border-radius: 4px;
overflow: hidden;
margin-left: 8px;
}
.structure-activity-bar {
height: 100%;
background: linear-gradient(90deg, #238636, #d29922);
border-radius: 4px;
}
.structure-summary {
background: #161b22;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.structure-summary-title {
color: #58a6ff;
font-weight: 500;
margin-bottom: 8px;
}
.structure-summary-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.structure-stat {
display: flex;
align-items: center;
gap: 4px;
}
.structure-stat-value {
font-weight: 600;
}
.structure-stat-label {
color: #8b949e;
font-size: 11px;
}
.structure-empty {
color: #484f58;
font-style: italic;
text-align: center;
padding: 20px;
}
.structure-item.nested {
margin-left: 20px;
}
.structure-item.nested-2 {
margin-left: 40px;
}
.event-panel {
background: #161b22;
border-left: 1px solid #30363d;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
padding: 10px 12px;
font-size: 12px;
font-weight: 600;
color: #8b949e;
border-bottom: 1px solid #30363d;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header span {
color: #58a6ff;
}
.event-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.event-list::-webkit-scrollbar {
width: 6px;
}
.event-list::-webkit-scrollbar-track {
background: transparent;
}
.event-list::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
.event-row {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 8px;
font-size: 12px;
border-bottom: 1px solid #21262d;
cursor: pointer;
transition: background 0.15s;
}
.event-row:hover {
background: #21262d;
}
.event-row.current {
background: rgba(88, 166, 255, 0.1);
border-left: 2px solid #58a6ff;
}
.event-row:last-child {
border-bottom: none;
}
.event-type {
font-size: 14px;
width: 20px;
text-align: center;
}
.event-path {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #c9d1d9;
}
.event-row.type-created .event-path { color: #3fb950; }
.event-row.type-modified .event-path { color: #d29922; }
.event-row.type-deleted .event-path { color: #f85149; }
.event-time {
color: #484f58;
font-size: 10px;
}
.tree-header {
display: flex;
align-items: center;
padding: 4px 16px;
font-size: 11px;
color: #484f58;
border-bottom: 1px solid #21262d;
position: sticky;
top: 0;
background: #0d1117;
z-index: 1;
user-select: none;
}
.tree-header-name {
flex: 1;
}
.tree-header-size {
width: 60px;
text-align: right;
margin-right: 8px;
}
.tree-header-loc {
width: 50px;
text-align: right;
margin-right: 8px;
}
.tree-header-status {
width: 40px;
text-align: center;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #484f58;
font-size: 13px;
}
.kbd-hints {
grid-column: 1 / -1;
padding: 6px 16px;
background: #0d1117;
border-top: 1px solid #30363d;
display: flex;
gap: 16px;
font-size: 11px;
color: #484f58;
}
kbd {
background: #21262d;
border: 1px solid #30363d;
border-radius: 3px;
padding: 2px 5px;
font-family: inherit;
font-size: 10px;
color: #8b949e;
}
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: #238636;
color: #fff;
padding: 10px 24px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
z-index: 1000;
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
pointer-events: none;
}
.toast.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.toast.error {
background: #f85149;
}
.share-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 999;
display: none;
align-items: center;
justify-content: center;
}
.share-overlay.visible {
display: flex;
}
.share-modal {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 24px;
max-width: 520px;
width: 90%;
}
.share-modal h3 {
color: #58a6ff;
font-size: 15px;
margin-bottom: 12px;
}
.share-modal p {
color: #8b949e;
font-size: 12px;
margin-bottom: 12px;
}
.share-url-box {
display: flex;
gap: 8px;
}
.share-url-box input {
flex: 1;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
padding: 8px 12px;
font-family: inherit;
font-size: 12px;
}
.share-url-box input:focus {
outline: none;
border-color: #58a6ff;
}
.share-size {
color: #484f58;
font-size: 11px;
margin-top: 8px;
}
.share-size.warn {
color: #d29922;
}
.share-size.error {
color: #f85149;
}
@media (max-width: 900px) {
.player {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr auto;
}
.event-panel {
border-left: none;
border-top: 1px solid #30363d;
max-height: 200px;
}
}
</style>
</head>
<body>
<div class="upload-screen" id="uploadScreen">
<div class="upload-box" id="uploadBox">
<h2>Drop Recording File</h2>
<p>or click to browse (.json)</p>
<input type="file" id="fileInput" accept=".json">
</div>
</div>
<div class="player" id="player">
<div class="top-bar">
<div class="logo">ChronoCode</div>
<div class="stats-row">
<div class="stat">
<span class="stat-value" id="statFiles">0</span>
<span class="stat-label">files</span>
</div>
<div class="stat stat-created">
<span class="stat-value" id="statCreated">0</span>
<span class="stat-label">created</span>
</div>
<div class="stat stat-modified">
<span class="stat-value" id="statModified">0</span>
<span class="stat-label">modified</span>
</div>
<div class="stat stat-deleted">
<span class="stat-value" id="statDeleted">0</span>
<span class="stat-label">deleted</span>
</div>
</div>
<div class="controls">
<button class="btn" id="prevBtn" title="Previous (Left Arrow)">◀</button>
<button class="btn btn-primary" id="playBtn" title="Play/Pause (Space)">
<span id="playIcon">▶</span>
</button>
<button class="btn" id="nextBtn" title="Next (Right Arrow)">▶</button>
<span class="step-display" id="stepDisplay">0 / 0</span>
<select class="speed-select" id="speedSelect" title="Events per second">
<option value="1">1/sec</option>
<option value="2">2/sec</option>
<option value="5" selected>5/sec</option>
<option value="10">10/sec</option>
<option value="20">20/sec</option>
</select>
<button class="btn" id="resetBtn" title="Reset (R)">Reset</button>
<button class="btn" id="togglePreviewBtn" title="Toggle Preview (P)">Preview</button>
<button class="btn" id="shareBtn" title="Share recording URL (S)">Share</button>
<button class="btn" id="loadNewBtn" title="Load new file">Load New</button>
</div>
</div>
<div class="timeline">
<div class="timeline-controls">
<span class="time-label" id="currentTime">0:00</span>
<div class="scrubber-container">
<div class="scrubber-track" id="scrubberTrack">
<div class="event-markers" id="eventMarkers"></div>
<div class="scrubber-fill" id="scrubberFill" style="width: 0%"></div>
<div class="scrubber-handle" id="scrubberHandle" style="left: 0%"></div>
</div>
</div>
<span class="time-label right" id="totalTime">0:00</span>
</div>
</div>
<div class="main-content" id="mainContent">
<div class="file-tree" id="fileTree">
<div class="tree-header" id="treeHeader" style="display: none;">
<span class="tree-header-name">Name</span>
<span class="tree-header-size">Size</span>
<span class="tree-header-loc">LOC</span>
<span class="tree-header-status">Status</span>
</div>
<div class="empty-state" id="emptyState">No files yet</div>
</div>
<div class="content-panel" id="contentPanel">
<div class="content-header">
<div class="content-title">
<span>File:</span>
<span class="filename" id="previewFilename">No file selected</span>
</div>
<div class="content-tabs">
<button class="content-tab active" id="tabContent" data-tab="content">Content</button>
<button class="content-tab" id="tabDiff" data-tab="diff">Diff</button>
<button class="content-tab" id="tabStructure" data-tab="structure">Structure</button>
</div>
</div>
<div class="content-body" id="contentBody">
<div class="no-content">Click a file to view its content</div>
</div>
</div>
</div>
<div class="event-panel">
<div class="panel-header">
Events <span id="eventCount">0</span>
</div>
<div class="event-list" id="eventList"></div>
</div>
<div class="kbd-hints">
<span><kbd>Space</kbd> Play/Pause</span>
<span><kbd>←</kbd><kbd>→</kbd> Step</span>
<span><kbd>R</kbd> Reset</span>
<span><kbd>P</kbd> Toggle Preview</span>
<span><kbd>S</kbd> Share</span>
<span>Click folder to collapse/expand</span>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="share-overlay" id="shareOverlay">
<div class="share-modal">
<h3>Share Recording</h3>
<p>Anyone with this URL can view the recording in their browser.</p>
<div class="share-url-box">
<input type="text" id="shareUrlInput" readonly>
<button class="btn btn-primary" id="copyShareBtn">Copy</button>
</div>
<div class="share-size" id="shareSize"></div>
</div>
</div>
<script>
class ChronoCodePlayer {
constructor() {
this.initElements();
this.resetUI();
this.bindEvents();
this.loadFromUrlHash();
}
resetUI() {
this.initialState = [];
this.events = [];
this.currentState = new Map();
this.contentHistory = new Map();
this.recentlyChanged = new Set();
this.isPlaying = false;
this.currentEventIndex = 0;
this.eventsPerSecond = 5;
this.selectedFile = null;
this.showPreview = false;
this.isDragging = false;
this.activeTab = 'content';
this.collapsedFolders = new Set();
this.rawData = null;
if (this.playbackTimer) {
clearInterval(this.playbackTimer);
this.playbackTimer = null;
}
this.fileInput.value = '';
this.uploadScreen.style.display = 'flex';
this.player.classList.remove('active');
this.speedSelect.value = '5';
this.scrubberFill.style.width = '0%';
this.scrubberHandle.style.left = '0%';
this.currentTimeEl.textContent = '0:00';
this.totalTimeEl.textContent = '0:00';
this.stepDisplay.textContent = '0 / 0';
this.eventList.innerHTML = '';
this.fileTree.querySelectorAll('.tree-row').forEach(el => el.remove());
this.emptyState.style.display = 'flex';
this.statFiles.textContent = '0';
this.statCreated.textContent = '0';
this.statModified.textContent = '0';
this.statDeleted.textContent = '0';
this.playIcon.innerHTML = '▶';
this.prevBtn.disabled = true;
this.nextBtn.disabled = true;
this.eventMarkers.innerHTML = '';
this.mainContent.classList.add('no-preview');
this.togglePreviewBtn.style.background = '';
this.previewFilename.textContent = 'No file selected';
this.contentBody.innerHTML = '<div class="no-content">Click a file to view its content</div>';
this.tabContent.classList.add('active');
this.tabDiff.classList.remove('active');
this.tabStructure.classList.remove('active');
}
initElements() {
this.uploadScreen = document.getElementById('uploadScreen');
this.uploadBox = document.getElementById('uploadBox');
this.fileInput = document.getElementById('fileInput');
this.player = document.getElementById('player');
this.playBtn = document.getElementById('playBtn');
this.playIcon = document.getElementById('playIcon');
this.prevBtn = document.getElementById('prevBtn');
this.nextBtn = document.getElementById('nextBtn');
this.resetBtn = document.getElementById('resetBtn');
this.loadNewBtn = document.getElementById('loadNewBtn');
this.togglePreviewBtn = document.getElementById('togglePreviewBtn');
this.speedSelect = document.getElementById('speedSelect');
this.stepDisplay = document.getElementById('stepDisplay');
this.scrubberTrack = document.getElementById('scrubberTrack');
this.scrubberFill = document.getElementById('scrubberFill');
this.scrubberHandle = document.getElementById('scrubberHandle');
this.eventMarkers = document.getElementById('eventMarkers');
this.currentTimeEl = document.getElementById('currentTime');
this.totalTimeEl = document.getElementById('totalTime');
this.mainContent = document.getElementById('mainContent');
this.fileTree = document.getElementById('fileTree');
this.treeHeader = document.getElementById('treeHeader');
this.emptyState = document.getElementById('emptyState');
this.eventList = document.getElementById('eventList');
this.eventCount = document.getElementById('eventCount');
this.contentPanel = document.getElementById('contentPanel');
this.previewFilename = document.getElementById('previewFilename');
this.contentBody = document.getElementById('contentBody');
this.tabContent = document.getElementById('tabContent');
this.tabDiff = document.getElementById('tabDiff');
this.tabStructure = document.getElementById('tabStructure');
this.structureAnalyzer = new CodeStructureAnalyzer();
this.statFiles = document.getElementById('statFiles');
this.statCreated = document.getElementById('statCreated');
this.statModified = document.getElementById('statModified');
this.statDeleted = document.getElementById('statDeleted');
this.shareBtn = document.getElementById('shareBtn');
this.shareOverlay = document.getElementById('shareOverlay');
this.shareUrlInput = document.getElementById('shareUrlInput');
this.copyShareBtn = document.getElementById('copyShareBtn');
this.shareSize = document.getElementById('shareSize');
this.toast = document.getElementById('toast');
}
bindEvents() {
this.uploadBox.addEventListener('click', () => this.fileInput.click());
this.fileInput.addEventListener('change', (e) => this.handleFile(e.target.files[0]));
this.uploadBox.addEventListener('dragover', (e) => {
e.preventDefault();
this.uploadBox.classList.add('dragover');
});
this.uploadBox.addEventListener('dragleave', () => {
this.uploadBox.classList.remove('dragover');
});
this.uploadBox.addEventListener('drop', (e) => {
e.preventDefault();
this.uploadBox.classList.remove('dragover');
if (e.dataTransfer.files.length) {
this.handleFile(e.dataTransfer.files[0]);
}
});
this.playBtn.addEventListener('click', () => this.togglePlay());
this.prevBtn.addEventListener('click', () => this.stepBackward());
this.nextBtn.addEventListener('click', () => this.stepForward());
this.resetBtn.addEventListener('click', () => this.reset());
this.loadNewBtn.addEventListener('click', () => this.loadNew());
this.togglePreviewBtn.addEventListener('click', () => this.togglePreview());
this.speedSelect.addEventListener('change', (e) => {
this.eventsPerSecond = parseFloat(e.target.value);
if (this.isPlaying) {
this.pause();
this.play();
}
});
this.shareBtn.addEventListener('click', () => this.showShareModal());
this.copyShareBtn.addEventListener('click', () => this.copyShareUrl());
this.shareOverlay.addEventListener('click', (e) => {
if (e.target === this.shareOverlay) this.shareOverlay.classList.remove('visible');
});
this.scrubberTrack.addEventListener('mousedown', (e) => this.startScrubbing(e));
document.addEventListener('mousemove', (e) => this.scrub(e));
document.addEventListener('mouseup', () => this.stopScrubbing());
this.tabContent.addEventListener('click', () => this.switchTab('content'));
this.tabDiff.addEventListener('click', () => this.switchTab('diff'));
this.tabStructure.addEventListener('click', () => this.switchTab('structure'));
document.addEventListener('keydown', (e) => {
if (!this.player.classList.contains('active')) return;
if (e.target.tagName === 'SELECT') return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch(e.key) {
case ' ':
e.preventDefault();
this.togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
this.stepBackward();
break;
case 'ArrowRight':
e.preventDefault();
this.stepForward();
break;
case 'r':
case 'R':
e.preventDefault();
this.reset();
break;
case 'p':
case 'P':
e.preventDefault();
this.togglePreview();
break;
case 's':
case 'S':
e.preventDefault();
this.showShareModal();
break;
case 'Escape':
this.shareOverlay.classList.remove('visible');
break;
}
});
}
handleFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
this.loadSession(data);
} catch (err) {
alert('Invalid JSON file');
}
this.fileInput.value = '';
};
reader.readAsText(file);
}
loadNew() {
this.pause();
this.initialState = [];
this.events = [];
this.currentState.clear();
this.contentHistory.clear();
this.recentlyChanged.clear();
this.currentEventIndex = 0;
this.selectedFile = null;
this.player.classList.remove('active');
this.uploadScreen.style.display = 'flex';
this.eventList.innerHTML = '';
this.fileTree.querySelectorAll('.tree-row').forEach(el => el.remove());
this.emptyState.style.display = 'flex';
this.eventMarkers.innerHTML = '';
this.fileInput.value = '';
}
loadSession(data) {
this.rawData = data;
this.initialState = data.initial_state || [];
this.events = data.events || [];
if (!this.initialState.length && !this.events.length) {
alert('No data in recording');
return;
}
this.buildContentHistory();
this.uploadScreen.style.display = 'none';
this.player.classList.add('active');
this.renderEventMarkers();
this.updateTimeDisplay();
this.reset();
}
buildContentHistory() {
this.contentHistory.clear();
for (const item of this.initialState) {
if (item.content !== undefined) {
this.contentHistory.set(item.path, [{
eventIndex: -1,
content: item.content
}]);
}
}
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i];
if (event.content !== undefined) {
if (!this.contentHistory.has(event.path)) {
this.contentHistory.set(event.path, []);
}
this.contentHistory.get(event.path).push({
eventIndex: i,
content: event.content
});
}
}
}
getContentAtIndex(path, eventIndex) {
const history = this.contentHistory.get(path);
if (!history || history.length === 0) return null;
let content = null;
for (const entry of history) {
if (entry.eventIndex <= eventIndex) {
content = entry.content;
} else {
break;
}
}
return content;
}
getPreviousContent(path, eventIndex) {
const history = this.contentHistory.get(path);
if (!history || history.length === 0) return null;
let content = null;
for (const entry of history) {
if (entry.eventIndex < eventIndex) {
content = entry.content;
} else {
break;
}
}
return content;
}
renderEventMarkers() {
this.eventMarkers.innerHTML = '';
if (this.events.length === 0) return;
const maxTimestamp = this.events[this.events.length - 1].timestamp || 1;
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i];
const percent = (event.timestamp / maxTimestamp) * 100;
const marker = document.createElement('div');
marker.className = `event-marker ${event.event_type}`;
marker.style.left = `${percent}%`;
this.eventMarkers.appendChild(marker);
}
}
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
updateTimeDisplay() {
if (this.events.length === 0) {
this.currentTimeEl.textContent = '0:00';
this.totalTimeEl.textContent = '0:00';
return;
}
const totalTime = this.events[this.events.length - 1].timestamp || 0;
const currentTime = this.currentEventIndex > 0
? this.events[this.currentEventIndex - 1].timestamp
: 0;
this.currentTimeEl.textContent = this.formatTime(currentTime);
this.totalTimeEl.textContent = this.formatTime(totalTime);
}
startScrubbing(e) {
this.isDragging = true;
this.scrub(e);
}
scrub(e) {
if (!this.isDragging) return;
const rect = this.scrubberTrack.getBoundingClientRect();
let percent = (e.clientX - rect.left) / rect.width;
percent = Math.max(0, Math.min(1, percent));
this.seekTo(percent);
}
stopScrubbing() {
this.isDragging = false;
}
formatSize(bytes) {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes, i = 0;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`;
}
getFileIcon(name, isDir) {
if (isDir) return '📁';
const ext = name.split('.').pop().toLowerCase();
const icons = {
py: '🐍', js: '📜', ts: '📘', jsx: '📜', tsx: '📘',
json: '📋', md: '📝', txt: '📄', html: '🌐', css: '🎨',
png: '🖼', jpg: '🖼', svg: '🖼', gif: '🖼',
mp4: '🎬', mp3: '🎵', zip: '📦', tar: '📦', gz: '📦',
yaml: '⚙', yml: '⚙', toml: '⚙', lock: '🔒', pdf: '📕'
};
return icons[ext] || '📄';
}
togglePlay() {
this.isPlaying ? this.pause() : this.play();
}
play() {
if (this.currentEventIndex >= this.events.length) {
this.reset();
}
this.isPlaying = true;
this.playIcon.innerHTML = '❚❚';
const intervalMs = 1000 / this.eventsPerSecond;
this.playbackTimer = setInterval(() => this.tick(), intervalMs);
}
pause() {
this.isPlaying = false;
this.playIcon.innerHTML = '▶';
if (this.playbackTimer) {
clearInterval(this.playbackTimer);
this.playbackTimer = null;
}
}
tick() {
if (this.currentEventIndex >= this.events.length) {
this.pause();
return;
}
this.recentlyChanged.clear();
this.applyEvent(this.events[this.currentEventIndex], true, true);
this.currentEventIndex++;
this.updateDisplay();
this.updateProgress();
this.updateStepDisplay();
this.updateTimeDisplay();
this.updateContentPreview();
}
reset() {
this.pause();
this.currentEventIndex = 0;
this.currentState.clear();
this.recentlyChanged.clear();
this.eventList.innerHTML = '';
this.selectedFile = null;
this.loadInitialState();
this.updateDisplay();
this.updateProgress();
this.updateStats();
this.updateStepDisplay();
this.updateTimeDisplay();
this.clearContentPreview();
}
stepForward() {
if (this.isPlaying) this.pause();
if (this.currentEventIndex < this.events.length) {
this.recentlyChanged.clear();
this.applyEvent(this.events[this.currentEventIndex], true, true);
this.currentEventIndex++;
this.updateDisplay();
this.updateProgress();
this.updateStepDisplay();
this.updateTimeDisplay();
this.updateContentPreview();
}
}
loadInitialState() {
for (const item of this.initialState) {
this.currentState.set(item.path, {
path: item.path,
size: item.size,
is_dir: item.is_dir,
loc: item.loc || 0,
status: null,
content: item.content
});
}
}
stepBackward() {
if (this.isPlaying) this.pause();
if (this.currentEventIndex > 0) {
this.currentEventIndex--;
this.currentState.clear();
this.recentlyChanged.clear();
this.eventList.innerHTML = '';
this.loadInitialState();
for (let i = 0; i < this.currentEventIndex; i++) {
this.applyEvent(this.events[i], false, false);
}
for (let i = Math.max(0, this.currentEventIndex - 100); i < this.currentEventIndex; i++) {
this.addEventLogEntry(this.events[i], i);
}
this.updateDisplay();
this.updateProgress();
this.updateStats();
this.updateStepDisplay();
this.updateTimeDisplay();
this.updateContentPreview();
}
}
seekTo(percent) {
if (this.isPlaying) this.pause();
this.currentEventIndex = Math.round(percent * this.events.length);
if (this.currentEventIndex > this.events.length) this.currentEventIndex = this.events.length;
this.currentState.clear();
this.recentlyChanged.clear();
this.eventList.innerHTML = '';
this.loadInitialState();
for (let i = 0; i < this.currentEventIndex; i++) {
this.applyEvent(this.events[i], false, false);
}
for (let i = Math.max(0, this.currentEventIndex - 100); i < this.currentEventIndex; i++) {
this.addEventLogEntry(this.events[i], i);
}
this.updateDisplay();
this.updateProgress();
this.updateStats();
this.updateStepDisplay();
this.updateTimeDisplay();
this.updateContentPreview();
}
applyEvent(event, logIt, trackChange = true) {
const path = event.path;
if (event.event_type === 'deleted') {
this.currentState.delete(path);
} else {
this.currentState.set(path, {
path,
size: event.size,
is_dir: event.is_dir,
loc: event.loc || 0,
status: event.event_type,
content: event.content
});
}
if (trackChange) {
this.recentlyChanged.add(path);
}
if (logIt) {
this.addEventLogEntry(event, this.currentEventIndex);
this.updateStats();
}
}
buildTree() {
const root = { name: '', children: new Map(), isDir: true };
for (const [path, info] of this.currentState) {
if (path === '.' || path === '') continue;
const parts = path.split('/').filter(p => p && p !== '.');
if (parts.length === 0) continue;
let current = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!current.children.has(part)) {
current.children.set(part, {
name: part,
children: new Map(),
isDir: true,
path: parts.slice(0, i + 1).join('/')
});
}
current = current.children.get(part);
}
const name = parts[parts.length - 1];
const existing = current.children.get(name);
if (existing) {
existing.isDir = info.is_dir;
existing.path = path;
existing.size = info.size;
existing.loc = info.loc;
existing.status = info.status;
existing.content = info.content;
} else {
current.children.set(name, {
name: name,
children: new Map(),
isDir: info.is_dir,
path: path,
size: info.size,
loc: info.loc,
status: info.status,
content: info.content
});
}
}
return root;
}
flattenTree(node, depth = 0, result = []) {
const sorted = Array.from(node.children.values()).sort((a, b) => {
if (a.isDir !== b.isDir) return b.isDir - a.isDir;
return a.name.localeCompare(b.name);
});
for (const child of sorted) {
const isCollapsed = child.isDir && child.path && this.collapsedFolders.has(child.path);
result.push({ ...child, depth, isCollapsed });
if (child.isDir && child.children.size > 0 && !isCollapsed) {
this.flattenTree(child, depth + 1, result);
}
}
return result;
}
renderRowContent(item) {
let indent = '';
for (let i = 0; i < item.depth; i++) {
indent += '<span class="tree-indent"></span>';
}
let chevronHtml;
if (item.isDir && item.children && item.children.size > 0) {
const chevronClass = item.isCollapsed ? 'collapsed' : 'expanded';
chevronHtml = `<span class="tree-chevron ${chevronClass}" data-toggle-path="${item.path}">▶</span>`;
} else if (item.isDir) {
chevronHtml = '<span class="tree-chevron-spacer"></span>';
} else {
chevronHtml = '<span class="tree-chevron-spacer"></span>';
}
const icon = this.getFileIcon(item.name, item.isDir);
const size = item.isDir ? '' : this.formatSize(item.size || 0);
const loc = (!item.isDir && item.loc) ? `${item.loc}` : '';
const statusLabel = item.status ? item.status.substring(0, 3).toUpperCase() : '';
const statusClass = item.status ? ` ${item.status}` : '';
return `${indent}${chevronHtml}<span class="tree-icon">${icon}</span><span class="tree-name ${item.isDir ? 'is-dir' : ''}">${item.name}</span><span class="tree-size">${size}</span><span class="tree-loc">${loc}</span><span class="tree-status${statusClass}">${statusLabel}</span>`;
}
updateDisplay() {
if (this.currentState.size === 0) {
this.fileTree.querySelectorAll('.tree-row').forEach(el => el.remove());
this.emptyState.style.display = 'flex';
this.treeHeader.style.display = 'none';
return;
}
this.emptyState.style.display = 'none';
this.treeHeader.style.display = 'flex';
const tree = this.buildTree();
const items = this.flattenTree(tree);
const existingRows = new Map();
this.fileTree.querySelectorAll('.tree-row').forEach(el => {
existingRows.set(el.dataset.path, el);
});
const seenPaths = new Set();
const orderedRows = [];
for (const item of items) {
seenPaths.add(item.path);
let row = existingRows.get(item.path);
const isRecentlyChanged = this.recentlyChanged.has(item.path);
if (!row) {
row = document.createElement('div');
row.className = 'tree-row';
row.dataset.path = item.path;
row.innerHTML = this.renderRowContent(item);
row.addEventListener('click', (e) => {
const chevron = e.target.closest('.tree-chevron');
if (chevron) {
e.stopPropagation();
this.toggleFolder(item.path);
return;
}
if (item.isDir) {
this.toggleFolder(item.path);
} else {
this.selectFile(item.path);
}
});
} else if (isRecentlyChanged || row.dataset.collapsed !== String(!!item.isCollapsed)) {
row.innerHTML = this.renderRowContent(item);
}
row.dataset.collapsed = String(!!item.isCollapsed);
orderedRows.push(row);
row.classList.toggle('selected', this.selectedFile === item.path);
if (isRecentlyChanged) {
row.classList.remove('is-new', 'is-modified');
void row.offsetWidth;
if (item.status === 'created') {
row.classList.add('is-new');
} else if (item.status === 'modified') {
row.classList.add('is-modified');
}
const rowRef = row;
setTimeout(() => {
rowRef.classList.remove('is-new', 'is-modified');
}, 1000);
}
}
existingRows.forEach((el, path) => {
if (!seenPaths.has(path)) {
el.remove();
}
});
for (const row of orderedRows) {
this.fileTree.appendChild(row);
}
}
selectFile(path) {
this.selectedFile = path;
this.updateDisplay();
this.updateContentPreview();
}
toggleFolder(path) {
if (this.collapsedFolders.has(path)) {
this.collapsedFolders.delete(path);
} else {
this.collapsedFolders.add(path);
}
this.updateDisplay();
}
togglePreview() {
this.showPreview = !this.showPreview;
this.mainContent.classList.toggle('no-preview', !this.showPreview);
this.togglePreviewBtn.style.background = this.showPreview ? '#238636' : '';
}
switchTab(tab) {
this.tabContent.classList.toggle('active', tab === 'content');
this.tabDiff.classList.toggle('active', tab === 'diff');
this.tabStructure.classList.toggle('active', tab === 'structure');
this.activeTab = tab;
this.updateContentPreview();
}
clearContentPreview() {
this.previewFilename.textContent = 'No file selected';
this.contentBody.innerHTML = '<div class="no-content">Click a file to view its content</div>';
}
updateContentPreview() {
if (!this.selectedFile) {
this.clearContentPreview();
return;
}
const fileName = this.selectedFile.split('/').pop();
this.previewFilename.textContent = fileName;
const info = this.currentState.get(this.selectedFile);
if (!info || info.is_dir) {
this.contentBody.innerHTML = '<div class="no-content">Directory selected</div>';
return;
}
const content = this.getContentAtIndex(this.selectedFile, this.currentEventIndex - 1);
if (content === null) {
this.contentBody.innerHTML = '<div class="no-content">No content available (binary file or too large)</div>';
return;
}
const activeTab = this.activeTab || 'content';
if (activeTab === 'structure') {
this.renderStructure(fileName, content);
} else if (activeTab === 'diff' && info.status === 'modified') {
const prevContent = this.getPreviousContent(this.selectedFile, this.currentEventIndex - 1);
this.renderDiff(prevContent, content);
} else {
this.renderContent(content);
}
}
renderContent(content) {
const lines = content.split('\n');
let html = '<div class="code-view">';
for (let i = 0; i < lines.length; i++) {
const lineNum = i + 1;
const lineContent = this.escapeHtml(lines[i]);
html += `<div class="code-line"><span class="line-number">${lineNum}</span><span class="line-content">${lineContent}</span></div>`;
}
html += '</div>';
this.contentBody.innerHTML = html;
}
renderDiff(oldContent, newContent) {
if (oldContent === null) {
this.renderContent(newContent);
return;
}
const oldLines = oldContent.split('\n');
const newLines = newContent.split('\n');
const diff = this.computeDiff(oldLines, newLines);
let addedCount = 0;
let removedCount = 0;
let html = '<div class="diff-view"><div class="diff-header"><div class="diff-stats">';
for (const line of diff) {
if (line.type === 'add') addedCount++;
if (line.type === 'remove') removedCount++;
}
html += `<span class="added">+${addedCount} additions</span>`;
html += `<span class="removed">-${removedCount} deletions</span>`;
html += '</div></div>';
let lineNum = 0;
for (const line of diff) {
if (line.type !== 'remove') lineNum++;
const prefix = line.type === 'add' ? '+' : (line.type === 'remove' ? '-' : ' ');
const className = line.type === 'add' ? 'line-added' :
(line.type === 'remove' ? 'line-removed' : 'line-context');
const displayNum = line.type === 'remove' ? '' : lineNum;
const content = this.escapeHtml(line.content);
html += `<div class="code-line ${className}"><span class="line-number">${displayNum}</span><span class="line-content">${prefix} ${content}</span></div>`;
}
html += '</div>';
this.contentBody.innerHTML = html;
}
computeDiff(oldLines, newLines) {
const lcs = this.longestCommonSubsequence(oldLines, newLines);
const result = [];
let oldIdx = 0;
let newIdx = 0;
let lcsIdx = 0;
while (oldIdx < oldLines.length || newIdx < newLines.length) {
if (lcsIdx < lcs.length) {
while (oldIdx < oldLines.length && oldLines[oldIdx] !== lcs[lcsIdx]) {
result.push({ type: 'remove', content: oldLines[oldIdx] });
oldIdx++;
}
while (newIdx < newLines.length && newLines[newIdx] !== lcs[lcsIdx]) {
result.push({ type: 'add', content: newLines[newIdx] });
newIdx++;
}
if (oldIdx < oldLines.length && newIdx < newLines.length) {
result.push({ type: 'context', content: lcs[lcsIdx] });
oldIdx++;
newIdx++;
lcsIdx++;
}
} else {
while (oldIdx < oldLines.length) {
result.push({ type: 'remove', content: oldLines[oldIdx] });
oldIdx++;
}
while (newIdx < newLines.length) {
result.push({ type: 'add', content: newLines[newIdx] });
newIdx++;
}
}
}
return result;
}
longestCommonSubsequence(arr1, arr2) {
const m = arr1.length;
const n = arr2.length;
if (m > 1000 || n > 1000) {
return this.simpleLCS(arr1, arr2);
}
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (arr1[i - 1] === arr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
const result = [];
let i = m, j = n;
while (i > 0 && j > 0) {
if (arr1[i - 1] === arr2[j - 1]) {
result.unshift(arr1[i - 1]);
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return result;
}
simpleLCS(arr1, arr2) {
const m = arr1.length, n = arr2.length;
if (m === 0) return [];
if (n === 0) return [];
if (m === 1) {
return arr2.includes(arr1[0]) ? [arr1[0]] : [];
}
if (n === 1) {
return arr1.includes(arr2[0]) ? [arr2[0]] : [];
}
const lcsLens = (a, b) => {
let prev = new Uint32Array(b.length + 1);
let curr = new Uint32Array(b.length + 1);
for (let i = 0; i < a.length; i++) {
for (let j = 0; j < b.length; j++) {
curr[j + 1] = a[i] === b[j]
? prev[j] + 1
: Math.max(curr[j], prev[j + 1]);
}
[prev, curr] = [curr, prev];
curr.fill(0);
}
return prev;
};
const mid = Math.floor(m / 2);
const top = arr1.slice(0, mid);
const bot = arr1.slice(mid);
const rowTop = lcsLens(top, arr2);
const rowBot = lcsLens([...bot].reverse(), [...arr2].reverse());
let best = 0, split = 0;
for (let j = 0; j <= n; j++) {
const s = rowTop[j] + rowBot[n - j];
if (s > best) { best = s; split = j; }
}
return [
...this.simpleLCS(top, arr2.slice(0, split)),
...this.simpleLCS(bot, arr2.slice(split)),
];
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
addEventLogEntry(event, index) {
const icons = { created: '✨', modified: '✏', deleted: '🗑' };
const fileName = event.path.split('/').pop();
const row = document.createElement('div');
row.className = `event-row type-${event.event_type}`;
row.dataset.index = index;
const time = this.formatTime(event.timestamp || 0);
row.innerHTML = `
<span class="event-type">${icons[event.event_type]}</span>
<span class="event-path" title="${event.path}">${fileName}</span>
<span class="event-time">${time}</span>
`;
row.addEventListener('click', () => {
this.seekTo((index + 1) / this.events.length);
this.selectFile(event.path);
});
this.eventList.insertBefore(row, this.eventList.firstChild);
while (this.eventList.children.length > 100) {
this.eventList.removeChild(this.eventList.lastChild);
}
this.eventCount.textContent = this.currentEventIndex;
}
updateProgress() {
const percent = this.events.length ? (this.currentEventIndex / this.events.length) * 100 : 0;
this.scrubberFill.style.width = `${percent}%`;
this.scrubberHandle.style.left = `${percent}%`;
}
updateStats() {
let created = 0, modified = 0, deleted = 0;
for (let i = 0; i < this.currentEventIndex; i++) {
const e = this.events[i];
if (e.event_type === 'created') created++;
else if (e.event_type === 'modified') modified++;
else if (e.event_type === 'deleted') deleted++;
}
this.statFiles.textContent = this.currentState.size;
this.statCreated.textContent = created;
this.statModified.textContent = modified;
this.statDeleted.textContent = deleted;
}
updateStepDisplay() {
this.stepDisplay.textContent = `${this.currentEventIndex} / ${this.events.length}`;
this.prevBtn.disabled = this.currentEventIndex === 0;
this.nextBtn.disabled = this.currentEventIndex >= this.events.length;
}
renderStructure(fileName, content) {
const ext = fileName.split('.').pop().toLowerCase();
const structure = this.structureAnalyzer.analyze(content, ext);
if (!structure || structure.items.length === 0) {
this.contentBody.innerHTML = '<div class="structure-empty">No structure detected for this file type</div>';
return;
}
const activityMap = this.calculateStructureActivity(this.selectedFile, structure);
let html = '<div class="structure-view">';
html += '<div class="structure-summary">';
html += `<div class="structure-summary-title">${structure.language} Structure</div>`;
html += '<div class="structure-summary-stats">';
const counts = {};
for (const item of structure.items) {
counts[item.type] = (counts[item.type] || 0) + 1;
}
for (const [type, count] of Object.entries(counts)) {
html += `<div class="structure-stat"><span class="structure-stat-value">${count}</span><span class="structure-stat-label">${type}${count > 1 ? 's' : ''}</span></div>`;
}
html += '</div></div>';
const grouped = {};
for (const item of structure.items) {
if (!grouped[item.type]) grouped[item.type] = [];
grouped[item.type].push(item);
}
for (const [type, items] of Object.entries(grouped)) {
html += '<div class="structure-section">';
html += `<div class="structure-section-title">${type}s</div>`;
for (const item of items) {
const activity = activityMap.get(`${item.name}:${item.startLine}`) || 0;
const activityPercent = Math.min(100, activity * 20); const modClass = activity > 0 ? 'modified' : '';
const nestedClass = item.parent ? 'nested' : '';
html += `<div class="structure-item ${modClass} ${nestedClass}" data-line="${item.startLine}">`;
html += `<span class="structure-icon">${this.structureAnalyzer.getIcon(item.type)}</span>`;
html += `<span class="structure-name">${this.escapeHtml(item.name)}`;
if (item.params) {
html += `<span class="params">(${this.escapeHtml(item.params)})</span>`;
}
html += '</span>';
html += `<span class="structure-line">L${item.startLine}</span>`;
if (activity > 0) {
html += `<div class="structure-activity"><div class="structure-activity-bar" style="width: ${activityPercent}%"></div></div>`;
}
html += '</div>';
if (item.children) {
for (const child of item.children) {
const childActivity = activityMap.get(`${child.name}:${child.startLine}`) || 0;
const childActivityPercent = Math.min(100, childActivity * 20);
const childModClass = childActivity > 0 ? 'modified' : '';
html += `<div class="structure-item nested ${childModClass}" data-line="${child.startLine}">`;
html += `<span class="structure-icon">${this.structureAnalyzer.getIcon(child.type)}</span>`;
html += `<span class="structure-name">${this.escapeHtml(child.name)}`;
if (child.params) {
html += `<span class="params">(${this.escapeHtml(child.params)})</span>`;
}
html += '</span>';
html += `<span class="structure-line">L${child.startLine}</span>`;
if (childActivity > 0) {
html += `<div class="structure-activity"><div class="structure-activity-bar" style="width: ${childActivityPercent}%"></div></div>`;
}
html += '</div>';
}
}
}
html += '</div>';
}
html += '</div>';
this.contentBody.innerHTML = html;
this.contentBody.querySelectorAll('.structure-item').forEach(el => {
el.addEventListener('click', () => {
const line = parseInt(el.dataset.line);
this.switchTab('content');
setTimeout(() => {
const lineEl = this.contentBody.querySelector(`.code-line:nth-child(${line})`);
if (lineEl) {
lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
lineEl.style.background = 'rgba(88, 166, 255, 0.3)';
setTimeout(() => lineEl.style.background = '', 1000);
}
}, 50);
});
});
}
calculateStructureActivity(path, structure) {
const activity = new Map();
const history = this.contentHistory.get(path);
if (!history || history.length < 2) return activity;
for (let i = 1; i < history.length; i++) {
const prevContent = history[i - 1].content;
const currContent = history[i].content;
if (prevContent === currContent) continue;
const prevLines = prevContent.split('\n');
const currLines = currContent.split('\n');
const changedLines = new Set();
const maxLen = Math.max(prevLines.length, currLines.length);
for (let j = 0; j < maxLen; j++) {
if (prevLines[j] !== currLines[j]) {
changedLines.add(j + 1); }
}
for (const item of structure.items) {
const endLine = item.endLine || item.startLine + 10;
for (let line = item.startLine; line <= endLine; line++) {
if (changedLines.has(line)) {
const key = `${item.name}:${item.startLine}`;
activity.set(key, (activity.get(key) || 0) + 1);
break;
}
}
if (item.children) {
for (const child of item.children) {
const childEnd = child.endLine || child.startLine + 5;
for (let line = child.startLine; line <= childEnd; line++) {
if (changedLines.has(line)) {
const key = `${child.name}:${child.startLine}`;
activity.set(key, (activity.get(key) || 0) + 1);
break;
}
}
}
}
}
}
return activity;
}
showToast(message, isError = false) {
this.toast.textContent = message;
this.toast.classList.toggle('error', isError);
this.toast.classList.add('visible');
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => {
this.toast.classList.remove('visible');
}, 3000);
}
compressData(data) {
const json = JSON.stringify(data);
const compressed = pako.deflate(json);
let binary = '';
for (let i = 0; i < compressed.length; i++) {
binary += String.fromCharCode(compressed[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
decompressData(encoded) {
let b64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const json = pako.inflate(bytes, { to: 'string' });
return JSON.parse(json);
}
showShareModal() {
if (!this.rawData) {
this.showToast('No recording loaded', true);
return;
}
try {
const stripped = this.stripContentForShare(this.rawData);
const encoded = this.compressData(stripped);
const baseUrl = window.location.origin + window.location.pathname;
const url = `${baseUrl}#data=${encoded}`;
this.shareUrlInput.value = url;
const kb = (url.length / 1024).toFixed(1);
const sizeEl = this.shareSize;
sizeEl.textContent = `URL size: ${kb} KB`;
sizeEl.className = 'share-size';
if (url.length > 100000) {
sizeEl.textContent += ' — Very large URL, may not work in all browsers.';
sizeEl.className = 'share-size error';
} else if (url.length > 50000) {
sizeEl.textContent += ' — Large URL, may be truncated by some services.';
sizeEl.className = 'share-size warn';
}
this.shareOverlay.classList.add('visible');
this.shareUrlInput.select();
} catch (e) {
this.showToast('Failed to generate share URL: ' + e.message, true);
}
}
stripContentForShare(data) {
const stripped = { ...data };
if (stripped.initial_state) {
stripped.initial_state = stripped.initial_state.map(item => {
const { content, ...rest } = item;
return rest;
});
}
if (stripped.events) {
stripped.events = stripped.events.map(event => {
const { content, ...rest } = event;
return rest;
});
}
return stripped;
}
async copyShareUrl() {
try {
await navigator.clipboard.writeText(this.shareUrlInput.value);
this.showToast('URL copied to clipboard!');
this.shareOverlay.classList.remove('visible');
} catch (e) {
this.shareUrlInput.select();
document.execCommand('copy');
this.showToast('URL copied to clipboard!');
this.shareOverlay.classList.remove('visible');
}
}
loadFromUrlHash() {
const hash = window.location.hash;
if (!hash || !hash.startsWith('#data=')) return false;
const encoded = hash.slice(6); if (!encoded) return false;
try {
const data = this.decompressData(encoded);
this.loadSession(data);
history.replaceState(null, '', window.location.pathname + window.location.search);
return true;
} catch (e) {
console.error('Failed to load recording from URL:', e);
this.showToast('Failed to load recording from URL', true);
return false;
}
}
}
class CodeStructureAnalyzer {
analyze(content, ext) {
const lines = content.split('\n');
switch (ext) {
case 'py':
return this.analyzePython(lines);
case 'js':
case 'jsx':
return this.analyzeJavaScript(lines, false);
case 'ts':
case 'tsx':
return this.analyzeJavaScript(lines, true);
case 'html':
return this.analyzeHTML(lines);
case 'css':
return this.analyzeCSS(lines);
case 'json':
return this.analyzeJSON(content);
default:
return this.analyzeGeneric(lines);
}
}
analyzePython(lines) {
const items = [];
let currentClass = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
const classMatch = line.match(/^class\s+(\w+)(?:\s*\(([^)]*)\))?:/);
if (classMatch) {
currentClass = {
type: 'class',
name: classMatch[1],
params: classMatch[2] || '',
startLine: lineNum,
endLine: this.findPythonBlockEnd(lines, i),
children: []
};
items.push(currentClass);
continue;
}
const funcMatch = line.match(/^(\s*)(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)/);
if (funcMatch) {
const indent = funcMatch[1].length;
const func = {
type: indent > 0 ? 'method' : 'function',
name: funcMatch[2],
params: this.shortenParams(funcMatch[3]),
startLine: lineNum,
endLine: this.findPythonBlockEnd(lines, i)
};
if (currentClass && indent > 0) {
currentClass.children.push(func);
} else {
items.push(func);
currentClass = null;
}
}
const decoratorMatch = line.match(/^@(\w+)/);
if (decoratorMatch) {
}
}
return { language: 'Python', items };
}
analyzeJavaScript(lines, isTypeScript) {
const items = [];
let currentClass = null;
let braceDepth = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
braceDepth += (line.match(/{/g) || []).length;
braceDepth -= (line.match(/}/g) || []).length;
if (braceDepth === 0) currentClass = null;
const classMatch = line.match(/(?:export\s+)?(?:default\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/);
if (classMatch) {
currentClass = {
type: 'class',
name: classMatch[1],
params: classMatch[2] ? `extends ${classMatch[2]}` : '',
startLine: lineNum,
children: []
};
items.push(currentClass);
continue;
}
const componentMatch = line.match(/(?:export\s+)?(?:default\s+)?(?:const|function)\s+([A-Z]\w+)\s*[=:]\s*(?:\([^)]*\)|[^=]*)\s*(?:=>|{)/);
if (componentMatch) {
items.push({
type: 'component',
name: componentMatch[1],
startLine: lineNum
});
continue;
}
const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/);
if (funcMatch && !currentClass) {
items.push({
type: 'function',
name: funcMatch[1],
params: this.shortenParams(funcMatch[2]),
startLine: lineNum
});
continue;
}
const arrowMatch = line.match(/(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=]*)\s*=>/);
if (arrowMatch && !currentClass && !arrowMatch[1].match(/^[A-Z]/)) {
items.push({
type: 'function',
name: arrowMatch[1],
startLine: lineNum
});
continue;
}
const methodMatch = line.match(/^\s+(?:async\s+)?(?:static\s+)?(\w+)\s*\(([^)]*)\)\s*{/);
if (methodMatch && currentClass) {
currentClass.children.push({
type: 'method',
name: methodMatch[1],
params: this.shortenParams(methodMatch[2]),
startLine: lineNum
});
}
if (isTypeScript) {
const interfaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
if (interfaceMatch) {
items.push({
type: 'interface',
name: interfaceMatch[1],
startLine: lineNum
});
}
const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*=/);
if (typeMatch) {
items.push({
type: 'type',
name: typeMatch[1],
startLine: lineNum
});
}
}
}
return { language: isTypeScript ? 'TypeScript' : 'JavaScript', items };
}
analyzeHTML(lines) {
const items = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
const sectionMatch = line.match(/<(head|body|header|nav|main|article|section|aside|footer)[\s>]/i);
if (sectionMatch) {
items.push({
type: 'section',
name: sectionMatch[1].toLowerCase(),
startLine: lineNum
});
}
const scriptMatch = line.match(/<script[^>]*(?:src=["']([^"']+)["'])?[^>]*>/i);
if (scriptMatch) {
items.push({
type: 'script',
name: scriptMatch[1] || 'inline',
startLine: lineNum
});
}
const styleMatch = line.match(/<style[^>]*>/i);
if (styleMatch) {
items.push({
type: 'style',
name: 'inline styles',
startLine: lineNum
});
}
const idMatch = line.match(/<(\w+)[^>]*id=["']([^"']+)["']/i);
if (idMatch) {
items.push({
type: 'element',
name: `#${idMatch[2]}`,
params: idMatch[1],
startLine: lineNum
});
}
}
return { language: 'HTML', items };
}
analyzeCSS(lines) {
const items = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
const selectorMatch = line.match(/^([.#]?[\w-]+(?:\s*,\s*[.#]?[\w-]+)*)\s*{/);
if (selectorMatch) {
items.push({
type: 'rule',
name: selectorMatch[1].trim(),
startLine: lineNum
});
}
const mediaMatch = line.match(/@media\s+([^{]+)/);
if (mediaMatch) {
items.push({
type: 'media',
name: mediaMatch[1].trim(),
startLine: lineNum
});
}
const keyframeMatch = line.match(/@keyframes\s+(\w+)/);
if (keyframeMatch) {
items.push({
type: 'keyframes',
name: keyframeMatch[1],
startLine: lineNum
});
}
}
return { language: 'CSS', items };
}
analyzeJSON(content) {
const items = [];
try {
const obj = JSON.parse(content);
const keys = Object.keys(obj);
for (const key of keys.slice(0, 20)) { const value = obj[key];
items.push({
type: Array.isArray(value) ? 'array' : typeof value === 'object' ? 'object' : 'property',
name: key,
params: Array.isArray(value) ? `[${value.length}]` : '',
startLine: 1 });
}
} catch (e) {
return null;
}
return { language: 'JSON', items };
}
analyzeGeneric(lines) {
const items = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
const funcMatch = line.match(/(?:function|func|fn|def|sub|procedure)\s+(\w+)/i);
if (funcMatch) {
items.push({
type: 'function',
name: funcMatch[1],
startLine: lineNum
});
}
}
return items.length > 0 ? { language: 'Unknown', items } : null;
}
findPythonBlockEnd(lines, startIndex) {
const startIndent = lines[startIndex].match(/^\s*/)[0].length;
for (let i = startIndex + 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '') continue;
const indent = line.match(/^\s*/)[0].length;
if (indent <= startIndent && line.trim() !== '') {
return i;
}
}
return lines.length;
}
shortenParams(params) {
if (!params) return '';
const parts = params.split(',').map(p => p.trim().split(':')[0].split('=')[0].trim());
if (parts.length > 3) {
return parts.slice(0, 3).join(', ') + ', ...';
}
return parts.join(', ');
}
getIcon(type) {
const icons = {
'class': '📦', 'function': '⚡', 'method': '⇝', 'component': '⚙', 'interface': '📋', 'type': '📄', 'section': '📌', 'script': '📜', 'style': '🎨', 'element': '📍', 'rule': '🎨', 'media': '📺', 'keyframes': '🎬', 'property': '🔑', 'object': '📦', 'array': '📚', };
return icons[type] || '📄';
}
}
new ChronoCodePlayer();
</script>
</body>
</html>