Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web_src/css/themes/theme-gitea-dark.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ gitea-theme-meta-info {
--color-console-active-bg: #2e353b;
--color-console-menu-bg: #262b31;
--color-console-menu-border: #414b55;
--color-console-link: #8f9ba8;
/* named colors */
--color-red: #cc4848;
--color-orange: #cc580c;
Expand Down
1 change: 1 addition & 0 deletions web_src/css/themes/theme-gitea-light.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ gitea-theme-meta-info {
--color-console-active-bg: #d0d7de;
--color-console-menu-bg: #f8f9fb;
--color-console-menu-border: #d0d7de;
--color-console-link: #5c656d;
/* named colors */
--color-red: #db2828;
--color-orange: #f2711c;
Expand Down
5 changes: 5 additions & 0 deletions web_src/js/components/ActionRunJobView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,11 @@ async function hashChangeListener() {
overflow-wrap: anywhere;
}

.job-step-logs .log-msg a {
color: var(--color-console-link) !important;
text-decoration: underline;
}

.job-step-logs .job-log-line .log-cmd-command {
color: var(--color-ansi-blue);
}
Expand Down
5 changes: 5 additions & 0 deletions web_src/js/render/ansi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ test('renderAnsi', () => {
// treat "\033[0K" and "\033[0J" (Erase display/line) as "\r", then it will be covered to "\n" finally.
expect(renderAnsi('a\x1b[Kb\x1b[2Jc')).toEqual('a\nb\nc');
expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`<span style="background-color:rgb(135,0,0)">a</span><span style="background-color:rgb(175,255,255)">b</span>`);

// URLs in ANSI output become clickable links
const link = (url: string) => `<a href="${url}" target="_blank">${url}</a>`;
expect(renderAnsi('Downloading https://github.com/actions/upload-artifact/releases')).toEqual(`Downloading ${link('https://github.com/actions/upload-artifact/releases')}`);
expect(renderAnsi('\x1b[32mhttps://proxy.golang.org/cached-only\x1b[0m')).toEqual(`<span class="ansi-green-fg">${link('https://proxy.golang.org/cached-only')}</span>`);
});
29 changes: 16 additions & 13 deletions web_src/js/render/ansi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {AnsiUp} from 'ansi_up';
import {linkifyURLs} from '../utils/url.ts';

const replacements: Array<[RegExp, string]> = [
[/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op
Expand All @@ -25,21 +26,23 @@ export function renderAnsi(line: string): string {
}
}

let result: string;
if (!line.includes('\r')) {
return ansi_up.ansi_to_html(line);
}

// handle "\rReading...1%\rReading...5%\rReading...100%",
// convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%"
const lines: Array<string> = [];
for (const part of line.split('\r')) {
if (part === '') continue;
const partHtml = ansi_up.ansi_to_html(part);
if (partHtml !== '') {
lines.push(partHtml);
result = ansi_up.ansi_to_html(line);
} else {
// handle "\rReading...1%\rReading...5%\rReading...100%",
// convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%"
const lines: Array<string> = [];
for (const part of line.split('\r')) {
if (part === '') continue;
const partHtml = ansi_up.ansi_to_html(part);
if (partHtml !== '') {
lines.push(partHtml);
}
}
// the log message element is with "white-space: break-spaces;", so use "\n" to break lines
result = lines.join('\n');
}

// the log message element is with "white-space: break-spaces;", so use "\n" to break lines
return lines.join('\n');
return linkifyURLs(result);
}
22 changes: 21 additions & 1 deletion web_src/js/utils/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import {pathEscapeSegments, toOriginUrl} from './url.ts';
import {linkifyURLs, pathEscapeSegments, toOriginUrl} from './url.ts';

test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
});

test('linkifyURLs', () => {
const link = (url: string) => `<a href="${url}" target="_blank">${url}</a>`;
expect(linkifyURLs('https://example.com')).toEqual(link('https://example.com'));
expect(linkifyURLs('https://dl.google.com/go/go1.23.6.linux-amd64.tar.gz')).toEqual(link('https://dl.google.com/go/go1.23.6.linux-amd64.tar.gz'));
expect(linkifyURLs('https://example.com/path?query=1&amp;b=2#frag')).toEqual(link('https://example.com/path?query=1&amp;b=2#frag'));
expect(linkifyURLs('visit https://example.com/repo for info')).toEqual(`visit ${link('https://example.com/repo')} for info`);
expect(linkifyURLs('See https://example.com.')).toEqual(`See ${link('https://example.com')}.`);
expect(linkifyURLs('https://example.com, and more')).toEqual(`${link('https://example.com')}, and more`);
expect(linkifyURLs('<span class="ansi-green-fg">https://proxy.golang.org/cached-only</span>')).toEqual(`<span class="ansi-green-fg">${link('https://proxy.golang.org/cached-only')}</span>`);
expect(linkifyURLs('<span style="color:rgb(0,255,0)">https://registry.npmjs.org/@types/node</span>')).toEqual(`<span style="color:rgb(0,255,0)">${link('https://registry.npmjs.org/@types/node')}</span>`);
expect(linkifyURLs('https://a.com and https://b.org')).toEqual(`${link('https://a.com')} and ${link('https://b.org')}`);
expect(linkifyURLs('no urls here')).toEqual('no urls here');
expect(linkifyURLs('http://example.com/path')).toEqual(link('http://example.com/path'));
expect(linkifyURLs('http://localhost:3000/repo')).toEqual(link('http://localhost:3000/repo'));
expect(linkifyURLs('https://')).toEqual('https://');
// existing <a> tags from ansi_up OSC 8 hyperlinks are not double-linkified
Comment thread
silverwind marked this conversation as resolved.
Outdated
expect(linkifyURLs('<a href="https://example.com">Click here</a>')).toEqual('<a href="https://example.com">Click here</a>');
expect(linkifyURLs('<a href="https://example.com">https://example.com</a>')).toEqual('<a href="https://example.com">https://example.com</a>');
});

test('toOriginUrl', () => {
const oldLocation = String(window.location);
for (const origin of ['https://example.com', 'https://example.com:3000']) {
Expand Down
26 changes: 26 additions & 0 deletions web_src/js/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@ export function pathEscapeSegments(s: string): string {
return s.split('/').map(encodeURIComponent).join('/');
}

// Match HTML tags (to skip) or URLs (to linkify) in ANSI-rendered HTML output
const urlLinkifyPattern = /(<[^>]*>)|(https?:\/\/[^\s<>"'`|(){}[\]]+)/gi;
const trailingPunctPattern = /[.,;:!?]+$/;

// Convert URLs to clickable links in HTML, preserving existing HTML tags
export function linkifyURLs(html: string): string {
let inAnchor = false;
return html.replace(urlLinkifyPattern, (_match, tag, url) => {
if (tag) {
// skip URLs inside existing <a> tags (e.g. from ansi_up OSC 8 hyperlinks)
Comment thread
silverwind marked this conversation as resolved.
Outdated
if (tag.startsWith('<a ') || tag.startsWith('<a>')) { // eslint-disable-line github/unescaped-html-literal
inAnchor = true;
} else if (tag === '</a>') {
inAnchor = false;
}
return tag;
}
if (inAnchor) return url;
const trailingPunct = url.match(trailingPunctPattern);
const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
const trailing = trailingPunct ? trailingPunct[0] : '';
// safe because ansi_up already HTML-escaped the content
return `<a href="${cleanUrl}" target="_blank">${cleanUrl}</a>${trailing}`; // eslint-disable-line github/unescaped-html-literal
});
}

/** Convert an absolute or relative URL to an absolute URL with the current origin. It only
* processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */
export function toOriginUrl(urlStr: string) {
Expand Down
Loading