feat(playback): ALL·merged tab - every log on one video-synced timeline

The merged view interleaves every *.log in time order across the
whole leg, filename-prefixed per line (green .file span), and syncs
like any other tab: the video highlights the right file's line at the
right moment, clicking a line seeks the video. Untimed lines inherit
their file's previous timestamp so intra-file order survives the
sort; the merged tab is first and the default.

Verified in a real browser with an interleaved synthetic zip (two
logs, overlapping timelines): time-order merge, prefixes, follow-sync
at t=22 highlighting the app-update.log line, click-to-seek to t=30.
This commit is contained in:
ethernet
2026-08-14 15:10:45 -04:00
parent 91ba10835a
commit 4bc6bfede1
+31
View File
@@ -60,6 +60,7 @@
border-radius: 3px; }
.line:hover { background: rgba(88,166,255,.08); }
.line .ts { color: var(--dim); margin-right: 8px; user-select: none; }
.line .file { color: #7ee787; margin-right: 8px; user-select: none; }
.line.synced { color: var(--accent); }
#noSync { color: var(--dim); padding: 4px 10px; font-size: 12px; }
#syncing { position: sticky; top: 0; z-index: 3; background: rgba(13,17,23,.9);
@@ -191,6 +192,12 @@ function renderPane(scrollToEnd) {
$('video').play().catch(() => {});
};
}
if (ln.file) {
const file = document.createElement('span');
file.className = 'file';
file.textContent = `${ln.file}: `;
div.appendChild(file);
}
div.appendChild(document.createTextNode(ln.text));
frag.appendChild(div);
}
@@ -227,6 +234,27 @@ function syncPane() {
}
/* ── zip loading ───────────────────────────────────────────────────────── */
/**
* Merge every log into one timeline, in time order, with each line
* filename-prefixed. Untimed lines inherit the previous timed line of
* their file so ordering within a file survives the sort.
* @param {Map<string, {lines: {ts: number | null, text: string}[]}>} logs
* @returns {{lines: {ts: number, text: string, file: string}[], hasTs: boolean}}
*/
function buildMergedLog(logs) {
/** @type {{ts: number, text: string, file: string}[]} */
const merged = [];
for (const [name, entry] of logs) {
let lastTs = 0;
for (const ln of entry.lines) {
if (ln.ts !== null) lastTs = ln.ts;
merged.push({ ts: lastTs, text: ln.text, file: name });
}
}
merged.sort((a, b) => a.ts - b.ts);
return { lines: merged, hasTs: true };
}
async function loadZipFromBlob(blob, label) {
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const names = Object.keys(zip.files).filter((n) => !zip.files[n].dir);
@@ -242,6 +270,9 @@ async function loadZipFromBlob(blob, label) {
state.logs.set(n, { lines, hasTs: lines.some((l) => l.ts !== null) });
}
if (state.logs.size === 0) throw new Error('no *.log files in the zip');
// The merged view comes first: one timeline across every log, synced
// to the video like any other tab.
state.logs = new Map([['ALL · merged', buildMergedLog(state.logs)], ...state.logs]);
state.zipName = label || 'leg artifact';
state.activeTab = [...state.logs.keys()][0];
}