This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
|
||||
import {normalizeTestHtml} from '../utils/testhelper.ts';
|
||||
|
||||
describe('buildArtifactTooltipHtml', () => {
|
||||
test('active artifact', () => {
|
||||
const expiresUnix = Date.UTC(2026, 2, 20, 12, 0, 0) / 1000;
|
||||
const expiresLocal = new Date(expiresUnix * 1000).toLocaleString();
|
||||
const result = buildArtifactTooltipHtml({
|
||||
name: 'artifact.zip',
|
||||
size: 1024 * 1024,
|
||||
status: 'completed',
|
||||
expiresUnix,
|
||||
}, 'Expires at %s (extra)');
|
||||
|
||||
expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(`<span class="flex-text-inline">
|
||||
<span>Expires at </span>
|
||||
<relative-time datetime="${expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
|
||||
${expiresLocal}
|
||||
</relative-time>
|
||||
<span> (extra)</span>
|
||||
<span class="inline-divider">,</span>
|
||||
<span>1.0 MiB</span>
|
||||
</span>
|
||||
`));
|
||||
});
|
||||
|
||||
test('no expiry', () => {
|
||||
const result = buildArtifactTooltipHtml({
|
||||
name: 'artifact.zip',
|
||||
size: 512,
|
||||
status: 'completed',
|
||||
expiresUnix: 0,
|
||||
}, 'Expires at %s');
|
||||
expect(normalizeTestHtml(result)).toBe(`<span class="flex-text-inline">512 B</span>`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {html} from '../utils/html.ts';
|
||||
import {formatBytes} from '../utils.ts';
|
||||
import type {ActionsArtifact} from '../modules/gitea-actions.ts';
|
||||
|
||||
export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLocale: string): string {
|
||||
const sizeText = formatBytes(artifact.size);
|
||||
if (artifact.expiresUnix <= 0) {
|
||||
return html`<span class="flex-text-inline">${sizeText}</span>`; // use the same layout as below
|
||||
}
|
||||
const datetimeLocal = new Date(artifact.expiresUnix * 1000).toLocaleString();
|
||||
// split so the <relative-time> element can be interleaved, e.g. "Expires at %s" -> ["Expires at ", ""]
|
||||
const [prefix, suffix = ''] = expiresAtLocale.split('%s');
|
||||
return html`
|
||||
<span class="flex-text-inline">
|
||||
<span>${prefix}</span>
|
||||
<relative-time datetime="${artifact.expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
|
||||
${datetimeLocal}
|
||||
</relative-time>
|
||||
<span>${suffix}</span>
|
||||
<span class="inline-divider">,</span>
|
||||
<span>${sizeText}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||
import {formatDatetime} from '../utils/time.ts';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import {addDelegatedEventListener, createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import type {ActionsArtifact, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
import {
|
||||
type ActionRunViewStore,
|
||||
createLogLineMessage,
|
||||
type LogLine,
|
||||
type LogLineCommand,
|
||||
parseLogLineCommand
|
||||
parseLogLineCommand,
|
||||
} from './ActionRunView.ts';
|
||||
|
||||
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
||||
@@ -26,7 +27,7 @@ function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPor
|
||||
type Step = {
|
||||
summary: string,
|
||||
duration: string,
|
||||
status: ActionsRunStatus,
|
||||
status: ActionsStatus,
|
||||
}
|
||||
|
||||
type JobStepState = {
|
||||
@@ -77,9 +78,8 @@ defineOptions({
|
||||
|
||||
const props = defineProps<{
|
||||
store: ActionRunViewStore,
|
||||
runId: number;
|
||||
jobId: number;
|
||||
actionsUrl: string;
|
||||
actionsViewUrl: string;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
const store = props.store;
|
||||
@@ -116,6 +116,12 @@ const currentJob = ref<CurrentJob>({
|
||||
const stepsContainer = ref<HTMLElement | null>(null);
|
||||
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
||||
|
||||
// Reusable workflow caller view: the right pane shows just the header (name + uses path +
|
||||
// status). Callers don't run on a runner, and the dependency graph for their children lives
|
||||
// in the run summary's WorkflowGraph, not here — matching GitHub Actions.
|
||||
const selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
|
||||
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
|
||||
|
||||
watch(optionAlwaysAutoScroll, () => {
|
||||
saveLocaleStorageOptions();
|
||||
});
|
||||
@@ -202,6 +208,22 @@ function endLogGroup(stepIndex: number) {
|
||||
el._stepLogsActiveContainer = undefined;
|
||||
}
|
||||
|
||||
async function copyStepOutput(event: MouseEvent, stepIndex: number) {
|
||||
await copyToClipboardWithFeedback(event.currentTarget as HTMLElement, async () => {
|
||||
const data = await fetchJobData([{step: stepIndex, cursor: null, expanded: true}]);
|
||||
const stepLog = data.logs.stepsLog?.find((s) => s.step === stepIndex);
|
||||
const lines: string[] = [];
|
||||
for (const line of stepLog?.lines ?? []) {
|
||||
const cmd = parseLogLineCommand(line);
|
||||
if (cmd?.name === 'hidden' || cmd?.name === 'endgroup') continue;
|
||||
const ts = formatDatetimeISO(line.timestamp);
|
||||
const msg = createLogLineMessage(line, cmd).textContent ?? '';
|
||||
lines.push(`${ts} ${msg}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
});
|
||||
}
|
||||
|
||||
// show/hide the step logs for a step
|
||||
function toggleStepLogs(idx: number) {
|
||||
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
|
||||
@@ -217,7 +239,7 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
|
||||
String(line.index),
|
||||
);
|
||||
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
||||
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
|
||||
formatDatetime(line.timestamp * 1000), // for "Show timestamps"
|
||||
);
|
||||
const logMsg = createLogLineMessage(line, cmd);
|
||||
const seconds = Math.floor(line.timestamp - startTime);
|
||||
@@ -225,9 +247,6 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
|
||||
`${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,
|
||||
@@ -262,18 +281,14 @@ function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
});
|
||||
// "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.
|
||||
// Frontend knows nothing about its type, never uses its value.
|
||||
// For example: backend can make cursor=null means the first time to fetch logs, cursor=1234 for a position, cursor=eof for no more logs, etc.
|
||||
type LogCursor = {step: number, cursor: any, expanded: boolean};
|
||||
|
||||
async function fetchJobData(logCursors: LogCursor[], signal?: AbortSignal): Promise<JobData> {
|
||||
const resp = await POST(props.actionsViewUrl, {signal, data: {logCursors}});
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
@@ -288,7 +303,8 @@ async function loadJob() {
|
||||
const abortController = new AbortController();
|
||||
loadingAbortController = abortController;
|
||||
try {
|
||||
const runJobResp = await fetchJobData(abortController);
|
||||
const logCursors = currentJobStepsStates.value.map((it, idx) => ({step: idx, cursor: it.cursor, expanded: it.expanded}));
|
||||
const runJobResp = await fetchJobData(logCursors, abortController.signal);
|
||||
if (loadingAbortController !== abortController) return;
|
||||
|
||||
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
|
||||
@@ -354,11 +370,11 @@ async function loadJob() {
|
||||
}
|
||||
}
|
||||
|
||||
function isDone(status: ActionsRunStatus) {
|
||||
function isDone(status: ActionsStatus) {
|
||||
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
||||
}
|
||||
|
||||
function isExpandable(status: ActionsRunStatus) {
|
||||
function isExpandable(status: ActionsStatus) {
|
||||
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
||||
}
|
||||
|
||||
@@ -372,9 +388,6 @@ function elStepsContainer(): 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();
|
||||
}
|
||||
|
||||
@@ -404,16 +417,22 @@ async function hashChangeListener() {
|
||||
<template>
|
||||
<div class="job-info-header">
|
||||
<div class="job-info-header-left gt-ellipsis">
|
||||
<h3 class="job-info-header-title gt-ellipsis">
|
||||
{{ currentJob.title }}
|
||||
</h3>
|
||||
<div class="job-info-header-title-row">
|
||||
<h3 class="job-info-header-title gt-ellipsis">
|
||||
{{ isCallerJob ? selectedJob?.name : currentJob.title }}
|
||||
</h3>
|
||||
<span v-if="isCallerJob && selectedJob?.callUses" class="ui label job-info-header-uses">
|
||||
<span>uses:</span>
|
||||
<span class="gt-ellipsis">{{ selectedJob.callUses }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="job-info-header-detail">
|
||||
{{ currentJob.detail }}
|
||||
{{ isCallerJob && selectedJob ? locale.status[selectedJob.status] : currentJob.detail }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="job-info-header-right">
|
||||
<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">
|
||||
<button class="btn interact-bg tw-p-2">
|
||||
<SvgIcon name="octicon-gear" :size="18"/>
|
||||
</button>
|
||||
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
|
||||
@@ -448,7 +467,15 @@ async function hashChangeListener() {
|
||||
</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-container"
|
||||
ref="stepsContainer"
|
||||
v-show="!isCallerJob && currentJob.steps.length"
|
||||
:class="{
|
||||
'log-line-show-timestamps': timeVisible['log-time-stamp'],
|
||||
'log-line-show-seconds': timeVisible['log-time-seconds']
|
||||
}"
|
||||
>
|
||||
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||
<div
|
||||
class="job-step-summary"
|
||||
@@ -461,15 +488,25 @@ async function hashChangeListener() {
|
||||
<SvgIcon
|
||||
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
|
||||
name="gitea-running"
|
||||
class="tw-mr-2 rotate-clockwise"
|
||||
class="rotate-clockwise"
|
||||
/>
|
||||
<SvgIcon
|
||||
v-else
|
||||
:name="currentJobStepsStates[stepIdx].expanded ? 'octicon-chevron-down' : 'octicon-chevron-right'"
|
||||
:class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"
|
||||
name="octicon-chevron-right"
|
||||
class="tw-mr-2 step-summary-chevron"
|
||||
:class="{'tw-invisible': !isExpandable(jobStep.status)}"
|
||||
/>
|
||||
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
|
||||
<ActionStatusIcon :status="jobStep.status" icon-variant="circle-fill"/>
|
||||
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
||||
<button
|
||||
v-if="isExpandable(jobStep.status)"
|
||||
class="btn interact-fg step-copy-btn"
|
||||
:aria-label="locale.copyOutput"
|
||||
:data-tooltip-content="locale.copyOutput"
|
||||
@click.stop="copyStepOutput($event, stepIdx)"
|
||||
>
|
||||
<SvgIcon name="octicon-copy" :size="14"/>
|
||||
</button>
|
||||
<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,
|
||||
@@ -541,6 +578,21 @@ async function hashChangeListener() {
|
||||
|
||||
.job-info-header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-info-header-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-info-header-uses {
|
||||
display: inline-flex !important;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-step-container {
|
||||
@@ -554,6 +606,7 @@ async function hashChangeListener() {
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
@@ -566,12 +619,32 @@ async function hashChangeListener() {
|
||||
background: var(--color-console-hover-bg);
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary .step-summary-chevron {
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.selected .step-summary-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.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 .step-copy-btn {
|
||||
visibility: hidden;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary:hover .step-copy-btn,
|
||||
.job-step-container .job-step-summary.selected .step-copy-btn {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.job-step-container .job-step-summary:focus-within .step-copy-btn {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary.selected {
|
||||
@@ -610,8 +683,22 @@ async function hashChangeListener() {
|
||||
scroll-margin-top: 95px;
|
||||
}
|
||||
|
||||
.job-log-line .log-time-stamp,
|
||||
.job-log-line .log-time-seconds {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-line-show-timestamps .job-log-line .log-time-stamp {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.log-line-show-seconds .job-log-line .log-time-seconds {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
||||
.job-log-line .line-num, .log-time-seconds {
|
||||
.job-log-line .line-num,
|
||||
.job-log-line .log-time-seconds {
|
||||
width: 48px;
|
||||
color: var(--color-text-light-3);
|
||||
text-align: right;
|
||||
@@ -628,16 +715,16 @@ async function hashChangeListener() {
|
||||
}
|
||||
|
||||
.job-log-line .log-time,
|
||||
.log-time-stamp {
|
||||
.job-log-line .log-time-stamp {
|
||||
color: var(--color-text-light-3);
|
||||
margin-left: 10px;
|
||||
margin-left: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-step-logs .job-log-line .log-msg {
|
||||
flex: 1;
|
||||
white-space: break-spaces;
|
||||
margin-left: 10px;
|
||||
white-space: break-spaces; /* decoded commands like "::error::foo%0Abar" contain "\n" */
|
||||
margin-left: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -704,18 +791,28 @@ async function hashChangeListener() {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.job-log-group .job-log-list .job-log-line .log-msg {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.job-log-group-summary {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
list-style: none; /* hide the standard disclosure marker (Chrome, Edge, Firefox) */
|
||||
}
|
||||
|
||||
.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;
|
||||
.job-log-group-summary::-webkit-details-marker { /* hide the disclosure marker on Safari */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-line-group .log-msg::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-top: -2.5px;
|
||||
margin-right: 8px;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 6px solid var(--color-text-light-3);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.job-log-group[open] .log-line-group .log-msg::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<!-- This vue should be kept the same as templates/repo/actions/status.tmpl
|
||||
Please also update the template file above if this vue is modified.
|
||||
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
|
||||
-->
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'unknown',
|
||||
size?: number,
|
||||
className?: string,
|
||||
localeStatus?: string,
|
||||
}>(), {
|
||||
size: 16,
|
||||
className: '',
|
||||
localeStatus: undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
|
||||
<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>
|
||||
@@ -1,5 +1,4 @@
|
||||
<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";
|
||||
@@ -11,15 +10,42 @@ defineOptions({
|
||||
const props = defineProps<{
|
||||
store: ActionRunViewStore;
|
||||
locale: Record<string, any>;
|
||||
artifactCount: number;
|
||||
}>();
|
||||
|
||||
const locale = props.locale;
|
||||
const {currentRun: run} = toRefs(props.store.viewData);
|
||||
|
||||
const runTriggeredAtIso = computed(() => {
|
||||
const t = props.store.viewData.currentRun.triggeredAt;
|
||||
return t ? new Date(t * 1000).toISOString() : '';
|
||||
const isRerun = computed(() => run.value.runAttempt > 1);
|
||||
|
||||
// The summary's dependency graph is the workflow's top-level shape: a reusable caller
|
||||
// renders as a single node, its expanded children belong to the caller's own detail page.
|
||||
const topLevelJobs = computed(() => (run.value.jobs || []).filter((j) => !j.parentJobID));
|
||||
|
||||
const triggerUser = computed(() => {
|
||||
const currentAttempt = run.value.attempts.find((attempt) => attempt.current);
|
||||
if (currentAttempt) {
|
||||
return {
|
||||
name: currentAttempt.triggerUserName,
|
||||
link: currentAttempt.triggerUserLink,
|
||||
avatar: currentAttempt.triggerUserAvatar,
|
||||
};
|
||||
}
|
||||
const pusher = run.value.commit.pusher;
|
||||
return pusher.displayName ? {
|
||||
name: pusher.displayName,
|
||||
link: pusher.link,
|
||||
avatar: pusher.avatarLink,
|
||||
} : null;
|
||||
});
|
||||
|
||||
const triggerLabel = computed(() => {
|
||||
if (isRerun.value) return locale.rerunTriggered;
|
||||
return locale.triggeredVia.replace('%s', run.value.triggerEvent);
|
||||
});
|
||||
|
||||
const artifactsDisplay = computed(() => props.artifactCount > 0 ? String(props.artifactCount) : '–');
|
||||
|
||||
onMounted(async () => {
|
||||
await props.store.startPollingCurrentRun();
|
||||
});
|
||||
@@ -31,20 +57,71 @@ onBeforeUnmount(() => {
|
||||
<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 class="action-run-summary-trigger">
|
||||
<span class="action-run-summary-label">
|
||||
{{ triggerLabel }} <relative-time :datetime="run.triggeredAt || ''" prefix=""/>
|
||||
</span>
|
||||
<div class="flex-text-block tw-flex-wrap action-run-summary-trigger-content">
|
||||
<component
|
||||
:is="triggerUser.link ? 'a' : 'span'"
|
||||
v-if="triggerUser"
|
||||
class="flex-text-inline action-run-summary-user"
|
||||
:class="{silenced: triggerUser.link}"
|
||||
:href="triggerUser.link || undefined"
|
||||
>
|
||||
<img
|
||||
v-if="triggerUser.avatar"
|
||||
class="ui avatar tw-align-middle"
|
||||
:src="triggerUser.avatar"
|
||||
width="16"
|
||||
height="16"
|
||||
:alt="triggerUser.name"
|
||||
>
|
||||
<span>{{ triggerUser.name }}</span>
|
||||
</component>
|
||||
<a v-if="run.pullRequest" class="action-run-summary-pr silenced" :href="run.pullRequest.link">{{ run.pullRequest.index }}</a>
|
||||
<span v-else-if="run.commit.branch.name" class="action-run-summary-branch-label tw-max-w-full">
|
||||
<a
|
||||
v-if="!run.commit.branch.isDeleted && run.commit.branch.link"
|
||||
class="gt-ellipsis silenced"
|
||||
:href="run.commit.branch.link"
|
||||
:title="run.commit.branch.name"
|
||||
>{{ run.commit.branch.name }}</a>
|
||||
<span
|
||||
v-else
|
||||
class="gt-ellipsis tw-line-through"
|
||||
:title="run.commit.branch.name"
|
||||
>{{ run.commit.branch.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</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 class="action-run-summary-stat-divider"/>
|
||||
|
||||
<div class="action-run-summary-stat">
|
||||
<span class="action-run-summary-label">{{ locale.statusLabel }}</span>
|
||||
<span class="action-run-summary-stat-value">{{ locale.status[run.status] }}</span>
|
||||
</div>
|
||||
|
||||
<div class="action-run-summary-stat">
|
||||
<span class="action-run-summary-label">{{ locale.totalDuration }}</span>
|
||||
<span class="action-run-summary-stat-value">{{ run.duration || '–' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="action-run-summary-stat action-run-summary-stat-last">
|
||||
<span class="action-run-summary-label">{{ locale.artifactsTitle }}</span>
|
||||
<span class="action-run-summary-stat-value">{{ artifactsDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowGraph
|
||||
v-if="run.jobs.length > 0"
|
||||
v-if="topLevelJobs.length > 0"
|
||||
:store="store"
|
||||
:jobs="run.jobs"
|
||||
:jobs="topLevelJobs"
|
||||
:run-link="run.link"
|
||||
:workflow-id="run.workflowID"
|
||||
:workflow-link="run.canViewWorkflowFile ? `${run.link}/workflow` : ''"
|
||||
:trigger-event="run.triggerEvent"
|
||||
:locale="locale"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -58,13 +135,119 @@ onBeforeUnmount(() => {
|
||||
|
||||
.action-run-summary-block {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
align-items: stretch; /* equal-height columns so labels align at top and values at bottom */
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
background: var(--color-box-header);
|
||||
background: var(--color-console-bg);
|
||||
}
|
||||
|
||||
.action-run-summary-trigger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.action-run-summary-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.action-run-summary-trigger-content {
|
||||
margin-top: auto; /* pin trigger content to the bottom, aligned with the stat values */
|
||||
color: var(--color-text-light-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-run-summary-user {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.action-run-summary-user .ui.avatar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-run-summary-pr {
|
||||
color: var(--color-text);
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.action-run-summary-branch-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 200px;
|
||||
min-height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-primary-light-6);
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-family: var(--fonts-monospace);
|
||||
}
|
||||
|
||||
.action-run-summary-branch-label a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.action-run-summary-branch-label a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-run-summary-user:hover span {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.action-run-summary-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
min-width: 72px;
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.action-run-summary-stat-last {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.action-run-summary-stat-divider {
|
||||
display: none;
|
||||
flex: 0 0 100%;
|
||||
margin: 8px 0;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.action-run-summary-stat-value {
|
||||
display: block;
|
||||
margin-top: auto; /* pin value to the bottom so all column values share a baseline */
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.action-run-summary-trigger {
|
||||
flex: 0 0 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.action-run-summary-stat {
|
||||
margin-left: 0;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.action-run-summary-stat-divider {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,8 +14,16 @@ test('LogLineMessage', () => {
|
||||
'##[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>',
|
||||
'::error::foo%0Abar': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar</span></span>',
|
||||
'::error::foo%0D%0Abar': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar</span></span>',
|
||||
'::error::100%25 done%250A': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> 100% done%0A</span></span>',
|
||||
'::error::keep%5Dsemi%3B': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> keep%5Dsemi%3B</span></span>',
|
||||
'::group::foo%0Abar': '<span class="log-msg log-cmd-group">foo\nbar</span>',
|
||||
'##[error]foo%0Abar%3B%5D': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar;]</span></span>',
|
||||
'##[command]foo%0Abar': '<span class="log-msg log-cmd-command">foo%0Abar</span>',
|
||||
'::notice::foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
|
||||
'::debug::foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
|
||||
'##[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
|
||||
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
|
||||
|
||||
// hidden is special, it is actually skipped before creating
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {renderAnsi} from '../render/ansi.ts';
|
||||
import {renderAnsiInto} from '../render/ansi.ts';
|
||||
import {reactive} from 'vue';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
|
||||
@@ -9,7 +9,8 @@ import {POST} from '../modules/fetch.ts';
|
||||
// * 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.
|
||||
// HOWEVER: Gitea cannot, a decoded message may contain newlines and FormatLog drops them,
|
||||
// so the commands arrive here still escaped and the frontend decodes them.
|
||||
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
||||
'::group::': 'group',
|
||||
'##[group]': 'group',
|
||||
@@ -20,6 +21,7 @@ const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
|
||||
'##[warning]': 'warning',
|
||||
'##[notice]': 'notice',
|
||||
'##[debug]': 'debug',
|
||||
'##[command]': 'command',
|
||||
'[command]': 'command',
|
||||
|
||||
// https://github.com/actions/toolkit/blob/master/docs/commands.md
|
||||
@@ -46,9 +48,9 @@ export type LogLineCommand = {
|
||||
|
||||
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)) {
|
||||
for (const [prefix, commandName] of Object.entries(LogLinePrefixCommandMap)) {
|
||||
if (line.message.startsWith(prefix)) {
|
||||
return {name: LogLinePrefixCommandMap[prefix], prefix};
|
||||
return {name: commandName, prefix};
|
||||
}
|
||||
}
|
||||
// Handle ::cmd:: and ::cmd args:: format (runner may pass these through raw)
|
||||
@@ -66,34 +68,61 @@ const LogLineLabelMap: Partial<Record<LogLineCommandName, string>> = {
|
||||
'debug': 'Debug',
|
||||
};
|
||||
|
||||
function decodeLineMessage(line: LogLine, cmd: LogLineCommand | null): string {
|
||||
// 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)
|
||||
if (!cmd) return line.message;
|
||||
let msg = line.message.substring(cmd.prefix.length);
|
||||
if (cmd.name === 'command') return msg; // "command" is only an output tag, do not parse or escape it
|
||||
// "##[cmd]" also escapes ";" and "]" which delimit its header, "::cmd::" does not
|
||||
if (!cmd.prefix.startsWith('::')) msg = msg.replace(/%3B/g, ';').replace(/%5D/g, ']');
|
||||
// renderAnsiInto breaks a line per "\r", so "%0D%0A" is one break. "%25" last keeps "%250A" literal
|
||||
return msg.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%');
|
||||
}
|
||||
|
||||
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 msgContent = decodeLineMessage(line, cmd);
|
||||
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())}`;
|
||||
renderAnsiInto(msgSpan, ` ${msgContent.trimStart()}`);
|
||||
logMsg.append(msgSpan);
|
||||
} else {
|
||||
logMsg.innerHTML = renderAnsi(msgContent);
|
||||
renderAnsiInto(logMsg, msgContent);
|
||||
}
|
||||
return logMsg;
|
||||
}
|
||||
|
||||
// buildJobsByParentJobID groups jobs by their parentJobID (0 = top level).
|
||||
// Useful for rendering the reusable-workflow caller/child tree in the sidebar.
|
||||
export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJob[]> {
|
||||
const childrenByParent = new Map<number, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
const parentID = job.parentJobID || 0;
|
||||
const existing = childrenByParent.get(parentID);
|
||||
if (existing) {
|
||||
existing.push(job);
|
||||
} else {
|
||||
childrenByParent.set(parentID, [job]);
|
||||
}
|
||||
}
|
||||
return childrenByParent;
|
||||
}
|
||||
|
||||
export function createEmptyActionsRun(): ActionsRun {
|
||||
return {
|
||||
repoId: 0,
|
||||
index: 0,
|
||||
link: '',
|
||||
viewLink: '',
|
||||
title: '',
|
||||
titleHTML: '',
|
||||
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
||||
status: '' as ActionsStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
||||
canCancel: false,
|
||||
canApprove: false,
|
||||
canRerun: false,
|
||||
@@ -102,11 +131,16 @@ export function createEmptyActionsRun(): ActionsRun {
|
||||
done: false,
|
||||
workflowID: '',
|
||||
workflowLink: '',
|
||||
canViewWorkflowFile: true,
|
||||
isSchedule: false,
|
||||
runAttempt: 0,
|
||||
attempts: [],
|
||||
duration: '',
|
||||
triggeredAt: 0,
|
||||
triggerEvent: '',
|
||||
pullRequest: null,
|
||||
jobs: [] as Array<ActionsJob>,
|
||||
jobSummaries: [],
|
||||
commit: {
|
||||
localeCommit: '',
|
||||
localePushedBy: '',
|
||||
@@ -115,6 +149,7 @@ export function createEmptyActionsRun(): ActionsRun {
|
||||
pusher: {
|
||||
displayName: '',
|
||||
link: '',
|
||||
avatarLink: '',
|
||||
},
|
||||
branch: {
|
||||
name: '',
|
||||
@@ -125,7 +160,7 @@ export function createEmptyActionsRun(): ActionsRun {
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
||||
export function createActionRunViewStore(viewUrl: string) {
|
||||
let loadingAbortController: AbortController | null = null;
|
||||
let intervalID: IntervalId | null = null;
|
||||
const viewData = reactive({
|
||||
@@ -137,8 +172,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
||||
const abortController = new AbortController();
|
||||
loadingAbortController = abortController;
|
||||
try {
|
||||
const url = `${actionsUrl}/runs/${runId}`;
|
||||
const resp = await POST(url, {signal: abortController.signal, data: {}});
|
||||
const resp = await POST(viewUrl, {signal: abortController.signal, data: {}});
|
||||
const runResp = await resp.json();
|
||||
if (loadingAbortController !== abortController) return;
|
||||
|
||||
@@ -158,7 +192,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
||||
}
|
||||
};
|
||||
|
||||
return reactive({
|
||||
return {
|
||||
viewData,
|
||||
|
||||
async startPollingCurrentRun() {
|
||||
@@ -175,7 +209,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- Keep in sync with templates/repo/icons/action_status.tmpl.
|
||||
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, cancelling, unknown.
|
||||
-->
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {getActionStatusIcon, type ActionStatusIconVariant} from '../modules/action-status-icon.ts';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'cancelling' | 'unknown',
|
||||
size?: number,
|
||||
className?: string,
|
||||
localeStatus?: string,
|
||||
iconVariant?: ActionStatusIconVariant,
|
||||
}>(), {
|
||||
size: 16,
|
||||
className: '',
|
||||
localeStatus: undefined,
|
||||
iconVariant: '',
|
||||
});
|
||||
|
||||
const icon = computed(() => getActionStatusIcon(props.status, props.iconVariant));
|
||||
const iconClass = computed(() => {
|
||||
const classes = [icon.value.colorClass, props.className];
|
||||
if (props.status === 'running') classes.push('rotate-clockwise');
|
||||
return classes.filter(Boolean).join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SvgIcon v-if="status" :name="icon.name" :class="iconClass" :size="size" :data-tooltip-content="localeStatus ?? status"/>
|
||||
</template>
|
||||
@@ -1,21 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
|
||||
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
|
||||
import {onMounted, shallowRef} from 'vue';
|
||||
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
|
||||
import {computed, onBeforeUnmount, onMounted} from 'vue';
|
||||
import tippy, {createSingleton} from 'tippy.js';
|
||||
import type {CreateSingletonInstance, Instance} from 'tippy.js';
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
|
||||
defineProps<{
|
||||
type HeatmapValue = {date: Date; count: number};
|
||||
type HeatmapCell = {date: Date; colorIndex: number; ariaLabel: string; tooltip: string};
|
||||
type MonthLabel = {monthIdx: number; weekIdx: number};
|
||||
|
||||
const props = defineProps<{
|
||||
values: HeatmapValue[];
|
||||
locale: {
|
||||
textTotalContributions: string;
|
||||
heatMapLocale: Partial<HeatmapLocale>;
|
||||
heatMapLocale: {months: string[]; days: string[]; on: string; more: string; less: string};
|
||||
noDataText: string;
|
||||
tooltipUnit: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const colorRange = [
|
||||
'var(--color-secondary-alpha-60)',
|
||||
'var(--color-secondary-alpha-60)',
|
||||
'var(--color-primary-light-4)',
|
||||
'var(--color-primary-light-2)',
|
||||
@@ -24,21 +27,113 @@ const colorRange = [
|
||||
'var(--color-primary-dark-4)',
|
||||
];
|
||||
|
||||
const endDate = shallowRef(new Date());
|
||||
const squareSize = 10;
|
||||
const squareBorder = 2;
|
||||
const cellSize = squareSize + squareBorder;
|
||||
const daysInWeek = 7;
|
||||
const trailingDays = 365;
|
||||
const gridLeft = Math.ceil(squareSize * 2.5);
|
||||
const gridTop = squareSize + squareSize / 2;
|
||||
|
||||
onMounted(() => {
|
||||
// work around issue with first legend color being rendered twice and legend cut off
|
||||
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper')!;
|
||||
legend.setAttribute('viewBox', '12 0 80 10');
|
||||
legend.style.marginRight = '-12px';
|
||||
const now = new Date();
|
||||
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}${String(d.getMonth()).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function shiftDate(d: Date, days: number): Date {
|
||||
const out = new Date(d);
|
||||
out.setDate(out.getDate() + days);
|
||||
return out;
|
||||
}
|
||||
|
||||
const grid = computed(() => {
|
||||
const start = shiftDate(now, -trailingDays);
|
||||
const padStart = start.getDay();
|
||||
const padEnd = daysInWeek - 1 - now.getDay();
|
||||
const weekCount = (trailingDays + 1 + padStart + padEnd) / daysInWeek;
|
||||
|
||||
const maxCount = props.values.length ? Math.max(...props.values.map((v) => v.count)) : 0;
|
||||
const max = maxCount > 0 ? Math.ceil(maxCount / 5 * 4) : 1;
|
||||
|
||||
const activities = new Map<string, {count: number; colorIndex: number}>();
|
||||
for (const {date, count} of props.values) {
|
||||
const colorIndex = count >= max ? 4 : Math.max(1, Math.ceil((count / max) * 3));
|
||||
activities.set(dateKey(date), {count, colorIndex});
|
||||
}
|
||||
|
||||
const {on} = props.locale.heatMapLocale;
|
||||
const {noDataText, tooltipUnit} = props.locale;
|
||||
const currentLocale = getCurrentLocale();
|
||||
|
||||
const cursorStart = shiftDate(start, -padStart);
|
||||
const cursor = new Date(cursorStart.getFullYear(), cursorStart.getMonth(), cursorStart.getDate());
|
||||
const calendar: HeatmapCell[][] = [];
|
||||
for (let w = 0; w < weekCount; w++) {
|
||||
const week: HeatmapCell[] = [];
|
||||
for (let d = 0; d < daysInWeek; d++) {
|
||||
const hit = activities.get(dateKey(cursor));
|
||||
const dateStr = cursor.toLocaleDateString(currentLocale, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||
const head = hit ? `${hit.count} ${tooltipUnit}` : noDataText;
|
||||
week.push({
|
||||
date: new Date(cursor),
|
||||
colorIndex: hit ? hit.colorIndex : 0,
|
||||
ariaLabel: `${head} ${on} ${dateStr}`,
|
||||
tooltip: `<b>${head}</b> ${on} ${dateStr}`,
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
calendar.push(week);
|
||||
}
|
||||
|
||||
const monthLabels: MonthLabel[] = [];
|
||||
for (let w = 1; w < calendar.length; w++) {
|
||||
const prev = calendar[w - 1][0].date;
|
||||
const curr = calendar[w][0].date;
|
||||
if (prev.getMonth() !== curr.getMonth()) {
|
||||
monthLabels.push({monthIdx: curr.getMonth(), weekIdx: w});
|
||||
}
|
||||
}
|
||||
|
||||
const width = gridLeft + (cellSize * weekCount) + squareBorder;
|
||||
const height = gridTop + (cellSize * daysInWeek);
|
||||
return {calendar, monthLabels, width, height};
|
||||
});
|
||||
|
||||
function handleDayClick(e: Event & {date: Date}) {
|
||||
// Reset filter if same date is clicked
|
||||
const legendViewBox = `${cellSize} 0 ${squareSize * (colorRange.length + 2)} ${squareSize}`;
|
||||
|
||||
const cellInstances = new Map<Element, Instance>();
|
||||
let singleton: CreateSingletonInstance | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
singleton = createSingleton([], {
|
||||
overrides: [],
|
||||
moveTransition: 'transform 0.1s ease-out',
|
||||
allowHTML: true,
|
||||
theme: 'tooltip',
|
||||
role: 'tooltip',
|
||||
placement: 'top',
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
singleton?.destroy();
|
||||
for (const instance of cellInstances.values()) instance.destroy();
|
||||
cellInstances.clear();
|
||||
});
|
||||
|
||||
function lazyInitTooltip(e: MouseEvent) {
|
||||
const el = e.target as Element;
|
||||
if (!singleton || cellInstances.has(el) || !el.classList.contains('heatmap-day')) return;
|
||||
cellInstances.set(el, tippy(el, {content: el.getAttribute('data-tooltip')!}));
|
||||
singleton.setInstances([...cellInstances.values()]);
|
||||
}
|
||||
|
||||
function handleDayClick(date: Date) {
|
||||
const params = new URLSearchParams(document.location.search);
|
||||
const queryDate = params.get('date');
|
||||
// Timezone has to be stripped because toISOString() converts to UTC
|
||||
const clickedDate = new Date(e.date.getTime() - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
||||
const clickedDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
||||
|
||||
if (queryDate && queryDate === clickedDate) {
|
||||
params.delete('date');
|
||||
@@ -53,16 +148,63 @@ function handleDayClick(e: Event & {date: Date}) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<calendar-heatmap
|
||||
:locale="locale.heatMapLocale"
|
||||
:no-data-text="locale.noDataText"
|
||||
:tooltip-unit="locale.tooltipUnit"
|
||||
:end-date="endDate"
|
||||
:values="values"
|
||||
:range-color="colorRange"
|
||||
@day-click="handleDayClick($event)"
|
||||
:tippy-props="{theme: 'tooltip'}"
|
||||
>
|
||||
<template #vch__legend-left>{{ locale.textTotalContributions }}</template>
|
||||
</calendar-heatmap>
|
||||
<div>
|
||||
<svg class="heatmap-svg" :viewBox="`0 0 ${grid.width} ${grid.height}`">
|
||||
<g class="heatmap-month-labels" :transform="`translate(${gridLeft}, 0)`">
|
||||
<text
|
||||
v-for="m in grid.monthLabels"
|
||||
:key="m.weekIdx"
|
||||
class="heatmap-month-label"
|
||||
:x="cellSize * m.weekIdx"
|
||||
:y="cellSize - squareBorder"
|
||||
>
|
||||
{{ locale.heatMapLocale.months[m.monthIdx] }}
|
||||
</text>
|
||||
</g>
|
||||
<g class="heatmap-day-labels" :transform="`translate(0, ${gridTop})`">
|
||||
<text class="heatmap-day-label" :x="0" :y="20">{{ locale.heatMapLocale.days[1] }}</text>
|
||||
<text class="heatmap-day-label" :x="0" :y="44">{{ locale.heatMapLocale.days[3] }}</text>
|
||||
<text class="heatmap-day-label" :x="0" :y="69">{{ locale.heatMapLocale.days[5] }}</text>
|
||||
</g>
|
||||
<g class="heatmap-grid" :transform="`translate(${gridLeft}, ${gridTop})`" @mouseover="lazyInitTooltip">
|
||||
<g
|
||||
v-for="(week, w) in grid.calendar"
|
||||
:key="w"
|
||||
class="heatmap-week"
|
||||
:transform="`translate(${w * cellSize}, 0)`"
|
||||
>
|
||||
<template v-for="(day, d) in week" :key="d">
|
||||
<rect
|
||||
v-if="day.date < now"
|
||||
class="heatmap-day"
|
||||
:transform="`translate(0, ${d * cellSize})`"
|
||||
:width="squareSize"
|
||||
:height="squareSize"
|
||||
:style="{fill: colorRange[day.colorIndex]}"
|
||||
:aria-label="day.ariaLabel"
|
||||
:data-tooltip="day.tooltip"
|
||||
@click="handleDayClick(day.date)"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="heatmap-footer">
|
||||
<div>{{ locale.textTotalContributions }}</div>
|
||||
<div class="heatmap-legend">
|
||||
<div>{{ locale.heatMapLocale.less }}</div>
|
||||
<svg class="heatmap-legend-svg" :viewBox="legendViewBox" :height="squareSize">
|
||||
<rect
|
||||
v-for="(color, i) in colorRange"
|
||||
:key="i"
|
||||
:width="squareSize"
|
||||
:height="squareSize"
|
||||
:x="(i + 1) * cellSize"
|
||||
:style="{fill: color}"
|
||||
/>
|
||||
</svg>
|
||||
<div>{{ locale.heatMapLocale.more }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,63 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
import {computed} from 'vue';
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
repoLink: string,
|
||||
loadIssueInfoUrl: string,
|
||||
issue?: Issue | null,
|
||||
renderedLabels?: string,
|
||||
errorMessage?: string,
|
||||
}>();
|
||||
|
||||
const loading = shallowRef(false);
|
||||
const issue = shallowRef<Issue | null>(null);
|
||||
const renderedLabels = shallowRef('');
|
||||
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'});
|
||||
if (!props.issue) return '';
|
||||
return new Date(props.issue.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, ' ');
|
||||
if (!props.issue) return '';
|
||||
const body = props.issue.body.replace(/\n+/g, ' ');
|
||||
return body.length > 85 ? `${body.substring(0, 85)}…` : body;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const resp = await GET(props.loadIssueInfoUrl);
|
||||
if (!resp.ok) {
|
||||
errorMessage.value = resp.status ? resp.statusText : 'Unknown network error';
|
||||
return;
|
||||
}
|
||||
const respJson = await resp.json();
|
||||
issue.value = respJson.convertedIssue;
|
||||
renderedLabels.value = respJson.renderedLabels;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw-p-4">
|
||||
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
|
||||
<div v-else-if="issue" class="tw-flex tw-flex-col tw-gap-2">
|
||||
<div v-if="issue" class="tw-flex tw-flex-col tw-gap-2">
|
||||
<div class="tw-text-12">
|
||||
<a :href="repoLink" class="muted">{{ issue.repository.full_name }}</a>
|
||||
<a :href="issue.repository.html_url" class="muted">{{ issue.repository.full_name }}</a>
|
||||
on {{ createdAt }}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
<svg-icon :name="getIssueIcon(issue)" :class="getIssueColorClass(issue)"/>
|
||||
<span class="issue-title tw-font-semibold tw-break-anywhere">
|
||||
<a :href="issue.html_url" class="issue-title tw-font-semibold tw-break-anywhere muted">
|
||||
{{ issue.title }}
|
||||
<span class="index">#{{ issue.number }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="body">{{ body }}</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
|
||||
@@ -3,9 +3,13 @@ import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
|
||||
const {pageData} = window.config;
|
||||
const props = defineProps<{
|
||||
mergeFormProps: any, // TODO: this is a huge object, need to be refactored in the future
|
||||
}>();
|
||||
|
||||
const mergeForm = pageData.pullRequestMergeForm!;
|
||||
const mergeStyleManuallyMerged = 'manually-merged';
|
||||
|
||||
const mergeForm = props.mergeFormProps;
|
||||
|
||||
const mergeTitleFieldValue = shallowRef('');
|
||||
const mergeMessageFieldValue = shallowRef('');
|
||||
@@ -27,10 +31,18 @@ const showMergeStyleMenu = shallowRef(false);
|
||||
const showActionForm = shallowRef(false);
|
||||
|
||||
const mergeButtonStyleClass = computed(() => {
|
||||
if (mergeStyle.value === mergeStyleManuallyMerged) return 'red';
|
||||
if (mergeForm.allOverridableChecksOk) return 'primary';
|
||||
return autoMergeWhenSucceed.value ? 'primary' : 'red';
|
||||
});
|
||||
|
||||
const mergeSelectStyleClass = computed(() => {
|
||||
if (mergeForm.emptyCommit) return '';
|
||||
if (mergeStyle.value === mergeStyleManuallyMerged) return 'red';
|
||||
if (!mergeForm.allOverridableChecksOk) return 'red';
|
||||
return 'primary';
|
||||
});
|
||||
|
||||
const forceMerge = computed(() => {
|
||||
return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk;
|
||||
});
|
||||
@@ -113,30 +125,32 @@ function clearMergeMessage() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="field" v-if="mergeStyle === 'manually-merged'">
|
||||
<div class="field" v-if="mergeStyle === mergeStyleManuallyMerged">
|
||||
<input type="text" name="merge_commit_id" :placeholder="mergeForm.textMergeCommitId">
|
||||
</div>
|
||||
|
||||
<button class="ui button" :class="mergeButtonStyleClass" type="submit" name="do" :value="mergeStyle">
|
||||
{{ mergeStyleDetail.textDoMerge }}
|
||||
<template v-if="autoMergeWhenSucceed">
|
||||
{{ mergeForm.textAutoMergeButtonWhenSucceed }}
|
||||
</template>
|
||||
</button>
|
||||
<div class="flex-text-block tw-gap-3">
|
||||
<button class="ui button" :class="mergeButtonStyleClass" type="submit" name="do" :value="mergeStyle">
|
||||
{{ mergeStyleDetail.textDoMerge }}
|
||||
<template v-if="autoMergeWhenSucceed">
|
||||
{{ mergeForm.textAutoMergeButtonWhenSucceed }}
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<button class="ui button merge-cancel" @click="toggleActionForm(false)">
|
||||
{{ mergeForm.textCancel }}
|
||||
</button>
|
||||
<button class="ui button merge-cancel" type="button" @click="toggleActionForm(false)">
|
||||
{{ mergeForm.textCancel }}
|
||||
</button>
|
||||
|
||||
<div class="ui checkbox tw-ml-1" v-if="mergeForm.isPullBranchDeletable">
|
||||
<input name="delete_branch_after_merge" type="checkbox" v-model="deleteBranchAfterMerge" id="delete-branch-after-merge">
|
||||
<label for="delete-branch-after-merge">{{ mergeForm.textDeleteBranch }}</label>
|
||||
<div class="ui checkbox" v-if="mergeForm.isPullBranchDeletable">
|
||||
<input name="delete_branch_after_merge" type="checkbox" v-model="deleteBranchAfterMerge" id="delete-branch-after-merge">
|
||||
<label for="delete-branch-after-merge">{{ mergeForm.textDeleteBranch }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="!showActionForm" class="tw-flex">
|
||||
<!-- the merge button -->
|
||||
<div class="ui buttons merge-button" :class="[mergeForm.emptyCommit ? '' : mergeForm.allOverridableChecksOk ? 'primary' : 'red']" @click="toggleActionForm(true)">
|
||||
<div class="ui buttons merge-button" :class="mergeSelectStyleClass" @click="toggleActionForm(true)">
|
||||
<button class="ui button">
|
||||
<svg-icon name="octicon-git-merge"/>
|
||||
<span class="button-text">
|
||||
|
||||
@@ -1,26 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionRunStatus from './ActionRunStatus.vue';
|
||||
import {toRefs} from 'vue';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import {computed, onBeforeUnmount, ref, toRefs, watch} from 'vue';
|
||||
import {resetActionFavicon, syncActionRunFavicon} from '../modules/favicon-status.ts';
|
||||
import {POST, DELETE} from '../modules/fetch.ts';
|
||||
import ActionRunSummaryView from './ActionRunSummaryView.vue';
|
||||
import ActionRunJobView from './ActionRunJobView.vue';
|
||||
import {createActionRunViewStore} from "./ActionRunView.ts";
|
||||
import type {ActionsJob, ActionsRunAttempt} from '../modules/gitea-actions.ts';
|
||||
import {buildJobsByParentJobID, createActionRunViewStore} from './ActionRunView.ts';
|
||||
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
|
||||
|
||||
defineOptions({
|
||||
name: 'RepoActionView',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
runId: number;
|
||||
jobId: number;
|
||||
actionsUrl: string;
|
||||
actionsViewUrl: string;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const locale = props.locale;
|
||||
const store = createActionRunViewStore(props.actionsUrl, props.runId);
|
||||
const {currentRun: run , runArtifacts: artifacts} = toRefs(store.viewData);
|
||||
const store = createActionRunViewStore(props.actionsViewUrl);
|
||||
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
||||
const visibleJobSummaries = computed(() => {
|
||||
const summaries = run.value.jobSummaries || [];
|
||||
if (!props.jobId) return summaries;
|
||||
return summaries.filter((summary) => summary.jobId === props.jobId);
|
||||
});
|
||||
|
||||
type JobListItem = {
|
||||
job: ActionsJob;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
|
||||
const expandedJobIDs = ref(new Set<number>());
|
||||
|
||||
function toggleExpandedJob(jobID: number) {
|
||||
const next = new Set(expandedJobIDs.value);
|
||||
if (next.has(jobID)) {
|
||||
next.delete(jobID);
|
||||
} else {
|
||||
next.add(jobID);
|
||||
}
|
||||
expandedJobIDs.value = next;
|
||||
}
|
||||
|
||||
// When a child job is currently selected, force-expand the chain of caller ancestors
|
||||
const forcedExpandedJobIDs = computed(() => {
|
||||
const expanded = new Set<number>();
|
||||
if (!props.jobId) return expanded;
|
||||
const jobsByID = new Map((run.value.jobs || []).map((job) => [job.id, job]));
|
||||
let cur = jobsByID.get(props.jobId);
|
||||
while (cur?.parentJobID) {
|
||||
expanded.add(cur.parentJobID);
|
||||
cur = jobsByID.get(cur.parentJobID);
|
||||
}
|
||||
return expanded;
|
||||
});
|
||||
|
||||
function isJobCollapsed(jobID: number) {
|
||||
return !expandedJobIDs.value.has(jobID) && !forcedExpandedJobIDs.value.has(jobID);
|
||||
}
|
||||
|
||||
const visibleJobListItems = computed<JobListItem[]>(() => {
|
||||
const jobs = [...(run.value.jobs || [])].sort((a, b) => a.id - b.id);
|
||||
const childrenByParent = buildJobsByParentJobID(jobs);
|
||||
|
||||
const result: JobListItem[] = [];
|
||||
const stack: Array<{job: ActionsJob; depth: number}> = [];
|
||||
const top = childrenByParent.get(0) || [];
|
||||
for (let i = top.length - 1; i >= 0; i--) stack.push({job: top[i], depth: 0});
|
||||
|
||||
while (stack.length > 0) {
|
||||
const {job, depth} = stack.pop()!;
|
||||
const children = childrenByParent.get(job.id) || [];
|
||||
result.push({job, depth});
|
||||
if (children.length > 0 && isJobCollapsed(job.id)) continue;
|
||||
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function formatAttemptTitle(attempt: ActionsRunAttempt) {
|
||||
return attempt.latest ? `${locale.latestAttempt} #${attempt.attempt}` : `${locale.attempt} #${attempt.attempt}`;
|
||||
}
|
||||
|
||||
function formatCurrentAttemptTitle(attempt: ActionsRunAttempt) {
|
||||
return attempt.latest ? `${locale.latest} #${attempt.attempt}` : formatAttemptTitle(attempt);
|
||||
}
|
||||
|
||||
const backLink = computed(() => {
|
||||
if (run.value.pullRequest) {
|
||||
return {href: run.value.pullRequest.link, prefix: locale.backToPullRequest, name: run.value.pullRequest.index};
|
||||
}
|
||||
if (run.value.workflowLink) {
|
||||
return {href: run.value.workflowLink, prefix: locale.backToWorkflow, name: run.value.workflowID.replace(/\.(yml|yaml)$/i, '')};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function buildArtifactLink(name: string) {
|
||||
const searchString = run.value.runAttempt > 0 ? `?attempt=${run.value.runAttempt}` : '';
|
||||
return `${run.value.link}/artifacts/${encodeURIComponent(name)}${searchString}`;
|
||||
}
|
||||
|
||||
function cancelRun() {
|
||||
POST(`${run.value.link}/cancel`);
|
||||
@@ -32,28 +116,41 @@ function approveRun() {
|
||||
|
||||
async function deleteArtifact(name: string) {
|
||||
if (!window.confirm(locale.confirmDeleteArtifact.replace('%s', name))) return;
|
||||
await DELETE(`${run.value.link}/artifacts/${encodeURIComponent(name)}`);
|
||||
await DELETE(buildArtifactLink(name));
|
||||
await store.forceReloadCurrentRun();
|
||||
}
|
||||
|
||||
watch(() => run.value.status, (status) => {
|
||||
syncActionRunFavicon(status);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetActionFavicon();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<!-- make the view container full width to make users easier to read logs -->
|
||||
<div class="ui fluid container">
|
||||
<div class="action-view-header">
|
||||
<a v-if="backLink" class="action-view-back silenced" :href="backLink.href">
|
||||
<SvgIcon name="octicon-arrow-left" :size="14"/>
|
||||
<span>{{ backLink.prefix }} <span class="action-view-back-name">{{ backLink.name }}</span></span>
|
||||
</a>
|
||||
<div class="action-info-summary">
|
||||
<div class="action-info-summary-title">
|
||||
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
|
||||
<ActionStatusIcon :locale-status="locale.status[run.status]" :status="run.status" :size="22" icon-variant="circle-fill"/>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
|
||||
<span class="action-info-summary-title-index">#{{ run.index }}</span>
|
||||
</div>
|
||||
<div class="flex-text-block tw-shrink-0 tw-flex-wrap">
|
||||
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
|
||||
{{ locale.approve }}
|
||||
</button>
|
||||
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
|
||||
<button class="ui small compact button tw-text-red" @click="cancelRun()" v-else-if="run.canCancel">
|
||||
{{ locale.cancel }}
|
||||
</button>
|
||||
<template v-else-if="run.canRerun">
|
||||
<template v-if="run.canRerun">
|
||||
<div v-if="run.canRerunFailed" class="ui small compact buttons">
|
||||
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun-failed`">
|
||||
{{ locale.rerun_failed }}
|
||||
@@ -71,99 +168,173 @@ async function deleteArtifact(name: string) {
|
||||
{{ locale.rerun_all }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="run.attempts.length > 1" class="ui dropdown basic small compact button">
|
||||
<div class="flex-text-inline">
|
||||
<SvgIcon name="octicon-history" :size="14"/>
|
||||
<span>{{ formatCurrentAttemptTitle(run.attempts.find((attempt) => attempt.current)!) }}</span>
|
||||
</div>
|
||||
<SvgIcon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
|
||||
<div class="menu">
|
||||
<a
|
||||
v-for="attempt in run.attempts"
|
||||
:key="attempt.attempt"
|
||||
class="item tw-flex tw-flex-col tw-gap-2"
|
||||
:class="attempt.current ? 'selected' : ''"
|
||||
:href="attempt.link"
|
||||
>
|
||||
<div class="flex-text-block">
|
||||
<SvgIcon name="octicon-check" :size="14" :class="{'tw-invisible': !Boolean(attempt.current)}"/>
|
||||
<strong class="tw-text-sm gt-ellipsis">{{ formatAttemptTitle(attempt) }}</strong>
|
||||
</div>
|
||||
<div class="flex-text-block tw-pl-[20px]">
|
||||
<span class="flex-text-inline tw-flex-shrink-0">
|
||||
<ActionStatusIcon :locale-status="locale.status[attempt.status]" :status="attempt.status" :size="14" class="flex-text-block" icon-variant="circle-fill"/>
|
||||
<span>{{ locale.status[attempt.status] }}</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<relative-time :datetime="attempt.triggeredAt" prefix=""/>
|
||||
<span>•</span>
|
||||
<span class="gt-ellipsis">{{ attempt.triggerUserName }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-commit-summary">
|
||||
<span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
|
||||
<template v-if="run.isSchedule">
|
||||
{{ locale.scheduled }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ locale.commit }}
|
||||
<a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>
|
||||
{{ locale.pushedBy }}
|
||||
<a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
|
||||
</template>
|
||||
<span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">
|
||||
<span v-if="run.commit.branch.isDeleted" class="gt-ellipsis tw-line-through" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</span>
|
||||
<a v-else class="gt-ellipsis" :href="run.commit.branch.link" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-view-body">
|
||||
<div class="action-view-left">
|
||||
<!-- summary -->
|
||||
<a class="job-brief-item silenced" :href="run.link" :class="!props.jobId ? 'selected' : ''">
|
||||
<SvgIcon name="octicon-list-unordered"/>
|
||||
<span class="gt-ellipsis">{{ locale.summary }}</span>
|
||||
</a>
|
||||
<div class="flex-items-block action-view-sidebar-list">
|
||||
<a class="item silenced" :href="run.viewLink" :class="!props.jobId ? 'selected' : ''">
|
||||
<SvgIcon name="octicon-home"/>
|
||||
<span class="gt-ellipsis">{{ locale.summary }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- jobs list -->
|
||||
<div class="ui divider"/>
|
||||
<div class="left-list-header">{{ locale.allJobs }}</div>
|
||||
<!-- unlike other lists, the items have paddings already -->
|
||||
<ul class="ui relaxed list flex-items-block tw-p-0">
|
||||
<li class="item job-brief-item" v-for="job in run.jobs" :key="job.id" :class="props.jobId === job.id ? 'selected' : ''">
|
||||
<a class="tw-contents silenced" :href="run.link+'/jobs/'+job.id">
|
||||
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${job.id}/rerun`" v-if="job.canRerun"/>
|
||||
<span>{{ job.duration }}</span>
|
||||
<div class="flex-items-block action-view-sidebar-list">
|
||||
<template
|
||||
v-for="item in visibleJobListItems"
|
||||
:key="item.job.id"
|
||||
>
|
||||
<!-- Callers have no log page of their own; the whole row toggles expansion
|
||||
(matches GitHub Actions, where caller rows are not navigation targets). -->
|
||||
<button
|
||||
v-if="item.job.isReusableCaller"
|
||||
type="button"
|
||||
class="item caller-row-toggle"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
@click="toggleExpandedJob(item.job.id)"
|
||||
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-expanded="!isJobCollapsed(item.job.id)"
|
||||
>
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
<SvgIcon name="octicon-chevron-down" :size="14" class="job-brief-toggle-icon" :class="{'collapsed': isJobCollapsed(item.job.id)}"/>
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
class="item silenced"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
:href="item.job.link"
|
||||
>
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- artifacts list -->
|
||||
<template v-if="artifacts.length > 0">
|
||||
<div class="ui divider"/>
|
||||
<div class="left-list-header">{{ locale.artifactsTitle }} ({{ artifacts.length }})</div>
|
||||
<ul class="ui relaxed list flex-items-block">
|
||||
<li class="item" v-for="artifact in artifacts" :key="artifact.name">
|
||||
<div class="flex-items-block action-view-sidebar-list">
|
||||
<div class="item" v-for="artifact in artifacts" :key="artifact.name">
|
||||
<template v-if="artifact.status !== 'expired'">
|
||||
<a class="tw-flex-1 flex-text-block" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
|
||||
<SvgIcon name="octicon-file" class="tw-text-text"/>
|
||||
<a
|
||||
class="tw-flex-1 tw-min-w-0 flex-text-block silenced" target="_blank"
|
||||
:href="buildArtifactLink(artifact.name)"
|
||||
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
|
||||
data-tooltip-render="html"
|
||||
data-tooltip-placement="top-end"
|
||||
>
|
||||
<SvgIcon name="octicon-file" class="tw-text-text-light"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||
</a>
|
||||
<a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
|
||||
<SvgIcon name="octicon-trash" class="tw-text-text"/>
|
||||
<a v-if="run.canDeleteArtifact" class="silenced" @click="deleteArtifact(artifact.name)">
|
||||
<SvgIcon name="octicon-trash"/>
|
||||
</a>
|
||||
</template>
|
||||
<span v-else class="flex-text-block tw-flex-1 tw-text-grey-light">
|
||||
<SvgIcon name="octicon-file"/>
|
||||
<span
|
||||
v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2"
|
||||
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiredAt)"
|
||||
data-tooltip-render="html"
|
||||
data-tooltip-placement="top-end"
|
||||
>
|
||||
<SvgIcon name="octicon-file-removed"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||
<span class="ui label tw-text-grey-light tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- run details -->
|
||||
<div class="ui divider"/>
|
||||
<div class="left-list-header">{{ locale.runDetails }}</div>
|
||||
<ul class="ui relaxed list">
|
||||
<li class="item">
|
||||
<a class="flex-text-block" :href="`${run.link}/workflow`">
|
||||
<div class="flex-items-block action-view-sidebar-list">
|
||||
<div class="item">
|
||||
<a v-if="run.canViewWorkflowFile" class="flex-text-block silenced" :href="`${run.link}/workflow`">
|
||||
<SvgIcon name="octicon-file-code" class="tw-text-text"/>
|
||||
<span class="gt-ellipsis">{{ locale.workflowFile }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<span v-else class="flex-text-block silenced" :data-tooltip-content="locale.workflowFileNoPermission">
|
||||
<SvgIcon name="octicon-lock" class="tw-text-text"/>
|
||||
<span class="gt-ellipsis">{{ locale.workflowFileNoPermission }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-view-right">
|
||||
<ActionRunSummaryView
|
||||
v-if="!props.jobId"
|
||||
:store="store"
|
||||
:locale="locale"
|
||||
/>
|
||||
<ActionRunJobView
|
||||
v-else
|
||||
:store="store"
|
||||
:locale="locale"
|
||||
:run-id="props.runId"
|
||||
:job-id="props.jobId"
|
||||
:actions-url="props.actionsUrl"
|
||||
/>
|
||||
<div class="action-view-right-panel">
|
||||
<ActionRunSummaryView
|
||||
v-if="!props.jobId"
|
||||
:store="store"
|
||||
:locale="locale"
|
||||
:artifact-count="artifacts.length"
|
||||
/>
|
||||
<ActionRunJobView
|
||||
v-else
|
||||
:store="store"
|
||||
:locale="locale"
|
||||
:actions-view-url="props.actionsViewUrl"
|
||||
:job-id="props.jobId"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="visibleJobSummaries.length" class="action-view-right-panel job-summary-section">
|
||||
<div class="job-summary-section-header">
|
||||
{{ locale.jobSummaries }}
|
||||
</div>
|
||||
<div class="job-summary-list">
|
||||
<div v-for="s in visibleJobSummaries" :key="s.jobId" class="job-summary-item">
|
||||
<div class="job-summary-header">
|
||||
<strong class="gt-ellipsis">{{ s.jobName || `Job ${s.jobId}` }}</strong>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="markup job-summary-body" v-html="s.summaryHTML"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,9 +351,30 @@ async function deleteArtifact(name: string) {
|
||||
/* action view header */
|
||||
|
||||
.action-view-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-view-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-light-1);
|
||||
}
|
||||
|
||||
.action-view-back:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.action-view-back-name {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.action-info-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -200,30 +392,20 @@ async function deleteArtifact(name: string) {
|
||||
.action-info-summary-title-text {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action-info-summary-title-index {
|
||||
font-size: 20px;
|
||||
color: var(--color-text-light-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-info-summary .ui.button {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-commit-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.action-commit-summary {
|
||||
margin-left: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================ */
|
||||
/* action view left */
|
||||
|
||||
@@ -251,32 +433,77 @@ async function deleteArtifact(name: string) {
|
||||
|
||||
.left-list-header {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.action-view-left .ui.relaxed.list {
|
||||
.action-view-sidebar-list {
|
||||
margin: var(--gap-block) 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.job-brief-item {
|
||||
.action-view-sidebar-list:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.action-view-sidebar-list > .item {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--gap-block);
|
||||
}
|
||||
|
||||
.job-brief-item:hover {
|
||||
.action-view-sidebar-list > .item:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
.job-brief-item.selected {
|
||||
.action-view-sidebar-list > .item.selected {
|
||||
font-weight: var(--font-weight-bold);
|
||||
background-color: var(--color-active);
|
||||
}
|
||||
|
||||
.caller-row-toggle {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
line-height: inherit; /* buttons don't inherit line-height; match the <a> rows' row height */
|
||||
cursor: pointer;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.job-brief-toggle-icon {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
/* sit between name and duration; duration uses order:2 with margin-left:auto to float right */
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.job-brief-toggle-icon:not(.collapsed) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* push rerun/duration to the right edge; only one is visible at a time (hover swap),
|
||||
the visible one absorbs the free space via auto-margin */
|
||||
.action-view-sidebar-list > .item .job-rerun-button,
|
||||
.action-view-sidebar-list > .item .job-duration {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* the re-run button replaces the duration on hover or job-link focus */
|
||||
.action-view-sidebar-list > .item .job-rerun-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-view-sidebar-list > .item:hover .job-rerun-button,
|
||||
.action-view-sidebar-list > .item:focus .job-rerun-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* only swap out the duration when a re-run button exists to take its place */
|
||||
.action-view-sidebar-list > .item:hover .job-rerun-button ~ .job-duration,
|
||||
.action-view-sidebar-list > .item:focus .job-rerun-button ~ .job-duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ================ */
|
||||
/* action view right */
|
||||
|
||||
@@ -287,25 +514,33 @@ async function deleteArtifact(name: string) {
|
||||
width: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-view-right-panel {
|
||||
flex: 1; /* fill the right column so the summary graph stretches even without a job-summary section */
|
||||
border: 1px solid var(--color-console-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-console-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* begin fomantic button overrides */
|
||||
|
||||
.action-view-right .ui.button,
|
||||
.action-view-right .ui.button:focus {
|
||||
.action-view-right-panel .ui.button,
|
||||
.action-view-right-panel .ui.button:focus {
|
||||
background: transparent;
|
||||
color: var(--color-console-fg-subtle);
|
||||
}
|
||||
|
||||
.action-view-right .ui.button:hover {
|
||||
.action-view-right-panel .ui.button:hover {
|
||||
background: var(--color-console-hover-bg);
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.button:active {
|
||||
.action-view-right-panel .ui.button:active {
|
||||
background: var(--color-console-active-bg);
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
@@ -323,4 +558,40 @@ async function deleteArtifact(name: string) {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.job-summary-section {
|
||||
flex: 0 0 auto; /* size to its content; let the summary panel keep the remaining height */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-summary-section-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-console-border);
|
||||
background: var(--color-console-bg);
|
||||
color: var(--color-console-fg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.job-summary-list {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.job-summary-item {
|
||||
padding: 12px;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-console-hover-bg);
|
||||
border: 1px solid var(--color-console-border);
|
||||
}
|
||||
|
||||
.job-summary-header {
|
||||
color: var(--color-console-fg);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.job-summary-body {
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type DayDataObject,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, shallowRef} from 'vue';
|
||||
@@ -78,7 +79,7 @@ async function fetchGraphData() {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = err.message;
|
||||
errorText.value = errorMessage(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -144,7 +145,7 @@ const options: ChartOptions<'line'> = {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
||||
<div class="ui header">
|
||||
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: `Code frequency over the history of ${repoLink.slice(1)}` }}
|
||||
</div>
|
||||
<div class="tw-flex ui segment main-graph">
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
@@ -166,7 +167,7 @@ export default defineComponent({
|
||||
this.errorText = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorText = err.message;
|
||||
this.errorText = errorMessage(err);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
@@ -269,7 +270,7 @@ export default defineComponent({
|
||||
plugins: {
|
||||
title: {
|
||||
display: type === 'main',
|
||||
text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
|
||||
text: this.locale.chartZoomHint,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
},
|
||||
@@ -339,7 +340,7 @@ export default defineComponent({
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
||||
<div class="ui header flex-left-right">
|
||||
<div>
|
||||
<relative-time
|
||||
v-if="xAxisMin && xAxisMin > 0"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type DayDataObject,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, ref, shallowRef} from 'vue';
|
||||
@@ -74,7 +75,7 @@ async function fetchGraphData() {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = err.message;
|
||||
errorText.value = errorMessage(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -122,7 +123,7 @@ const options: ChartOptions<'bar'> = {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
||||
<div class="ui header">
|
||||
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "Number of commits in the past year" }}
|
||||
</div>
|
||||
<div class="tw-flex ui segment main-graph">
|
||||
|
||||
@@ -32,6 +32,8 @@ const onItemClick = (e: MouseEvent) => {
|
||||
// - 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;
|
||||
// submodules (commit entry mode) point to external repos, let the browser handle navigation normally
|
||||
if (props.item.entryMode === 'commit') return;
|
||||
e.preventDefault();
|
||||
if (props.item.entryMode === 'tree') doLoadChildren();
|
||||
store.navigateTreeView(props.item.fullPath);
|
||||
|
||||
@@ -57,9 +57,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
|
||||
await store.loadViewContent(url);
|
||||
},
|
||||
|
||||
buildTreePathWebUrl(treePath: string) {
|
||||
return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`;
|
||||
},
|
||||
buildTreePathWebUrl: (treePath: string) => `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import {computeGraphHighlightState, computeJobLevels, createWorkflowGraphModel} from './WorkflowGraph.utils.ts';
|
||||
import type {ActionsJob} from '../modules/gitea-actions.ts';
|
||||
|
||||
const mockJobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'job-100', name: 'job-100', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 2, link: '', jobId: 'job-101', name: 'job-101', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['job-100']},
|
||||
{id: 3, link: '', jobId: 'job-102', name: 'job-102', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-101']},
|
||||
{id: 4, link: '', jobId: 'job-103', name: 'job-103', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100']},
|
||||
{id: 5, link: '', jobId: 'prep-jdk', name: 'prep-jdk', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 6, link: '', jobId: 'code-analysis', name: 'code-analysis', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 7, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (1, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 8, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (1, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 9, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (2, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 10, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (3, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 11, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (3, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 12, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (99, webkit)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 13, link: '', jobId: 'unit-test', name: 'unit-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
|
||||
{id: 14, link: '', jobId: 'arch-test', name: 'arch-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
|
||||
{id: 15, link: '', jobId: 'integration-test', name: 'integration-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['prep-jdk', 'code-analysis']},
|
||||
{id: 16, link: '', jobId: 'build-image', name: 'build-image', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: [
|
||||
'unit-test',
|
||||
'arch-test',
|
||||
'integration-test',
|
||||
'matrix-e2e',
|
||||
]},
|
||||
];
|
||||
|
||||
const verifyDeployJobs: ActionsJob[] = [
|
||||
{id: 101, link: '', jobId: 'seed-dev', name: 'seed-dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s'},
|
||||
{id: 102, link: '', jobId: 'seed-qa', name: 'seed-qa', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 103, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['seed-dev']},
|
||||
{id: 104, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['seed-qa']},
|
||||
{id: 105, link: '', jobId: 'deploy', name: 'Deploy', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '', needs: ['verify-dev', 'verify-qa']},
|
||||
];
|
||||
|
||||
// Multi-level pipeline with two matrices and a leaf with two parents.
|
||||
const wfTest1Jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'init', name: 'Initialize Pipeline', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
||||
{id: 2, link: '', jobId: 'lint-frontend', name: 'Lint Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
|
||||
{id: 3, link: '', jobId: 'lint-backend', name: 'Lint Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
|
||||
{id: 4, link: '', jobId: 'build-frontend', name: 'Build Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['lint-frontend']},
|
||||
{id: 5, link: '', jobId: 'build-backend', name: 'Build Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['lint-backend']},
|
||||
{id: 6, link: '', jobId: 'unit-tests', name: 'Unit Tests (api, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 7, link: '', jobId: 'unit-tests', name: 'Unit Tests (api, false)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 8, link: '', jobId: 'unit-tests', name: 'Unit Tests (service, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 9, link: '', jobId: 'test-integration', name: 'Integration Tests', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '6s', needs: ['build-backend']},
|
||||
{id: 10, link: '', jobId: 'e2e-tests', name: 'E2E Tests (chrome, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
||||
{id: 11, link: '', jobId: 'e2e-tests', name: 'E2E Tests (chrome, mobile)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
||||
{id: 12, link: '', jobId: 'e2e-tests', name: 'E2E Tests (firefox, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
||||
{id: 13, link: '', jobId: 'bundle-app', name: 'Bundle Application', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['unit-tests', 'test-integration', 'e2e-tests']},
|
||||
{id: 14, link: '', jobId: 'deploy-dev', name: 'Deploy to Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
|
||||
{id: 15, link: '', jobId: 'deploy-qa', name: 'Deploy to QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
|
||||
{id: 16, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-dev']},
|
||||
{id: 17, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-qa']},
|
||||
{id: 18, link: '', jobId: 'deploy-prod', name: 'Deploy to Production', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['verify-dev', 'verify-qa']},
|
||||
{id: 19, link: '', jobId: 'post-deploy-checks', name: 'Post-Deploy Checks', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-prod']},
|
||||
];
|
||||
|
||||
const mockJob = (id: number, jobId: string, name: string, needs?: string[]): ActionsJob =>
|
||||
({id, link: '', jobId, name, status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s', needs});
|
||||
|
||||
test('matrix nodes key on job id, not on the display name', () => {
|
||||
const legs = createWorkflowGraphModel([mockJob(1, 'explicit', 'leg one'), mockJob(2, 'explicit', 'leg two')]);
|
||||
expect(legs.nodes).toHaveLength(1);
|
||||
expect(legs.nodes[0].type).toBe('matrix');
|
||||
expect(legs.nodes[0].name).toBe('explicit');
|
||||
expect(legs.nodes[0].jobs.map((j) => j.id)).toEqual([1, 2]);
|
||||
|
||||
const lookalikes = createWorkflowGraphModel([
|
||||
mockJob(1, 'setup', 'setup'),
|
||||
mockJob(2, 'build-fast', 'build (fast)', ['setup']),
|
||||
mockJob(3, 'build-slow', 'build (slow)', ['setup']),
|
||||
]);
|
||||
expect(lookalikes.nodes.map((n) => n.type)).toEqual(['job', 'group']);
|
||||
|
||||
const noJobId = createWorkflowGraphModel([mockJob(1, '', 'first'), mockJob(2, '', 'second')]);
|
||||
expect(noJobId.nodes.map((n) => n.id)).toEqual(['job:1', 'job:2']);
|
||||
});
|
||||
|
||||
test('computeJobLevels keeps stable topological levels', () => {
|
||||
const levels = computeJobLevels(mockJobs);
|
||||
expect(levels.get('job-100')).toBe(0);
|
||||
expect(levels.get('job-101')).toBe(1);
|
||||
expect(levels.get('job-102')).toBe(2);
|
||||
expect(levels.get('build-image')).toBe(2);
|
||||
});
|
||||
|
||||
test('graph model collapses matrix and groups jobs that share parents and children', () => {
|
||||
const graph = createWorkflowGraphModel(mockJobs);
|
||||
|
||||
expect(graph.nodes.find((n) => n.type === 'matrix')?.jobs).toHaveLength(6);
|
||||
const groupJobIds = graph.nodes.filter((n) => n.type === 'group').map((g) => g.jobs.map((j) => j.jobId));
|
||||
expect(groupJobIds).toEqual(expect.arrayContaining([
|
||||
['prep-jdk', 'code-analysis'],
|
||||
['unit-test', 'arch-test', 'integration-test'],
|
||||
]));
|
||||
});
|
||||
|
||||
test('expanded matrix height includes summary and toggle rows', () => {
|
||||
const collapsed = createWorkflowGraphModel(mockJobs);
|
||||
const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix:matrix-e2e']));
|
||||
const collapsedMatrix = collapsed.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
||||
const expandedMatrix = expanded.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
||||
|
||||
expect(collapsedMatrix?.displayHeight).toBeLessThan(expandedMatrix?.displayHeight ?? 0);
|
||||
// 6 jobs * 26 row height + 24 header + 6 pad * 2 = 192
|
||||
expect(expandedMatrix?.displayHeight).toBe(192);
|
||||
});
|
||||
|
||||
test('every dependency is rendered as one routed edge', () => {
|
||||
const graph = createWorkflowGraphModel(mockJobs);
|
||||
const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
|
||||
const testGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'unit-test'))!;
|
||||
const expectedKeys = [
|
||||
`${rootGroup.id}->matrix:matrix-e2e`,
|
||||
`${rootGroup.id}->${testGroup.id}`,
|
||||
];
|
||||
const keys = new Set(graph.routedEdges.map((e) => e.key));
|
||||
for (const k of expectedKeys) expect(keys.has(k)).toBe(true);
|
||||
});
|
||||
|
||||
test('same-row edge collapses to a single horizontal line', () => {
|
||||
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
||||
const verifyDevEdge = graph.routedEdges.find((e) => e.fromId === 'job:101' && e.toId === 'job:103');
|
||||
const verifyQaEdge = graph.routedEdges.find((e) => e.fromId === 'job:102' && e.toId === 'job:104');
|
||||
expect(verifyDevEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
|
||||
expect(verifyQaEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
|
||||
});
|
||||
|
||||
test('different-row edge uses cubic bezier curve', () => {
|
||||
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
||||
const deployLowerEdge = graph.routedEdges.find((e) => e.fromId === 'job:104' && e.toId === 'job:105');
|
||||
expect(deployLowerEdge?.path).toContain(' C ');
|
||||
});
|
||||
|
||||
test('multi-level pipeline with two matrices and a converging leaf renders without errors', () => {
|
||||
const graph = createWorkflowGraphModel(wfTest1Jobs);
|
||||
const matrices = graph.nodes.filter((n) => n.type === 'matrix');
|
||||
expect(matrices.map((n) => n.name).sort()).toEqual(['E2E Tests', 'Unit Tests']);
|
||||
|
||||
const deployProd = graph.nodes.find((n) => n.id === 'job:18');
|
||||
const verifyDev = graph.nodes.find((n) => n.id === 'job:16');
|
||||
const verifyQa = graph.nodes.find((n) => n.id === 'job:17');
|
||||
expect(verifyDev?.level).toBe(verifyQa?.level);
|
||||
expect(deployProd?.level).toBe((verifyDev?.level ?? 0) + 1);
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
expect(Number.isFinite(node.x)).toBe(true);
|
||||
expect(Number.isFinite(node.y)).toBe(true);
|
||||
expect(node.x).toBeGreaterThanOrEqual(0);
|
||||
expect(node.y).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
for (const edge of graph.routedEdges) {
|
||||
expect(edge.path).not.toMatch(/NaN|undefined|Infinity/);
|
||||
}
|
||||
});
|
||||
|
||||
test('reusable callers with identical dependency signature are kept as separate nodes', () => {
|
||||
const jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
||||
{id: 2, link: '', jobId: 'local_caller', name: 'local caller', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '5m', needs: ['prepare'], callUses: './.gitea/workflows/lib.yml'},
|
||||
{id: 3, link: '', jobId: 'cross_caller', name: 'cross-repo caller', status: 'waiting', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '0s', needs: ['prepare'], callUses: 'user2/lib/.gitea/workflows/ext.yml@main'},
|
||||
{id: 4, link: '', jobId: 'final', name: 'final', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '0s', needs: ['local_caller', 'cross_caller']},
|
||||
];
|
||||
const graph = createWorkflowGraphModel(jobs);
|
||||
expect(graph.nodes.find((n) => n.type === 'group')).toBeUndefined();
|
||||
expect(graph.nodes.find((n) => n.id === 'job:2')?.name).toBe('local caller');
|
||||
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller');
|
||||
});
|
||||
|
||||
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
|
||||
const jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
||||
{id: 2, link: '', jobId: 'build-call', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 3, link: '', jobId: 'build-call', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 4, link: '', jobId: 'build-call', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
];
|
||||
const graph = createWorkflowGraphModel(jobs);
|
||||
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
|
||||
expect(matrixNodes).toHaveLength(1);
|
||||
expect(matrixNodes[0].name).toBe('build-call');
|
||||
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
test('directed highlight state covers ancestors and descendants of the hovered node', () => {
|
||||
const graph = createWorkflowGraphModel(mockJobs);
|
||||
const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
|
||||
|
||||
const highlight = computeGraphHighlightState(rootGroup.id, graph.adjacency);
|
||||
expect(highlight.nodeIds.has('matrix:matrix-e2e')).toBe(true);
|
||||
expect(highlight.nodeIds.has('job:16')).toBe(true);
|
||||
expect(highlight.edgeKeys.has(`${rootGroup.id}->matrix:matrix-e2e`)).toBe(true);
|
||||
});
|
||||
|
||||
test('directed highlight state for converging graph excludes sibling branch when hovering parent', () => {
|
||||
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
||||
|
||||
const parentHighlight = computeGraphHighlightState('job:103', graph.adjacency);
|
||||
expect(parentHighlight.nodeIds.has('job:101')).toBe(true);
|
||||
expect(parentHighlight.nodeIds.has('job:105')).toBe(true);
|
||||
expect(parentHighlight.nodeIds.has('job:104')).toBe(false);
|
||||
expect(parentHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
|
||||
expect(parentHighlight.edgeKeys.has('job:104->job:105')).toBe(false);
|
||||
|
||||
const sinkHighlight = computeGraphHighlightState('job:105', graph.adjacency);
|
||||
expect(sinkHighlight.nodeIds.has('job:103')).toBe(true);
|
||||
expect(sinkHighlight.nodeIds.has('job:104')).toBe(true);
|
||||
expect(sinkHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
|
||||
expect(sinkHighlight.edgeKeys.has('job:104->job:105')).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,538 @@
|
||||
import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
|
||||
export type GraphNodeType = 'job' | 'matrix' | 'group';
|
||||
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
type: GraphNodeType;
|
||||
name: string;
|
||||
status: ActionsStatus;
|
||||
duration: string;
|
||||
x: number;
|
||||
y: number;
|
||||
level: number;
|
||||
displayHeight: number;
|
||||
jobs: ActionsJob[];
|
||||
};
|
||||
|
||||
export type Edge = {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type RoutedEdge = Edge & {
|
||||
path: string;
|
||||
fromNode: GraphNode;
|
||||
toNode: GraphNode;
|
||||
};
|
||||
|
||||
export type SharedSegment = {
|
||||
key: string;
|
||||
edgeKeys: string[];
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type GraphHighlightState = {
|
||||
nodeIds: Set<string>;
|
||||
edgeKeys: Set<string>;
|
||||
};
|
||||
|
||||
export type WorkflowGraphLayoutOptions = {
|
||||
margin: number;
|
||||
nodeWidth: number;
|
||||
nodeHeight: number;
|
||||
columnGap: number;
|
||||
laneGap: number;
|
||||
groupRowHeight: number;
|
||||
groupPadY: number;
|
||||
matrixCollapsedHeight: number;
|
||||
matrixHeaderHeight: number;
|
||||
matrixRowHeight: number;
|
||||
matrixPadY: number;
|
||||
};
|
||||
|
||||
export type WorkflowGraphModel = {
|
||||
nodes: GraphNode[];
|
||||
edges: Edge[];
|
||||
routedEdges: RoutedEdge[];
|
||||
sharedSegments: SharedSegment[];
|
||||
adjacency: NodeAdjacency;
|
||||
};
|
||||
|
||||
export type NodeAdjacency = {
|
||||
incomingByNodeId: Map<string, string[]>;
|
||||
outgoingByNodeId: Map<string, string[]>;
|
||||
};
|
||||
|
||||
const defaultLayoutOptions: WorkflowGraphLayoutOptions = {
|
||||
margin: 24,
|
||||
nodeWidth: 220,
|
||||
nodeHeight: 40,
|
||||
columnGap: 96,
|
||||
laneGap: 32,
|
||||
groupRowHeight: 28,
|
||||
groupPadY: 8,
|
||||
matrixCollapsedHeight: 78,
|
||||
matrixHeaderHeight: 24,
|
||||
matrixRowHeight: 26,
|
||||
matrixPadY: 6,
|
||||
};
|
||||
|
||||
function canonicalKey(ids: Iterable<string>): string {
|
||||
return Array.from(ids).sort().join('');
|
||||
}
|
||||
|
||||
function graphIdForJob(job: ActionsJob): string {
|
||||
return `job:${job.id}`;
|
||||
}
|
||||
|
||||
// matrix legs are named `<job name> (<combination>)`; a workflow-provided `name:` may not be
|
||||
function matrixLabel(matrixJobs: ActionsJob[], jobId: string): string {
|
||||
const prefixes = new Set(matrixJobs.map((job) => {
|
||||
const idx = job.name.indexOf(' (');
|
||||
return idx === -1 ? '' : job.name.slice(0, idx).trim();
|
||||
}));
|
||||
const [prefix] = prefixes;
|
||||
return prefixes.size === 1 && prefix ? prefix : jobId;
|
||||
}
|
||||
|
||||
export function boxBottom(node: GraphNode): number {
|
||||
return node.y + node.displayHeight;
|
||||
}
|
||||
|
||||
export function boxCenterY(node: GraphNode): number {
|
||||
return node.y + node.displayHeight / 2;
|
||||
}
|
||||
|
||||
function matrixPanelHeight(rowCount: number, expanded: boolean, options: WorkflowGraphLayoutOptions): number {
|
||||
if (rowCount <= 0) return options.nodeHeight;
|
||||
if (!expanded) return options.matrixCollapsedHeight;
|
||||
return options.matrixHeaderHeight + rowCount * options.matrixRowHeight + options.matrixPadY * 2;
|
||||
}
|
||||
|
||||
function groupPanelHeight(rowCount: number, options: WorkflowGraphLayoutOptions): number {
|
||||
return rowCount * options.groupRowHeight + options.groupPadY * 2;
|
||||
}
|
||||
|
||||
function compareStatusWorstFirst(a: ActionsStatus, b: ActionsStatus): number {
|
||||
const rank = (s: ActionsStatus) => {
|
||||
if (s === 'failure') return 0;
|
||||
if (s === 'cancelled') return 1;
|
||||
if (s === 'running') return 2;
|
||||
if (s === 'waiting') return 3;
|
||||
if (s === 'blocked') return 4;
|
||||
if (s === 'success') return 5;
|
||||
if (s === 'skipped') return 6;
|
||||
return 7;
|
||||
};
|
||||
return rank(a) - rank(b);
|
||||
}
|
||||
|
||||
function aggregateStatus(children: ActionsJob[]): ActionsStatus {
|
||||
return children.map((c) => c.status).slice().sort(compareStatusWorstFirst)[0] ?? 'unknown';
|
||||
}
|
||||
|
||||
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 = Array.from(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) {
|
||||
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
|
||||
return needs.every((other) => other === need || !canReach(need, other));
|
||||
}));
|
||||
}
|
||||
return reducedNeedsByJobId;
|
||||
}
|
||||
|
||||
export function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||||
const jobMap = new Map<string, ActionsJob>();
|
||||
for (const job of jobs) {
|
||||
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>();
|
||||
|
||||
function dfs(jobNameOrId: string): number {
|
||||
if (recursionStack.has(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);
|
||||
if (job.jobId !== job.name) levels.set(job.name, 0);
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxLevel = -1;
|
||||
for (const need of job.needs) {
|
||||
if (!jobMap.has(need)) continue;
|
||||
maxLevel = Math.max(maxLevel, dfs(need));
|
||||
}
|
||||
const level = maxLevel + 1;
|
||||
levels.set(job.name, level);
|
||||
levels.set(job.jobId, level);
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return level;
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!visited.has(job.jobId)) dfs(job.jobId);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
export function computeGraphHighlightState(hoveredId: string | null, adjacency: NodeAdjacency): GraphHighlightState {
|
||||
if (!hoveredId) return {nodeIds: new Set(), edgeKeys: new Set()};
|
||||
const {incomingByNodeId, outgoingByNodeId} = adjacency;
|
||||
|
||||
const edgeKeys = new Set<string>();
|
||||
const collect = (startId: string, adj: Map<string, string[]>, edgeKeyForward: boolean): Set<string> => {
|
||||
const seen = new Set<string>();
|
||||
const queue = [startId];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
for (const next of adj.get(current) || []) {
|
||||
edgeKeys.add(edgeKeyForward ? `${current}->${next}` : `${next}->${current}`);
|
||||
if (!seen.has(next)) queue.push(next);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
const ancestors = collect(hoveredId, incomingByNodeId, false);
|
||||
const descendants = collect(hoveredId, outgoingByNodeId, true);
|
||||
return {nodeIds: new Set([...ancestors, ...descendants]), edgeKeys};
|
||||
}
|
||||
|
||||
type VisualGraphBuild = {
|
||||
nodes: GraphNode[];
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
function buildVisualGraph(
|
||||
jobs: ActionsJob[],
|
||||
expandedMatrixNodeIds: ReadonlySet<string>,
|
||||
options: WorkflowGraphLayoutOptions,
|
||||
): VisualGraphBuild {
|
||||
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||||
const jobIndexById = new Map<number, number>();
|
||||
for (const [index, job] of jobs.entries()) {
|
||||
jobIndexById.set(job.id, index);
|
||||
if (!jobsByJobId.has(job.jobId)) jobsByJobId.set(job.jobId, []);
|
||||
jobsByJobId.get(job.jobId)!.push(job);
|
||||
}
|
||||
|
||||
// legs of one matrix job share its `jobId`; their display names are free-form so cannot key them
|
||||
const isMatrixLeg = (job: ActionsJob): boolean => Boolean(job.jobId) && jobsByJobId.get(job.jobId)!.length > 1;
|
||||
|
||||
const directNeedsByJobId = buildDirectNeedsMap(jobs);
|
||||
const rawLevels = computeJobLevels(jobs);
|
||||
const dependentsByJobId = new Map<string, string[]>();
|
||||
const rawEdges: Array<{from: ActionsJob; to: ActionsJob}> = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
for (const need of directNeedsByJobId.get(job.jobId) || []) {
|
||||
for (const upstream of jobsByJobId.get(need) || []) {
|
||||
rawEdges.push({from: upstream, to: job});
|
||||
if (!dependentsByJobId.has(upstream.jobId)) dependentsByJobId.set(upstream.jobId, []);
|
||||
dependentsByJobId.get(upstream.jobId)!.push(job.jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const list of dependentsByJobId.values()) list.sort();
|
||||
|
||||
// Group sibling jobs that share an identical (parents, children) signature into a single
|
||||
// collapsed "group" node. This is a visual aggregation only - the underlying jobs are
|
||||
// preserved on the node so the panel can list them.
|
||||
const groupedJobIds = new Map<number, string>();
|
||||
const groupsById = new Map<string, ActionsJob[]>();
|
||||
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
if (isMatrixLeg(job)) continue;
|
||||
// Reusable callers represent distinct workflow files — keep each as its own node so the
|
||||
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
|
||||
// siblings share an identical (parents, children) dependency signature.
|
||||
if (job.isReusableCaller) continue;
|
||||
const needsKey = canonicalKey(directNeedsByJobId.get(job.jobId) || []);
|
||||
const childrenKey = (dependentsByJobId.get(job.jobId) || []).join('');
|
||||
if (!needsKey && !childrenKey) continue;
|
||||
const level = rawLevels.get(job.jobId) ?? 0;
|
||||
const key = `group:${level}:${needsKey}:${childrenKey}`;
|
||||
if (!groupCandidateBuckets.has(key)) groupCandidateBuckets.set(key, []);
|
||||
groupCandidateBuckets.get(key)!.push(job);
|
||||
}
|
||||
for (const [groupId, groupJobs] of groupCandidateBuckets) {
|
||||
if (groupJobs.length < 2) continue;
|
||||
groupJobs.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
|
||||
groupsById.set(groupId, groupJobs);
|
||||
for (const job of groupJobs) groupedJobIds.set(job.id, groupId);
|
||||
}
|
||||
|
||||
const visualIdByJobId = new Map<number, string>();
|
||||
const emittedNodeIds = new Set<string>();
|
||||
const nodes: GraphNode[] = [];
|
||||
for (const job of jobs) {
|
||||
const matrixJobs = isMatrixLeg(job) ? jobsByJobId.get(job.jobId)! : null;
|
||||
const visualId = matrixJobs ? `matrix:${job.jobId}` : (groupedJobIds.get(job.id) || graphIdForJob(job));
|
||||
visualIdByJobId.set(job.id, visualId);
|
||||
if (emittedNodeIds.has(visualId)) continue;
|
||||
emittedNodeIds.add(visualId);
|
||||
|
||||
if (matrixJobs) {
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'matrix',
|
||||
name: matrixLabel(matrixJobs, job.jobId),
|
||||
status: aggregateStatus(matrixJobs),
|
||||
duration: '',
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixNodeIds.has(visualId), options),
|
||||
jobs: matrixJobs,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupJobs = groupsById.get(visualId);
|
||||
if (groupJobs) {
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'group',
|
||||
name: groupJobs.map((g) => g.name).join(', '),
|
||||
status: aggregateStatus(groupJobs),
|
||||
duration: '',
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: groupPanelHeight(groupJobs.length, options),
|
||||
jobs: groupJobs,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'job',
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
duration: job.duration,
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: options.nodeHeight,
|
||||
jobs: [job],
|
||||
});
|
||||
}
|
||||
|
||||
const seenEdges = new Set<string>();
|
||||
const edges: Edge[] = [];
|
||||
for (const {from, to} of rawEdges) {
|
||||
const fromId = visualIdByJobId.get(from.id);
|
||||
const toId = visualIdByJobId.get(to.id);
|
||||
if (!fromId || !toId || fromId === toId) continue;
|
||||
const key = `${fromId}->${toId}`;
|
||||
if (seenEdges.has(key)) continue;
|
||||
seenEdges.add(key);
|
||||
edges.push({fromId, toId, key});
|
||||
}
|
||||
|
||||
return {nodes, edges};
|
||||
}
|
||||
|
||||
function buildNodeAdjacency(edges: Edge[]): NodeAdjacency {
|
||||
const incomingByNodeId = new Map<string, string[]>();
|
||||
const outgoingByNodeId = new Map<string, string[]>();
|
||||
for (const edge of edges) {
|
||||
if (!incomingByNodeId.has(edge.toId)) incomingByNodeId.set(edge.toId, []);
|
||||
incomingByNodeId.get(edge.toId)!.push(edge.fromId);
|
||||
if (!outgoingByNodeId.has(edge.fromId)) outgoingByNodeId.set(edge.fromId, []);
|
||||
outgoingByNodeId.get(edge.fromId)!.push(edge.toId);
|
||||
}
|
||||
return {incomingByNodeId, outgoingByNodeId};
|
||||
}
|
||||
|
||||
function assignNodeLevels(nodes: GraphNode[], {incomingByNodeId}: NodeAdjacency): void {
|
||||
const cache = new Map<string, number>();
|
||||
function levelFor(id: string, visiting = new Set<string>()): number {
|
||||
if (cache.has(id)) return cache.get(id)!;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
const incoming = incomingByNodeId.get(id) || [];
|
||||
const level = incoming.length > 0 ?
|
||||
Math.max(...incoming.map((fromId) => levelFor(fromId, visiting))) + 1 :
|
||||
0;
|
||||
visiting.delete(id);
|
||||
cache.set(id, level);
|
||||
return level;
|
||||
}
|
||||
for (const node of nodes) node.level = levelFor(node.id);
|
||||
}
|
||||
|
||||
// Roots stay in input order; later levels are sorted by the mean parent Y so that simple
|
||||
// chains stay on a straight horizontal line.
|
||||
function assignNodeCoordinates(nodesById: Map<string, GraphNode>, nodes: GraphNode[], adjacency: NodeAdjacency, options: WorkflowGraphLayoutOptions): void {
|
||||
const {incomingByNodeId} = adjacency;
|
||||
const inputRank = (node: GraphNode): number => Math.min(...node.jobs.map((j) => j.id));
|
||||
|
||||
const nodesByLevel = new Map<number, GraphNode[]>();
|
||||
for (const node of nodes) {
|
||||
if (!nodesByLevel.has(node.level)) nodesByLevel.set(node.level, []);
|
||||
nodesByLevel.get(node.level)!.push(node);
|
||||
}
|
||||
const orderedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b);
|
||||
|
||||
// Initial X assignment and a default Y so barycenters can use a finite value.
|
||||
for (const level of orderedLevels) {
|
||||
const list = nodesByLevel.get(level)!;
|
||||
list.sort((a, b) => inputRank(a) - inputRank(b));
|
||||
let yCursor = options.margin;
|
||||
for (const node of list) {
|
||||
node.x = options.margin + level * (options.nodeWidth + options.columnGap);
|
||||
node.y = yCursor;
|
||||
yCursor += node.displayHeight + options.laneGap;
|
||||
}
|
||||
}
|
||||
|
||||
function packLevel(level: number, anchorOf: (n: GraphNode) => number): void {
|
||||
const list = nodesByLevel.get(level)!;
|
||||
const sorted = Array.from(list).sort((a, b) => anchorOf(a) - anchorOf(b) || inputRank(a) - inputRank(b));
|
||||
// Pack tight to top after sorting. Using barycenter only for order (not Y) keeps terminal
|
||||
// nodes like build-image close to the top of their column instead of being pulled down to
|
||||
// the mean Y of their parents — matching GitHub Actions' compact layout.
|
||||
let prevBottom = options.margin - options.laneGap;
|
||||
for (const node of sorted) {
|
||||
node.y = prevBottom + options.laneGap;
|
||||
prevBottom = boxBottom(node);
|
||||
}
|
||||
nodesByLevel.set(level, sorted);
|
||||
}
|
||||
|
||||
function meanCenterOf(ids: string[]): number | null {
|
||||
if (ids.length === 0) return null;
|
||||
let sum = 0;
|
||||
for (const id of ids) sum += boxCenterY(nodesById.get(id)!);
|
||||
return sum / ids.length;
|
||||
}
|
||||
|
||||
// Down-only barycenter pass: each child is anchored to the mean Y of its parents. Roots
|
||||
// keep their initial yaml-declaration order (via inputRank), matching how GitHub Actions
|
||||
// arranges root jobs. This produces a "main chain on top" layout where job-100 → job-101 →
|
||||
// job-102 stays on a straight horizontal line.
|
||||
for (const level of orderedLevels) {
|
||||
if (level === 0) continue;
|
||||
packLevel(level, (node) => meanCenterOf(incomingByNodeId.get(node.id) || []) ?? boxCenterY(node));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-edge connector: source stub → cubic-bezier corner down/up to column midpoint →
|
||||
// vertical run → cubic-bezier corner back to horizontal → target stub. The corner radius is
|
||||
// fixed (not clamped to the row delta) so any two edges sharing the same source produce the
|
||||
// same source-side path and overlap into a single visual line until they diverge at the V.
|
||||
const cornerRadius = 12;
|
||||
|
||||
function connectorPath(sx: number, sy: number, ex: number, ey: number, options: WorkflowGraphLayoutOptions): string {
|
||||
if (Math.abs(sy - ey) < 0.5) return `M ${sx} ${sy} H ${ex}`;
|
||||
// Anchor the V segment in the column gap immediately before the target instead of the
|
||||
// horizontal midpoint. The long H stays at the source's Y, matching GitHub Actions' style
|
||||
// — a multi-column edge runs along the source row across intermediate columns, then turns
|
||||
// up/down only when it reaches the target column.
|
||||
const midX = Math.max(ex - options.columnGap / 2, (sx + ex) / 2);
|
||||
const dy = ey > sy ? 1 : -1;
|
||||
// Keep the same H prefix to `midX - cornerRadius` for every edge so that edges sharing a
|
||||
// source overlap visually until they fork. When there isn't 2*cornerRadius of vertical
|
||||
// room for the V segment, emit a single S-curve between (midX - r, sy) and (midX + r, ey)
|
||||
// instead of a backward V kink.
|
||||
if (Math.abs(ey - sy) < cornerRadius * 2) {
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`H ${midX - cornerRadius}`,
|
||||
`C ${midX} ${sy} ${midX} ${ey} ${midX + cornerRadius} ${ey}`,
|
||||
`H ${ex}`,
|
||||
].join(' ');
|
||||
}
|
||||
const half = cornerRadius / 2;
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`H ${midX - cornerRadius}`,
|
||||
`C ${midX - half} ${sy} ${midX} ${sy + half * dy} ${midX} ${sy + cornerRadius * dy}`,
|
||||
`V ${ey - cornerRadius * dy}`,
|
||||
`C ${midX} ${ey - half * dy} ${midX + half} ${ey} ${midX + cornerRadius} ${ey}`,
|
||||
`H ${ex}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildRoutedEdges(
|
||||
nodesById: Map<string, GraphNode>,
|
||||
edges: Edge[],
|
||||
options: WorkflowGraphLayoutOptions,
|
||||
): Pick<WorkflowGraphModel, 'routedEdges' | 'sharedSegments'> {
|
||||
const routedEdges: RoutedEdge[] = [];
|
||||
for (const edge of edges) {
|
||||
const fromNode = nodesById.get(edge.fromId);
|
||||
const toNode = nodesById.get(edge.toId);
|
||||
if (!fromNode || !toNode) continue;
|
||||
const startX = fromNode.x + options.nodeWidth;
|
||||
const endX = toNode.x;
|
||||
const startY = boxCenterY(fromNode);
|
||||
const endY = boxCenterY(toNode);
|
||||
routedEdges.push({...edge, fromNode, toNode, path: connectorPath(startX, startY, endX, endY, options)});
|
||||
}
|
||||
|
||||
return {routedEdges, sharedSegments: []};
|
||||
}
|
||||
|
||||
export function createWorkflowGraphModel(
|
||||
jobs: ActionsJob[],
|
||||
expandedMatrixNodeIds: ReadonlySet<string> = new Set(),
|
||||
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
|
||||
): WorkflowGraphModel {
|
||||
const options = {...defaultLayoutOptions, ...partialOptions};
|
||||
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixNodeIds, options);
|
||||
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const adjacency = buildNodeAdjacency(edges);
|
||||
assignNodeLevels(nodes, adjacency);
|
||||
assignNodeCoordinates(nodesById, nodes, adjacency, options);
|
||||
return {nodes, edges, ...buildRoutedEdges(nodesById, edges, options), adjacency};
|
||||
}
|
||||
|
||||
export function getWorkflowGraphLayoutOptions(partialOptions: Partial<WorkflowGraphLayoutOptions> = {}): WorkflowGraphLayoutOptions {
|
||||
return {...defaultLayoutOptions, ...partialOptions};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>;
|
||||
const frontendPlugins: Record<string, LazyLoadFunc> = {
|
||||
'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'),
|
||||
'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'),
|
||||
'asciicast': () => import('./render/plugins/frontend-asciicast.ts'),
|
||||
};
|
||||
|
||||
class Options implements FrontendRenderOptions {
|
||||
@@ -44,23 +45,28 @@ async function initFrontendExternalRender() {
|
||||
const viewerContainer = document.querySelector<HTMLElement>('#frontend-render-viewer')!;
|
||||
const renderNames = viewerContainer.getAttribute('data-frontend-renders')!.split(' ');
|
||||
const fileTreePath = viewerContainer.getAttribute('data-file-tree-path')!;
|
||||
viewerContainer.setAttribute('data-window-origin', window.origin); // mainly for testing purpose
|
||||
|
||||
const fileDataElem = document.querySelector<HTMLTextAreaElement>('#frontend-render-data')!;
|
||||
fileDataElem.remove();
|
||||
const fileDataContent = fileDataElem.value;
|
||||
const fileDataEncoding = fileDataElem.getAttribute('data-content-encoding')!;
|
||||
const opts = new Options(viewerContainer, fileTreePath, fileDataEncoding, fileDataContent);
|
||||
|
||||
let found = false;
|
||||
let renderName = '', rendered = false;
|
||||
for (const name of renderNames) {
|
||||
if (!(name in frontendPlugins)) continue;
|
||||
const plugin = await frontendPlugins[name]();
|
||||
found = true;
|
||||
if (await plugin.frontendRender(opts)) break;
|
||||
renderName = name;
|
||||
rendered = await plugin.frontendRender(opts);
|
||||
if (rendered) break;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
if (!renderName) {
|
||||
viewerContainer.textContent = 'No frontend render plugin found for this file, but backend declares that there must be one, there must be a bug';
|
||||
} else if (!rendered) {
|
||||
viewerContainer.textContent = `Failed to render by ${renderName}`;
|
||||
} else {
|
||||
viewerContainer.setAttribute('data-frontend-render-name', renderName); // succeeded render, mainly for testing purpose
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './external-render-helper.ts';
|
||||
|
||||
test('isValidCssColor', async () => {
|
||||
const isValidCssColor = window.testModules.externalRenderHelper!.isValidCssColor;
|
||||
const isValidCssColor = window.giteaExternalRenderHelper!.isValidCssColor;
|
||||
expect(isValidCssColor(null)).toBe(false);
|
||||
expect(isValidCssColor('')).toBe(false);
|
||||
|
||||
|
||||
@@ -50,12 +50,12 @@ body { background: ${backgroundColor}; }
|
||||
}
|
||||
|
||||
const iframeId = queryParams.get('gitea-iframe-id');
|
||||
if (iframeId) {
|
||||
// iframe is in different origin, so we need to use postMessage to communicate
|
||||
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
|
||||
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
|
||||
};
|
||||
// iframe is in different origin, so we need to use postMessage to communicate
|
||||
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
|
||||
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
|
||||
};
|
||||
|
||||
if (iframeId) {
|
||||
const updateIframeHeight = () => {
|
||||
if (!document.body) return; // the body might not be available when this function is called
|
||||
// Use scrollHeight to get the full content height, even when CSS sets html/body to height:100%
|
||||
@@ -90,6 +90,4 @@ if (iframeId) {
|
||||
});
|
||||
}
|
||||
|
||||
if (window.testModules) {
|
||||
window.testModules.externalRenderHelper = {isValidCssColor};
|
||||
}
|
||||
window.giteaExternalRenderHelper = {isValidCssColor, queryParams, postIframeMsg};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {checkAppUrl} from '../common-page.ts';
|
||||
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {pathEscape} from '../../utils/url.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
@@ -23,6 +24,33 @@ export function initAdminCommon(): void {
|
||||
initAdminUser();
|
||||
initAdminAuthentication();
|
||||
initAdminNotice();
|
||||
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
|
||||
}
|
||||
|
||||
function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
|
||||
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
|
||||
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
|
||||
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
|
||||
if (!selectAll) return;
|
||||
|
||||
const refresh = () => {
|
||||
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
||||
formRunnerIds.value = checked.map((c) => c.getAttribute('data-runner-id')!).join(',');
|
||||
toggleElem(toolbar, checked.length > 0);
|
||||
for (const btn of actionButtons) {
|
||||
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
||||
}
|
||||
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
|
||||
};
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
|
||||
refresh();
|
||||
});
|
||||
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function initAdminUser() {
|
||||
@@ -78,7 +106,7 @@ function initAdminAuthentication() {
|
||||
}
|
||||
|
||||
function onOAuth2Change(applyDefaultValues: boolean) {
|
||||
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
|
||||
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
|
||||
input.removeAttribute('required');
|
||||
}
|
||||
@@ -86,8 +114,10 @@ function initAdminAuthentication() {
|
||||
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
|
||||
switch (provider) {
|
||||
case 'openidConnect':
|
||||
case 'aws-cognito':
|
||||
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
|
||||
showElem('.open_id_connect_auto_discovery_url');
|
||||
showElem('.open_id_connect_external_id_claim');
|
||||
break;
|
||||
default: {
|
||||
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
|
||||
@@ -249,7 +279,7 @@ function initAdminNotice() {
|
||||
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
|
||||
const elModalDesc = detailModal.querySelector('.content pre')!;
|
||||
elModalDesc.textContent = elNoticeDesc.textContent;
|
||||
fomanticQuery(detailModal).modal('show');
|
||||
showFomanticModal(detailModal);
|
||||
}));
|
||||
|
||||
// Select actions
|
||||
@@ -285,6 +315,6 @@ function initAdminNotice() {
|
||||
}
|
||||
}
|
||||
await POST(this.getAttribute('data-link')!, {data});
|
||||
window.location.href = this.getAttribute('data-redirect')!;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ test('ConfigFormValueMapper', () => {
|
||||
mapper.fillFromSystemConfig();
|
||||
const formData = mapper.collectToFormData();
|
||||
const result: Record<string, string> = {};
|
||||
const keys = [], values = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
const keys: string[] = [], values: string[] = [];
|
||||
for (const [key, value] of formData) {
|
||||
if (key === 'key') keys.push(value as string);
|
||||
if (key === 'value') values.push(value as string);
|
||||
else if (key === 'value') values.push(value as string);
|
||||
}
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
result[keys[i]] = values[i];
|
||||
|
||||
@@ -2,7 +2,9 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {errorMessage} from '../../modules/errors.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
import {cutString} from '../../utils/string.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
@@ -26,7 +28,7 @@ function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
|
||||
const json: Record<string, any> = await resp.json();
|
||||
if (json.errorMessage) throw new Error(json.errorMessage);
|
||||
} catch (ex) {
|
||||
showTemporaryTooltip(el, ex.toString());
|
||||
showTemporaryTooltip(el, errorMessage(ex));
|
||||
el.checked = !el.checked;
|
||||
}
|
||||
});
|
||||
@@ -101,9 +103,7 @@ export class ConfigFormValueMapper {
|
||||
} else if (el.matches('[type="datetime-local"]')) {
|
||||
if (valType !== 'timestamp') requireExplicitValueType(el);
|
||||
if (val) el.value = toDatetimeLocalValue(val);
|
||||
} else if (el.matches('textarea')) {
|
||||
el.value = String(val ?? el.value);
|
||||
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
|
||||
} else if (el.matches('textarea') || (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text')) {
|
||||
el.value = String(val ?? el.value);
|
||||
} else {
|
||||
unsupportedElement(el);
|
||||
@@ -122,9 +122,7 @@ export class ConfigFormValueMapper {
|
||||
} else if (el.matches('[type="datetime-local"]')) {
|
||||
if (valType !== 'timestamp') requireExplicitValueType(el);
|
||||
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
|
||||
} else if (el.matches('textarea')) {
|
||||
val = el.value;
|
||||
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
|
||||
} else if (el.matches('textarea') || (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text')) {
|
||||
val = el.value;
|
||||
} else {
|
||||
unsupportedElement(el);
|
||||
@@ -161,7 +159,7 @@ export class ConfigFormValueMapper {
|
||||
const apps: Array<{DisplayName: string, OpenURL: string}> = [];
|
||||
const lines = cfgVal.split('\n');
|
||||
for (const line of lines) {
|
||||
let [displayName, openUrl] = line.split('=', 2);
|
||||
let [displayName, openUrl] = cutString(line, '=');
|
||||
displayName = displayName.trim();
|
||||
openUrl = openUrl?.trim() ?? '';
|
||||
if (!displayName || !openUrl) continue;
|
||||
@@ -177,7 +175,7 @@ export class ConfigFormValueMapper {
|
||||
|
||||
collectToFormData(): FormData {
|
||||
const namedElems: Array<GeneralFormFieldElement | null> = [];
|
||||
queryElems(this.form, '[name]', (el) => namedElems.push(el as GeneralFormFieldElement));
|
||||
queryElems(this.form, '[name]', (el) => { namedElems.push(el as GeneralFormFieldElement) });
|
||||
|
||||
// first, process the config options with sub values, for example:
|
||||
// merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar"
|
||||
|
||||
@@ -5,14 +5,14 @@ export function initAdminUserListSearchForm(): void {
|
||||
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
|
||||
if (!form) return;
|
||||
|
||||
for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) {
|
||||
for (const button of form.querySelectorAll(`button[name=sort][value="${CSS.escape(searchForm.SortType)}"]`)) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
if (searchForm.StatusFilterMap) {
|
||||
for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) {
|
||||
if (!v) continue;
|
||||
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
|
||||
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${CSS.escape(k)}]"][value="${CSS.escape(v)}"]`)) {
|
||||
input.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
|
||||
const {pageData} = window.config;
|
||||
@@ -46,7 +47,7 @@ export async function initCitationFileCopyContent() {
|
||||
try {
|
||||
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
|
||||
} catch (e) {
|
||||
console.error(`initCitationFileCopyContent error: ${e}`, e);
|
||||
console.error(`initCitationFileCopyContent error: ${errorMessage(e)}`, e);
|
||||
return;
|
||||
}
|
||||
updateUi();
|
||||
@@ -65,6 +66,6 @@ export async function initCitationFileCopyContent() {
|
||||
inputContent.select();
|
||||
});
|
||||
|
||||
fomanticQuery('#cite-repo-modal').modal('show');
|
||||
showFomanticModal(document.querySelector('#cite-repo-modal'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {clippie} from 'clippie';
|
||||
|
||||
const {copy_success, copy_error} = window.config.i18n;
|
||||
|
||||
// Enable clipboard copy from HTML attributes. These properties are supported:
|
||||
// - data-clipboard-text: Direct text to copy
|
||||
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
|
||||
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
|
||||
export function initGlobalCopyToClipboardListener() {
|
||||
document.addEventListener('click', async (e) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-clipboard-text], [data-clipboard-target]');
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let text = target.getAttribute('data-clipboard-text');
|
||||
if (text === null) {
|
||||
const textSelector = target.getAttribute('data-clipboard-target')!;
|
||||
const textTarget = document.querySelector(textSelector)!;
|
||||
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
|
||||
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
|
||||
} else if (textTarget.nodeName === 'DIV') {
|
||||
text = textTarget.textContent;
|
||||
} else {
|
||||
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {
|
||||
text = toAbsoluteUrl(text);
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const success = await clippie(text);
|
||||
showTemporaryTooltip(target, success ? copy_success : copy_error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import {assignElementProperty, type ElementWithAssignableProperties} from './com
|
||||
|
||||
test('assignElementProperty', () => {
|
||||
const elForm = document.createElement('form');
|
||||
assignElementProperty(elForm, 'action', '/test-link');
|
||||
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
|
||||
expect(elForm.getAttribute('action')).eq('/test-link');
|
||||
expect(() => assignElementProperty(elForm, 'action', '/test-link')).toThrow();
|
||||
assignElementProperty(elForm, 'url', '/test-url');
|
||||
expect(elForm.action).contains('/test-url'); // the DOM always returns absolute URL
|
||||
expect(elForm.getAttribute('action')).eq('/test-url');
|
||||
assignElementProperty(elForm, 'text-content', 'dummy');
|
||||
expect(elForm.textContent).toBe('dummy');
|
||||
|
||||
@@ -14,8 +15,9 @@ test('assignElementProperty', () => {
|
||||
_attrs: Record<string, string> = {};
|
||||
setAttribute(name: string, value: string) { this._attrs[name] = value }
|
||||
getAttribute(name: string): string | null { return this._attrs[name] }
|
||||
nodeName = 'FORM';
|
||||
}();
|
||||
assignElementProperty(elFormWithAction, 'action', '/bar');
|
||||
assignElementProperty(elFormWithAction, 'url', '/bar');
|
||||
expect(elFormWithAction.getAttribute('action')).eq('/bar');
|
||||
|
||||
const elInput = document.createElement('input');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {camelize} from 'vue';
|
||||
import {applyAutoFocus} from './common-page.ts';
|
||||
|
||||
@@ -13,74 +12,6 @@ export function initGlobalButtonClickOnEnter(): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function initGlobalDeleteButton(): void {
|
||||
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
|
||||
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
|
||||
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
|
||||
// If there is no form, then the data will be posted to `data-url`.
|
||||
// TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
|
||||
// FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
|
||||
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
|
||||
const dataObj = btn.dataset;
|
||||
|
||||
const modalId = btn.getAttribute('data-modal-id');
|
||||
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
|
||||
|
||||
// set the modal "display name" by `data-name`
|
||||
const modalNameEl = modal.querySelector('.name');
|
||||
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
|
||||
|
||||
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
|
||||
for (const [key, value] of Object.entries(dataObj)) {
|
||||
if (key.startsWith('data')) {
|
||||
const textEl = modal.querySelector(`.${key}`);
|
||||
if (textEl) textEl.textContent = value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
fomanticQuery(modal).modal({
|
||||
closable: false,
|
||||
onApprove: () => {
|
||||
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
|
||||
if (btn.getAttribute('data-type') === 'form') {
|
||||
const formSelector = btn.getAttribute('data-form')!;
|
||||
const form = document.querySelector<HTMLFormElement>(formSelector);
|
||||
if (!form) throw new Error(`no form named ${formSelector} found`);
|
||||
modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
|
||||
form.classList.add('is-loading');
|
||||
form.submit();
|
||||
return false; // prevent modal from closing automatically
|
||||
}
|
||||
|
||||
// prepare an AJAX form by data attributes
|
||||
const postData = new FormData();
|
||||
for (const [key, value] of Object.entries(dataObj)) {
|
||||
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
|
||||
postData.append(key.slice(4), String(value));
|
||||
}
|
||||
if (key === 'id') { // for data-id="..."
|
||||
postData.append('id', String(value));
|
||||
}
|
||||
}
|
||||
(async () => {
|
||||
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
window.location.href = data.redirect;
|
||||
}
|
||||
})();
|
||||
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
|
||||
return false; // prevent modal from closing automatically
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
|
||||
// a '.show-panel' element can show a panel, by `data-panel="selector"`
|
||||
// if it has "toggle" class, it toggles the panel
|
||||
@@ -111,11 +42,20 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
|
||||
}
|
||||
|
||||
export type ElementWithAssignableProperties = {
|
||||
nodeName: string;
|
||||
getAttribute: (name: string) => string | null;
|
||||
setAttribute: (name: string, value: string) => void;
|
||||
} & Record<string, any>;
|
||||
|
||||
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
|
||||
if (el.nodeName === 'FORM') {
|
||||
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: a special case for Golang HTML template escaping.
|
||||
// Golang HTML template only handles some "known" attribute names as URL (e.g.: when the name is "action" or contains "url")
|
||||
// To prevent template developers from making mistakes like `data-modal-form.action="?k={{ValueWithSpecialChars}}" (no escaping),
|
||||
// here we use `data-modal-form.url="?k={{ValueWithSpecialChars}}", then the value gets correctly escaped by Golang HTML template.
|
||||
if (kebabName === 'action') throw new Error(`don't assign element property "action" by value, use "data-modal-form.url" instead`);
|
||||
if (kebabName === 'url') kebabName = 'action';
|
||||
}
|
||||
const camelizedName = camelize(kebabName);
|
||||
const old = el[camelizedName];
|
||||
if (typeof old === 'boolean') {
|
||||
@@ -128,7 +68,7 @@ export function assignElementProperty(el: ElementWithAssignableProperties, kebab
|
||||
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
|
||||
el.setAttribute(kebabName, val);
|
||||
} else {
|
||||
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
|
||||
// in the future, maybe we could introduce a better typing system if it is really needed
|
||||
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
|
||||
}
|
||||
}
|
||||
@@ -140,7 +80,11 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
||||
// * Then, try to query '[name=target]'
|
||||
// * Then, try to query '.target'
|
||||
// * Then, try to query 'target' as HTML tag
|
||||
// If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
|
||||
// If there's a ".{prop-name}" part like "data-modal-input.value", the "input" element's "value" property will be set,
|
||||
// the "prop-name" will be camel-cased to "propName" (e.g.: "data-modal-input.read-only" for "readOnly" property).
|
||||
//
|
||||
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: Form element's "action" property must be set by "data-modal-form.url"
|
||||
// to make the template variables get correctly escaped in the URL.
|
||||
e.preventDefault();
|
||||
const modalSelector = el.getAttribute('data-modal')!;
|
||||
const elModal = document.querySelector(modalSelector);
|
||||
@@ -156,10 +100,10 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
||||
const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
|
||||
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag", and then try the modal itself
|
||||
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
|
||||
elModal.querySelector(`[name=${attrTargetName}]`) ||
|
||||
elModal.querySelector(`[name=${CSS.escape(attrTargetName)}]`) ||
|
||||
elModal.querySelector(`.${attrTargetName}`) ||
|
||||
elModal.querySelector(`${attrTargetName}`) ||
|
||||
(elModal.matches(`${attrTargetName}`) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
|
||||
elModal.querySelector(attrTargetName) ||
|
||||
(elModal.matches(attrTargetName) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
|
||||
if (!attrTarget) {
|
||||
if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
|
||||
continue;
|
||||
@@ -174,7 +118,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
fomanticQuery(elModal).modal('show');
|
||||
showFomanticModal(elModal);
|
||||
}
|
||||
|
||||
export function initGlobalButtons(): void {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {execPseudoSelectorCommands, handleFetchActionSuccessJson} from './common-fetch-action.ts';
|
||||
|
||||
test('execPseudoSelectorCommands', () => {
|
||||
window.document.body.innerHTML = `
|
||||
<div id="d1">
|
||||
<ul id="u1">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
<ul id="u2">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="d2">
|
||||
<ul id="u3">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
</div>`;
|
||||
|
||||
let ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '');
|
||||
expect(ret.targets).toEqual([document.querySelector('#u1')]);
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$this');
|
||||
expect(ret.targets).toEqual([document.querySelector('#u1')]);
|
||||
expect(ret.cmdInnerHTML).toBeFalsy();
|
||||
expect(ret.cmdMorph).toBeFalsy();
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body $morph $innerHTML');
|
||||
expect(ret.targets).toEqual([document.body]);
|
||||
expect(ret.cmdInnerHTML).toBeTruthy();
|
||||
expect(ret.cmdMorph).toBeTruthy();
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body .x');
|
||||
expect(ret.targets.length).toEqual(3);
|
||||
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('.x')));
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1 .x')!, '$closest(div) .x');
|
||||
expect(ret.targets.length).toEqual(2);
|
||||
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
|
||||
});
|
||||
|
||||
test('handleFetchActionSuccessJson', async () => {
|
||||
const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {});
|
||||
const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: '/'});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(1);
|
||||
expect(spyReload).toHaveBeenCalledTimes(0);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: ''});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -1,89 +1,161 @@
|
||||
import {request} from '../modules/fetch.ts';
|
||||
import {GET, request} from '../modules/fetch.ts';
|
||||
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
|
||||
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
|
||||
import {errorMessage, errorName} from '../modules/errors.ts';
|
||||
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
|
||||
import type {RequestOpts} from '../types.ts';
|
||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {Idiomorph} from 'idiomorph';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
const {appSubUrl, runModeIsProd} = window.config;
|
||||
|
||||
type FetchActionOpts = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
body?: FormData;
|
||||
formSubmitter?: HTMLElement | null;
|
||||
|
||||
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
|
||||
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
|
||||
successSync: string;
|
||||
|
||||
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||
// empty means no loading indicator, "$this" means the element itself
|
||||
loadingIndicator: string;
|
||||
};
|
||||
|
||||
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
|
||||
// more details are in the backend's fetch-redirect handler
|
||||
function fetchActionDoRedirect(redirect: string) {
|
||||
const form = document.createElement('form');
|
||||
const input = document.createElement('input');
|
||||
form.method = 'post';
|
||||
form.action = `${appSubUrl}/-/fetch-redirect`;
|
||||
input.type = 'hidden';
|
||||
input.name = 'redirect';
|
||||
input.value = redirect;
|
||||
form.append(input);
|
||||
// In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
|
||||
// Otherwise, need to use backend to do redirection:
|
||||
// * Also do so in development, to make sure the redirection logic is always tested by real users
|
||||
const needBackendHelp = redirect.includes('#');
|
||||
if (runModeIsProd && !needBackendHelp) {
|
||||
window.location.assign(redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
// use backend to do redirection, which can bypass the browser's limitations of "location"
|
||||
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
|
||||
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
|
||||
const showErrorForResponse = (code: number, message: string) => {
|
||||
showErrorToast(`Error ${code || 'request'}: ${message}`);
|
||||
};
|
||||
|
||||
let respStatus = 0;
|
||||
let respText = '';
|
||||
try {
|
||||
hideToastsAll();
|
||||
const resp = await request(url, opt);
|
||||
respStatus = resp.status;
|
||||
respText = await resp.text();
|
||||
const respJson = JSON.parse(respText);
|
||||
if (respStatus === 200) {
|
||||
let {redirect} = respJson;
|
||||
redirect = redirect || actionElem.getAttribute('data-redirect');
|
||||
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
|
||||
if (redirect) {
|
||||
fetchActionDoRedirect(redirect);
|
||||
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
|
||||
const loadingIndicatorElems = opt.loadingIndicator ? execPseudoSelectorCommands(el, opt.loadingIndicator).targets : [];
|
||||
for (const indicatorEl of loadingIndicatorElems) {
|
||||
if (isLoading) {
|
||||
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = true;
|
||||
} else {
|
||||
window.location.reload();
|
||||
indicatorEl.classList.add('is-loading');
|
||||
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
|
||||
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
|
||||
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
|
||||
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
|
||||
} else {
|
||||
showErrorForResponse(respStatus, respText);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'SyntaxError') {
|
||||
showErrorForResponse(respStatus, (respText || '').substring(0, 100));
|
||||
} else if (e.name !== 'AbortError') {
|
||||
console.error('fetchActionDoRequest error', e);
|
||||
showErrorForResponse(respStatus, `${e}`);
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = false;
|
||||
} else {
|
||||
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
}
|
||||
}
|
||||
actionElem.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
|
||||
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
|
||||
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
||||
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
||||
const redirect = respJson?.redirect;
|
||||
if (typeof redirect === 'string' && redirect) {
|
||||
fetchActionDoRedirect(redirect);
|
||||
} else {
|
||||
// reserved behavior, in the future, there can be more fields to introduce more behaviors
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
|
||||
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
|
||||
const respText = await resp.text();
|
||||
const respJson = isRespJson ? JSON.parse(respText) : null;
|
||||
if (isRespJson) {
|
||||
await handleFetchActionSuccessJson(el, respJson);
|
||||
} else if (opt.successSync) {
|
||||
await handleFetchActionSuccessSync(el, opt.successSync, respText);
|
||||
} else {
|
||||
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchActionError(resp: Response) {
|
||||
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
|
||||
const respText = await resp.text();
|
||||
const respJson = isRespJson ? JSON.parse(respText) : null;
|
||||
if (respJson?.errorMessage) {
|
||||
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
|
||||
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
|
||||
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
|
||||
} else {
|
||||
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
|
||||
let url = opt.url;
|
||||
if ('name' in el && 'value' in el) {
|
||||
// ref: https://htmx.org/attributes/hx-get/
|
||||
// If the element with the hx-get attribute also has a value, this will be included as a parameter
|
||||
const name = (el as HTMLInputElement).name;
|
||||
const val = (el as HTMLInputElement).value;
|
||||
const u = new URL(url, window.location.href);
|
||||
if (name && !u.searchParams.has(name)) {
|
||||
u.searchParams.set(name, val);
|
||||
url = u.href;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
|
||||
const attrIsLoading = 'data-fetch-is-loading';
|
||||
if (el.getAttribute(attrIsLoading)) return;
|
||||
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
|
||||
|
||||
el.setAttribute(attrIsLoading, 'true');
|
||||
toggleLoadingIndicator(el, opt, true);
|
||||
|
||||
try {
|
||||
const url = buildFetchActionUrl(el, opt);
|
||||
const headers = new Headers(opt.headers);
|
||||
headers.set('X-Gitea-Fetch-Action', '1');
|
||||
const resp = await request(url, {method: opt.method, body: opt.body, headers});
|
||||
if (resp.ok) {
|
||||
await handleFetchActionSuccess(el, opt, resp);
|
||||
return;
|
||||
}
|
||||
await handleFetchActionError(resp);
|
||||
} catch (err) {
|
||||
if (errorName(err) !== 'AbortError') {
|
||||
console.error(`Fetch action request error:`, err);
|
||||
showErrorToast(`Error: ${errorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
toggleLoadingIndicator(el, opt, false);
|
||||
el.removeAttribute(attrIsLoading);
|
||||
}
|
||||
}
|
||||
|
||||
type SubmitFormFetchActionOpts = {
|
||||
formSubmitter?: HTMLElement;
|
||||
formSubmitter?: HTMLElement | null;
|
||||
formData?: FormData;
|
||||
};
|
||||
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
|
||||
if (formEl.classList.contains('is-loading')) return;
|
||||
|
||||
formEl.classList.add('is-loading');
|
||||
if (formEl.clientHeight < 50) {
|
||||
formEl.classList.add('loading-icon-2px');
|
||||
}
|
||||
|
||||
const formMethod = formEl.getAttribute('method') || 'get';
|
||||
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
|
||||
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
|
||||
const formActionUrl = formEl.getAttribute('action') || window.location.href;
|
||||
const formData = opts.formData ?? new FormData(formEl);
|
||||
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
|
||||
@@ -92,11 +164,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
|
||||
}
|
||||
|
||||
let reqUrl = formActionUrl;
|
||||
const reqOpt = {
|
||||
method: formMethod.toUpperCase(),
|
||||
body: null as FormData | null,
|
||||
};
|
||||
if (formMethod.toLowerCase() === 'get') {
|
||||
let reqBody: FormData | undefined;
|
||||
if (formMethodUpper === 'GET') {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of formData) {
|
||||
params.append(key, value as string);
|
||||
@@ -107,25 +176,24 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
|
||||
}
|
||||
reqUrl += `?${params.toString()}`;
|
||||
} else {
|
||||
reqOpt.body = formData;
|
||||
reqBody = formData;
|
||||
}
|
||||
|
||||
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
|
||||
return {
|
||||
method: formMethodUpper,
|
||||
url: reqUrl,
|
||||
body: reqBody,
|
||||
formSubmitter: opts.formSubmitter,
|
||||
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
|
||||
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
|
||||
};
|
||||
}
|
||||
|
||||
async function onLinkActionClick(el: HTMLElement, e: Event) {
|
||||
// A "link-action" can post AJAX request to its "data-url"
|
||||
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
|
||||
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
|
||||
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
|
||||
e.preventDefault();
|
||||
const url = el.getAttribute('data-url')!;
|
||||
const doRequest = async () => {
|
||||
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
|
||||
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
|
||||
if ('disabled' in el) el.disabled = false;
|
||||
};
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
|
||||
hideToastsAll();
|
||||
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
|
||||
}
|
||||
|
||||
async function confirmFetchAction(el: HTMLElement) {
|
||||
let elModal: HTMLElement | null = null;
|
||||
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
|
||||
if (dataModalConfirm.startsWith('#')) {
|
||||
@@ -147,18 +215,204 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!elModal) return true;
|
||||
return await confirmModal(elModal);
|
||||
}
|
||||
|
||||
if (!elModal) {
|
||||
await doRequest();
|
||||
async function performLinkFetchAction(el: HTMLElement) {
|
||||
hideToastsAll();
|
||||
await performActionRequest(el, {
|
||||
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
|
||||
url: el.getAttribute('data-url')!,
|
||||
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
|
||||
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
|
||||
});
|
||||
}
|
||||
|
||||
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
|
||||
|
||||
export async function performFetchActionTrigger(el: HTMLElement, triggerType: FetchActionTriggerType) {
|
||||
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
|
||||
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
|
||||
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
|
||||
|
||||
if (isUserInitiated) hideToastsAll();
|
||||
await performActionRequest(el, {
|
||||
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
|
||||
url: el.getAttribute('data-fetch-url')!,
|
||||
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
|
||||
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
|
||||
});
|
||||
}
|
||||
|
||||
type PseudoSelectorCommandResult = {
|
||||
targets: Element[];
|
||||
cmdInnerHTML: boolean;
|
||||
cmdMorph: boolean;
|
||||
};
|
||||
|
||||
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
|
||||
const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
|
||||
let targets = [el], cmdInnerHTML = false, cmdMorph = false;
|
||||
for (const cmd of cmds) {
|
||||
if (cmd === '$this') {
|
||||
targets = [el];
|
||||
} else if (cmd === '$body') {
|
||||
targets = [document.body];
|
||||
} else if (cmd === '$innerHTML') {
|
||||
cmdInnerHTML = true;
|
||||
} else if (cmd === '$morph') {
|
||||
cmdMorph = true;
|
||||
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
|
||||
const selector = cmd.substring('$closest('.length, cmd.length - 1);
|
||||
const newTargets: Element[] = [];
|
||||
for (const target of targets) {
|
||||
const closest = target.closest(selector);
|
||||
if (closest) newTargets.push(closest);
|
||||
}
|
||||
targets = newTargets;
|
||||
} else {
|
||||
const newTargets: Element[] = [];
|
||||
for (const target of targets) {
|
||||
newTargets.push(...target.querySelectorAll(cmd));
|
||||
}
|
||||
targets = newTargets;
|
||||
}
|
||||
}
|
||||
return {targets, cmdInnerHTML, cmdMorph};
|
||||
}
|
||||
|
||||
async function handleFetchActionSuccessSync(el: Element, successSync: string, respText: string) {
|
||||
const res = execPseudoSelectorCommands(el, successSync);
|
||||
if (!res.targets.length) throw new Error(`Fetch-sync command "${successSync}" did not find any target element to update`);
|
||||
if (res.targets.length > 1) throw new Error(`Fetch-sync command "${successSync}" found multiple target elements, which is not supported`);
|
||||
const target = res.targets[0];
|
||||
if (res.cmdMorph) {
|
||||
Idiomorph.morph(target, respText, {morphStyle: res.cmdInnerHTML ? 'innerHTML' : 'outerHTML'});
|
||||
} else if (res.cmdInnerHTML) {
|
||||
target.innerHTML = respText;
|
||||
} else {
|
||||
target.outerHTML = respText;
|
||||
}
|
||||
await fetchActionReloadOutdatedElements();
|
||||
}
|
||||
|
||||
async function fetchActionReloadOutdatedElements() {
|
||||
const outdatedElems: HTMLElement[] = [];
|
||||
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
|
||||
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
|
||||
outdatedElems.push(outdated);
|
||||
}
|
||||
if (!outdatedElems.length) return;
|
||||
|
||||
const resp = await GET(window.location.href);
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
const newPageHtml = await resp.text();
|
||||
const newPageDom = parseDom(newPageHtml, 'text/html');
|
||||
for (const oldEl of outdatedElems) {
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
const newEl = newPageDom.getElementById(oldEl.id);
|
||||
if (newEl) {
|
||||
oldEl.replaceWith(newEl);
|
||||
} else {
|
||||
oldEl.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (await confirmModal(elModal)) {
|
||||
await doRequest();
|
||||
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
|
||||
const interval = trigger.substring('every '.length);
|
||||
const match = /^(\d+)(ms|s)$/.exec(interval);
|
||||
if (!match) throw new Error(`Invalid interval format: ${interval}`);
|
||||
|
||||
const num = parseInt(match[1], 10), unit = match[2];
|
||||
const intervalMs = unit === 's' ? num * 1000 : num;
|
||||
const fn = async () => {
|
||||
try {
|
||||
await performFetchActionTrigger(el, 'every');
|
||||
} finally {
|
||||
// only continue if the element is still in the document
|
||||
if (document.contains(el)) {
|
||||
setTimeout(fn, intervalMs);
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(fn, intervalMs);
|
||||
}
|
||||
|
||||
function initFetchActionTrigger(el: HTMLElement) {
|
||||
const trigger = el.getAttribute('data-fetch-trigger');
|
||||
|
||||
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
|
||||
if (trigger === 'fetch-reload') return;
|
||||
|
||||
if (trigger === 'load') {
|
||||
performFetchActionTrigger(el, trigger);
|
||||
} else if (trigger === 'change') {
|
||||
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
|
||||
} else if (trigger?.startsWith('every ')) {
|
||||
initFetchActionTriggerEvery(el, trigger);
|
||||
} else if (!trigger || trigger === 'click') {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
performFetchActionTrigger(el, 'click');
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported fetch trigger: ${trigger}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function initGlobalFetchAction() {
|
||||
addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
|
||||
addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
|
||||
// The "fetch-action" framework is a general approach for elements to trigger fetch requests:
|
||||
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
|
||||
//
|
||||
// If you need more fine-grained control more details, sometimes it's clearer to write the logic in JavaScript, instead of using this generic framework.
|
||||
//
|
||||
// Attributes:
|
||||
//
|
||||
// * data-fetch-method: the HTTP method to use
|
||||
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
|
||||
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
|
||||
//
|
||||
// * data-fetch-url: the URL for the request
|
||||
//
|
||||
// * data-fetch-trigger: the event to trigger the fetch action, can be:
|
||||
// * "click", "change" (user-initiated events)
|
||||
// * "load" (triggered on page load)
|
||||
// * "every 5s" (also support "ms" unit)
|
||||
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
|
||||
//
|
||||
// * data-fetch-indicator: the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||
//
|
||||
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
|
||||
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
|
||||
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
|
||||
// * "$this": replace the current element with the response
|
||||
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
|
||||
// * "$morph": use morph algorithm to update the target element
|
||||
// * "$body #the-id .the-class": query the selector one by one from body
|
||||
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
|
||||
//
|
||||
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
|
||||
// * it can be a string for the content of the modal dialog
|
||||
// * it has "-header" and "-content" variants to set the header and content of the "confirm modal"
|
||||
// * it can refer an existing modal element by "#the-modal-id"
|
||||
|
||||
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.form-fetch-action', async (el, e) => {
|
||||
// "fetch-action" will use the form's data to send the request
|
||||
e.preventDefault();
|
||||
await submitFormFetchAction(el, {formSubmitter: e.submitter});
|
||||
});
|
||||
|
||||
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
|
||||
// `<a class="link-action" data-url="...">` is a shorthand for
|
||||
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="$this">`
|
||||
e.preventDefault();
|
||||
await performLinkFetchAction(el);
|
||||
});
|
||||
|
||||
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
|
||||
}
|
||||
|
||||
@@ -16,17 +16,13 @@ export function initGlobalEnterQuickSubmit() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.key !== 'Enter') return;
|
||||
const el = e.target as HTMLElement;
|
||||
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
|
||||
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
|
||||
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if ((e.target as HTMLElement).matches('input') && !(e.target as HTMLElement).closest('form')) {
|
||||
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
|
||||
// eslint-disable-next-line unicorn/no-lonely-if
|
||||
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const isCtrlEnterInTextarea = hasCtrlOrMeta && el.matches('textarea');
|
||||
// an input in a normal form could handle Enter key by default, so we only handle the input outside a form
|
||||
const isEnterInBareInput = el.matches('input') && !el.closest('form');
|
||||
if ((isCtrlEnterInTextarea || isEnterInBareInput) && handleGlobalEnterQuickSubmit(el)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function initCommonIssueListQuickGoto() {
|
||||
const repoLink = elGotoButton.getAttribute('data-repo-link') || '';
|
||||
|
||||
elGotoButton.addEventListener('click', () => {
|
||||
window.location.href = elGotoButton.getAttribute('data-issue-goto-link')!;
|
||||
window.location.assign(elGotoButton.getAttribute('data-issue-goto-link')!);
|
||||
});
|
||||
|
||||
const onInput = async () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showGlobalErrorMessage} from '../modules/errors.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {initTabSwitcher} from '../modules/fomantic/tab.ts';
|
||||
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
|
||||
import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts';
|
||||
import {initScopedWorkflowRequired} from './comp/ScopedWorkflows.ts';
|
||||
|
||||
const {appUrl, appSubUrl} = window.config;
|
||||
|
||||
@@ -100,9 +102,10 @@ export function initGlobalDropdown() {
|
||||
}
|
||||
|
||||
export function initGlobalComponent() {
|
||||
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab();
|
||||
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
|
||||
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
|
||||
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
|
||||
registerGlobalInitFunc('initScopedWorkflowRequired', initScopedWorkflowRequired);
|
||||
}
|
||||
|
||||
// for performance considerations, it only uses performant syntax
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from './EditorMarkdown.ts';
|
||||
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
|
||||
import {createTippy} from '../../modules/tippy.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
|
||||
import type EasyMDE from 'easymde';
|
||||
import {localUserSettings} from '../../modules/user-settings.ts';
|
||||
|
||||
@@ -71,26 +71,26 @@ export class ComboMarkdownEditor {
|
||||
|
||||
options: ComboMarkdownEditorOptions;
|
||||
|
||||
tabEditor: HTMLElement;
|
||||
tabPreviewer: HTMLElement;
|
||||
tabEditor?: HTMLElement;
|
||||
tabPreviewer?: HTMLElement;
|
||||
|
||||
supportEasyMDE: boolean;
|
||||
supportEasyMDE!: boolean;
|
||||
easyMDE: any;
|
||||
easyMDEToolbarActions: any;
|
||||
easyMDEToolbarDefault: any;
|
||||
|
||||
textarea: ComboMarkdownEditorTextarea;
|
||||
textareaMarkdownToolbar: HTMLElement;
|
||||
textarea!: ComboMarkdownEditorTextarea;
|
||||
textareaMarkdownToolbar!: HTMLElement;
|
||||
textareaAutosize: any;
|
||||
|
||||
buttonMonospace: HTMLButtonElement;
|
||||
buttonMonospace!: HTMLButtonElement;
|
||||
|
||||
dropzone: HTMLElement | null;
|
||||
dropzone: HTMLElement | null = null;
|
||||
attachedDropzoneInst: any;
|
||||
|
||||
previewMode: string;
|
||||
previewUrl: string;
|
||||
previewContext: string;
|
||||
previewMode!: string;
|
||||
previewUrl!: string;
|
||||
previewContext!: string;
|
||||
|
||||
constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
|
||||
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
|
||||
@@ -204,22 +204,21 @@ export class ComboMarkdownEditor {
|
||||
}
|
||||
|
||||
setupTab() {
|
||||
const tabs = this.container.querySelectorAll<HTMLElement>('.tabular.menu > .item');
|
||||
if (!tabs.length) return;
|
||||
const elTabular = this.container.querySelector('.ui.tabular');
|
||||
if (!elTabular) return;
|
||||
this.tabEditor = this.container.querySelector('[data-tab-for="markdown-writer"]')!;
|
||||
this.tabPreviewer = this.container.querySelector('[data-tab-for="markdown-previewer"]')!;
|
||||
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
|
||||
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
|
||||
|
||||
// Fomantic Tab requires the "data-tab" to be globally unique.
|
||||
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
|
||||
const tabIdSuffix = generateElemId();
|
||||
const tabsArr = Array.from(tabs);
|
||||
this.tabEditor = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer')!;
|
||||
this.tabPreviewer = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer')!;
|
||||
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
|
||||
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
|
||||
|
||||
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
|
||||
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
|
||||
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
|
||||
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
|
||||
initTabSwitcher(elTabular);
|
||||
|
||||
this.tabEditor.addEventListener('click', () => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -227,8 +226,6 @@ export class ComboMarkdownEditor {
|
||||
});
|
||||
});
|
||||
|
||||
fomanticQuery(tabs).tab();
|
||||
|
||||
this.tabPreviewer.addEventListener('click', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('mode', this.previewMode);
|
||||
@@ -291,7 +288,7 @@ export class ComboMarkdownEditor {
|
||||
}
|
||||
|
||||
switchTabToEditor() {
|
||||
this.tabEditor.click();
|
||||
this.tabEditor!.click(); // when this function is called, the tab must exist
|
||||
}
|
||||
|
||||
prepareEasyMDEToolbarActions() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {svg} from '../../svg.ts';
|
||||
import {html, htmlRaw} from '../../utils/html.ts';
|
||||
import {createElementFromHTML} from '../../utils/dom.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {hideToastsAll} from '../../modules/toast.ts';
|
||||
|
||||
const {i18n} = window.config;
|
||||
@@ -32,15 +32,14 @@ export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<
|
||||
// it's fine to do so because the modal is triggered by user's explicit action, so the user should already have read the toast messages
|
||||
hideToastsAll();
|
||||
return new Promise((resolve) => {
|
||||
const $modal = fomanticQuery(modal);
|
||||
$modal.modal({
|
||||
showFomanticModal(modal, {
|
||||
onApprove() {
|
||||
resolve(true);
|
||||
},
|
||||
onHidden() {
|
||||
$modal.remove();
|
||||
modal.remove();
|
||||
resolve(false);
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import {subscribe} from '@github/paste-markdown';
|
||||
import type CodeMirror from 'codemirror';
|
||||
import type EasyMDE from 'easymde';
|
||||
import type {DropzoneFile} from 'dropzone';
|
||||
import type Dropzone from '@deltablot/dropzone';
|
||||
|
||||
let uploadIdCounter = 0;
|
||||
|
||||
@@ -31,7 +31,7 @@ function uploadFile(dropzoneEl: HTMLElement, file: File) {
|
||||
};
|
||||
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
|
||||
// FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time)
|
||||
dropzoneInst.addFile(file as DropzoneFile);
|
||||
dropzoneInst.addFile(file as Dropzone.DropzoneFile);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {toggleElem} from '../../utils/dom.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
|
||||
function nameHasScope(name: string): boolean {
|
||||
@@ -65,7 +65,7 @@ export function initCompLabelEdit(pageSelector: string) {
|
||||
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
|
||||
toggleElem(elIsArchivedField, isEdit);
|
||||
syncModalUi();
|
||||
fomanticQuery(elModal).modal({
|
||||
showFomanticModal(elModal, {
|
||||
onApprove() {
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
@@ -74,7 +74,7 @@ export function initCompLabelEdit(pageSelector: string) {
|
||||
submitFormFetchAction(form);
|
||||
return false;
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
};
|
||||
|
||||
elModal.addEventListener('input', () => syncModalUi());
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {initScopedWorkflowRequired} from './ScopedWorkflows.ts';
|
||||
|
||||
function setupForm(required = false) {
|
||||
window.document.body.innerHTML = `
|
||||
<form>
|
||||
<table><tbody>
|
||||
<tr>
|
||||
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
|
||||
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" ${required ? 'checked' : ''}><label></label></div></td>
|
||||
<td>
|
||||
<textarea class="js-scoped-required-patterns${required ? '' : ' tw-hidden'}" data-default-pattern="org/src: CI / *">${required ? 'org/src: CI / *' : ''}</textarea>
|
||||
<span class="js-scoped-required-hint${required ? ' tw-hidden' : ''}">hint</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</form>`;
|
||||
const form = document.querySelector('form')!;
|
||||
const checkbox = form.querySelector<HTMLInputElement>('.js-scoped-required-toggle')!;
|
||||
const textarea = form.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
|
||||
const hint = form.querySelector<HTMLElement>('.js-scoped-required-hint')!;
|
||||
return {form, checkbox, textarea, hint};
|
||||
}
|
||||
|
||||
test('required toggle shows/prefills the patterns textarea (and hides the hint) and reverses otherwise, keeping the value', () => {
|
||||
const {form, checkbox, textarea, hint} = setupForm();
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(true); // initial: not required -> textarea hidden
|
||||
expect(hint.classList.contains('tw-hidden')).toBe(false); // ... and the hint shown in its place
|
||||
|
||||
// check -> textarea shown and prefilled; hint hidden
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(hint.classList.contains('tw-hidden')).toBe(true);
|
||||
expect(textarea.value).toBe('org/src: CI / *');
|
||||
|
||||
// admin edits the pattern
|
||||
textarea.value = 'org/src: CI / build (pull_request)';
|
||||
|
||||
// uncheck -> textarea hidden (value kept, still submits as history), hint shown again
|
||||
checkbox.checked = false;
|
||||
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(true);
|
||||
expect(hint.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
|
||||
|
||||
// re-check -> shown again with the same value (not re-prefilled to the default)
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
|
||||
});
|
||||
|
||||
test('an already-required row stays shown with its stored patterns (not re-prefilled)', () => {
|
||||
const {form, textarea} = setupForm(true);
|
||||
textarea.value = 'org/src: custom / build (push)'; // a stored, admin-edited pattern
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(textarea.value).toBe('org/src: custom / build (push)');
|
||||
});
|
||||
|
||||
function setupFormWithContexts(patterns: string) {
|
||||
window.document.body.innerHTML = `
|
||||
<form>
|
||||
<table><tbody>
|
||||
<tr>
|
||||
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
|
||||
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" checked><label></label></div></td>
|
||||
<td>
|
||||
<textarea class="js-scoped-required-patterns" data-default-pattern="org/src: CI / *">${patterns}</textarea>
|
||||
<span class="js-scoped-required-hint tw-hidden">hint</span>
|
||||
<table class="js-scoped-required-contexts"><tbody>
|
||||
<tr><td><span class="js-scoped-context" data-context="org/src: CI / lint (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
|
||||
<tr><td><span class="js-scoped-context" data-context="org/src: CI / build (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
|
||||
</tbody></table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</form>`;
|
||||
const form = document.querySelector('form')!;
|
||||
const [lintMark, buildMark] = Array.from(form.querySelectorAll<HTMLElement>('.js-scoped-context-matched'));
|
||||
return {form, lintMark, buildMark};
|
||||
}
|
||||
|
||||
test('an exact pattern marks only the context it matches', () => {
|
||||
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / lint (push)');
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(lintMark.classList.contains('tw-hidden')).toBe(false); // matched
|
||||
expect(buildMark.classList.contains('tw-hidden')).toBe(true); // not matched
|
||||
});
|
||||
|
||||
test('a wildcard pattern marks every matching context', () => {
|
||||
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / *');
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
|
||||
});
|
||||
|
||||
test('a wildcard crossing "/" matches every matching context', () => {
|
||||
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: *');
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {addDelegatedEventListener, onInputDebounce, toggleElem} from '../../utils/dom.ts';
|
||||
import {globMatch} from '../../utils/glob.ts';
|
||||
|
||||
// markRowMatchedContexts marks each expected status-check context whose row's textarea patterns match it.
|
||||
function markRowMatchedContexts(row: HTMLElement) {
|
||||
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
|
||||
const patterns = textarea.value.split(/[\r\n]+/).map((p) => p.trim()).filter(Boolean);
|
||||
for (const ctxEl of row.querySelectorAll<HTMLElement>('.js-scoped-context')) {
|
||||
const context = ctxEl.getAttribute('data-context')!;
|
||||
const matched = patterns.some((p) => globMatch(context, p));
|
||||
toggleElem(ctxEl.parentElement!.querySelector('.js-scoped-context-matched')!, matched);
|
||||
}
|
||||
}
|
||||
|
||||
// syncScopedRequiredRow shows a scoped workflow's status-check patterns textarea (and its expected-checks preview) only while the workflow is required.
|
||||
function syncScopedRequiredRow(checkbox: HTMLInputElement) {
|
||||
const row = checkbox.closest('tr')!;
|
||||
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
|
||||
toggleElem(textarea, checkbox.checked);
|
||||
toggleElem(row.querySelector('.js-scoped-required-hint')!, !checkbox.checked); // the "mark as required" hint shown in the textarea's place
|
||||
const contexts = row.querySelector('.js-scoped-required-contexts'); // only rendered when the workflow has expected checks
|
||||
if (contexts) toggleElem(contexts, checkbox.checked);
|
||||
if (checkbox.checked && !textarea.value.trim()) {
|
||||
textarea.value = textarea.getAttribute('data-default-pattern')!;
|
||||
}
|
||||
if (checkbox.checked) markRowMatchedContexts(row);
|
||||
}
|
||||
|
||||
export function initScopedWorkflowRequired(form: HTMLElement) {
|
||||
for (const checkbox of form.querySelectorAll<HTMLInputElement>('.js-scoped-required-toggle')) {
|
||||
syncScopedRequiredRow(checkbox);
|
||||
}
|
||||
for (const textarea of form.querySelectorAll<HTMLTextAreaElement>('.js-scoped-required-patterns')) {
|
||||
const row = textarea.closest('tr')!;
|
||||
textarea.addEventListener('input', onInputDebounce(() => markRowMatchedContexts(row)));
|
||||
}
|
||||
addDelegatedEventListener(form, 'change', '.js-scoped-required-toggle', (checkbox: HTMLInputElement) => syncScopedRequiredRow(checkbox));
|
||||
}
|
||||
@@ -1,31 +1,18 @@
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {htmlEscape} from '../../utils/html.ts';
|
||||
import {attachSearchBox} from '../../modules/search.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
type RepoSearchResponse = {data: Array<{repository: {full_name: string}}>};
|
||||
|
||||
export function initCompSearchRepoBox(el: HTMLElement) {
|
||||
const uid = el.getAttribute('data-uid');
|
||||
const exclusive = el.getAttribute('data-exclusive');
|
||||
// when set, the selected value is the full "owner/name" rather than the bare repo name, so a cross-owner search can be resolved unambiguously
|
||||
const fullName = el.getAttribute('data-full-name') === 'true';
|
||||
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
|
||||
if (exclusive === 'true') {
|
||||
url += `&exclusive=true`;
|
||||
}
|
||||
fomanticQuery(el).search({
|
||||
minCharacters: 2,
|
||||
apiSettings: {
|
||||
url,
|
||||
onResponse(response: any) {
|
||||
const items = [];
|
||||
for (const item of response.data) {
|
||||
items.push({
|
||||
title: htmlEscape(item.repository.full_name.split('/')[1]),
|
||||
description: htmlEscape(item.repository.full_name),
|
||||
});
|
||||
}
|
||||
return {results: items};
|
||||
},
|
||||
},
|
||||
searchFields: ['full_name'],
|
||||
showNoResults: false,
|
||||
});
|
||||
if (exclusive === 'true') url += `&exclusive=true`;
|
||||
attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({
|
||||
title: fullName ? item.repository.full_name : item.repository.full_name.split('/')[1],
|
||||
description: item.repository.full_name,
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -1,49 +1,30 @@
|
||||
import {htmlEscape} from '../../utils/html.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {attachSearchBox, type SearchResult} from '../../modules/search.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
const looksLikeEmailAddressCheck = /^\S+@\S+$/;
|
||||
|
||||
type UserSearchResponse = {data: Array<{login: string; avatar_url: string; full_name: string}>};
|
||||
|
||||
export function initCompSearchUserBox() {
|
||||
const searchUserBox = document.querySelector('#search-user-box');
|
||||
if (!searchUserBox) return;
|
||||
const box = document.querySelector<HTMLElement>('#search-user-box');
|
||||
if (!box) return;
|
||||
|
||||
const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
|
||||
const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
|
||||
const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true';
|
||||
fomanticQuery(searchUserBox).search({
|
||||
minCharacters: 2,
|
||||
apiSettings: {
|
||||
url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`,
|
||||
onResponse(response: any) {
|
||||
const resultItems = [];
|
||||
const searchQuery = searchUserBox.querySelector('input')!.value;
|
||||
const searchQueryUppercase = searchQuery.toUpperCase();
|
||||
for (const item of response.data) {
|
||||
const resultItem = {
|
||||
title: item.login,
|
||||
image: item.avatar_url,
|
||||
description: htmlEscape(item.full_name),
|
||||
};
|
||||
if (searchQueryUppercase === item.login.toUpperCase()) {
|
||||
resultItems.unshift(resultItem); // add the exact match to the top
|
||||
} else {
|
||||
resultItems.push(resultItem);
|
||||
}
|
||||
}
|
||||
const allowEmailInput = box.getAttribute('data-allow-email') === 'true';
|
||||
const allowEmailDescription = box.getAttribute('data-allow-email-description') ?? undefined;
|
||||
const includeOrgs = box.getAttribute('data-include-orgs') === 'true';
|
||||
const url = `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`;
|
||||
|
||||
if (allowEmailInput && !resultItems.length && looksLikeEmailAddressCheck.test(searchQuery)) {
|
||||
const resultItem = {
|
||||
title: searchQuery,
|
||||
description: allowEmailDescription,
|
||||
};
|
||||
resultItems.push(resultItem);
|
||||
}
|
||||
|
||||
return {results: resultItems};
|
||||
},
|
||||
},
|
||||
searchFields: ['login', 'full_name'],
|
||||
showNoResults: false,
|
||||
attachSearchBox(box, url, (response: UserSearchResponse, query) => {
|
||||
const items: SearchResult[] = [];
|
||||
const queryUpper = query.toUpperCase();
|
||||
for (const item of response.data) {
|
||||
const result: SearchResult = {title: item.login, image: item.avatar_url, description: item.full_name};
|
||||
if (queryUpper === item.login.toUpperCase()) items.unshift(result); // exact match floats to top
|
||||
else items.push(result);
|
||||
}
|
||||
if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(query)) {
|
||||
items.push({title: query, description: allowEmailDescription});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@ export function initCompWebHookEditor() {
|
||||
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
|
||||
this.classList.add('is-loading', 'disabled');
|
||||
await POST(this.getAttribute('data-link')!);
|
||||
setTimeout(() => {
|
||||
const redirectUrl = this.getAttribute('data-redirect');
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
}, 5000);
|
||||
setTimeout(() => window.location.reload(), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function initRepoContributors() {
|
||||
loadingTitle: el.getAttribute('data-locale-loading-title'),
|
||||
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
|
||||
loadingInfo: el.getAttribute('data-locale-loading-info'),
|
||||
chartZoomHint: el.getAttribute('data-locale-chart-zoom-hint'),
|
||||
},
|
||||
});
|
||||
View.mount(el);
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
import {clippie} from 'clippie';
|
||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||
import {convertImage} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||
|
||||
const {i18n} = window.config;
|
||||
|
||||
export function initCopyContent() {
|
||||
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
|
||||
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
|
||||
const rawFileLink = btn.getAttribute('data-raw-file-link');
|
||||
|
||||
let content, isRasterImage = false;
|
||||
|
||||
// when "data-raw-link" is present, we perform a fetch. this is either because
|
||||
// the text to copy is not in the DOM, or it is an image that should be
|
||||
// fetched to copy in full resolution
|
||||
if (rawFileLink) {
|
||||
btn.classList.add('is-loading', 'loading-icon-2px');
|
||||
try {
|
||||
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
|
||||
const contentType = res.headers.get('content-type')!;
|
||||
|
||||
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
|
||||
isRasterImage = true;
|
||||
content = await res.blob();
|
||||
} else {
|
||||
content = await res.text();
|
||||
}
|
||||
} catch {
|
||||
return showTemporaryTooltip(btn, i18n.copy_error);
|
||||
} finally {
|
||||
btn.classList.remove('is-loading', 'loading-icon-2px');
|
||||
await copyToClipboardWithFeedback(btn, async () => {
|
||||
const rawFileLink = btn.getAttribute('data-raw-file-link');
|
||||
if (!rawFileLink) {
|
||||
const lineEls = document.querySelectorAll('.file-view .lines-code');
|
||||
return Array.from(lineEls, (el) => el.textContent).join('');
|
||||
}
|
||||
} else { // text, read from DOM
|
||||
const lineEls = document.querySelectorAll('.file-view .lines-code');
|
||||
content = Array.from(lineEls, (el) => el.textContent).join('');
|
||||
}
|
||||
|
||||
// try copy original first, if that fails, and it's an image, convert it to png
|
||||
const success = await clippie(content);
|
||||
if (success) {
|
||||
showTemporaryTooltip(btn, i18n.copy_success);
|
||||
} else {
|
||||
if (isRasterImage) {
|
||||
const success = await clippie(await convertImage(content as Blob, 'image/png'));
|
||||
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
|
||||
} else {
|
||||
showTemporaryTooltip(btn, i18n.copy_error);
|
||||
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
|
||||
const contentType = res.headers.get('content-type')!;
|
||||
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
|
||||
// browsers only accept image/png in the clipboard, convert other raster formats
|
||||
const blob = await res.blob();
|
||||
return contentType === 'image/png' ? blob : convertImage(blob, 'image/png');
|
||||
}
|
||||
}
|
||||
return await res.text();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {clippie} from 'clippie';
|
||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {isImageFile, isVideoFile} from '../utils.ts';
|
||||
import type {DropzoneFile, DropzoneOptions} from 'dropzone/index.js';
|
||||
import type Dropzone from '@deltablot/dropzone';
|
||||
|
||||
const {i18n} = window.config;
|
||||
|
||||
type CustomDropzoneFile = DropzoneFile & {uuid: string};
|
||||
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
|
||||
|
||||
// dropzone has its owner event dispatcher (emitter)
|
||||
export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
|
||||
export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file';
|
||||
export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
|
||||
|
||||
async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
|
||||
async function createDropzone(el: HTMLElement, opts: Dropzone.DropzoneOptions) {
|
||||
const [{default: Dropzone}] = await Promise.all([
|
||||
import('dropzone'),
|
||||
import('dropzone/dist/dropzone.css'),
|
||||
import('@deltablot/dropzone'),
|
||||
import('@deltablot/dropzone/dist/dropzone.css'),
|
||||
]);
|
||||
return new Dropzone(el, opts);
|
||||
}
|
||||
@@ -47,14 +45,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
|
||||
function addCopyLink(file: Partial<CustomDropzoneFile>) {
|
||||
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
|
||||
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
|
||||
const copyLinkEl = createElementFromHTML(`
|
||||
<div class="tw-text-center">
|
||||
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
|
||||
</div>`);
|
||||
const copyLinkEl = createElementFromHTML<HTMLDivElement>(html`
|
||||
<div class="tw-text-center">
|
||||
<a href="#" class="tw-cursor-pointer">${svgRaw('octicon-copy', 14)} Copy link</a>
|
||||
</div>
|
||||
`);
|
||||
copyLinkEl.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const success = await clippie(generateMarkdownLinkForAttachment(file));
|
||||
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
|
||||
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
|
||||
});
|
||||
file.previewTemplate!.append(copyLinkEl);
|
||||
}
|
||||
@@ -112,8 +110,8 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
|
||||
});
|
||||
|
||||
dzInst.on('submit', () => {
|
||||
for (const fileUuid of Object.keys(fileUuidDict)) {
|
||||
fileUuidDict[fileUuid].submitted = true;
|
||||
for (const value of Object.values(fileUuidDict)) {
|
||||
value.submitted = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,7 +147,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
|
||||
} catch (error) {
|
||||
// TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
|
||||
// otherwise the attachments might be lost.
|
||||
showErrorToast(`Failed to load attachments: ${error}`);
|
||||
showErrorToast(`Failed to load attachments: ${errorMessage(error)}`);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {InplaceRenderPlugin} from '../render/plugin.ts';
|
||||
import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {basename} from '../utils.ts';
|
||||
|
||||
@@ -30,7 +31,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
|
||||
rendered = true;
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg = `${e}`;
|
||||
errorMsg = errorMessage(e);
|
||||
} finally {
|
||||
container.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export async function initHeatmap() {
|
||||
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
|
||||
}
|
||||
|
||||
const values = Object.keys(heatmap).map((v) => {
|
||||
return {date: new Date(v), count: heatmap[v]};
|
||||
const values = Object.entries(heatmap).map(([dateStr, count]) => {
|
||||
return {date: new Date(dateStr), count};
|
||||
});
|
||||
|
||||
const totalFormatted = totalContributions.toLocaleString();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
|
||||
type ImageContext = {
|
||||
imageBefore: HTMLImageElement | undefined,
|
||||
@@ -94,15 +93,13 @@ function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageEleme
|
||||
}
|
||||
|
||||
class ImageDiff {
|
||||
containerEl: HTMLElement;
|
||||
diffContainerWidth: number;
|
||||
containerEl!: HTMLElement;
|
||||
diffContainerWidth!: number;
|
||||
|
||||
async init(containerEl: HTMLElement) {
|
||||
this.containerEl = containerEl;
|
||||
containerEl.setAttribute('data-image-diff-loaded', 'true');
|
||||
|
||||
fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab();
|
||||
|
||||
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
|
||||
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box')!.clientWidth - 300, 100);
|
||||
|
||||
@@ -294,7 +291,7 @@ class ImageDiff {
|
||||
|
||||
function updateOpacity() {
|
||||
if (ctx.imageAfter) {
|
||||
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = `${Number(rangeInput.value) / 100}`;
|
||||
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = String(Number(rangeInput.value) / 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ function initPostInstall() {
|
||||
if (tid && resp.status === 200) {
|
||||
clearInterval(tid);
|
||||
tid = null;
|
||||
window.location.href = targetUrl;
|
||||
window.location.assign(targetUrl);
|
||||
}
|
||||
} catch {}
|
||||
}, 1000);
|
||||
|
||||
@@ -10,7 +10,7 @@ async function receiveUpdateCount(event: MessageEvent<{type: string, data: strin
|
||||
const data = JSON.parse(event.data.data);
|
||||
for (const count of document.querySelectorAll('.notification_count')) {
|
||||
count.classList.toggle('tw-hidden', data.Count === 0);
|
||||
count.textContent = `${data.Count}`;
|
||||
count.textContent = String(data.Count);
|
||||
}
|
||||
await updateNotificationTable();
|
||||
} catch (error) {
|
||||
@@ -76,28 +76,26 @@ async function updateNotificationCountWithCallback(callback: (timeout: number, n
|
||||
}
|
||||
|
||||
async function updateNotificationTable() {
|
||||
let notificationDiv = document.querySelector('#notification_div');
|
||||
if (notificationDiv) {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('div-only', 'true');
|
||||
params.set('sequence-number', String(++notificationSequenceNumber));
|
||||
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
|
||||
const notificationDiv = document.querySelector('#notification_div');
|
||||
if (!notificationDiv) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch notification table');
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('div-only', 'true');
|
||||
params.set('sequence-number', String(++notificationSequenceNumber));
|
||||
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
|
||||
|
||||
const data = await response.text();
|
||||
const el = createElementFromHTML(data);
|
||||
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
|
||||
notificationDiv.outerHTML = data;
|
||||
notificationDiv = document.querySelector('#notification_div')!;
|
||||
window.htmx.process(notificationDiv); // when using htmx, we must always remember to process the new content changed by us
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch notification table');
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
const el = createElementFromHTML(data);
|
||||
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
|
||||
notificationDiv.outerHTML = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +112,7 @@ async function updateNotificationCount(): Promise<number> {
|
||||
toggleElem('.notification_count', data.new !== 0);
|
||||
|
||||
for (const el of document.querySelectorAll('.notification_count')) {
|
||||
el.textContent = `${data.new}`;
|
||||
el.textContent = String(data.new);
|
||||
}
|
||||
|
||||
return data.new as number;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {parseIssueHref} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {createApp} from 'vue';
|
||||
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
type IssueInfo = {
|
||||
convertedIssue: Issue,
|
||||
renderedLabels: string,
|
||||
};
|
||||
|
||||
const issueInfoCache = new Map<string, IssueInfo>();
|
||||
|
||||
async function getIssueInfo(url: string): Promise<IssueInfo> {
|
||||
if (issueInfoCache.has(url)) return issueInfoCache.get(url)!;
|
||||
const resp = await GET(url);
|
||||
if (!resp.ok) throw new Error(resp.statusText || 'Unknown network error');
|
||||
const data = await resp.json();
|
||||
issueInfoCache.set(url, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function showRefIssuePopup(link: HTMLAnchorElement) {
|
||||
const [data, {default: ContextPopup}] = await Promise.all([
|
||||
getIssueInfo(`${link.pathname}/info`),
|
||||
import('../components/ContextPopup.vue'),
|
||||
]);
|
||||
const el = document.createElement('div');
|
||||
const app = createApp(ContextPopup, {
|
||||
issue: data.convertedIssue,
|
||||
renderedLabels: data.renderedLabels,
|
||||
});
|
||||
app.mount(el);
|
||||
// suppress ancestor title like from .commit-summary to prevent double tooltip
|
||||
link.title = '';
|
||||
createTippy(link, {
|
||||
theme: 'default',
|
||||
content: el,
|
||||
trigger: 'mouseenter focus',
|
||||
placement: 'top-start',
|
||||
interactive: true,
|
||||
role: 'dialog',
|
||||
interactiveBorder: 5,
|
||||
onDestroy: () => app.unmount(),
|
||||
}).show();
|
||||
}
|
||||
|
||||
export function initRefIssueContextPopup() {
|
||||
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
|
||||
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
|
||||
if (!parseIssueHref(link.getAttribute('href')!).ownerName) return;
|
||||
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
|
||||
if (getAttachedTippyInstance(link)) return;
|
||||
link.setAttribute('data-ref-issue-popup', '');
|
||||
|
||||
// delay so a mouse passing over the link doesn't fire a fetch
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
link.removeAttribute('data-ref-issue-popup');
|
||||
link.removeEventListener('mouseleave', cancel);
|
||||
};
|
||||
timer = setTimeout(async () => {
|
||||
link.removeEventListener('mouseleave', cancel);
|
||||
try {
|
||||
await showRefIssuePopup(link);
|
||||
} catch (err) {
|
||||
console.error('Failed to load issue info:', err);
|
||||
link.removeAttribute('data-ref-issue-popup');
|
||||
}
|
||||
}, 300);
|
||||
link.addEventListener('mouseleave', cancel);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {updateWorkflowBadgeFields} from './repo-actions.ts';
|
||||
|
||||
test('updateWorkflowBadgeFields updates badge snippets for selected branch', () => {
|
||||
document.body.innerHTML = `
|
||||
<div
|
||||
data-badge-url="https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=main"
|
||||
data-workflow-url="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"
|
||||
data-workflow-display-name="CI [prod]\\build "fast" <ok>"
|
||||
>
|
||||
<img data-workflow-badge-image src="">
|
||||
<input id="workflow-badge-url" readonly>
|
||||
<textarea id="workflow-badge-markdown" readonly></textarea>
|
||||
<textarea id="workflow-badge-html" readonly></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const form = document.querySelector<HTMLElement>('[data-badge-url]')!;
|
||||
|
||||
updateWorkflowBadgeFields(form, 'release/1.0 & hotfix');
|
||||
|
||||
const badgeURL = 'https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=release%2F1.0+%26+hotfix';
|
||||
expect(form.querySelector<HTMLImageElement>('[data-workflow-badge-image]')!.src).toBe(badgeURL);
|
||||
expect(form.querySelector<HTMLInputElement>('#workflow-badge-url')!.value).toBe(badgeURL);
|
||||
expect(form.querySelector<HTMLTextAreaElement>('#workflow-badge-markdown')!.value).toBe(
|
||||
`[![CI \\[prod\\]\\\\build "fast" <ok>](${badgeURL})](https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml)`,
|
||||
);
|
||||
expect(form.querySelector<HTMLTextAreaElement>('#workflow-badge-html')!.value).toBe(
|
||||
`<a href="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"><img src="${badgeURL}" alt="CI [prod]\\build "fast" <ok>"></a>`,
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,37 @@
|
||||
import {createApp} from 'vue';
|
||||
import RepoActionView from '../components/RepoActionView.vue';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
|
||||
export function initRepositoryActionView() {
|
||||
export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void {
|
||||
const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!);
|
||||
badgeURLParsed.searchParams.set('branch', branch);
|
||||
|
||||
const badgeURL = badgeURLParsed.href;
|
||||
const workflowURL = form.getAttribute('data-workflow-url')!;
|
||||
const displayName = form.getAttribute('data-workflow-display-name')!;
|
||||
const markdownAltText = displayName.replaceAll(/[\\[\]]/g, (c) => `\\${c}`);
|
||||
|
||||
form.querySelector<HTMLImageElement>('[data-workflow-badge-image]')!.src = badgeURL;
|
||||
form.querySelector<HTMLInputElement>('#workflow-badge-url')!.value = badgeURL;
|
||||
form.querySelector<HTMLTextAreaElement>('#workflow-badge-markdown')!.value = `[](${workflowURL})`;
|
||||
form.querySelector<HTMLTextAreaElement>('#workflow-badge-html')!.value = html`<a href="${workflowURL}"><img src="${badgeURL}" alt="${displayName}"></a>`;
|
||||
}
|
||||
|
||||
function initWorkflowBadgeForm(form: HTMLElement): void {
|
||||
const branchInput = form.querySelector<HTMLInputElement>('[data-workflow-badge-branch]')!;
|
||||
branchInput.addEventListener('change', () => updateWorkflowBadgeFields(form, branchInput.value));
|
||||
updateWorkflowBadgeFields(form, branchInput.value);
|
||||
}
|
||||
|
||||
export function initRepositoryActions() {
|
||||
registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm);
|
||||
initRepositoryActionsView();
|
||||
}
|
||||
|
||||
function initRepositoryActionsView() {
|
||||
const el = document.querySelector('#repo-action-view');
|
||||
if (!el) return;
|
||||
const runId = parseInt(el.getAttribute('data-run-id')!);
|
||||
const jobId = parseInt(el.getAttribute('data-job-id')!);
|
||||
|
||||
// TODO: the parent element's full height doesn't work well now,
|
||||
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
|
||||
@@ -13,35 +39,46 @@ export function initRepositoryActionView() {
|
||||
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
|
||||
|
||||
const view = createApp(RepoActionView, {
|
||||
runId,
|
||||
jobId,
|
||||
actionsUrl: el.getAttribute('data-actions-url'),
|
||||
jobId: parseInt(el.getAttribute('data-job-id')!),
|
||||
actionsViewUrl: el.getAttribute('data-actions-view-url'),
|
||||
locale: {
|
||||
approve: el.getAttribute('data-locale-approve'),
|
||||
cancel: el.getAttribute('data-locale-cancel'),
|
||||
rerun: el.getAttribute('data-locale-rerun'),
|
||||
rerun_all: el.getAttribute('data-locale-rerun-all'),
|
||||
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
|
||||
latest: el.getAttribute('data-locale-latest'),
|
||||
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
|
||||
attempt: el.getAttribute('data-locale-attempt'),
|
||||
scheduled: el.getAttribute('data-locale-runs-scheduled'),
|
||||
commit: el.getAttribute('data-locale-runs-commit'),
|
||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
|
||||
summary: el.getAttribute('data-locale-summary'),
|
||||
allJobs: el.getAttribute('data-locale-all-jobs'),
|
||||
jobSummaries: el.getAttribute('data-locale-job-summaries'),
|
||||
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
|
||||
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
|
||||
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
||||
rerunTriggered: el.getAttribute('data-locale-rerun-triggered'),
|
||||
backToPullRequest: el.getAttribute('data-locale-back-to-pull-request'),
|
||||
backToWorkflow: el.getAttribute('data-locale-back-to-workflow'),
|
||||
statusLabel: el.getAttribute('data-locale-status-label'),
|
||||
totalDuration: el.getAttribute('data-locale-total-duration'),
|
||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
||||
artifactExpiredAt: el.getAttribute('data-locale-artifact-expired-at'),
|
||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
|
||||
downloadLogs: el.getAttribute('data-locale-download-logs'),
|
||||
copyOutput: el.getAttribute('data-locale-copy-output'),
|
||||
status: {
|
||||
unknown: el.getAttribute('data-locale-status-unknown'),
|
||||
waiting: el.getAttribute('data-locale-status-waiting'),
|
||||
running: el.getAttribute('data-locale-status-running'),
|
||||
cancelling: el.getAttribute('data-locale-status-cancelling'),
|
||||
success: el.getAttribute('data-locale-status-success'),
|
||||
failure: el.getAttribute('data-locale-status-failure'),
|
||||
cancelled: el.getAttribute('data-locale-status-cancelled'),
|
||||
@@ -51,7 +88,18 @@ export function initRepositoryActionView() {
|
||||
logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'),
|
||||
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
|
||||
workflowFile: el.getAttribute('data-locale-workflow-file'),
|
||||
workflowFileNoPermission: el.getAttribute('data-locale-workflow-file-no-permission'),
|
||||
runDetails: el.getAttribute('data-locale-run-details'),
|
||||
workflowDependencies: el.getAttribute('data-locale-workflow-dependencies'),
|
||||
graphJobsCount1: el.getAttribute('data-locale-graph-jobs-count-1'),
|
||||
graphJobsCountN: el.getAttribute('data-locale-graph-jobs-count-n'),
|
||||
graphDependenciesCount1: el.getAttribute('data-locale-graph-dependencies-count-1'),
|
||||
graphDependenciesCountN: el.getAttribute('data-locale-graph-dependencies-count-n'),
|
||||
graphSuccessRate: el.getAttribute('data-locale-graph-success-rate'),
|
||||
graphZoomIn: el.getAttribute('data-locale-graph-zoom-in'),
|
||||
graphZoomMax: el.getAttribute('data-locale-graph-zoom-max'),
|
||||
graphZoomOut: el.getAttribute('data-locale-graph-zoom-out'),
|
||||
graphResetView: el.getAttribute('data-locale-graph-reset-view'),
|
||||
},
|
||||
});
|
||||
view.mount(el);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
|
||||
export function initRepoBranchButton() {
|
||||
initRepoCreateBranchButton();
|
||||
@@ -18,7 +18,7 @@ function initRepoCreateBranchButton() {
|
||||
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
|
||||
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
|
||||
|
||||
fomanticQuery(el.getAttribute('data-modal')!).modal('show');
|
||||
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
|
||||
function changeHash(hash: string) {
|
||||
@@ -24,16 +23,16 @@ function selectRange(range: string): Element | null {
|
||||
if (!refInNewIssue) return;
|
||||
const urlIssueNew = refInNewIssue.getAttribute('data-url-issue-new');
|
||||
const urlParamBodyLink = refInNewIssue.getAttribute('data-url-param-body-link')!;
|
||||
const issueContent = `${toAbsoluteUrl(urlParamBodyLink)}#${anchor}`; // the default content for issue body
|
||||
const issueContent = `${urlParamBodyLink}#${anchor}`; // the default content for issue body
|
||||
refInNewIssue.setAttribute('href', `${urlIssueNew}?body=${encodeURIComponent(issueContent)}`);
|
||||
};
|
||||
|
||||
const updateViewGitBlameFragment = function (anchor: string) {
|
||||
if (!viewGitBlame) return;
|
||||
let href = viewGitBlame.getAttribute('href')!;
|
||||
href = `${href.replace(/#L\d+$|#L\d+-L\d+$/, '')}`;
|
||||
href = href.replace(/#L\d+$|#L\d+-L\d+$/, '');
|
||||
if (anchor.length !== 0) {
|
||||
href = `${href}#${anchor}`;
|
||||
href += `#${anchor}`;
|
||||
}
|
||||
viewGitBlame.setAttribute('href', href);
|
||||
};
|
||||
@@ -41,9 +40,8 @@ function selectRange(range: string): Element | null {
|
||||
const updateCopyPermalinkUrl = function (anchor: string) {
|
||||
if (!copyPermalink) return;
|
||||
let link = copyPermalink.getAttribute('data-url')!;
|
||||
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
|
||||
link = `${window.location.origin}${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
|
||||
copyPermalink.setAttribute('data-clipboard-text', link);
|
||||
copyPermalink.setAttribute('data-clipboard-text-type', 'url');
|
||||
};
|
||||
|
||||
const rangeFields = range ? range.split('-') : [];
|
||||
|
||||
@@ -24,3 +24,29 @@ export function initCommitStatuses() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function initAvatarStackPopup() {
|
||||
registerGlobalInitFunc('initAvatarStackPopup', (el: HTMLElement) => {
|
||||
const nextEl = el.nextElementSibling!;
|
||||
if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target');
|
||||
createTippy(el, {
|
||||
content: nextEl,
|
||||
placement: 'bottom-start',
|
||||
interactive: true,
|
||||
role: 'dialog',
|
||||
theme: 'menu',
|
||||
trigger: 'click',
|
||||
hideOnClick: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function initCommitFileHistoryFollowRename() {
|
||||
registerGlobalInitFunc('initCommitHistoryFollowRename', (el: HTMLInputElement) => {
|
||||
el.addEventListener('change', () => {
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set('follow-rename', String(el.checked));
|
||||
window.location.assign(url.href);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
@@ -22,10 +23,10 @@ async function onDownloadArchive(e: Event) {
|
||||
if (data.complete) break;
|
||||
await sleep(Math.min((tryCount + 1) * 750, 2000));
|
||||
}
|
||||
window.location.href = el.href; // the archive is ready, start real downloading
|
||||
window.location.assign(el.href); // the archive is ready, start real downloading
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showErrorToast(`Failed to download the archive: ${e}`, {duration: 2500});
|
||||
showErrorToast(`Failed to download the archive: ${errorMessage(e)}`, {duration: 2500});
|
||||
} finally {
|
||||
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts';
|
||||
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
|
||||
loadingButton.classList.add('disabled');
|
||||
try {
|
||||
const res = await GET(loadingButton.getAttribute('data-fetch-url')!);
|
||||
const res = await GET(loadingButton.getAttribute('data-url')!);
|
||||
const data = await res.json();
|
||||
hideElem(loadingButton);
|
||||
addTags(area, data.tags);
|
||||
|
||||
@@ -5,12 +5,14 @@ import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
|
||||
import {initViewedCheckboxListenerFor, initExpandAndCollapseFilesButton} from './pull-view-file.ts';
|
||||
import {initImageDiff} from './imagediff.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
|
||||
import {queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {POST, GET} from '../modules/fetch.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {invertFileFolding} from './file-fold.ts';
|
||||
import {parseDom, sleep} from '../utils.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {performFetchActionTrigger} from './common-fetch-action.ts';
|
||||
|
||||
function initRepoDiffFileBox(el: HTMLElement) {
|
||||
// switch between "rendered" and "source", for image and CSV files
|
||||
@@ -40,8 +42,8 @@ function initRepoDiffConversationForm() {
|
||||
const formData = new FormData(form);
|
||||
|
||||
// if the form is submitted by a button, append the button's name and value to the form data
|
||||
const submitter = submitEventSubmitter(e);
|
||||
const isSubmittedByButton = (submitter?.nodeName === 'BUTTON') || (submitter?.nodeName === 'INPUT' && submitter.type === 'submit');
|
||||
const submitter = e.submitter;
|
||||
const isSubmittedByButton = submitter instanceof HTMLButtonElement || (submitter instanceof HTMLInputElement && submitter.type === 'submit');
|
||||
if (isSubmittedByButton && submitter.name) {
|
||||
formData.append(submitter.name, submitter.value);
|
||||
}
|
||||
@@ -84,7 +86,7 @@ function initRepoDiffConversationForm() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showErrorToast(`Submit form failed: ${error}`);
|
||||
showErrorToast(`Submit form failed: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
form?.classList.remove('is-loading');
|
||||
}
|
||||
@@ -127,7 +129,7 @@ function initRepoDiffConversationNav() {
|
||||
const navIndex = isPrevious ? previousIndex : nextIndex;
|
||||
const elNavConversation = elAllConversations[navIndex];
|
||||
const anchor = elNavConversation.querySelector('.comment')!.id;
|
||||
window.location.href = `#${anchor}`;
|
||||
window.location.assign(`#${anchor}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,7 +174,6 @@ async function loadMoreFiles(btn: Element): Promise<boolean> {
|
||||
// * append the newly loaded file list items to the existing list
|
||||
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
|
||||
for (const el of respFileBoxesChildren) window.htmx.process(el);
|
||||
onShowMoreFiles();
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -204,7 +205,6 @@ function initRepoDiffShowMore() {
|
||||
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
|
||||
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||
el.parentElement!.replaceWith(...respFileBodyChildren);
|
||||
for (const el of respFileBodyChildren) window.htmx.process(el);
|
||||
// FIXME: calling onShowMoreFiles is not quite right here.
|
||||
// But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
|
||||
// so it still needs to call it to make the "ImageDiff" and something similar work.
|
||||
@@ -245,14 +245,14 @@ async function onLocationHashChange() {
|
||||
const issueCommentPrefix = '#issuecomment-';
|
||||
if (currentHash.startsWith(issueCommentPrefix)) {
|
||||
const commentId = currentHash.substring(issueCommentPrefix.length);
|
||||
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`);
|
||||
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${CSS.escape(commentId)},"]`);
|
||||
if (expandButton) {
|
||||
// avoid infinite loop, do not re-click the button if already clicked
|
||||
const attrAutoLoadClicked = 'data-auto-load-clicked';
|
||||
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
|
||||
expandButton.setAttribute(attrAutoLoadClicked, 'true');
|
||||
expandButton.click();
|
||||
await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
|
||||
// trigger the fetch action to load the hidden comments, after loading, it will try to find the target element again
|
||||
await performFetchActionTrigger(expandButton, 'load');
|
||||
continue; // Try again to find the element
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,32 +6,58 @@ import {POST} from '../modules/fetch.ts';
|
||||
import {initDropzone} from './dropzone.ts';
|
||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {submitFormFetchAction} from './common-fetch-action.ts';
|
||||
import {dirname} from '../utils.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
|
||||
function initEditPreviewTab(elForm: HTMLFormElement) {
|
||||
const elTabMenu = elForm.querySelector('.repo-editor-menu')!;
|
||||
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
|
||||
const elTabMenu = elForm.querySelector('.repo-editor-menu');
|
||||
if (!elTabMenu) return;
|
||||
|
||||
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
|
||||
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
|
||||
if (!elPreviewTab || !elPreviewPanel) return;
|
||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
|
||||
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
|
||||
if (!elTreePath || !elTextarea) return;
|
||||
|
||||
const repoLink = elTabMenu.getAttribute('data-repo-link')!;
|
||||
const refSubUrl = elTabMenu.getAttribute('data-ref-sub-url')!;
|
||||
const branchName = elTabMenu.getAttribute('data-branch-name')!;
|
||||
|
||||
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]')!;
|
||||
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]')!;
|
||||
elPreviewTab.addEventListener('click', async () => {
|
||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path')!;
|
||||
const previewUrl = elPreviewTab.getAttribute('data-preview-url')!;
|
||||
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
|
||||
let previewContext = `${previewContextRef}/${elTreePath.value}`;
|
||||
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
|
||||
// "preview context" is the request path directory of the file, the rendered links will be resolved based on this path
|
||||
// TODO: MARKUP-RENDER-CONTEXT: due to various hacky patches, this logic is unnecessarily complicated, see the backend
|
||||
const previewContext = dirname(`${repoLink}/src/${refSubUrl}/${pathEscapeSegments(elTreePath.value)}`);
|
||||
const formData = new FormData();
|
||||
formData.append('mode', 'file');
|
||||
formData.append('context', previewContext);
|
||||
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea')!.value);
|
||||
formData.append('text', elTextarea.value);
|
||||
formData.append('file_path', elTreePath.value);
|
||||
const response = await POST(previewUrl, {data: formData});
|
||||
const data = await response.text();
|
||||
const resp = await POST(`${repoLink}/markup`, {data: formData});
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to render preview: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
const data = await resp.text();
|
||||
renderPreviewPanelContent(elPreviewPanel, data);
|
||||
});
|
||||
|
||||
const elDiffTab = elTabMenu.querySelector('a[data-tab="diff"]');
|
||||
const elDiffPanel = elForm.querySelector('.tab[data-tab="diff"]');
|
||||
if (elDiffTab && elDiffPanel) {
|
||||
// the "diff" tab only exists for an existing file, but not for a new file
|
||||
elDiffTab.addEventListener('click', async () => {
|
||||
const diffUrl = `${repoLink}/_preview/${pathEscapeSegments(branchName)}/${pathEscapeSegments(elTreePath.value)}`;
|
||||
// don't use FormData, because FormData sends "\r\n" line endings, backend assumes "\n" line endings
|
||||
const resp = await POST(diffUrl, {data: new URLSearchParams({content: elTextarea.value})});
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to render diff: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
elDiffPanel.innerHTML = await resp.text();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoEditor() {
|
||||
@@ -54,6 +80,8 @@ export function initRepoEditor() {
|
||||
// ATTENTION: two pages have this filename input
|
||||
// * new/edit file page: there is a code editor
|
||||
// * upload page: there is no code editor, but a uploader
|
||||
// FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
|
||||
// why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times
|
||||
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
|
||||
if (!filenameInput) return;
|
||||
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh
|
||||
|
||||
@@ -42,7 +42,7 @@ export function initRepoGraphGit() {
|
||||
|
||||
elGraphBody.classList.add('is-loading');
|
||||
try {
|
||||
const resp = await GET(ajaxUrl.toString());
|
||||
const resp = await GET(ajaxUrl.href);
|
||||
elGraphBody.innerHTML = await resp.text();
|
||||
} finally {
|
||||
elGraphBody.classList.remove('is-loading');
|
||||
@@ -51,7 +51,7 @@ export function initRepoGraphGit() {
|
||||
|
||||
const dropdownSelected = params.getAll('branch');
|
||||
if (params.has('hide-pr-refs') && params.get('hide-pr-refs') === 'true') {
|
||||
dropdownSelected.splice(0, 0, '...flow-hide-pr-refs');
|
||||
dropdownSelected.unshift('...flow-hide-pr-refs');
|
||||
}
|
||||
|
||||
const $dropdown = fomanticQuery('#flow-select-refs-dropdown');
|
||||
|
||||
@@ -96,10 +96,7 @@ export function initRepoTopicBar() {
|
||||
};
|
||||
const query = stripTags(this.urlData.query.trim());
|
||||
let found_query = false;
|
||||
const current_topics = [];
|
||||
for (const el of queryElemChildren(topicDropdown, 'a.ui.label.visible')) {
|
||||
current_topics.push(el.getAttribute('data-value'));
|
||||
}
|
||||
const current_topics = Array.from(queryElemChildren(topicDropdown, 'a.ui.label.visible'), (el) => el.getAttribute('data-value'));
|
||||
|
||||
if (res.topics) {
|
||||
let found = false;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {createElementFromHTML, showElem} from '../utils/dom.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
|
||||
let i18nTextEdited: string;
|
||||
let i18nTextOptions: string;
|
||||
@@ -11,24 +13,24 @@ let i18nTextDeleteFromHistory: string;
|
||||
let i18nTextDeleteFromHistoryConfirm: string;
|
||||
|
||||
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
|
||||
const elDetailDialog = createElementFromHTML(`
|
||||
<div class="ui modal content-history-detail-dialog">
|
||||
${svg('octicon-x', 16, 'close icon inside')}
|
||||
<div class="header tw-flex tw-items-center tw-justify-between">
|
||||
<div>${itemTitleHtml}</div>
|
||||
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
|
||||
${i18nTextOptions}
|
||||
${svg('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
|
||||
const elDetailDialog = createElementFromHTML(html`
|
||||
<div class="ui modal content-history-detail-dialog">
|
||||
${svgRaw('octicon-x', 16, 'close icon inside')}
|
||||
<div class="header flex-left-right">
|
||||
<div>${htmlRaw(itemTitleHtml)}</div>
|
||||
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
|
||||
${i18nTextOptions}
|
||||
${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-diff-data is-loading"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-diff-data is-loading"></div>
|
||||
</div>`);
|
||||
`);
|
||||
document.body.append(elDetailDialog);
|
||||
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
|
||||
const $fomanticDialog = fomanticQuery(elDetailDialog);
|
||||
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
|
||||
$fomanticDropdownOptions.dropdown({
|
||||
showOnFocus: false,
|
||||
@@ -46,7 +48,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
const resp = await response.json();
|
||||
|
||||
if (resp.ok) {
|
||||
$fomanticDialog.modal('hide');
|
||||
hideFomanticModal(elDetailDialog);
|
||||
} else {
|
||||
showErrorToast(resp.message);
|
||||
}
|
||||
@@ -63,7 +65,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
$fomanticDropdownOptions.dropdown('clear', true);
|
||||
},
|
||||
});
|
||||
$fomanticDialog.modal({
|
||||
showFomanticModal(elDetailDialog, {
|
||||
async onShow() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -86,19 +88,20 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
}
|
||||
},
|
||||
onHidden() {
|
||||
$fomanticDialog.remove();
|
||||
elDetailDialog.remove();
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
}
|
||||
|
||||
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
|
||||
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left')!;
|
||||
const menuHtml = `
|
||||
<div class="ui dropdown interact-fg content-history-menu" data-comment-id="${commentId}">
|
||||
• ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
const menuHtml = html`
|
||||
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
|
||||
• ${i18nTextEdited}${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
`;
|
||||
|
||||
elHeaderLeft.querySelector(`.ui.dropdown.content-history-menu`)?.remove(); // remove the old one if exists
|
||||
elHeaderLeft.append(createElementFromHTML(menuHtml));
|
||||
@@ -127,7 +130,7 @@ export async function initRepoIssueContentHistory() {
|
||||
const issuePageInfo = parseIssuePageInfo();
|
||||
if (!issuePageInfo.issueNumber) return;
|
||||
|
||||
const elIssueDescription = document.querySelector('.repository.issue .timeline-item.comment.first'); // issue(PR) main content
|
||||
const elIssueDescription = document.querySelector('.repository.issue .timeline-item.comment.issue-content-comment'); // issue(PR) main content
|
||||
const elComments = document.querySelectorAll('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments
|
||||
if (!elIssueDescription && !elComments.length) return;
|
||||
|
||||
@@ -145,7 +148,7 @@ export async function initRepoIssueContentHistory() {
|
||||
if (resp.editedHistoryCountMap[0] && elIssueDescription) {
|
||||
showContentHistoryMenu(issueBaseUrl, elIssueDescription, '0');
|
||||
}
|
||||
for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) {
|
||||
for (const commentId of Object.keys(resp.editedHistoryCountMap)) {
|
||||
if (commentId === '0') continue;
|
||||
const elIssueComment = document.querySelector(`#issuecomment-${commentId}`);
|
||||
if (elIssueComment) showContentHistoryMenu(issueBaseUrl, elIssueComment, commentId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} fr
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
|
||||
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
|
||||
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
@@ -73,7 +74,7 @@ async function tryOnEditContent(e: Event) {
|
||||
}
|
||||
comboMarkdownEditor.dropzoneSubmitReload();
|
||||
} catch (error) {
|
||||
showErrorToast(`Failed to save the content: ${error}`);
|
||||
showErrorToast(`Failed to save the content: ${errorMessage(error)}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
renderContent.classList.remove('is-loading');
|
||||
|
||||
@@ -2,6 +2,7 @@ import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {DELETE, POST} from '../modules/fetch.ts';
|
||||
@@ -56,21 +57,12 @@ function initRepoIssueListCheckboxes() {
|
||||
|
||||
const url = el.getAttribute('data-url')!;
|
||||
let action = el.getAttribute('data-action')!;
|
||||
let elementId = el.getAttribute('data-element-id')!;
|
||||
const issueIDList: string[] = [];
|
||||
for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
|
||||
issueIDList.push(el.getAttribute('data-issue-id')!);
|
||||
}
|
||||
const elementId = el.getAttribute('data-element-id')!;
|
||||
const issueIDList: string[] = Array.from(document.querySelectorAll('.issue-checkbox:checked'), (el) => (el.getAttribute('data-issue-id')!));
|
||||
const issueIDs = issueIDList.join(',');
|
||||
if (!issueIDs) return;
|
||||
|
||||
// for assignee
|
||||
if (elementId === '0' && url.endsWith('/assignee')) {
|
||||
elementId = '';
|
||||
action = 'clear';
|
||||
}
|
||||
|
||||
// for toggle
|
||||
// for label toggle
|
||||
if (action === 'toggle' && e.altKey) {
|
||||
action = 'toggle-alt';
|
||||
}
|
||||
@@ -87,7 +79,9 @@ function initRepoIssueListCheckboxes() {
|
||||
await updateIssuesMeta(url, action, issueIDs, elementId);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
showErrorToast(err.responseJSON?.error ?? err.message);
|
||||
// FIXME: this logic (including updateIssuesMeta) is not right, should refactor to our JSONError framework
|
||||
const e = err as {responseJSON?: {error: string}};
|
||||
showErrorToast(e.responseJSON?.error ?? errorMessage(err));
|
||||
}
|
||||
},
|
||||
));
|
||||
@@ -106,7 +100,7 @@ function initDropdownUserRemoteSearch(el: Element) {
|
||||
fullTextSearch: true,
|
||||
selectOnKeydown: false,
|
||||
action: (_text: string, value: string) => {
|
||||
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
|
||||
window.location.assign(actionJumpUrl.replace('{username}', encodeURIComponent(value)));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -139,7 +133,7 @@ function initDropdownUserRemoteSearch(el: Element) {
|
||||
processedResults.length = 0;
|
||||
for (const item of resp.results) {
|
||||
const htmlAvatar = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20">`;
|
||||
const htmlFullName = item.full_name ? html`<span class="username-fullname gt-ellipsis">(${item.full_name})</span>` : '';
|
||||
const htmlFullName = item.full_name ? html`<span class="username-fullname">(${item.full_name})</span>` : '';
|
||||
const htmlItem = html`<span class="username-display">${htmlRaw(htmlAvatar)}<span>${item.username}</span>${htmlRaw(htmlFullName)}</span>`;
|
||||
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
|
||||
processedResults.push({value: item.username, name: htmlItem});
|
||||
@@ -189,7 +183,7 @@ function initPinRemoveButton() {
|
||||
// Delete the tooltip
|
||||
el._tippy.destroy();
|
||||
// Remove the Card
|
||||
el.closest(`div.issue-card[data-issue-id="${id}"]`)!.remove();
|
||||
el.closest(`div.issue-card[data-issue-id="${CSS.escape(String(id))}"]`)!.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,94 +1,42 @@
|
||||
import {createApp} from 'vue';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||
|
||||
function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||
const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base');
|
||||
if (!prUpdateButtonContainer) return;
|
||||
export function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||
const elDropdown = el.querySelector(':scope > .ui.dropdown');
|
||||
if (!elDropdown) return;
|
||||
const elButton = el.querySelector<HTMLButtonElement>(':scope > button')!;
|
||||
|
||||
const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button')!;
|
||||
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!;
|
||||
prUpdateButton.addEventListener('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const redirect = this.getAttribute('data-redirect');
|
||||
this.classList.add('is-loading');
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
response = await POST(this.getAttribute('data-do')!);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.classList.remove('is-loading');
|
||||
}
|
||||
let data: Record<string, any> | undefined;
|
||||
try {
|
||||
data = await response?.json(); // the response is probably not a JSON
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
if (data?.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else if (redirect) {
|
||||
window.location.href = redirect;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
fomanticQuery(prUpdateDropdown).dropdown({
|
||||
fomanticQuery(elDropdown).dropdown({
|
||||
onChange(_text: string, _value: string, $choice: any) {
|
||||
const choiceEl = $choice[0];
|
||||
const url = choiceEl.getAttribute('data-do');
|
||||
if (url) {
|
||||
const buttonText = prUpdateButton.querySelector('.button-text');
|
||||
if (buttonText) {
|
||||
buttonText.textContent = choiceEl.textContent;
|
||||
}
|
||||
prUpdateButton.setAttribute('data-do', url);
|
||||
}
|
||||
elButton.textContent = choiceEl.textContent;
|
||||
elButton.setAttribute('data-url', choiceEl.getAttribute('data-update-url'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initRepoPullRequestCommitStatus(el: HTMLElement) {
|
||||
for (const btn of el.querySelectorAll('.commit-status-hide-checks')) {
|
||||
const panel = btn.closest('.commit-status-panel')!;
|
||||
const list = panel.querySelector<HTMLElement>('.commit-status-list')!;
|
||||
btn.addEventListener('click', () => {
|
||||
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
|
||||
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
|
||||
});
|
||||
}
|
||||
function onCommitStatusChecksToggle(btn: HTMLElement) {
|
||||
const panel = btn.closest('.commit-status-toggle')!.parentElement!;
|
||||
const list = panel.querySelector<HTMLElement>('.commit-status-list')!;
|
||||
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
|
||||
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
|
||||
}
|
||||
|
||||
async function initRepoPullRequestMergeForm(box: HTMLElement) {
|
||||
const el = box.querySelector('#pull-request-merge-form');
|
||||
if (!el) return;
|
||||
|
||||
const data = JSON.parse(el.getAttribute('data-merge-form-props')!);
|
||||
const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
|
||||
const view = createApp(PullRequestMergeForm);
|
||||
view.mount(el);
|
||||
}
|
||||
|
||||
function executeScripts(elem: Element) {
|
||||
for (const oldScript of elem.querySelectorAll('script')) {
|
||||
// TODO: that's the only way to load the data for the merge form. In the future
|
||||
// we need to completely decouple the page data and embedded script
|
||||
// eslint-disable-next-line github/no-dynamic-script-tag
|
||||
const newScript = document.createElement('script');
|
||||
for (const attr of oldScript.attributes) {
|
||||
if (attr.name === 'type' && attr.value === 'module') continue;
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
newScript.text = oldScript.text;
|
||||
document.body.append(newScript);
|
||||
}
|
||||
const view = createApp(PullRequestMergeForm, {mergeFormProps: data});
|
||||
view.mount(el); // TODO: can unmount when reloaded?
|
||||
}
|
||||
|
||||
export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
initRepoPullRequestCommitStatus(el);
|
||||
initRepoPullRequestUpdate(el);
|
||||
registerGlobalEventFunc('click', 'onCommitStatusChecksToggle', onCommitStatusChecksToggle);
|
||||
initRepoPullRequestMergeForm(el);
|
||||
|
||||
const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
|
||||
@@ -98,6 +46,14 @@ export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
const pullLink = el.getAttribute('data-pull-link');
|
||||
let timerId: number | null;
|
||||
|
||||
// The merge box has complex buttons & form, if the user has interacted with any element, don't refresh.
|
||||
// Otherwise, the user won't be able to merge or schedule a merge (auto-merge) when the PR status is not ready.
|
||||
let interacted = false;
|
||||
const interactionEvents = ['focusin', 'mousedown', 'click', 'keydown', 'input'];
|
||||
for (const event of interactionEvents) {
|
||||
el.addEventListener(event, () => { interacted = true }, {capture: true});
|
||||
}
|
||||
|
||||
let reloadMergeBox: () => Promise<void>;
|
||||
const stopReloading = () => {
|
||||
if (!timerId) return;
|
||||
@@ -105,7 +61,7 @@ export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
timerId = null;
|
||||
};
|
||||
const startReloading = () => {
|
||||
if (timerId) return;
|
||||
if (timerId || interacted) return;
|
||||
setTimeout(reloadMergeBox, reloadingInterval);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
@@ -116,6 +72,7 @@ export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
}
|
||||
};
|
||||
reloadMergeBox = async () => {
|
||||
if (interacted) return;
|
||||
const resp = await GET(`${pullLink}/merge_box`);
|
||||
stopReloading();
|
||||
if (!resp.ok) {
|
||||
@@ -123,8 +80,13 @@ export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
return;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
const newElem = createElementFromHTML(await resp.text());
|
||||
executeScripts(newElem);
|
||||
|
||||
const respText = (await resp.text()).trim();
|
||||
if (!respText) {
|
||||
el.remove(); // merge box might not exist if the PR has changed (e.g.: merged and the head branch has been deleted)
|
||||
return;
|
||||
}
|
||||
const newElem = createElementFromHTML(respText);
|
||||
el.replaceWith(newElem);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
|
||||
export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMainContent: Element) {
|
||||
@@ -28,12 +29,10 @@ export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMa
|
||||
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
|
||||
// because the label operations can be merged into one event item, so the new item might be different from the old one
|
||||
oldItem.replaceWith(newItem);
|
||||
window.htmx.process(newItem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
timelineEnd.insertAdjacentElement('beforebegin', newItem);
|
||||
window.htmx.process(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +43,7 @@ export class IssueSidebarComboList {
|
||||
elDropdown: HTMLElement;
|
||||
elList: HTMLElement | null;
|
||||
elComboValue: HTMLInputElement;
|
||||
initialValues: string[];
|
||||
initialValues: string[] = [];
|
||||
container: HTMLElement;
|
||||
|
||||
elIssueMainContent: HTMLElement;
|
||||
@@ -71,7 +70,7 @@ export class IssueSidebarComboList {
|
||||
|
||||
updateUiList(changedValues: Array<string>) {
|
||||
if (!this.elList) return;
|
||||
const elEmptyTip = this.elList.querySelector('.item.empty-list')!;
|
||||
const elEmptyTip = this.elList.querySelector(':scope > .item.empty-list')!;
|
||||
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
||||
for (const value of changedValues) {
|
||||
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
@@ -92,7 +91,6 @@ export class IssueSidebarComboList {
|
||||
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
|
||||
const newSidebar = doc.querySelector('.issue-content-right')!;
|
||||
this.elIssueSidebar.replaceWith(newSidebar);
|
||||
window.htmx.process(newSidebar);
|
||||
|
||||
// for the main content (left side), at the moment we only support handling known timeline items
|
||||
const newMainContent = doc.querySelector('.issue-content-left')!;
|
||||
@@ -132,7 +130,7 @@ export class IssueSidebarComboList {
|
||||
await this.reloadPagePartially();
|
||||
} catch (e) {
|
||||
console.error('Failed to update to backend', e);
|
||||
showErrorToast(`Failed to update to backend: ${e}`);
|
||||
showErrorToast(`Failed to update to backend: ${errorMessage(e)}`);
|
||||
} finally {
|
||||
this.elIssueSidebar.classList.remove('is-loading');
|
||||
}
|
||||
@@ -141,7 +139,7 @@ export class IssueSidebarComboList {
|
||||
async doUpdate() {
|
||||
const changedValues = this.collectCheckedValues();
|
||||
if (this.initialValues.join(',') === changedValues.join(',')) return;
|
||||
this.updateUiList(changedValues);
|
||||
if (!this.updateUrl) this.updateUiList(changedValues);
|
||||
if (this.updateUrl) await this.updateToBackend(changedValues);
|
||||
this.initialValues = changedValues;
|
||||
}
|
||||
@@ -198,7 +196,9 @@ export class IssueSidebarComboList {
|
||||
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
elItem?.classList.add('checked');
|
||||
}
|
||||
this.updateUiList(values);
|
||||
if (this.elList && this.elList.getAttribute('data-combo-list-inited') !== 'true') {
|
||||
this.updateUiList(values);
|
||||
}
|
||||
}
|
||||
this.initialValues = this.collectCheckedValues();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {html, htmlEscape, htmlRaw} from '../utils/html.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {
|
||||
addDelegatedEventListener,
|
||||
@@ -10,11 +11,11 @@ import {
|
||||
} from '../utils/dom.ts';
|
||||
import {setFileFolding} from './file-fold.ts';
|
||||
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
@@ -26,7 +27,7 @@ function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
|
||||
const queryLabels = url.searchParams.get('labels') || '';
|
||||
const selectedLabelIds = new Set<string>();
|
||||
for (const id of queryLabels ? queryLabels.split(',') : []) {
|
||||
selectedLabelIds.add(`${Math.abs(parseInt(id))}`); // "labels" contains negative ids, which are excluded
|
||||
selectedLabelIds.add(String(Math.abs(parseInt(id)))); // "labels" contains negative ids, which are excluded
|
||||
}
|
||||
|
||||
const excludeLabel = (e: MouseEvent | KeyboardEvent, item: Element) => {
|
||||
@@ -128,9 +129,9 @@ export function initRepoIssueCommentDelete() {
|
||||
// on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment"
|
||||
if (lineType) {
|
||||
if (lineType === 'same') {
|
||||
document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`)!.classList.remove('tw-invisible');
|
||||
document.querySelector(`[data-path="${CSS.escape(String(path))}"] .add-code-comment[data-idx="${CSS.escape(String(idx))}"]`)!.classList.remove('tw-invisible');
|
||||
} else {
|
||||
document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`)!.classList.remove('tw-invisible');
|
||||
document.querySelector(`[data-path="${CSS.escape(String(path))}"] .add-code-comment[data-side="${CSS.escape(String(side))}"][data-idx="${CSS.escape(String(idx))}"]`)!.classList.remove('tw-invisible');
|
||||
}
|
||||
}
|
||||
conversationHolder.remove();
|
||||
@@ -196,8 +197,9 @@ export async function handleReply(el: HTMLElement) {
|
||||
}
|
||||
|
||||
export function initRepoPullRequestReview() {
|
||||
if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
|
||||
const commentDiv = document.querySelector(window.location.hash);
|
||||
const currentHash = window.location.hash;
|
||||
if (currentHash.startsWith('#issuecomment-') || currentHash.startsWith('#pullrequestreview-')) {
|
||||
const commentDiv = document.querySelector(currentHash);
|
||||
if (commentDiv) {
|
||||
// get the name of the parent id
|
||||
const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
|
||||
@@ -272,15 +274,13 @@ export function initRepoPullRequestReview() {
|
||||
|
||||
let ntr = tr.nextElementSibling;
|
||||
if (!ntr?.classList.contains('add-comment')) {
|
||||
ntr = createElementFromHTML(`
|
||||
<tr class="add-comment" data-line-type="${htmlEscape(lineType)}">
|
||||
${isSplit ? `
|
||||
<td class="add-comment-left" colspan="4"></td>
|
||||
<td class="add-comment-right" colspan="4"></td>
|
||||
` : `
|
||||
<td class="add-comment-left add-comment-right" colspan="5"></td>
|
||||
`}
|
||||
</tr>`);
|
||||
const tdSplit = html`<td class="add-comment-left" colspan="4"></td><td class="add-comment-right" colspan="4"></td>`;
|
||||
const tdUnified = html`<td class="add-comment-left add-comment-right" colspan="5"></td>`;
|
||||
ntr = createElementFromHTML(html`
|
||||
<tr class="add-comment" data-line-type="${lineType}">
|
||||
${isSplit ? htmlRaw(tdSplit) : htmlRaw(tdUnified)}
|
||||
</tr>
|
||||
`);
|
||||
tr.after(ntr);
|
||||
}
|
||||
const td = ntr.querySelector(`.add-comment-${side}`)!;
|
||||
@@ -329,12 +329,12 @@ export function initRepoIssueReferenceIssue() {
|
||||
const target = el.getAttribute('data-target');
|
||||
const content = document.querySelector(`#${target}`)?.textContent ?? '';
|
||||
const poster = el.getAttribute('data-poster-username');
|
||||
const reference = toAbsoluteUrl(el.getAttribute('data-reference')!);
|
||||
const reference = el.getAttribute('data-reference')!;
|
||||
const modalSelector = el.getAttribute('data-modal')!;
|
||||
const modal = document.querySelector(modalSelector)!;
|
||||
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
|
||||
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
|
||||
fomanticQuery(modal).modal('show');
|
||||
showFomanticModal(modal);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ export function initRepoIssueTitleEdit() {
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showErrorToast(error.message);
|
||||
showErrorToast(errorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {initRepoMilestone} from './repo-milestone.ts';
|
||||
import {initRepoNew} from './repo-new.ts';
|
||||
import {createApp} from 'vue';
|
||||
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
|
||||
import {initRepoPullMergeBox} from './repo-issue-pull.ts';
|
||||
import {initRepoPullMergeBox, initRepoPullRequestUpdate} from './repo-issue-pull.ts';
|
||||
|
||||
function initRepoBranchTagSelector() {
|
||||
registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
|
||||
@@ -38,6 +38,9 @@ export function initBranchSelectorTabs() {
|
||||
}
|
||||
|
||||
export function initRepository() {
|
||||
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
|
||||
registerGlobalInitFunc('initRepoPullRequestUpdate', initRepoPullRequestUpdate);
|
||||
|
||||
const pageContent = document.querySelector('.page-content.repository');
|
||||
if (!pageContent) return;
|
||||
|
||||
@@ -68,8 +71,6 @@ export function initRepository() {
|
||||
initRepoIssueCommentDelete();
|
||||
initRepoIssueCodeCommentCancel();
|
||||
initCompReactionSelector();
|
||||
|
||||
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
|
||||
}
|
||||
|
||||
initUnicodeEscapeButton();
|
||||
|
||||
@@ -47,7 +47,6 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
|
||||
value: String(tmplRepo.repository.id),
|
||||
});
|
||||
}
|
||||
$repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value);
|
||||
return {results};
|
||||
},
|
||||
cache: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {contrastColor} from '../utils/color.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {POST, request} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import type {SortableEvent} from 'sortablejs';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
@@ -120,11 +120,11 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
||||
}
|
||||
|
||||
// update the newly saved column title and color in the project board (to avoid reload)
|
||||
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`)!;
|
||||
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${CSS.escape(attrDataColumnId)}="${CSS.escape(columnId)}"]`)!;
|
||||
elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value);
|
||||
elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value);
|
||||
|
||||
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${columnId}"]`)!;
|
||||
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${CSS.escape(columnId)}"]`)!;
|
||||
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
|
||||
elBoardColumnTitle.textContent = elColumnTitle.value;
|
||||
if (elColumnColor.value) {
|
||||
@@ -138,7 +138,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
||||
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
|
||||
}
|
||||
|
||||
fomanticQuery(elModal).modal('hide');
|
||||
hideFomanticModal(elModal);
|
||||
} finally {
|
||||
elForm.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {hideElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {compareVersions} from 'compare-versions';
|
||||
@@ -11,7 +12,7 @@ export function initRepoReleaseNew() {
|
||||
registerGlobalEventFunc('click', 'onReleaseEditAttachmentDelete', (el) => {
|
||||
const uuid = el.getAttribute('data-uuid')!;
|
||||
const id = el.getAttribute('data-id')!;
|
||||
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
|
||||
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${CSS.escape(uuid)}']`)!.value = 'true';
|
||||
hideElem(`#attachment-${id}`);
|
||||
});
|
||||
registerGlobalInitFunc('initReleaseEditForm', (elForm: HTMLFormElement) => {
|
||||
@@ -112,7 +113,7 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
}
|
||||
} finally {
|
||||
elModal.classList.remove('loading', 'disabled');
|
||||
fomanticQuery(elModal).modal('hide');
|
||||
hideFomanticModal(elModal);
|
||||
comboEditor.focus();
|
||||
}
|
||||
};
|
||||
@@ -139,12 +140,12 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
}
|
||||
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
|
||||
|
||||
fomanticQuery(elModal).modal({
|
||||
showFomanticModal(elModal, {
|
||||
onApprove: () => {
|
||||
doSubmit(tagName); // don't await, need to return false to keep the modal
|
||||
return false;
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
};
|
||||
|
||||
buttonShowModal.addEventListener('click', doShowModal);
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
||||
|
||||
export function initRepositorySearch() {
|
||||
const repositorySearchForm = document.querySelector<HTMLFormElement>('#repo-search-form');
|
||||
if (!repositorySearchForm) return;
|
||||
|
||||
repositorySearchForm.addEventListener('change', (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of new FormData(repositorySearchForm).entries()) {
|
||||
params.set(key, value as string);
|
||||
}
|
||||
if ((e.target as HTMLInputElement).name === 'clear-filter') {
|
||||
params.delete('archived');
|
||||
params.delete('fork');
|
||||
params.delete('mirror');
|
||||
params.delete('template');
|
||||
params.delete('private');
|
||||
}
|
||||
|
||||
params.delete('clear-filter');
|
||||
window.location.search = params.toString();
|
||||
registerGlobalInitFunc('initRepositorySearch', (form: HTMLFormElement) => {
|
||||
addDelegatedEventListener(form, 'change', 'input[type="radio"]', () => form.submit());
|
||||
form.querySelector('.repo-search-filter-reset')!.addEventListener('click', () => {
|
||||
queryElems(form, 'input[type="radio"]', (el: HTMLInputElement) => el.checked = false);
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ vi.mock('../modules/sortable.ts', () => ({
|
||||
|
||||
const branchesHTML = `
|
||||
<div id="protected-branches-list" data-update-priority-url="some/repo/branches/priority">
|
||||
<div class="flex-item tw-items-center item" data-id="1">
|
||||
<div class="item" data-id="1">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
<div class="flex-item tw-items-center item" data-id="2">
|
||||
<div class="item" data-id="2">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
<div class="flex-item tw-items-center item" data-id="3">
|
||||
<div class="item" data-id="3">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {createSortable} from '../modules/sortable.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {queryElemChildren} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
|
||||
export function initRepoSettingsBranchesDrag() {
|
||||
const protectedBranchesList = document.querySelector<HTMLElement>('#protected-branches-list');
|
||||
@@ -23,8 +24,7 @@ export function initRepoSettingsBranchesDrag() {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = String(err);
|
||||
showErrorToast(`Failed to update branch protection rule priority:, error: ${errorMessage}`);
|
||||
showErrorToast(`Failed to update branch protection rule priority: ${errorMessage(err)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {attachSearchBox} from '../modules/search.ts';
|
||||
import {globMatch} from '../utils/glob.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
@@ -17,16 +18,21 @@ function initRepoSettingsCollaboration() {
|
||||
dropdownEl.classList.add('is-loading', 'loading-icon-2px');
|
||||
const lastValue = dropdownEl.getAttribute('data-last-value')!;
|
||||
$dropdown.dropdown('hide');
|
||||
let respOk = false;
|
||||
try {
|
||||
const uid = dropdownEl.getAttribute('data-uid')!;
|
||||
await POST(dropdownEl.getAttribute('data-url')!, {data: new URLSearchParams({uid, 'mode': value})});
|
||||
textEl.textContent = text;
|
||||
dropdownEl.setAttribute('data-last-value', value);
|
||||
} catch {
|
||||
textEl.textContent = '(error)'; // prevent from misleading users when error occurs
|
||||
dropdownEl.setAttribute('data-last-value', lastValue);
|
||||
const resp = await POST(dropdownEl.getAttribute('data-url')!, {data: new URLSearchParams({uid, 'mode': value})});
|
||||
respOk = resp.ok;
|
||||
if (respOk) {
|
||||
textEl.textContent = text;
|
||||
dropdownEl.setAttribute('data-last-value', value);
|
||||
}
|
||||
} finally {
|
||||
dropdownEl.classList.remove('is-loading');
|
||||
if (!respOk) {
|
||||
textEl.textContent = '(error)'; // prevent from misleading users when error occurs
|
||||
dropdownEl.setAttribute('data-last-value', lastValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
onHide() {
|
||||
@@ -45,29 +51,17 @@ function initRepoSettingsCollaboration() {
|
||||
}
|
||||
}
|
||||
|
||||
function initRepoSettingsSearchTeamBox() {
|
||||
const searchTeamBox = document.querySelector('#search-team-box');
|
||||
if (!searchTeamBox) return;
|
||||
type TeamSearchResponse = {data: Array<{name: string; permission: string}>};
|
||||
|
||||
fomanticQuery(searchTeamBox).search({
|
||||
minCharacters: 2,
|
||||
searchFields: ['name', 'description'],
|
||||
showNoResults: false,
|
||||
rawResponse: true,
|
||||
apiSettings: {
|
||||
url: `${appSubUrl}/org/${searchTeamBox.getAttribute('data-org-name')}/teams/-/search?q={query}`,
|
||||
onResponse(response: any) {
|
||||
const items: Array<Record<string, any>> = [];
|
||||
for (const item of response.data) {
|
||||
items.push({
|
||||
title: item.name,
|
||||
description: `${item.permission} access`, // TODO: translate this string
|
||||
});
|
||||
}
|
||||
return {results: items};
|
||||
},
|
||||
},
|
||||
});
|
||||
function initRepoSettingsSearchTeamBox() {
|
||||
const box = document.querySelector<HTMLElement>('#search-team-box');
|
||||
if (!box) return;
|
||||
|
||||
const url = `${appSubUrl}/org/${box.getAttribute('data-org-name')}/teams/-/search?q={query}`;
|
||||
attachSearchBox(box, url, (response: TeamSearchResponse) => response.data.map((item) => ({
|
||||
title: item.name,
|
||||
description: `${item.permission} access`, // TODO: translate this string
|
||||
})));
|
||||
}
|
||||
|
||||
function initRepoSettingsGitHook() {
|
||||
|
||||
@@ -50,9 +50,9 @@ export async function attachTribute(element: HTMLElement) {
|
||||
|
||||
const tribute = new Tribute({
|
||||
collection: [
|
||||
emojiCollection as TributeCollection<any>,
|
||||
mentionCollection as TributeCollection<any>,
|
||||
],
|
||||
emojiCollection,
|
||||
mentionCollection,
|
||||
] as TributeCollection<any>[],
|
||||
noMatchTemplate: () => '',
|
||||
});
|
||||
tribute.attach(element);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
@@ -78,9 +79,9 @@ async function loginPasskey() {
|
||||
}
|
||||
const reply = await res.json();
|
||||
|
||||
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
|
||||
window.location.assign(reply?.redirect ?? `${appSubUrl}/`);
|
||||
} catch (err) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ async function login2FA() {
|
||||
await verifyAssertion(credential);
|
||||
} catch (err) {
|
||||
if (!options.publicKey.extensions?.appid) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
return;
|
||||
}
|
||||
delete options.publicKey.extensions.appid;
|
||||
@@ -114,7 +115,7 @@ async function login2FA() {
|
||||
});
|
||||
await verifyAssertion(credential);
|
||||
} catch (err) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +151,7 @@ async function verifyAssertion(assertedCredential: any) { // TODO: Credential ty
|
||||
}
|
||||
const reply = await res.json();
|
||||
|
||||
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
|
||||
window.location.assign(reply?.redirect ?? `${appSubUrl}/`);
|
||||
}
|
||||
|
||||
async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work
|
||||
@@ -187,7 +188,7 @@ function webAuthnError(errorType: ErrorType, message:string = '') {
|
||||
if (errorType === 'general') {
|
||||
elErrorMsg.textContent = message || 'unknown error';
|
||||
} else {
|
||||
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`);
|
||||
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${CSS.escape(errorType)}]`);
|
||||
if (elTypedError) {
|
||||
elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`;
|
||||
} else {
|
||||
@@ -262,6 +263,11 @@ async function webAuthnRegisterRequest() {
|
||||
});
|
||||
await webauthnRegistered(credential);
|
||||
} catch (err) {
|
||||
webAuthnError('unknown', err);
|
||||
// an already registered authenticator raises this
|
||||
if (err instanceof DOMException && err.name === 'InvalidStateError') {
|
||||
webAuthnError('duplicated');
|
||||
return;
|
||||
}
|
||||
webAuthnError('unknown', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-15
@@ -1,13 +1,10 @@
|
||||
interface JQuery {
|
||||
areYouSure: any, // jquery.are-you-sure
|
||||
fomanticExt: any; // fomantic extension
|
||||
api: any, // fomantic
|
||||
dimmer: any, // fomantic
|
||||
dropdown: any; // fomantic
|
||||
modal: any; // fomantic
|
||||
tab: any; // fomantic
|
||||
transition: any, // fomantic
|
||||
search: any, // fomantic
|
||||
}
|
||||
|
||||
interface JQueryStatic {
|
||||
@@ -41,7 +38,6 @@ interface Window {
|
||||
FolderOpenIcon?: string,
|
||||
repoLink?: string,
|
||||
repoActivityTopAuthors?: any[],
|
||||
pullRequestMergeForm?: Record<string, any>,
|
||||
dashboardRepoList?: Record<string, any>,
|
||||
},
|
||||
notificationSettings: {
|
||||
@@ -53,10 +49,10 @@ interface Window {
|
||||
enableTimeTracking: boolean,
|
||||
mermaidMaxSourceCharacters: number,
|
||||
i18n: Record<string, string>,
|
||||
frontendInited: boolean,
|
||||
},
|
||||
$: JQueryStatic,
|
||||
jQuery: JQueryStatic,
|
||||
htmx: typeof import('htmx.org').default,
|
||||
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
|
||||
_inited: boolean,
|
||||
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
|
||||
@@ -68,19 +64,14 @@ interface Window {
|
||||
turnstile: any,
|
||||
hcaptcha: any,
|
||||
|
||||
// Make IIFE private functions can be tested in unit tests, without exposing the IIFE module to global scope.
|
||||
// Make IIFE private functions can be managed by us in our scope, without exposing the IIFE module to global scope.
|
||||
// Otherwise, when using "export" in IIFE code, the compiled JS will inject global "var externalRenderHelper = ..."
|
||||
// which is not expected and may cause conflicts with other modules.
|
||||
testModules: {
|
||||
externalRenderHelper?: {
|
||||
isValidCssColor(s: string | null): boolean,
|
||||
}
|
||||
giteaExternalRenderHelper?: {
|
||||
isValidCssColor(s: string | null): boolean,
|
||||
queryParams: URLSearchParams,
|
||||
postIframeMsg(cmd: string, data: Record<string, any> = {}),
|
||||
}
|
||||
|
||||
// do not add more properties here unless it is a must
|
||||
}
|
||||
|
||||
declare module '*?worker' {
|
||||
const workerConstructor: new () => Worker;
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
|
||||
import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
|
||||
import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports
|
||||
|
||||
// Some users still use inline scripts and expect jQuery to be available globally.
|
||||
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
|
||||
window.$ = window.jQuery = jquery;
|
||||
|
||||
// There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
|
||||
// The bug makes htmx impossible to be loaded from an ES module: importing the htmx in onDomReady will make htmx skip its initialization.
|
||||
// ref: https://github.com/bigskysoftware/htmx/pull/3365
|
||||
window.htmx = htmx;
|
||||
|
||||
// https://htmx.org/reference/#config
|
||||
htmx.config.requestClass = 'is-loading';
|
||||
htmx.config.scrollIntoViewOnBoost = false;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import '../fomantic/build/fomantic.js';
|
||||
import '../css/index.css';
|
||||
import type {HtmxResponseInfo} from 'htmx.org';
|
||||
import {showErrorToast} from './modules/toast.ts';
|
||||
|
||||
import {initDashboardRepoList} from './features/dashboard.ts';
|
||||
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
||||
import {initGlobalCopyToClipboardListener} from './modules/clipboard.ts';
|
||||
import {initCopyContent} from './features/copycontent.ts';
|
||||
import {initRepoGraphGit} from './features/repo-graph.ts';
|
||||
import {initHeatmap} from './features/heatmap.ts';
|
||||
import {initImageDiff} from './features/imagediff.ts';
|
||||
@@ -22,7 +21,7 @@ import {initMarkupContent} from './markup/content.ts';
|
||||
import {initRepoFileView} from './features/file-view.ts';
|
||||
import {initUserExternalLogins, initUserCheckAppUrl} from './features/user-auth.ts';
|
||||
import {initRepoPullRequestReview, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
|
||||
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
|
||||
import {initRepoEllipsisButton, initCommitStatuses, initAvatarStackPopup, initCommitFileHistoryFollowRename} from './features/repo-commit.ts';
|
||||
import {initRepoTopicBar} from './features/repo-home.ts';
|
||||
import {initAdminCommon} from './features/admin/common.ts';
|
||||
import {initRepoCodeView} from './features/repo-code.ts';
|
||||
@@ -42,12 +41,10 @@ import {initRepoBranchButton} from './features/repo-branch.ts';
|
||||
import {initCommonOrganization} from './features/common-organization.ts';
|
||||
import {initRepoWikiForm} from './features/repo-wiki.ts';
|
||||
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
|
||||
import {initCopyContent} from './features/copycontent.ts';
|
||||
import {initCaptcha} from './features/captcha.ts';
|
||||
import {initRepositoryActionView} from './features/repo-actions.ts';
|
||||
import {initRepositoryActions} from './features/repo-actions.ts';
|
||||
import {initGlobalTooltips} from './modules/tippy.ts';
|
||||
import {initGiteaFomantic} from './modules/fomantic.ts';
|
||||
import {initSubmitEventPolyfill} from './utils/dom.ts';
|
||||
import {initRepoIssueList} from './features/repo-issue-list.ts';
|
||||
import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts';
|
||||
import {initRepoContributors} from './features/contributors.ts';
|
||||
@@ -61,17 +58,17 @@ import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
|
||||
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
|
||||
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
|
||||
import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts';
|
||||
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
|
||||
import {initGlobalButtonClickOnEnter, initGlobalButtons} from './features/common-button.ts';
|
||||
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
|
||||
import {callInitFunctions} from './modules/init.ts';
|
||||
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
|
||||
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
|
||||
import {initRefIssueContextPopup} from './features/ref-issue.ts';
|
||||
import {initGlobalShortcut} from './modules/shortcut.ts';
|
||||
import {initDevtest} from './modules/devtest.ts';
|
||||
|
||||
const initStartTime = performance.now();
|
||||
const initPerformanceTracer = callInitFunctions([
|
||||
initSubmitEventPolyfill,
|
||||
initGiteaFomantic,
|
||||
|
||||
initGlobalComponent,
|
||||
@@ -84,7 +81,6 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initGlobalEnterQuickSubmit,
|
||||
initGlobalFormDirtyLeaveConfirm,
|
||||
initGlobalComboMarkdownEditor,
|
||||
initGlobalDeleteButton,
|
||||
initGlobalInput,
|
||||
initGlobalShortcut,
|
||||
|
||||
@@ -102,6 +98,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initImageDiff,
|
||||
initMarkupAnchors,
|
||||
initMarkupContent,
|
||||
initRefIssueContextPopup,
|
||||
initSshKeyFormParser,
|
||||
initStopwatch,
|
||||
initTableSort,
|
||||
@@ -125,6 +122,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initRepoCodeView,
|
||||
initBranchSelectorTabs,
|
||||
initRepoEllipsisButton,
|
||||
initCommitFileHistoryFollowRename,
|
||||
initRepoDiffCommitBranchesAndTags,
|
||||
initRepoEditor,
|
||||
initRepoGraphGit,
|
||||
@@ -140,13 +138,14 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initRepoViewFileTree,
|
||||
initRepoWikiForm,
|
||||
initRepository,
|
||||
initRepositoryActionView,
|
||||
initRepositoryActions,
|
||||
initRepositorySearch,
|
||||
initRepoContributors,
|
||||
initRepoCodeFrequency,
|
||||
initRepoRecentCommits,
|
||||
|
||||
initCommitStatuses,
|
||||
initAvatarStackPopup,
|
||||
initCaptcha,
|
||||
|
||||
initUserCheckAppUrl,
|
||||
@@ -174,16 +173,4 @@ if (initDur > 500) {
|
||||
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
|
||||
}
|
||||
|
||||
// https://htmx.org/events/#htmx:sendError
|
||||
type HtmxEvent = Event & {detail: HtmxResponseInfo};
|
||||
document.body.addEventListener('htmx:sendError', (event) => {
|
||||
// TODO: add translations
|
||||
showErrorToast(`Network error when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
|
||||
});
|
||||
// https://htmx.org/events/#htmx:responseError
|
||||
document.body.addEventListener('htmx:responseError', (event) => {
|
||||
// TODO: add translations
|
||||
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
|
||||
});
|
||||
|
||||
document.dispatchEvent(new CustomEvent('gitea:index-ready'));
|
||||
window.config.frontendInited = true;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
|
||||
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
|
||||
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
|
||||
const [player] = await Promise.all([
|
||||
import('asciinema-player'),
|
||||
import('asciinema-player/dist/bundle/asciinema-player.css'),
|
||||
]);
|
||||
|
||||
player.create(el.getAttribute('data-asciinema-player-src')!, el, {
|
||||
// poster (a preview frame) to display until the playback is started.
|
||||
// Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more.
|
||||
poster: 'npt:1:0:0',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
export function displayError(el: Element, err: Error): void {
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
|
||||
export function displayError(el: Element, err: unknown): void {
|
||||
el.classList.remove('is-loading');
|
||||
const errorNode = document.createElement('pre');
|
||||
errorNode.setAttribute('class', 'ui message error markup-block-error');
|
||||
errorNode.textContent = err.message || String(err);
|
||||
errorNode.textContent = errorMessage(err);
|
||||
el.before(errorNode);
|
||||
el.setAttribute('data-render-done', 'true');
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {initMarkupCodeMermaid} from './mermaid.ts';
|
||||
import {initMarkupCodeMath} from './math.ts';
|
||||
import {initMarkupCodeCopy} from './codecopy.ts';
|
||||
import {initMarkupRenderAsciicast} from './asciicast.ts';
|
||||
import {initMarkupTasklist} from './tasklist.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initExternalRenderIframe} from './render-iframe.ts';
|
||||
import {initMarkupRefIssue} from './refissue.ts';
|
||||
import {toggleElemClass} from '../utils/dom.ts';
|
||||
|
||||
// code that runs for all markup content
|
||||
@@ -25,7 +23,5 @@ export function initMarkupContent(): void {
|
||||
initMarkupTasklist(el);
|
||||
initMarkupCodeMermaid(el);
|
||||
initMarkupCodeMath(el);
|
||||
initMarkupRenderAsciicast(el);
|
||||
initMarkupRefIssue(el);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,15 +18,9 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
|
||||
const level = parseInt(el.tagName.slice(1));
|
||||
el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`;
|
||||
},
|
||||
STRONG(el: HTMLElement) {
|
||||
return `**${el.textContent}**`;
|
||||
},
|
||||
EM(el: HTMLElement) {
|
||||
return `_${el.textContent}_`;
|
||||
},
|
||||
DEL(el: HTMLElement) {
|
||||
return `~~${el.textContent}~~`;
|
||||
},
|
||||
STRONG: (el: HTMLElement) => `**${el.textContent}**`,
|
||||
EM: (el: HTMLElement) => `_${el.textContent}_`,
|
||||
DEL: (el: HTMLElement) => `~~${el.textContent}~~`,
|
||||
A(el: HTMLElement) {
|
||||
const text = el.textContent || 'link';
|
||||
const href = el.getAttribute('href');
|
||||
@@ -62,9 +56,7 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
|
||||
el.textContent = `${' '.repeat(nestingIdentLevel * 4)}${bullet}${el.textContent}${ctx.elementIsLast ? '' : '\n'}`;
|
||||
return el;
|
||||
},
|
||||
INPUT(el: HTMLElement) {
|
||||
return (el as HTMLInputElement).checked ? '[x] ' : '[ ] ';
|
||||
},
|
||||
INPUT: (el: HTMLElement) => (el as HTMLInputElement).checked ? '[x] ' : '[ ] ',
|
||||
CODE(el: HTMLElement) {
|
||||
const text = el.textContent;
|
||||
if (el.parentNode && (el.parentNode as HTMLElement).tagName === 'PRE') {
|
||||
@@ -86,8 +78,8 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
|
||||
|
||||
function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement): string | void {
|
||||
if (el.hasAttribute('data-markdown-generated-content')) return el.textContent;
|
||||
if (el.tagName === 'A' && el.children.length === 1 && el.children[0].tagName === 'IMG') {
|
||||
return processElement(ctx, processors, el.children[0] as HTMLElement);
|
||||
if (el.tagName === 'A' && el.children.length === 1 && el.firstElementChild!.tagName === 'IMG') {
|
||||
return processElement(ctx, processors, el.firstElementChild as HTMLElement);
|
||||
}
|
||||
|
||||
const isListContainer = el.tagName === 'OL' || el.tagName === 'UL';
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {parseIssueHref} from '../utils.ts';
|
||||
import {createApp} from 'vue';
|
||||
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
|
||||
|
||||
export function initMarkupRefIssue(el: HTMLElement) {
|
||||
queryElems(el, '.ref-issue', (el) => {
|
||||
el.addEventListener('mouseenter', showMarkupRefIssuePopup);
|
||||
el.addEventListener('focus', showMarkupRefIssuePopup);
|
||||
});
|
||||
}
|
||||
|
||||
function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
|
||||
const refIssue = e.currentTarget as HTMLElement;
|
||||
if (getAttachedTippyInstance(refIssue)) return;
|
||||
if (refIssue.classList.contains('ref-external-issue')) return;
|
||||
|
||||
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href')!);
|
||||
if (!issuePathInfo.ownerName) return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
const onShowAsync = async () => {
|
||||
const {default: ContextPopup} = await import('../components/ContextPopup.vue');
|
||||
const view = createApp(ContextPopup, {
|
||||
// backend: GetIssueInfo
|
||||
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
|
||||
});
|
||||
view.mount(el);
|
||||
};
|
||||
const tippy = createTippy(refIssue, {
|
||||
theme: 'default',
|
||||
content: el,
|
||||
trigger: 'mouseenter focus',
|
||||
placement: 'top-start',
|
||||
interactive: true,
|
||||
role: 'dialog',
|
||||
interactiveBorder: 5,
|
||||
// onHide() { return false }, // help to keep the popup and debug the layout
|
||||
onShow: () => { onShowAsync() },
|
||||
});
|
||||
tippy.show();
|
||||
}
|
||||
@@ -29,7 +29,6 @@ describe('navigateToIframeLink', () => {
|
||||
|
||||
test('unsafe links', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
window.location.href = 'http://localhost:3000/';
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
navigateToIframeLink('javascript:void(0);', '_blank');
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import {generateElemId} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
function safeRenderIframeLink(link: any): string | null {
|
||||
try {
|
||||
const url = new URL(`${link}`, window.location.href);
|
||||
const url = new URL(link, window.location.href);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
console.error(`Unsupported link protocol: ${link}`);
|
||||
return null;
|
||||
}
|
||||
return url.href;
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse link: ${link}, error: ${e}`);
|
||||
console.error(`Failed to parse link: ${link}, error: ${errorMessage(e)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,31 @@ export async function initExternalRenderIframe(iframe: HTMLIFrameElement) {
|
||||
u.searchParams.set('gitea-iframe-id', iframe.id);
|
||||
u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe));
|
||||
|
||||
// It must use "srcdoc" here, because our backend always sends CSP sandbox directive for the rendered content
|
||||
// (to protect from XSS risks), so we can't use "src" to load the content directly, otherwise there will be console errors like:
|
||||
// Unsafe attempt to load URL http://localhost:3000/test from frame with URL http://localhost:3000/test
|
||||
const resp = await GET(u.href);
|
||||
iframe.srcdoc = await resp.text();
|
||||
// There are 3 kinds of external render modes:
|
||||
// * external frontend render:
|
||||
// * parent page creates iframe, iframe navigates to render page
|
||||
// * render generates frame page with external-render-helper (injected), external-render-frontend and file content (hidden textarea)
|
||||
// * frame page executes external-render-frontend JS code to finds a frontend plugin to render
|
||||
// * external backend render (HTML)
|
||||
// * parent page creates iframe, iframe navigates to render page
|
||||
// * render executes command to generate rendered HTML content with external-render-helper (injected)
|
||||
// * frame page displays the rendered content
|
||||
// * external backend render (non-HTML, e.g.: PDF, image)
|
||||
// * parent page creates iframe, iframe navigates to render page
|
||||
// * render executes command to generate rendered content
|
||||
// * response header is automatically detected from rendered content
|
||||
|
||||
// It must use "src" here, because the frame content should not inherit parent's CSP.
|
||||
// Otherwise, "srcdoc" makes the frame content inherit the parent's CSP,
|
||||
// then some renders like "asciicast (asciinema)" which require "unsafe-eval" won't work.
|
||||
//
|
||||
// When using "src", Chrome can report false-alarm error like:
|
||||
// * Unsafe attempt to load URL http://localhost/owner/repo/render/branch/main/file from frame with URL http://localhost/owner/repo/render/branch/main/file. Domains, protocols and ports must match.
|
||||
// (only for the first time that the developer opens the browser console)
|
||||
// Such error log can also appear even if you access the link "http://.../owner/repo/render/branch/main/file" directly.
|
||||
// Everything just works, it is just a false-alarm caused by Chrome's Developer Tools, so such error log can be ignored.
|
||||
//
|
||||
// Another reason for why "src" is a must: if the render outputs non-HTML contents like PDF or image,
|
||||
// Only "src" can correctly load and display the rendered content, "srcdoc" won't work.
|
||||
iframe.src = u.href;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {getActionStatusIcon} from './action-status-icon.ts';
|
||||
|
||||
test('getActionStatusIcon', () => {
|
||||
expect(getActionStatusIcon('success')).toEqual({name: 'octicon-check', colorClass: 'tw-text-green'});
|
||||
expect(getActionStatusIcon('success', 'circle-fill')).toEqual({name: 'octicon-check-circle-fill', colorClass: 'tw-text-green'});
|
||||
expect(getActionStatusIcon('running')).toEqual({name: 'gitea-running', colorClass: 'tw-text-yellow'});
|
||||
expect(getActionStatusIcon('failure', 'circle-fill')).toEqual({name: 'octicon-x-circle-fill', colorClass: 'tw-text-red'});
|
||||
expect(getActionStatusIcon('cancelled')).toEqual({name: 'octicon-stop', colorClass: 'tw-text-text-light'});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {SvgName} from '../svg.ts';
|
||||
import type {ActionsStatus} from './gitea-actions.ts';
|
||||
|
||||
export type ActionStatusIconVariant = 'circle-fill' | '';
|
||||
|
||||
export type ActionStatusIconSpec = {
|
||||
name: SvgName,
|
||||
colorClass: string,
|
||||
};
|
||||
|
||||
// Keep in sync with templates/repo/icons/action_status.tmpl and ActionStatusIcon.vue.
|
||||
export function getActionStatusIcon(status: ActionsStatus, iconVariant: ActionStatusIconVariant = ''): ActionStatusIconSpec {
|
||||
const circleFill = iconVariant === 'circle-fill';
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return {name: circleFill ? 'octicon-check-circle-fill' : 'octicon-check', colorClass: 'tw-text-green'};
|
||||
case 'skipped':
|
||||
return {name: 'octicon-skip', colorClass: 'tw-text-text-light'};
|
||||
case 'cancelled':
|
||||
return {name: 'octicon-stop', colorClass: 'tw-text-text-light'};
|
||||
case 'waiting':
|
||||
return {name: 'octicon-circle', colorClass: 'tw-text-text-light'};
|
||||
case 'blocked':
|
||||
return {name: 'octicon-blocked', colorClass: 'tw-text-yellow'};
|
||||
case 'running':
|
||||
return {name: 'gitea-running', colorClass: 'tw-text-yellow'};
|
||||
case 'cancelling':
|
||||
return {name: 'octicon-stop', colorClass: 'tw-text-yellow'};
|
||||
case 'failure':
|
||||
case 'unknown':
|
||||
return {name: circleFill ? 'octicon-x-circle-fill' : 'octicon-x', colorClass: 'tw-text-red'};
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {clippie, type ClippieContent} from 'clippie';
|
||||
import {showTemporaryTooltip} from './tippy.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
|
||||
const {copy_success, copy_error} = window.config.i18n;
|
||||
const pendingFeedback = new WeakSet<HTMLElement>();
|
||||
|
||||
/** copy the copiable content to clipboard, return "true" on success, otherwise "false" */
|
||||
export async function copyToClipboard(content: ClippieContent): Promise<boolean> {
|
||||
return await clippie(content);
|
||||
}
|
||||
|
||||
/** Copy `content` to the clipboard. `target` is used to:
|
||||
* - avoid duplicate copy actions (especially when the content will be fetched from an async function)
|
||||
* - provide feedback to end users (its `.octicon-copy` is swapped to show success/fail feedback, or a tooltip if it has none)
|
||||
* When `content` is a function, `target` also shows a spinner while it resolves. */
|
||||
export async function copyToClipboardWithFeedback(target: HTMLElement, content: ClippieContent | (() => Promise<ClippieContent>)) {
|
||||
if (pendingFeedback.has(target)) return;
|
||||
pendingFeedback.add(target);
|
||||
|
||||
let success = false;
|
||||
const feedbackSvg = target.querySelector<SVGElement>('.octicon-copy');
|
||||
|
||||
// prepare copiable "content"
|
||||
try {
|
||||
if (typeof content === 'function') {
|
||||
if (feedbackSvg) target.style.setProperty('--loading-size', `${feedbackSvg.getAttribute('width')!}px`);
|
||||
target.classList.add('is-loading', 'loading-icon-2px');
|
||||
try {
|
||||
content = await content();
|
||||
} finally {
|
||||
target.classList.remove('is-loading', 'loading-icon-2px');
|
||||
target.style.removeProperty('--loading-size');
|
||||
}
|
||||
}
|
||||
success = await copyToClipboard(content);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// show feedback
|
||||
if (feedbackSvg) {
|
||||
const restore = replaceWithFeedbackSvg(feedbackSvg, success);
|
||||
await sleep(1000);
|
||||
restore();
|
||||
} else {
|
||||
showTemporaryTooltip(target, success ? copy_success : copy_error);
|
||||
}
|
||||
|
||||
pendingFeedback.delete(target);
|
||||
}
|
||||
|
||||
function replaceWithFeedbackSvg(origSvg: SVGElement, success: boolean): () => void {
|
||||
const size = Number(origSvg.getAttribute('width')!);
|
||||
const {icon, color} = success ?
|
||||
{icon: 'octicon-check', color: 'tw-text-green'} as const :
|
||||
{icon: 'octicon-x', color: 'tw-text-red'} as const;
|
||||
const newSvg = createElementFromHTML<SVGElement>(svg(icon, size, color));
|
||||
origSvg.replaceWith(newSvg);
|
||||
return () => newSvg.replaceWith(origSvg);
|
||||
}
|
||||
|
||||
// Enable clipboard copy from HTML attributes. These properties are supported:
|
||||
// - data-clipboard-text: Direct text to copy
|
||||
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
|
||||
export function initGlobalCopyToClipboardListener() {
|
||||
document.addEventListener('click', async (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]');
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let text = target.getAttribute('data-clipboard-text');
|
||||
if (text === null) {
|
||||
const textSelector = target.getAttribute('data-clipboard-target')!;
|
||||
const textTarget = document.querySelector(textSelector)!;
|
||||
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
|
||||
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
|
||||
} else if (textTarget.nodeName === 'DIV') {
|
||||
text = textTarget.textContent;
|
||||
} else {
|
||||
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
|
||||
}
|
||||
}
|
||||
// now, text can not be null
|
||||
await copyToClipboardWithFeedback(target, text);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {clippie} from 'clippie';
|
||||
import {createTippy} from '../tippy.ts';
|
||||
import {copyToClipboard} from '../clipboard.ts';
|
||||
import {keySymbols} from '../../utils.ts';
|
||||
import {goToDefinitionAt} from './utils.ts';
|
||||
import type {Instance} from 'tippy.js';
|
||||
@@ -95,13 +95,13 @@ function buildMenuItems(cm: CodemirrorModules, view: EditorView, togglePalette:
|
||||
'separator',
|
||||
{label: 'Cut', keys: 'Mod+X', disabled: !hasSelection, run: async (v) => {
|
||||
const {from, to} = v.state.selection.main;
|
||||
if (await clippie(v.state.doc.sliceString(from, to))) {
|
||||
if (await copyToClipboard(v.state.doc.sliceString(from, to))) {
|
||||
v.dispatch({changes: {from, to}});
|
||||
}
|
||||
}},
|
||||
{label: 'Copy', keys: 'Mod+C', disabled: !hasSelection, run: async (v) => {
|
||||
const {from, to} = v.state.selection.main;
|
||||
await clippie(v.state.doc.sliceString(from, to));
|
||||
await copyToClipboard(v.state.doc.sliceString(from, to));
|
||||
}},
|
||||
{label: 'Paste', keys: 'Mod+V', run: async (view) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {buildLanguageDescriptions, importCodemirror} from './main.ts';
|
||||
|
||||
test('matchFilename — language detection covers extended rules', async () => {
|
||||
const cm = await importCodemirror();
|
||||
const list = buildLanguageDescriptions(cm);
|
||||
const match = (filename: string) =>
|
||||
cm.language.LanguageDescription.matchFilename(list, filename)?.name;
|
||||
|
||||
// Linguist-supplied filenames + extensions
|
||||
expect(match('.bashrc')).toBe('Shell');
|
||||
expect(match('PKGBUILD')).toBe('Shell');
|
||||
expect(match('foo.zsh')).toBe('Shell');
|
||||
expect(match('Cargo.lock')).toBe('TOML');
|
||||
expect(match('Gemfile')).toBe('Ruby');
|
||||
expect(match('foo.gemspec')).toBe('Ruby');
|
||||
expect(match('foo.psgi')).toBe('Perl');
|
||||
expect(match('foo.pyi')).toBe('Python');
|
||||
expect(match('foo.webmanifest')).toBe('JSON');
|
||||
expect(match('foo.tcc')).toBe('C++');
|
||||
|
||||
// Script-side extras (extraFilenames / extraExtensions)
|
||||
expect(match('.editorconfig')).toBe('Properties files');
|
||||
expect(match('foo.conf')).toBe('Properties files');
|
||||
expect(match('Snakefile')).toBe('Python');
|
||||
|
||||
// Custom Gitea entries override language-data
|
||||
expect(match('Containerfile.test')).toBe('Dockerfile');
|
||||
expect(match('Dockerfile.dev')).toBe('Dockerfile');
|
||||
expect(match('Makefile.am')).toBe('Makefile');
|
||||
expect(match('foo.mk')).toBe('Makefile');
|
||||
expect(match('.env.local')).toBe('Dotenv');
|
||||
expect(match('foo.json5')).toBe('JSON5');
|
||||
expect(match('foo.mdown')).toBe('Markdown');
|
||||
|
||||
// Filename regex wins over extension match
|
||||
expect(match('nginx.conf')).toBe('Nginx');
|
||||
|
||||
// .spec routes to RPM Spec via excludeExt redirect
|
||||
expect(match('foo.spec')).toBe('RPM Spec');
|
||||
|
||||
// CM original ownership preserved against Linguist's broader claims (.sql is SQL,
|
||||
// not PLSQL, even though Linguist's PLSQL extension list includes it).
|
||||
expect(match('foo.sql')).toBe('SQL');
|
||||
expect(match('foo.h')).toBe('C');
|
||||
expect(match('foo.mm')).toBe('Objective-C++');
|
||||
|
||||
// Globally ambiguous extensions fall through to plain text
|
||||
expect(match('foo.cgi')).toBeUndefined();
|
||||
expect(match('foo.inc')).toBeUndefined();
|
||||
|
||||
// Smoke: existing language-data entries still resolve
|
||||
expect(match('foo.go')).toBe('Go');
|
||||
expect(match('foo.tsx')).toBe('TSX');
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import type {PaletteCommand} from './command-palette.ts';
|
||||
import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts';
|
||||
import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts';
|
||||
import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts';
|
||||
import type {LanguageDescription} from '@codemirror/language';
|
||||
import type {LanguageDescription, LanguageSupport} from '@codemirror/language';
|
||||
import type {Compartment, Extension} from '@codemirror/state';
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
|
||||
@@ -41,10 +41,12 @@ export type CodemirrorEditor = {
|
||||
};
|
||||
};
|
||||
|
||||
type LinguistLanguage = {name: string; extensions: string[]; filenames: string[]};
|
||||
|
||||
export type CodemirrorModules = Awaited<ReturnType<typeof importCodemirror>>;
|
||||
|
||||
async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap] = await Promise.all([
|
||||
export async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguist] = await Promise.all([
|
||||
import('@codemirror/autocomplete'),
|
||||
import('@codemirror/commands'),
|
||||
import('@codemirror/language'),
|
||||
@@ -56,8 +58,77 @@ async function importCodemirror() {
|
||||
import('@lezer/highlight'),
|
||||
import('@replit/codemirror-indentation-markers'),
|
||||
import('@replit/codemirror-vscode-keymap'),
|
||||
import('../../../../assets/codemirror-languages.json'),
|
||||
]);
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap};
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguistLanguages: linguist.default as LinguistLanguage[]};
|
||||
}
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const filenameUnion = (filenames: string[]) =>
|
||||
filenames.length ? new RegExp(`^(${filenames.map(escapeRegex).join('|')})$`) : undefined;
|
||||
|
||||
export function buildLanguageDescriptions(cm: CodemirrorModules): LanguageDescription[] {
|
||||
const list: LanguageDescription[] = [
|
||||
...buildBaseLanguages(cm),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd', 'mdown', 'mdwn', 'mkdn', 'mkdown'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: list}),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dockerfile', extensions: ['dockerfile', 'containerfile'],
|
||||
filename: /^(Containerfile|Dockerfile)(\..+)?$/i,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/dockerfile')).dockerFile)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Elixir', extensions: ['ex', 'exs'],
|
||||
load: async () => (await import('codemirror-lang-elixir')).elixir(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Nix', extensions: ['nix'],
|
||||
load: async () => (await import('@replit/codemirror-lang-nix')).nix(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Svelte', extensions: ['svelte'],
|
||||
load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Makefile', extensions: ['mk', 'mak', 'make'], filename: /^(GNU|BSD)?[Mm]akefile(\..+)?$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'JSON5', extensions: ['json5', 'jsonc'],
|
||||
load: async () => (await import('@codemirror/lang-json')).json(),
|
||||
}),
|
||||
];
|
||||
return list;
|
||||
}
|
||||
|
||||
// Languages that the JSON omits because they're constructed manually above.
|
||||
const customNames = new Set(['Dockerfile', 'Markdown']);
|
||||
|
||||
let baseLanguagesCache: LanguageDescription[] | null = null;
|
||||
function buildBaseLanguages(cm: CodemirrorModules): LanguageDescription[] {
|
||||
if (baseLanguagesCache) return baseLanguagesCache;
|
||||
const loadByName = new Map<string, LanguageDescription['load']>(
|
||||
cm.languageData.languages.map((l: LanguageDescription) => [l.name, l.load.bind(l)]),
|
||||
);
|
||||
const overrides = cm.linguistLanguages
|
||||
.filter((l) => loadByName.has(l.name))
|
||||
.map((l) => cm.language.LanguageDescription.of({
|
||||
name: l.name,
|
||||
extensions: l.extensions,
|
||||
filename: filenameUnion(l.filenames),
|
||||
load: loadByName.get(l.name)!,
|
||||
}));
|
||||
const overrideNames = new Set(overrides.map((o) => o.name));
|
||||
const fallback = cm.languageData.languages.filter(
|
||||
(l: LanguageDescription) => !overrideNames.has(l.name) && !customNames.has(l.name),
|
||||
);
|
||||
return baseLanguagesCache = [...overrides, ...fallback];
|
||||
}
|
||||
|
||||
function togglePreviewDisplay(previewable: boolean): void {
|
||||
@@ -85,38 +156,7 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
const previewableExts = new Set(config.previewableExtensions || []);
|
||||
const lineWrapExts = config.lineWrapExtensions || [];
|
||||
const cm = await importCodemirror();
|
||||
|
||||
const languageDescriptions: LanguageDescription[] = [
|
||||
...cm.languageData.languages.filter((l: LanguageDescription) => l.name !== 'Markdown'),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: languageDescriptions}),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Elixir', extensions: ['ex', 'exs'],
|
||||
load: async () => (await import('codemirror-lang-elixir')).elixir(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Nix', extensions: ['nix'],
|
||||
load: async () => (await import('@replit/codemirror-lang-nix')).nix(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Svelte', extensions: ['svelte'],
|
||||
load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Makefile', filename: /^(GNUm|M|m)akefile$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'JSON5', extensions: ['json5', 'jsonc'],
|
||||
load: async () => (await import('@codemirror/lang-json')).json(),
|
||||
}),
|
||||
];
|
||||
const languageDescriptions = buildLanguageDescriptions(cm);
|
||||
const matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename);
|
||||
|
||||
const container = document.createElement('div');
|
||||
@@ -163,9 +203,7 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
},
|
||||
}),
|
||||
cm.language.foldGutter({
|
||||
markerDOM(open: boolean) {
|
||||
return createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13));
|
||||
},
|
||||
markerDOM: (open: boolean) => createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13)),
|
||||
}),
|
||||
cm.view.highlightActiveLineGutter(),
|
||||
cm.view.highlightSpecialChars(),
|
||||
@@ -295,16 +333,19 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
return editor;
|
||||
}
|
||||
|
||||
// files that are JSONC despite having a .json extension
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$/;
|
||||
// files that the JSON parser is too strict for (comments, trailing commas)
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$|\.(jsonc|json5)$/i;
|
||||
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: {language: unknown} | null): Promise<Extension> {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (ext === '.json' || ext === '.map') {
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: LanguageSupport | null): Promise<Extension> {
|
||||
if (!loadedLang) return [];
|
||||
const lang = loadedLang.language;
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (lang instanceof cm.language.StreamLanguage) return [];
|
||||
if (lang.name === 'json') {
|
||||
return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)];
|
||||
}
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (!loadedLang || loadedLang.language instanceof cm.language.StreamLanguage) return [];
|
||||
// markdown's parser emits no error nodes, and nested code-fence overlays aren't traversed
|
||||
if (lang.name === 'markdown') return [];
|
||||
return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,4 @@
|
||||
import {findUrlAtPosition, trimUrlPunctuation, urlRawRegex} from './utils.ts';
|
||||
|
||||
function matchUrls(text: string): string[] {
|
||||
return Array.from(text.matchAll(urlRawRegex), (m) => trimUrlPunctuation(m[0]));
|
||||
}
|
||||
|
||||
test('matchUrls', () => {
|
||||
expect(matchUrls('visit https://example.com for info')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com.')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com, and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com; and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('(https://example.com)')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('"https://example.com"')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash.')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://x.co')).toEqual(['https://x.co']);
|
||||
expect(matchUrls('https://example.com/path_(wiki)')).toEqual(['https://example.com/path_(wiki)']);
|
||||
expect(matchUrls('https://en.wikipedia.org/wiki/Rust_(programming_language)')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('(https://en.wikipedia.org/wiki/Rust_(programming_language))')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('http://example.com')).toEqual(['http://example.com']);
|
||||
expect(matchUrls('no url here')).toEqual([]);
|
||||
expect(matchUrls('https://a.com and https://b.com')).toEqual(['https://a.com', 'https://b.com']);
|
||||
expect(matchUrls('[](https://www.npmjs.org/package/pkg)')).toEqual(['https://img.shields.io/npm/v/pkg.svg?style=flat', 'https://www.npmjs.org/package/pkg']);
|
||||
});
|
||||
|
||||
test('trimUrlPunctuation', () => {
|
||||
expect(trimUrlPunctuation('https://example.com.')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com,')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com:')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation("https://example.com'")).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com"')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com.,;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com/path')).toEqual('https://example.com/path');
|
||||
expect(trimUrlPunctuation('https://example.com/path_(wiki)')).toEqual('https://example.com/path_(wiki)');
|
||||
expect(trimUrlPunctuation('https://example.com)')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://en.wikipedia.org/wiki/Rust_(lang))')).toEqual('https://en.wikipedia.org/wiki/Rust_(lang)');
|
||||
});
|
||||
import {findUrlAtPosition} from './utils.ts';
|
||||
|
||||
test('findUrlAtPosition', () => {
|
||||
const doc = 'visit https://example.com for info';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
import type {CodemirrorModules} from './main.ts';
|
||||
import {trimUrlPunctuation, urlRawRegex} from '../../utils/url.ts';
|
||||
|
||||
/** Remove trailing whitespace from all lines in the editor. */
|
||||
export function trimTrailingWhitespaceFromView(view: EditorView): void {
|
||||
@@ -15,22 +16,9 @@ export function trimTrailingWhitespaceFromView(view: EditorView): void {
|
||||
if (changes.length) view.dispatch({changes});
|
||||
}
|
||||
|
||||
/** Matches URLs, excluding characters that are never valid unencoded in URLs per RFC 3986. */
|
||||
export const urlRawRegex = /\bhttps?:\/\/[^\s<>[\]]+/gi;
|
||||
|
||||
/** Strip trailing punctuation that is likely not part of the URL. */
|
||||
export function trimUrlPunctuation(url: string): string {
|
||||
url = url.replace(/[.,;:'"]+$/, '');
|
||||
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links)
|
||||
while (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Find the URL at the given character position in a document string, or null if none. */
|
||||
export function findUrlAtPosition(doc: string, pos: number): string | null {
|
||||
for (const match of doc.matchAll(urlRawRegex)) {
|
||||
for (const match of doc.matchAll(urlRawRegex())) {
|
||||
const url = trimUrlPunctuation(match[0]);
|
||||
if (match.index !== undefined && pos >= match.index && pos < match.index + url.length) {
|
||||
return url;
|
||||
@@ -67,7 +55,7 @@ export function goToDefinitionAt(cm: CodemirrorModules, view: EditorView, pos: n
|
||||
export function clickableUrls(cm: CodemirrorModules) {
|
||||
const urlMark = cm.view.Decoration.mark({class: 'cm-url'});
|
||||
const urlDecorator = new cm.view.MatchDecorator({
|
||||
regexp: urlRawRegex,
|
||||
regexp: urlRawRegex(),
|
||||
decorate: (add, from, _to, match) => {
|
||||
const trimmed = trimUrlPunctuation(match[0]);
|
||||
add(from, from + trimmed.length, urlMark);
|
||||
|
||||
@@ -1,20 +1,67 @@
|
||||
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
|
||||
import type {Toast} from './toast.ts';
|
||||
import {registerGlobalInitFunc} from './observer.ts';
|
||||
import {showFomanticModal} from './fomantic/modal.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {showGlobalErrorMessage} from './errors.ts';
|
||||
|
||||
type LevelMap = Record<string, (message: string) => Toast | null>;
|
||||
|
||||
export function initDevtest() {
|
||||
registerGlobalInitFunc('initDevtestPage', () => {
|
||||
const els = document.querySelectorAll('.toast-test-button');
|
||||
if (!els.length) return;
|
||||
function initDevtestPage() {
|
||||
const toastButtons = document.querySelectorAll('.toast-test-button');
|
||||
if (toastButtons.length) {
|
||||
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
|
||||
for (const el of els) {
|
||||
for (const el of toastButtons) {
|
||||
el.addEventListener('click', () => {
|
||||
const level = el.getAttribute('data-toast-level')!;
|
||||
const message = el.getAttribute('data-toast-message')!;
|
||||
levelMap[level](message);
|
||||
});
|
||||
}
|
||||
document.querySelector('.toast-test-button-pre')!.addEventListener('click', () => {
|
||||
showErrorToast(html`<div>message <pre>pre ${'a'.repeat(200)}</pre><details><summary>summary</summary>details</details></div>`, {useHtmlBody: true});
|
||||
});
|
||||
}
|
||||
|
||||
const modalButtons = document.querySelector('.modal-buttons');
|
||||
if (modalButtons) {
|
||||
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
|
||||
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
|
||||
btn.addEventListener('click', () => showFomanticModal(el));
|
||||
modalButtons.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
const sampleButtons = document.querySelectorAll('#devtest-button-samples button.ui.button');
|
||||
if (sampleButtons.length) {
|
||||
const buttonStyles = document.querySelectorAll<HTMLInputElement>('input[name*="button-style"]');
|
||||
for (const elStyle of buttonStyles) {
|
||||
elStyle.addEventListener('click', () => {
|
||||
for (const btn of sampleButtons) {
|
||||
for (const el of buttonStyles) {
|
||||
if (el.value) btn.classList.toggle(el.value, el.checked);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const buttonStates = document.querySelectorAll<HTMLInputElement>('input[name*="button-state"]');
|
||||
for (const elState of buttonStates) {
|
||||
elState.addEventListener('click', () => {
|
||||
for (const btn of sampleButtons) {
|
||||
(btn as any)[elState.value] = elState.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initDevtest() {
|
||||
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
|
||||
registerGlobalInitFunc('initDevtestDetailsErrorMessage', () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
showGlobalErrorMessage('showGlobalErrorMessage single message', 'warning');
|
||||
showGlobalErrorMessage('showGlobalErrorMessage message with details', 'error', `detail message 1\nvery lo${'o'.repeat(200)}ng line 2\nline 3`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user