Render ANSI log colors
This commit is contained in:
+230
-3
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user