diff --git a/public/app.js b/public/app.js index dab1c7f..40c0831 100644 --- a/public/app.js +++ b/public/app.js @@ -12,6 +12,44 @@ 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; @@ -45,6 +83,195 @@ function updateCounts() { 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; @@ -65,7 +292,7 @@ function appendText(text) { visibleLines = visibleLines.slice(-MAX_VISIBLE_LINES); } - output.textContent = visibleLines.join('\n'); + renderLog(); updateCounts(); if (shouldScroll) { @@ -76,7 +303,7 @@ function appendText(text) { function setSnapshot(lines) { visibleLines = Array.isArray(lines) ? lines.slice(-MAX_VISIBLE_LINES) : []; pendingChunks = []; - output.textContent = visibleLines.join('\n'); + renderLog(); updateCounts(); if (autoScroll.checked) { @@ -173,7 +400,7 @@ pauseButton.addEventListener('click', () => { clearButton.addEventListener('click', () => { visibleLines = []; pendingChunks = []; - output.textContent = ''; + renderLog(); updateCounts(); }); diff --git a/public/styles.css b/public/styles.css index 7aaafde..9967bf4 100644 --- a/public/styles.css +++ b/public/styles.css @@ -193,6 +193,127 @@ input:focus-visible { content: "No log lines yet."; } +.ansi-bold { + font-weight: 700; +} + +.ansi-dim { + opacity: 0.72; +} + +.ansi-italic { + font-style: italic; +} + +.ansi-underline { + text-decoration: underline; +} + +.ansi-fg-black { + color: #777d85; +} + +.ansi-fg-red { + color: #e86f6f; +} + +.ansi-fg-green { + color: #6fcf97; +} + +.ansi-fg-yellow { + color: #d8b75f; +} + +.ansi-fg-blue { + color: #7aa7e8; +} + +.ansi-fg-magenta { + color: #cf8ee8; +} + +.ansi-fg-cyan { + color: #68c8d3; +} + +.ansi-fg-white { + color: #d7dbdf; +} + +.ansi-fg-bright-black { + color: #9aa1aa; +} + +.ansi-fg-bright-red { + color: #ff8a8a; +} + +.ansi-fg-bright-green { + color: #8ee6ad; +} + +.ansi-fg-bright-yellow { + color: #f0d47b; +} + +.ansi-fg-bright-blue { + color: #9dc1ff; +} + +.ansi-fg-bright-magenta { + color: #e5a6ff; +} + +.ansi-fg-bright-cyan { + color: #8fe6ef; +} + +.ansi-fg-bright-white { + color: #ffffff; +} + +.ansi-bg-black, +.ansi-bg-bright-black { + background-color: #25282c; +} + +.ansi-bg-red, +.ansi-bg-bright-red { + background-color: #5c2323; +} + +.ansi-bg-green, +.ansi-bg-bright-green { + background-color: #1f4a35; +} + +.ansi-bg-yellow, +.ansi-bg-bright-yellow { + background-color: #4f4120; +} + +.ansi-bg-blue, +.ansi-bg-bright-blue { + background-color: #203c5f; +} + +.ansi-bg-magenta, +.ansi-bg-bright-magenta { + background-color: #4b2b59; +} + +.ansi-bg-cyan, +.ansi-bg-bright-cyan { + background-color: #20484f; +} + +.ansi-bg-white, +.ansi-bg-bright-white { + background-color: #d7dbdf; + color: #101112; +} + @media (max-width: 760px) { .toolbar { align-items: stretch;