mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-22 04:59:45 +02:00
Merge remote-tracking branch 'upstream/main' into ssh-mirror-migrations
# Conflicts: # go.mod # models/repo/mirror.go # modules/git/gitcmd/command.go # modules/git/remote.go # routers/web/user/setting/keys.go # services/repository/migrate.go
This commit is contained in:
@@ -350,9 +350,7 @@ a.label,
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
mask-image: var(--select-arrows);
|
||||
-webkit-mask-image: var(--select-arrows);
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
@@ -384,16 +382,6 @@ a.label,
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
/* styles from removed fomantic transition module */
|
||||
.hidden.transition {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
.visible.transition {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.ui.comments .comment .metadata {
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
@@ -440,11 +428,6 @@ img.ui.avatar,
|
||||
margin-top: calc(var(--page-spacing) - 1rem);
|
||||
}
|
||||
|
||||
.ui.message.flash-message pre {
|
||||
white-space: pre-line;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ui .header > i + .content {
|
||||
padding-left: 0.75rem;
|
||||
vertical-align: middle;
|
||||
@@ -488,10 +471,6 @@ img.ui.avatar,
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.ui .border {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.user-menu > .item {
|
||||
width: 100%;
|
||||
border-radius: 0 !important;
|
||||
|
||||
@@ -22,8 +22,9 @@
|
||||
@import "./modules/tab.css";
|
||||
@import "./modules/form.css";
|
||||
@import "./modules/dropdown.css";
|
||||
@import "./modules/shortcut.css";
|
||||
@import "./modules/transition.css";
|
||||
|
||||
@import "./modules/shortcut.css";
|
||||
@import "./modules/tippy.css";
|
||||
@import "./modules/breadcrumb.css";
|
||||
@import "./modules/comment.css";
|
||||
|
||||
@@ -101,13 +101,6 @@ code.language-math.is-loading::after {
|
||||
animation: pulse-1p5 200ms linear;
|
||||
}
|
||||
|
||||
.ui.modal,
|
||||
.ui.dimmer.transition {
|
||||
animation-name: fadein;
|
||||
animation-duration: 100ms;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.rotate-clockwise {
|
||||
animation: rotate-clockwise-keyframes 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -35,13 +35,9 @@ input[type="checkbox"]::before {
|
||||
height: var(--checkbox-size);
|
||||
clip-path: inset(var(--checkbox-size) 0 0 0);
|
||||
mask-image: var(--checkbox-mask-checked);
|
||||
-webkit-mask-image: var(--checkbox-mask-checked);
|
||||
mask-size: 75%;
|
||||
-webkit-mask-size: 75%;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
-webkit-mask-position: center;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked,
|
||||
@@ -67,9 +63,7 @@ input[type="radio"]:disabled:checked {
|
||||
input[type="checkbox"]:indeterminate::before {
|
||||
clip-path: inset(0);
|
||||
mask-image: var(--checkbox-mask-indeterminate);
|
||||
-webkit-mask-image: var(--checkbox-mask-indeterminate);
|
||||
mask-size: 75%;
|
||||
-webkit-mask-size: 75%;
|
||||
}
|
||||
|
||||
.ui.checkbox {
|
||||
|
||||
@@ -127,8 +127,6 @@
|
||||
background-color: currentcolor;
|
||||
mask-image: var(--octicon-x);
|
||||
mask-size: cover;
|
||||
-webkit-mask-image: var(--octicon-x);
|
||||
-webkit-mask-size: cover;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +209,6 @@ select.ui.dropdown {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
backface-visibility: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-top-width: 0 !important;
|
||||
outline: none;
|
||||
margin: 0 -1px;
|
||||
@@ -379,7 +378,6 @@ select.ui.dropdown {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
backface-visibility: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 767.98px) {
|
||||
@@ -653,7 +651,6 @@ select.ui.dropdown {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
backface-visibility: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-width: 100% !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
@@ -311,6 +311,7 @@
|
||||
.ui.vertical.menu .item > .menu {
|
||||
margin: 0.5em -1.14285714em 0;
|
||||
}
|
||||
|
||||
.ui.vertical.menu .menu .item {
|
||||
background: none;
|
||||
padding: 0.5em 1.33333333em;
|
||||
|
||||
@@ -31,6 +31,11 @@ details.ui.message:not(:has(pre)) summary {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.ui.message.flash-message pre {
|
||||
white-space: pre-line;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ui.message:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/* styles for Fomantic transition (toggle): dimmer, modal, dropdown menu */
|
||||
|
||||
/* this is the only place using "hidden" and "visible" classes, it can safely work with tailwind without prefix in the future */
|
||||
.ui.modal.transition.hidden,
|
||||
.ui.dropdown > .menu.transition.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ui.modal.transition.visible,
|
||||
.ui.dropdown > .menu.transition.visible {
|
||||
display: block;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* dimmer uses "active" but not "hidden/visible" classes, no special classes for it here */
|
||||
|
||||
/* only modal and dimmer need animation, dropdown menu doesn't */
|
||||
.ui.modal.transition,
|
||||
.ui.dimmer.transition {
|
||||
animation-name: fadein;
|
||||
animation-duration: 100ms;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#issue-list .branches {
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
height: 14px;
|
||||
background-color: var(--color-yellow); /* TODO: maybe it needs a new kind of color, there is no suitable "warning" color in the current palette */
|
||||
mask-image: var(--octicon-alert-fill);
|
||||
-webkit-mask-image: var(--octicon-alert-fill);
|
||||
mask-size: contain;
|
||||
-webkit-mask-size: contain;
|
||||
}
|
||||
|
||||
.repository .diff-file-box .code-diff td.lines-escape {
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
details.toggleable-item {
|
||||
user-select: none !important;
|
||||
padding: 0 !important;
|
||||
.ui.vertical.menu > details.item {
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
details.toggleable-item .menu {
|
||||
margin: 4px 0 10px !important;
|
||||
}
|
||||
|
||||
details.toggleable-item summary {
|
||||
.ui.vertical.menu > details.item summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: space-between; /* make the "::after" right-aligned */
|
||||
align-items: center;
|
||||
padding: 0.92857143em 1.14285714em;
|
||||
padding: 16px 13px; /* match Fomantic menu item padding */
|
||||
}
|
||||
|
||||
details.toggleable-item summary::marker, /* Chrome, Edge, Firefox */
|
||||
details.toggleable-item summary::-webkit-details-marker /* Safari */ {
|
||||
.ui.vertical.menu > details.item > summary::marker, /* Chrome, Edge, Firefox */
|
||||
.ui.vertical.menu > details.item > summary::-webkit-details-marker /* Safari */ {
|
||||
display: none;
|
||||
}
|
||||
|
||||
details.toggleable-item summary::after {
|
||||
.ui.vertical.menu > details.item > summary::after {
|
||||
transition: transform 0.25s ease;
|
||||
content: "";
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
mask-size: cover;
|
||||
-webkit-mask-size: cover;
|
||||
mask-image: var(--octicon-chevron-right);
|
||||
-webkit-mask-image: var(--octicon-chevron-right);
|
||||
background: currentcolor;
|
||||
border: 1px solid var(--color-body); /* workaround https://bugzilla.mozilla.org/show_bug.cgi?id=1671784 */
|
||||
}
|
||||
|
||||
details.toggleable-item[open] summary::after {
|
||||
.ui.vertical.menu > details.item[open] > summary::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* default toggleable details menu: items don't have menu item padding, don't change background color on hover or active */
|
||||
.ui.vertical.menu > details.item > .menu {
|
||||
margin: 0 0 10px; /* only need the space between the current details menu and next item */
|
||||
}
|
||||
|
||||
/* full width toggleable details menu: items have menu item padding, change background color on hover and active */
|
||||
.ui.vertical.menu > details.item > .menu.items-full-width {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ui.vertical.menu > details.item > .menu.items-full-width > .item {
|
||||
padding: 16px 13px; /* match Fomantic menu item padding */
|
||||
}
|
||||
|
||||
.ui.vertical.menu > details.item > .menu.items-full-width > .item.active {
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.ui.vertical.menu > details.item > .menu.items-full-width > .item:hover {
|
||||
background: var(--color-hover); /* ".ui.vertical.menu .menu .item" resets the hover background, so we need to add it again */
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import WorkflowGraph from './WorkflowGraph.vue';
|
||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
@@ -9,13 +10,14 @@ import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import type {ActionsArtifact, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
import {
|
||||
type ActionRunViewStore,
|
||||
collectCallerChildJobs,
|
||||
createLogLineMessage,
|
||||
type LogLine,
|
||||
type LogLineCommand,
|
||||
parseLogLineCommand
|
||||
parseLogLineCommand,
|
||||
} from './ActionRunView.ts';
|
||||
|
||||
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
||||
@@ -116,6 +118,15 @@ const currentJob = ref<CurrentJob>({
|
||||
const stepsContainer = ref<HTMLElement | null>(null);
|
||||
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
||||
|
||||
// Reusable workflow caller view: when the selected job is a caller node, the right pane
|
||||
// shows the children list rather than step logs (callers don't run on a runner).
|
||||
const selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
|
||||
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
|
||||
const callerChildJobs = computed<ActionsJob[]>(() => {
|
||||
if (!isCallerJob.value) return [];
|
||||
return collectCallerChildJobs(run.value.jobs || [], props.jobId);
|
||||
});
|
||||
|
||||
watch(optionAlwaysAutoScroll, () => {
|
||||
saveLocaleStorageOptions();
|
||||
});
|
||||
@@ -417,11 +428,17 @@ async function hashChangeListener() {
|
||||
<template>
|
||||
<div class="job-info-header">
|
||||
<div class="job-info-header-left gt-ellipsis">
|
||||
<h3 class="job-info-header-title gt-ellipsis">
|
||||
{{ currentJob.title }}
|
||||
</h3>
|
||||
<div class="job-info-header-title-row">
|
||||
<h3 class="job-info-header-title gt-ellipsis">
|
||||
{{ isCallerJob ? selectedJob?.name : currentJob.title }}
|
||||
</h3>
|
||||
<span v-if="isCallerJob && selectedJob?.callUses" class="ui label job-info-header-uses">
|
||||
<span>uses:</span>
|
||||
<span class="gt-ellipsis">{{ selectedJob.callUses }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="job-info-header-detail">
|
||||
{{ currentJob.detail }}
|
||||
{{ isCallerJob && selectedJob ? locale.status[selectedJob.status] : currentJob.detail }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="job-info-header-right">
|
||||
@@ -460,8 +477,22 @@ async function hashChangeListener() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Caller (reusable workflow) view: render the direct children's dependency graph,
|
||||
mirroring the run summary's WorkflowGraph but scoped to this caller's subtree.
|
||||
The caller's name + uses path + status all live in job-info-header above. -->
|
||||
<div class="caller-children-container" v-if="isCallerJob">
|
||||
<WorkflowGraph
|
||||
v-if="callerChildJobs.length > 0"
|
||||
:store="store"
|
||||
:jobs="callerChildJobs"
|
||||
:run-link="run.link"
|
||||
:workflow-id="`${run.workflowID}#caller-${props.jobId}`"
|
||||
:locale="locale"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
||||
<div class="job-step-container" ref="stepsContainer" v-show="currentJob.steps.length">
|
||||
<div class="job-step-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length">
|
||||
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||
<div
|
||||
class="job-step-summary"
|
||||
@@ -547,7 +578,8 @@ async function hashChangeListener() {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.job-info-header:has(+ .job-step-container) {
|
||||
.job-info-header:has(+ .job-step-container),
|
||||
.job-info-header:has(+ .caller-children-container) {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
@@ -564,6 +596,29 @@ async function hashChangeListener() {
|
||||
|
||||
.job-info-header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-info-header-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-info-header-uses {
|
||||
display: inline-flex !important;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.caller-children-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--color-console-border);
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.job-step-container {
|
||||
|
||||
@@ -18,6 +18,10 @@ const {currentRun: run} = toRefs(props.store.viewData);
|
||||
|
||||
const isRerun = computed(() => run.value.runAttempt > 1);
|
||||
|
||||
// The summary's dependency graph is the workflow's top-level shape: a reusable caller
|
||||
// renders as a single node, its expanded children belong to the caller's own detail page.
|
||||
const topLevelJobs = computed(() => (run.value.jobs || []).filter((j) => !j.parentJobID));
|
||||
|
||||
const triggerUser = computed(() => {
|
||||
const currentAttempt = run.value.attempts.find((attempt) => attempt.current);
|
||||
if (currentAttempt) {
|
||||
@@ -54,11 +58,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowGraph
|
||||
v-if="run.jobs.length > 0"
|
||||
v-if="topLevelJobs.length > 0"
|
||||
:store="store"
|
||||
:jobs="run.jobs"
|
||||
:jobs="topLevelJobs"
|
||||
:run-link="run.link"
|
||||
:workflow-id="run.workflowID"
|
||||
:locale="locale"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,7 @@ test('LogLineMessage', () => {
|
||||
'::warning file=test.js,line=1::foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
|
||||
'::notice::foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
|
||||
'::debug::foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
|
||||
'##[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
|
||||
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
|
||||
|
||||
// hidden is special, it is actually skipped before creating
|
||||
|
||||
@@ -20,6 +20,7 @@ const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
||||
'##[warning]': 'warning',
|
||||
'##[notice]': 'notice',
|
||||
'##[debug]': 'debug',
|
||||
'##[command]': 'command',
|
||||
'[command]': 'command',
|
||||
|
||||
// https://github.com/actions/toolkit/blob/master/docs/commands.md
|
||||
@@ -87,6 +88,28 @@ export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null)
|
||||
return logMsg;
|
||||
}
|
||||
|
||||
// buildJobsByParentJobID groups jobs by their parentJobID (0 = top level).
|
||||
// Useful for rendering the reusable-workflow caller/child tree in the sidebar.
|
||||
export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJob[]> {
|
||||
const childrenByParent = new Map<number, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
const parentID = job.parentJobID || 0;
|
||||
const existing = childrenByParent.get(parentID);
|
||||
if (existing) {
|
||||
existing.push(job);
|
||||
} else {
|
||||
childrenByParent.set(parentID, [job]);
|
||||
}
|
||||
}
|
||||
return childrenByParent;
|
||||
}
|
||||
|
||||
// collectCallerChildJobs returns the direct children of a caller job.
|
||||
export function collectCallerChildJobs(jobs: ActionsJob[], callerJobID: number): ActionsJob[] {
|
||||
if (!callerJobID) return [];
|
||||
return buildJobsByParentJobID(jobs).get(callerJobID) || [];
|
||||
}
|
||||
|
||||
export function createEmptyActionsRun(): ActionsRun {
|
||||
return {
|
||||
repoId: 0,
|
||||
@@ -160,7 +183,7 @@ export function createActionRunViewStore(viewUrl: string) {
|
||||
}
|
||||
};
|
||||
|
||||
return reactive({
|
||||
return {
|
||||
viewData,
|
||||
|
||||
async startPollingCurrentRun() {
|
||||
@@ -177,7 +200,7 @@ export function createActionRunViewStore(viewUrl: string) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import {toRefs} from 'vue';
|
||||
import {computed, ref, toRefs} from 'vue';
|
||||
import {POST, DELETE} from '../modules/fetch.ts';
|
||||
import ActionRunSummaryView from './ActionRunSummaryView.vue';
|
||||
import ActionRunJobView from './ActionRunJobView.vue';
|
||||
import type {ActionsRunAttempt} from '../modules/gitea-actions.ts';
|
||||
import {createActionRunViewStore} from './ActionRunView.ts';
|
||||
import type {ActionsJob, ActionsRunAttempt} from '../modules/gitea-actions.ts';
|
||||
import {buildJobsByParentJobID, createActionRunViewStore} from './ActionRunView.ts';
|
||||
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
|
||||
|
||||
defineOptions({
|
||||
@@ -23,6 +23,62 @@ const locale = props.locale;
|
||||
const store = createActionRunViewStore(props.actionsViewUrl);
|
||||
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
||||
|
||||
type JobListItem = {
|
||||
job: ActionsJob;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
};
|
||||
|
||||
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
|
||||
const expandedJobIDs = ref(new Set<number>());
|
||||
|
||||
function toggleExpandedJob(jobID: number) {
|
||||
const next = new Set(expandedJobIDs.value);
|
||||
if (next.has(jobID)) {
|
||||
next.delete(jobID);
|
||||
} else {
|
||||
next.add(jobID);
|
||||
}
|
||||
expandedJobIDs.value = next;
|
||||
}
|
||||
|
||||
// When a child job is currently selected, force-expand the chain of caller ancestors
|
||||
const forcedExpandedJobIDs = computed(() => {
|
||||
const expanded = new Set<number>();
|
||||
if (!props.jobId) return expanded;
|
||||
const jobsByID = new Map((run.value.jobs || []).map((job) => [job.id, job]));
|
||||
let cur = jobsByID.get(props.jobId);
|
||||
while (cur?.parentJobID) {
|
||||
expanded.add(cur.parentJobID);
|
||||
cur = jobsByID.get(cur.parentJobID);
|
||||
}
|
||||
return expanded;
|
||||
});
|
||||
|
||||
function isJobCollapsed(jobID: number) {
|
||||
return !expandedJobIDs.value.has(jobID) && !forcedExpandedJobIDs.value.has(jobID);
|
||||
}
|
||||
|
||||
const visibleJobListItems = computed<JobListItem[]>(() => {
|
||||
const jobs = [...(run.value.jobs || [])].sort((a, b) => a.id - b.id);
|
||||
const childrenByParent = buildJobsByParentJobID(jobs);
|
||||
|
||||
const result: JobListItem[] = [];
|
||||
const stack: Array<{job: ActionsJob; depth: number}> = [];
|
||||
const top = childrenByParent.get(0) || [];
|
||||
for (let i = top.length - 1; i >= 0; i--) stack.push({job: top[i], depth: 0});
|
||||
|
||||
while (stack.length > 0) {
|
||||
const {job, depth} = stack.pop()!;
|
||||
const children = childrenByParent.get(job.id) || [];
|
||||
const hasChildren = children.length > 0;
|
||||
result.push({job, depth, hasChildren});
|
||||
if (hasChildren && isJobCollapsed(job.id)) continue;
|
||||
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function formatAttemptTitle(attempt: ActionsRunAttempt) {
|
||||
return attempt.latest ? `${locale.latestAttempt} #${attempt.attempt}` : `${locale.attempt} #${attempt.attempt}`;
|
||||
}
|
||||
@@ -153,13 +209,31 @@ async function deleteArtifact(name: string) {
|
||||
<div class="ui divider"/>
|
||||
<div class="left-list-header">{{ locale.allJobs }}</div>
|
||||
<div class="flex-items-block action-view-sidebar-list">
|
||||
<div class="item" v-for="job in run.jobs" :key="job.id" :class="props.jobId === job.id ? 'selected' : ''">
|
||||
<a class="flex-text-block tw-flex-1 silenced" :href="job.link">
|
||||
<ActionStatusIcon :locale-status="locale.status[job.status]" :status="job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${job.id}/rerun`" v-if="job.canRerun"/>
|
||||
<span class="job-duration">{{ job.duration }}</span>
|
||||
<div
|
||||
class="item job-brief-item"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
v-for="item in visibleJobListItems"
|
||||
:key="item.job.id"
|
||||
>
|
||||
<a class="tw-contents silenced" :href="item.job.link">
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
</a>
|
||||
<button
|
||||
v-if="item.hasChildren"
|
||||
type="button"
|
||||
class="job-brief-toggle"
|
||||
:class="{'collapsed': isJobCollapsed(item.job.id)}"
|
||||
@click="toggleExpandedJob(item.job.id)"
|
||||
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-expanded="!isJobCollapsed(item.job.id)"
|
||||
>
|
||||
<SvgIcon name="octicon-chevron-down" :size="14"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,19 +406,47 @@ async function deleteArtifact(name: string) {
|
||||
background-color: var(--color-active);
|
||||
}
|
||||
|
||||
/* the re-run button replaces the duration on hover/focus */
|
||||
.job-brief-toggle {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
/* the icon is always chevron-down; flip to chevron-up when expanded */
|
||||
transition: transform 0.15s ease;
|
||||
/* sit right after the job name; rerun/duration float to the right via auto-margin */
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.job-brief-toggle:not(.collapsed) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* push rerun/duration to the right edge; only one is visible at a time (hover swap),
|
||||
the visible one absorbs the free space via auto-margin */
|
||||
.action-view-sidebar-list > .item .job-rerun-button,
|
||||
.action-view-sidebar-list > .item .job-duration {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* the re-run button replaces the duration on hover or job-link focus */
|
||||
.action-view-sidebar-list > .item .job-rerun-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-view-sidebar-list > .item:hover .job-rerun-button,
|
||||
.action-view-sidebar-list > .item:focus-within .job-rerun-button {
|
||||
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* only swap out the duration when a re-run button exists to take its place */
|
||||
.action-view-sidebar-list > .item:hover .job-rerun-button ~ .job-duration,
|
||||
.action-view-sidebar-list > .item:focus-within .job-rerun-button ~ .job-duration {
|
||||
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button ~ .job-duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ export default defineComponent({
|
||||
plugins: {
|
||||
title: {
|
||||
display: type === 'main',
|
||||
text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
|
||||
text: this.locale.chartZoomHint,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import {SvgIcon} from '../svg.ts';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import {isPlainClick} from '../utils/dom.ts';
|
||||
import {trN} from '../modules/i18n.ts';
|
||||
import {debounce} from 'throttle-debounce';
|
||||
import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
import type {ActionRunViewStore} from './ActionRunView.ts';
|
||||
@@ -43,6 +44,7 @@ const props = defineProps<{
|
||||
jobs: ActionsJob[];
|
||||
runLink: string;
|
||||
workflowId: string;
|
||||
locale: Record<string, string>;
|
||||
}>()
|
||||
|
||||
const settingKeyStates = 'actions-graph-states';
|
||||
@@ -113,7 +115,8 @@ const jobsWithLayout = computed<JobNode[]>(() => {
|
||||
let maxJobsPerLevel = 0;
|
||||
|
||||
props.jobs.forEach(job => {
|
||||
const level = levels.get(job.name) || levels.get(job.jobId) || 0;
|
||||
// `?? 0`, not `|| 0`: a root job's level is 0, which `||` would wrongly discard.
|
||||
const level = levels.get(scopedKey(job)) ?? 0;
|
||||
|
||||
if (!jobsByLevel[level]) {
|
||||
jobsByLevel[level] = [];
|
||||
@@ -162,75 +165,87 @@ const jobsWithLayout = computed<JobNode[]>(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// scopedKey identifies a job within its reusable-workflow call scope so that the same
|
||||
// JobID in different reusable calls does not collide.
|
||||
function scopedKey(job: {parentJobID: number; jobId: string}): string {
|
||||
return `${job.parentJobID || 0}:${job.jobId}`;
|
||||
}
|
||||
|
||||
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
|
||||
const directNeedsByJobId = new Map<string, string[]>();
|
||||
const dependentsByJobId = new Map<string, Set<string>>();
|
||||
// The map keys/values are scoped keys, not bare jobIds, so we keep edge construction
|
||||
// accurate when reusable workflows reuse common job names like "build" / "test".
|
||||
const directNeedsByScopedKey = new Map<string, string[]>();
|
||||
const dependentsByScopedKey = new Map<string, Set<string>>();
|
||||
|
||||
for (const job of jobs) {
|
||||
const needs = job.needs || [];
|
||||
directNeedsByJobId.set(job.jobId, needs);
|
||||
const fromKey = scopedKey(job);
|
||||
const needKeys = (job.needs || []).map((n) => `${job.parentJobID || 0}:${n}`);
|
||||
directNeedsByScopedKey.set(fromKey, needKeys);
|
||||
|
||||
for (const need of needs) {
|
||||
if (!dependentsByJobId.has(need)) {
|
||||
dependentsByJobId.set(need, new Set());
|
||||
for (const needKey of needKeys) {
|
||||
if (!dependentsByScopedKey.has(needKey)) {
|
||||
dependentsByScopedKey.set(needKey, new Set());
|
||||
}
|
||||
dependentsByJobId.get(need)!.add(job.jobId);
|
||||
dependentsByScopedKey.get(needKey)!.add(fromKey);
|
||||
}
|
||||
}
|
||||
|
||||
const reachabilityCache = new Map<string, boolean>();
|
||||
|
||||
function canReach(fromJobId: string, toJobId: string): boolean {
|
||||
const cacheKey = `${fromJobId}->${toJobId}`;
|
||||
function canReach(fromKey: string, toKey: string): boolean {
|
||||
const cacheKey = `${fromKey}->${toKey}`;
|
||||
if (reachabilityCache.has(cacheKey)) {
|
||||
return reachabilityCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const stack = [...(dependentsByJobId.get(fromJobId) || [])];
|
||||
const stack = [...(dependentsByScopedKey.get(fromKey) || [])];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
if (current === toJobId) {
|
||||
if (current === toKey) {
|
||||
reachabilityCache.set(cacheKey, true);
|
||||
return true;
|
||||
}
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
stack.push(...(dependentsByJobId.get(current) || []));
|
||||
stack.push(...(dependentsByScopedKey.get(current) || []));
|
||||
}
|
||||
|
||||
reachabilityCache.set(cacheKey, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const reducedNeedsByJobId = new Map<string, string[]>();
|
||||
for (const [jobId, needs] of directNeedsByJobId.entries()) {
|
||||
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
|
||||
const reducedNeedsByScopedKey = new Map<string, string[]>();
|
||||
for (const [fromKey, needs] of directNeedsByScopedKey.entries()) {
|
||||
reducedNeedsByScopedKey.set(fromKey, needs.filter((need) => {
|
||||
return !needs.some((otherNeed) => otherNeed !== need && canReach(need, otherNeed));
|
||||
}));
|
||||
}
|
||||
|
||||
return reducedNeedsByJobId;
|
||||
return reducedNeedsByScopedKey;
|
||||
}
|
||||
|
||||
const directNeedsByJobId = computed(() => buildDirectNeedsMap(props.jobs));
|
||||
const directNeedsByScopedKey = computed(() => buildDirectNeedsMap(props.jobs));
|
||||
|
||||
const edges = computed<Edge[]>(() => {
|
||||
const edgesList: Edge[] = [];
|
||||
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||||
// Store every job per scoped key, not just one: matrix-expanded jobs share same jobId
|
||||
const jobsByScopedKey = new Map<string, ActionsJob[]>();
|
||||
|
||||
for (const job of props.jobs) {
|
||||
if (!jobsByJobId.has(job.jobId)) {
|
||||
jobsByJobId.set(job.jobId, []);
|
||||
const key = scopedKey(job);
|
||||
const existing = jobsByScopedKey.get(key);
|
||||
if (existing) {
|
||||
existing.push(job);
|
||||
} else {
|
||||
jobsByScopedKey.set(key, [job]);
|
||||
}
|
||||
jobsByJobId.get(job.jobId)!.push(job);
|
||||
}
|
||||
|
||||
for (const job of props.jobs) {
|
||||
for (const need of directNeedsByJobId.value.get(job.jobId) || []) {
|
||||
const upstreamJobs = jobsByJobId.get(need) || [];
|
||||
for (const upstreamJob of upstreamJobs) {
|
||||
for (const needKey of directNeedsByScopedKey.value.get(scopedKey(job)) || []) {
|
||||
for (const upstreamJob of jobsByScopedKey.get(needKey) || []) {
|
||||
edgesList.push({
|
||||
fromId: upstreamJob.id,
|
||||
toId: job.id,
|
||||
@@ -344,6 +359,12 @@ const graphMetrics = computed(() => {
|
||||
};
|
||||
})
|
||||
|
||||
const graphStats = computed(() => [
|
||||
trN(props.jobs.length, props.locale.graphJobsCount1, props.locale.graphJobsCountN),
|
||||
trN(edges.value.length, props.locale.graphDependenciesCount1, props.locale.graphDependenciesCountN),
|
||||
props.locale.graphSuccessRate.replace('%s', graphMetrics.value.successRate),
|
||||
].join(' • '))
|
||||
|
||||
const nodeHeight = 52;
|
||||
const verticalSpacing = 90;
|
||||
const margin = 40;
|
||||
@@ -461,10 +482,11 @@ const nodesWithOutgoingEdge = computed(() => {
|
||||
|
||||
|
||||
function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||||
const jobMap = new Map<string, ActionsJob>()
|
||||
// Scope-aware: each job is keyed by `${parentJobID}:${jobId}` so the same JobID
|
||||
// in different reusable workflow calls does not cross-link in the level graph.
|
||||
const jobMap = new Map<string, ActionsJob>();
|
||||
jobs.forEach(job => {
|
||||
jobMap.set(job.name, job);
|
||||
if (job.jobId) jobMap.set(job.jobId, job);
|
||||
jobMap.set(scopedKey(job), job);
|
||||
});
|
||||
|
||||
const levels = new Map<string, number>();
|
||||
@@ -472,60 +494,59 @@ function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||||
const recursionStack = new Set<string>();
|
||||
const MAX_DEPTH = 100;
|
||||
|
||||
function dfs(jobNameOrId: string, depth: number = 0): number {
|
||||
function dfs(scoped: string, depth: number = 0): number {
|
||||
if (depth > MAX_DEPTH) {
|
||||
console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${jobNameOrId}`);
|
||||
console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${scoped}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (recursionStack.has(jobNameOrId)) {
|
||||
console.error(`Cycle detected involving: ${jobNameOrId}`);
|
||||
if (recursionStack.has(scoped)) {
|
||||
console.error(`Cycle detected involving: ${scoped}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (visited.has(jobNameOrId)) {
|
||||
return levels.get(jobNameOrId) || 0;
|
||||
if (visited.has(scoped)) {
|
||||
return levels.get(scoped) || 0;
|
||||
}
|
||||
|
||||
recursionStack.add(jobNameOrId);
|
||||
visited.add(jobNameOrId);
|
||||
recursionStack.add(scoped);
|
||||
visited.add(scoped);
|
||||
|
||||
const job = jobMap.get(jobNameOrId);
|
||||
const job = jobMap.get(scoped);
|
||||
if (!job) {
|
||||
recursionStack.delete(jobNameOrId);
|
||||
recursionStack.delete(scoped);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!job.needs?.length) {
|
||||
levels.set(job.jobId, 0);
|
||||
recursionStack.delete(jobNameOrId);
|
||||
levels.set(scoped, 0);
|
||||
recursionStack.delete(scoped);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxLevel = -1;
|
||||
for (const need of job.needs) {
|
||||
const needJob = jobMap.get(need);
|
||||
const needScoped = `${job.parentJobID || 0}:${need}`;
|
||||
const needJob = jobMap.get(needScoped);
|
||||
if (!needJob) continue;
|
||||
|
||||
const needLevel = dfs(need, depth + 1);
|
||||
const needLevel = dfs(needScoped, depth + 1);
|
||||
maxLevel = Math.max(maxLevel, needLevel);
|
||||
}
|
||||
|
||||
const level = maxLevel + 1
|
||||
levels.set(job.name, level);
|
||||
if (job.jobId && job.jobId !== job.name) {
|
||||
levels.set(job.jobId, level);
|
||||
}
|
||||
const level = maxLevel + 1;
|
||||
levels.set(scoped, level);
|
||||
|
||||
recursionStack.delete(jobNameOrId);
|
||||
recursionStack.delete(scoped);
|
||||
return level;
|
||||
}
|
||||
|
||||
jobs.forEach(job => {
|
||||
if (!visited.has(job.name) && !visited.has(job.jobId)) {
|
||||
dfs(job.name);
|
||||
const sk = scopedKey(job);
|
||||
if (!visited.has(sk)) {
|
||||
dfs(sk);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return levels;
|
||||
}
|
||||
@@ -543,27 +564,22 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
<template>
|
||||
<div class="workflow-graph" v-if="jobs.length > 0">
|
||||
<div class="graph-header">
|
||||
<h4 class="graph-title">Workflow Dependencies</h4>
|
||||
<div class="graph-stats">
|
||||
{{ jobs.length }} jobs • {{ edges.length }} dependencies
|
||||
<span v-if="graphMetrics">
|
||||
• <span class="graph-metrics">{{ graphMetrics.successRate }} success</span>
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="graph-title">{{ locale.workflowDependencies }}</h4>
|
||||
<div class="graph-stats">{{ graphStats }}</div>
|
||||
<div class="flex-text-block">
|
||||
<button
|
||||
type="button"
|
||||
@click="zoomIn"
|
||||
class="ui compact tiny icon button"
|
||||
:disabled="!canZoomIn"
|
||||
:title="canZoomIn ? 'Zoom in (Ctrl/Cmd + scroll on graph)' : 'Already at 100% zoom'"
|
||||
:title="canZoomIn ? locale.graphZoomIn : locale.graphZoomMax"
|
||||
>
|
||||
<SvgIcon name="octicon-zoom-in" :size="12"/>
|
||||
</button>
|
||||
<button type="button" @click="resetView" class="ui compact tiny icon button" title="Reset view">
|
||||
<button type="button" @click="resetView" class="ui compact tiny icon button" :title="locale.graphResetView">
|
||||
<SvgIcon name="octicon-sync" :size="12"/>
|
||||
</button>
|
||||
<button type="button" @click="zoomOut" class="ui compact tiny icon button" title="Zoom out (Ctrl/Cmd + scroll on graph)">
|
||||
<button type="button" @click="zoomOut" class="ui compact tiny icon button" :title="locale.graphZoomOut">
|
||||
<SvgIcon name="octicon-zoom-out" :size="12"/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -701,11 +717,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.graph-metrics {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {pathEscape} from '../../utils/url.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
@@ -23,6 +24,41 @@ export function initAdminCommon(): void {
|
||||
initAdminUser();
|
||||
initAdminAuthentication();
|
||||
initAdminNotice();
|
||||
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
|
||||
}
|
||||
|
||||
function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
|
||||
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
|
||||
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
|
||||
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
|
||||
if (!selectAll) return;
|
||||
|
||||
const refresh = () => {
|
||||
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
||||
toggleElem(toolbar, checked.length > 0);
|
||||
for (const btn of actionButtons) {
|
||||
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
||||
}
|
||||
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
|
||||
};
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
|
||||
refresh();
|
||||
});
|
||||
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
||||
refresh();
|
||||
|
||||
const collectSelectedIds = () => {
|
||||
const ids = [];
|
||||
for (const cb of rowCheckboxes) {
|
||||
if (cb.checked) ids.push(cb.getAttribute('data-runner-id')!);
|
||||
}
|
||||
return ids.join(',');
|
||||
};
|
||||
formRunnerIds.value = collectSelectedIds();
|
||||
}
|
||||
|
||||
function initAdminUser() {
|
||||
|
||||
@@ -16,6 +16,7 @@ type FetchActionOpts = {
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
body?: FormData;
|
||||
formSubmitter?: HTMLElement | null;
|
||||
|
||||
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
|
||||
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
|
||||
@@ -122,7 +123,7 @@ function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
|
||||
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
|
||||
const attrIsLoading = 'data-fetch-is-loading';
|
||||
if (el.getAttribute(attrIsLoading)) return;
|
||||
if (!await confirmFetchAction(el)) return;
|
||||
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
|
||||
|
||||
el.setAttribute(attrIsLoading, 'true');
|
||||
toggleLoadingIndicator(el, opt, true);
|
||||
@@ -181,6 +182,7 @@ function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFet
|
||||
method: formMethodUpper,
|
||||
url: reqUrl,
|
||||
body: reqBody,
|
||||
formSubmitter: opts.formSubmitter,
|
||||
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
|
||||
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function initRepoContributors() {
|
||||
loadingTitle: el.getAttribute('data-locale-loading-title'),
|
||||
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
|
||||
loadingInfo: el.getAttribute('data-locale-loading-info'),
|
||||
chartZoomHint: el.getAttribute('data-locale-chart-zoom-hint'),
|
||||
},
|
||||
});
|
||||
View.mount(el);
|
||||
|
||||
@@ -27,6 +27,8 @@ export function initRepositoryActionView() {
|
||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||
summary: el.getAttribute('data-locale-summary'),
|
||||
allJobs: el.getAttribute('data-locale-all-jobs'),
|
||||
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
|
||||
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
|
||||
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
||||
totalDuration: el.getAttribute('data-locale-total-duration'),
|
||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||
@@ -53,6 +55,16 @@ export function initRepositoryActionView() {
|
||||
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
|
||||
workflowFile: el.getAttribute('data-locale-workflow-file'),
|
||||
runDetails: el.getAttribute('data-locale-run-details'),
|
||||
workflowDependencies: el.getAttribute('data-locale-workflow-dependencies'),
|
||||
graphJobsCount1: el.getAttribute('data-locale-graph-jobs-count-1'),
|
||||
graphJobsCountN: el.getAttribute('data-locale-graph-jobs-count-n'),
|
||||
graphDependenciesCount1: el.getAttribute('data-locale-graph-dependencies-count-1'),
|
||||
graphDependenciesCountN: el.getAttribute('data-locale-graph-dependencies-count-n'),
|
||||
graphSuccessRate: el.getAttribute('data-locale-graph-success-rate'),
|
||||
graphZoomIn: el.getAttribute('data-locale-graph-zoom-in'),
|
||||
graphZoomMax: el.getAttribute('data-locale-graph-zoom-max'),
|
||||
graphZoomOut: el.getAttribute('data-locale-graph-zoom-out'),
|
||||
graphResetView: el.getAttribute('data-locale-graph-reset-view'),
|
||||
},
|
||||
});
|
||||
view.mount(el);
|
||||
|
||||
@@ -62,6 +62,10 @@ export type ActionsJob = {
|
||||
canRerun: boolean;
|
||||
needs?: string[];
|
||||
duration: string;
|
||||
|
||||
isReusableCaller: boolean;
|
||||
parentJobID: number; // 0 for top-level jobs.
|
||||
callUses?: string;
|
||||
};
|
||||
|
||||
export type ActionsArtifact = {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {trN} from './i18n.ts';
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
|
||||
vi.mock('../utils.ts', () => ({getCurrentLocale: vi.fn()}));
|
||||
|
||||
test('trN', () => {
|
||||
vi.mocked(getCurrentLocale).mockReturnValue('en-US');
|
||||
expect(trN(0, '%d job', '%d jobs')).toEqual('0 jobs');
|
||||
expect(trN(1, '%d job', '%d jobs')).toEqual('1 job');
|
||||
expect(trN(2, '%d job', '%d jobs')).toEqual('2 jobs');
|
||||
expect(trN(1000, '%d job', '%d jobs')).toEqual('1000 jobs');
|
||||
// languages without a distinct singular always use the plural form
|
||||
vi.mocked(getCurrentLocale).mockReturnValue('zh-CN');
|
||||
expect(trN(1, '%d job', '%d jobs')).toEqual('1 jobs');
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
|
||||
/** frontend `Locale.TrN`: pick the `_1` or `_n` form for `count` and interpolate `%d` */
|
||||
export function trN(count: number, form1: string, formN: string): string {
|
||||
const form = new Intl.PluralRules(getCurrentLocale()).select(count) === 'one' ? form1 : formN;
|
||||
return form.replace('%d', String(count));
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: it just happens to work for current dependencies
|
||||
// If this module depends on another one and that one imports "swagger.css", then {{AssetURI "css/swagger.css"}} won't work
|
||||
import '../css/swagger-standalone.css';
|
||||
import {initSwaggerUI} from './render/swagger.ts';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user