From 74804df4a5688bac06d1fb68a8b8cce92259b29c Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 5 Aug 2026 23:59:26 +0200 Subject: [PATCH] enhance(actions): replace `ansi_up` with first-party code (#38619) Replaces the `ansi_up` dependency with first-party code and fixes a number of bugs in turn. - Faster rendering, around 7x for plain lines and 3x for colored ones. - Render many SGR features like hyperlinks, blink, inverse, conceal, strikethrough, overline, underline styles and underline color, including `:` sub-parameters, which no longer swallow the codes after them. - Drop OSC, DCS, SOS, PM and APC with their payload, ending them at BEL, `ESC \` or the 8-bit ST. A truncated sequence is dropped instead of corrupting a later line. - A backspace moves the cursor back a column, so what follows overwrites it, even across a style change. - A style inside an OSC 8 label renders instead of leaking, and a private CSI ending in `m` no longer resets the style. - Log lines render as DOM nodes, never as markup, and only an `http(s)` url becomes a link. - Named colors render as CSS classes, only 24-bit color stays inline. - Invisible text is now selectable, and the `z-index` workaround is gone. Signed-off-by: silverwind Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: wxiaoguang --- package.json | 1 - pnpm-lock.yaml | 8 - templates/devtest/ansi-render.tmpl | 7 + web_src/css/features/console.css | 36 ++- web_src/js/components/ActionRunJobView.vue | 13 +- web_src/js/components/ActionRunView.test.ts | 4 +- web_src/js/components/ActionRunView.ts | 10 +- web_src/js/modules/devtest.ts | 49 +++ web_src/js/render/ansi.test.ts | 60 +++- web_src/js/render/ansi.ts | 324 ++++++++++++++++---- web_src/js/utils/url.ts | 12 +- 11 files changed, 419 insertions(+), 105 deletions(-) create mode 100644 templates/devtest/ansi-render.tmpl diff --git a/package.json b/package.json index 3e8898c38bf..d554feb8764 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "@replit/codemirror-vscode-keymap": "6.0.2", "@resvg/resvg-wasm": "2.6.2", "@vitejs/plugin-vue": "6.0.8", - "ansi_up": "6.0.6", "asciinema-player": "3.17.0", "chart.js": "4.5.1", "chartjs-adapter-dayjs-4": "1.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73c2cc40a0a..b34b9ef40d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,9 +95,6 @@ importers: '@vitejs/plugin-vue': specifier: 6.0.8 version: 6.0.8(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) - ansi_up: - specifier: 6.0.6 - version: 6.0.6 asciinema-player: specifier: 3.17.0 version: 3.17.0 @@ -1679,9 +1676,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi_up@6.0.6: - resolution: {integrity: sha512-yIa1x3Ecf8jWP4UWEunNjqNX6gzE4vg2gGz+xqRGY+TBSucnYp6RRdPV4brmtg6bQ1ljD48mZ5iGSEj7QEpRKA==} - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -5946,8 +5940,6 @@ snapshots: ansi-styles@5.2.0: {} - ansi_up@6.0.6: {} - any-promise@1.3.0: {} anymatch@3.1.3: diff --git a/templates/devtest/ansi-render.tmpl b/templates/devtest/ansi-render.tmpl new file mode 100644 index 00000000000..60ca542db77 --- /dev/null +++ b/templates/devtest/ansi-render.tmpl @@ -0,0 +1,7 @@ +{{template "devtest/devtest-header"}} +
+
+
+
+
+{{template "devtest/devtest-footer"}} diff --git a/web_src/css/features/console.css b/web_src/css/features/console.css index 17f3275aceb..5ae0795c1df 100644 --- a/web_src/css/features/console.css +++ b/web_src/css/features/console.css @@ -16,15 +16,41 @@ text-decoration: underline; text-decoration-style: dashed; } -.console a:hover { color: var(--color-primary); } +.console a:hover { + color: var(--color-primary); + text-underline-position: auto; /* the global "a:hover" sets "under", which would shift the underline */ +} @keyframes blink-animation { - to { - visibility: hidden; + 50% { + color: transparent; } } -/* ansi_up colors used in actions */ +/* SGR attributes emitted by web_src/js/render/ansi.ts */ + +.ansi-bold { font-weight: var(--font-weight-semibold); } +.ansi-italic { font-style: italic; } +.ansi-blink { animation: blink-animation 1s step-end infinite; } +.ansi-faint { color: color-mix(in srgb, currentcolor 70%, transparent); } +.ansi-conceal { color: transparent; } +.ansi-inverse-fg { color: var(--color-console-bg); } +.ansi-inverse-bg { background-color: var(--color-console-fg); } + +.ansi-underline { text-decoration-line: underline; } +.ansi-line-through { text-decoration-line: line-through; } +.ansi-overline { text-decoration-line: overline; } +.ansi-underline.ansi-line-through { text-decoration-line: underline line-through; } +.ansi-underline.ansi-overline { text-decoration-line: underline overline; } +.ansi-line-through.ansi-overline { text-decoration-line: line-through overline; } +.ansi-underline.ansi-line-through.ansi-overline { text-decoration-line: underline line-through overline; } + +.ansi-double { text-decoration-style: double; } +.ansi-wavy { text-decoration-style: wavy; } +.ansi-dotted { text-decoration-style: dotted; } +.ansi-dashed { text-decoration-style: dashed; } + +/* ANSI colors used in actions */ .ansi-black-fg { color: var(--color-ansi-black); } .ansi-red-fg { color: var(--color-ansi-red); } @@ -67,7 +93,7 @@ .term-fg2 { color: var(--color-ansi-bright-black); } /* faint (decreased intensity) - same as gray really */ .term-fg3 { font-style: italic; } /* italic */ .term-fg4 { text-decoration: underline; } /* underline */ -.term-fg5 { animation: blink-animation 1s steps(3, start) infinite; } /* blink */ +.term-fg5 { animation: blink-animation 1s step-end infinite; } /* blink */ .term-fg9 { text-decoration: line-through; } /* crossed-out */ .term-fg30 { color: var(--color-ansi-black); } /* black (but we can't use black, so a diff color) */ diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index b297ff98d22..168569c1829 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -10,6 +10,7 @@ import type {IntervalId} from '../types.ts'; import {toggleFullScreen} from '../utils.ts'; import {localUserSettings} from '../modules/user-settings.ts'; import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts'; +import {AnsiLineRenderer} from '../render/ansi.ts'; import { type ActionRunViewStore, createLogLineMessage, @@ -36,6 +37,9 @@ type JobStepState = { manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again } +// one ANSI renderer per step, so an unterminated color carries between that step's lines only +const stepAnsiRenderers: AnsiLineRenderer[] = []; + type StepContainerElement = HTMLElement & { // To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it, // then the following batches of logs should still use the same group (active logs container). @@ -213,10 +217,11 @@ async function copyStepOutput(event: MouseEvent, stepIndex: number) { const data = await fetchJobData([{step: stepIndex, cursor: null, expanded: true}]); const stepLog = data.logs.stepsLog?.find((s) => s.step === stepIndex); const lines: string[] = []; + const ansi = new AnsiLineRenderer(); for (const line of stepLog?.lines ?? []) { const cmd = parseLogLineCommand(line); if (cmd?.name === 'hidden' || cmd?.name === 'endgroup') continue; - const msg = createLogLineMessage(line, cmd).textContent ?? ''; + const msg = createLogLineMessage(ansi, line, cmd).textContent ?? ''; lines.push(timeVisible.value['log-time-stamp'] ? `${formatDatetimeISO(line.timestamp)} ${msg}` : msg); } return lines.join('\n'); @@ -240,7 +245,7 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'}, formatDatetime(line.timestamp * 1000), // for "Show timestamps" ); - const logMsg = createLogLineMessage(line, cmd); + const logMsg = createLogLineMessage(stepAnsiRenderers[stepIndex] ??= new AnsiLineRenderer(), line, cmd); const seconds = Math.floor(line.timestamp - startTime); const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'}, `${seconds}s`, // for "Show seconds" @@ -598,7 +603,6 @@ async function hashChangeListener() { .job-step-container { max-height: 100%; border-radius: 0 0 var(--border-radius) var(--border-radius); - z-index: 0; } .job-step-container .job-step-summary { @@ -651,9 +655,6 @@ async function hashChangeListener() { background-color: var(--color-console-active-bg); position: sticky; top: 60px; - /* workaround ansi_up issue related to faintStyle generating a CSS stacking context via `opacity` - inline style which caused such elements to render above the .job-step-summary header. */ - z-index: 1; } diff --git a/web_src/js/components/ActionRunView.test.ts b/web_src/js/components/ActionRunView.test.ts index e6eb6585034..040fa06de6e 100644 --- a/web_src/js/components/ActionRunView.test.ts +++ b/web_src/js/components/ActionRunView.test.ts @@ -1,4 +1,5 @@ import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts'; +import {AnsiLineRenderer} from '../render/ansi.ts'; test('LogLineMessage', () => { const cases = { @@ -31,10 +32,11 @@ test('LogLineMessage', () => { '::add-matcher::foo': 'foo', '::remove-matcher foo::': ' foo::', // not correctly parsed, but we don't need it }; + const ansi = new AnsiLineRenderer(); for (const [input, html] of Object.entries(cases)) { const line = {index: 0, timestamp: 0, message: input}; const cmd = parseLogLineCommand(line); - const el = createLogLineMessage(line, cmd); + const el = createLogLineMessage(ansi, line, cmd); expect(el.outerHTML).toBe(html); } }); diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts index d94c6510a1a..f9b3aebd564 100644 --- a/web_src/js/components/ActionRunView.ts +++ b/web_src/js/components/ActionRunView.ts @@ -1,5 +1,5 @@ import {createElementFromAttrs} from '../utils/dom.ts'; -import {renderAnsiInto} from '../render/ansi.ts'; +import type {AnsiLineRenderer} from '../render/ansi.ts'; import {reactive} from 'vue'; import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts'; import type {IntervalId} from '../types.ts'; @@ -76,11 +76,11 @@ function decodeLineMessage(line: LogLine, cmd: LogLineCommand | null): string { if (cmd.name === 'command') return msg; // "command" is only an output tag, do not parse or escape it // "##[cmd]" also escapes ";" and "]" which delimit its header, "::cmd::" does not if (!cmd.prefix.startsWith('::')) msg = msg.replace(/%3B/g, ';').replace(/%5D/g, ']'); - // renderAnsiInto breaks a line per "\r", so "%0D%0A" is one break. "%25" last keeps "%250A" literal + // a line breaks per "\r" when rendered, so "%0D%0A" is one break. "%25" last keeps "%250A" literal return msg.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%'); } -export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) { +export function createLogLineMessage(ansi: AnsiLineRenderer, line: LogLine, cmd: LogLineCommand | null) { const logMsgAttrs = {class: 'log-msg'}; if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error" @@ -90,10 +90,10 @@ export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) if (label) { logMsg.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`)); const msgSpan = document.createElement('span'); - renderAnsiInto(msgSpan, ` ${msgContent.trimStart()}`); + ansi.renderLine(msgSpan, ` ${msgContent.trimStart()}`); logMsg.append(msgSpan); } else { - renderAnsiInto(logMsg, msgContent); + ansi.renderLine(logMsg, msgContent); } return logMsg; } diff --git a/web_src/js/modules/devtest.ts b/web_src/js/modules/devtest.ts index 598690fccd7..01359e0fb5c 100644 --- a/web_src/js/modules/devtest.ts +++ b/web_src/js/modules/devtest.ts @@ -5,6 +5,7 @@ import {showFomanticModal} from './fomantic/modal.ts'; import {createElementFromHTML} from '../utils/dom.ts'; import {html} from '../utils/html.ts'; import {showGlobalErrorMessage} from './errors.ts'; +import {AnsiLineRenderer} from '../render/ansi.ts'; type LevelMap = Record Toast | null>; @@ -56,8 +57,56 @@ function initDevtestPage() { } } +// shows every sequence web_src/js/render/ansi.ts renders. Lines whose output does not explain +// itself are preceded by their own escaped source. +function initDevtestAnsiRender(container: HTMLElement) { + const esc = '\x1b'; + const cells = (count: number, cell: (index: number) => string, separator = '') => + Array.from({length: count}, (_value, index) => cell(index)).join(separator); + const attr = (params: string, label: string) => `${esc}[${params}m${label}${esc}[m`; + const withSource = (line: string) => [attr('2', line.replaceAll(esc, '\\e').replaceAll('\b', '\\b').replaceAll('\r', '\\r')), line]; + + const lines = [ + ...Array.from({length: 16}, (_value, row) => + cells(16, (col) => `${esc}[38;5;${row * 16 + col}m${String(row * 16 + col).padStart(4)}${esc}[0m`)), + ' ', + cells(16, (index) => `${esc}[48;5;${index}m ${String(index).padStart(3)} ${esc}[0m`), + // truecolor, a gradient no palette index can express + cells(77, (col) => `${esc}[48;2;${255 - col * 3};0;${col * 3}m${esc}[38;2;${col * 3};0;${255 - col * 3}m/${esc}[0m`), + ' ', + [cells(10, (code) => attr(String(code), `SGR ${code}`), ' '), attr('53', 'SGR 53')].join(' '), + ' ', + [ + cells(5, (index) => attr(`4:${index + 1}`, `SGR 4:${index + 1}`), ' '), + attr('21', 'SGR 21'), + `${esc}[4:3m${esc}[58;2;135;0;255mtruecolor underline${esc}[59m${esc}[4:0m`, + `${esc}]8;;https://example.com${esc}\\${esc}[3mstyled${esc}[23m hyperlink${esc}]8;;${esc}\\`, + ].join(' '), + ' ', + ...withSource('Reading... 1%\rReading... 50%\rReading... 100%'), + ...withSource(`first${esc}[Ksecond${esc}[2Jthird`), + ...withSource(`cursor ${esc}[3Amovement, private ${esc}[?25lCSI, ${esc}]0;title${esc}\\titles, ${esc}Pquery${esc}\\strings, truncated${esc}[38;5;`), + ...withSource('Reading... 10%\b\b\b100%'), + ...withSource(' & "quotes", and a bare url https://example.com'), + ' ', + `${esc}[31man unterminated color`, + 'carries into the following lines', + `${esc}[0muntil something resets it`, + ]; + + const elConsole = createElementFromHTML(html`
`); + const ansi = new AnsiLineRenderer(); + for (const line of lines) { + const el = document.createElement('div'); + ansi.renderLine(el, line); + elConsole.append(el); + } + container.append(elConsole); +} + export function initDevtest() { registerGlobalInitFunc('initDevtestPage', initDevtestPage); + registerGlobalInitFunc('initDevtestAnsiRender', initDevtestAnsiRender); registerGlobalInitFunc('initDevtestDetailsErrorMessage', () => { for (let i = 0; i < 2; i++) { showGlobalErrorMessage('showGlobalErrorMessage single message', 'warning'); diff --git a/web_src/js/render/ansi.test.ts b/web_src/js/render/ansi.test.ts index e7e7af8928b..7a7713537df 100644 --- a/web_src/js/render/ansi.test.ts +++ b/web_src/js/render/ansi.test.ts @@ -1,9 +1,9 @@ -import {renderAnsiInto} from './ansi.ts'; +import {AnsiLineRenderer} from './ansi.ts'; test('renderAnsi', () => { - const renderAnsi = (line: string) => { + const renderAnsi = (line: string, ansi = new AnsiLineRenderer()) => { const el = document.createElement('div'); - renderAnsiInto(el, line); + ansi.renderLine(el, line); return el.innerHTML; }; @@ -17,12 +17,10 @@ test('renderAnsi', () => { expect(renderAnsi('