feat(actions): implement adaptive auto-refresh for workflow runs list (#38329)

### Description
This PR implements an optimized, adaptive client-side auto-refresh
mechanism for the Gitea Actions workflow runs list page. It allows users
to see workflow progress updates dynamically without having to reload
the page.

Fixes https://github.com/go-gitea/gitea/issues/37457
---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Shudhanshu Singh
2026-07-12 12:14:37 +00:00
committed by GitHub
co-authored by wxiaoguang
parent 65c5a5ff7b
commit b998c3c1aa
9 changed files with 282 additions and 107 deletions
+29
View File
@@ -2,6 +2,9 @@ import {createApp} from 'vue';
import RepoActionView from '../components/RepoActionView.vue';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {html} from '../utils/html.ts';
import {GET} from '../modules/fetch.ts';
import {activePageTimerRefresh, createElementFromHTML, protectMorphElements, recoverMorphElements} from '../utils/dom.ts';
import {Idiomorph} from 'idiomorph';
export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void {
const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!);
@@ -27,6 +30,7 @@ function initWorkflowBadgeForm(form: HTMLElement): void {
export function initRepositoryActions() {
registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm);
initRepositoryActionsView();
registerGlobalInitFunc('initActionRunsList', initActionRunsList);
}
function initRepositoryActionsView() {
@@ -103,3 +107,28 @@ function initRepositoryActionsView() {
});
view.mount(el);
}
function initActionRunsList(el: HTMLElement) {
activePageTimerRefresh({
interval: () => Number(el.getAttribute('data-action-runs-refresh-interval')),
async callback() {
const resp = await GET(el.getAttribute('data-action-runs-refresh-link')!);
if (!resp.ok || resp.status !== 200) return;
const newEl = createElementFromHTML(await resp.text());
for (const attr of newEl.attributes) el.setAttribute(attr.name, attr.value);
for (const newItem of newEl.querySelectorAll(':scope > .item')) {
const oldItem = el.querySelector(`#${newItem.id}`);
if (!oldItem) continue;
// If the end user is operating the row, then don't refresh its content.
// Otherwise, there will be more edge cases and inconsistencies, e.g.: dropdown still shows old items but the icon has changed.
if (oldItem.querySelector('.ui.dropdown.active')) continue;
const protectedElems = protectMorphElements(newItem);
Idiomorph.morph(oldItem, newItem, {morphStyle: 'outerHTML'});
recoverMorphElements(el.querySelector(`#${newItem.id}`)!, protectedElems);
}
},
});
}
+19 -4
View File
@@ -1,8 +1,7 @@
import {
createElementFromAttrs,
createElementFromHTML,
queryElemChildren,
querySingleVisibleElem,
createElementFromAttrs, createElementFromHTML,
queryElemChildren, querySingleVisibleElem,
protectMorphElements, recoverMorphElements,
toggleElem,
} from './dom.ts';
@@ -54,3 +53,19 @@ test('toggleElem', () => {
toggleElem(el.children, true);
expect(el.outerHTML).toEqual('<div><div class="">a</div><div class="">b</div></div>');
});
test('protectMorphElements', () => {
const el = createElementFromHTML('<div><span data-morph-protect="">foo</span></div>');
const protectedElems = protectMorphElements(el);
const span = el.querySelector('span')!;
const spanMorphProtectId = span.getAttribute('data-morph-protect');
expect(spanMorphProtectId).toBeTruthy();
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}">foo</span></div>`);
span.textContent = 'bar';
span.classList.add('new-class');
expect(el.outerHTML).toEqual(`<div><span data-morph-protect="${spanMorphProtectId}" class="new-class">bar</span></div>`);
recoverMorphElements(el, protectedElems);
expect(el.outerHTML).toEqual('<div><span data-morph-protect="">foo</span></div>');
});
+23
View File
@@ -411,3 +411,26 @@ export function activePageTimerRefresh(opts: ActivePageTimerOptions): ActivePage
timer.start();
return timer;
}
type ProtectedMorphElements = Record<string, string>;
export function protectMorphElements(el: Element): ProtectedMorphElements {
// Some components like Dropdown manage their internal states and styles.
// If morph changes any style or layout, then the Dropdown will stop working.
// So, protect such fragile components ahead, then morph, then recover.
const ret: ProtectedMorphElements = {};
for (const it of el.querySelectorAll('.ui.dropdown, [data-morph-protect]')) {
const id = generateElemId();
ret[id] = it.outerHTML;
it.setAttribute('data-morph-protect', id);
}
return ret;
}
export function recoverMorphElements(el: Element, protectedElems: ProtectedMorphElements) {
for (const [id, html] of Object.entries(protectedElems)) {
const it = el.querySelector(`[data-morph-protect="${CSS.escape(id)}"]`);
if (!it) continue;
it.outerHTML = html;
}
}