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('