'use strict'; const MAX_VISIBLE_LINES = 5000; const RECONNECT_DELAY_MS = 1000; const output = document.querySelector('#logOutput'); const statusBadge = document.querySelector('#status'); const filePath = document.querySelector('#filePath'); const lineCount = document.querySelector('#lineCount'); const pendingCount = document.querySelector('#pendingCount'); const autoScroll = document.querySelector('#autoScroll'); const pauseButton = document.querySelector('#pauseButton'); const clearButton = document.querySelector('#clearButton'); const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g; const ANSI_FG_CLASSES = new Map([ [30, 'ansi-fg-black'], [31, 'ansi-fg-red'], [32, 'ansi-fg-green'], [33, 'ansi-fg-yellow'], [34, 'ansi-fg-blue'], [35, 'ansi-fg-magenta'], [36, 'ansi-fg-cyan'], [37, 'ansi-fg-white'], [90, 'ansi-fg-bright-black'], [91, 'ansi-fg-bright-red'], [92, 'ansi-fg-bright-green'], [93, 'ansi-fg-bright-yellow'], [94, 'ansi-fg-bright-blue'], [95, 'ansi-fg-bright-magenta'], [96, 'ansi-fg-bright-cyan'], [97, 'ansi-fg-bright-white'] ]); const ANSI_BG_CLASSES = new Map([ [40, 'ansi-bg-black'], [41, 'ansi-bg-red'], [42, 'ansi-bg-green'], [43, 'ansi-bg-yellow'], [44, 'ansi-bg-blue'], [45, 'ansi-bg-magenta'], [46, 'ansi-bg-cyan'], [47, 'ansi-bg-white'], [100, 'ansi-bg-bright-black'], [101, 'ansi-bg-bright-red'], [102, 'ansi-bg-bright-green'], [103, 'ansi-bg-bright-yellow'], [104, 'ansi-bg-bright-blue'], [105, 'ansi-bg-bright-magenta'], [106, 'ansi-bg-bright-cyan'], [107, 'ansi-bg-bright-white'] ]); let socket; let reconnectTimer; let paused = false; let visibleLines = []; let pendingChunks = []; function setStatus(state, label) { statusBadge.dataset.state = state; statusBadge.textContent = label; } function isNearBottom() { const distance = output.scrollHeight - output.scrollTop - output.clientHeight; return distance < 24; } function scrollToBottom() { output.scrollTop = output.scrollHeight; } function updateCounts() { lineCount.textContent = String(visibleLines.length); if (pendingChunks.length === 0) { pendingCount.hidden = true; pendingCount.textContent = '0 buffered'; return; } pendingCount.hidden = false; pendingCount.textContent = `${pendingChunks.length} buffered`; } function createAnsiState() { return { fgClass: null, bgClass: null, fgColor: null, bgColor: null, bold: false, dim: false, italic: false, underline: false }; } function hasAnsiStyle(state) { return Boolean( state.fgClass || state.bgClass || state.fgColor || state.bgColor || state.bold || state.dim || state.italic || state.underline ); } function appendAnsiSegment(fragment, state, text) { if (!text) { return; } if (!hasAnsiStyle(state)) { fragment.append(document.createTextNode(text)); return; } const span = document.createElement('span'); if (state.fgClass) span.classList.add(state.fgClass); if (state.bgClass) span.classList.add(state.bgClass); if (state.bold) span.classList.add('ansi-bold'); if (state.dim) span.classList.add('ansi-dim'); if (state.italic) span.classList.add('ansi-italic'); if (state.underline) span.classList.add('ansi-underline'); if (state.fgColor) span.style.color = state.fgColor; if (state.bgColor) span.style.backgroundColor = state.bgColor; span.textContent = text; fragment.append(span); } function readExtendedAnsiColor(params, index) { const mode = params[index + 1]; if (mode === 5 && Number.isInteger(params[index + 2])) { return { color: ansi256ToCss(params[index + 2]), nextIndex: index + 2 }; } if ( mode === 2 && Number.isInteger(params[index + 2]) && Number.isInteger(params[index + 3]) && Number.isInteger(params[index + 4]) ) { const r = clampColor(params[index + 2]); const g = clampColor(params[index + 3]); const b = clampColor(params[index + 4]); return { color: `rgb(${r} ${g} ${b})`, nextIndex: index + 4 }; } return { color: null, nextIndex: index }; } function ansi256ToCss(value) { const color = clampColor(value); if (color < 16) { const palette = [ '#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5', '#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#ffffff' ]; return palette[color]; } if (color >= 232) { const level = 8 + (color - 232) * 10; return `rgb(${level} ${level} ${level})`; } const adjusted = color - 16; const r = Math.floor(adjusted / 36); const g = Math.floor((adjusted % 36) / 6); const b = adjusted % 6; const component = (part) => (part === 0 ? 0 : 55 + part * 40); return `rgb(${component(r)} ${component(g)} ${component(b)})`; } function clampColor(value) { return Math.max(0, Math.min(255, value)); } function applyAnsiCodes(state, rawCodes) { const codes = rawCodes === '' ? [0] : rawCodes.split(';').map((code) => Number.parseInt(code || '0', 10)); for (let index = 0; index < codes.length; index += 1) { const code = Number.isNaN(codes[index]) ? 0 : codes[index]; if (code === 0) { Object.assign(state, createAnsiState()); } else if (code === 1) { state.bold = true; state.dim = false; } else if (code === 2) { state.dim = true; state.bold = false; } else if (code === 3) { state.italic = true; } else if (code === 4) { state.underline = true; } else if (code === 22) { state.bold = false; state.dim = false; } else if (code === 23) { state.italic = false; } else if (code === 24) { state.underline = false; } else if (code === 38) { const extended = readExtendedAnsiColor(codes, index); state.fgClass = null; state.fgColor = extended.color; index = extended.nextIndex; } else if (code === 39) { state.fgClass = null; state.fgColor = null; } else if (code === 48) { const extended = readExtendedAnsiColor(codes, index); state.bgClass = null; state.bgColor = extended.color; index = extended.nextIndex; } else if (code === 49) { state.bgClass = null; state.bgColor = null; } else if (ANSI_FG_CLASSES.has(code)) { state.fgClass = ANSI_FG_CLASSES.get(code); state.fgColor = null; } else if (ANSI_BG_CLASSES.has(code)) { state.bgClass = ANSI_BG_CLASSES.get(code); state.bgColor = null; } } } function renderAnsiText(text) { const fragment = document.createDocumentFragment(); const state = createAnsiState(); let lastIndex = 0; let match; ANSI_SGR_PATTERN.lastIndex = 0; while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) { appendAnsiSegment(fragment, state, text.slice(lastIndex, match.index)); applyAnsiCodes(state, match[1]); lastIndex = ANSI_SGR_PATTERN.lastIndex; } appendAnsiSegment(fragment, state, text.slice(lastIndex)); return fragment; } function renderLog() { output.replaceChildren(renderAnsiText(visibleLines.join('\n'))); } function appendText(text) { if (!text) { return; } const shouldScroll = autoScroll.checked && isNearBottom(); const normalized = text.replace(/\r\n/g, '\n'); const nextLines = normalized.split('\n'); if (visibleLines.length === 0) { visibleLines = nextLines; } else { visibleLines[visibleLines.length - 1] += nextLines[0]; visibleLines.push(...nextLines.slice(1)); } if (visibleLines.length > MAX_VISIBLE_LINES) { visibleLines = visibleLines.slice(-MAX_VISIBLE_LINES); } renderLog(); updateCounts(); if (shouldScroll) { requestAnimationFrame(scrollToBottom); } } function setSnapshot(lines) { visibleLines = Array.isArray(lines) ? lines.slice(-MAX_VISIBLE_LINES) : []; pendingChunks = []; renderLog(); updateCounts(); if (autoScroll.checked) { requestAnimationFrame(scrollToBottom); } } function handleStreamText(text) { if (paused) { pendingChunks.push(text); updateCounts(); return; } appendText(text); } function flushPending() { if (pendingChunks.length === 0) { return; } const chunks = pendingChunks.join(''); pendingChunks = []; appendText(chunks); } function connect() { clearTimeout(reconnectTimer); setStatus(socket ? 'reconnecting' : 'connecting', socket ? 'Reconnecting' : 'Connecting'); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; socket = new WebSocket(`${protocol}//${window.location.host}/stream`); socket.addEventListener('open', () => { setStatus('connected', 'Connected'); }); socket.addEventListener('message', (event) => { let message; try { message = JSON.parse(event.data); } catch { return; } if (message.type === 'hello') { filePath.textContent = message.file || 'Unknown file'; return; } if (message.type === 'snapshot') { setSnapshot(message.lines); return; } if (message.type === 'append') { handleStreamText(message.text); return; } if (message.type === 'truncate') { setSnapshot([]); appendText(`[stream reset] ${message.message}\n`); return; } if (message.type === 'error') { setStatus('error', 'Error'); appendText(`[stream error] ${message.message}\n`); } }); socket.addEventListener('close', () => { setStatus('disconnected', 'Disconnected'); reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); }); socket.addEventListener('error', () => { setStatus('error', 'Error'); }); } pauseButton.addEventListener('click', () => { paused = !paused; pauseButton.textContent = paused ? 'Resume' : 'Pause'; if (!paused) { flushPending(); } }); clearButton.addEventListener('click', () => { visibleLines = []; pendingChunks = []; renderLog(); updateCounts(); }); autoScroll.addEventListener('change', () => { if (autoScroll.checked) { scrollToBottom(); } }); connect();