mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-15 16:14:10 +00:00
feat(actions): show run status on browser tab favicon (#38071)
This commit is contained in:
9
web_src/js/modules/action-status-icon.test.ts
Normal file
9
web_src/js/modules/action-status-icon.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {getActionStatusIcon} from './action-status-icon.ts';
|
||||
|
||||
test('getActionStatusIcon', () => {
|
||||
expect(getActionStatusIcon('success')).toEqual({name: 'octicon-check', colorClass: 'tw-text-green'});
|
||||
expect(getActionStatusIcon('success', 'circle-fill')).toEqual({name: 'octicon-check-circle-fill', colorClass: 'tw-text-green'});
|
||||
expect(getActionStatusIcon('running')).toEqual({name: 'gitea-running', colorClass: 'tw-text-yellow'});
|
||||
expect(getActionStatusIcon('failure', 'circle-fill')).toEqual({name: 'octicon-x-circle-fill', colorClass: 'tw-text-red'});
|
||||
expect(getActionStatusIcon('cancelled')).toEqual({name: 'octicon-stop', colorClass: 'tw-text-text-light'});
|
||||
});
|
||||
37
web_src/js/modules/action-status-icon.ts
Normal file
37
web_src/js/modules/action-status-icon.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {SvgName} from '../svg.ts';
|
||||
import type {ActionsStatus} from './gitea-actions.ts';
|
||||
|
||||
export type ActionStatusIconVariant = 'circle-fill' | '';
|
||||
|
||||
export type ActionStatusIconSpec = {
|
||||
name: SvgName,
|
||||
colorClass: string,
|
||||
};
|
||||
|
||||
// Keep in sync with templates/repo/icons/action_status.tmpl and ActionStatusIcon.vue.
|
||||
export function getActionStatusIcon(status: ActionsStatus, iconVariant: ActionStatusIconVariant = ''): ActionStatusIconSpec {
|
||||
const circleFill = iconVariant === 'circle-fill';
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return {name: circleFill ? 'octicon-check-circle-fill' : 'octicon-check', colorClass: 'tw-text-green'};
|
||||
case 'skipped':
|
||||
return {name: 'octicon-skip', colorClass: 'tw-text-text-light'};
|
||||
case 'cancelled':
|
||||
return {name: 'octicon-stop', colorClass: 'tw-text-text-light'};
|
||||
case 'waiting':
|
||||
return {name: 'octicon-circle', colorClass: 'tw-text-text-light'};
|
||||
case 'blocked':
|
||||
return {name: 'octicon-blocked', colorClass: 'tw-text-yellow'};
|
||||
case 'running':
|
||||
return {name: 'gitea-running', colorClass: 'tw-text-yellow'};
|
||||
case 'cancelling':
|
||||
return {name: 'octicon-stop', colorClass: 'tw-text-yellow'};
|
||||
case 'failure':
|
||||
case 'unknown':
|
||||
return {name: circleFill ? 'octicon-x-circle-fill' : 'octicon-x', colorClass: 'tw-text-red'};
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
web_src/js/modules/favicon-status.test.ts
Normal file
29
web_src/js/modules/favicon-status.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {buildStatusFaviconSvg, resetActionFavicon, syncActionRunFavicon} from './favicon-status.ts';
|
||||
|
||||
test('buildStatusFaviconSvg uses action status icons', () => {
|
||||
const success = buildStatusFaviconSvg('success');
|
||||
expect(success).toContain('viewBox="0 0 640 640"');
|
||||
expect(success).toContain('fill:#609926');
|
||||
expect(success).toContain('data-actions-status-name="success"');
|
||||
|
||||
const running = buildStatusFaviconSvg('running');
|
||||
expect(running).toContain('data-actions-status-name="running"');
|
||||
|
||||
const failure = buildStatusFaviconSvg('failure');
|
||||
expect(failure).toContain('data-actions-status-name="failure"');
|
||||
});
|
||||
|
||||
test('syncActionRunFavicon updates favicon links', () => {
|
||||
document.head.innerHTML = `
|
||||
<link rel="icon" href="/assets/img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="/assets/img/favicon.png" type="image/png">
|
||||
`;
|
||||
const links = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
|
||||
syncActionRunFavicon('running');
|
||||
for (const link of links) {
|
||||
expect(link.href).toMatch(/^data:image\/svg\+xml,/);
|
||||
expect(decodeURIComponent(link.href)).toContain('data-actions-status-name="running"');
|
||||
}
|
||||
resetActionFavicon();
|
||||
expect(links[0].href).toContain('favicon.svg');
|
||||
});
|
||||
90
web_src/js/modules/favicon-status.ts
Normal file
90
web_src/js/modules/favicon-status.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {getActionStatusIcon} from './action-status-icon.ts';
|
||||
import type {ActionsStatus} from './gitea-actions.ts';
|
||||
import {svgParseOuterInner} from '../svg.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
|
||||
const {svgOuter, svgInnerHtml: giteaFaviconInner} = svgParseOuterInner('gitea-favicon');
|
||||
const faviconViewBox = svgOuter.getAttribute('viewBox')!;
|
||||
const [, , faviconViewBoxWidth, faviconViewBoxHeight] = faviconViewBox.split(/\s+/).map(Number);
|
||||
|
||||
// the status badge is rendered in the bottom-right corner, following GitHub Actions favicon proportions
|
||||
const badgeIconSize = 16;
|
||||
const badgeSizeRatio = 340 / 640;
|
||||
const badgeMargin = 6;
|
||||
const badgeDrawSize = faviconViewBoxWidth * badgeSizeRatio;
|
||||
const badgeX = faviconViewBoxWidth - badgeDrawSize - badgeMargin;
|
||||
const badgeY = faviconViewBoxHeight - badgeDrawSize - badgeMargin;
|
||||
const badgeScale = badgeDrawSize / badgeIconSize;
|
||||
// white ring behind the badge so it stands out from the logo, like GitHub's favicon
|
||||
const badgeCenter = badgeDrawSize / 2;
|
||||
const badgeRingRadius = badgeCenter + badgeDrawSize * 0.08;
|
||||
|
||||
let currentStatus: ActionsStatus | null = null;
|
||||
const defaultFaviconHrefs = new Map<HTMLLinkElement, string>();
|
||||
const faviconDataUrlCache = new Map<ActionsStatus, string>();
|
||||
let colorProbe: HTMLElement | null = null;
|
||||
|
||||
function rememberDefaultFaviconHrefs() {
|
||||
if (defaultFaviconHrefs.size > 0) return;
|
||||
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')) {
|
||||
defaultFaviconHrefs.set(link, link.href);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTailwindTextColor(colorClass: string): string {
|
||||
if (!colorProbe) {
|
||||
colorProbe = document.createElement('span');
|
||||
colorProbe.style.display = 'none';
|
||||
document.body.append(colorProbe);
|
||||
}
|
||||
colorProbe.className = colorClass;
|
||||
return getComputedStyle(colorProbe).color || '#000000';
|
||||
}
|
||||
|
||||
function buildStatusIconMarkup(status: ActionsStatus): string {
|
||||
const {name, colorClass} = getActionStatusIcon(status, 'circle-fill');
|
||||
const color = resolveTailwindTextColor(colorClass);
|
||||
const {svgInnerHtml} = svgParseOuterInner(name);
|
||||
const coloredInner = svgInnerHtml.replaceAll('currentColor', color);
|
||||
const ring = html`<circle cx="${badgeX + badgeCenter}" cy="${badgeY + badgeCenter}" r="${badgeRingRadius}" fill="#ffffff"/>`;
|
||||
const badge = html`<g data-actions-status-name="${status}" transform="translate(${badgeX}, ${badgeY}) scale(${badgeScale})" fill="${color}" color="${color}">${htmlRaw(coloredInner)}</g>`;
|
||||
return html`${htmlRaw(ring)}${htmlRaw(badge)}`;
|
||||
}
|
||||
|
||||
export function buildStatusFaviconSvg(status: ActionsStatus): string {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${faviconViewBox}">${htmlRaw(giteaFaviconInner)}${htmlRaw(buildStatusIconMarkup(status))}</svg>`;
|
||||
}
|
||||
|
||||
function buildStatusFaviconDataUrl(status: ActionsStatus): string {
|
||||
const cached = faviconDataUrlCache.get(status);
|
||||
if (cached) return cached;
|
||||
const dataUrl = `data:image/svg+xml,${encodeURIComponent(buildStatusFaviconSvg(status))}`;
|
||||
faviconDataUrlCache.set(status, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
function setFaviconHref(href: string) {
|
||||
rememberDefaultFaviconHrefs();
|
||||
for (const link of defaultFaviconHrefs.keys()) {
|
||||
if (link.isConnected) link.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
export function syncActionRunFavicon(status: ActionsStatus | ''): void {
|
||||
if (status === '') {
|
||||
resetActionFavicon();
|
||||
return;
|
||||
}
|
||||
if (status === currentStatus) return;
|
||||
setFaviconHref(buildStatusFaviconDataUrl(status));
|
||||
currentStatus = status;
|
||||
}
|
||||
|
||||
export function resetActionFavicon(): void {
|
||||
if (currentStatus === null) return;
|
||||
rememberDefaultFaviconHrefs();
|
||||
for (const [link, href] of defaultFaviconHrefs) {
|
||||
if (link.isConnected) link.href = href;
|
||||
}
|
||||
currentStatus = null;
|
||||
}
|
||||
Reference in New Issue
Block a user