Skip to content

Commit 86bc004

Browse files
authored
feat(actions): show run status on browser tab favicon (go-gitea#38071)
1 parent 454f456 commit 86bc004

7 files changed

Lines changed: 188 additions & 11 deletions

File tree

web_src/js/components/ActionStatusIcon.vue

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,33 @@
22
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, cancelling, unknown.
33
-->
44
<script lang="ts" setup>
5+
import {computed} from 'vue';
56
import {SvgIcon} from '../svg.ts';
7+
import {getActionStatusIcon, type ActionStatusIconVariant} from '../modules/action-status-icon.ts';
68
79
const props = withDefaults(defineProps<{
810
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'cancelling' | 'unknown',
911
size?: number,
1012
className?: string,
1113
localeStatus?: string,
12-
iconVariant?: 'circle-fill' | '',
14+
iconVariant?: ActionStatusIconVariant,
1315
}>(), {
1416
size: 16,
1517
className: '',
1618
localeStatus: undefined,
1719
iconVariant: '',
1820
});
19-
const circleFill = props.iconVariant === 'circle-fill';
21+
22+
const icon = computed(() => getActionStatusIcon(props.status, props.iconVariant));
23+
const iconClass = computed(() => {
24+
const classes = [icon.value.colorClass, props.className];
25+
if (props.status === 'running') classes.push('rotate-clockwise');
26+
return classes.filter(Boolean).join(' ');
27+
});
2028
</script>
2129

2230
<template>
2331
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
24-
<SvgIcon :name="circleFill ? 'octicon-check-circle-fill' : 'octicon-check'" class="tw-text-green" :size="size" :class="className" v-if="status === 'success'"/>
25-
<SvgIcon name="octicon-skip" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'skipped'"/>
26-
<SvgIcon name="octicon-stop" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
27-
<SvgIcon name="octicon-circle" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'waiting'"/>
28-
<SvgIcon name="octicon-blocked" class="tw-text-yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
29-
<SvgIcon name="gitea-running" class="tw-text-yellow" :size="size" :class="'rotate-clockwise ' + className" v-else-if="status === 'running'"/>
30-
<SvgIcon name="octicon-stop" class="tw-text-yellow" :size="size" :class="className" v-else-if="status === 'cancelling'"/>
31-
<SvgIcon :name="circleFill ? 'octicon-x-circle-fill' : 'octicon-x'" class="tw-text-red" :size="size" :class="className" v-else/><!-- failure, unknown -->
32+
<SvgIcon :name="icon.name" :class="iconClass" :size="size"/>
3233
</span>
3334
</template>

web_src/js/components/RepoActionView.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script setup lang="ts">
22
import {SvgIcon} from '../svg.ts';
33
import ActionStatusIcon from './ActionStatusIcon.vue';
4-
import {computed, ref, toRefs} from 'vue';
4+
import {computed, onBeforeUnmount, ref, toRefs, watch} from 'vue';
5+
import {resetActionFavicon, syncActionRunFavicon} from '../modules/favicon-status.ts';
56
import {POST, DELETE} from '../modules/fetch.ts';
67
import ActionRunSummaryView from './ActionRunSummaryView.vue';
78
import ActionRunJobView from './ActionRunJobView.vue';
@@ -118,6 +119,14 @@ async function deleteArtifact(name: string) {
118119
await DELETE(buildArtifactLink(name));
119120
await store.forceReloadCurrentRun();
120121
}
122+
123+
watch(() => run.value.status, (status) => {
124+
syncActionRunFavicon(status);
125+
});
126+
127+
onBeforeUnmount(() => {
128+
resetActionFavicon();
129+
});
121130
</script>
122131
<template>
123132
<!-- make the view container full width to make users easier to read logs -->
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {getActionStatusIcon} from './action-status-icon.ts';
2+
3+
test('getActionStatusIcon', () => {
4+
expect(getActionStatusIcon('success')).toEqual({name: 'octicon-check', colorClass: 'tw-text-green'});
5+
expect(getActionStatusIcon('success', 'circle-fill')).toEqual({name: 'octicon-check-circle-fill', colorClass: 'tw-text-green'});
6+
expect(getActionStatusIcon('running')).toEqual({name: 'gitea-running', colorClass: 'tw-text-yellow'});
7+
expect(getActionStatusIcon('failure', 'circle-fill')).toEqual({name: 'octicon-x-circle-fill', colorClass: 'tw-text-red'});
8+
expect(getActionStatusIcon('cancelled')).toEqual({name: 'octicon-stop', colorClass: 'tw-text-text-light'});
9+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type {SvgName} from '../svg.ts';
2+
import type {ActionsStatus} from './gitea-actions.ts';
3+
4+
export type ActionStatusIconVariant = 'circle-fill' | '';
5+
6+
export type ActionStatusIconSpec = {
7+
name: SvgName,
8+
colorClass: string,
9+
};
10+
11+
// Keep in sync with templates/repo/icons/action_status.tmpl and ActionStatusIcon.vue.
12+
export function getActionStatusIcon(status: ActionsStatus, iconVariant: ActionStatusIconVariant = ''): ActionStatusIconSpec {
13+
const circleFill = iconVariant === 'circle-fill';
14+
switch (status) {
15+
case 'success':
16+
return {name: circleFill ? 'octicon-check-circle-fill' : 'octicon-check', colorClass: 'tw-text-green'};
17+
case 'skipped':
18+
return {name: 'octicon-skip', colorClass: 'tw-text-text-light'};
19+
case 'cancelled':
20+
return {name: 'octicon-stop', colorClass: 'tw-text-text-light'};
21+
case 'waiting':
22+
return {name: 'octicon-circle', colorClass: 'tw-text-text-light'};
23+
case 'blocked':
24+
return {name: 'octicon-blocked', colorClass: 'tw-text-yellow'};
25+
case 'running':
26+
return {name: 'gitea-running', colorClass: 'tw-text-yellow'};
27+
case 'cancelling':
28+
return {name: 'octicon-stop', colorClass: 'tw-text-yellow'};
29+
case 'failure':
30+
case 'unknown':
31+
return {name: circleFill ? 'octicon-x-circle-fill' : 'octicon-x', colorClass: 'tw-text-red'};
32+
default: {
33+
const _exhaustive: never = status;
34+
return _exhaustive;
35+
}
36+
}
37+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {buildStatusFaviconSvg, resetActionFavicon, syncActionRunFavicon} from './favicon-status.ts';
2+
3+
test('buildStatusFaviconSvg uses action status icons', () => {
4+
const success = buildStatusFaviconSvg('success');
5+
expect(success).toContain('viewBox="0 0 640 640"');
6+
expect(success).toContain('fill:#609926');
7+
expect(success).toContain('data-actions-status-name="success"');
8+
9+
const running = buildStatusFaviconSvg('running');
10+
expect(running).toContain('data-actions-status-name="running"');
11+
12+
const failure = buildStatusFaviconSvg('failure');
13+
expect(failure).toContain('data-actions-status-name="failure"');
14+
});
15+
16+
test('syncActionRunFavicon updates favicon links', () => {
17+
document.head.innerHTML = `
18+
<link rel="icon" href="/assets/img/favicon.svg" type="image/svg+xml">
19+
<link rel="alternate icon" href="/assets/img/favicon.png" type="image/png">
20+
`;
21+
const links = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
22+
syncActionRunFavicon('running');
23+
for (const link of links) {
24+
expect(link.href).toMatch(/^data:image\/svg\+xml,/);
25+
expect(decodeURIComponent(link.href)).toContain('data-actions-status-name="running"');
26+
}
27+
resetActionFavicon();
28+
expect(links[0].href).toContain('favicon.svg');
29+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {getActionStatusIcon} from './action-status-icon.ts';
2+
import type {ActionsStatus} from './gitea-actions.ts';
3+
import {svgParseOuterInner} from '../svg.ts';
4+
import {html, htmlRaw} from '../utils/html.ts';
5+
6+
const {svgOuter, svgInnerHtml: giteaFaviconInner} = svgParseOuterInner('gitea-favicon');
7+
const faviconViewBox = svgOuter.getAttribute('viewBox')!;
8+
const [, , faviconViewBoxWidth, faviconViewBoxHeight] = faviconViewBox.split(/\s+/).map(Number);
9+
10+
// the status badge is rendered in the bottom-right corner, following GitHub Actions favicon proportions
11+
const badgeIconSize = 16;
12+
const badgeSizeRatio = 340 / 640;
13+
const badgeMargin = 6;
14+
const badgeDrawSize = faviconViewBoxWidth * badgeSizeRatio;
15+
const badgeX = faviconViewBoxWidth - badgeDrawSize - badgeMargin;
16+
const badgeY = faviconViewBoxHeight - badgeDrawSize - badgeMargin;
17+
const badgeScale = badgeDrawSize / badgeIconSize;
18+
// white ring behind the badge so it stands out from the logo, like GitHub's favicon
19+
const badgeCenter = badgeDrawSize / 2;
20+
const badgeRingRadius = badgeCenter + badgeDrawSize * 0.08;
21+
22+
let currentStatus: ActionsStatus | null = null;
23+
const defaultFaviconHrefs = new Map<HTMLLinkElement, string>();
24+
const faviconDataUrlCache = new Map<ActionsStatus, string>();
25+
let colorProbe: HTMLElement | null = null;
26+
27+
function rememberDefaultFaviconHrefs() {
28+
if (defaultFaviconHrefs.size > 0) return;
29+
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')) {
30+
defaultFaviconHrefs.set(link, link.href);
31+
}
32+
}
33+
34+
function resolveTailwindTextColor(colorClass: string): string {
35+
if (!colorProbe) {
36+
colorProbe = document.createElement('span');
37+
colorProbe.style.display = 'none';
38+
document.body.append(colorProbe);
39+
}
40+
colorProbe.className = colorClass;
41+
return getComputedStyle(colorProbe).color || '#000000';
42+
}
43+
44+
function buildStatusIconMarkup(status: ActionsStatus): string {
45+
const {name, colorClass} = getActionStatusIcon(status, 'circle-fill');
46+
const color = resolveTailwindTextColor(colorClass);
47+
const {svgInnerHtml} = svgParseOuterInner(name);
48+
const coloredInner = svgInnerHtml.replaceAll('currentColor', color);
49+
const ring = html`<circle cx="${badgeX + badgeCenter}" cy="${badgeY + badgeCenter}" r="${badgeRingRadius}" fill="#ffffff"/>`;
50+
const badge = html`<g data-actions-status-name="${status}" transform="translate(${badgeX}, ${badgeY}) scale(${badgeScale})" fill="${color}" color="${color}">${htmlRaw(coloredInner)}</g>`;
51+
return html`${htmlRaw(ring)}${htmlRaw(badge)}`;
52+
}
53+
54+
export function buildStatusFaviconSvg(status: ActionsStatus): string {
55+
return html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${faviconViewBox}">${htmlRaw(giteaFaviconInner)}${htmlRaw(buildStatusIconMarkup(status))}</svg>`;
56+
}
57+
58+
function buildStatusFaviconDataUrl(status: ActionsStatus): string {
59+
const cached = faviconDataUrlCache.get(status);
60+
if (cached) return cached;
61+
const dataUrl = `data:image/svg+xml,${encodeURIComponent(buildStatusFaviconSvg(status))}`;
62+
faviconDataUrlCache.set(status, dataUrl);
63+
return dataUrl;
64+
}
65+
66+
function setFaviconHref(href: string) {
67+
rememberDefaultFaviconHrefs();
68+
for (const link of defaultFaviconHrefs.keys()) {
69+
if (link.isConnected) link.href = href;
70+
}
71+
}
72+
73+
export function syncActionRunFavicon(status: ActionsStatus | ''): void {
74+
if (status === '') {
75+
resetActionFavicon();
76+
return;
77+
}
78+
if (status === currentStatus) return;
79+
setFaviconHref(buildStatusFaviconDataUrl(status));
80+
currentStatus = status;
81+
}
82+
83+
export function resetActionFavicon(): void {
84+
if (currentStatus === null) return;
85+
rememberDefaultFaviconHrefs();
86+
for (const [link, href] of defaultFaviconHrefs) {
87+
if (link.isConnected) link.href = href;
88+
}
89+
currentStatus = null;
90+
}

web_src/js/svg.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-che
55
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
66
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
77
import giteaExclamation from '../../public/assets/img/svg/gitea-exclamation.svg';
8+
import giteaFavicon from '../../public/assets/img/favicon.svg';
89
import giteaRunning from '../../public/assets/img/svg/gitea-running.svg';
910
import octiconArchive from '../../public/assets/img/svg/octicon-archive.svg';
1011
import octiconArrowLeft from '../../public/assets/img/svg/octicon-arrow-left.svg';
@@ -93,6 +94,7 @@ const svgs = {
9394
'gitea-double-chevron-right': giteaDoubleChevronRight,
9495
'gitea-empty-checkbox': giteaEmptyCheckbox,
9596
'gitea-exclamation': giteaExclamation,
97+
'gitea-favicon': giteaFavicon,
9698
'gitea-running': giteaRunning,
9799
'octicon-archive': octiconArchive,
98100
'octicon-arrow-left': octiconArrowLeft,

0 commit comments

Comments
 (0)