forked from hinterland/hearth
feat: vendor gitea 1.16.2
This commit is contained in:
@@ -0,0 +1,721 @@
|
||||
<script setup lang="ts">
|
||||
import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||
import {formatDatetime} from '../utils/time.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import type {ActionsArtifact, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||
import {
|
||||
type ActionRunViewStore,
|
||||
createLogLineMessage,
|
||||
type LogLine,
|
||||
type LogLineCommand,
|
||||
parseLogLineCommand
|
||||
} from './ActionRunView.ts';
|
||||
|
||||
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// only check whether bottom is in viewport, because the log element can be a log group which is usually tall
|
||||
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
|
||||
}
|
||||
|
||||
type Step = {
|
||||
summary: string,
|
||||
duration: string,
|
||||
status: ActionsRunStatus,
|
||||
}
|
||||
|
||||
type JobStepState = {
|
||||
cursor: string|null,
|
||||
expanded: boolean,
|
||||
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
||||
}
|
||||
|
||||
type StepContainerElement = HTMLElement & {
|
||||
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
||||
// then the following batches of logs should still use the same group (active logs container).
|
||||
// maybe it can be refactored to decouple from the HTML element in the future.
|
||||
_stepLogsActiveContainer?: HTMLElement;
|
||||
}
|
||||
|
||||
type LocaleStorageOptions = {
|
||||
autoScroll: boolean;
|
||||
expandRunning: boolean;
|
||||
actionsLogShowSeconds: boolean;
|
||||
actionsLogShowTimestamps: boolean;
|
||||
};
|
||||
|
||||
type CurrentJob = {
|
||||
title: string;
|
||||
detail: string;
|
||||
steps: Array<Step>;
|
||||
};
|
||||
|
||||
type JobData = {
|
||||
artifacts: Array<ActionsArtifact>;
|
||||
state: {
|
||||
run: ActionsRun;
|
||||
currentJob: CurrentJob;
|
||||
},
|
||||
logs: {
|
||||
stepsLog?: Array<{
|
||||
step: number;
|
||||
cursor: string | null;
|
||||
started: number;
|
||||
lines: LogLine[];
|
||||
}>;
|
||||
},
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
name: 'ActionRunJobView',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
store: ActionRunViewStore,
|
||||
runId: number;
|
||||
jobId: number;
|
||||
actionsUrl: string;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
const store = props.store;
|
||||
const {currentRun: run} = toRefs(store.viewData);
|
||||
|
||||
const defaultViewOptions: LocaleStorageOptions = {
|
||||
autoScroll: true,
|
||||
expandRunning: false,
|
||||
actionsLogShowSeconds: false,
|
||||
actionsLogShowTimestamps: false,
|
||||
};
|
||||
|
||||
const savedViewOptions = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
|
||||
const {autoScroll, expandRunning, actionsLogShowSeconds, actionsLogShowTimestamps} = savedViewOptions;
|
||||
|
||||
// internal state
|
||||
let loadingAbortController: AbortController | null = null;
|
||||
let intervalID: IntervalId | null = null;
|
||||
|
||||
const currentJobStepsStates = ref<Array<JobStepState>>([]);
|
||||
const menuVisible = ref(false);
|
||||
const isFullScreen = ref(false);
|
||||
const timeVisible = ref<Record<string, boolean>>({
|
||||
'log-time-stamp': actionsLogShowTimestamps,
|
||||
'log-time-seconds': actionsLogShowSeconds,
|
||||
});
|
||||
const optionAlwaysAutoScroll = ref(autoScroll);
|
||||
const optionAlwaysExpandRunning = ref(expandRunning);
|
||||
const currentJob = ref<CurrentJob>({
|
||||
title: '',
|
||||
detail: '',
|
||||
steps: [] as Array<Step>,
|
||||
});
|
||||
const stepsContainer = ref<HTMLElement | null>(null);
|
||||
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
||||
|
||||
watch(optionAlwaysAutoScroll, () => {
|
||||
saveLocaleStorageOptions();
|
||||
});
|
||||
|
||||
watch(optionAlwaysExpandRunning, () => {
|
||||
saveLocaleStorageOptions();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// load job data and then auto-reload periodically
|
||||
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
|
||||
await loadJob();
|
||||
|
||||
// auto-scroll to the bottom of the log group when it is opened
|
||||
// "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
|
||||
addDelegatedEventListener(elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
|
||||
if (!optionAlwaysAutoScroll.value) return;
|
||||
const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
|
||||
setTimeout(() => {
|
||||
if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
|
||||
elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
intervalID = setInterval(() => void loadJob(), 1000);
|
||||
document.body.addEventListener('click', closeDropdown);
|
||||
void hashChangeListener();
|
||||
window.addEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.removeEventListener('click', closeDropdown);
|
||||
window.removeEventListener('hashchange', hashChangeListener);
|
||||
// clear the interval timer when the component is unmounted
|
||||
// even our page is rendered once, not spa style
|
||||
if (intervalID) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
}
|
||||
});
|
||||
|
||||
function saveLocaleStorageOptions() {
|
||||
const opts: LocaleStorageOptions = {
|
||||
autoScroll: optionAlwaysAutoScroll.value,
|
||||
expandRunning: optionAlwaysExpandRunning.value,
|
||||
actionsLogShowSeconds: timeVisible.value['log-time-seconds'],
|
||||
actionsLogShowTimestamps: timeVisible.value['log-time-stamp'],
|
||||
};
|
||||
localUserSettings.setJsonObject('actions-view-options', opts);
|
||||
}
|
||||
|
||||
// get the job step logs container ('.job-step-logs')
|
||||
function getJobStepLogsContainer(stepIndex: number): StepContainerElement {
|
||||
return jobStepLogs.value[stepIndex] as StepContainerElement;
|
||||
}
|
||||
|
||||
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
|
||||
function getActiveLogsContainer(stepIndex: number): StepContainerElement {
|
||||
const el = getJobStepLogsContainer(stepIndex);
|
||||
return el._stepLogsActiveContainer ?? el;
|
||||
}
|
||||
|
||||
// begin a log group
|
||||
function beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
||||
const el = getJobStepLogsContainer(stepIndex);
|
||||
// Using "summary + details" is the best way to create a log group because it has built-in support for "toggle" and "accessibility".
|
||||
// And it makes users can use "Ctrl+F" to search the logs without opening all log groups.
|
||||
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
|
||||
createLogLine(stepIndex, startTime, line, cmd),
|
||||
);
|
||||
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
|
||||
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
|
||||
elJobLogGroupSummary,
|
||||
elJobLogList,
|
||||
);
|
||||
el.append(elJobLogGroup);
|
||||
el._stepLogsActiveContainer = elJobLogList;
|
||||
}
|
||||
|
||||
// end a log group
|
||||
function endLogGroup(stepIndex: number) {
|
||||
const el = getJobStepLogsContainer(stepIndex);
|
||||
el._stepLogsActiveContainer = undefined;
|
||||
}
|
||||
|
||||
// show/hide the step logs for a step
|
||||
function toggleStepLogs(idx: number) {
|
||||
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
|
||||
if (currentJobStepsStates.value[idx].expanded) {
|
||||
void loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
|
||||
} else if (currentJob.value.steps[idx].status === 'running') {
|
||||
currentJobStepsStates.value[idx].manuallyCollapsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
|
||||
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
|
||||
String(line.index),
|
||||
);
|
||||
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
||||
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
|
||||
);
|
||||
const logMsg = createLogLineMessage(line, cmd);
|
||||
const seconds = Math.floor(line.timestamp - startTime);
|
||||
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
|
||||
`${seconds}s`, // for "Show seconds"
|
||||
);
|
||||
|
||||
toggleElem(logTimeStamp, timeVisible.value['log-time-stamp']);
|
||||
toggleElem(logTimeSeconds, timeVisible.value['log-time-seconds']);
|
||||
|
||||
const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line';
|
||||
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
|
||||
lineNum, logTimeStamp, logMsg, logTimeSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAutoScroll(stepIndex: number): boolean {
|
||||
if (!optionAlwaysAutoScroll.value) return false;
|
||||
const el = getJobStepLogsContainer(stepIndex);
|
||||
// if the logs container is empty, then auto-scroll if the step is expanded
|
||||
if (!el.lastChild) return currentJobStepsStates.value[stepIndex].expanded;
|
||||
// use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
|
||||
return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
|
||||
}
|
||||
|
||||
function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
||||
for (const line of logLines) {
|
||||
const cmd = parseLogLineCommand(line);
|
||||
switch (cmd?.name) {
|
||||
case 'hidden':
|
||||
continue;
|
||||
case 'group':
|
||||
beginLogGroup(stepIndex, startTime, line, cmd);
|
||||
continue;
|
||||
case 'endgroup':
|
||||
endLogGroup(stepIndex);
|
||||
continue;
|
||||
}
|
||||
// the active logs container may change during the loop, for example: entering and leaving a group
|
||||
const el = getActiveLogsContainer(stepIndex);
|
||||
el.append(createLogLine(stepIndex, startTime, line, cmd));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJobData(abortController: AbortController): Promise<JobData> {
|
||||
const logCursors = currentJobStepsStates.value.map((it, idx) => {
|
||||
// cursor is used to indicate the last position of the logs
|
||||
// it's only used by backend, frontend just reads it and passes it back, it can be any type.
|
||||
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
|
||||
return {step: idx, cursor: it.cursor, expanded: it.expanded};
|
||||
});
|
||||
const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`;
|
||||
const resp = await POST(url, {
|
||||
signal: abortController.signal,
|
||||
data: {logCursors},
|
||||
});
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function loadJobForce() {
|
||||
loadingAbortController?.abort();
|
||||
loadingAbortController = null;
|
||||
await loadJob();
|
||||
}
|
||||
|
||||
async function loadJob() {
|
||||
if (loadingAbortController) return;
|
||||
const abortController = new AbortController();
|
||||
loadingAbortController = abortController;
|
||||
try {
|
||||
const runJobResp = await fetchJobData(abortController);
|
||||
if (loadingAbortController !== abortController) return;
|
||||
|
||||
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
|
||||
// Use consistent "store" operations to load/update the view data
|
||||
store.viewData.runArtifacts = runJobResp.artifacts || [];
|
||||
store.viewData.currentRun = runJobResp.state.run;
|
||||
|
||||
currentJob.value = runJobResp.state.currentJob;
|
||||
const jobLogs = runJobResp.logs.stepsLog ?? [];
|
||||
|
||||
// sync the currentJobStepsStates to store the job step states
|
||||
for (let i = 0; i < currentJob.value.steps.length; i++) {
|
||||
const autoExpand = optionAlwaysExpandRunning.value && currentJob.value.steps[i].status === 'running';
|
||||
if (!currentJobStepsStates.value[i]) {
|
||||
// initial states for job steps
|
||||
currentJobStepsStates.value[i] = {cursor: null, expanded: autoExpand, manuallyCollapsed: false};
|
||||
} else {
|
||||
// if the step is not manually collapsed by user, then auto-expand it if option is enabled
|
||||
if (autoExpand && !currentJobStepsStates.value[i].manuallyCollapsed) {
|
||||
currentJobStepsStates.value[i].expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
// find the step indexes that need to auto-scroll
|
||||
const autoScrollStepIndexes = new Map<number, boolean>();
|
||||
for (const stepLogs of jobLogs) {
|
||||
if (autoScrollStepIndexes.has(stepLogs.step)) continue;
|
||||
autoScrollStepIndexes.set(stepLogs.step, shouldAutoScroll(stepLogs.step));
|
||||
}
|
||||
|
||||
// append logs to the UI
|
||||
for (const stepLogs of jobLogs) {
|
||||
// save the cursor, it will be passed to backend next time
|
||||
currentJobStepsStates.value[stepLogs.step].cursor = stepLogs.cursor;
|
||||
appendLogs(stepLogs.step, stepLogs.started, stepLogs.lines);
|
||||
}
|
||||
|
||||
// auto-scroll to the last log line of the last step
|
||||
let autoScrollJobStepElement: StepContainerElement | undefined;
|
||||
for (let stepIndex = 0; stepIndex < currentJob.value.steps.length; stepIndex++) {
|
||||
if (!autoScrollStepIndexes.get(stepIndex)) continue;
|
||||
autoScrollJobStepElement = getJobStepLogsContainer(stepIndex);
|
||||
}
|
||||
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
|
||||
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
|
||||
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
|
||||
}
|
||||
|
||||
// clear the interval timer if the job is done
|
||||
if (run.value.done && intervalID) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// avoid network error while unloading page, and ignore "abort" error
|
||||
if (e instanceof TypeError || abortController.signal.aborted) return;
|
||||
throw e;
|
||||
} finally {
|
||||
if (loadingAbortController === abortController) loadingAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isDone(status: ActionsRunStatus) {
|
||||
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
||||
}
|
||||
|
||||
function isExpandable(status: ActionsRunStatus) {
|
||||
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
if (menuVisible.value) menuVisible.value = false;
|
||||
}
|
||||
|
||||
function elStepsContainer(): HTMLElement {
|
||||
return stepsContainer.value as HTMLElement;
|
||||
}
|
||||
|
||||
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
||||
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
|
||||
for (const el of elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
|
||||
toggleElem(el, timeVisible.value[`log-time-${type}`]);
|
||||
}
|
||||
saveLocaleStorageOptions();
|
||||
}
|
||||
|
||||
function toggleFullScreenMode() {
|
||||
isFullScreen.value = !isFullScreen.value;
|
||||
toggleFullScreen(document.querySelector('.action-view-right')!, isFullScreen.value, '.action-view-body');
|
||||
}
|
||||
|
||||
async function hashChangeListener() {
|
||||
const selectedLogStep = window.location.hash;
|
||||
if (!selectedLogStep) return;
|
||||
const [_, step, _line] = selectedLogStep.split('-');
|
||||
const stepNum = Number(step);
|
||||
if (!currentJobStepsStates.value[stepNum]) return;
|
||||
if (!currentJobStepsStates.value[stepNum].expanded && currentJobStepsStates.value[stepNum].cursor === null) {
|
||||
currentJobStepsStates.value[stepNum].expanded = true;
|
||||
// need to await for load job if the step log is loaded for the first time
|
||||
// so logline can be selected by querySelector
|
||||
await loadJob();
|
||||
}
|
||||
await nextTick();
|
||||
const logLine = elStepsContainer().querySelector(selectedLogStep);
|
||||
if (!logLine) return;
|
||||
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
|
||||
}
|
||||
</script>
|
||||
<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>
|
||||
<p class="job-info-header-detail">
|
||||
{{ currentJob.detail }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="job-info-header-right">
|
||||
<div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
|
||||
<button class="ui button tw-px-3">
|
||||
<SvgIcon name="octicon-gear" :size="18"/>
|
||||
</button>
|
||||
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
|
||||
<a class="item" @click="toggleTimeDisplay('seconds')">
|
||||
<i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showLogSeconds }}
|
||||
</a>
|
||||
<a class="item" @click="toggleTimeDisplay('stamp')">
|
||||
<i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showTimeStamps }}
|
||||
</a>
|
||||
<a class="item" @click="toggleFullScreenMode()">
|
||||
<i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.showFullScreen }}
|
||||
</a>
|
||||
<div class="divider"/>
|
||||
<a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
|
||||
<i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.logsAlwaysAutoScroll }}
|
||||
</a>
|
||||
<a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
|
||||
<i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
|
||||
{{ locale.logsAlwaysExpandRunning }}
|
||||
</a>
|
||||
<div class="divider"/>
|
||||
<a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link + '/jobs/' + jobId + '/logs'" download>
|
||||
<i class="icon"><SvgIcon name="octicon-download"/></i>
|
||||
{{ locale.downloadLogs }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||
<div
|
||||
class="job-step-summary"
|
||||
@click.stop="isExpandable(jobStep.status) && toggleStepLogs(stepIdx)"
|
||||
:class="[currentJobStepsStates[stepIdx].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']"
|
||||
>
|
||||
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
|
||||
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
|
||||
-->
|
||||
<SvgIcon
|
||||
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
|
||||
name="gitea-running"
|
||||
class="tw-mr-2 rotate-clockwise"
|
||||
/>
|
||||
<SvgIcon
|
||||
v-else
|
||||
:name="currentJobStepsStates[stepIdx].expanded ? 'octicon-chevron-down' : 'octicon-chevron-right'"
|
||||
:class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"
|
||||
/>
|
||||
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
|
||||
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
||||
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
||||
</div>
|
||||
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
|
||||
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
|
||||
<div class="job-step-logs" :ref="(el) => jobStepLogs[stepIdx] = el as StepContainerElement" v-show="currentJobStepsStates[stepIdx].expanded"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* begin fomantic dropdown menu overrides */
|
||||
|
||||
.action-view-right .ui.dropdown .menu {
|
||||
background: var(--color-console-menu-bg);
|
||||
border-color: var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item {
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item:hover {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .item:active {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-active-bg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.dropdown .menu > .divider {
|
||||
border-top-color: var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
|
||||
background: var(--color-console-menu-bg);
|
||||
box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
|
||||
}
|
||||
|
||||
/* end fomantic dropdown menu overrides */
|
||||
|
||||
.job-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 60px;
|
||||
z-index: 1; /* above .job-step-container */
|
||||
background: var(--color-console-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.job-info-header:has(+ .job-step-container) {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.job-info-header .job-info-header-title {
|
||||
color: var(--color-console-fg);
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.job-info-header .job-info-header-detail {
|
||||
color: var(--color-console-fg-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-info-header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-step-container {
|
||||
max-height: 100%;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
border-top: 1px solid var(--color-console-border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary {
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.step-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.step-expandable:hover {
|
||||
color: var(--color-console-fg);
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-duration {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.selected {
|
||||
color: var(--color-console-fg);
|
||||
background-color: var(--color-console-active-bg);
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
/* workaround ansi_up issue related to faintStyle generating a CSS stacking context via `opacity`
|
||||
inline style which caused such elements to render above the .job-step-summary header. */
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
|
||||
/* some elements are not managed by vue, so we need to use global style */
|
||||
.job-step-section {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs {
|
||||
font-family: var(--fonts-monospace);
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.job-step-section .job-step-logs .job-log-line {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.job-log-line:hover,
|
||||
.job-log-line:target {
|
||||
background-color: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-log-line:target {
|
||||
scroll-margin-top: 95px;
|
||||
}
|
||||
|
||||
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
||||
.job-log-line .line-num, .log-time-seconds {
|
||||
width: 48px;
|
||||
color: var(--color-text-light-3);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.job-log-line:target > .line-num {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.log-time-seconds {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.job-log-line .log-time,
|
||||
.log-time-stamp {
|
||||
color: var(--color-text-light-3);
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-msg {
|
||||
flex: 1;
|
||||
white-space: break-spaces;
|
||||
margin-left: 10px;
|
||||
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);
|
||||
}
|
||||
|
||||
.job-step-logs .log-msg-label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.job-step-logs .log-line-error {
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
|
||||
.job-step-logs .log-line-warning {
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.job-step-logs .log-line-notice {
|
||||
background: var(--color-info-bg);
|
||||
}
|
||||
|
||||
.job-step-logs .log-line-debug {
|
||||
background: var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
.job-step-logs .log-cmd-error > .log-msg-label {
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.job-step-logs .log-cmd-warning > .log-msg-label {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.job-step-logs .log-cmd-notice > .log-msg-label {
|
||||
color: var(--color-info-text);
|
||||
}
|
||||
|
||||
.job-step-logs .log-cmd-debug > .log-msg-label {
|
||||
color: var(--color-violet);
|
||||
}
|
||||
|
||||
/* selectors here are intentionally exact to only match fullscreen */
|
||||
|
||||
.full.height > .action-view-right {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.full.height > .action-view-right > .job-info-header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.full.height > .action-view-right > .job-step-container {
|
||||
height: calc(100% - 60px);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.job-log-group .job-log-list .job-log-line .log-msg {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.job-log-group-summary {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.job-log-group-summary > .job-log-line {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1; /* to avoid hiding the triangle of the "details" element */
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -19,12 +19,12 @@ withDefaults(defineProps<{
|
||||
|
||||
<template>
|
||||
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
|
||||
<SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class="className" v-if="status === 'success'"/>
|
||||
<SvgIcon name="octicon-skip" class="text grey" :size="size" :class="className" v-else-if="status === 'skipped'"/>
|
||||
<SvgIcon name="octicon-stop" class="text grey" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
|
||||
<SvgIcon name="octicon-circle" class="text grey" :size="size" :class="className" v-else-if="status === 'waiting'"/>
|
||||
<SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
|
||||
<SvgIcon name="gitea-running" class="text yellow" :size="size" :class="'rotate-clockwise ' + className" v-else-if="status === 'running'"/>
|
||||
<SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown -->
|
||||
<SvgIcon name="octicon-check-circle-fill" class="tw-text-green" :size="size" :class="className" v-if="status === 'success'"/>
|
||||
<SvgIcon name="octicon-skip" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'skipped'"/>
|
||||
<SvgIcon name="octicon-stop" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
|
||||
<SvgIcon name="octicon-circle" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'waiting'"/>
|
||||
<SvgIcon name="octicon-blocked" class="tw-text-yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
|
||||
<SvgIcon name="gitea-running" class="tw-text-yellow" :size="size" :class="'rotate-clockwise ' + className" v-else-if="status === 'running'"/>
|
||||
<SvgIcon name="octicon-x-circle-fill" class="tw-text-red" :size="size" v-else/><!-- failure, unknown -->
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import WorkflowGraph from './WorkflowGraph.vue';
|
||||
import type {ActionRunViewStore} from "./ActionRunView.ts";
|
||||
import {computed, onBeforeUnmount, onMounted, toRefs} from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: 'ActionRunSummaryView',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
store: ActionRunViewStore;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const {currentRun: run} = toRefs(props.store.viewData);
|
||||
|
||||
const runTriggeredAtIso = computed(() => {
|
||||
const t = props.store.viewData.currentRun.triggeredAt;
|
||||
return t ? new Date(t * 1000).toISOString() : '';
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await props.store.startPollingCurrentRun();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.store.stopPollingCurrentRun();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="action-run-summary-view">
|
||||
<div class="action-run-summary-block">
|
||||
<div class="flex-text-block">
|
||||
{{ locale.triggeredVia.replace('%s', run.triggerEvent) }} • <relative-time :datetime="runTriggeredAtIso" prefix=""/>
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>
|
||||
<span>{{ locale.status[run.status] }}</span> • <span>{{ locale.totalDuration }} {{ run.duration || '–' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowGraph
|
||||
v-if="run.jobs.length > 0"
|
||||
:store="store"
|
||||
:jobs="run.jobs"
|
||||
:run-link="run.link"
|
||||
:workflow-id="run.workflowID"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.action-run-summary-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-text-light-1);
|
||||
}
|
||||
|
||||
.action-run-summary-block {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
background: var(--color-box-header);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts';
|
||||
|
||||
test('LogLineMessage', () => {
|
||||
const cases = {
|
||||
'normal message': '<span class="log-msg">normal message</span>',
|
||||
'##[group] foo': '<span class="log-msg log-cmd-group"> foo</span>',
|
||||
'::group::foo': '<span class="log-msg log-cmd-group">foo</span>',
|
||||
'##[endgroup]': '<span class="log-msg log-cmd-endgroup"></span>',
|
||||
'::endgroup::': '<span class="log-msg log-cmd-endgroup"></span>',
|
||||
|
||||
'##[error] foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
|
||||
'##[warning] 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>',
|
||||
'::error::foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
|
||||
'::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>',
|
||||
|
||||
// hidden is special, it is actually skipped before creating
|
||||
'##[add-matcher]foo': '<span class="log-msg log-cmd-hidden">foo</span>',
|
||||
'::add-matcher::foo': '<span class="log-msg log-cmd-hidden">foo</span>',
|
||||
'::remove-matcher foo::': '<span class="log-msg log-cmd-hidden"> foo::</span>', // not correctly parsed, but we don't need it
|
||||
};
|
||||
for (const [input, html] of Object.entries(cases)) {
|
||||
const line = {index: 0, timestamp: 0, message: input};
|
||||
const cmd = parseLogLineCommand(line);
|
||||
const el = createLogLineMessage(line, cmd);
|
||||
expect(el.outerHTML).toBe(html);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import {createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {renderAnsi} from '../render/ansi.ts';
|
||||
import {reactive} from 'vue';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
|
||||
// How GitHub Actions logs work:
|
||||
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
|
||||
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
|
||||
// * The reported logs are the processed logs.
|
||||
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
|
||||
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
||||
'::group::': 'group',
|
||||
'##[group]': 'group',
|
||||
'::endgroup::': 'endgroup',
|
||||
'##[endgroup]': 'endgroup',
|
||||
|
||||
'##[error]': 'error',
|
||||
'##[warning]': 'warning',
|
||||
'##[notice]': 'notice',
|
||||
'##[debug]': 'debug',
|
||||
'[command]': 'command',
|
||||
|
||||
// https://github.com/actions/toolkit/blob/master/docs/commands.md
|
||||
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
|
||||
'::add-matcher::': 'hidden',
|
||||
'##[add-matcher]': 'hidden',
|
||||
'::remove-matcher': 'hidden', // it has arguments
|
||||
};
|
||||
|
||||
// Pattern for ::cmd:: and ::cmd args:: format (args are stripped for display)
|
||||
const LogLineCmdPattern = /^::(error|warning|notice|debug)(?:\s[^:]*)?::/;
|
||||
|
||||
export type LogLine = {
|
||||
index: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'warning' | 'notice' | 'debug' | 'hidden';
|
||||
export type LogLineCommand = {
|
||||
name: LogLineCommandName,
|
||||
prefix: string,
|
||||
};
|
||||
|
||||
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
|
||||
// TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
|
||||
for (const prefix of Object.keys(LogLinePrefixCommandMap)) {
|
||||
if (line.message.startsWith(prefix)) {
|
||||
return {name: LogLinePrefixCommandMap[prefix], prefix};
|
||||
}
|
||||
}
|
||||
// Handle ::cmd:: and ::cmd args:: format (runner may pass these through raw)
|
||||
const match = LogLineCmdPattern.exec(line.message);
|
||||
if (match) {
|
||||
return {name: match[1] as LogLineCommandName, prefix: match[0]};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const LogLineLabelMap: Partial<Record<LogLineCommandName, string>> = {
|
||||
'error': 'Error',
|
||||
'warning': 'Warning',
|
||||
'notice': 'Notice',
|
||||
'debug': 'Debug',
|
||||
};
|
||||
|
||||
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
|
||||
const logMsgAttrs = {class: 'log-msg'};
|
||||
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error"
|
||||
|
||||
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
|
||||
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
|
||||
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
|
||||
|
||||
const logMsg = createElementFromAttrs('span', logMsgAttrs);
|
||||
const label = cmd ? LogLineLabelMap[cmd.name] : null;
|
||||
if (label) {
|
||||
logMsg.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`));
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.innerHTML = ` ${renderAnsi(msgContent.trimStart())}`;
|
||||
logMsg.append(msgSpan);
|
||||
} else {
|
||||
logMsg.innerHTML = renderAnsi(msgContent);
|
||||
}
|
||||
return logMsg;
|
||||
}
|
||||
|
||||
export function createEmptyActionsRun(): ActionsRun {
|
||||
return {
|
||||
repoId: 0,
|
||||
link: '',
|
||||
title: '',
|
||||
titleHTML: '',
|
||||
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
||||
canCancel: false,
|
||||
canApprove: false,
|
||||
canRerun: false,
|
||||
canRerunFailed: false,
|
||||
canDeleteArtifact: false,
|
||||
done: false,
|
||||
workflowID: '',
|
||||
workflowLink: '',
|
||||
isSchedule: false,
|
||||
duration: '',
|
||||
triggeredAt: 0,
|
||||
triggerEvent: '',
|
||||
jobs: [] as Array<ActionsJob>,
|
||||
commit: {
|
||||
localeCommit: '',
|
||||
localePushedBy: '',
|
||||
shortSHA: '',
|
||||
link: '',
|
||||
pusher: {
|
||||
displayName: '',
|
||||
link: '',
|
||||
},
|
||||
branch: {
|
||||
name: '',
|
||||
link: '',
|
||||
isDeleted: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
||||
let loadingAbortController: AbortController | null = null;
|
||||
let intervalID: IntervalId | null = null;
|
||||
const viewData = reactive({
|
||||
currentRun: createEmptyActionsRun(),
|
||||
runArtifacts: [] as Array<ActionsArtifact>,
|
||||
});
|
||||
const loadCurrentRun = async () => {
|
||||
if (loadingAbortController) return;
|
||||
const abortController = new AbortController();
|
||||
loadingAbortController = abortController;
|
||||
try {
|
||||
const url = `${actionsUrl}/runs/${runId}`;
|
||||
const resp = await POST(url, {signal: abortController.signal, data: {}});
|
||||
const runResp = await resp.json();
|
||||
if (loadingAbortController !== abortController) return;
|
||||
|
||||
viewData.runArtifacts = runResp.artifacts || [];
|
||||
viewData.currentRun = runResp.state.run;
|
||||
// clear the interval timer if the job is done
|
||||
if (viewData.currentRun.done && intervalID) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// avoid network error while unloading page, and ignore "abort" error
|
||||
if (e instanceof TypeError || abortController.signal.aborted) return;
|
||||
throw e;
|
||||
} finally {
|
||||
if (loadingAbortController === abortController) loadingAbortController = null;
|
||||
}
|
||||
};
|
||||
|
||||
return reactive({
|
||||
viewData,
|
||||
|
||||
async startPollingCurrentRun() {
|
||||
await loadCurrentRun();
|
||||
intervalID = setInterval(() => loadCurrentRun(), 1000);
|
||||
},
|
||||
async forceReloadCurrentRun() {
|
||||
loadingAbortController?.abort();
|
||||
loadingAbortController = null;
|
||||
await loadCurrentRun();
|
||||
},
|
||||
stopPollingCurrentRun() {
|
||||
if (!intervalID) return;
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
|
||||
@@ -5,7 +5,7 @@ import {onMounted, shallowRef} from 'vue';
|
||||
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
|
||||
|
||||
defineProps<{
|
||||
values?: HeatmapValue[];
|
||||
values: HeatmapValue[];
|
||||
locale: {
|
||||
textTotalContributions: string;
|
||||
heatMapLocale: Partial<HeatmapLocale>;
|
||||
@@ -28,7 +28,7 @@ const endDate = shallowRef(new Date());
|
||||
|
||||
onMounted(() => {
|
||||
// work around issue with first legend color being rendered twice and legend cut off
|
||||
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper');
|
||||
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper')!;
|
||||
legend.setAttribute('viewBox', '12 0 80 10');
|
||||
legend.style.marginRight = '-12px';
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {getIssueColor, getIssueIcon} from '../features/issue.ts';
|
||||
import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
repoLink: string,
|
||||
@@ -10,22 +11,24 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const loading = shallowRef(false);
|
||||
const issue = shallowRef(null);
|
||||
const issue = shallowRef<Issue | null>(null);
|
||||
const renderedLabels = shallowRef('');
|
||||
const errorMessage = shallowRef(null);
|
||||
const errorMessage = shallowRef('');
|
||||
|
||||
const createdAt = computed(() => {
|
||||
if (!issue?.value) return '';
|
||||
return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||
});
|
||||
|
||||
const body = computed(() => {
|
||||
if (!issue?.value) return '';
|
||||
const body = issue.value.body.replace(/\n+/g, ' ');
|
||||
return body.length > 85 ? `${body.substring(0, 85)}…` : body;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const resp = await GET(props.loadIssueInfoUrl);
|
||||
if (!resp.ok) {
|
||||
@@ -50,7 +53,7 @@ onMounted(async () => {
|
||||
on {{ createdAt }}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
<svg-icon :name="getIssueIcon(issue)" :class="['text', getIssueColor(issue)]"/>
|
||||
<svg-icon :name="getIssueIcon(issue)" :class="getIssueColorClass(issue)"/>
|
||||
<span class="issue-title tw-font-semibold tw-break-anywhere">
|
||||
{{ issue.title }}
|
||||
<span class="index">#{{ issue.number }}</span>
|
||||
|
||||
@@ -3,26 +3,42 @@ import {nextTick, defineComponent} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import type {SvgName} from '../svg.ts';
|
||||
|
||||
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
|
||||
|
||||
type DashboardRepo = {
|
||||
id: number,
|
||||
link: string,
|
||||
full_name: string,
|
||||
archived: boolean,
|
||||
fork: boolean,
|
||||
mirror: boolean,
|
||||
template: boolean,
|
||||
private: boolean,
|
||||
internal: boolean,
|
||||
latest_commit_status_state?: CommitStatus,
|
||||
latest_commit_status_state_link?: string,
|
||||
locale_latest_commit_status_state?: string,
|
||||
};
|
||||
|
||||
type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped';
|
||||
|
||||
type CommitStatusMap = {
|
||||
[status in CommitStatus]: {
|
||||
name: string,
|
||||
name: SvgName,
|
||||
color: string,
|
||||
};
|
||||
};
|
||||
|
||||
// make sure this matches templates/repo/commit_status.tmpl
|
||||
const commitStatus: CommitStatusMap = {
|
||||
pending: {name: 'octicon-dot-fill', color: 'yellow'},
|
||||
success: {name: 'octicon-check', color: 'green'},
|
||||
error: {name: 'gitea-exclamation', color: 'red'},
|
||||
failure: {name: 'octicon-x', color: 'red'},
|
||||
warning: {name: 'gitea-exclamation', color: 'yellow'},
|
||||
skipped: {name: 'octicon-skip', color: 'grey'},
|
||||
pending: {name: 'octicon-dot-fill', color: 'tw-text-yellow'},
|
||||
success: {name: 'octicon-check', color: 'tw-text-green'},
|
||||
error: {name: 'gitea-exclamation', color: 'tw-text-red'},
|
||||
failure: {name: 'octicon-x', color: 'tw-text-red'},
|
||||
warning: {name: 'gitea-exclamation', color: 'tw-text-yellow'},
|
||||
skipped: {name: 'octicon-skip', color: 'tw-text-text-light'},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
@@ -38,8 +54,8 @@ export default defineComponent({
|
||||
|
||||
return {
|
||||
tab,
|
||||
repos: [],
|
||||
reposTotalCount: null,
|
||||
repos: [] as DashboardRepo[],
|
||||
reposTotalCount: null as number | null,
|
||||
reposFilter,
|
||||
archivedFilter,
|
||||
privateFilter,
|
||||
@@ -48,7 +64,7 @@ export default defineComponent({
|
||||
searchQuery,
|
||||
isLoading: false,
|
||||
staticPrefix: assetUrlPrefix,
|
||||
counts: {},
|
||||
counts: {} as Record<string, number>,
|
||||
repoTypes: {
|
||||
all: {
|
||||
searchMode: '',
|
||||
@@ -65,16 +81,49 @@ export default defineComponent({
|
||||
collaborative: {
|
||||
searchMode: 'collaborative',
|
||||
},
|
||||
},
|
||||
textArchivedFilterTitles: {},
|
||||
textPrivateFilterTitles: {},
|
||||
|
||||
organizations: [],
|
||||
} as Record<string, {searchMode: string}>,
|
||||
textArchivedFilterTitles: {} as Record<string, string>,
|
||||
textPrivateFilterTitles: {} as Record<string, string>,
|
||||
organizations: [] as Array<{name: string, full_name: string, num_repos: number, org_visibility: string}>,
|
||||
isOrganization: true,
|
||||
canCreateOrganization: false,
|
||||
organizationsTotalCount: 0,
|
||||
organizationId: 0,
|
||||
|
||||
searchLimit: 0,
|
||||
uid: 0,
|
||||
teamId: 0,
|
||||
isMirrorsEnabled: false,
|
||||
isStarsEnabled: false,
|
||||
canCreateMigrations: false,
|
||||
textNoOrg: '',
|
||||
textNoRepo: '',
|
||||
textRepository: '',
|
||||
textOrganization: '',
|
||||
textMyRepos: '',
|
||||
textNewRepo: '',
|
||||
textSearchRepos: '',
|
||||
textFilter: '',
|
||||
textShowArchived: '',
|
||||
textShowPrivate: '',
|
||||
textShowBothArchivedUnarchived: '',
|
||||
textShowOnlyUnarchived: '',
|
||||
textShowOnlyArchived: '',
|
||||
textShowBothPrivatePublic: '',
|
||||
textShowOnlyPublic: '',
|
||||
textShowOnlyPrivate: '',
|
||||
textAll: '',
|
||||
textSources: '',
|
||||
textForks: '',
|
||||
textMirrors: '',
|
||||
textCollaborative: '',
|
||||
textFirstPage: '',
|
||||
textPreviousPage: '',
|
||||
textNextPage: '',
|
||||
textLastPage: '',
|
||||
textMyOrgs: '',
|
||||
textNewOrg: '',
|
||||
textOrgVisibilityLimited: '',
|
||||
textOrgVisibilityPrivate: '',
|
||||
subUrl: appSubUrl,
|
||||
...pageData.dashboardRepoList,
|
||||
activeIndex: -1, // don't select anything at load, first cursor down will select
|
||||
@@ -110,9 +159,9 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const el = document.querySelector('#dashboard-repo-list');
|
||||
const el = document.querySelector('#dashboard-repo-list')!;
|
||||
this.changeReposFilter(this.reposFilter);
|
||||
fomanticQuery(el.querySelector('.ui.dropdown')).dropdown();
|
||||
fomanticQuery(el.querySelector('.ui.dropdown')!).dropdown();
|
||||
|
||||
this.textArchivedFilterTitles = {
|
||||
'archived': this.textShowOnlyArchived,
|
||||
@@ -250,7 +299,7 @@ export default defineComponent({
|
||||
nextTick(() => {
|
||||
// MDN: If there's no focused element, this is the Document.body or Document.documentElement.
|
||||
if ((document.activeElement === document.body || document.activeElement === document.documentElement)) {
|
||||
this.$refs.search.focus({preventScroll: true});
|
||||
(this.$refs.search as HTMLInputElement).focus({preventScroll: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -283,7 +332,7 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
repoIcon(repo: any) {
|
||||
repoIcon(repo: DashboardRepo) {
|
||||
if (repo.fork) {
|
||||
return 'octicon-repo-forked';
|
||||
} else if (repo.mirror) {
|
||||
@@ -307,6 +356,7 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
async reposFilterKeyControl(e: KeyboardEvent) {
|
||||
if (e.isComposing) return;
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
|
||||
@@ -429,14 +479,14 @@ export default defineComponent({
|
||||
<li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id">
|
||||
<a class="repo-list-link muted" :href="repo.link">
|
||||
<svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/>
|
||||
<div class="text truncate">{{ repo.full_name }}</div>
|
||||
<div class="tw-inline-block tw-truncate">{{ repo.full_name }}</div>
|
||||
<div v-if="repo.archived">
|
||||
<svg-icon name="octicon-archive" :size="16"/>
|
||||
</div>
|
||||
</a>
|
||||
<a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || null" :data-tooltip-content="repo.locale_latest_commit_status_state">
|
||||
<a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || undefined" :data-tooltip-content="repo.locale_latest_commit_status_state">
|
||||
<!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
|
||||
<svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
|
||||
<svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -493,14 +543,14 @@ export default defineComponent({
|
||||
<li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name">
|
||||
<a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)">
|
||||
<svg-icon name="octicon-organization" :size="16" class="repo-list-icon"/>
|
||||
<div class="text truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div>
|
||||
<div class="tw-inline-block tw-truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div>
|
||||
<div><!-- div to prevent underline of label on hover -->
|
||||
<span class="ui tiny basic label" v-if="org.org_visibility !== 'public'">
|
||||
{{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="text light grey tw-flex tw-items-center tw-ml-2">
|
||||
<div class="tw-text-grey-light tw-flex tw-items-center tw-ml-2">
|
||||
{{ org.num_repos }}
|
||||
<svg-icon name="octicon-repo" :size="16" class="tw-ml-1 tw-mt-0.5"/>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ type CommitListResult = {
|
||||
export default defineComponent({
|
||||
components: {SvgIcon},
|
||||
data: () => {
|
||||
const el = document.querySelector('#diff-commit-select');
|
||||
const el = document.querySelector('#diff-commit-select')!;
|
||||
return {
|
||||
menuVisible: false,
|
||||
isLoading: false,
|
||||
@@ -35,7 +35,7 @@ export default defineComponent({
|
||||
mergeBase: el.getAttribute('data-merge-base'),
|
||||
commits: [] as Array<Commit>,
|
||||
hoverActivated: false,
|
||||
lastReviewCommitSha: '',
|
||||
lastReviewCommitSha: '' as string | null,
|
||||
uniqueIdMenu: generateElemId('diff-commit-selector-menu-'),
|
||||
uniqueIdShowAll: generateElemId('diff-commit-selector-show-all-'),
|
||||
};
|
||||
@@ -165,7 +165,7 @@ export default defineComponent({
|
||||
},
|
||||
/** Called when user clicks on since last review */
|
||||
changesSinceLastReviewClick() {
|
||||
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`);
|
||||
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1)!.id}${this.queryParams}`);
|
||||
},
|
||||
/** Clicking on a single commit opens this specific commit */
|
||||
commitClicked(commitId: string, newWindow = false) {
|
||||
@@ -193,7 +193,7 @@ export default defineComponent({
|
||||
// find all selected commits and generate a link
|
||||
const firstSelected = this.commits.findIndex((x) => x.selected);
|
||||
const lastSelected = this.commits.findLastIndex((x) => x.selected);
|
||||
let beforeCommitID: string;
|
||||
let beforeCommitID: string | null = null;
|
||||
if (firstSelected === 0) {
|
||||
beforeCommitID = this.mergeBase;
|
||||
} else {
|
||||
@@ -204,7 +204,7 @@ export default defineComponent({
|
||||
if (firstSelected === lastSelected) {
|
||||
// if the start and end are the same, we show this single commit
|
||||
window.location.assign(`${this.issueLink}/commits/${afterCommitID}${this.queryParams}`);
|
||||
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1).id) {
|
||||
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1)!.id) {
|
||||
// if the first commit is selected and the last commit is selected, we show all commits
|
||||
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
|
||||
} else {
|
||||
@@ -236,7 +236,7 @@ export default defineComponent({
|
||||
<div class="gt-ellipsis">
|
||||
{{ locale.show_all_commits }}
|
||||
</div>
|
||||
<div class="gt-ellipsis text light-2 tw-mb-0">
|
||||
<div class="gt-ellipsis tw-text-text-light-2 tw-mb-0">
|
||||
{{ locale.stats_num_commits }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,11 +251,11 @@ export default defineComponent({
|
||||
<div class="gt-ellipsis">
|
||||
{{ locale.show_changes_since_your_last_review }}
|
||||
</div>
|
||||
<div class="gt-ellipsis text light-2">
|
||||
<div class="gt-ellipsis tw-text-text-light-2">
|
||||
{{ commitsSinceLastReview }} commits
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!isLoading" class="info text light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
|
||||
<span v-if="!isLoading" class="info tw-text-text-light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
|
||||
<template v-for="(commit, idx) in commits" :key="commit.id">
|
||||
<div
|
||||
class="item" role="menuitem"
|
||||
@@ -273,7 +273,7 @@ export default defineComponent({
|
||||
<div class="gt-ellipsis commit-list-summary">
|
||||
{{ commit.summary }}
|
||||
</div>
|
||||
<div class="gt-ellipsis text light-2">
|
||||
<div class="gt-ellipsis tw-text-text-light-2">
|
||||
{{ commit.committer_or_author_name }}
|
||||
<span class="text right">
|
||||
<!-- TODO: make this respect the PreferredTimestampTense setting -->
|
||||
|
||||
@@ -4,6 +4,7 @@ import {toggleElem} from '../utils/dom.ts';
|
||||
import {diffTreeStore} from '../modules/diff-file.ts';
|
||||
import {setFileFolding} from '../features/file-fold.ts';
|
||||
import {onMounted, onUnmounted} from 'vue';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
|
||||
|
||||
@@ -11,15 +12,15 @@ const store = diffTreeStore();
|
||||
|
||||
onMounted(() => {
|
||||
// Default to true if unset
|
||||
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
|
||||
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility);
|
||||
store.fileTreeIsVisible = localUserSettings.getBoolean(LOCAL_STORAGE_KEY, true);
|
||||
document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility);
|
||||
|
||||
hashChangeListener();
|
||||
window.addEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility);
|
||||
document.querySelector('.diff-toggle-file-tree-button')!.removeEventListener('click', toggleVisibility);
|
||||
window.removeEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
|
||||
@@ -33,7 +34,7 @@ function expandSelectedFile() {
|
||||
if (store.selectedItem) {
|
||||
const box = document.querySelector(store.selectedItem);
|
||||
const folded = box?.getAttribute('data-folded') === 'true';
|
||||
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
|
||||
if (folded) setFileFolding(box, box.querySelector('.fold-file')!, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,15 +44,15 @@ function toggleVisibility() {
|
||||
|
||||
function updateVisibility(visible: boolean) {
|
||||
store.fileTreeIsVisible = visible;
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString());
|
||||
localUserSettings.setBoolean(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
|
||||
updateState(store.fileTreeIsVisible);
|
||||
}
|
||||
|
||||
function updateState(visible: boolean) {
|
||||
const btn = document.querySelector('.diff-toggle-file-tree-button');
|
||||
const btn = document.querySelector('.diff-toggle-file-tree-button')!;
|
||||
const [toShow, toHide] = btn.querySelectorAll('.icon');
|
||||
const tree = document.querySelector('#diff-file-tree');
|
||||
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
|
||||
const tree = document.querySelector('#diff-file-tree')!;
|
||||
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text')!;
|
||||
btn.setAttribute('data-tooltip-content', newTooltip);
|
||||
toggleElem(tree, visible);
|
||||
toggleElem(toShow, !visible);
|
||||
|
||||
@@ -12,13 +12,13 @@ const collapsed = shallowRef(props.item.IsViewed);
|
||||
|
||||
function getIconForDiffStatus(pType: DiffStatus) {
|
||||
const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
|
||||
'': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case
|
||||
'added': {name: 'octicon-diff-added', classes: ['text', 'green']},
|
||||
'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
|
||||
'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
|
||||
'renamed': {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
|
||||
'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']},
|
||||
'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
|
||||
'': {name: 'octicon-blocked', classes: ['tw-text-red']}, // unknown case
|
||||
'added': {name: 'octicon-diff-added', classes: ['tw-text-green']},
|
||||
'modified': {name: 'octicon-diff-modified', classes: ['tw-text-yellow']},
|
||||
'deleted': {name: 'octicon-diff-removed', classes: ['tw-text-red']},
|
||||
'renamed': {name: 'octicon-diff-renamed', classes: ['tw-text-teal']},
|
||||
'copied': {name: 'octicon-diff-renamed', classes: ['tw-text-green']},
|
||||
'typechange': {name: 'octicon-diff-modified', classes: ['tw-text-green']}, // there is no octicon for copied, so renamed should be ok
|
||||
};
|
||||
return diffTypes[pType] ?? diffTypes[''];
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
|
||||
const {csrfToken, pageData} = window.config;
|
||||
const {pageData} = window.config;
|
||||
|
||||
const mergeForm = pageData.pullRequestMergeForm;
|
||||
const mergeForm = pageData.pullRequestMergeForm!;
|
||||
|
||||
const mergeTitleFieldValue = shallowRef('');
|
||||
const mergeMessageFieldValue = shallowRef('');
|
||||
@@ -95,7 +95,6 @@ function clearMergeMessage() {
|
||||
|
||||
<!-- another similar form is in pull.tmpl (manual merge)-->
|
||||
<form class="ui form form-fetch-action" v-if="showActionForm" :action="mergeForm.baseLink+'/merge'" method="post">
|
||||
<input type="hidden" name="_csrf" :value="csrfToken">
|
||||
<input type="hidden" name="head_commit_id" v-model="mergeForm.pullHeadCommitID">
|
||||
<input type="hidden" name="merge_when_checks_succeed" v-model="autoMergeWhenSucceed">
|
||||
<input type="hidden" name="force_merge" v-model="forceMerge">
|
||||
@@ -177,7 +176,6 @@ function clearMergeMessage() {
|
||||
|
||||
<!-- the cancel auto merge button -->
|
||||
<form v-if="mergeForm.hasPendingPullRequestMerge" :action="mergeForm.baseLink+'/cancel_auto_merge'" method="post" class="tw-ml-4">
|
||||
<input type="hidden" name="_csrf" :value="csrfToken">
|
||||
<button class="ui button">
|
||||
{{ mergeForm.textAutoMergeCancelSchedule }}
|
||||
</button>
|
||||
@@ -232,7 +230,7 @@ function clearMergeMessage() {
|
||||
bottom: -1px;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
color: var(--color-info-text);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-info-bg);
|
||||
border: 1px solid var(--color-info-border);
|
||||
border-left: none;
|
||||
@@ -240,7 +238,7 @@ function clearMergeMessage() {
|
||||
}
|
||||
|
||||
.auto-merge-small:hover {
|
||||
color: var(--color-info-text);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-info-bg);
|
||||
border: 1px solid var(--color-info-border);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
// @ts-expect-error - module exports no types
|
||||
import {VueBarGraph} from 'vue-bar-graph';
|
||||
import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
|
||||
import {computed, onMounted, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
|
||||
|
||||
const colors = shallowRef({
|
||||
barColor: 'green',
|
||||
@@ -41,8 +40,8 @@ const graphWidth = computed(() => {
|
||||
return activityTopAuthors.length * 40;
|
||||
});
|
||||
|
||||
const styleElement = useTemplateRef('styleElement');
|
||||
const altStyleElement = useTemplateRef('altStyleElement');
|
||||
const styleElement = useTemplateRef('styleElement') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||
const altStyleElement = useTemplateRef('altStyleElement') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||
|
||||
onMounted(() => {
|
||||
const refStyle = window.getComputedStyle(styleElement.value);
|
||||
|
||||
@@ -20,12 +20,14 @@ type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
|
||||
export default defineComponent({
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
elRoot: HTMLElement,
|
||||
elRoot: {
|
||||
type: HTMLElement,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const shouldShowTabBranches = this.elRoot.getAttribute('data-show-tab-branches') === 'true';
|
||||
return {
|
||||
csrfToken: window.config.csrfToken,
|
||||
allItems: [] as ListItem[],
|
||||
selectedTab: (shouldShowTabBranches ? 'branches' : 'tags') as SelectedTab,
|
||||
searchTerm: '',
|
||||
@@ -33,28 +35,28 @@ export default defineComponent({
|
||||
activeItemIndex: 0,
|
||||
tabLoadingStates: {} as TabLoadingStates,
|
||||
|
||||
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare'),
|
||||
textBranches: this.elRoot.getAttribute('data-text-branches'),
|
||||
textTags: this.elRoot.getAttribute('data-text-tags'),
|
||||
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch'),
|
||||
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag'),
|
||||
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label'),
|
||||
textCreateTag: this.elRoot.getAttribute('data-text-create-tag'),
|
||||
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch'),
|
||||
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from'),
|
||||
textNoResults: this.elRoot.getAttribute('data-text-no-results'),
|
||||
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches'),
|
||||
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags'),
|
||||
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare')!,
|
||||
textBranches: this.elRoot.getAttribute('data-text-branches')!,
|
||||
textTags: this.elRoot.getAttribute('data-text-tags')!,
|
||||
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch')!,
|
||||
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag')!,
|
||||
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label')!,
|
||||
textCreateTag: this.elRoot.getAttribute('data-text-create-tag')!,
|
||||
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch')!,
|
||||
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from')!,
|
||||
textNoResults: this.elRoot.getAttribute('data-text-no-results')!,
|
||||
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches')!,
|
||||
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags')!,
|
||||
|
||||
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch'),
|
||||
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link'),
|
||||
currentTreePath: this.elRoot.getAttribute('data-current-tree-path'),
|
||||
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch')!,
|
||||
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link')!,
|
||||
currentTreePath: this.elRoot.getAttribute('data-current-tree-path')!,
|
||||
currentRefType: this.elRoot.getAttribute('data-current-ref-type') as GitRefType,
|
||||
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name'),
|
||||
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name')!,
|
||||
|
||||
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template'),
|
||||
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template'),
|
||||
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text'),
|
||||
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template')!,
|
||||
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template')!,
|
||||
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text')!,
|
||||
showTabBranches: shouldShowTabBranches,
|
||||
showTabTags: this.elRoot.getAttribute('data-show-tab-tags') === 'true',
|
||||
allowCreateNewRef: this.elRoot.getAttribute('data-allow-create-new-ref') === 'true',
|
||||
@@ -92,7 +94,7 @@ export default defineComponent({
|
||||
}).length;
|
||||
},
|
||||
createNewRefFormActionUrl() {
|
||||
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName)}`;
|
||||
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName!)}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -153,11 +155,11 @@ export default defineComponent({
|
||||
return -1;
|
||||
},
|
||||
getActiveItem() {
|
||||
const el = this.$refs[`listItem${this.activeItemIndex}`];
|
||||
// @ts-expect-error - el is unknown type
|
||||
return (el && el.length) ? el[0] : null;
|
||||
const el = this.$refs[`listItem${this.activeItemIndex}`] as Array<HTMLDivElement>;
|
||||
return el?.length ? el[0] : null;
|
||||
},
|
||||
keydown(e: KeyboardEvent) {
|
||||
if (e.isComposing) return;
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -172,7 +174,7 @@ export default defineComponent({
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = nextIndex;
|
||||
this.getActiveItem().scrollIntoView({block: 'nearest'});
|
||||
this.getActiveItem()!.scrollIntoView({block: 'nearest'});
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.getActiveItem()?.click();
|
||||
@@ -265,11 +267,10 @@ export default defineComponent({
|
||||
<svg-icon name="octicon-git-branch" class="tw-mr-1"/>
|
||||
<span v-text="textCreateBranch.replace('%s', searchTerm)"/>
|
||||
</div>
|
||||
<div class="text small">
|
||||
<div class="tw-text-xs">
|
||||
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
|
||||
</div>
|
||||
<form ref="createNewRefForm" method="post" :action="createNewRefFormActionUrl">
|
||||
<input type="hidden" name="_csrf" :value="csrfToken">
|
||||
<input type="hidden" name="new_branch_name" :value="searchTerm">
|
||||
<input type="hidden" name="create_tag" :value="String(selectedTab === 'tags')">
|
||||
<input type="hidden" name="current_path" :value="currentTreePath">
|
||||
|
||||
@@ -49,7 +49,7 @@ defineProps<{
|
||||
|
||||
const isLoading = shallowRef(false);
|
||||
const errorText = shallowRef('');
|
||||
const repoLink = pageData.repoLink;
|
||||
const repoLink = pageData.repoLink!;
|
||||
const data = shallowRef<DayData[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -153,7 +153,7 @@ const options: ChartOptions<'line'> = {
|
||||
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
||||
{{ locale.loadingInfo }}
|
||||
</div>
|
||||
<div v-else class="text red">
|
||||
<div v-else class="tw-text-red">
|
||||
<SvgIcon name="octicon-x-circle-fill"/>
|
||||
{{ errorText }}
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,15 @@ const customEventListener: Plugin = {
|
||||
},
|
||||
};
|
||||
|
||||
type LineOptions = ChartOptions<'line'> & {
|
||||
plugins?: {
|
||||
customEventListener?: {
|
||||
chartType: string;
|
||||
instance: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Chart.defaults.color = chartJsColors.text;
|
||||
Chart.defaults.borderColor = chartJsColors.border;
|
||||
|
||||
@@ -174,7 +183,7 @@ export default defineComponent({
|
||||
user.max_contribution_type = 0;
|
||||
const filteredWeeks = user.weeks.filter((week: Record<string, number>) => {
|
||||
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
|
||||
if (week.week >= this.xAxisMin! - oneWeek && week.week <= this.xAxisMax! + oneWeek) {
|
||||
user.total_commits += week.commits;
|
||||
user.total_additions += week.additions;
|
||||
user.total_deletions += week.deletions;
|
||||
@@ -238,8 +247,8 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) {
|
||||
const minVal = Number(chart.options.scales.x.min);
|
||||
const maxVal = Number(chart.options.scales.x.max);
|
||||
const minVal = Number(chart.options.scales?.x?.min);
|
||||
const maxVal = Number(chart.options.scales?.x?.max);
|
||||
if (reset) {
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
@@ -251,7 +260,7 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
getOptions(type: string): ChartOptions<'line'> {
|
||||
getOptions(type: string): LineOptions {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -264,7 +273,6 @@ export default defineComponent({
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
},
|
||||
// @ts-expect-error: bug in chart.js types
|
||||
customEventListener: {
|
||||
chartType: type,
|
||||
instance: this,
|
||||
@@ -302,8 +310,8 @@ export default defineComponent({
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
min: this.xAxisMin,
|
||||
max: this.xAxisMax,
|
||||
min: this.xAxisMin ?? undefined,
|
||||
max: this.xAxisMax ?? undefined,
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
@@ -334,7 +342,7 @@ export default defineComponent({
|
||||
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
||||
<div>
|
||||
<relative-time
|
||||
v-if="xAxisMin > 0"
|
||||
v-if="xAxisMin && xAxisMin > 0"
|
||||
format="datetime"
|
||||
year="numeric"
|
||||
month="short"
|
||||
@@ -346,7 +354,7 @@ export default defineComponent({
|
||||
</relative-time>
|
||||
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
|
||||
<relative-time
|
||||
v-if="xAxisMax > 0"
|
||||
v-if="xAxisMax && xAxisMax > 0"
|
||||
format="datetime"
|
||||
year="numeric"
|
||||
month="short"
|
||||
@@ -384,7 +392,7 @@ export default defineComponent({
|
||||
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
||||
{{ locale.loadingInfo }}
|
||||
</div>
|
||||
<div v-else class="text red">
|
||||
<div v-else class="tw-text-red">
|
||||
<SvgIcon name="octicon-x-circle-fill"/>
|
||||
{{ errorText }}
|
||||
</div>
|
||||
@@ -416,8 +424,8 @@ export default defineComponent({
|
||||
{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}
|
||||
</a>
|
||||
</strong>
|
||||
<strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
|
||||
<strong v-if="contributor.total_deletions" class="text red">
|
||||
<strong v-if="contributor.total_additions" class="tw-text-green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
|
||||
<strong v-if="contributor.total_deletions" class="tw-text-red">
|
||||
{{ contributor.total_deletions.toLocaleString() }}--</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watch, nextTick, useTemplateRef, onMounted, onUnmounted, type ShallowRef} from 'vue';
|
||||
import {generateElemId} from '../utils/dom.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {filterRepoFilesWeighted} from '../features/repo-findfile.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {throttle} from 'throttle-debounce';
|
||||
|
||||
const props = defineProps({
|
||||
repoLink: { type: String, required: true },
|
||||
currentRefNameSubURL: { type: String, required: true },
|
||||
treeListUrl: { type: String, required: true },
|
||||
noResultsText: { type: String, required: true },
|
||||
placeholder: { type: String, required: true },
|
||||
});
|
||||
|
||||
const refElemInput = useTemplateRef('searchInput') as Readonly<ShallowRef<HTMLInputElement>>;
|
||||
const refElemPopup = useTemplateRef('searchPopup') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||
|
||||
const searchQuery = ref('');
|
||||
const allFiles = ref<string[]>([]);
|
||||
const selectedIndex = ref(0);
|
||||
const isLoadingFileList = ref(false);
|
||||
const hasLoadedFileList = ref(false);
|
||||
|
||||
const showPopup = computed(() => searchQuery.value.length > 0);
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
if (!searchQuery.value) return [];
|
||||
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
|
||||
});
|
||||
|
||||
const applySearchQuery = throttle(300, () => {
|
||||
searchQuery.value = refElemInput.value.value;
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
const handleSearchInput = () => {
|
||||
loadFileListForSearch();
|
||||
applySearchQuery();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
clearSearch();
|
||||
nextTick(() => refElemInput.value.blur());
|
||||
return;
|
||||
}
|
||||
if (!searchQuery.value || filteredFiles.value.length === 0) return;
|
||||
|
||||
const handleSelectedItem = (idx: number) => {
|
||||
e.preventDefault();
|
||||
selectedIndex.value = idx;
|
||||
const el = refElemPopup.value.querySelector(`.file-search-results > :nth-child(${idx+1} of .item)`);
|
||||
el?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
|
||||
};
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
handleSelectedItem(Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
handleSelectedItem(Math.max(selectedIndex.value - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selectedFile = filteredFiles.value[selectedIndex.value];
|
||||
if (selectedFile) {
|
||||
handleSearchResultClick(selectedFile.matchResult.join(''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = '';
|
||||
refElemInput.value.value = '';
|
||||
};
|
||||
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!searchQuery.value) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const clickInside = refElemInput.value.contains(target) || refElemPopup.value.contains(target);
|
||||
if (!clickInside) clearSearch();
|
||||
};
|
||||
|
||||
const loadFileListForSearch = async () => {
|
||||
if (hasLoadedFileList.value || isLoadingFileList.value) return;
|
||||
|
||||
isLoadingFileList.value = true;
|
||||
try {
|
||||
const response = await GET(props.treeListUrl);
|
||||
allFiles.value = await response.json();
|
||||
hasLoadedFileList.value = true;
|
||||
} finally {
|
||||
isLoadingFileList.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
function handleSearchResultClick(filePath: string) {
|
||||
clearSearch();
|
||||
window.location.href = `${props.repoLink}/src/${pathEscapeSegments(props.currentRefNameSubURL)}/${pathEscapeSegments(filePath)}`;
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!showPopup.value) return;
|
||||
|
||||
const rectInput = refElemInput.value.getBoundingClientRect();
|
||||
const rectPopup = refElemPopup.value.getBoundingClientRect();
|
||||
const docElem = document.documentElement;
|
||||
const style = refElemPopup.value.style;
|
||||
style.top = `${docElem.scrollTop + rectInput.bottom + 4}px`;
|
||||
if (rectInput.x + rectPopup.width < docElem.clientWidth) {
|
||||
// enough space to align left with the input
|
||||
style.left = `${docElem.scrollLeft + rectInput.x}px`;
|
||||
} else {
|
||||
// no enough space, align right from the viewport right edge minus page margin
|
||||
const leftPos = docElem.scrollLeft + docElem.getBoundingClientRect().width - rectPopup.width;
|
||||
style.left = `calc(${leftPos}px - var(--page-margin-x))`;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const searchPopupId = generateElemId('file-search-popup-');
|
||||
refElemPopup.value.setAttribute('id', searchPopupId);
|
||||
refElemInput.value.setAttribute('aria-controls', searchPopupId);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
});
|
||||
|
||||
// Position search results below the input
|
||||
watch([searchQuery, filteredFiles], async () => {
|
||||
if (searchQuery.value) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui small input global-shortcut-wrapper">
|
||||
<input
|
||||
ref="searchInput" :placeholder="placeholder" autocomplete="off"
|
||||
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
|
||||
@input="handleSearchInput" @keydown="handleKeyDown"
|
||||
>
|
||||
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="t">T</kbd>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-show="showPopup" ref="searchPopup" class="file-search-popup">
|
||||
<!-- always create the popup by v-show above to avoid null ref, only create the popup content if the popup should be displayed to save memory -->
|
||||
<template v-if="showPopup">
|
||||
<div v-if="filteredFiles.length" role="listbox" class="file-search-results flex-items-block">
|
||||
<div
|
||||
v-for="(result, idx) in filteredFiles" :key="result.matchResult.join('')"
|
||||
:class="['item', { 'selected': idx === selectedIndex }]"
|
||||
role="option" :aria-selected="idx === selectedIndex" @click="handleSearchResultClick(result.matchResult.join(''))"
|
||||
@mouseenter="selectedIndex = idx" :title="result.matchResult.join('')"
|
||||
>
|
||||
<SvgIcon name="octicon-file" class="file-icon"/>
|
||||
<span class="full-path">
|
||||
<span v-for="(part, index) in result.matchResult" :key="index">{{ part }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isLoadingFileList">
|
||||
<div class="is-loading"/>
|
||||
</div>
|
||||
<div v-else class="tw-p-4">
|
||||
{{ props.noResultsText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-search-popup {
|
||||
position: absolute;
|
||||
background: var(--color-box-body);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
width: max-content;
|
||||
max-height: min(calc(100vw - 20px), 300px);
|
||||
max-width: min(calc(100vw - 40px), 600px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-search-popup .is-loading {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.file-search-results .item {
|
||||
align-items: flex-start;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.file-search-results .item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-search-results .item:hover,
|
||||
.file-search-results .item.selected {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
.file-search-results .item .file-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.file-search-results .item .full-path {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file-search-results .item .full-path :nth-child(even) {
|
||||
color: var(--color-red);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TimeScale,
|
||||
type ChartOptions,
|
||||
type ChartData,
|
||||
type ChartDataset,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {Bar} from 'vue-chartjs';
|
||||
@@ -45,7 +46,7 @@ defineProps<{
|
||||
|
||||
const isLoading = shallowRef(false);
|
||||
const errorText = shallowRef('');
|
||||
const repoLink = pageData.repoLink;
|
||||
const repoLink = pageData.repoLink!;
|
||||
const data = ref<DayData[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -83,13 +84,12 @@ function toGraphData(data: DayData[]): ChartData<'bar'> {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
// @ts-expect-error -- bar chart expects one-dimensional data, but apparently x/y still works
|
||||
data: data.map((i) => ({x: i.week, y: i.commits})),
|
||||
label: 'Commits',
|
||||
backgroundColor: chartJsColors['commits'],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
} as unknown as ChartDataset<'bar'>,
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -131,7 +131,7 @@ const options: ChartOptions<'bar'> = {
|
||||
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
||||
{{ locale.loadingInfo }}
|
||||
</div>
|
||||
<div v-else class="text red">
|
||||
<div v-else class="tw-text-red">
|
||||
<SvgIcon name="octicon-x-circle-fill"/>
|
||||
{{ errorText }}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import ViewFileTreeItem from './ViewFileTreeItem.vue';
|
||||
import {onMounted, useTemplateRef} from 'vue';
|
||||
import {onMounted, useTemplateRef, type ShallowRef} from 'vue';
|
||||
import {createViewFileTreeStore} from './ViewFileTreeStore.ts';
|
||||
|
||||
const elRoot = useTemplateRef('elRoot');
|
||||
const elRoot = useTemplateRef('elRoot') as Readonly<ShallowRef<HTMLDivElement>>;;
|
||||
|
||||
const props = defineProps({
|
||||
repoLink: {type: String, required: true},
|
||||
@@ -24,7 +24,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="view-file-tree-items" ref="elRoot">
|
||||
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/>
|
||||
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.entryName" :item="item" :store="store"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {isPlainClick} from '../utils/dom.ts';
|
||||
import {shouldTriggerAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {shallowRef} from 'vue';
|
||||
import {type createViewFileTreeStore} from './ViewFileTreeStore.ts';
|
||||
|
||||
type Item = {
|
||||
entryName: string;
|
||||
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
|
||||
entryIcon: string;
|
||||
entryIconOpen: string;
|
||||
fullPath: string;
|
||||
submoduleUrl?: string;
|
||||
children?: Item[];
|
||||
};
|
||||
import type {createViewFileTreeStore, FileTreeItem} from './ViewFileTreeStore.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
item: Item,
|
||||
item: FileTreeItem,
|
||||
store: ReturnType<typeof createViewFileTreeStore>
|
||||
}>();
|
||||
|
||||
@@ -37,9 +28,10 @@ const doLoadChildren = async () => {
|
||||
};
|
||||
|
||||
const onItemClick = (e: MouseEvent) => {
|
||||
// only handle the click event with page partial reloading if the user didn't press any special key
|
||||
// let browsers handle special keys like "Ctrl+Click"
|
||||
if (!isPlainClick(e)) return;
|
||||
// only handle the click event with partial page reloading if both
|
||||
// - the user didn't press any special key like "Ctrl+Click" (which may have custom browser behavior)
|
||||
// - the editor/commit form isn't dirty (a full page reload shows a confirmation dialog if the form contains unsaved changes)
|
||||
if (!isPlainClick(e) || shouldTriggerAreYouSure()) return;
|
||||
e.preventDefault();
|
||||
if (props.item.entryMode === 'tree') doLoadChildren();
|
||||
store.navigateTreeView(props.item.fullPath);
|
||||
|
||||
@@ -4,12 +4,25 @@ import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
|
||||
export type FileTreeItem = {
|
||||
entryName: string;
|
||||
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
|
||||
entryIcon: string;
|
||||
entryIconOpen: string;
|
||||
fullPath: string;
|
||||
submoduleUrl?: string;
|
||||
children?: Array<FileTreeItem>;
|
||||
};
|
||||
|
||||
export function createViewFileTreeStore(props: {repoLink: string, treePath: string, currentRefNameSubURL: string}) {
|
||||
const store = reactive({
|
||||
rootFiles: [],
|
||||
rootFiles: [] as Array<FileTreeItem>,
|
||||
selectedItem: props.treePath,
|
||||
|
||||
async loadChildren(treePath: string, subPath: string = '') {
|
||||
// there is no git ref if no commits were made yet (an empty repo)
|
||||
if (!props.currentRefNameSubURL) return null;
|
||||
|
||||
const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`);
|
||||
const json = await response.json();
|
||||
const poolSvgs = [];
|
||||
@@ -28,7 +41,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
|
||||
const u = new URL(url, window.origin);
|
||||
u.searchParams.set('only_content', 'true');
|
||||
const response = await GET(u.href);
|
||||
const elViewContent = document.querySelector('.repo-view-content');
|
||||
const elViewContent = document.querySelector('.repo-view-content')!;
|
||||
elViewContent.innerHTML = await response.text();
|
||||
const elViewContentData = elViewContent.querySelector('.repo-view-content-data');
|
||||
if (!elViewContentData) return; // if error occurs, there is no such element
|
||||
@@ -39,7 +52,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
|
||||
|
||||
async navigateTreeView(treePath: string) {
|
||||
const url = store.buildTreePathWebUrl(treePath);
|
||||
window.history.pushState({treePath, url}, null, url);
|
||||
window.history.pushState({treePath, url}, '', url);
|
||||
store.selectedItem = treePath;
|
||||
await store.loadViewContent(url);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import {isPlainClick} from '../utils/dom.ts';
|
||||
import {debounce} from 'throttle-debounce';
|
||||
import type {ActionsJob, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||
import type {ActionRunViewStore} from './ActionRunView.ts';
|
||||
|
||||
interface JobNode {
|
||||
id: number;
|
||||
name: string;
|
||||
status: ActionsRunStatus;
|
||||
duration: string;
|
||||
|
||||
x: number;
|
||||
y: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
fromId: number;
|
||||
toId: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface RoutedEdge extends Edge {
|
||||
path: string;
|
||||
fromNode: JobNode;
|
||||
toNode: JobNode;
|
||||
}
|
||||
|
||||
interface StoredState {
|
||||
scale: number;
|
||||
translateX: number;
|
||||
translateY: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
store: ActionRunViewStore;
|
||||
jobs: ActionsJob[];
|
||||
runLink: string;
|
||||
workflowId: string;
|
||||
}>()
|
||||
|
||||
const settingKeyStates = 'actions-graph-states';
|
||||
const maxStoredStates = 10;
|
||||
|
||||
const scale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const isDragging = ref(false);
|
||||
const lastMousePos = ref({x: 0, y: 0});
|
||||
const graphContainer = ref<HTMLElement | null>(null);
|
||||
const hoveredJobId = ref<number | null>(null);
|
||||
|
||||
const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`;
|
||||
|
||||
const loadSavedState = () => {
|
||||
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
|
||||
const saved = allStates[stateKey()];
|
||||
if (!saved) return;
|
||||
scale.value = clampScale(saved.scale ?? scale.value);
|
||||
translateX.value = saved.translateX ?? translateX.value;
|
||||
translateY.value = saved.translateY ?? translateY.value;
|
||||
};
|
||||
|
||||
const saveState = () => {
|
||||
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
|
||||
allStates[stateKey()] = {
|
||||
scale: scale.value,
|
||||
translateX: translateX.value,
|
||||
translateY: translateY.value,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const sortedStates = Object.entries(allStates)
|
||||
.sort(([, a], [, b]) => b.timestamp - a.timestamp)
|
||||
.slice(0, maxStoredStates);
|
||||
|
||||
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
|
||||
};
|
||||
|
||||
const nodeWidth = computed(() => {
|
||||
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
|
||||
return Math.min(Math.max(140, maxNameLength * 8), 180);
|
||||
});
|
||||
|
||||
const horizontalSpacing = computed(() => nodeWidth.value + 84);
|
||||
const graphWidth = computed(() => {
|
||||
if (jobsWithLayout.value.length === 0) return 800;
|
||||
const maxX = Math.max(...jobsWithLayout.value.map(j => j.x + nodeWidth.value));
|
||||
return maxX + margin * 2;
|
||||
});
|
||||
|
||||
const graphHeight = computed(() => {
|
||||
if (jobsWithLayout.value.length === 0) return 400;
|
||||
const maxY = Math.max(...jobsWithLayout.value.map(j => j.y + nodeHeight));
|
||||
return maxY + margin * 2;
|
||||
});
|
||||
|
||||
|
||||
const jobsWithLayout = computed<JobNode[]>(() => {
|
||||
try {
|
||||
const levels = computeJobLevels(props.jobs);
|
||||
const currentHorizontalSpacing = horizontalSpacing.value;
|
||||
|
||||
const jobsByLevel: ActionsJob[][] = [];
|
||||
let maxJobsPerLevel = 0;
|
||||
|
||||
props.jobs.forEach(job => {
|
||||
const level = levels.get(job.name) || levels.get(job.jobId) || 0;
|
||||
|
||||
if (!jobsByLevel[level]) {
|
||||
jobsByLevel[level] = [];
|
||||
}
|
||||
jobsByLevel[level].push(job);
|
||||
|
||||
if (jobsByLevel[level].length > maxJobsPerLevel) {
|
||||
maxJobsPerLevel = jobsByLevel[level].length;
|
||||
}
|
||||
});
|
||||
|
||||
const result: JobNode[] = [];
|
||||
jobsByLevel.forEach((levelJobs, levelIndex) => {
|
||||
if (!levelJobs || levelJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startY = margin;
|
||||
|
||||
levelJobs.forEach((job, jobIndex) => {
|
||||
result.push({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
duration: job.duration,
|
||||
|
||||
x: margin + levelIndex * currentHorizontalSpacing,
|
||||
y: startY + jobIndex * verticalSpacing,
|
||||
level: levelIndex,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return props.jobs.map((job, index) => ({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
duration: job.duration,
|
||||
|
||||
x: margin + index * horizontalSpacing.value,
|
||||
y: margin,
|
||||
level: 0,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
|
||||
const directNeedsByJobId = new Map<string, string[]>();
|
||||
const dependentsByJobId = new Map<string, Set<string>>();
|
||||
|
||||
for (const job of jobs) {
|
||||
const needs = job.needs || [];
|
||||
directNeedsByJobId.set(job.jobId, needs);
|
||||
|
||||
for (const need of needs) {
|
||||
if (!dependentsByJobId.has(need)) {
|
||||
dependentsByJobId.set(need, new Set());
|
||||
}
|
||||
dependentsByJobId.get(need)!.add(job.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
const reachabilityCache = new Map<string, boolean>();
|
||||
|
||||
function canReach(fromJobId: string, toJobId: string): boolean {
|
||||
const cacheKey = `${fromJobId}->${toJobId}`;
|
||||
if (reachabilityCache.has(cacheKey)) {
|
||||
return reachabilityCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const stack = [...(dependentsByJobId.get(fromJobId) || [])];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
if (current === toJobId) {
|
||||
reachabilityCache.set(cacheKey, true);
|
||||
return true;
|
||||
}
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
stack.push(...(dependentsByJobId.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) => {
|
||||
return !needs.some((otherNeed) => otherNeed !== need && canReach(need, otherNeed));
|
||||
}));
|
||||
}
|
||||
|
||||
return reducedNeedsByJobId;
|
||||
}
|
||||
|
||||
const directNeedsByJobId = computed(() => buildDirectNeedsMap(props.jobs));
|
||||
|
||||
const edges = computed<Edge[]>(() => {
|
||||
const edgesList: Edge[] = [];
|
||||
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||||
|
||||
for (const job of props.jobs) {
|
||||
if (!jobsByJobId.has(job.jobId)) {
|
||||
jobsByJobId.set(job.jobId, []);
|
||||
}
|
||||
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) {
|
||||
edgesList.push({
|
||||
fromId: upstreamJob.id,
|
||||
toId: job.id,
|
||||
key: `${upstreamJob.id}-${job.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edgesList;
|
||||
});
|
||||
|
||||
function buildRoundedConnectorPath(startX: number, startY: number, endX: number, endY: number, turnX: number): string {
|
||||
const deltaY = endY - startY;
|
||||
if (Math.abs(deltaY) < 1) {
|
||||
return `M ${startX} ${startY} H ${endX}`;
|
||||
}
|
||||
|
||||
const direction = deltaY > 0 ? 1 : -1;
|
||||
const elbowSize = Math.max(8, Math.min(24, Math.abs(deltaY) / 2, Math.abs(endX - startX) / 2));
|
||||
const controlOffset = elbowSize / 2;
|
||||
const clampedTurnX = Math.min(Math.max(turnX, startX + elbowSize), endX - elbowSize);
|
||||
|
||||
return [
|
||||
`M ${startX} ${startY}`,
|
||||
`H ${clampedTurnX - elbowSize}`,
|
||||
`C ${clampedTurnX - controlOffset} ${startY} ${clampedTurnX} ${startY + direction * controlOffset} ${clampedTurnX} ${startY + direction * elbowSize}`,
|
||||
`V ${endY - direction * elbowSize}`,
|
||||
`C ${clampedTurnX} ${endY - direction * controlOffset} ${clampedTurnX + controlOffset} ${endY} ${clampedTurnX + elbowSize} ${endY}`,
|
||||
`H ${endX}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
const routedEdges = computed<RoutedEdge[]>(() => {
|
||||
const nodesById = new Map(jobsWithLayout.value.map((job) => [job.id, job]));
|
||||
const outgoingEdges = new Map<number, Edge[]>();
|
||||
const incomingEdges = new Map<number, Edge[]>();
|
||||
|
||||
for (const edge of edges.value) {
|
||||
if (!outgoingEdges.has(edge.fromId)) {
|
||||
outgoingEdges.set(edge.fromId, []);
|
||||
}
|
||||
outgoingEdges.get(edge.fromId)!.push(edge);
|
||||
|
||||
if (!incomingEdges.has(edge.toId)) {
|
||||
incomingEdges.set(edge.toId, []);
|
||||
}
|
||||
incomingEdges.get(edge.toId)!.push(edge);
|
||||
}
|
||||
|
||||
for (const sourceEdges of outgoingEdges.values()) {
|
||||
sourceEdges.sort((a, b) => {
|
||||
const targetA = nodesById.get(a.toId);
|
||||
const targetB = nodesById.get(b.toId);
|
||||
if (!targetA || !targetB) return 0;
|
||||
return targetA.y - targetB.y || a.toId - b.toId;
|
||||
});
|
||||
}
|
||||
|
||||
const edgePaths: RoutedEdge[] = [];
|
||||
|
||||
for (const edge of edges.value) {
|
||||
const fromNode = nodesById.get(edge.fromId);
|
||||
const toNode = nodesById.get(edge.toId);
|
||||
if (!fromNode || !toNode) continue;
|
||||
|
||||
const startX = fromNode.x + nodeWidth.value;
|
||||
const startY = fromNode.y + nodeHeight / 2;
|
||||
const endX = toNode.x;
|
||||
const endY = toNode.y + nodeHeight / 2;
|
||||
const sourceEdges = outgoingEdges.get(edge.fromId) || [];
|
||||
const targetEdges = incomingEdges.get(edge.toId) || [];
|
||||
const horizontalGap = endX - startX;
|
||||
const turnOffset = Math.min(28, Math.max(16, horizontalGap * 0.14));
|
||||
const sourceTurnX = startX + turnOffset;
|
||||
const targetTurnX = endX - turnOffset;
|
||||
|
||||
let turnX = startX + horizontalGap / 2;
|
||||
if (sourceEdges.length > 1) {
|
||||
turnX = sourceTurnX;
|
||||
} else if (targetEdges.length > 1) {
|
||||
turnX = targetTurnX;
|
||||
}
|
||||
|
||||
const path = buildRoundedConnectorPath(startX, startY, endX, endY, turnX);
|
||||
|
||||
edgePaths.push({
|
||||
...edge,
|
||||
path,
|
||||
fromNode,
|
||||
toNode,
|
||||
});
|
||||
}
|
||||
|
||||
return edgePaths;
|
||||
});
|
||||
|
||||
const graphMetrics = computed(() => {
|
||||
const successCount = jobsWithLayout.value.filter(job => job.status === 'success').length;
|
||||
|
||||
const levels = new Map<number, number>();
|
||||
jobsWithLayout.value.forEach(job => {
|
||||
const count = levels.get(job.level) || 0;
|
||||
levels.set(job.level, count + 1);
|
||||
})
|
||||
const parallelism = Math.max(...Array.from(levels.values()), 0);
|
||||
|
||||
return {
|
||||
successRate: `${((successCount / jobsWithLayout.value.length) * 100).toFixed(0)}%`,
|
||||
parallelism,
|
||||
};
|
||||
})
|
||||
|
||||
const nodeHeight = 48;
|
||||
const verticalSpacing = 88;
|
||||
const margin = 40;
|
||||
|
||||
const minScale = 0.3;
|
||||
const maxScale = 1;
|
||||
|
||||
function clampScale(nextScale: number): number {
|
||||
return Math.min(Math.max(Math.round(nextScale * 100) / 100, minScale), maxScale);
|
||||
}
|
||||
|
||||
const canZoomIn = computed(() => scale.value < maxScale);
|
||||
|
||||
function zoomTo(nextScale: number) {
|
||||
scale.value = clampScale(nextScale);
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
zoomTo(scale.value * 1.2);
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
zoomTo(scale.value / 1.2);
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
scale.value = 1;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (!isPlainClick(e)) return;
|
||||
|
||||
// don't start drag on interactive/text elements inside the SVG
|
||||
const target = e.target as Element;
|
||||
const interactive = target.closest('div, p, a, span, button, input, text, .job-node-group');
|
||||
if (interactive?.closest('svg')) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
isDragging.value = true;
|
||||
lastMousePos.value = {x: e.clientX, y: e.clientY};
|
||||
graphContainer.value!.style.cursor = 'grabbing';
|
||||
}
|
||||
|
||||
function handleMouseMoveOnDocument(event: MouseEvent) {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const dx = event.clientX - lastMousePos.value.x;
|
||||
const dy = event.clientY - lastMousePos.value.y;
|
||||
|
||||
translateX.value += dx;
|
||||
translateY.value += dy;
|
||||
|
||||
lastMousePos.value = {x: event.clientX, y: event.clientY};
|
||||
}
|
||||
|
||||
function handleMouseUpOnDocument() {
|
||||
if (!isDragging.value) return;
|
||||
isDragging.value = false;
|
||||
graphContainer.value!.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
// Without a modifier, let the wheel scroll the page
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const zoomFactor = Math.exp(-event.deltaY * 0.0015);
|
||||
zoomTo(scale.value * zoomFactor);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSavedState();
|
||||
watch([translateX, translateY, scale], debounce(500, saveState));
|
||||
watch([scale], debounce(100, saveState));
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMoveOnDocument);
|
||||
document.addEventListener('mouseup', handleMouseUpOnDocument);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMoveOnDocument);
|
||||
document.removeEventListener('mouseup', handleMouseUpOnDocument);
|
||||
});
|
||||
|
||||
function handleNodeMouseEnter(job: JobNode) {
|
||||
hoveredJobId.value = job.id;
|
||||
}
|
||||
|
||||
function handleNodeMouseLeave() {
|
||||
hoveredJobId.value = null;
|
||||
}
|
||||
|
||||
function isEdgeHighlighted(edge: RoutedEdge): boolean {
|
||||
if (!hoveredJobId.value) {
|
||||
return false;
|
||||
}
|
||||
return edge.fromId === hoveredJobId.value || edge.toId === hoveredJobId.value;
|
||||
}
|
||||
|
||||
const nodesWithIncomingEdge = computed(() => {
|
||||
const set = new Set<number>();
|
||||
for (const edge of routedEdges.value) set.add(edge.toId);
|
||||
return set;
|
||||
});
|
||||
|
||||
const nodesWithOutgoingEdge = computed(() => {
|
||||
const set = new Set<number>();
|
||||
for (const edge of routedEdges.value) set.add(edge.fromId);
|
||||
return set;
|
||||
});
|
||||
|
||||
|
||||
function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||||
const jobMap = new Map<string, ActionsJob>()
|
||||
jobs.forEach(job => {
|
||||
jobMap.set(job.name, job);
|
||||
if (job.jobId) jobMap.set(job.jobId, job);
|
||||
});
|
||||
|
||||
const levels = new Map<string, number>();
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const MAX_DEPTH = 100;
|
||||
|
||||
function dfs(jobNameOrId: string, depth: number = 0): number {
|
||||
if (depth > MAX_DEPTH) {
|
||||
console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${jobNameOrId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (recursionStack.has(jobNameOrId)) {
|
||||
console.error(`Cycle detected involving: ${jobNameOrId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (visited.has(jobNameOrId)) {
|
||||
return levels.get(jobNameOrId) || 0;
|
||||
}
|
||||
|
||||
recursionStack.add(jobNameOrId);
|
||||
visited.add(jobNameOrId);
|
||||
|
||||
const job = jobMap.get(jobNameOrId);
|
||||
if (!job) {
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!job.needs?.length) {
|
||||
levels.set(job.jobId, 0);
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxLevel = -1;
|
||||
for (const need of job.needs) {
|
||||
const needJob = jobMap.get(need);
|
||||
if (!needJob) continue;
|
||||
|
||||
const needLevel = dfs(need, 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);
|
||||
}
|
||||
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return level;
|
||||
}
|
||||
|
||||
jobs.forEach(job => {
|
||||
if (!visited.has(job.name) && !visited.has(job.jobId)) {
|
||||
dfs(job.name);
|
||||
}
|
||||
})
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
const link = `${props.runLink}/jobs/${job.id}`;
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
window.open(link, '_blank');
|
||||
return;
|
||||
}
|
||||
window.location.href = link;
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<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'"
|
||||
>
|
||||
<SvgIcon name="octicon-zoom-in" :size="12"/>
|
||||
</button>
|
||||
<button type="button" @click="resetView" class="ui compact tiny icon button" title="Reset view">
|
||||
<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)">
|
||||
<SvgIcon name="octicon-zoom-out" :size="12"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="graph-container"
|
||||
ref="graphContainer"
|
||||
@mousedown="handleMouseDown"
|
||||
@wheel="handleWheel"
|
||||
:class="{dragging: isDragging}"
|
||||
>
|
||||
<svg
|
||||
:width="graphWidth"
|
||||
:height="graphHeight"
|
||||
class="graph-svg"
|
||||
:style="{
|
||||
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}"
|
||||
>
|
||||
<path
|
||||
v-for="edge in routedEdges"
|
||||
:key="edge.key"
|
||||
:d="edge.path"
|
||||
fill="none"
|
||||
stroke="var(--color-secondary-alpha-50)"
|
||||
stroke-width="1.75"
|
||||
:class="['node-edge', { 'highlighted-edge': isEdgeHighlighted(edge) }]"
|
||||
/>
|
||||
|
||||
<g
|
||||
v-for="job in jobsWithLayout"
|
||||
:key="job.id"
|
||||
class="job-node-group"
|
||||
@click="onNodeClick(job, $event)"
|
||||
@mouseenter="handleNodeMouseEnter(job)"
|
||||
@mouseleave="handleNodeMouseLeave"
|
||||
>
|
||||
<title>{{ job.name }}</title>
|
||||
|
||||
<rect
|
||||
:x="job.x"
|
||||
:y="job.y"
|
||||
:width="nodeWidth"
|
||||
:height="nodeHeight"
|
||||
rx="10"
|
||||
fill="var(--color-button)"
|
||||
stroke="var(--color-light-border)"
|
||||
stroke-width="1.25"
|
||||
class="job-rect"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-if="nodesWithIncomingEdge.has(job.id)"
|
||||
:cx="job.x"
|
||||
:cy="job.y + nodeHeight / 2"
|
||||
r="6"
|
||||
class="node-port"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-if="nodesWithOutgoingEdge.has(job.id)"
|
||||
:cx="job.x + nodeWidth"
|
||||
:cy="job.y + nodeHeight / 2"
|
||||
r="6"
|
||||
class="node-port"
|
||||
/>
|
||||
|
||||
<foreignObject
|
||||
:x="job.x + 10"
|
||||
:y="job.y + 14"
|
||||
width="20"
|
||||
height="20"
|
||||
class="job-status-fg-obj"
|
||||
>
|
||||
<div class="job-status-icon-wrap">
|
||||
<ActionRunStatus :status="job.status"/>
|
||||
</div>
|
||||
</foreignObject>
|
||||
|
||||
<foreignObject
|
||||
:x="job.x + 36"
|
||||
:y="job.y"
|
||||
:width="nodeWidth - 40"
|
||||
:height="nodeHeight"
|
||||
>
|
||||
<div class="job-text-wrap">
|
||||
<span class="job-name">{{ job.name }}</span>
|
||||
<span
|
||||
v-if="job.duration || job.status === 'success' || job.status === 'failure'"
|
||||
class="job-duration"
|
||||
>{{ job.duration }}</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-graph {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.graph-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
background: var(--color-box-header);
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
gap: var(--gap-block);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.graph-title {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
column-gap: 8px;
|
||||
color: var(--color-text-light-1);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.graph-metrics {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 12px 16px 20px;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
background: var(--color-box-body);
|
||||
}
|
||||
|
||||
.graph-container.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-svg {
|
||||
display: block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.graph-svg path {
|
||||
transition: all 0.2s ease;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.highlighted-edge {
|
||||
stroke-width: 2.25 !important;
|
||||
stroke: var(--color-workflow-edge-hover) !important;
|
||||
}
|
||||
|
||||
.job-node-group {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.job-node-group:hover .job-rect {
|
||||
/* due to SVG rendering limitation, only one of fill and drop-shadow can work */
|
||||
fill: var(--color-hover);
|
||||
/* filter: drop-shadow(0 1px 3px var(--color-shadow-opaque)); */
|
||||
}
|
||||
|
||||
.job-text-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.job-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.job-duration {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light-2);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.job-status-fg-obj,
|
||||
.job-status-icon-wrap {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.job-status-icon-wrap {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.node-port {
|
||||
fill: var(--color-box-body);
|
||||
stroke: var(--color-light-border);
|
||||
stroke-width: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node-edge {
|
||||
transition: stroke-width 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user